From a3d40ae844290cf8a000cb005cb031699af6443c Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:10:58 -0600 Subject: [PATCH 01/56] docs(player): define native mpv playback architecture --- docs/specs/native-mpv-playback.md | 953 ++++++++++++++++++++++++++++++ 1 file changed, 953 insertions(+) create mode 100644 docs/specs/native-mpv-playback.md diff --git a/docs/specs/native-mpv-playback.md b/docs/specs/native-mpv-playback.md new file mode 100644 index 00000000..7d702185 --- /dev/null +++ b/docs/specs/native-mpv-playback.md @@ -0,0 +1,953 @@ +# Native mpv Playback Integration + +- **Status:** Accepted; implementation in progress +- **Scope:** `ferrex-player` desktop playback +- **Last updated:** 2026-07-12 +- **Tracking plan:** [Native mpv Playback Migration Plan](../plans/native-mpv-playback-migration.md) + +## 1. Purpose + +This specification defines the target architecture for making mpv the primary +playback engine in Ferrex while keeping the Iced user experience native and +integrated. It is the design reference for implementation, review, testing, and +the staged migration away from GStreamer as the default player backend. + +The central decision is to use libmpv as the playback control plane while mpv +retains ownership of decoding and native video presentation. Video frames do +not normally pass through Iced or wgpu. Iced owns application layout, controls, +input, and non-video UI. + +This document is normative for the target design. The accompanying plan tracks +incremental delivery and may change sequencing without changing the +architecture defined here. + +## 2. Decision Summary + +1. **mpv is the target primary desktop playback backend.** GStreamer remains a + supported migration and failure fallback until the mpv path satisfies the + acceptance gates in this specification. +2. **Use libmpv without `mpv_render_context` for the primary path.** mpv uses its + normal native VO, preferably `gpu-next`, and owns frame timing, hardware + decoding, subtitles, color conversion, swapchain negotiation, and HDR + signaling. +3. **Treat presentation as a platform capability.** A platform presenter joins + mpv's native surface/window with the Iced player UI without copying decoded + frames into wgpu. +4. **Keep the control API open-ended.** Typed Ferrex operations are conveniences + over mpv commands and properties, not a replacement for them. +5. **Do not add mpv-specific APIs to Iced.** The first implementation uses + current generic Iced raw-window access and custom-widget lifecycle. Any + upstream Iced proposal must be independently useful and discussed upstream + before code is submitted. +6. **Wayland currently uses a HYBRID backend policy.** W0 found no safe way for + stable in-process libmpv to direct only its delayed VO/driver connections to + a private bridge without a process-global race. GStreamer therefore remains + the integrated Wayland backend and mpv uses ordinary native-window + presentation until a maintainable path exists. +7. **Windows and macOS remain full integration targets.** The Wayland decision + does not reduce their requirement for an embedded Ferrex player experience + using mpv's native VO and platform presenter without routing decoded frames + through wgpu. +8. **A normal mpv window is always an acceptable fallback.** Integration + failure must not force a CPU-copy or SDR path when native mpv presentation + remains available. + +## 3. Context + +### 3.1 Current playback paths + +At the migration baseline, the extracted `ferrex-player-playback` crate stored +`SubwaveVideo` directly in `PlayerDomainState::video_opt` and called backend +methods from playback update and view code. Subwave supplies two materially +different paths: + +- a Wayland GStreamer sink rendered through custom subsurfaces, including the + current zero-copy/HDR effort; and +- an appsink path that uploads decoded image data into a custom Iced/wgpu + primitive and is effectively the cross-platform fallback. + +Ferrex also has +`crates/ferrex-player-playback/src/external_mpv.rs`, which starts an external +mpv process and uses JSON IPC for progress and limited lifecycle control. It is +a handoff rather than an integrated player. + +The current arrangement has important strengths: the Wayland sink can accept a +host-provided display and surface, and that path already demonstrates HDR +playback in Ferrex's primary environment. It also has structural costs: + +- behavior differs substantially between Wayland and other platforms; +- the Wayland path depends on a development GStreamer series and a narrow known + working version; +- appsink cannot expose the full native HDR and presentation behavior; +- Ferrex owns subtitle, color, sink, and pipeline behavior that mpv already + implements across platforms; and +- the Iced fork contains platform-specific surface hooks that are difficult to + propose upstream. + +### 3.2 Why not render libmpv into wgpu now + +The stable libmpv render API primarily exposes OpenGL and software rendering. +A seamless OpenGL implementation is possible when a GUI toolkit owns and +exposes the OpenGL context, as demonstrated by Switchfin. That does not map +cleanly to Iced's portable wgpu renderer: + +- wgpu intentionally hides the native graphics context and swapchain; +- `wgpu-hal` interop is unsafe, backend-specific, and not a stable application + contract; +- Windows would require D3D/Vulkan/DX interop or a private mpv render backend; +- macOS OpenGL is deprecated and is not the desired HDR path; +- software rendering introduces a full CPU path; and +- the host becomes responsible for HDR target selection, metadata, frame + timing, and synchronization. + +Relevant upstream mpv efforts are still open as of the date above, including +issues `#6575` and `#11031` and pull requests `#16818` and `#17828`. The target +architecture therefore allows a future render-API presenter, but does not make +unmerged work a production dependency. + +## 4. Goals + +The implementation MUST: + +- preserve mpv's broad demuxer, decoder, subtitle, audio, filter, script, and + protocol compatibility; +- permit mpv's normal hardware-decoding and native presentation paths; +- preserve native HDR and color-management behavior where mpv and the platform + support it; +- present Iced controls as part of one coherent player experience; +- support Wayland, X11, Windows, and macOS with explicit capability reporting; +- expose arbitrary mpv commands, options, properties, observations, events, + and node values in addition to typed Ferrex conveniences; +- avoid per-frame CPU readback or upload in the primary path; +- let mpv render independently of Iced's redraw cadence; +- fail deterministically to a documented fallback; +- keep watch progress, episode navigation, stream selection, and Ferrex server + behavior independent of the selected playback backend; +- keep unsafe native-window and Wayland protocol code outside Iced; and +- support incremental rollout without changing the current default until its + replacement passes platform gates. + +The implementation SHOULD: + +- bundle a known compatible libmpv build in release artifacts; +- allow an opt-in user mpv configuration while retaining deterministic Ferrex + defaults; +- make presentation mode and capabilities visible in diagnostics; +- support a normal mpv-native window mode for compatibility and debugging; and +- isolate presenter failures from the player domain state machine. + +## 5. Non-goals + +The initial migration does not attempt to: + +- make native video behave like an arbitrary Iced texture under transforms, + rounded clipping, scrolling, or nested opacity; +- guarantee zero-copy for every codec, format, driver, or hardware decoder; +- implement a new stable Vulkan, D3D, Metal, or libplacebo render API for mpv; +- maintain a permanent private mpv graphics backend; +- make Iced itself understand mpv, GStreamer, HDR metadata, or Wayland + subsurfaces; +- remove GStreamer before the Wayland and release-packaging gates pass; +- change server-side transcoding or media analysis solely because the desktop + playback backend changes; +- cover Android, iOS, or console playback in the first implementation; or +- promise that every mpv script that assumes direct ownership of native input + will work unchanged in integrated-Iced mode. + +## 6. Terminology + +- **Control plane:** libmpv commands, options, properties, observations, and + events. +- **Native VO:** mpv's normal video output path, such as `gpu-next`, creating or + using a platform-native presentation surface. +- **Presenter:** UI-thread platform code that joins the mpv native output with + an Iced-owned player surface/window and synchronizes geometry and lifecycle. +- **Integrated mode:** Iced controls and input are visually integrated with the + native mpv video surface. +- **Native-window mode:** mpv owns an ordinary top-level player window and may + use its native input/OSC behavior. +- **Surface slot:** an axis-aligned logical rectangle reserved by Iced for + native video. It is not an Iced texture. +- **Host:** the Iced window or native overlay window participating in the + presenter relationship. + +## 7. Architectural Invariants + +The following invariants apply across all platforms: + +1. A decoded video frame MUST NOT cross into Iced/wgpu in the primary native-VO + path. +2. `mpv_render_context` MUST NOT be created for the native-VO session. +3. The libmpv wakeup callback MUST only signal Ferrex. It MUST NOT call back + into libmpv. +4. Normal libmpv calls and render/presenter operations MUST not form cyclic + lock or wait dependencies. +5. Native presenter objects MUST be created, mutated, and destroyed on the + platform-appropriate UI/event-loop thread unless the platform API explicitly + permits otherwise. +6. Player domain logic MUST consume Ferrex-owned commands, events, snapshots, + and track models instead of branching on `SubwaveVideo` versus mpv. +7. Presentation failure MUST be reported as a capability/error transition; it + MUST NOT be inferred from a missing frame timer. +8. Geometry synchronization MUST happen at most once per host redraw/layout + revision, not once per decoded frame. +9. The native presenter MUST be detached before its host window is destroyed. +10. Backend selection and every fallback transition MUST be logged with a + machine-readable reason. + +## 8. Target Component Model + +```text ++--------------------------------------------------------------+ +| ferrex-player | +| | +| Player domain | +| +----------------------+ +--------------------------+ | +| | PlaybackSnapshot |<------| PlaybackEvent reducer | | +| | Ferrex track models | +--------------------------+ | +| +----------+-----------+ ^ | +| | | | +| v | | +| Iced player controls ---- PlaybackCommand --+ | +| | | +| v | +| NativeVideoSlot / dedicated player overlay | ++-------------+-------------------------------+----------------+ + | geometry/lifecycle | control/events + v v ++-----------------------------+ +-----------------------------+ +| Platform presenter | | MpvSession | +| UI-thread native resources | | libmpv handle + event pump | ++-------------+---------------+ +---------------+-------------+ + | native surface/window relationship | + +--------------------+-----------------+ + v + mpv native VO / gpu-next +``` + +### 8.1 Playback domain contract + +Ferrex MUST own a backend-neutral contract. Its initial implementation lives +under `ferrex-player-playback::contract`; the exact Rust layout may evolve, but +it should have the following shape: + +```rust +pub enum PlaybackCommand { + Load(PlaybackSource), + SetPaused(bool), + SeekAbsolute(Duration), + SeekRelative(DurationDelta), + SetVolume(f64), + SetMuted(bool), + SetSpeed(f64), + SelectAudio(TrackId), + SelectSubtitle(Option), + SelectChapter(ChapterId), + SelectEdition(EditionId), + SetContentFit(ContentFit), + SetFullscreen(bool), + Stop, +} + +pub enum PlaybackEvent { + StateChanged(PlaybackState), + PositionChanged(Duration), + DurationChanged(Option), + BufferChanged(BufferState), + TracksChanged(TrackCatalog), + ChaptersChanged(Vec), + ChapterChanged(Option), + EditionsChanged(Vec), + EditionChanged(Option), + VideoParametersChanged(VideoParameters), + Ended(EndReason), + Error(PlaybackError), + Presenter(PresenterEvent), +} + +pub struct PlaybackSnapshot { + pub state: PlaybackState, + pub position: Duration, + pub duration: Option, + pub tracks: TrackCatalog, + pub chapters: Vec, + pub current_chapter: Option, + pub editions: Vec, + pub current_edition: Option, + pub video: Option, + pub capabilities: PlaybackCapabilities, +} +``` + +Commands are asynchronous messages to the backend owner. Events update one +snapshot in the Iced application state. The view reads the snapshot and never +polls libmpv or GStreamer directly. + +The GStreamer/Subwave adapter and mpv adapter MUST implement the same behavioral +contract during migration. Backend-specific diagnostics may be attached to a +structured diagnostic payload, but must not leak into normal player messages. + +### 8.2 Mpv control API + +`MpvSession` owns one libmpv core and provides: + +- pre-initialization option setting; +- async command submission with request identifiers; +- typed and node-valued property get/set; +- property observation with stable observation identifiers; +- event draining; +- log forwarding with secret redaction; +- API/version and compiled-capability reporting; and +- an explicitly unsafe/raw escape hatch where the safe wrapper cannot represent + a supported libmpv operation. + +Typed Ferrex behavior maps to standard properties and commands, including at +least: + +- `pause`, `time-pos`, `duration`, `speed`, `volume`, and `mute`; +- `track-list`, `aid`, `sid`, `chapter-list`, `chapter`, `edition-list`, and + `edition`; +- `demuxer-cache-state`, `core-idle`, `eof-reached`, and `seeking`; +- `video-params`, `video-out-params`, `hwdec-current`, and `vo-configured`; +- `loadfile`, `seek`, `stop`, and playlist commands; and +- `fullscreen` where mpv owns the top-level window. + +Content fit uses one deterministic native-VO property set so it does not depend +on renderer geometry or decoded-frame uploads: + +| Ferrex fit | `keepaspect` | `video-unscaled` | `panscan` | +|---|---:|---|---:| +| Contain | `yes` | `no` | `0.0` | +| Cover | `yes` | `no` | `1.0` | +| Fill | `no` | `no` | `0.0` | +| None/native size | `yes` | `yes` | `0.0` | +| Scale down only | `yes` | `downscale-big` | `0.0` | + +These properties are submitted asynchronously through the same serialized +owner as other controls. Ferrex updates the requested fit in its snapshot only +after the serialized owner accepts all three submissions. + +The wrapper MUST preserve arbitrary command/property access so future mpv +features do not require a new Ferrex release merely to become reachable. + +### 8.3 Presentation contract + +The native presenter is separate from `MpvSession` because its resources and +threading rules differ. Conceptually it provides: + +```rust +pub struct SurfaceGeometry { + pub logical_bounds: Rectangle, + pub visible_bounds: Option, + pub scale_factor: f64, +} + +pub trait NativePresenter { + fn attach(&mut self, host: HostWindow<'_>) -> Result<(), PresenterError>; + fn synchronize(&mut self, geometry: SurfaceGeometry) + -> Result<(), PresenterError>; + fn set_visible(&mut self, visible: bool) -> Result<(), PresenterError>; + fn set_fullscreen(&mut self, fullscreen: bool) + -> Result<(), PresenterError>; + fn detach(&mut self); + fn capabilities(&self) -> PresenterCapabilities; +} +``` + +This trait is illustrative: the implementation may need a command handle around +a UI-thread-owned state machine instead of a Rust trait object. It MUST NOT +impose `Send` on platform resources that are correctly event-loop-local. + +Capabilities include at least: + +- integrated overlay support; +- embedded surface support; +- native HDR signaling support; +- fractional scaling support; +- native-window fallback availability; +- whether mpv or Iced owns fullscreen; and +- any known compositor requirement. + +## 9. Session and Presenter Lifecycle + +The implementation MUST model lifecycle explicitly. A suggested state model is: + +```text +Uninitialized + -> Initializing + -> Idle + -> Loading + -> AwaitingHost / AwaitingVoSurface + -> Presenting + -> Hidden or Suspended + -> Stopping + -> Idle + -> Terminating + -> Terminated +``` + +Host and VO readiness can arrive in either order outside Wayland. Every attach +attempt carries a monotonically increasing generation so late native or libmpv +events from an old load cannot attach to a new player. + +Required lifecycle behavior: + +1. Entering the player creates or acquires a playback session. +2. Integrated mode waits for both host and VO readiness. +3. The presenter attaches once per generation. +4. Zero-sized or fully clipped geometry hides/unmaps presentation without + destroying the playback core. +5. Window occlusion/suspension is forwarded as a power/performance hint. +6. Leaving the player detaches native presentation before closing its Iced + window. +7. `stop` is issued and relevant final position is captured before the core is + destroyed. +8. Presenter resources are destroyed before libmpv terminates when they refer + to mpv-owned native objects. +9. On macOS, termination must keep the AppKit main loop serviceable while mpv's + VO tears down. + +The implementation MUST tolerate repeated load, stop, backend switch, and +window recreation cycles without retaining native surfaces or callbacks. + +## 10. Threading and Event Delivery + +### 10.1 Control plane + +A dedicated owner serializes normal libmpv access. It may be a worker thread or +a strictly serialized executor, subject to platform constraints discovered in +the initial spike. + +- `mpv_set_wakeup_callback` only wakes the owner/Iced subscription. +- The owner drains `mpv_wait_event(handle, 0)` until `MPV_EVENT_NONE`. +- Blocking property calls are not performed in Iced `view` or native callbacks. +- Prefer async commands and async property updates for user operations. +- Event payloads are copied into Ferrex-owned data before the next libmpv event + call invalidates pointers. +- Log callbacks redact stream credentials and authorization headers. + +### 10.2 Presenter plane + +Presenter changes execute on the native UI/event-loop thread. Cross-thread mpv +signals are converted into presenter commands and wake Iced; they do not mutate +window-system objects directly. + +The native VO renders at its own cadence. Iced redraws only for UI animation, +input, snapshot changes, or geometry changes. Playback MUST NOT create a +continuous Iced redraw loop merely to poll position. Periodic progress +persistence remains timer-driven at a substantially lower rate. + +## 11. Iced Integration + +### 11.1 Surface widget + +An external `NativeVideoSlot` custom widget reserves layout and owns presenter +attachment state in `iced::advanced::widget::Tree::State`. + +The pinned Iced revision does not expose a host on `Shell`. Before the first +attachment, the widget therefore emits one host-capture request. The +application services it with `window::run`, copies the raw window/display +handles into an event-loop thread-local lease, and returns only a pointer-free +result through the Iced task channel. The lease is detached/released before +window destruction and never imposes `Send` on the presenter or native host. + +On `Window::RedrawRequested`, the widget: + +1. verifies that the generation's event-loop-local host lease is ready; +2. reads its current layout bounds and inherited viewport; +3. computes visibility and the scale-aware geometry revision; +4. attaches the presenter if needed; +5. synchronizes geometry only if it changed; and +6. requests a follow-up redraw only when the presenter reports pending host + work. + +Its normal `draw` implementation does not draw video. It may draw a fallback +poster, loading state, or black rectangle before native presentation attaches. + +When the widget leaves the tree, its state detaches the presenter. Window-close +handling MUST also perform explicit teardown so correctness does not depend +only on drop order. + +### 11.2 Transparency and layers + +In an integrated player, the Iced surface over the video region must preserve +alpha. Normal controls are rendered as SDR UI over a separately managed video +surface. This is intentional: + +- mpv owns video color conversion and HDR description; +- the compositor/window system can blend SDR UI and HDR video as separate + surfaces; and +- Iced does not need to select a 10-bit surface merely because video is HDR. + +The implementation MUST verify that the Iced window does not advertise an +opaque region over transparent video pixels. + +### 11.3 Iced upstream policy + +The first implementation requires no new Iced API on the pinned fork: + +- `Window` exposes raw window and display handle traits; +- `window::run` supports event-loop-local native-window setup callbacks; +- custom-widget `Tree::State` and redraw events provide slot lifecycle and + geometry; and +- custom wgpu primitives remain available for a future render presenter. + +Ferrex MUST NOT upstream its current platform-specific Wayland hook. If +prototype experience demonstrates a generic missing facility, the smallest +candidate is pass-through support for a foreign parent window in +`window::Settings`, corresponding to functionality already modeled by winit. +The proposal must use non-media examples such as webviews, terminals, and +camera surfaces, and must be discussed with Iced maintainers before a PR. + +A one-shot generic pre-present action may be considered only if the Wayland +prototype proves synchronous staging during redraw is insufficient. Persistent +callback registries are out of scope. + +## 12. Platform Presentation + +### 12.1 Wayland + +#### Current HYBRID decision + +D-022 records HYBRID as the current Wayland release architecture. Integrated +playback uses the proven GStreamer/Subwave surface path; an mpv selection uses +mpv's ordinary native window. The bridge design below is retained as normative +re-entry criteria, not an active release commitment. Reopening it requires a +new decision backed by a safe per-session connection bootstrap or an explicit +amendment for another maintainable topology. + +This Wayland-only decision does not alter the Windows or macOS integrated mpv +presenter requirements in sections 12.2 and 12.4. + +#### Target ownership for bridge re-entry + +Iced owns the real `xdg_toplevel`, decorations, input, application identity, +and fullscreen state. mpv owns a native Wayland video surface that becomes a +desynchronized subsurface below the transparent Iced surface. + +This direction preserves Iced's existing input and application window while +allowing mpv to retain its normal Wayland VO, Vulkan/EGL WSI, dmabuf, +hardware-decoding, and color-management behavior. + +#### Required bridge behavior + +mpv does not support `wid` on Wayland. The presenter therefore uses a private +in-process protocol bridge inspired by Jellyfin Desktop's `wl-proxy` work. The +bridge MUST: + +1. expose a private Wayland socket used only by mpv's VO; +2. forward ordinary requests, events, and file descriptors; +3. map mpv-created upstream objects onto the same upstream Wayland connection + as the Iced parent; +4. capture the first relevant mpv `wl_surface`; +5. suppress forwarding of its `xdg_surface` and `xdg_toplevel` role requests; +6. assign the upstream surface a `wl_subsurface` role under Iced's parent; +7. use desynchronized child commits for independent video cadence; +8. synthesize `xdg_surface.configure` and `xdg_toplevel.configure` events from + the surface slot's logical size and state; +9. translate mpv fullscreen, minimize, close, move, and resize requests into + host actions where meaningful; +10. keep native pointer, keyboard, and touch ownership with Iced in integrated + mode; +11. forward output, fractional-scale, viewporter, presentation-time, tearing, + content-type, idle-inhibit, dmabuf, explicit-sync, and color-management + behavior needed by the selected VO; and +12. destroy the subsurface role before the Iced parent is destroyed. + +The bridge MUST NOT create its upstream child on an unrelated Wayland +connection. Wayland forbids constructing a subsurface relationship across +clients. `xdg-foreign` supplies relationship metadata, not reparenting, and is +not a substitute. + +The bridge MUST NOT create a second viewport or color-management role on mpv's +surface when mpv already owns one. It virtualizes shell ownership while leaving +video-specific surface extensions with mpv. + +#### Configure and geometry + +The slot sends logical size, clipping visibility, scale, and host state to the +bridge. Subsurface position changes are staged before the next Iced parent +commit. mpv receives a configure matching the logical video extent and remains +responsible for buffer scale, viewport destination, and render size. + +A geometry command must have a defined acknowledgment/order boundary before the +host presents. If current Iced redraw ordering is sufficient, no framework +change is made. If not, the generic Iced discussion described in section 11.3 +is required before adding hooks. + +#### Connection bootstrap risk + +Stable libmpv does not accept an application-provided Wayland display for its +normal VO. W0 also observed delayed libmpv/libplacebo/driver connection +activity, so a temporary `WAYLAND_DISPLAY`/`WAYLAND_SOCKET` override cannot +safely direct only mpv to the bridge. A process-lifetime startup proxy avoids +the race only by proxying Iced too, which violates the current private mpv-only +boundary and greatly expands ownership risk. Symbol interposition and scoped +environment overrides are rejected. This blocker is the basis for D-022 and +must be resolved by a new architecture decision before bridge work resumes. + +#### Wayland backend and fallback + +Under D-022: + +1. Auto/integrated playback uses the proven GStreamer Wayland path; +2. an explicit mpv selection uses ordinary mpv native-window mode; and +3. failure reaches the other policy-approved backend or an explicit playback + error. + +Integrated mpv is reported as unavailable with the connection-bootstrap reason. +Ferrex MUST NOT silently choose appsink/software presentation for HDR content. + +### 12.2 Windows + +The preferred full-player arrangement is: + +- mpv owns its normal top-level HWND and gpu-next/D3D presentation; +- a transparent undecorated Iced playback window is attached as an owned or + child overlay above the mpv content area; +- only one taskbar entry and one apparent player window are exposed; and +- Iced owns integrated input while fullscreen/window state is delegated through + the presenter to the mpv root. + +The presenter synchronizes content rectangle, DPI, visibility, z-order, focus, +minimize, and teardown. The overlay is created hidden, attached using raw +window handles, then shown to prevent startup flicker. + +For an inline surface with no overlapping Iced controls, a host child HWND may +be passed as mpv's `wid`; mpv creates its own child and fills the host. This is +an alternate capability, not the required full-player arrangement. + +Native-window mode leaves mpv's HWND independent and may enable mpv's OSC. + +### 12.3 X11 + +mpv 0.41 compiles its X11 VO and `wid` support only when Meson's `gpl` option +is enabled. D-005 requires Ferrex release artifacts to link the reviewed +LGPL-only libmpv profile, so those paths are not present in the bundled +library. D-023 therefore makes X11 a licensing-gated hybrid: integrated +playback remains on GStreamer, and the separately launched external mpv action +may remain available as an explicit process boundary. In-process mpv MUST be +reported as unavailable rather than failing into a headless or CPU-copy VO. + +If a future mpv release provides X11 native VO under a compatible profile, or +Ferrex adopts a different reviewed distribution policy, the preferred re-entry +arrangement mirrors Windows: + +- mpv owns its normal X11 window; +- an ARGB Iced overlay is attached/stacked above it; +- geometry, focus, and visibility follow the mpv root; and +- Iced owns integrated input. + +A compositing manager is required for that overlay. Non-composited X11 would +use a proven inline `wid` host with non-overlapping controls or a normal +native-window fallback, but neither path is claimed for the current LGPL-only +bundle. + +### 12.4 macOS + +The modern mpv Cocoa/Swift path should own its native NSWindow and video layer. +The presenter obtains the native mpv window when available and attaches a +transparent Iced child window or native overlay view above the content area. +It synchronizes backing scale, content bounds, focus, Spaces/fullscreen +transitions, occlusion, and teardown on the AppKit main thread. + +Ferrex MUST NOT make macOS depend on `wid`. Although generic libmpv header text +still mentions macOS, current mpv source does not consume `WinID` in the modern +macOS window backend. Native-root composition and ordinary native-window mode +are the supported strategies. + +Child-window behavior across native fullscreen and Spaces must be proven in the +platform spike. If a transparent Iced child window cannot be made reliable, +normal mpv native-window mode remains the release fallback; an OpenGL render +path is not promoted merely to emulate embedding. + +## 13. Input, Focus, and Window Ownership + +Integrated mode routes keyboard, pointer, touch, and controller gestures +through Iced. Player actions produce `PlaybackCommand`s or raw mpv input +commands. Ferrex remains responsible for its current shortcuts and controls. + +The integration SHOULD expose a mapping layer for mpv key names so scripts and +bindings can be invoked intentionally. It does not need to forward every host +input event by default. + +Window ownership differs by presenter: + +- Wayland: Iced owns top-level state and sends synthetic state/configures to + mpv. +- Windows and macOS native-root mode: mpv owns top-level state and the Iced + overlay follows it. +- X11 remains GStreamer-integrated under D-023; the native-root rule is a + re-entry requirement for a future compatible libmpv profile. + +The application-level fullscreen command goes through the presenter and is +updated from the resulting native state. `PlayerDomainState` MUST not toggle an +optimistic fullscreen boolean without confirmation. + +When the overlay is hidden, input policy must be explicit. Ferrex may keep a +transparent input target to reveal controls, or temporarily return input to +mpv and use mpv input bindings to reveal the overlay. The selected policy must +be tested for focus and power impact on each platform. + +## 14. Color, HDR, Subtitles, and Frame Pacing + +mpv owns: + +- source color interpretation; +- hardware-decoder image import; +- scaling, tone mapping, dithering, and user shaders; +- native swapchain/surface format selection; +- native color-space and HDR metadata signaling; +- ASS, text, bitmap/PGS, and external subtitle rendering; and +- display synchronization and frame scheduling. + +Iced owns SDR UI and lets the compositor/window system combine surfaces. +Ferrex MUST not infer HDR solely from filenames or force an HDR output profile +without mpv/native output evidence. + +Capabilities and diagnostics should surface at least: + +- `video-params` and `video-out-params`; +- `hwdec-current`; +- selected VO and graphics context; +- detected output color characteristics where mpv exposes them; and +- presenter/compositor color-management support. + +"Zero-copy" is reported only as an observed diagnostic with evidence; it is not +a universal capability promise. Some decode formats and driver paths may +legitimately copy while still using native presentation. + +## 15. Backend Selection and Fallback + +Ferrex exposes conceptual backend choices: + +- **Auto:** release-policy default with capability-based fallback. +- **mpv integrated:** require the native presenter; report a clear error or + policy-approved fallback if unavailable. +- **mpv native window:** use ordinary mpv presentation and native OSC/input as + configured. +- **GStreamer:** use the existing Subwave adapter during migration. +- **External mpv:** optional process-isolated fallback while it remains + maintained. + +Under D-022, Auto continues selecting integrated GStreamer on Wayland while +Windows and macOS proceed through independent gates toward integrated mpv. An +explicit Wayland mpv selection uses native-window presentation. Moving Wayland +Auto to integrated mpv requires a new GO decision and all deferred Wayland exit +criteria in the tracking plan. + +Fallback selection MUST consider content requirements. For example, an +integrated presenter failure during HDR playback should prefer mpv +native-window mode over an SDR appsink path. The user-facing diagnostics must +state the selected backend and reason. + +## 16. mpv Configuration and Compatibility Policy + +Ferrex supplies a deterministic built-in mpv profile and permits supported +user overrides. + +Default policy: + +- prefer `vo=gpu-next` where included, while retaining a tested fallback list; +- begin with a conservative hardware-decoding policy and expose user override; +- do not load arbitrary user config or scripts unless the user enables it; +- do not disable mpv capabilities merely because Ferrex lacks a typed UI for + them; +- pass authentication as headers/cookies where possible instead of embedding + secrets in URLs; and +- redact URLs, headers, cookies, and tokens in logs and error reports. + +Release builds SHOULD bundle libmpv, FFmpeg, libplacebo, and required runtime +assets at known versions. Runtime diagnostics include mpv, client API, FFmpeg, +and libplacebo versions. + +The minimum supported libmpv version and exact linking strategy are finalized +by the dependency/packaging spike. The wrapper must fail gracefully with a +clear capability result when a system libmpv is missing or incompatible in a +development configuration. + +## 17. Packaging and Platform Integration + +The migration includes, not postpones, release packaging: + +- Nix development and NixOS package inputs; +- Linux dynamic-library lookup and RPATH policy; +- Flatpak modules, permissions, GPU, audio, and Wayland socket behavior; +- Windows DLL discovery and bundling; +- macOS dylib/framework bundling, signing, and AppKit main-thread requirements; +- license inventory for the exact mpv/FFmpeg build options; and +- CI build coverage for every supported target. + +A development machine finding a system libmpv is insufficient evidence of a +shippable backend. + +## 18. Security + +libmpv and optional user scripts operate inside the Ferrex process. Therefore: + +- user config and scripts are opt-in and clearly described as trusted code; +- untrusted remote media does not control arbitrary mpv command execution; +- stream credentials are not placed in process arguments when avoidable; +- command and property names originating outside trusted Ferrex code are + validated against their intended use; +- URL/header logging uses the existing application redaction policy or adds one + before mpv rollout; and +- external tools such as `yt-dlp` are disabled by default unless explicitly + packaged and enabled. + +## 19. Observability + +A diagnostic snapshot MUST include: + +- requested and selected backend/presentation mode; +- every fallback decision and reason; +- libmpv and native presenter lifecycle state; +- VO, GPU API/context, adapter, and hardware decoder when exposed; +- current video and output color parameters; +- current surface logical/physical size and scale; +- dropped/delayed frame statistics exposed by mpv; +- Wayland bridge protocol/capability summary without sensitive object data; and +- the last structured playback/presenter error. + +Normal logs should remain concise. Protocol tracing and verbose mpv logs are +opt-in diagnostics. + +## 20. Verification and Acceptance Criteria + +### 20.1 Automated coverage + +The implementation requires tests for: + +- command serialization and async reply correlation; +- property node conversion and event payload ownership; +- snapshot reduction under reordered or repeated property events; +- track identity and selection across reloads; +- lifecycle generation rejection of stale events; +- fallback policy decisions; +- final progress persistence on stop, EOF, error, and presenter failure; +- widget geometry, clipping, visibility, and drop behavior with a fake + presenter; +- platform capability parsing where testable without a display; and +- repeated session creation/termination without leaked callbacks. + +### 20.2 Platform matrix + +Manual/integration coverage includes: + +- Wayland: Hyprland/wlroots, KDE, and GNOME where available; +- Linux GPUs: Intel, AMD, and NVIDIA proprietary where available; +- X11 with and without a compositing manager; +- supported Windows versions with SDR and HDR displays; +- macOS Intel and Apple Silicon where supported; and +- fractional scaling and moving between displays with different scales. + +### 20.3 Media matrix + +At minimum: + +- H.264, HEVC, VP9, and AV1; +- 8-bit SDR, 10-bit SDR, HDR10/PQ, and HLG where test hardware permits; +- representative software- and hardware-decoding paths; +- ASS with fonts/animation, SRT/WebVTT, PGS/DVD bitmap subtitles, and external + subtitles; +- multiple audio/subtitle tracks, chapters, editions, and attachments; +- local files and authenticated HTTP range playback; and +- direct play plus Ferrex server transcoding output. + +### 20.4 Native presentation gates + +Before mpv becomes the default on a platform: + +- play/pause/seek/track controls and progress reporting are feature-complete; +- resize, DPI change, minimize, hide/show, fullscreen, suspend/resume, and close + are stable; +- no decoded frame enters the CPU/wgpu path in native-VO mode; +- expected hardware decoding is demonstrated through mpv diagnostics; +- HDR output and metadata are manually validated on supported hardware; +- Iced controls render and receive input without corrupting video color; +- 100 repeated load/stop/window cycles complete without native resource growth + or crashes; +- presenter failure reaches a documented fallback; and +- release packaging installs and starts without developer-only paths. + +### 20.5 Wayland-specific re-entry gate + +D-022 defers this gate while HYBRID is active. Any future integrated Wayland +mpv proposal must satisfy, in addition to the above: + +- the bridge proves parent and child are on the same upstream connection; +- no real `xdg_toplevel` role is assigned to mpv's video surface; +- configure/ack behavior remains valid across resize and fullscreen; +- fractional-scale, viewporter, dmabuf, explicit synchronization, presentation, + and color-management traffic required by mpv is preserved; +- Iced retains input and no duplicate seat consumes events; +- teardown never outlives or destroys the Iced display; and +- failure is clean on compositors missing optional protocols. + +## 21. Migration and Removal Policy + +Migration is adapter-first: + +1. Introduce Ferrex-owned playback commands, events, snapshots, and track + models. +2. Adapt the current Subwave/GStreamer path without changing behavior. +3. Add libmpv control and native-window mode. +4. Add Windows and macOS integrated presenters; retain Wayland HYBRID unless a + new bridge decision passes its re-entry gate. +5. Run both backends behind an opt-in selector and collect diagnostics. +6. Change per-platform Auto defaults only after that platform passes its gate. +7. Remove code only after at least one release retains a tested rollback. + +GStreamer may remain after mpv becomes default when it provides a documented +capability not yet replaced. It is removed from desktop playback only when: + +- no supported platform selects it in Auto; +- release and CI packaging no longer require it for playback; +- rollback data shows the mpv path is stable; and +- server/media-pipeline uses are confirmed independent. + +The current external mpv path is removed or demoted only after in-process +native-window mode provides equivalent fallback and progress behavior. + +## 22. Open Decisions and Required Spikes + +The following are implementation decisions, not reasons to weaken the target +architecture: + +1. Which Rust FFI foundation best exposes full libmpv while allowing a safe + Ferrex wrapper? +2. What minimum bundled mpv version and build options are required on each + platform? +3. Should development builds support runtime dynamic loading in addition to + release bundling? +4. What future libmpv/upstream or otherwise maintainable mechanism can direct + only mpv's Wayland connections to a private bridge without a process-global + race? This is deferred under D-022. +5. If D-022 is reopened, can the bridge safely multiplex onto winit's existing + display connection with independent event queues across all target + compositors? +6. Is staging Wayland subsurface state during redraw sufficient, or is a + generic one-shot pre-present facility demonstrably necessary? +7. Which transparent overlay relationship is most reliable for Windows and + X11 while preserving one taskbar/window identity? +8. Does an Iced child NSWindow survive macOS native fullscreen and Spaces + transitions reliably, or is a lower-level NSView target required? +9. What input policy best permits controls to appear when the transparent + overlay is otherwise hidden? +10. Which mpv configuration and scripts are enabled by default without making + behavior depend on a user's standalone mpv installation? + +Each spike must produce a short decision record in the tracking plan before the +related production phase begins. + +## 23. References + +- mpv client API: +- mpv render API: +- mpv Vulkan render request: +- mpv Vulkan/dmabuf request: +- mpv gpu-next render draft: +- mpv libplacebo render RFC: +- Jellyfin Desktop native-VO precedent: +- Switchfin direct-render precedent: +- Iced contribution guidance: From 23e903182ab9a9e8362bea5048b107d1f50a1d9a Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:11:06 -0600 Subject: [PATCH 02/56] test(player): add native playback baseline and fixtures --- .../developer/native-playback-baseline.md | 147 ++ scripts/qa/native_playback_fixture_server.py | 324 +++++ scripts/qa/native_playback_fixtures.py | 1202 +++++++++++++++++ scripts/qa/test_native_playback_fixtures.py | 176 +++ 4 files changed, 1849 insertions(+) create mode 100644 docs/src/content/docs/developer/native-playback-baseline.md create mode 100755 scripts/qa/native_playback_fixture_server.py create mode 100755 scripts/qa/native_playback_fixtures.py create mode 100755 scripts/qa/test_native_playback_fixtures.py diff --git a/docs/src/content/docs/developer/native-playback-baseline.md b/docs/src/content/docs/developer/native-playback-baseline.md new file mode 100644 index 00000000..c45ede98 --- /dev/null +++ b/docs/src/content/docs/developer/native-playback-baseline.md @@ -0,0 +1,147 @@ +--- +title: "Native playback migration baseline" +description: "Code inventory and behavior baseline captured before the libmpv migration." +sidebar: + order: 9 +--- + +This inventory anchors P0 of the [native mpv integration specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md) and its [migration plan](https://github.com/Lowband21/ferrex/blob/dev/docs/plans/native-mpv-playback-migration.md). It describes the code at `dev` commit `23c5715b0c73234bc4d2708d7f2068685730dde7` before the backend-neutral contract is wired into the existing player. + +## Ownership boundary + +The current desktop playback implementation is already extracted from the UI shell: + +- `crates/ferrex-player-playback` owns playback state, update policy, Subwave loading, controls, the external-mpv process, and the desktop player view. +- `crates/ferrex-player-ui` adapts the playback ports, contributes the 10-foot player overlay, chooses transparent window theming, and starts playback-related subscriptions. +- `crates/ferrex-player-playback/src/contract/` is the selected initial boundary for Ferrex-owned backend-neutral commands, events, snapshots, track models, channels, and fallback policy. A further crate split is not needed until a second client consumes this contract. + +## Concrete backend coupling inventory + +### `SubwaveVideo` + +| Location | Direct coupling | +|---|---| +| `ferrex-player-playback/src/state.rs` | Imports and stores `Option` as `video_opt`; derives playing/video presence by polling it; pauses and drops it during teardown. | +| `ferrex-player-playback/src/video.rs` | Constructs `SubwaveVideo::open_at_seconds`, queries initial duration, and pauses/drops the old instance. | +| `ferrex-player-playback/src/update.rs` | Polls position, duration, and paused state; directly issues pause, seek, volume, mute, speed, track, and diagnostic backend-switch operations. | +| `ferrex-player-playback/src/track_selection.rs` | Copies Subwave track DTOs into state and selects tracks by Subwave integer index. | +| `ferrex-player-playback/src/view.rs` | Accepts `&SubwaveVideo` and calls `video.widget(...)`; frame callbacks also drive snapshot-like polling. | +| `ferrex-player-playback/src/controls.rs` | Reads `video.backend()` to label the Wayland/AppSink diagnostic toggle. | +| `ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs` | Calls `video.widget(...)` directly for the 10-foot player and branches on `video_opt`. | +| `ferrex-player-ui/src/domains/ui/theme.rs` | Polls the Subwave backend preference to decide whether the main Iced window is transparent. | +| UI streaming/media/player subscriptions | Treat `video_opt.is_some()` as the internal-session and playback-ready signal. | + +The streaming domain also uses `video_opt` presence to decide whether HLS may start, whether transcoding status polling should continue, and whether a discovered source duration can be copied into player state. These are session-policy checks rather than presentation concerns and must move to backend-neutral state. + +### External mpv process + +`external_mpv_active` is read by the playback view, playback and media subscriptions, episode navigation, the 10-foot overlay, and keyboard gating. `external_mpv_handle` is owned by `PlayerDomainState` and is directly polled or cleared by `update.rs`. The two fields jointly represent backend selection, liveness, progress, fullscreen restoration, and process ownership; there is no single external-player snapshot. + +Both playback and media subscription composers currently add an external-mpv poll when active. Root subscription composition must be checked for duplicate one-second polls during migration. + +## Message-to-behavior map + +| `PlayerMessage` | Current backend/state effect | +|---|---| +| `PlayMedia` | Synthesizes a random movie ID and delegates to `PlayMediaWithId`; watch tracking therefore has a placeholder identity. | +| `PlayMediaWithId` | Stores media/ID, consumes pending resume data, seeds duration/HDR heuristics, clears stale URLs, and asynchronously requests a playback ticket. | +| `SetStreamUrl` | Parses and stores the ticketed URL, closes an existing Subwave provider during episode replacement, and calls `load_video`. | +| `StreamUrlResolutionFailed` | Clears URL/loading flags and enters the video-error view; it does not persist terminal progress. | +| `VideoReadyToPlay` | Calls the internal Subwave loader. | +| `VideoLoaded(true)` | Copies Subwave audio/subtitle state and enters the player view. | +| `VideoLoaded(false)` | Enters the video-error view. | +| `Play` / `Pause` | Calls `set_paused`, then immediately sends progress using direct position/duration polling. Both paths unwrap `current_media_id` when a video exists. | +| `PlayPause` | Polls `paused`, toggles it, sends progress, and reveals controls. It also unwraps `current_media_id`. | +| `Stop` | Sends final progress from `last_valid_position`/`last_valid_duration`, then queues reset and back navigation. It does not query the backend at stop time. | +| `ResetAfterStop` | Clears media, URL, Subwave handle, progress cache, tracks, and transient playback state. External-mpv fields are not reset by `PlayerDomainState::reset`; callers clear them separately. | +| `NavigateBack` / `NavigateHome` | Polls internal position/duration when possible, sends final progress if a media ID exists, then queues reset and navigation. | +| `Seek` | Updates drag/UI position only. | +| `SeekBarPressed` | Starts a drag only when the last mouse-derived seek position is valid. | +| `MouseMoved` | Computes seek position, updates UI immediately, and sends a direct seek at most every 100 ms while dragging; one pending value is retained. | +| `SeekRelease` | Sends the pending/final absolute seek, marks seeking, clears drag throttling fields, and persists the UI-side position. | +| `SeekDone` | Polls backend position, clears seeking, and persists progress; no current producer was found in the repository. | +| `SeekRelative` | Polls current position, clamps against source/known duration, issues an absolute Subwave seek, and updates UI optimistically. | +| `SeekTo` | Converts to seconds and delegates to `Seek`, so it changes drag/UI state rather than immediately seeking. | +| `SetVolume` | Interprets `1.1`/`0.9` as keyboard increments, clamps 0–1, stores state, and calls Subwave. | +| `ToggleMute` | Optimistically toggles state and calls Subwave. | +| `SetPlaybackSpeed` | Stores speed and calls Subwave; backend errors are discarded. | +| `SetContentFit` | Stores Iced `ContentFit`; the Subwave widget consumes it during view construction. External mpv is unaffected. | +| `ToggleFullscreen` | Optimistically flips `is_fullscreen` and emits an Iced window-mode event. | +| `DisableFullscreen` | Emits windowed mode if the boolean is true but does not clear the boolean locally. | +| `VideoClicked` | Single-click toggles play; a second click within 300 ms toggles fullscreen. | +| `VideoDoubleClicked` | Toggles fullscreen directly. | +| `ShowControls` / `CheckControlsVisibility` | Reveals controls or hides them after three seconds; the timer also expires track notifications. | +| Settings/menu toggles | Mutate only overlay visibility and mutually close selected menus. | +| `AudioTrackSelected` | Selects a Subwave integer index and updates a toast. | +| `SubtitleTrackSelected` | Selects an optional Subwave integer index, updates enabled state, and closes the menu. | +| `ToggleSubtitles` | Selects the current/first index when enabling, or `None` when disabling. | +| `CycleAudioTrack` | Increments the integer index modulo track count. | +| `CycleSubtitleTrack` | Cycles `None -> 0..N-1 -> None`. | +| `CycleSubtitleSimple` | Implements the existing off/first/last-used behavior with integer indices. | +| `TracksLoaded` | Only advances notification timeout; no current producer was found. | +| `ToggleAppsinkBackend` | Wayland diagnostic that switches Subwave between forced AppSink and forced Wayland; non-Wayland forces AppSink. | +| `ToggleShuffle` / `ToggleRepeat` | Toggle UI booleans only; no backend playlist command is issued. | +| `NextEpisode` | Persists current progress, resolves the next ordered episode, and preserves internal-vs-external mode. | +| `PreviousEpisode` | At or after 5% seeks/restarts the current episode; before 5% persists progress and opens the prior episode, preserving mode. | +| `EndOfStream` | Persists direct backend progress, auto-opens the next episode when present, otherwise resets and navigates back. No current producer was found in the repository. | +| `NewFrame` | Polls duration/position, clears a one-second seek timeout, lazily refreshes tracks, and updates notification state. | +| `ProgressHeartbeat` | Every ten seconds while internally playing, polls valid position/duration and sends watch progress. | +| `Reload` | No-op in the playback reducer; its comment refers to obsolete main-level handling. | +| `PlayExternal` | Waits for URL resolution, captures internal resume position, stops Subwave, and starts external mpv. Launch failure falls back to `load_video`. | +| `ExternalPlaybackStarted` | Log-only acknowledgement. | +| `PollExternalMpv` | Polls process liveness and JSON IPC state; emits update/end handling and fullscreen restoration. | +| `ExternalPlaybackUpdate` | Copies position/duration into state and advances `last_progress_sent`. | +| `ExternalPlaybackEnded` | Captures final position/fullscreen, persists progress, auto-advances episodes in external mode, or resets/navigates/restores the app window. | + +## Lifecycle and persistence baseline + +- Internal loading is synchronous on the UI thread once the ticket URL resolves. +- `video_opt.is_some()` is overloaded as session existence, rendering readiness, and streaming-start gating. +- Position and duration are copied into `last_valid_*` primarily from `NewFrame`; values at exactly `0.0` are generally treated as unavailable. +- The normal heartbeat interval is ten seconds. Frame callbacks and an additional ten-second `NewFrame` media subscription also poll backend state. +- Final progress is attempted on stop, back/home navigation, EOF, external process exit, and episode transitions. Internal load/auth errors do not have a common terminal-progress path. +- Internal teardown pauses and drops `SubwaveVideo`; there is no generation token, explicit event-channel close, or stale-callback rejection. +- External teardown depends on process-handle polling and per-branch field clearing. + +## Track identity baseline + +Subwave `AudioTrack` and `SubtitleTrack` values escape into `PlayerDomainState` and controls. Selection identity is an `i32` index. The same number is used both as a vector offset and as the backend selection argument. Reloads replace the vectors without preserving a Ferrex-owned stable identity; a prior subtitle index is retained separately for the simple toggle behavior. + +## Content fit and presentation baseline + +- The UI exposes `Contain`, `Cover`, `Fill`, `None`, and `ScaleDown` through Iced `ContentFit`. +- Fit is passed to the Subwave widget and is not represented as a backend capability. +- Wayland transparency is inferred from process environment plus Subwave backend preference. +- Desktop and 10-foot views each call `SubwaveVideo::widget`, so both must migrate to one presentation boundary. +- Fullscreen belongs to the Iced window in internal mode and to mpv in external mode. Internal state is optimistic; only external process teardown reports a final native fullscreen value for restoration. + +## Dependency and packaging pins + +| Input | Baseline | +|---|---| +| Iced fork | `Lowband21/iced-ferrex` commit `577abb7fa132ecd160adb5c8dfaf5c187b4f888d` | +| iced_aw fork | `Lowband21/iced_aw_ferrex` commit `6ebb6e587d2312bef9ca8c7f8acdf4e0f6384148` | +| Subwave | `Lowband21/subwave` `main` commit `4de8fd485a8077d17fd0f25e7b426988ac0da116` | +| gstreamer-rs | `main` commit `7922e962b267bdb645443615a5ae84239c71f19c` (`0.26.0-alpha`) | +| Nix GStreamer | Source overlay `1.28.4`; Rust toolchain `1.92.0` | +| Flatpak | Freedesktop `24.08`, Rust `1.92.0`, GStreamer core/base/good/bad/ugly/libav `1.28.4` | +| Windows CI | Official MSVC GStreamer `1.28.4` | +| Windows dist | Official MSVC GStreamer `1.28.4` | +| macOS handoff | Homebrew GStreamer exact gate `1.28.5`; pinned custom FFmpeg commit `38b88335f99e76ed89ff3c93f877fdefce736c13`; macOS `15.0` floor | +| Nix inputs | nixpkgs `9ae611a455b90cf061d8f332b977e387bda8e1ca`; rust-overlay `06f25b8e40805beb2121a4dae4cc37d6f981800f`; crane `59a82a1222dd3b2080b5cc52a1a2e8d5f1b77f37` | + +Nix wraps the player with the pinned plugin paths and Linux graphics libraries. +Flatpak builds the media stack from source. Windows distribution starts from +the hash-pinned official SDK but stages only a reviewed plugin/PE/GIO/TLS +closure; OpenH264 and Media Foundation avoid a second FFmpeg ABI. macOS builds +FFmpeg and the mpv dependency core from exact sources, while Homebrew +GStreamer/build-support inputs are version/hash recorded and fail closed on +profile drift. Those rolling Homebrew/MSYS2/Rust inputs make the handoff paths +canonical and provenance-recorded, not bit-for-bit reproducible release +inputs. + +## Baseline test gaps retained as P0 work + +The reproducible synthetic media, authenticated range/HLS transport, initial platform inventory, Wayland operation matrix, and ignored results location are defined in [Native playback fixtures and test matrix](/developer/native-playback-fixtures/). The generator validates codecs, color signaling, HDR side data, subtitles, tracks, chapters, attachments, and malformed inputs without committing generated media. + +Startup, seek, CPU/GPU, memory-cycle, hardware-decoder, compositor/HDR, and protocol-trace measurements still require runs on the physical environments in that matrix. EOF and seek-completion producer wiring also needs an explicit reproduction test before behavior is frozen. diff --git a/scripts/qa/native_playback_fixture_server.py b/scripts/qa/native_playback_fixture_server.py new file mode 100755 index 00000000..f9274a19 --- /dev/null +++ b/scripts/qa/native_playback_fixture_server.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Serve generated playback fixtures with redacted auth and HTTP ranges. + +The server binds to loopback and reads its bearer/query token from an +environment variable so the credential does not appear in its argument vector. +It is a deterministic transport fixture, not a substitute for the Ferrex-server +acceptance gate. +""" + +from __future__ import annotations + +import argparse +import hmac +import http.server +import mimetypes +import os +import re +import signal +import sys +import threading +import urllib.parse +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO, Sequence + +MARKER_NAME = ".ferrex-native-playback-fixtures" +DEFAULT_ROOT = Path("target/native-playback-fixtures") +DEFAULT_TOKEN_ENV = "FERREX_FIXTURE_TOKEN" +_RANGE_PATTERN = re.compile(r"^bytes=(\d*)-(\d*)$") + + +class ServerError(RuntimeError): + """Invalid server configuration.""" + + +@dataclass(frozen=True) +class ByteRange: + start: int + end: int + + @property + def length(self) -> int: + return self.end - self.start + 1 + + +def parse_byte_range(value: str, size: int) -> ByteRange: + """Parse the single byte-range form used by media clients.""" + + match = _RANGE_PATTERN.fullmatch(value.strip()) + if match is None or size <= 0: + raise ValueError("invalid byte range") + + start_text, end_text = match.groups() + if not start_text and not end_text: + raise ValueError("empty byte range") + + if not start_text: + suffix = int(end_text) + if suffix <= 0: + raise ValueError("invalid suffix byte range") + start = max(0, size - suffix) + return ByteRange(start, size - 1) + + start = int(start_text) + end = int(end_text) if end_text else size - 1 + if start >= size or end < start: + raise ValueError("unsatisfiable byte range") + return ByteRange(start, min(end, size - 1)) + + +class FixtureServer(http.server.ThreadingHTTPServer): + daemon_threads = True + + def __init__( + self, + address: tuple[str, int], + root: Path, + token: str, + auth_mode: str, + ) -> None: + super().__init__(address, FixtureHandler) + self.root = root + self.token = token + self.auth_mode = auth_mode + + +class FixtureHandler(http.server.BaseHTTPRequestHandler): + server: FixtureServer + protocol_version = "HTTP/1.1" + + def log_message(self, format_string: str, *arguments: object) -> None: + message = format_string % arguments + message = message.replace(self.server.token, "") + sys.stderr.write( + f"fixture-server {self.client_address[0]} {message}\n" + ) + + def log_request( + self, code: int | str = "-", size: int | str = "-" + ) -> None: + # BaseHTTPRequestHandler logs the complete request target, including a + # query ticket. Keep only the decoded path and never retain the query. + path = urllib.parse.urlsplit(self.path).path + self.log_message( + '"%s %s %s" %s %s', + self.command, + path, + self.request_version, + code, + size, + ) + + def do_HEAD(self) -> None: # noqa: N802 - stdlib handler API + self._serve(send_body=False) + + def do_GET(self) -> None: # noqa: N802 - stdlib handler API + self._serve(send_body=True) + + def _serve(self, *, send_body: bool) -> None: + parsed = urllib.parse.urlsplit(self.path) + if not self._authorized(parsed): + self._plain_error(401, "authorization required") + return + + try: + path = self._resolve_path(parsed.path) + except ValueError: + self._plain_error(404, "fixture not found") + return + + if not path.is_file(): + self._plain_error(404, "fixture not found") + return + + size = path.stat().st_size + selected_range: ByteRange | None = None + range_header = self.headers.get("Range") + if range_header is not None: + try: + selected_range = parse_byte_range(range_header, size) + except (ValueError, OverflowError): + self.send_response(416) + self.send_header("Content-Range", f"bytes */{size}") + self.send_header("Content-Length", "0") + self.send_header("Connection", "close") + self.end_headers() + return + + content_type = mimetypes.guess_type(path.name)[0] + if path.suffix.lower() == ".mkv": + content_type = "video/x-matroska" + elif path.suffix.lower() == ".m3u8": + content_type = "application/vnd.apple.mpegurl" + elif path.suffix.lower() == ".ts": + content_type = "video/mp2t" + + if selected_range is None: + self.send_response(200) + content_length = size + else: + self.send_response(206) + content_length = selected_range.length + self.send_header( + "Content-Range", + f"bytes {selected_range.start}-{selected_range.end}/{size}", + ) + + self.send_header("Content-Type", content_type or "application/octet-stream") + self.send_header("Content-Length", str(content_length)) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + + if not send_body: + return + + with path.open("rb") as source: + if selected_range is not None: + source.seek(selected_range.start) + self._copy_exact(source, content_length) + + def _authorized(self, parsed: urllib.parse.SplitResult) -> bool: + bearer_allowed = self.server.auth_mode in ("bearer", "either") + query_allowed = self.server.auth_mode in ("query", "either") + + if bearer_allowed: + authorization = self.headers.get("Authorization", "") + expected = f"Bearer {self.server.token}" + if hmac.compare_digest(authorization, expected): + return True + + if query_allowed: + values = urllib.parse.parse_qs( + parsed.query, keep_blank_values=True + ).get("access_token", []) + if len(values) == 1 and hmac.compare_digest( + values[0], self.server.token + ): + return True + + return False + + def _resolve_path(self, request_path: str) -> Path: + decoded = urllib.parse.unquote(request_path) + relative = Path(decoded.lstrip("/")) + if not relative.parts or any(part in ("", ".", "..") for part in relative.parts): + raise ValueError("invalid fixture path") + + candidate = (self.server.root / relative).resolve() + try: + candidate.relative_to(self.server.root) + except ValueError as error: + raise ValueError("fixture path escapes root") from error + return candidate + + def _plain_error(self, status: int, message: str) -> None: + body = f"{message}\n".encode("utf-8") + self.send_response(status) + if status == 401: + self.send_header("WWW-Authenticate", 'Bearer realm="ferrex-fixtures"') + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("Connection", "close") + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def _copy_exact(self, source: BinaryIO, remaining: int) -> None: + while remaining > 0: + block = source.read(min(64 * 1024, remaining)) + if not block: + break + self.wfile.write(block) + remaining -= len(block) + + +def write_port_file(path: Path, port: int) -> None: + path = path.expanduser().resolve() + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}") + temporary.write_text(f"{port}\n", encoding="utf-8") + if os.name != "nt": + temporary.chmod(0o600) + temporary.replace(path) + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=0) + parser.add_argument( + "--auth", + choices=("bearer", "query", "either"), + default="bearer", + help="accepted credential transport (default: bearer)", + ) + parser.add_argument( + "--token-env", + default=DEFAULT_TOKEN_ENV, + help=f"environment variable containing the token (default: {DEFAULT_TOKEN_ENV})", + ) + parser.add_argument( + "--port-file", + type=Path, + help="atomically write the selected port for smoke-test automation", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv if argv is not None else sys.argv[1:]) + try: + root = args.root.expanduser().resolve() + if not root.is_dir() or not (root / MARKER_NAME).is_file(): + raise ServerError( + f"not a generated Ferrex fixture directory: {root}" + ) + if args.bind not in ("127.0.0.1", "localhost"): + raise ServerError("fixture server must bind to loopback") + if not 0 <= args.port <= 65535: + raise ServerError("port must be between 0 and 65535") + + token = os.environ.get(args.token_env, "") + if not token or "\r" in token or "\n" in token: + raise ServerError( + f"set {args.token_env} to a non-empty, single-line token" + ) + + server = FixtureServer( + (args.bind, args.port), root, token, args.auth + ) + if args.port_file is not None: + write_port_file(args.port_file, server.server_port) + + stopping = threading.Event() + + def request_shutdown(_signum: int, _frame: object) -> None: + if not stopping.is_set(): + stopping.set() + threading.Thread(target=server.shutdown, daemon=True).start() + + for signal_number in (signal.SIGINT, signal.SIGTERM): + signal.signal(signal_number, request_shutdown) + + print( + f"Serving {root} at http://{args.bind}:{server.server_port}/ " + f"with {args.auth} authentication", + flush=True, + ) + try: + server.serve_forever(poll_interval=0.2) + finally: + server.server_close() + except (OSError, ServerError) as error: + print(f"native playback fixture server error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qa/native_playback_fixtures.py b/scripts/qa/native_playback_fixtures.py new file mode 100755 index 00000000..c595be8e --- /dev/null +++ b/scripts/qa/native_playback_fixtures.py @@ -0,0 +1,1202 @@ +#!/usr/bin/env python3 +"""Generate and verify the synthetic desktop playback acceptance fixtures. + +The output is deliberately written below ``target/`` by default and is not +committed. Every media stream starts from FFmpeg lavfi sources; the only +external input is a user-selected font embedded in the ASS/attachment fixtures. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shlex +import shutil +import struct +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Sequence + +SCHEMA_VERSION = 1 +MARKER_NAME = ".ferrex-native-playback-fixtures" +DEFAULT_OUTPUT = Path("target/native-playback-fixtures") +DURATION_SECONDS = 4 +VIDEO_SIZE = "640x360" +FRAME_RATE = 24 + +SDR_FILTER = ( + "format=yuv420p," + "setparams=range=limited:color_primaries=bt709:" + "color_trc=bt709:colorspace=bt709" +) +SDR_10_FILTER = ( + "format=yuv420p10le," + "setparams=range=limited:color_primaries=bt709:" + "color_trc=bt709:colorspace=bt709" +) +PQ_FILTER = ( + "format=yuv420p10le," + "setparams=range=limited:color_primaries=bt2020:" + "color_trc=smpte2084:colorspace=bt2020nc" +) +HLG_FILTER = ( + "format=yuv420p10le," + "setparams=range=limited:color_primaries=bt2020:" + "color_trc=arib-std-b67:colorspace=bt2020nc" +) + +PRIMARY_FIXTURES = ( + "h264-sdr-8bit.mkv", + "hevc-main10-sdr.mkv", + "hdr10-pq.mkv", + "hlg.mkv", + "vp9-sdr.mkv", + "av1-sdr.mkv", + "ass-animation-fonts.mkv", + "pgs-bitmap.mkv", + "multitrack-structure.mkv", +) + + +class FixtureError(RuntimeError): + """Actionable fixture generation or validation failure.""" + + +@dataclass(frozen=True) +class Tools: + ffmpeg: str + ffprobe: str + + +@dataclass(frozen=True) +class BuildContext: + root: Path + tools: Tools + font: Path + + @property + def sources(self) -> Path: + return self.root / "sources" + + +def run( + command: Sequence[os.PathLike[str] | str], + *, + cwd: Path | None = None, + capture: bool = False, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + argv = [os.fspath(part) for part in command] + if not capture: + print("+", shlex.join(argv), flush=True) + try: + return subprocess.run( + argv, + cwd=cwd, + check=check, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + ) + except FileNotFoundError as error: + raise FixtureError(f"required command not found: {argv[0]}") from error + except subprocess.CalledProcessError as error: + detail = (error.stderr or "").strip() + suffix = f": {detail}" if detail else "" + raise FixtureError( + f"command failed with status {error.returncode}: " + f"{shlex.join(argv)}{suffix}" + ) from error + + +def ffmpeg( + context: BuildContext, + *arguments: os.PathLike[str] | str, + cwd: Path | None = None, +) -> None: + run( + [ + context.tools.ffmpeg, + "-hide_banner", + "-nostdin", + "-loglevel", + "error", + "-y", + *arguments, + ], + cwd=cwd, + ) + + +def tool_path(name: str) -> str: + path = shutil.which(name) + if path is None: + raise FixtureError(f"required command not found: {name}") + return path + + +def discover_tools(*, generation: bool) -> Tools: + ffprobe = tool_path("ffprobe") + ffmpeg_path = tool_path("ffmpeg") if generation else shutil.which("ffmpeg") + return Tools(ffmpeg=ffmpeg_path or "ffmpeg", ffprobe=ffprobe) + + +def discover_font(explicit: str | None) -> Path: + if explicit is not None: + candidate = Path(explicit).expanduser().resolve() + if not candidate.is_file(): + raise FixtureError(f"font does not exist: {candidate}") + return candidate + + fc_match = shutil.which("fc-match") + if fc_match is not None: + result = run( + [fc_match, "-f", "%{file}\n", "DejaVu Sans"], capture=True + ) + first = result.stdout.splitlines()[0].strip() if result.stdout else "" + if first and Path(first).is_file(): + return Path(first).resolve() + + for candidate in ( + Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), + Path("/usr/share/fonts/dejavu/DejaVuSans.ttf"), + Path.home() / ".local/share/fonts/DejaVuSans.ttf", + ): + if candidate.is_file(): + return candidate.resolve() + + raise FixtureError( + "could not find DejaVu Sans; install fontconfig/dejavu-fonts or pass " + "--font /path/to/a redistributable .ttf" + ) + + +def require_ffmpeg_capabilities(tools: Tools) -> None: + encoders = run( + [tools.ffmpeg, "-hide_banner", "-encoders"], capture=True + ).stdout + required_encoders = ( + "libx264", + "libx265", + "libvpx-vp9", + "libaom-av1", + "aac", + "ass", + ) + missing = [name for name in required_encoders if name not in encoders] + + filters = run( + [tools.ffmpeg, "-hide_banner", "-filters"], capture=True + ).stdout + required_filters = ("testsrc2", "sine", "setparams", "format") + missing.extend( + f"filter:{name}" for name in required_filters if name not in filters + ) + + if missing: + raise FixtureError( + "FFmpeg lacks required fixture capabilities: " + ", ".join(missing) + ) + + +def lavfi_inputs(*, second_audio: bool = False) -> list[str]: + arguments = [ + "-f", + "lavfi", + "-i", + f"testsrc2=size={VIDEO_SIZE}:rate={FRAME_RATE}:duration={DURATION_SECONDS}", + "-f", + "lavfi", + "-i", + f"sine=frequency=440:sample_rate=48000:duration={DURATION_SECONDS}", + ] + if second_audio: + arguments.extend( + [ + "-f", + "lavfi", + "-i", + f"sine=frequency=660:sample_rate=48000:duration={DURATION_SECONDS}", + ] + ) + return arguments + + +def common_maps() -> list[str]: + return ["-map", "0:v:0", "-map", "1:a:0"] + + +def common_audio() -> list[str]: + return ["-c:a", "aac", "-b:a", "96k", "-af", "volume=0.08"] + + +def common_output(title: str) -> list[str]: + return [ + "-t", + str(DURATION_SECONDS), + "-map_metadata", + "-1", + "-metadata", + f"title={title}", + ] + + +def build_codec_fixtures(context: BuildContext) -> None: + root = context.root + + ffmpeg( + context, + *lavfi_inputs(), + *common_maps(), + "-vf", + SDR_FILTER, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-g", + str(FRAME_RATE), + "-keyint_min", + str(FRAME_RATE), + "-sc_threshold", + "0", + "-threads", + "2", + *common_audio(), + *common_output("Ferrex H.264 SDR 8-bit fixture"), + root / "h264-sdr-8bit.mkv", + ) + + x265_common = ( + "log-level=error:pools=1:frame-threads=1:repeat-headers=1" + ) + ffmpeg( + context, + *lavfi_inputs(), + *common_maps(), + "-vf", + SDR_10_FILTER, + "-c:v", + "libx265", + "-preset", + "ultrafast", + "-crf", + "28", + "-x265-params", + f"{x265_common}:colorprim=1:transfer=1:colormatrix=1", + *common_audio(), + *common_output("Ferrex HEVC Main10 SDR fixture"), + root / "hevc-main10-sdr.mkv", + ) + + pq_parameters = ( + f"{x265_common}:colorprim=9:transfer=16:colormatrix=9:hdr10=1:" + "master-display=G(13250,34500)B(7500,3000)R(34000,16000)" + "WP(15635,16450)L(10000000,50):max-cll=1000,400" + ) + ffmpeg( + context, + *lavfi_inputs(), + *common_maps(), + "-vf", + PQ_FILTER, + "-c:v", + "libx265", + "-preset", + "ultrafast", + "-crf", + "28", + "-x265-params", + pq_parameters, + *common_audio(), + *common_output("Ferrex synthetic HDR10 PQ metadata fixture"), + root / "hdr10-pq.mkv", + ) + + ffmpeg( + context, + *lavfi_inputs(), + *common_maps(), + "-vf", + HLG_FILTER, + "-c:v", + "libx265", + "-preset", + "ultrafast", + "-crf", + "28", + "-x265-params", + f"{x265_common}:colorprim=9:transfer=18:colormatrix=9", + *common_audio(), + *common_output("Ferrex synthetic HLG metadata fixture"), + root / "hlg.mkv", + ) + + ffmpeg( + context, + *lavfi_inputs(), + *common_maps(), + "-vf", + SDR_FILTER, + "-c:v", + "libvpx-vp9", + "-deadline", + "realtime", + "-cpu-used", + "8", + "-crf", + "35", + "-b:v", + "0", + "-threads", + "2", + *common_audio(), + *common_output("Ferrex VP9 SDR fixture"), + root / "vp9-sdr.mkv", + ) + + ffmpeg( + context, + *lavfi_inputs(), + *common_maps(), + "-vf", + SDR_FILTER, + "-c:v", + "libaom-av1", + "-cpu-used", + "8", + "-crf", + "40", + "-b:v", + "0", + "-row-mt", + "1", + "-threads", + "4", + *common_audio(), + *common_output("Ferrex AV1 SDR fixture"), + root / "av1-sdr.mkv", + ) + + +def write_text_sources(context: BuildContext) -> None: + context.sources.mkdir(parents=True, exist_ok=True) + (context.sources / "animated.ass").write_text( + "[Script Info]\n" + "Title: Ferrex animated ASS fixture\n" + "ScriptType: v4.00+\n" + "PlayResX: 640\n" + "PlayResY: 360\n" + "ScaledBorderAndShadow: yes\n\n" + "[V4+ Styles]\n" + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, " + "OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, " + "ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, " + "Alignment, MarginL, MarginR, MarginV, Encoding\n" + "Style: Default,DejaVu Sans,32,&H00FFFFFF,&H0000FFFF,&H00000000," + "&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,20,20,24,1\n\n" + "[Events]\n" + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, " + "Effect, Text\n" + "Dialogue: 0,0:00:00.40,0:00:03.60,Default,,0,0,0,," + "{\\move(80,300,560,300)\\bord3}Ferrex ASS motion\n" + "Dialogue: 1,0:00:01.00,0:00:03.40,Default,,0,0,0,," + "{\\an8\\pos(320,48)\\k30}Ani{\\k30}ma{\\k30}ted\n", + encoding="utf-8", + ) + (context.sources / "english.srt").write_text( + "1\n00:00:00,500 --> 00:00:02,000\nEnglish text subtitle\n\n" + "2\n00:00:02,200 --> 00:00:03,700\nSecond English cue\n", + encoding="utf-8", + ) + (context.sources / "spanish.srt").write_text( + "1\n00:00:00,700 --> 00:00:02,200\nSubtítulo en español\n\n" + "2\n00:00:02,300 --> 00:00:03,800\nSegunda pista\n", + encoding="utf-8", + ) + (context.sources / "chapters.ffmeta").write_text( + ";FFMETADATA1\n" + "title=Ferrex chapter fixture\n" + "[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=2000\n" + "title=Opening\n" + "[CHAPTER]\nTIMEBASE=1/1000\nSTART=2000\nEND=4000\n" + "title=Second chapter\n", + encoding="utf-8", + ) + + +def build_ass_fixture(context: BuildContext) -> None: + ffmpeg( + context, + *lavfi_inputs(), + "-i", + context.sources / "animated.ass", + *common_maps(), + "-map", + "2:s:0", + "-vf", + SDR_FILTER, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-threads", + "2", + *common_audio(), + "-c:s", + "ass", + "-metadata:s:s:0", + "language=eng", + "-metadata:s:s:0", + "title=Animated ASS", + "-disposition:s:0", + "default", + "-attach", + context.font, + "-metadata:s:t:0", + "mimetype=application/x-truetype-font", + "-metadata:s:t:0", + "filename=DejaVuSans.ttf", + *common_output("Ferrex ASS animation and font fixture"), + context.root / "ass-animation-fonts.mkv", + ) + + +def write_pgs(path: Path) -> None: + """Write a tiny valid SUP stream without relying on a PGS encoder.""" + + width = 160 + height = 40 + + with path.open("wb") as output: + def segment(pts_seconds: float, kind: int, payload: bytes) -> None: + timestamp = int(pts_seconds * 90_000) + output.write(b"PG") + output.write( + struct.pack(">IIBH", timestamp, timestamp, kind, len(payload)) + ) + output.write(payload) + + # Epoch-start composition with one object in one window. + segment( + 1.0, + 0x16, + struct.pack( + ">HHBHB BBBHBBHH", + 640, + 360, + 0x10, + 0, + 0x80, + 0, + 0, + 1, + 0, + 0, + 0, + 240, + 280, + ), + ) + segment( + 1.0, + 0x17, + struct.pack(">BBHHHH", 1, 0, 240, 280, width, height), + ) + # Transparent, white, and black entries in Y/Cr/Cb/alpha order. + segment( + 1.0, + 0x14, + bytes( + [ + 0, + 0, + 0, + 16, + 128, + 128, + 0, + 1, + 235, + 128, + 128, + 255, + 2, + 16, + 128, + 128, + 255, + ] + ), + ) + + # PGS permits a nonzero palette index to represent one literal pixel. + # This intentionally uncompressed bordered rectangle stays small and + # makes the generator independent of third-party subtitle encoders. + rle = bytearray() + for y in range(height): + for x in range(width): + border = y in (0, height - 1) or x in (0, width - 1) + rle.append(1 if border else 2) + rle.extend((0, 0)) # end of line + + object_data_length = 4 + len(rle) + object_payload = ( + struct.pack(">HBB", 0, 0, 0xC0) + + object_data_length.to_bytes(3, "big") + + struct.pack(">HH", width, height) + + rle + ) + segment(1.0, 0x15, object_payload) + segment(1.0, 0x80, b"") + + # Clear the composition near the end of the fixture. + segment( + 3.0, + 0x16, + struct.pack(">HHBHB BBB", 640, 360, 0x10, 1, 0, 0, 0, 0), + ) + segment(3.0, 0x80, b"") + + +def build_pgs_fixture(context: BuildContext) -> None: + sup = context.sources / "bitmap.sup" + write_pgs(sup) + ffmpeg( + context, + *lavfi_inputs(), + "-f", + "sup", + "-i", + sup, + *common_maps(), + "-map", + "2:s:0", + "-vf", + SDR_FILTER, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-threads", + "2", + *common_audio(), + "-c:s", + "copy", + "-metadata:s:s:0", + "language=eng", + "-metadata:s:s:0", + "title=Synthetic PGS bitmap", + *common_output("Ferrex PGS bitmap subtitle fixture"), + context.root / "pgs-bitmap.mkv", + ) + + +def build_multitrack_fixture(context: BuildContext) -> None: + ffmpeg( + context, + *lavfi_inputs(second_audio=True), + "-i", + context.sources / "english.srt", + "-i", + context.sources / "spanish.srt", + "-f", + "ffmetadata", + "-i", + context.sources / "chapters.ffmeta", + "-map", + "0:v:0", + "-map", + "1:a:0", + "-map", + "2:a:0", + "-map", + "3:s:0", + "-map", + "4:s:0", + "-map_metadata", + "5", + "-map_chapters", + "5", + "-vf", + SDR_FILTER, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-threads", + "2", + "-c:a", + "aac", + "-b:a", + "96k", + "-filter:a:0", + "volume=0.08", + "-filter:a:1", + "volume=0.08", + "-metadata:s:a:0", + "language=eng", + "-metadata:s:a:0", + "title=English stereo", + "-metadata:s:a:1", + "language=spa", + "-metadata:s:a:1", + "title=Spanish stereo", + "-disposition:a:0", + "default", + "-disposition:a:1", + "0", + "-c:s", + "srt", + "-metadata:s:s:0", + "language=eng", + "-metadata:s:s:0", + "title=English text", + "-metadata:s:s:1", + "language=spa", + "-metadata:s:s:1", + "title=Spanish forced text", + "-disposition:s:0", + "default", + "-disposition:s:1", + "forced", + "-attach", + context.font, + "-metadata:s:t:0", + "mimetype=application/x-truetype-font", + "-metadata:s:t:0", + "filename=DejaVuSans.ttf", + "-t", + str(DURATION_SECONDS), + context.root / "multitrack-structure.mkv", + ) + + +def build_hls_fixture(context: BuildContext) -> None: + hls = context.root / "transcoded-hls" + hls.mkdir() + ffmpeg( + context, + "-i", + context.root / "h264-sdr-8bit.mkv", + "-map", + "0:v:0", + "-map", + "0:a:0", + "-c", + "copy", + "-f", + "hls", + "-hls_time", + "1", + "-hls_playlist_type", + "vod", + "-hls_flags", + "independent_segments", + "-hls_segment_filename", + "segment-%03d.ts", + "index.m3u8", + cwd=hls, + ) + + +def build_malformed_fixtures(context: BuildContext) -> None: + valid = (context.root / "h264-sdr-8bit.mkv").read_bytes() + # Keep a recognizable EBML prefix but remove the segment body. + (context.root / "malformed-truncated.mkv").write_bytes(valid[:64]) + (context.root / "unsupported.txt").write_text( + "This is deliberately not a media container.\n", encoding="utf-8" + ) + + +def ffmpeg_version(tools: Tools) -> str: + first = run([tools.ffmpeg, "-version"], capture=True).stdout.splitlines() + return first[0] if first else "unknown" + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def write_manifest(context: BuildContext) -> None: + manifest = { + "schema_version": SCHEMA_VERSION, + "generator": "scripts/qa/native_playback_fixtures.py", + "ffmpeg": ffmpeg_version(context.tools), + "duration_seconds": DURATION_SECONDS, + "video_size": VIDEO_SIZE, + "frame_rate": FRAME_RATE, + "font": { + "embedded_filename": "DejaVuSans.ttf", + "sha256": sha256(context.font), + }, + "fixtures": [ + { + "path": "h264-sdr-8bit.mkv", + "purpose": "H.264 SDR 8-bit baseline", + }, + { + "path": "hevc-main10-sdr.mkv", + "purpose": "HEVC Main10 SDR decode and output-color separation", + }, + { + "path": "hdr10-pq.mkv", + "purpose": "BT.2020/PQ mastering-display and MaxCLL metadata", + }, + { + "path": "hlg.mkv", + "purpose": "BT.2020/ARIB STD-B67 HLG signaling", + }, + {"path": "vp9-sdr.mkv", "purpose": "VP9 SDR decode"}, + {"path": "av1-sdr.mkv", "purpose": "AV1 SDR decode"}, + { + "path": "ass-animation-fonts.mkv", + "purpose": "animated ASS plus attached font", + }, + { + "path": "pgs-bitmap.mkv", + "purpose": "HDMV PGS bitmap subtitle", + }, + { + "path": "multitrack-structure.mkv", + "purpose": "multiple audio/subtitle tracks, chapters, and attachment", + }, + { + "path": "transcoded-hls/index.m3u8", + "purpose": "local server-style HLS/transcode output", + }, + { + "path": "malformed-truncated.mkv", + "purpose": "truncated-container failure", + }, + { + "path": "unsupported.txt", + "purpose": "unsupported-input failure", + }, + ], + } + (context.root / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + (context.root / "README.txt").write_text( + "Ferrex native playback fixtures\n" + "===============================\n\n" + "Generated locally by scripts/qa/native_playback_fixtures.py.\n" + "Do not commit this directory. Verify it with:\n\n" + " ./scripts/qa/native_playback_fixtures.py verify\n\n" + "The HDR10 and HLG files validate signaling and playback behavior;\n" + "their synthetic test pattern is not a visual mastering reference.\n", + encoding="utf-8", + ) + + +def checksum_files(root: Path) -> Iterable[Path]: + excluded = {MARKER_NAME, "SHA256SUMS"} + return ( + path + for path in sorted(root.rglob("*")) + if path.is_file() and path.name not in excluded + ) + + +def write_checksums(root: Path) -> None: + lines = [ + f"{sha256(path)} {path.relative_to(root).as_posix()}" + for path in checksum_files(root) + ] + (root / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def probe_json( + tools: Tools, + path: Path, + *, + frames: bool = False, +) -> dict[str, Any]: + arguments = [tools.ffprobe, "-v", "error"] + if frames: + arguments.extend( + ["-select_streams", "v:0", "-read_intervals", "%+#1", "-show_frames"] + ) + else: + arguments.extend(["-show_streams", "-show_chapters", "-show_format"]) + arguments.extend(["-of", "json", path]) + result = run(arguments, capture=True) + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise FixtureError(f"ffprobe returned invalid JSON for {path}") from error + + +def streams(probe: dict[str, Any], kind: str) -> list[dict[str, Any]]: + return [ + stream + for stream in probe.get("streams", []) + if stream.get("codec_type") == kind + ] + + +def packet_counts(tools: Tools, path: Path, selector: str) -> list[int]: + result = run( + [ + tools.ffprobe, + "-v", + "error", + "-select_streams", + selector, + "-count_packets", + "-show_entries", + "stream=nb_read_packets", + "-of", + "json", + path, + ], + capture=True, + ) + try: + probe = json.loads(result.stdout) + return [ + int(stream["nb_read_packets"]) + for stream in probe.get("streams", []) + if stream.get("nb_read_packets") not in (None, "N/A") + ] + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + raise FixtureError(f"could not count packets in {path}") from error + + +def require(condition: bool, message: str) -> None: + if not condition: + raise FixtureError(message) + + +def verify_video( + tools: Tools, + root: Path, + filename: str, + codec: str, + *, + pixel_format: str | None = None, + transfer: str | None = None, + primaries: str | None = None, +) -> dict[str, Any]: + probe = probe_json(tools, root / filename) + video = streams(probe, "video") + audio = streams(probe, "audio") + require(len(video) == 1, f"{filename}: expected one video stream") + require(len(audio) >= 1, f"{filename}: expected an audio stream") + stream = video[0] + require( + stream.get("codec_name") == codec, + f"{filename}: expected {codec}, got {stream.get('codec_name')}", + ) + if pixel_format is not None: + require( + stream.get("pix_fmt") == pixel_format, + f"{filename}: expected pixel format {pixel_format}, " + f"got {stream.get('pix_fmt')}", + ) + if transfer is not None: + require( + stream.get("color_transfer") == transfer, + f"{filename}: expected transfer {transfer}, " + f"got {stream.get('color_transfer')}", + ) + if primaries is not None: + require( + stream.get("color_primaries") == primaries, + f"{filename}: expected primaries {primaries}, " + f"got {stream.get('color_primaries')}", + ) + return probe + + +def verify_checksums(root: Path) -> None: + checksum_path = root / "SHA256SUMS" + require(checksum_path.is_file(), "SHA256SUMS is missing") + for line in checksum_path.read_text(encoding="utf-8").splitlines(): + expected, separator, relative = line.partition(" ") + require(bool(separator and relative), f"invalid checksum line: {line}") + path = root / relative + require(path.is_file(), f"checksummed fixture is missing: {relative}") + require( + sha256(path) == expected, + f"fixture checksum mismatch: {relative}", + ) + + +def verify_directory(root: Path, tools: Tools) -> None: + root = root.resolve() + require(root.is_dir(), f"fixture directory does not exist: {root}") + marker = root / MARKER_NAME + require(marker.is_file(), f"fixture marker is missing: {marker}") + require( + marker.read_text(encoding="utf-8").strip() == str(SCHEMA_VERSION), + "fixture marker schema is incompatible", + ) + + manifest_path = root / "manifest.json" + require(manifest_path.is_file(), "manifest.json is missing") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + require( + manifest.get("schema_version") == SCHEMA_VERSION, + "manifest schema is incompatible", + ) + + for filename in PRIMARY_FIXTURES: + require((root / filename).is_file(), f"fixture is missing: {filename}") + + verify_video( + tools, + root, + "h264-sdr-8bit.mkv", + "h264", + pixel_format="yuv420p", + transfer="bt709", + primaries="bt709", + ) + verify_video( + tools, + root, + "hevc-main10-sdr.mkv", + "hevc", + pixel_format="yuv420p10le", + transfer="bt709", + primaries="bt709", + ) + verify_video( + tools, + root, + "hdr10-pq.mkv", + "hevc", + pixel_format="yuv420p10le", + transfer="smpte2084", + primaries="bt2020", + ) + verify_video( + tools, + root, + "hlg.mkv", + "hevc", + pixel_format="yuv420p10le", + transfer="arib-std-b67", + primaries="bt2020", + ) + verify_video(tools, root, "vp9-sdr.mkv", "vp9") + verify_video(tools, root, "av1-sdr.mkv", "av1") + + pq_frames = probe_json(tools, root / "hdr10-pq.mkv", frames=True) + side_data_types = { + entry.get("side_data_type") + for frame in pq_frames.get("frames", []) + for entry in frame.get("side_data_list", []) + } + require( + "Mastering display metadata" in side_data_types, + "hdr10-pq.mkv: mastering-display metadata is missing", + ) + require( + "Content light level metadata" in side_data_types, + "hdr10-pq.mkv: MaxCLL/MaxFALL metadata is missing", + ) + + ass_probe = verify_video(tools, root, "ass-animation-fonts.mkv", "h264") + require( + any(stream.get("codec_name") == "ass" for stream in streams(ass_probe, "subtitle")), + "ass-animation-fonts.mkv: ASS stream is missing", + ) + require( + len(streams(ass_probe, "attachment")) >= 1, + "ass-animation-fonts.mkv: attached font is missing", + ) + require( + packet_counts(tools, root / "ass-animation-fonts.mkv", "s") == [2], + "ass-animation-fonts.mkv: expected two animated subtitle events", + ) + + pgs_probe = verify_video(tools, root, "pgs-bitmap.mkv", "h264") + require( + any( + stream.get("codec_name") == "hdmv_pgs_subtitle" + for stream in streams(pgs_probe, "subtitle") + ), + "pgs-bitmap.mkv: PGS stream is missing", + ) + require( + packet_counts(tools, root / "pgs-bitmap.mkv", "s") == [2], + "pgs-bitmap.mkv: expected show and clear display sets", + ) + + structure = verify_video( + tools, root, "multitrack-structure.mkv", "h264" + ) + require( + len(streams(structure, "audio")) >= 2, + "multitrack-structure.mkv: expected two audio tracks", + ) + require( + len(streams(structure, "subtitle")) >= 2, + "multitrack-structure.mkv: expected two subtitle tracks", + ) + require( + len(streams(structure, "attachment")) >= 1, + "multitrack-structure.mkv: expected an attachment", + ) + require( + len(structure.get("chapters", [])) >= 2, + "multitrack-structure.mkv: expected two chapters", + ) + + playlist = root / "transcoded-hls/index.m3u8" + require(playlist.is_file(), "transcoded HLS playlist is missing") + playlist_text = playlist.read_text(encoding="utf-8") + require("#EXT-X-ENDLIST" in playlist_text, "HLS VOD playlist is incomplete") + require( + "segment-" in playlist_text and list(playlist.parent.glob("segment-*.ts")), + "HLS transport segments are missing", + ) + hls_probe = probe_json(tools, playlist) + require(streams(hls_probe, "video"), "HLS fixture has no video stream") + require(streams(hls_probe, "audio"), "HLS fixture has no audio stream") + + for malformed in ("malformed-truncated.mkv", "unsupported.txt"): + result = subprocess.run( + [tools.ffprobe, "-v", "error", root / malformed], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + require( + result.returncode != 0, + f"{malformed}: expected ffprobe to reject malformed input", + ) + + verify_checksums(root) + print(f"Verified {len(PRIMARY_FIXTURES)} primary playback fixtures in {root}") + + +def generate(output: Path, *, force: bool, font: str | None) -> None: + output = output.expanduser().resolve() + parent = output.parent + parent.mkdir(parents=True, exist_ok=True) + + if output.exists(): + marker = output / MARKER_NAME + if not force: + raise FixtureError( + f"output already exists: {output} (pass --force to replace it)" + ) + if not marker.is_file(): + raise FixtureError( + f"refusing to replace unmarked directory: {output}" + ) + + tools = discover_tools(generation=True) + require_ffmpeg_capabilities(tools) + selected_font = discover_font(font) + + temporary = Path( + tempfile.mkdtemp(prefix=f".{output.name}.tmp-", dir=parent) + ).resolve() + context = BuildContext(root=temporary, tools=tools, font=selected_font) + + try: + (temporary / MARKER_NAME).write_text( + f"{SCHEMA_VERSION}\n", encoding="utf-8" + ) + write_text_sources(context) + build_codec_fixtures(context) + build_ass_fixture(context) + build_pgs_fixture(context) + build_multitrack_fixture(context) + build_hls_fixture(context) + build_malformed_fixtures(context) + write_manifest(context) + write_checksums(temporary) + verify_directory(temporary, tools) + + if output.exists(): + shutil.rmtree(output) + temporary.replace(output) + except BaseException: + shutil.rmtree(temporary, ignore_errors=True) + raise + + print(f"Generated playback fixtures: {output}") + + +def list_fixtures() -> None: + for fixture in PRIMARY_FIXTURES: + print(fixture) + print("transcoded-hls/index.m3u8") + print("malformed-truncated.mkv") + print("unsupported.txt") + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + generate_parser = subparsers.add_parser( + "generate", help="generate and verify the complete fixture set" + ) + generate_parser.add_argument( + "--output", "-o", type=Path, default=DEFAULT_OUTPUT + ) + generate_parser.add_argument( + "--force", action="store_true", help="replace a marked fixture directory" + ) + generate_parser.add_argument( + "--font", + help="redistributable TrueType font to embed (defaults to DejaVu Sans)", + ) + + verify_parser = subparsers.add_parser( + "verify", help="validate streams, metadata, structure, and checksums" + ) + verify_parser.add_argument( + "--output", "-o", type=Path, default=DEFAULT_OUTPUT + ) + + subparsers.add_parser("list", help="print the generated fixture paths") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv if argv is not None else sys.argv[1:]) + try: + if args.command == "generate": + generate(args.output, force=args.force, font=args.font) + elif args.command == "verify": + verify_directory( + args.output.expanduser().resolve(), + discover_tools(generation=False), + ) + else: + list_fixtures() + except FixtureError as error: + print(f"native playback fixture error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qa/test_native_playback_fixtures.py b/scripts/qa/test_native_playback_fixtures.py new file mode 100755 index 00000000..ad78d0b5 --- /dev/null +++ b/scripts/qa/test_native_playback_fixtures.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import contextlib +import importlib.util +import io +import os +import stat +import struct +import sys +import tempfile +import threading +import unittest +import urllib.error +import urllib.request +from pathlib import Path + + +def load_module(name: str, filename: str): + path = Path(__file__).with_name(filename) + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +fixture_server = load_module( + "native_playback_fixture_server", "native_playback_fixture_server.py" +) +fixture_generator = load_module( + "native_playback_fixtures", "native_playback_fixtures.py" +) + + +class NativePlaybackFixtureTest(unittest.TestCase): + def test_byte_ranges_cover_media_client_forms(self) -> None: + self.assertEqual( + fixture_server.ByteRange(10, 29), + fixture_server.parse_byte_range("bytes=10-29", 100), + ) + self.assertEqual( + fixture_server.ByteRange(90, 99), + fixture_server.parse_byte_range("bytes=-10", 100), + ) + self.assertEqual( + fixture_server.ByteRange(95, 99), + fixture_server.parse_byte_range("bytes=95-", 100), + ) + self.assertEqual( + fixture_server.ByteRange(95, 99), + fixture_server.parse_byte_range("bytes=95-200", 100), + ) + + def test_byte_ranges_reject_ambiguous_or_unsatisfiable_input(self) -> None: + for value in ( + "bytes=", + "bytes=100-101", + "bytes=20-10", + "bytes=0-1,4-5", + "items=0-1", + "bytes=-0", + ): + with self.subTest(value=value): + with self.assertRaises(ValueError): + fixture_server.parse_byte_range(value, 100) + + def test_port_file_is_private_and_replaced_atomically(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "ready/port" + fixture_server.write_port_file(path, 43210) + + self.assertEqual("43210\n", path.read_text(encoding="utf-8")) + if os.name != "nt": + self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode)) + self.assertEqual([], list(path.parent.glob(".*.tmp-*"))) + + def test_fixture_server_enforces_auth_and_exact_ranges(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + payload = bytes(range(256)) + (root / "fixture.bin").write_bytes(payload) + server = fixture_server.FixtureServer( + ("127.0.0.1", 0), root, "test-secret", "bearer" + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + log = io.StringIO() + with contextlib.redirect_stderr(log): + thread.start() + base = f"http://127.0.0.1:{server.server_port}/fixture.bin" + try: + with self.assertRaises(urllib.error.HTTPError) as error: + urllib.request.urlopen(base, timeout=2) + self.assertEqual(401, error.exception.code) + error.exception.close() + + request = urllib.request.Request( + base, + headers={ + "Authorization": "Bearer test-secret", + "Range": "bytes=10-29", + }, + ) + with urllib.request.urlopen( + request, timeout=2 + ) as response: + self.assertEqual(206, response.status) + self.assertEqual("bytes 10-29/256", response.headers["Content-Range"]) + self.assertEqual(payload[10:30], response.read()) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + self.assertNotIn("test-secret", log.getvalue()) + + def test_query_ticket_is_not_retained_in_server_log(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "fixture.bin").write_bytes(b"fixture") + server = fixture_server.FixtureServer( + ("127.0.0.1", 0), root, "query-secret", "query" + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + log = io.StringIO() + with contextlib.redirect_stderr(log): + thread.start() + url = ( + f"http://127.0.0.1:{server.server_port}/fixture.bin" + "?access_token=query-secret" + ) + try: + with urllib.request.urlopen(url, timeout=2) as response: + self.assertEqual(b"fixture", response.read()) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + retained_log = log.getvalue() + self.assertNotIn("query-secret", retained_log) + self.assertNotIn("access_token", retained_log) + + def test_generated_pgs_has_owned_epoch_and_clear_sequences(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "bitmap.sup" + fixture_generator.write_pgs(path) + data = path.read_bytes() + + segments: list[tuple[int, int, bytes]] = [] + offset = 0 + while offset < len(data): + self.assertEqual(b"PG", data[offset : offset + 2]) + pts, _dts, kind, length = struct.unpack( + ">IIBH", data[offset + 2 : offset + 13] + ) + start = offset + 13 + end = start + length + self.assertLessEqual(end, len(data)) + segments.append((pts, kind, data[start:end])) + offset = end + + self.assertEqual(len(data), offset) + self.assertEqual( + [0x16, 0x17, 0x14, 0x15, 0x80, 0x16, 0x80], + [kind for _pts, kind, _payload in segments], + ) + self.assertEqual(90_000, segments[0][0]) + self.assertEqual(270_000, segments[-1][0]) + self.assertEqual(1, segments[0][2][10]) # one composition object + self.assertEqual(0, segments[-2][2][-1]) # clear composition + + +if __name__ == "__main__": + unittest.main() From 5bf2fb09f099c0b7109f46c333c0a371dd707a49 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:13:18 -0600 Subject: [PATCH 03/56] refactor(player): define the backend-neutral playback contract --- .../src/contract/channel.rs | 384 ++++++ .../src/contract/mod.rs | 22 + .../src/contract/model.rs | 1041 +++++++++++++++ .../src/contract/policy.rs | 295 +++++ .../src/contract/reducer.rs | 438 +++++++ crates/ferrex-player-playback/src/session.rs | 805 ++++++++++++ .../src/subwave_adapter.rs | 1156 +++++++++++++++++ 7 files changed, 4141 insertions(+) create mode 100644 crates/ferrex-player-playback/src/contract/channel.rs create mode 100644 crates/ferrex-player-playback/src/contract/mod.rs create mode 100644 crates/ferrex-player-playback/src/contract/model.rs create mode 100644 crates/ferrex-player-playback/src/contract/policy.rs create mode 100644 crates/ferrex-player-playback/src/contract/reducer.rs create mode 100644 crates/ferrex-player-playback/src/session.rs create mode 100644 crates/ferrex-player-playback/src/subwave_adapter.rs diff --git a/crates/ferrex-player-playback/src/contract/channel.rs b/crates/ferrex-player-playback/src/contract/channel.rs new file mode 100644 index 00000000..d43ed87e --- /dev/null +++ b/crates/ferrex-player-playback/src/contract/channel.rs @@ -0,0 +1,384 @@ +use std::{ + hash::{Hash, Hasher}, + sync::{ + Arc, Mutex, + mpsc::{self, Receiver, Sender, TryRecvError}, + }, +}; + +use super::{ + EventSequence, PlaybackCommand, PlaybackEvent, PlaybackEventEnvelope, + PlaybackSnapshot, Reduction, SessionGeneration, reduce_event, +}; + +/// Coalesced backend-event readiness source for an application subscription. +/// +/// The signal carries no backend payload. Consumers wake, then drain owned +/// Ferrex events/snapshots through the normal session boundary. +#[derive(Clone)] +pub struct PlaybackEventSignal { + generation: SessionGeneration, + receiver: Arc>>, +} + +impl PlaybackEventSignal { + #[cfg(any(feature = "mpv", test))] + pub(crate) fn new( + generation: SessionGeneration, + receiver: Receiver<()>, + ) -> Self { + Self { + generation, + receiver: Arc::new(Mutex::new(receiver)), + } + } + + pub const fn generation(&self) -> SessionGeneration { + self.generation + } + + pub(crate) fn wait_blocking(&self) -> bool { + self.receiver + .lock() + .is_ok_and(|receiver| receiver.recv().is_ok()) + } +} + +impl std::fmt::Debug for PlaybackEventSignal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PlaybackEventSignal") + .field("generation", &self.generation) + .finish_non_exhaustive() + } +} + +impl PartialEq for PlaybackEventSignal { + fn eq(&self, other: &Self) -> bool { + self.generation == other.generation + && Arc::ptr_eq(&self.receiver, &other.receiver) + } +} + +impl Eq for PlaybackEventSignal {} + +impl Hash for PlaybackEventSignal { + fn hash(&self, state: &mut H) { + self.generation.hash(state); + Arc::as_ptr(&self.receiver).hash(state); + } +} + +/// Application-side command/event channel owner for one playback generation. +pub struct PlaybackController { + generation: SessionGeneration, + command_tx: Sender, + event_rx: Receiver, + shutdown_sent: bool, +} + +impl std::fmt::Debug for PlaybackController { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PlaybackController") + .field("generation", &self.generation) + .field("shutdown_sent", &self.shutdown_sent) + .finish_non_exhaustive() + } +} + +/// Backend-side channel endpoint. Exactly one serialized backend owner should +/// hold this value and assign event sequence numbers through [`Self::emit`]. +pub struct PlaybackBackendEndpoint { + generation: SessionGeneration, + next_sequence: EventSequence, + command_rx: Receiver, + event_tx: Sender, +} + +impl std::fmt::Debug for PlaybackBackendEndpoint { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PlaybackBackendEndpoint") + .field("generation", &self.generation) + .field("next_sequence", &self.next_sequence) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PlaybackControllerError { + #[error("playback backend command channel is closed")] + BackendClosed, + #[error("playback controller is shutting down")] + ShuttingDown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum BackendChannelError { + #[error("playback application event channel is closed")] + ApplicationClosed, + #[error("playback event sequence is exhausted")] + SequenceExhausted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DrainReport { + pub applied: usize, + pub ignored_stale_generation: usize, + pub ignored_duplicate_or_out_of_order: usize, + pub disconnected: bool, +} + +/// Create the two owned endpoints for one playback session. +/// +/// Commands and events are unbounded, non-blocking sends. The backend owner is +/// responsible for draining commands serially; the application drains copied +/// events into its snapshot. Dropping the controller best-effort sends one +/// [`PlaybackCommand::Shutdown`]. +pub fn playback_channel( + generation: SessionGeneration, +) -> (PlaybackController, PlaybackBackendEndpoint) { + let (command_tx, command_rx) = mpsc::channel(); + let (event_tx, event_rx) = mpsc::channel(); + + ( + PlaybackController { + generation, + command_tx, + event_rx, + shutdown_sent: false, + }, + PlaybackBackendEndpoint { + generation, + next_sequence: EventSequence::FIRST, + command_rx, + event_tx, + }, + ) +} + +impl PlaybackController { + pub const fn generation(&self) -> SessionGeneration { + self.generation + } + + pub fn send( + &self, + command: PlaybackCommand, + ) -> Result<(), PlaybackControllerError> { + if self.shutdown_sent { + return Err(PlaybackControllerError::ShuttingDown); + } + + self.command_tx + .send(command) + .map_err(|_| PlaybackControllerError::BackendClosed) + } + + /// Request ordered backend shutdown. Calling this more than once is safe. + pub fn shutdown(&mut self) -> Result<(), PlaybackControllerError> { + if self.shutdown_sent { + return Ok(()); + } + self.command_tx + .send(PlaybackCommand::Shutdown) + .map_err(|_| PlaybackControllerError::BackendClosed)?; + self.shutdown_sent = true; + Ok(()) + } + + /// Drain every currently queued event into `snapshot` without blocking. + pub fn drain_into( + &mut self, + snapshot: &mut PlaybackSnapshot, + ) -> DrainReport { + let mut report = DrainReport::default(); + + loop { + match self.event_rx.try_recv() { + Ok(envelope) => match reduce_event(snapshot, envelope) { + Reduction::Applied => report.applied += 1, + Reduction::IgnoredStaleGeneration => { + report.ignored_stale_generation += 1; + } + Reduction::IgnoredDuplicateOrOutOfOrder => { + report.ignored_duplicate_or_out_of_order += 1; + } + }, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + report.disconnected = true; + break; + } + } + } + + report + } +} + +impl Drop for PlaybackController { + fn drop(&mut self) { + if !self.shutdown_sent { + let _ = self.command_tx.send(PlaybackCommand::Shutdown); + self.shutdown_sent = true; + } + } +} + +impl PlaybackBackendEndpoint { + pub const fn generation(&self) -> SessionGeneration { + self.generation + } + + /// Receive the next queued command without blocking. + pub fn try_recv(&self) -> Result, TryRecvError> { + match self.command_rx.try_recv() { + Ok(command) => Ok(Some(command)), + Err(TryRecvError::Empty) => Ok(None), + Err(error @ TryRecvError::Disconnected) => Err(error), + } + } + + /// Copy and sequence one backend event for application-side reduction. + pub fn emit( + &mut self, + event: PlaybackEvent, + ) -> Result { + let sequence = self.next_sequence; + self.next_sequence = sequence + .next() + .ok_or(BackendChannelError::SequenceExhausted)?; + + self.event_tx + .send(PlaybackEventEnvelope { + generation: self.generation, + sequence, + event, + }) + .map_err(|_| BackendChannelError::ApplicationClosed)?; + + Ok(sequence) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::contract::{ + PlaybackCapabilities, PlaybackState, PlaybackTarget, + }; + + fn snapshot(generation: SessionGeneration) -> PlaybackSnapshot { + PlaybackSnapshot::new( + generation, + PlaybackTarget::GSTREAMER_EMBEDDED, + PlaybackCapabilities::default(), + ) + } + + #[test] + fn event_signal_is_cloneable_and_ends_when_backend_disconnects() { + let (sender, receiver) = mpsc::sync_channel(1); + let signal = + PlaybackEventSignal::new(SessionGeneration::new(9), receiver); + assert_eq!(signal, signal.clone()); + assert_eq!(signal.generation(), SessionGeneration::new(9)); + + sender.try_send(()).unwrap(); + assert!(signal.wait_blocking()); + drop(sender); + assert!(!signal.wait_blocking()); + } + + #[test] + fn fake_backend_receives_commands_in_order() { + let generation = SessionGeneration::INITIAL; + let (controller, backend) = playback_channel(generation); + + controller.send(PlaybackCommand::SetPaused(true)).unwrap(); + controller + .send(PlaybackCommand::SeekAbsolute(Duration::from_secs(42))) + .unwrap(); + + assert_eq!( + backend.try_recv().unwrap(), + Some(PlaybackCommand::SetPaused(true)) + ); + assert_eq!( + backend.try_recv().unwrap(), + Some(PlaybackCommand::SeekAbsolute(Duration::from_secs(42))) + ); + assert_eq!(backend.try_recv().unwrap(), None); + } + + #[test] + fn fake_backend_events_reduce_into_one_snapshot() { + let generation = SessionGeneration::INITIAL; + let (mut controller, mut backend) = playback_channel(generation); + let mut snapshot = snapshot(generation); + + backend + .emit(PlaybackEvent::StateChanged(PlaybackState::Playing)) + .unwrap(); + backend + .emit(PlaybackEvent::PositionChanged(Duration::from_secs(12))) + .unwrap(); + + let report = controller.drain_into(&mut snapshot); + + assert_eq!(report.applied, 2); + assert!(!report.disconnected); + assert_eq!(snapshot.state, PlaybackState::Playing); + assert_eq!(snapshot.position, Duration::from_secs(12)); + assert_eq!(snapshot.last_sequence, Some(EventSequence::new(2))); + } + + #[test] + fn explicit_shutdown_is_idempotent() { + let (mut controller, backend) = + playback_channel(SessionGeneration::INITIAL); + + controller.shutdown().unwrap(); + controller.shutdown().unwrap(); + + assert_eq!( + backend.try_recv().unwrap(), + Some(PlaybackCommand::Shutdown) + ); + assert_eq!(backend.try_recv().unwrap(), None); + drop(controller); + } + + #[test] + fn failed_shutdown_is_not_reported_as_complete() { + let (mut controller, backend) = + playback_channel(SessionGeneration::INITIAL); + drop(backend); + + assert_eq!( + controller.shutdown(), + Err(PlaybackControllerError::BackendClosed) + ); + assert_eq!( + controller.shutdown(), + Err(PlaybackControllerError::BackendClosed) + ); + } + + #[test] + fn drop_requests_shutdown() { + let (controller, backend) = + playback_channel(SessionGeneration::INITIAL); + + drop(controller); + + assert_eq!( + backend.try_recv().unwrap(), + Some(PlaybackCommand::Shutdown) + ); + } +} diff --git a/crates/ferrex-player-playback/src/contract/mod.rs b/crates/ferrex-player-playback/src/contract/mod.rs new file mode 100644 index 00000000..7ce346b2 --- /dev/null +++ b/crates/ferrex-player-playback/src/contract/mod.rs @@ -0,0 +1,22 @@ +//! Backend-neutral playback contracts. +//! +//! This module is owned by Ferrex. Backend adapters translate these commands +//! and events to Subwave, libmpv, or an external player without exposing their +//! native types to player policy or UI code. + +mod channel; +mod model; +mod policy; +mod reducer; + +pub use channel::{ + BackendChannelError, DrainReport, PlaybackBackendEndpoint, + PlaybackController, PlaybackControllerError, PlaybackEventSignal, + playback_channel, +}; +pub use model::*; +pub use policy::{ + BackendCandidate, BackendRequest, FallbackPolicy, PlaybackRequirements, + SelectionDecision, select_backend, +}; +pub use reducer::{Reduction, reduce_event}; diff --git a/crates/ferrex-player-playback/src/contract/model.rs b/crates/ferrex-player-playback/src/contract/model.rs new file mode 100644 index 00000000..753ee13f --- /dev/null +++ b/crates/ferrex-player-playback/src/contract/model.rs @@ -0,0 +1,1041 @@ +use std::{ + fmt, + path::{Path, PathBuf}, + time::Duration, +}; + +use serde::Serialize; +use url::Url; +use zeroize::Zeroize; + +/// Monotonically increasing identity for a playback session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SessionGeneration(u64); + +impl SessionGeneration { + pub const INITIAL: Self = Self(1); + + pub const fn new(value: u64) -> Self { + Self(value) + } + + pub const fn get(self) -> u64 { + self.0 + } + + pub fn next(self) -> Option { + self.0.checked_add(1).map(Self) + } +} + +/// Ordering assigned by the serialized backend event owner. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EventSequence(u64); + +impl EventSequence { + pub const FIRST: Self = Self(1); + + pub const fn new(value: u64) -> Self { + Self(value) + } + + pub const fn get(self) -> u64 { + self.0 + } + + pub fn next(self) -> Option { + self.0.checked_add(1).map(Self) + } +} + +/// A value that must not reveal its contents through normal diagnostics. +#[derive(Clone, PartialEq, Eq)] +pub struct SensitiveValue(String); + +impl SensitiveValue { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Deliberately expose this value to a backend adapter. + pub fn expose_secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SensitiveValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +impl Drop for SensitiveValue { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PlaybackHttpHeader { + pub name: String, + pub value: SensitiveValue, +} + +impl PlaybackHttpHeader { + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: SensitiveValue::new(value), + } + } +} + +impl fmt::Debug for PlaybackHttpHeader { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PlaybackHttpHeader") + .field("name", &self.name) + .field("value", &self.value) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PlaybackCookie { + pub name: String, + pub value: SensitiveValue, +} + +impl PlaybackCookie { + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: SensitiveValue::new(value), + } + } +} + +impl fmt::Debug for PlaybackCookie { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PlaybackCookie") + .field("name", &self.name) + .field("value", &self.value) + .finish() + } +} + +/// Media source plus authentication material. +/// +/// `Debug` intentionally omits the path, query, fragment, header values, and +/// cookie values. Adapters must pass this structure directly instead of +/// reconstructing a URL in a loggable command line. +#[derive(Clone, PartialEq, Eq)] +pub struct PlaybackSource { + uri: Url, + title: Option, + headers: Vec, + cookies: Vec, +} + +impl PlaybackSource { + pub fn new(uri: Url) -> Self { + Self { + uri, + title: None, + headers: Vec::new(), + cookies: Vec::new(), + } + } + + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn with_header( + mut self, + name: impl Into, + value: impl Into, + ) -> Self { + self.headers.push(PlaybackHttpHeader::new(name, value)); + self + } + + pub fn with_cookie( + mut self, + name: impl Into, + value: impl Into, + ) -> Self { + self.cookies.push(PlaybackCookie::new(name, value)); + self + } + + pub fn uri(&self) -> &Url { + &self.uri + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub fn headers(&self) -> &[PlaybackHttpHeader] { + &self.headers + } + + pub fn cookies(&self) -> &[PlaybackCookie] { + &self.cookies + } + + fn redacted_uri(&self) -> String { + match self.uri.host() { + Some(host) => { + let port = self + .uri + .port() + .map(|port| format!(":{port}")) + .unwrap_or_default(); + format!("{}://{host}{port}/", self.uri.scheme()) + } + None => format!("{}:", self.uri.scheme()), + } + } +} + +impl fmt::Debug for PlaybackSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PlaybackSource") + .field("uri", &self.redacted_uri()) + .field("title", &self.title) + .field("headers", &self.headers) + .field("cookies", &self.cookies) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendKind { + GStreamer, + Mpv, + ExternalMpv, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PresentationMode { + /// Native video surface integrated with the Iced host. + IntegratedNative, + /// Decoded frames presented by the legacy Iced/wgpu path. + EmbeddedFrames, + /// Backend-owned ordinary top-level window. + NativeWindow, + /// Window owned by a separate player process. + ExternalWindow, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +pub struct PlaybackTarget { + pub backend: BackendKind, + pub presentation: PresentationMode, +} + +impl PlaybackTarget { + pub const GSTREAMER_INTEGRATED: Self = Self { + backend: BackendKind::GStreamer, + presentation: PresentationMode::IntegratedNative, + }; + pub const GSTREAMER_EMBEDDED: Self = Self { + backend: BackendKind::GStreamer, + presentation: PresentationMode::EmbeddedFrames, + }; + pub const MPV_INTEGRATED: Self = Self { + backend: BackendKind::Mpv, + presentation: PresentationMode::IntegratedNative, + }; + pub const MPV_NATIVE_WINDOW: Self = Self { + backend: BackendKind::Mpv, + presentation: PresentationMode::NativeWindow, + }; + pub const EXTERNAL_MPV: Self = Self { + backend: BackendKind::ExternalMpv, + presentation: PresentationMode::ExternalWindow, + }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaybackContentFit { + Contain, + Cover, + Fill, + None, + ScaleDown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DurationDelta { + Forward(Duration), + Backward(Duration), +} + +impl DurationDelta { + pub fn from_seconds(seconds: f64) -> Option { + if !seconds.is_finite() { + return None; + } + + let duration = Duration::try_from_secs_f64(seconds.abs()).ok()?; + Some(if seconds.is_sign_negative() { + Self::Backward(duration) + } else { + Self::Forward(duration) + }) + } + + pub const fn magnitude(self) -> Duration { + match self { + Self::Forward(duration) | Self::Backward(duration) => duration, + } + } + + pub fn as_seconds_f64(self) -> f64 { + match self { + Self::Forward(duration) => duration.as_secs_f64(), + Self::Backward(duration) => -duration.as_secs_f64(), + } + } +} + +/// Local path passed intentionally to a playback backend. +/// +/// Debug output is redacted because home-directory components and filenames +/// can contain private account or media information. Backends must likewise +/// avoid copying the raw path into normal logs or diagnostics. +#[derive(Clone, PartialEq, Eq)] +pub struct PlaybackFilePath(PathBuf); + +impl PlaybackFilePath { + pub fn new(path: impl Into) -> Self { + Self(path.into()) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } +} + +impl fmt::Debug for PlaybackFilePath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +/// Name of a backend video profile selected explicitly by the user. +/// +/// Profile names are omitted from normal debug output because they originate +/// in trusted user configuration and are not needed in issue diagnostics. +#[derive(Clone, PartialEq, Eq)] +pub struct VideoProfileName(String); + +impl VideoProfileName { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for VideoProfileName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +/// Portion of native video output included in a screenshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaybackScreenshotMode { + /// Video after scaling/color processing, without rendered subtitles. + VideoOnly, + /// Video plus backend-rendered subtitles. OSD inclusion remains a + /// backend/video-output detail; use `Window` when it is required. + VideoWithSubtitles, + /// Complete native playback window when the backend supports it. + Window, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PlaybackCommand { + Load(PlaybackSource), + SetPaused(bool), + SeekAbsolute(Duration), + SeekRelative(DurationDelta), + SetVolume(f64), + SetMuted(bool), + SetSpeed(f64), + SelectAudio(TrackId), + SelectSubtitle(Option), + /// Add a local sidecar subtitle to the current media generation. + AddExternalSubtitle { + source: PlaybackFilePath, + select: bool, + }, + SelectChapter(ChapterId), + SelectEdition(EditionId), + SetContentFit(PlaybackContentFit), + SetFullscreen(bool), + /// Apply a named backend video profile. Capability-gated because user + /// profiles may depend on explicitly trusted backend configuration. + ApplyVideoProfile(VideoProfileName), + /// Replace the ordered local shader chain used by native video output. + SetVideoShaders(Vec), + /// Capture one native-output screenshot at an explicit local path. + CaptureScreenshot { + output: PlaybackFilePath, + mode: PlaybackScreenshotMode, + }, + Stop, + /// Terminate the backend owner after ordered stop/cleanup. + Shutdown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PlaybackState { + Idle, + Loading, + Playing, + Paused, + Buffering, + Seeking, + Stopping, + Ended, + Failed, + Terminated, +} + +impl PlaybackState { + pub const fn is_active(self) -> bool { + matches!( + self, + Self::Loading + | Self::Playing + | Self::Paused + | Self::Buffering + | Self::Seeking + | Self::Stopping + ) + } +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct BufferState { + pub buffering: bool, + pub percentage: Option, + pub cached_duration: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TrackId(String); + +impl TrackId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for TrackId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AudioTrack { + pub id: TrackId, + pub title: Option, + pub language: Option, + pub codec: Option, + pub channels: Option, + pub sample_rate: Option, + pub is_default: bool, + pub is_forced: bool, +} + +impl AudioTrack { + pub fn display_name(&self) -> &str { + self.title + .as_deref() + .or(self.language.as_deref()) + .unwrap_or(self.id.as_str()) + } +} + +impl fmt::Display for AudioTrack { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.display_name()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubtitleKind { + Text, + Bitmap, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubtitleTrack { + pub id: TrackId, + pub title: Option, + pub language: Option, + pub codec: Option, + pub kind: SubtitleKind, + pub is_default: bool, + pub is_forced: bool, + pub is_external: bool, +} + +impl SubtitleTrack { + pub fn display_name(&self) -> &str { + self.title + .as_deref() + .or(self.language.as_deref()) + .unwrap_or(self.id.as_str()) + } +} + +impl fmt::Display for SubtitleTrack { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.display_name()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TrackCatalog { + pub audio: Vec, + pub subtitles: Vec, + pub selected_audio: Option, + pub selected_subtitle: Option, +} + +impl TrackCatalog { + /// Drop selections that do not exist in this catalog. + pub fn normalize_selections(&mut self) { + if self.selected_audio.as_ref().is_some_and(|selected| { + !self.audio.iter().any(|track| &track.id == selected) + }) { + self.selected_audio = None; + } + + if self.selected_subtitle.as_ref().is_some_and(|selected| { + !self.subtitles.iter().any(|track| &track.id == selected) + }) { + self.selected_subtitle = None; + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ChapterId(String); + +impl ChapterId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Chapter { + pub id: ChapterId, + pub title: Option, + pub start: Duration, + pub end: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EditionId(String); + +impl EditionId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edition { + pub id: EditionId, + pub title: Option, + pub is_default: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct VideoParameters { + pub width: Option, + pub height: Option, + pub codec: Option, + pub pixel_format: Option, + pub bit_depth: Option, + pub color_primaries: Option, + pub color_transfer: Option, + pub color_matrix: Option, + pub hardware_decoder: Option, + pub hdr_metadata_observed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct PlaybackCapabilities { + pub seek: bool, + pub audio_track_selection: bool, + pub subtitle_track_selection: bool, + pub external_subtitle_loading: bool, + pub chapter_selection: bool, + pub edition_selection: bool, + pub speed: bool, + pub content_fit: bool, + pub fullscreen: bool, + pub screenshot: bool, + pub video_shader_passthrough: bool, + pub video_profile_passthrough: bool, + pub integrated_presentation: bool, + pub native_window_fallback: bool, + pub native_hdr: bool, + pub fractional_scaling: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndReason { + Eof, + Stopped, + Replaced, + /// The backend-owned native window requested an orderly quit. + Closed, + /// The backend core terminated without an earlier, more specific reason. + BackendTerminated, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PlaybackErrorKind { + InvalidSource, + Authentication, + UnsupportedMedia, + UnsupportedOperation, + BackendUnavailable, + BackendInitialization, + Command, + Presenter, + Protocol, + Shutdown, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, thiserror::Error)] +#[error("{kind:?}: {message}")] +pub struct PlaybackError { + pub kind: PlaybackErrorKind, + pub code: Option, + pub message: String, + pub recoverable: bool, + pub backend: Option, +} + +impl PlaybackError { + pub fn new(kind: PlaybackErrorKind, message: impl Into) -> Self { + Self { + kind, + code: None, + message: message.into(), + recoverable: false, + backend: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FallbackReasonCode { + RequestedUnavailable, + MissingCapability, + BackendDisabled, + RuntimeIncompatible, + InitializationFailed, + PresenterFailed, + UnsupportedPlatform, + Policy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FallbackReason { + pub code: FallbackReasonCode, + pub from: Option, + pub to: PlaybackTarget, + pub detail: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PresenterState { + Detached, + AwaitingHost, + AwaitingVideoOutput, + Attached, + Hidden, + Suspended, + Failed, +} + +/// Monotonically increasing host geometry revision. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, +)] +pub struct GeometryRevision(u64); + +impl GeometryRevision { + /// First valid geometry revision. + pub const INITIAL: Self = Self(1); + + /// Construct a geometry revision. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Return the numeric revision. + pub const fn get(self) -> u64 { + self.0 + } + + /// Advance without wrapping. + pub fn next(self) -> Option { + self.0.checked_add(1).map(Self) + } +} + +/// Axis-aligned rectangle in host logical coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct LogicalRect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl LogicalRect { + /// Construct a logical rectangle. + pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { + x, + y, + width, + height, + } + } + + fn is_finite(self) -> bool { + self.x.is_finite() + && self.y.is_finite() + && self.width.is_finite() + && self.height.is_finite() + } + + fn has_nonnegative_extent(self) -> bool { + self.width >= 0.0 && self.height >= 0.0 + } + + fn has_area(self) -> bool { + self.width > 0.0 && self.height > 0.0 + } +} + +/// Validated geometry reserved by the host layout for native video. +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct SurfaceGeometry { + /// Revision assigned by the host layout/redraw owner. + pub revision: GeometryRevision, + /// Complete logical slot bounds. + pub logical_bounds: LogicalRect, + /// Slot after inherited clipping; `None` means fully clipped. + pub visible_bounds: Option, + /// Logical-to-physical host scale. + pub scale_factor: f64, +} + +impl SurfaceGeometry { + /// Construct one geometry observation. + pub const fn new( + revision: GeometryRevision, + logical_bounds: LogicalRect, + visible_bounds: Option, + scale_factor: f64, + ) -> Self { + Self { + revision, + logical_bounds, + visible_bounds, + scale_factor, + } + } + + /// Validate finite coordinates, nonnegative extents, and positive scale. + pub fn validate(self) -> Result<(), SurfaceGeometryError> { + if !self.logical_bounds.is_finite() + || self + .visible_bounds + .is_some_and(|bounds| !bounds.is_finite()) + { + return Err(SurfaceGeometryError::NonFiniteBounds); + } + if !self.logical_bounds.has_nonnegative_extent() + || self + .visible_bounds + .is_some_and(|bounds| !bounds.has_nonnegative_extent()) + { + return Err(SurfaceGeometryError::NegativeExtent); + } + if !self.scale_factor.is_finite() || self.scale_factor <= 0.0 { + return Err(SurfaceGeometryError::InvalidScale); + } + Ok(()) + } + + /// Whether the slot has nonzero logical and clipped area. + pub fn is_visible(self) -> bool { + self.validate().is_ok() + && self.logical_bounds.has_area() + && self.visible_bounds.is_some_and(LogicalRect::has_area) + } + + pub(crate) fn same_layout(self, other: Self) -> bool { + self.logical_bounds == other.logical_bounds + && self.visible_bounds == other.visible_bounds + && self.scale_factor == other.scale_factor + } +} + +/// Invalid host geometry that cannot cross into a native API safely. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum SurfaceGeometryError { + #[error("presenter geometry contains a non-finite coordinate")] + NonFiniteBounds, + #[error("presenter geometry contains a negative extent")] + NegativeExtent, + #[error("presenter scale factor must be finite and positive")] + InvalidScale, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PresenterEvent { + StateChanged(PresenterState), + /// Latest accepted host geometry, or `None` after host detach/loss. + GeometryChanged(Option), + FullscreenChanged(bool), + Failure(PlaybackError), + FallbackRequested(FallbackReason), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PlaybackEvent { + StateChanged(PlaybackState), + PositionChanged(Duration), + DurationChanged(Option), + BufferChanged(BufferState), + TracksChanged(TrackCatalog), + ChaptersChanged(Vec), + ChapterChanged(Option), + EditionsChanged(Vec), + EditionChanged(Option), + VideoParametersChanged(Option), + CapabilitiesChanged(PlaybackCapabilities), + VolumeChanged(f64), + MutedChanged(bool), + SpeedChanged(f64), + ContentFitChanged(PlaybackContentFit), + FullscreenChanged(bool), + Ended(EndReason), + Error(PlaybackError), + Presenter(PresenterEvent), + Fallback(FallbackReason), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PlaybackEventEnvelope { + pub generation: SessionGeneration, + pub sequence: EventSequence, + pub event: PlaybackEvent, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PlaybackSnapshot { + pub generation: SessionGeneration, + pub last_sequence: Option, + pub target: PlaybackTarget, + pub state: PlaybackState, + pub position: Duration, + pub duration: Option, + pub buffer: BufferState, + pub tracks: TrackCatalog, + pub chapters: Vec, + pub current_chapter: Option, + pub editions: Vec, + pub current_edition: Option, + pub video: Option, + pub capabilities: PlaybackCapabilities, + pub volume: f64, + pub muted: bool, + pub speed: f64, + pub content_fit: PlaybackContentFit, + pub fullscreen: bool, + pub presenter: PresenterState, + pub presenter_geometry: Option, + pub end_reason: Option, + pub last_error: Option, + pub last_fallback: Option, + pub fallback_chain: Vec, +} + +impl PlaybackSnapshot { + pub fn new( + generation: SessionGeneration, + target: PlaybackTarget, + capabilities: PlaybackCapabilities, + ) -> Self { + Self { + generation, + last_sequence: None, + target, + state: PlaybackState::Idle, + position: Duration::ZERO, + duration: None, + buffer: BufferState::default(), + tracks: TrackCatalog::default(), + chapters: Vec::new(), + current_chapter: None, + editions: Vec::new(), + current_edition: None, + video: None, + capabilities, + volume: 1.0, + muted: false, + speed: 1.0, + content_fit: PlaybackContentFit::Contain, + fullscreen: false, + presenter: PresenterState::Detached, + presenter_geometry: None, + end_reason: None, + last_error: None, + last_fallback: None, + fallback_chain: Vec::new(), + } + } + + pub const fn is_playing(&self) -> bool { + matches!(self.state, PlaybackState::Playing) + } + + pub const fn is_paused(&self) -> bool { + matches!(self.state, PlaybackState::Paused) + } + + pub const fn has_active_session(&self) -> bool { + self.state.is_active() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_extension_inputs_are_redacted_from_debug_output() { + let path = PlaybackFilePath::new( + "/home/private-user/Videos/private-title.screenshot.png", + ); + let profile = VideoProfileName::new("private-user-profile"); + let command = PlaybackCommand::CaptureScreenshot { + output: path.clone(), + mode: PlaybackScreenshotMode::VideoWithSubtitles, + }; + + assert_eq!(format!("{path:?}"), ""); + assert_eq!(format!("{profile:?}"), ""); + let debug = format!("{command:?}"); + assert!(debug.contains("CaptureScreenshot")); + assert!(!debug.contains("private-user")); + assert!(!debug.contains("private-title")); + } + + #[test] + fn source_debug_redacts_all_authentication_material() { + let source = PlaybackSource::new( + Url::parse( + "https://user:password@example.test/private/media-id?access_token=query-secret#fragment", + ) + .unwrap(), + ) + .with_header("Authorization", "Bearer header-secret") + .with_cookie("session", "cookie-secret"); + + let debug = format!("{source:?}"); + + assert!(debug.contains("example.test")); + assert!(debug.contains("Authorization")); + assert!(debug.contains("session")); + for secret in [ + "user", + "password", + "private", + "media-id", + "access_token", + "query-secret", + "fragment", + "header-secret", + "cookie-secret", + ] { + assert!(!debug.contains(secret), "debug output leaked {secret}"); + } + } + + #[test] + fn pause_intent_is_not_inferred_from_transient_non_playing_states() { + let mut snapshot = PlaybackSnapshot::new( + SessionGeneration::INITIAL, + PlaybackTarget::MPV_NATIVE_WINDOW, + PlaybackCapabilities::default(), + ); + + for state in [ + PlaybackState::Loading, + PlaybackState::Buffering, + PlaybackState::Seeking, + ] { + snapshot.state = state; + assert!(!snapshot.is_paused()); + } + snapshot.state = PlaybackState::Paused; + assert!(snapshot.is_paused()); + } + + #[test] + fn signed_duration_rejects_non_finite_values() { + assert!(DurationDelta::from_seconds(f64::NAN).is_none()); + assert!(DurationDelta::from_seconds(f64::INFINITY).is_none()); + assert_eq!( + DurationDelta::from_seconds(-2.5).unwrap(), + DurationDelta::Backward(Duration::from_millis(2_500)) + ); + } +} diff --git a/crates/ferrex-player-playback/src/contract/policy.rs b/crates/ferrex-player-playback/src/contract/policy.rs new file mode 100644 index 00000000..ccfeccc4 --- /dev/null +++ b/crates/ferrex-player-playback/src/contract/policy.rs @@ -0,0 +1,295 @@ +use std::collections::HashSet; + +use serde::Serialize; + +use super::{ + FallbackReason, FallbackReasonCode, PlaybackError, PlaybackErrorKind, + PlaybackTarget, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendRequest { + Auto, + Exact(PlaybackTarget), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PlaybackRequirements { + /// Reject presentation paths that cannot preserve native HDR signaling. + pub native_hdr: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendCandidate { + pub target: PlaybackTarget, + pub available: bool, + pub native_hdr: bool, + pub unavailable_reason: Option, +} + +impl BackendCandidate { + pub const fn available(target: PlaybackTarget, native_hdr: bool) -> Self { + Self { + target, + available: true, + native_hdr, + unavailable_reason: None, + } + } + + pub const fn unavailable( + target: PlaybackTarget, + reason: FallbackReasonCode, + ) -> Self { + Self { + target, + available: false, + native_hdr: false, + unavailable_reason: Some(reason), + } + } +} + +/// Ordered rollout and fallback policy. Availability is supplied separately so +/// policy remains deterministic and straightforward to test. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FallbackPolicy { + pub auto_order: Vec, + pub fallback_order: Vec, + pub allow_explicit_fallback: bool, +} + +impl FallbackPolicy { + /// Current migration policy: keep GStreamer ahead of every mpv mode. + pub fn migration_default() -> Self { + Self { + auto_order: vec![ + PlaybackTarget::GSTREAMER_INTEGRATED, + PlaybackTarget::GSTREAMER_EMBEDDED, + ], + fallback_order: vec![ + PlaybackTarget::MPV_NATIVE_WINDOW, + PlaybackTarget::GSTREAMER_INTEGRATED, + PlaybackTarget::GSTREAMER_EMBEDDED, + PlaybackTarget::EXTERNAL_MPV, + ], + allow_explicit_fallback: true, + } + } + + /// Require the exact target requested by the caller. + pub fn strict() -> Self { + Self { + auto_order: Vec::new(), + fallback_order: Vec::new(), + allow_explicit_fallback: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionDecision { + pub selected: PlaybackTarget, + pub fallback: Option, +} + +/// Select a backend without probing global process state or mutating defaults. +/// +/// For HDR-required content, candidates lacking native HDR signaling are +/// rejected rather than silently selecting an SDR frame-upload path. +pub fn select_backend( + request: BackendRequest, + requirements: PlaybackRequirements, + policy: &FallbackPolicy, + candidates: &[BackendCandidate], +) -> Result { + let mut ordered = match request { + BackendRequest::Auto => policy.auto_order.clone(), + BackendRequest::Exact(target) => vec![target], + }; + + if matches!(request, BackendRequest::Auto) || policy.allow_explicit_fallback + { + ordered.extend(policy.fallback_order.iter().copied()); + } + + let mut seen = HashSet::new(); + ordered.retain(|target| seen.insert(*target)); + + let requested_target = ordered.first().copied(); + let mut first_rejection = None; + + for target in ordered { + let rejection = match candidates + .iter() + .find(|candidate| candidate.target == target) + { + None => Some(( + FallbackReasonCode::RequestedUnavailable, + "target was not reported by runtime capability discovery", + )), + Some(candidate) if !candidate.available => Some(( + candidate + .unavailable_reason + .unwrap_or(FallbackReasonCode::RequestedUnavailable), + "target is unavailable in this runtime", + )), + Some(candidate) + if requirements.native_hdr && !candidate.native_hdr => + { + Some(( + FallbackReasonCode::MissingCapability, + "target cannot preserve required native HDR signaling", + )) + } + Some(_) => None, + }; + + if let Some(rejection) = rejection { + first_rejection.get_or_insert(rejection); + continue; + } + + let fallback = requested_target + .filter(|requested| *requested != target) + .map(|requested| { + let (code, detail) = first_rejection.unwrap_or(( + FallbackReasonCode::Policy, + "selected by fallback policy", + )); + FallbackReason { + code, + from: Some(requested), + to: target, + detail: detail.to_string(), + } + }); + + return Ok(SelectionDecision { + selected: target, + fallback, + }); + } + + let mut error = PlaybackError::new( + PlaybackErrorKind::BackendUnavailable, + if requirements.native_hdr { + "no available playback target satisfies native HDR requirements" + } else { + "no playback target is available" + }, + ); + error.recoverable = true; + error.backend = requested_target.map(|target| target.backend); + Err(error) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_auto_keeps_gstreamer_as_default() { + let candidates = [ + BackendCandidate::available( + PlaybackTarget::GSTREAMER_EMBEDDED, + false, + ), + BackendCandidate::available( + PlaybackTarget::MPV_NATIVE_WINDOW, + true, + ), + ]; + + let decision = select_backend( + BackendRequest::Auto, + PlaybackRequirements::default(), + &FallbackPolicy::migration_default(), + &candidates, + ) + .unwrap(); + + assert_eq!(decision.selected, PlaybackTarget::GSTREAMER_EMBEDDED); + assert!(decision.fallback.is_some()); + assert_eq!( + decision.fallback.unwrap().from, + Some(PlaybackTarget::GSTREAMER_INTEGRATED) + ); + } + + #[test] + fn integrated_mpv_failure_falls_back_to_native_window() { + let candidates = [ + BackendCandidate::unavailable( + PlaybackTarget::MPV_INTEGRATED, + FallbackReasonCode::PresenterFailed, + ), + BackendCandidate::available( + PlaybackTarget::MPV_NATIVE_WINDOW, + true, + ), + ]; + + let decision = select_backend( + BackendRequest::Exact(PlaybackTarget::MPV_INTEGRATED), + PlaybackRequirements::default(), + &FallbackPolicy::migration_default(), + &candidates, + ) + .unwrap(); + + assert_eq!(decision.selected, PlaybackTarget::MPV_NATIVE_WINDOW); + assert_eq!( + decision.fallback.unwrap().code, + FallbackReasonCode::PresenterFailed + ); + } + + #[test] + fn hdr_requirement_never_selects_sdr_frame_upload_fallback() { + let candidates = [ + BackendCandidate::available( + PlaybackTarget::GSTREAMER_EMBEDDED, + false, + ), + BackendCandidate::available( + PlaybackTarget::MPV_NATIVE_WINDOW, + true, + ), + ]; + + let decision = select_backend( + BackendRequest::Exact(PlaybackTarget::GSTREAMER_EMBEDDED), + PlaybackRequirements { native_hdr: true }, + &FallbackPolicy::migration_default(), + &candidates, + ) + .unwrap(); + + assert_eq!(decision.selected, PlaybackTarget::MPV_NATIVE_WINDOW); + assert_eq!( + decision.fallback.unwrap().code, + FallbackReasonCode::MissingCapability + ); + } + + #[test] + fn strict_request_reports_unavailable_instead_of_falling_back() { + let candidates = [BackendCandidate::unavailable( + PlaybackTarget::MPV_INTEGRATED, + FallbackReasonCode::UnsupportedPlatform, + )]; + + let error = select_backend( + BackendRequest::Exact(PlaybackTarget::MPV_INTEGRATED), + PlaybackRequirements::default(), + &FallbackPolicy::strict(), + &candidates, + ) + .unwrap_err(); + + assert_eq!(error.kind, PlaybackErrorKind::BackendUnavailable); + assert_eq!(error.backend, Some(super::super::BackendKind::Mpv)); + } +} diff --git a/crates/ferrex-player-playback/src/contract/reducer.rs b/crates/ferrex-player-playback/src/contract/reducer.rs new file mode 100644 index 00000000..2f224dac --- /dev/null +++ b/crates/ferrex-player-playback/src/contract/reducer.rs @@ -0,0 +1,438 @@ +use super::{ + PlaybackEvent, PlaybackEventEnvelope, PlaybackSnapshot, PlaybackState, + PresenterEvent, PresenterState, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reduction { + Applied, + IgnoredStaleGeneration, + IgnoredDuplicateOrOutOfOrder, +} + +/// Apply one ordered, generation-scoped backend event to the player snapshot. +/// +/// Adapters may emit duplicate events, and late callbacks may survive session +/// replacement. This reducer rejects both without mutating the snapshot. +pub fn reduce_event( + snapshot: &mut PlaybackSnapshot, + envelope: PlaybackEventEnvelope, +) -> Reduction { + if envelope.generation != snapshot.generation { + return Reduction::IgnoredStaleGeneration; + } + + if snapshot + .last_sequence + .is_some_and(|last| envelope.sequence <= last) + { + return Reduction::IgnoredDuplicateOrOutOfOrder; + } + + snapshot.last_sequence = Some(envelope.sequence); + + match envelope.event { + PlaybackEvent::StateChanged(state) => { + snapshot.state = state; + if !matches!(state, PlaybackState::Ended) { + snapshot.end_reason = None; + } + if !matches!(state, PlaybackState::Failed) { + snapshot.last_error = None; + } + } + PlaybackEvent::PositionChanged(position) => { + snapshot.position = position; + } + PlaybackEvent::DurationChanged(duration) => { + snapshot.duration = duration; + } + PlaybackEvent::BufferChanged(buffer) => { + snapshot.buffer = buffer; + } + PlaybackEvent::TracksChanged(mut tracks) => { + tracks.normalize_selections(); + snapshot.tracks = tracks; + } + PlaybackEvent::ChaptersChanged(chapters) => { + snapshot.chapters = chapters; + if snapshot.current_chapter.as_ref().is_some_and(|selected| { + !snapshot + .chapters + .iter() + .any(|chapter| &chapter.id == selected) + }) { + snapshot.current_chapter = None; + } + } + PlaybackEvent::ChapterChanged(chapter) => { + snapshot.current_chapter = chapter.filter(|selected| { + snapshot + .chapters + .iter() + .any(|chapter| &chapter.id == selected) + }); + } + PlaybackEvent::EditionsChanged(editions) => { + snapshot.editions = editions; + if snapshot.current_edition.as_ref().is_some_and(|selected| { + !snapshot + .editions + .iter() + .any(|edition| &edition.id == selected) + }) { + snapshot.current_edition = None; + } + } + PlaybackEvent::EditionChanged(edition) => { + snapshot.current_edition = edition.filter(|selected| { + snapshot + .editions + .iter() + .any(|edition| &edition.id == selected) + }); + } + PlaybackEvent::VideoParametersChanged(video) => { + snapshot.video = video; + } + PlaybackEvent::CapabilitiesChanged(capabilities) => { + snapshot.capabilities = capabilities; + } + PlaybackEvent::VolumeChanged(volume) => { + snapshot.volume = volume.clamp(0.0, 1.0); + } + PlaybackEvent::MutedChanged(muted) => { + snapshot.muted = muted; + } + PlaybackEvent::SpeedChanged(speed) => { + if speed.is_finite() && speed > 0.0 { + snapshot.speed = speed; + } + } + PlaybackEvent::ContentFitChanged(content_fit) => { + snapshot.content_fit = content_fit; + } + PlaybackEvent::FullscreenChanged(fullscreen) => { + snapshot.fullscreen = fullscreen; + } + PlaybackEvent::Ended(reason) => { + snapshot.state = PlaybackState::Ended; + snapshot.end_reason = Some(reason); + } + PlaybackEvent::Error(error) => { + snapshot.state = PlaybackState::Failed; + snapshot.last_error = Some(error); + } + PlaybackEvent::Presenter(event) => match event { + PresenterEvent::StateChanged(state) => { + snapshot.presenter = state; + } + PresenterEvent::GeometryChanged(geometry) => { + snapshot.presenter_geometry = geometry; + } + PresenterEvent::FullscreenChanged(fullscreen) => { + snapshot.fullscreen = fullscreen; + } + PresenterEvent::Failure(error) => { + snapshot.presenter = PresenterState::Failed; + snapshot.last_error = Some(error); + } + PresenterEvent::FallbackRequested(reason) => { + record_fallback(snapshot, reason, false); + } + }, + PlaybackEvent::Fallback(reason) => { + record_fallback(snapshot, reason, true); + } + } + + Reduction::Applied +} + +fn record_fallback( + snapshot: &mut PlaybackSnapshot, + reason: super::FallbackReason, + update_target: bool, +) { + if update_target { + snapshot.target = reason.to; + } + snapshot.last_fallback = Some(reason.clone()); + if snapshot.fallback_chain.last() != Some(&reason) { + snapshot.fallback_chain.push(reason); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::contract::{ + AudioTrack, Chapter, ChapterId, Edition, EditionId, EventSequence, + FallbackReason, FallbackReasonCode, GeometryRevision, LogicalRect, + PlaybackCapabilities, PlaybackEvent, PlaybackEventEnvelope, + PlaybackTarget, PresenterEvent, SessionGeneration, SubtitleKind, + SubtitleTrack, SurfaceGeometry, TrackCatalog, TrackId, VideoParameters, + }; + + fn snapshot() -> PlaybackSnapshot { + PlaybackSnapshot::new( + SessionGeneration::new(7), + PlaybackTarget::GSTREAMER_EMBEDDED, + PlaybackCapabilities::default(), + ) + } + + fn event(sequence: u64, event: PlaybackEvent) -> PlaybackEventEnvelope { + PlaybackEventEnvelope { + generation: SessionGeneration::new(7), + sequence: EventSequence::new(sequence), + event, + } + } + + #[test] + fn duplicate_and_out_of_order_events_are_ignored() { + let mut snapshot = snapshot(); + + assert_eq!( + reduce_event( + &mut snapshot, + event( + 3, + PlaybackEvent::PositionChanged(Duration::from_secs(30)) + ) + ), + Reduction::Applied + ); + assert_eq!( + reduce_event( + &mut snapshot, + event( + 3, + PlaybackEvent::PositionChanged(Duration::from_secs(99)) + ) + ), + Reduction::IgnoredDuplicateOrOutOfOrder + ); + assert_eq!( + reduce_event( + &mut snapshot, + event( + 2, + PlaybackEvent::PositionChanged(Duration::from_secs(10)) + ) + ), + Reduction::IgnoredDuplicateOrOutOfOrder + ); + + assert_eq!(snapshot.position, Duration::from_secs(30)); + assert_eq!(snapshot.last_sequence, Some(EventSequence::new(3))); + } + + #[test] + fn events_from_replaced_sessions_are_ignored() { + let mut snapshot = snapshot(); + let stale = PlaybackEventEnvelope { + generation: SessionGeneration::new(6), + sequence: EventSequence::FIRST, + event: PlaybackEvent::StateChanged(PlaybackState::Playing), + }; + + assert_eq!( + reduce_event(&mut snapshot, stale), + Reduction::IgnoredStaleGeneration + ); + assert_eq!(snapshot.state, PlaybackState::Idle); + assert_eq!(snapshot.last_sequence, None); + } + + #[test] + fn missing_optional_values_clear_stale_snapshot_data() { + let mut snapshot = snapshot(); + snapshot.duration = Some(Duration::from_secs(100)); + snapshot.video = Some(VideoParameters { + width: Some(1920), + height: Some(1080), + ..VideoParameters::default() + }); + + reduce_event( + &mut snapshot, + event(1, PlaybackEvent::DurationChanged(None)), + ); + reduce_event( + &mut snapshot, + event(2, PlaybackEvent::VideoParametersChanged(None)), + ); + + assert_eq!(snapshot.duration, None); + assert_eq!(snapshot.video, None); + } + + #[test] + fn track_selection_is_identity_based_and_normalized_on_reload() { + let mut snapshot = snapshot(); + let audio_id = TrackId::new("audio:eng:main"); + let subtitle_id = TrackId::new("subtitle:eng:forced"); + let missing_id = TrackId::new("subtitle:stale-index"); + let catalog = TrackCatalog { + audio: vec![AudioTrack { + id: audio_id.clone(), + title: Some("Main".into()), + language: Some("eng".into()), + codec: Some("aac".into()), + channels: Some(2), + sample_rate: Some(48_000), + is_default: true, + is_forced: false, + }], + subtitles: vec![SubtitleTrack { + id: subtitle_id, + title: Some("Forced".into()), + language: Some("eng".into()), + codec: Some("ass".into()), + kind: SubtitleKind::Text, + is_default: false, + is_forced: true, + is_external: false, + }], + selected_audio: Some(audio_id.clone()), + selected_subtitle: Some(missing_id), + }; + + reduce_event( + &mut snapshot, + event(1, PlaybackEvent::TracksChanged(catalog)), + ); + + assert_eq!(snapshot.tracks.selected_audio, Some(audio_id)); + assert_eq!(snapshot.tracks.selected_subtitle, None); + } + + #[test] + fn chapter_and_edition_selection_is_normalized_against_owned_catalogs() { + let mut snapshot = snapshot(); + let chapter_id = ChapterId::new("chapter:opening"); + let edition_id = EditionId::new("edition:extended"); + + reduce_event( + &mut snapshot, + event( + 1, + PlaybackEvent::ChaptersChanged(vec![Chapter { + id: chapter_id.clone(), + title: Some("Opening".to_string()), + start: Duration::ZERO, + end: None, + }]), + ), + ); + reduce_event( + &mut snapshot, + event( + 2, + PlaybackEvent::EditionsChanged(vec![Edition { + id: edition_id.clone(), + title: Some("Extended".to_string()), + is_default: true, + }]), + ), + ); + reduce_event( + &mut snapshot, + event(3, PlaybackEvent::ChapterChanged(Some(chapter_id.clone()))), + ); + reduce_event( + &mut snapshot, + event(4, PlaybackEvent::EditionChanged(Some(edition_id.clone()))), + ); + + assert_eq!(snapshot.current_chapter, Some(chapter_id)); + assert_eq!(snapshot.current_edition, Some(edition_id)); + + reduce_event( + &mut snapshot, + event(5, PlaybackEvent::ChaptersChanged(Vec::new())), + ); + reduce_event( + &mut snapshot, + event(6, PlaybackEvent::EditionsChanged(Vec::new())), + ); + assert_eq!(snapshot.current_chapter, None); + assert_eq!(snapshot.current_edition, None); + } + + #[test] + fn presenter_geometry_and_fallback_chain_remain_backend_neutral() { + let mut snapshot = snapshot(); + snapshot.target = PlaybackTarget::MPV_INTEGRATED; + let geometry = SurfaceGeometry::new( + GeometryRevision::new(4), + LogicalRect::new(10.0, 20.0, 1280.0, 720.0), + Some(LogicalRect::new(10.0, 20.0, 1280.0, 700.0)), + 1.5, + ); + let presenter_fallback = FallbackReason { + code: FallbackReasonCode::PresenterFailed, + from: Some(PlaybackTarget::MPV_INTEGRATED), + to: PlaybackTarget::MPV_NATIVE_WINDOW, + detail: "overlay attachment failed".to_string(), + }; + let backend_fallback = FallbackReason { + code: FallbackReasonCode::InitializationFailed, + from: Some(PlaybackTarget::MPV_NATIVE_WINDOW), + to: PlaybackTarget::GSTREAMER_EMBEDDED, + detail: "native window failed to initialize".to_string(), + }; + + reduce_event( + &mut snapshot, + event( + 1, + PlaybackEvent::Presenter(PresenterEvent::GeometryChanged( + Some(geometry), + )), + ), + ); + reduce_event( + &mut snapshot, + event( + 2, + PlaybackEvent::Presenter(PresenterEvent::FallbackRequested( + presenter_fallback.clone(), + )), + ), + ); + assert_eq!(snapshot.target, PlaybackTarget::MPV_INTEGRATED); + + // Selection confirmation updates the target without duplicating the + // already recorded presenter transition. + reduce_event( + &mut snapshot, + event(3, PlaybackEvent::Fallback(presenter_fallback.clone())), + ); + reduce_event( + &mut snapshot, + event(4, PlaybackEvent::Fallback(backend_fallback.clone())), + ); + + assert_eq!(snapshot.presenter_geometry, Some(geometry)); + assert_eq!(snapshot.target, PlaybackTarget::GSTREAMER_EMBEDDED); + assert_eq!( + snapshot.fallback_chain, + vec![presenter_fallback, backend_fallback.clone()] + ); + assert_eq!(snapshot.last_fallback, Some(backend_fallback)); + + reduce_event( + &mut snapshot, + event( + 5, + PlaybackEvent::Presenter(PresenterEvent::GeometryChanged(None)), + ), + ); + assert_eq!(snapshot.presenter_geometry, None); + } +} diff --git a/crates/ferrex-player-playback/src/session.rs b/crates/ferrex-player-playback/src/session.rs new file mode 100644 index 00000000..bca38010 --- /dev/null +++ b/crates/ferrex-player-playback/src/session.rs @@ -0,0 +1,805 @@ +//! Backend-neutral playback session handle used by player state and views. + +use std::time::Duration; + +#[cfg(feature = "mpv")] +use std::ops::{Deref, DerefMut}; + +#[cfg(all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos") +))] +use crate::contract::PresenterEvent; +#[cfg(all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos") +))] +use crate::native_presentation::NativePresentation; +use crate::{ + contract::{ + BackendRequest, ChapterId, EditionId, PlaybackCommand, PlaybackError, + PlaybackEventSignal, PlaybackFilePath, PlaybackScreenshotMode, + PlaybackSnapshot, TrackCatalog, TrackId, VideoProfileName, + }, + diagnostics::PlaybackDiagnosticSnapshot, + subwave_adapter::SubwavePlaybackAdapter, +}; +#[cfg(feature = "mpv")] +use crate::{ + contract::{FallbackReason, PlaybackErrorKind, PlaybackTarget}, + mpv_adapter::MpvPlaybackAdapter, +}; + +/// Active in-process playback session. +/// +/// Backend variants remain private so domain and view code cannot branch on a +/// concrete engine. Capability and target differences are read from +/// [`PlaybackSnapshot`]. +pub struct PlaybackSession { + requested_backend: BackendRequest, + backend: BackendSession, +} + +enum BackendSession { + Subwave(Box), + #[cfg(feature = "mpv")] + Mpv(Box), +} + +/// Preflight result that chooses the mpv option set before its worker starts. +/// A live platform presenter is retained here only on supported UI targets. +#[cfg(feature = "mpv")] +pub(crate) struct MpvPresentationPlan { + target: PlaybackTarget, + fallback: Option, + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + presentation: Option, +} + +#[cfg(feature = "mpv")] +impl MpvPresentationPlan { + pub(crate) const fn target(&self) -> PlaybackTarget { + self.target + } +} + +#[cfg(feature = "mpv")] +struct MpvBackendSession { + adapter: Box, + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + presentation: Option, +} + +#[cfg(feature = "mpv")] +impl Deref for MpvBackendSession { + type Target = MpvPlaybackAdapter; + + fn deref(&self) -> &Self::Target { + &self.adapter + } +} + +#[cfg(feature = "mpv")] +impl DerefMut for MpvBackendSession { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.adapter + } +} + +#[cfg(feature = "mpv")] +impl MpvBackendSession { + fn new(adapter: MpvPlaybackAdapter, plan: MpvPresentationPlan) -> Self { + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + let mut adapter = adapter; + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + let presentation = plan.presentation; + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + if let Some(presentation) = presentation.as_ref() { + adapter.configure_integrated_presentation( + &presentation.capabilities(), + ); + } + + #[cfg(not(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + )))] + let _ = plan; + + Self { + adapter: Box::new(adapter), + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + presentation, + } + } + + fn apply_command( + &mut self, + command: PlaybackCommand, + ) -> Result<(), PlaybackError> { + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + { + if let PlaybackCommand::SetFullscreen(fullscreen) = &command + && self.presentation.is_some() + && self.adapter.snapshot().target + == crate::contract::PlaybackTarget::MPV_INTEGRATED + { + self.presentation + .as_ref() + .expect("checked") + .request_fullscreen(*fullscreen); + self.drain_presenter_state()?; + return Ok(()); + } + if matches!(&command, PlaybackCommand::Load(_)) + && let Some(presentation) = self.presentation.as_ref() + { + presentation.begin_media_load(); + } + if matches!(&command, PlaybackCommand::Shutdown) + && let Some(presentation) = self.presentation.as_ref() + { + presentation.detach(); + } + } + + self.adapter.apply_command(command) + } + + fn poll_events(&mut self) { + self.adapter.poll_events(); + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + if let Err(error) = self.synchronize_presenter() { + log::warn!("Could not synchronize native presenter: {error}"); + } + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + fn synchronize_presenter(&mut self) -> Result<(), PlaybackError> { + if let Some(presentation) = self.presentation.as_ref() { + presentation.synchronize_native_output( + self.adapter.native_window_id(), + self.adapter.vo_configured(), + self.adapter.native_video_output_started(), + self.adapter.snapshot().fullscreen, + ); + } + self.drain_presenter_state() + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + fn drain_presenter_state(&mut self) -> Result<(), PlaybackError> { + let Some(presentation) = self.presentation.as_ref() else { + return Ok(()); + }; + + if let Some(fullscreen) = presentation.take_fullscreen_request() { + self.adapter + .apply_command(PlaybackCommand::SetFullscreen(fullscreen))?; + } + + let events = presentation.drain_events(); + let mut fallback = false; + for event in events { + let fallback_reason = match &event { + PresenterEvent::FallbackRequested(reason) => { + Some(reason.clone()) + } + _ => None, + }; + self.adapter + .record_event(crate::contract::PlaybackEvent::Presenter(event)); + if let Some(reason) = fallback_reason { + if reason.to == PlaybackTarget::MPV_NATIVE_WINDOW { + self.adapter.commit_native_window_fallback(reason); + } else { + self.adapter.record_event( + crate::contract::PlaybackEvent::Fallback(reason), + ); + } + fallback = true; + } + } + if fallback { + presentation.detach(); + self.presentation = None; + } + Ok(()) + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + fn fail_host_capture( + &mut self, + window_id: iced::window::Id, + detail: String, + ) { + if let Some(presentation) = self.presentation.as_ref() { + presentation.fail_host_capture(window_id, detail); + let _ = self.drain_presenter_state(); + } + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + fn refresh_presenter(&mut self) -> Result<(), PlaybackError> { + if let Some(presentation) = self.presentation.as_ref() { + presentation.refresh_platform_window(); + } + self.drain_presenter_state() + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + fn set_presenter_host_visible( + &mut self, + visible: bool, + ) -> Result { + let Some(presentation) = self.presentation.as_ref() else { + return Ok(false); + }; + presentation.set_host_visible(visible); + self.drain_presenter_state()?; + Ok(self.presentation.is_some() + && self.adapter.snapshot().target == PlaybackTarget::MPV_INTEGRATED + && (!visible + || matches!( + self.adapter.snapshot().presenter, + crate::contract::PresenterState::Attached + | crate::contract::PresenterState::Hidden + | crate::contract::PresenterState::Suspended + ))) + } +} + +impl std::fmt::Debug for PlaybackSession { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PlaybackSession") + .field("requested_backend", &self.requested_backend) + .field("snapshot", self.snapshot()) + .finish_non_exhaustive() + } +} + +impl PlaybackSession { + /// Resolve integrated presenter availability before libmpv receives its + /// immutable startup option set. A failed preflight therefore starts the + /// ordinary native-window path with OSC and native input enabled. + #[cfg(feature = "mpv")] + pub(crate) fn preflight_mpv_presentation( + requested_target: PlaybackTarget, + generation: crate::contract::SessionGeneration, + ) -> MpvPresentationPlan { + if requested_target != PlaybackTarget::MPV_INTEGRATED { + return MpvPresentationPlan { + target: requested_target, + fallback: None, + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + presentation: None, + }; + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + { + return match NativePresentation::try_new(generation) { + Ok(presentation) => MpvPresentationPlan { + target: requested_target, + fallback: None, + presentation: Some(presentation), + }, + Err(reason) => MpvPresentationPlan { + target: PlaybackTarget::MPV_NATIVE_WINDOW, + fallback: Some(reason), + presentation: None, + }, + }; + } + + #[cfg(not(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + )))] + { + let _ = generation; + MpvPresentationPlan { + target: PlaybackTarget::MPV_NATIVE_WINDOW, + fallback: Some(FallbackReason { + code: crate::contract::FallbackReasonCode::UnsupportedPlatform, + from: Some(PlaybackTarget::MPV_INTEGRATED), + to: PlaybackTarget::MPV_NATIVE_WINDOW, + detail: "integrated mpv presentation is unavailable on this UI target" + .to_owned(), + }), + } + } + } + + pub(crate) fn from_subwave( + adapter: SubwavePlaybackAdapter, + requested_backend: BackendRequest, + ) -> Self { + Self { + requested_backend, + backend: BackendSession::Subwave(Box::new(adapter)), + } + } + + #[cfg(feature = "mpv")] + pub(crate) fn from_mpv( + mut adapter: MpvPlaybackAdapter, + requested_backend: BackendRequest, + mut plan: MpvPresentationPlan, + ) -> Self { + debug_assert_eq!(adapter.snapshot().target, plan.target); + if let Some(reason) = plan.fallback.take() { + adapter.record_fallback(reason); + } + Self { + requested_backend, + backend: BackendSession::Mpv(Box::new(MpvBackendSession::new( + adapter, plan, + ))), + } + } + + pub fn snapshot(&self) -> &PlaybackSnapshot { + match &self.backend { + BackendSession::Subwave(adapter) => adapter.snapshot(), + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => adapter.snapshot(), + } + } + + /// Redacted backend, lifecycle, version, and native-output observations. + pub fn diagnostics(&self) -> PlaybackDiagnosticSnapshot { + match &self.backend { + BackendSession::Subwave(adapter) => { + adapter.diagnostics(self.requested_backend) + } + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => { + adapter.diagnostics(self.requested_backend) + } + } + } + + pub fn apply_command( + &mut self, + command: PlaybackCommand, + ) -> Result<(), PlaybackError> { + match &mut self.backend { + BackendSession::Subwave(adapter) => adapter.apply_command(command), + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => adapter.apply_command(command), + } + } + + pub fn synchronize_snapshot(&mut self) { + match &mut self.backend { + BackendSession::Subwave(adapter) => { + adapter.synchronize_core_properties(); + } + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => adapter.poll_events(), + } + } + + pub fn refresh_tracks(&mut self) -> TrackCatalog { + match &mut self.backend { + BackendSession::Subwave(adapter) => adapter.refresh_tracks(), + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => adapter.refresh_tracks(), + } + } + + pub fn select_audio_track( + &mut self, + track_id: &TrackId, + ) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::SelectAudio(track_id.clone())) + } + + pub fn select_subtitle_track( + &mut self, + track_id: Option<&TrackId>, + ) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::SelectSubtitle(track_id.cloned())) + } + + /// Add a local sidecar subtitle to the current playback generation. + /// `select=true` requests immediate selection; `false` leaves selection to + /// the backend's automatic track policy. + pub fn add_external_subtitle( + &mut self, + source: PlaybackFilePath, + select: bool, + ) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::AddExternalSubtitle { + source, + select, + }) + } + + pub fn select_chapter( + &mut self, + chapter_id: &ChapterId, + ) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::SelectChapter(chapter_id.clone())) + } + + pub fn select_edition( + &mut self, + edition_id: &EditionId, + ) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::SelectEdition(edition_id.clone())) + } + + pub fn set_subtitles_enabled(&mut self, enabled: bool) { + match &mut self.backend { + BackendSession::Subwave(adapter) => { + adapter.set_subtitles_enabled(enabled); + } + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => { + adapter.set_subtitles_enabled(enabled); + } + } + } + + pub fn subtitles_enabled(&self) -> bool { + match &self.backend { + BackendSession::Subwave(adapter) => adapter.subtitles_enabled(), + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => adapter.subtitles_enabled(), + } + } + + pub fn set_paused(&mut self, paused: bool) { + if let Err(error) = + self.apply_command(PlaybackCommand::SetPaused(paused)) + { + log::error!("Failed to set playback pause state: {error}"); + } + } + + pub fn paused(&self) -> bool { + self.snapshot().is_paused() + } + + pub fn position(&mut self) -> Duration { + self.synchronize_snapshot(); + self.snapshot().position + } + + pub fn duration(&mut self) -> Duration { + self.synchronize_snapshot(); + self.snapshot().duration.unwrap_or(Duration::ZERO) + } + + pub fn seek( + &mut self, + position: Duration, + _accurate: bool, + ) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::SeekAbsolute(position)) + } + + pub fn set_volume(&mut self, volume: f64) { + if let Err(error) = + self.apply_command(PlaybackCommand::SetVolume(volume)) + { + log::error!("Failed to set playback volume: {error}"); + } + } + + pub fn set_muted(&mut self, muted: bool) { + if let Err(error) = self.apply_command(PlaybackCommand::SetMuted(muted)) + { + log::error!("Failed to set playback mute state: {error}"); + } + } + + pub fn set_speed(&mut self, speed: f64) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::SetSpeed(speed)) + } + + /// Apply a capability-gated named video profile. User-defined mpv profiles + /// require the explicit trusted-user configuration policy. + pub fn apply_video_profile( + &mut self, + profile: VideoProfileName, + ) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::ApplyVideoProfile(profile)) + } + + /// Replace the ordered native-video shader chain. Unsupported backends + /// return [`crate::contract::PlaybackErrorKind::UnsupportedOperation`]. + pub fn set_video_shaders( + &mut self, + shaders: Vec, + ) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::SetVideoShaders(shaders)) + } + + /// Capture one native-output screenshot at an explicit local destination. + pub fn capture_screenshot( + &mut self, + output: PlaybackFilePath, + mode: PlaybackScreenshotMode, + ) -> Result<(), PlaybackError> { + self.apply_command(PlaybackCommand::CaptureScreenshot { output, mode }) + } + + pub fn has_video(&self) -> bool { + match &self.backend { + BackendSession::Subwave(adapter) => adapter.has_video(), + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => adapter.has_video(), + } + } + + /// Legacy Subwave diagnostic retained during migration. This does not + /// represent a generic playback capability. + pub fn is_appsink(&self) -> bool { + match &self.backend { + BackendSession::Subwave(adapter) => adapter.is_appsink(), + #[cfg(feature = "mpv")] + BackendSession::Mpv(_) => false, + } + } + + pub fn uses_wayland_surface(&self) -> bool { + match &self.backend { + BackendSession::Subwave(adapter) => adapter.uses_wayland_surface(), + #[cfg(feature = "mpv")] + BackendSession::Mpv(_) => false, + } + } + + pub fn toggle_diagnostic_backend(&mut self) -> Result<(), PlaybackError> { + match &mut self.backend { + BackendSession::Subwave(adapter) => { + adapter.toggle_diagnostic_backend() + } + #[cfg(feature = "mpv")] + BackendSession::Mpv(_) => Err(PlaybackError::new( + PlaybackErrorKind::Command, + "Subwave diagnostic backend toggle is unavailable for mpv", + )), + } + } + + pub fn force_appsink(&mut self) -> Result<(), PlaybackError> { + match &mut self.backend { + BackendSession::Subwave(adapter) => adapter.force_appsink(), + #[cfg(feature = "mpv")] + BackendSession::Mpv(_) => Err(PlaybackError::new( + PlaybackErrorKind::Command, + "Subwave appsink mode is unavailable for mpv", + )), + } + } + + /// Backend-neutral readiness signal for copied asynchronous events. + pub fn event_signal(&self) -> Option { + match &self.backend { + BackendSession::Subwave(_) => None, + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => Some(adapter.event_signal()), + } + } + + /// Whether snapshot changes arrive through a push signal instead of the + /// migration-only bounded legacy synchronization timer. + pub fn uses_event_driven_snapshots(&self) -> bool { + match &self.backend { + BackendSession::Subwave(_) => false, + #[cfg(feature = "mpv")] + BackendSession::Mpv(_) => true, + } + } + + /// Convert a failed Iced raw-host capture into the normal presenter + /// failure/fallback transition for this session generation. + pub fn native_host_capture_failed( + &mut self, + window_id: iced::window::Id, + detail: String, + ) { + #[cfg(all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + if let BackendSession::Mpv(adapter) = &mut self.backend { + adapter.fail_host_capture(window_id, detail); + } + + #[cfg(not(all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos") + )))] + let _ = (window_id, detail); + } + + /// Re-query a platform-owned native video root from the UI thread. This is + /// intentionally separate from decoded-frame/event polling because window + /// movement and occlusion can change without producing an mpv event. + pub fn refresh_native_presenter(&mut self) { + #[cfg(all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + if let BackendSession::Mpv(adapter) = &mut self.backend + && let Err(error) = adapter.refresh_presenter() + { + log::warn!("Could not refresh native presenter: {error}"); + } + } + + /// Complete the shell-controlled visibility handoff for an integrated + /// native presenter. The presenter attaches while hidden; the shell calls + /// this only after its retained main window has been hidden. + pub fn set_native_presenter_host_visible(&mut self, visible: bool) -> bool { + #[cfg(all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + if let BackendSession::Mpv(adapter) = &mut self.backend { + return match adapter.set_presenter_host_visible(visible) { + Ok(available) => available, + Err(error) => { + log::warn!( + "Could not update native presenter host visibility: {error}" + ); + false + } + }; + } + + let _ = visible; + false + } + + #[cfg(feature = "ui")] + pub fn widget<'a, Theme>( + &'a self, + content_fit: iced::ContentFit, + _native_host_window: Option, + ) -> iced::Element<'a, crate::PlayerMessage, Theme, iced_wgpu::Renderer> + where + Theme: 'a, + { + match &self.backend { + BackendSession::Subwave(adapter) => adapter.widget(content_fit), + #[cfg(feature = "mpv")] + BackendSession::Mpv(adapter) => { + #[cfg(any(target_os = "windows", target_os = "macos"))] + if let (Some(presentation), Some(window_id)) = + (adapter.presentation.as_ref(), _native_host_window) + { + use crate::{ + contract::PresenterState, + native_video_slot::{ + NativeVideoSlot, NativeVideoSlotAppearance, + }, + }; + + let appearance = match adapter.snapshot().presenter { + PresenterState::Attached + | PresenterState::Hidden + | PresenterState::Suspended => { + NativeVideoSlotAppearance::Transparent + } + PresenterState::Failed => { + NativeVideoSlotAppearance::Failed + } + PresenterState::Detached + | PresenterState::AwaitingHost + | PresenterState::AwaitingVideoOutput => { + NativeVideoSlotAppearance::Loading + } + }; + return NativeVideoSlot::new( + presentation.slot_handle(window_id), + crate::PlayerMessage::CaptureNativeVideoHost, + ) + .appearance(appearance) + .on_presenter_update(|| { + crate::PlayerMessage::NativePresenterUpdated + }) + .into(); + } + + let _ = adapter; + iced::widget::Space::new() + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .into() + } + } + } +} + +impl Drop for PlaybackSession { + fn drop(&mut self) { + let _ = self.apply_command(PlaybackCommand::Shutdown); + } +} + +#[cfg(all( + test, + feature = "mpv", + not(any(target_os = "windows", target_os = "macos")) +))] +mod tests { + use super::*; + use crate::contract::SessionGeneration; + + #[test] + fn integrated_preflight_selects_native_controls_before_worker_spawn() { + let plan = PlaybackSession::preflight_mpv_presentation( + PlaybackTarget::MPV_INTEGRATED, + SessionGeneration::new(5), + ); + + assert_eq!(plan.target(), PlaybackTarget::MPV_NATIVE_WINDOW); + let fallback = plan.fallback.expect("unsupported presenter fallback"); + assert_eq!(fallback.from, Some(PlaybackTarget::MPV_INTEGRATED)); + assert_eq!(fallback.to, PlaybackTarget::MPV_NATIVE_WINDOW); + assert_eq!( + fallback.code, + crate::contract::FallbackReasonCode::UnsupportedPlatform + ); + } +} diff --git a/crates/ferrex-player-playback/src/subwave_adapter.rs b/crates/ferrex-player-playback/src/subwave_adapter.rs new file mode 100644 index 00000000..f77d31b7 --- /dev/null +++ b/crates/ferrex-player-playback/src/subwave_adapter.rs @@ -0,0 +1,1156 @@ +//! Subwave/GStreamer adapter behind Ferrex-owned playback models. +//! +//! The adapter is the only module allowed to mention `SubwaveVideo` or +//! Subwave track DTOs. It keeps legacy presentation available while the player +//! update loop moves from direct method calls to the command/event contract. + +use std::{collections::HashMap, time::Duration}; + +#[cfg(feature = "ui")] +use iced::Element; +use subwave_core::video::{ + types::{ + AudioTrack as SubwaveAudioTrack, SubtitleTrack as SubwaveSubtitleTrack, + }, + video_trait::Video as SubwaveVideoTrait, +}; +use subwave_unified::video::{BackendPreference, OpenOptions, SubwaveVideo}; + +use crate::contract::{ + AudioTrack, BackendKind, BackendRequest, DurationDelta, EndReason, + EventSequence, FallbackReason, PlaybackCapabilities, PlaybackCommand, + PlaybackError, PlaybackErrorKind, PlaybackEvent, PlaybackEventEnvelope, + PlaybackSnapshot, PlaybackSource, PlaybackState, PlaybackTarget, + SessionGeneration, SubtitleKind, SubtitleTrack, TrackCatalog, TrackId, + reduce_event, +}; +use crate::diagnostics::{PlaybackDiagnosticSnapshot, redact_playback_url}; + +/// Legacy Subwave provider adapted to Ferrex-owned commands and models. +pub struct SubwavePlaybackAdapter { + video: SubwaveVideo, + snapshot: PlaybackSnapshot, + next_sequence: EventSequence, + audio_indices: HashMap, + subtitle_indices: HashMap, +} + +impl std::fmt::Debug for SubwavePlaybackAdapter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SubwavePlaybackAdapter") + .field("snapshot", &self.snapshot) + .finish_non_exhaustive() + } +} + +/// Narrow command surface between the Ferrex adapter and Subwave. +/// +/// Keeping this seam independent of `SubwaveVideo` lets command translation be +/// verified without initializing GStreamer, a display server, or test media. +trait SubwaveCommandTarget { + fn position(&self) -> Duration; + fn set_paused(&mut self, paused: bool); + fn seek(&mut self, position: Duration) -> Result<(), String>; + fn set_volume(&mut self, volume: f64); + fn set_muted(&mut self, muted: bool); + fn set_speed(&mut self, speed: f64) -> Result<(), String>; + fn select_audio_track(&mut self, index: i32) -> Result<(), String>; + fn select_subtitle_track( + &mut self, + index: Option, + ) -> Result<(), String>; +} + +impl SubwaveCommandTarget for SubwaveVideo { + fn position(&self) -> Duration { + SubwaveVideo::position(self) + } + + fn set_paused(&mut self, paused: bool) { + SubwaveVideo::set_paused(self, paused); + } + + fn seek(&mut self, position: Duration) -> Result<(), String> { + SubwaveVideo::seek(self, position, false) + .map_err(|error| error.to_string()) + } + + fn set_volume(&mut self, volume: f64) { + SubwaveVideo::set_volume(self, volume); + } + + fn set_muted(&mut self, muted: bool) { + SubwaveVideo::set_muted(self, muted); + } + + fn set_speed(&mut self, speed: f64) -> Result<(), String> { + SubwaveVideo::set_speed(self, speed).map_err(|error| error.to_string()) + } + + fn select_audio_track(&mut self, index: i32) -> Result<(), String> { + SubwaveVideo::select_audio_track(self, index) + .map_err(|error| error.to_string()) + } + + fn select_subtitle_track( + &mut self, + index: Option, + ) -> Result<(), String> { + SubwaveVideo::select_subtitle_track(self, index) + .map_err(|error| error.to_string()) + } +} + +#[derive(Debug, Clone, PartialEq)] +enum SubwaveAdapterCommand { + SetPaused(bool), + SeekAbsolute(Duration), + SeekRelative(DurationDelta), + SetVolume(f64), + SetMuted(bool), + SetSpeed(f64), + SelectAudio(i32), + SelectSubtitle(Option), + Stop, + Shutdown, +} + +fn dispatch_subwave_command( + target: &mut impl SubwaveCommandTarget, + command: SubwaveAdapterCommand, +) -> Result, PlaybackError> { + let events = match command { + SubwaveAdapterCommand::SetPaused(paused) => { + target.set_paused(paused); + vec![PlaybackEvent::StateChanged(if paused { + PlaybackState::Paused + } else { + PlaybackState::Playing + })] + } + SubwaveAdapterCommand::SeekAbsolute(position) => { + target.seek(position).map_err(|error| { + backend_error( + PlaybackErrorKind::Command, + "Subwave absolute seek failed", + error, + ) + })?; + vec![PlaybackEvent::PositionChanged(position)] + } + SubwaveAdapterCommand::SeekRelative(delta) => { + let current = target.position(); + let position = match delta { + DurationDelta::Forward(amount) => { + current.saturating_add(amount) + } + DurationDelta::Backward(amount) => { + current.checked_sub(amount).unwrap_or(Duration::ZERO) + } + }; + target.seek(position).map_err(|error| { + backend_error( + PlaybackErrorKind::Command, + "Subwave relative seek failed", + error, + ) + })?; + vec![PlaybackEvent::PositionChanged(position)] + } + SubwaveAdapterCommand::SetVolume(volume) => { + if !volume.is_finite() { + return Err(PlaybackError::new( + PlaybackErrorKind::Command, + "volume must be finite", + )); + } + let volume = volume.clamp(0.0, 1.0); + target.set_volume(volume); + vec![PlaybackEvent::VolumeChanged(volume)] + } + SubwaveAdapterCommand::SetMuted(muted) => { + target.set_muted(muted); + vec![PlaybackEvent::MutedChanged(muted)] + } + SubwaveAdapterCommand::SetSpeed(speed) => { + if !speed.is_finite() || speed <= 0.0 { + return Err(PlaybackError::new( + PlaybackErrorKind::Command, + "playback speed must be finite and positive", + )); + } + target.set_speed(speed).map_err(|error| { + backend_error( + PlaybackErrorKind::Command, + "Subwave speed change failed", + error, + ) + })?; + vec![PlaybackEvent::SpeedChanged(speed)] + } + SubwaveAdapterCommand::SelectAudio(index) => { + target.select_audio_track(index).map_err(|error| { + backend_error( + PlaybackErrorKind::Command, + "Subwave audio-track selection failed", + error, + ) + })?; + Vec::new() + } + SubwaveAdapterCommand::SelectSubtitle(index) => { + target.select_subtitle_track(index).map_err(|error| { + backend_error( + PlaybackErrorKind::Command, + "Subwave subtitle-track selection failed", + error, + ) + })?; + Vec::new() + } + SubwaveAdapterCommand::Stop => { + target.set_paused(true); + vec![ + PlaybackEvent::StateChanged(PlaybackState::Stopping), + PlaybackEvent::Ended(EndReason::Stopped), + ] + } + SubwaveAdapterCommand::Shutdown => { + target.set_paused(true); + vec![PlaybackEvent::StateChanged(PlaybackState::Terminated)] + } + }; + + Ok(events) +} + +impl SubwavePlaybackAdapter { + /// Open a Subwave provider without exposing source credentials in errors or + /// diagnostics. HTTP headers and cookies are passed in-process. + pub fn open( + source: &PlaybackSource, + start: Duration, + generation: SessionGeneration, + ) -> Result { + let mut headers: Vec<(String, String)> = source + .headers() + .iter() + .map(|header| { + ( + header.name.clone(), + header.value.expose_secret().to_string(), + ) + }) + .collect(); + + if !source.cookies().is_empty() { + let cookie = source + .cookies() + .iter() + .map(|cookie| { + format!("{}={}", cookie.name, cookie.value.expose_secret()) + }) + .collect::>() + .join("; "); + headers.push(("Cookie".to_string(), cookie)); + } + + let mut options = OpenOptions::new().start_seconds(start.as_secs_f64()); + if !headers.is_empty() { + options = options.headers(&headers); + } + + let video = + SubwaveVideo::open(source.uri(), options).map_err(|error| { + source_backend_error( + source, + "Subwave failed to initialize the media source", + error, + ) + })?; + let target = target_for_backend(video.backend()); + let capabilities = capabilities_for_target(target); + let snapshot = PlaybackSnapshot::new(generation, target, capabilities); + let mut adapter = Self { + video, + snapshot, + next_sequence: EventSequence::FIRST, + audio_indices: HashMap::new(), + subtitle_indices: HashMap::new(), + }; + adapter.synchronize_core_properties(); + adapter.refresh_tracks(); + Ok(adapter) + } + + pub fn snapshot(&self) -> &PlaybackSnapshot { + &self.snapshot + } + + pub(crate) fn diagnostics( + &self, + requested_backend: BackendRequest, + ) -> PlaybackDiagnosticSnapshot { + PlaybackDiagnosticSnapshot::from_snapshot( + &self.snapshot, + requested_backend, + ) + } + + pub(crate) fn record_fallback(&mut self, reason: FallbackReason) { + self.record(PlaybackEvent::Fallback(reason)); + } + + /// Apply a Ferrex command and reduce resulting copied state into the + /// adapter snapshot. Presentation-only fullscreen remains owned by Iced in + /// this legacy backend, so the event records requested state only. + pub fn apply_command( + &mut self, + command: PlaybackCommand, + ) -> Result<(), PlaybackError> { + let command = match command { + PlaybackCommand::Load(_) => { + return Err(PlaybackError::new( + PlaybackErrorKind::Command, + "load requires creating a new Subwave adapter generation", + )); + } + PlaybackCommand::SelectAudio(track_id) => { + return self.select_audio_track(&track_id); + } + PlaybackCommand::SelectSubtitle(track_id) => { + return self.select_subtitle_track(track_id.as_ref()); + } + PlaybackCommand::AddExternalSubtitle { .. } => { + return Err(unsupported_extension("external subtitle loading")); + } + PlaybackCommand::SelectChapter(_) => { + return Err(PlaybackError::new( + PlaybackErrorKind::Command, + "chapter selection is unavailable for the Subwave backend", + )); + } + PlaybackCommand::SelectEdition(_) => { + return Err(PlaybackError::new( + PlaybackErrorKind::Command, + "edition selection is unavailable for the Subwave backend", + )); + } + PlaybackCommand::SetContentFit(content_fit) => { + self.record(PlaybackEvent::ContentFitChanged(content_fit)); + return Ok(()); + } + PlaybackCommand::SetFullscreen(fullscreen) => { + self.record(PlaybackEvent::FullscreenChanged(fullscreen)); + return Ok(()); + } + PlaybackCommand::ApplyVideoProfile(_) => { + return Err(unsupported_extension("video profile passthrough")); + } + PlaybackCommand::SetVideoShaders(_) => { + return Err(unsupported_extension("video shader passthrough")); + } + PlaybackCommand::CaptureScreenshot { .. } => { + return Err(unsupported_extension("native video screenshots")); + } + PlaybackCommand::SetPaused(paused) => { + SubwaveAdapterCommand::SetPaused(paused) + } + PlaybackCommand::SeekAbsolute(position) => { + SubwaveAdapterCommand::SeekAbsolute(position) + } + PlaybackCommand::SeekRelative(delta) => { + SubwaveAdapterCommand::SeekRelative(delta) + } + PlaybackCommand::SetVolume(volume) => { + SubwaveAdapterCommand::SetVolume(volume) + } + PlaybackCommand::SetMuted(muted) => { + SubwaveAdapterCommand::SetMuted(muted) + } + PlaybackCommand::SetSpeed(speed) => { + SubwaveAdapterCommand::SetSpeed(speed) + } + PlaybackCommand::Stop => SubwaveAdapterCommand::Stop, + PlaybackCommand::Shutdown => SubwaveAdapterCommand::Shutdown, + }; + + for event in dispatch_subwave_command(&mut self.video, command)? { + self.record(event); + } + Ok(()) + } + + pub fn synchronize_core_properties(&mut self) { + let eos = subwave_eos(&self.video); + if eos + && self.snapshot.state == PlaybackState::Ended + && self.snapshot.end_reason == Some(EndReason::Eof) + { + return; + } + + for event in subwave_core_events( + self.video.position(), + self.video.duration(), + self.video.paused(), + eos, + ) { + self.record(event); + } + } + + pub fn refresh_tracks(&mut self) -> TrackCatalog { + let selected_audio_index = self.video.current_audio_track(); + let selected_subtitle_index = self.video.current_subtitle_track(); + let subtitles_enabled = self.video.subtitles_enabled(); + let raw_audio = self.video.audio_tracks(); + let raw_subtitles = self.video.subtitle_tracks(); + + let (audio, audio_indices) = convert_audio_tracks(raw_audio); + let (subtitles, subtitle_indices) = + convert_subtitle_tracks(raw_subtitles); + self.audio_indices = audio_indices; + self.subtitle_indices = subtitle_indices; + + let selected_audio = + self.audio_indices.iter().find_map(|(id, index)| { + (*index == selected_audio_index).then(|| id.clone()) + }); + let selected_subtitle = subtitles_enabled + .then(|| { + self.subtitle_indices.iter().find_map(|(id, index)| { + (Some(*index) == selected_subtitle_index) + .then(|| id.clone()) + }) + }) + .flatten(); + + let catalog = TrackCatalog { + audio, + subtitles, + selected_audio, + selected_subtitle, + }; + self.record(PlaybackEvent::TracksChanged(catalog.clone())); + catalog + } + + pub fn select_audio_track( + &mut self, + track_id: &TrackId, + ) -> Result<(), PlaybackError> { + let index = self.lookup_audio_index(track_id)?; + dispatch_subwave_command( + &mut self.video, + SubwaveAdapterCommand::SelectAudio(index), + )?; + let mut catalog = self.snapshot.tracks.clone(); + catalog.selected_audio = Some(track_id.clone()); + self.record(PlaybackEvent::TracksChanged(catalog)); + Ok(()) + } + + pub fn select_subtitle_track( + &mut self, + track_id: Option<&TrackId>, + ) -> Result<(), PlaybackError> { + let index = track_id + .map(|track_id| self.lookup_subtitle_index(track_id)) + .transpose()?; + dispatch_subwave_command( + &mut self.video, + SubwaveAdapterCommand::SelectSubtitle(index), + )?; + let mut catalog = self.snapshot.tracks.clone(); + catalog.selected_subtitle = track_id.cloned(); + self.record(PlaybackEvent::TracksChanged(catalog)); + Ok(()) + } + + pub fn set_subtitles_enabled(&mut self, enabled: bool) { + self.video.set_subtitles_enabled(enabled); + if !enabled { + let mut catalog = self.snapshot.tracks.clone(); + catalog.selected_subtitle = None; + self.record(PlaybackEvent::TracksChanged(catalog)); + } + } + + pub fn subtitles_enabled(&self) -> bool { + self.snapshot.tracks.selected_subtitle.is_some() + || self.video.subtitles_enabled() + } + + pub fn has_video(&self) -> bool { + self.video.has_video() + } + + pub fn is_appsink(&self) -> bool { + matches!(self.video.backend(), BackendPreference::ForceAppsink) + } + + pub fn uses_wayland_surface(&self) -> bool { + matches!(self.video.backend(), BackendPreference::ForceWayland) + } + + pub fn toggle_diagnostic_backend(&mut self) -> Result<(), PlaybackError> { + let target = if self.is_appsink() { + BackendPreference::ForceWayland + } else { + BackendPreference::ForceAppsink + }; + self.set_diagnostic_backend(target) + } + + pub fn force_appsink(&mut self) -> Result<(), PlaybackError> { + self.set_diagnostic_backend(BackendPreference::ForceAppsink) + } + + #[cfg(feature = "ui")] + pub fn widget<'a, Message, Theme>( + &'a self, + content_fit: iced::ContentFit, + ) -> Element<'a, Message, Theme, iced_wgpu::Renderer> + where + Message: Clone + 'a, + Theme: 'a, + { + // Presentation widgets may redraw decoded frames as required, but UI + // state synchronization is deliberately timer/event-driven instead of + // publishing one application message per decoded frame. + self.video.widget(content_fit, None) + } + + fn set_diagnostic_backend( + &mut self, + target: BackendPreference, + ) -> Result<(), PlaybackError> { + self.video.set_preference(target).map_err(|error| { + backend_error( + PlaybackErrorKind::Command, + "Subwave diagnostic backend switch failed", + error, + ) + })?; + let target = target_for_backend(self.video.backend()); + self.snapshot.target = target; + self.snapshot.capabilities = capabilities_for_target(target); + self.synchronize_core_properties(); + self.refresh_tracks(); + Ok(()) + } + + fn lookup_audio_index( + &mut self, + track_id: &TrackId, + ) -> Result { + if let Some(index) = self.audio_indices.get(track_id) { + return Ok(*index); + } + self.refresh_tracks(); + self.audio_indices.get(track_id).copied().ok_or_else(|| { + PlaybackError::new( + PlaybackErrorKind::Command, + format!("unknown audio track identity: {track_id}"), + ) + }) + } + + fn lookup_subtitle_index( + &mut self, + track_id: &TrackId, + ) -> Result { + if let Some(index) = self.subtitle_indices.get(track_id) { + return Ok(*index); + } + self.refresh_tracks(); + self.subtitle_indices.get(track_id).copied().ok_or_else(|| { + PlaybackError::new( + PlaybackErrorKind::Command, + format!("unknown subtitle track identity: {track_id}"), + ) + }) + } + + fn record(&mut self, event: PlaybackEvent) { + let sequence = self.next_sequence; + let Some(next_sequence) = sequence.next() else { + let mut error = PlaybackError::new( + PlaybackErrorKind::Unknown, + "Subwave event sequence exhausted", + ); + error.backend = Some(BackendKind::GStreamer); + self.snapshot.state = PlaybackState::Failed; + self.snapshot.last_error = Some(error); + return; + }; + self.next_sequence = next_sequence; + let generation = self.snapshot.generation; + let _ = reduce_event( + &mut self.snapshot, + PlaybackEventEnvelope { + generation, + sequence, + event, + }, + ); + } +} + +fn subwave_eos(video: &SubwaveVideo) -> bool { + match video { + SubwaveVideo::Appsink { inner, .. } => { + SubwaveVideoTrait::eos(inner.as_ref()) + } + #[cfg(target_os = "linux")] + SubwaveVideo::Wayland { handle, .. } => handle + .try_borrow() + .ok() + .and_then(|video| video.as_deref().map(SubwaveVideoTrait::eos)) + .unwrap_or(false), + } +} + +fn subwave_core_events( + position: Duration, + duration: Duration, + paused: bool, + eos: bool, +) -> Vec { + let mut events = vec![ + PlaybackEvent::PositionChanged(position), + PlaybackEvent::DurationChanged( + (duration > Duration::ZERO).then_some(duration), + ), + ]; + if eos { + events.push(PlaybackEvent::Ended(EndReason::Eof)); + } else { + events.push(PlaybackEvent::StateChanged(if paused { + PlaybackState::Paused + } else { + PlaybackState::Playing + })); + } + events +} + +fn capabilities_for_target(target: PlaybackTarget) -> PlaybackCapabilities { + PlaybackCapabilities { + seek: true, + audio_track_selection: true, + subtitle_track_selection: true, + external_subtitle_loading: false, + chapter_selection: false, + edition_selection: false, + speed: true, + content_fit: true, + fullscreen: true, + screenshot: false, + video_shader_passthrough: false, + video_profile_passthrough: false, + integrated_presentation: matches!( + target.presentation, + crate::contract::PresentationMode::IntegratedNative + ), + native_window_fallback: false, + // These require observed compositor/output evidence; backend choice + // alone is not sufficient to claim them. + native_hdr: false, + fractional_scaling: false, + } +} + +fn unsupported_extension(operation: &'static str) -> PlaybackError { + let mut error = PlaybackError::new( + PlaybackErrorKind::UnsupportedOperation, + format!("{operation} is unavailable for the Subwave backend"), + ); + error.backend = Some(BackendKind::GStreamer); + error +} + +fn target_for_backend(backend: BackendPreference) -> PlaybackTarget { + match backend { + BackendPreference::ForceWayland => PlaybackTarget::GSTREAMER_INTEGRATED, + BackendPreference::Auto | BackendPreference::ForceAppsink => { + PlaybackTarget::GSTREAMER_EMBEDDED + } + } +} + +fn source_backend_error( + source: &PlaybackSource, + context: &str, + error: impl std::fmt::Display, +) -> PlaybackError { + let mut detail = error + .to_string() + .replace(source.uri().as_str(), ""); + for secret in source + .headers() + .iter() + .map(|header| header.value.expose_secret()) + .chain( + source + .cookies() + .iter() + .map(|cookie| cookie.value.expose_secret()), + ) + .filter(|secret| !secret.is_empty()) + { + detail = detail.replace(secret, ""); + } + backend_error(PlaybackErrorKind::BackendInitialization, context, detail) +} + +fn backend_error( + kind: PlaybackErrorKind, + context: &str, + error: impl std::fmt::Display, +) -> PlaybackError { + let detail = redact_playback_url(&error.to_string()); + let mut playback_error = + PlaybackError::new(kind, format!("{context}: {detail}")); + playback_error.backend = Some(BackendKind::GStreamer); + playback_error +} + +fn convert_audio_tracks( + tracks: Vec, +) -> (Vec, HashMap) { + let mut occurrences = HashMap::::new(); + let mut indices = HashMap::new(); + let tracks = tracks + .into_iter() + .map(|track| { + let base = audio_identity_base(&track); + let occurrence = occurrences.entry(base.clone()).or_default(); + let id = TrackId::new(format!("{base}#{occurrence}")); + *occurrence += 1; + indices.insert(id.clone(), track.index); + AudioTrack { + id, + title: track.title, + language: track.language, + codec: track.codec, + channels: track + .channels + .and_then(|channels| u16::try_from(channels).ok()), + sample_rate: track + .sample_rate + .and_then(|rate| u32::try_from(rate).ok()), + is_default: false, + is_forced: false, + } + }) + .collect(); + (tracks, indices) +} + +fn convert_subtitle_tracks( + tracks: Vec, +) -> (Vec, HashMap) { + let mut occurrences = HashMap::::new(); + let mut indices = HashMap::new(); + let tracks = tracks + .into_iter() + .map(|track| { + let kind = if track.is_text_based() { + SubtitleKind::Text + } else { + SubtitleKind::Bitmap + }; + let base = subtitle_identity_base(&track); + let occurrence = occurrences.entry(base.clone()).or_default(); + let id = TrackId::new(format!("{base}#{occurrence}")); + *occurrence += 1; + indices.insert(id.clone(), track.index); + SubtitleTrack { + id, + title: track.title, + language: track.language, + codec: track.codec, + kind, + is_default: false, + is_forced: false, + is_external: false, + } + }) + .collect(); + (tracks, indices) +} + +fn audio_identity_base(track: &SubwaveAudioTrack) -> String { + format!( + "subwave:audio:{}:{}:{}:{}:{}", + identity_component(track.language.as_deref()), + identity_component(track.title.as_deref()), + identity_component(track.codec.as_deref()), + track + .channels + .map_or_else(|| "_".to_string(), |value| value.to_string()), + track + .sample_rate + .map_or_else(|| "_".to_string(), |value| value.to_string()), + ) +} + +fn subtitle_identity_base(track: &SubwaveSubtitleTrack) -> String { + format!( + "subwave:subtitle:{}:{}:{}", + identity_component(track.language.as_deref()), + identity_component(track.title.as_deref()), + identity_component(track.codec.as_deref()), + ) +} + +fn identity_component(value: Option<&str>) -> String { + value + .filter(|value| !value.is_empty()) + .map(|value| { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() + || matches!(character, '-' | '_' | '.') + { + character.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() + }) + .unwrap_or_else(|| "_".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, PartialEq)] + enum FakeCall { + SetPaused(bool), + Seek(Duration), + SetVolume(f64), + SetMuted(bool), + SetSpeed(f64), + SelectAudio(i32), + SelectSubtitle(Option), + } + + #[derive(Debug, Default)] + struct FakeCommandTarget { + position: Duration, + calls: Vec, + fail_seek: bool, + } + + impl SubwaveCommandTarget for FakeCommandTarget { + fn position(&self) -> Duration { + self.position + } + + fn set_paused(&mut self, paused: bool) { + self.calls.push(FakeCall::SetPaused(paused)); + } + + fn seek(&mut self, position: Duration) -> Result<(), String> { + self.calls.push(FakeCall::Seek(position)); + if self.fail_seek { + Err("fake seek failure".to_string()) + } else { + self.position = position; + Ok(()) + } + } + + fn set_volume(&mut self, volume: f64) { + self.calls.push(FakeCall::SetVolume(volume)); + } + + fn set_muted(&mut self, muted: bool) { + self.calls.push(FakeCall::SetMuted(muted)); + } + + fn set_speed(&mut self, speed: f64) -> Result<(), String> { + self.calls.push(FakeCall::SetSpeed(speed)); + Ok(()) + } + + fn select_audio_track(&mut self, index: i32) -> Result<(), String> { + self.calls.push(FakeCall::SelectAudio(index)); + Ok(()) + } + + fn select_subtitle_track( + &mut self, + index: Option, + ) -> Result<(), String> { + self.calls.push(FakeCall::SelectSubtitle(index)); + Ok(()) + } + } + + #[test] + fn ferrex_commands_dispatch_to_subwave_and_return_snapshot_events() { + let mut target = FakeCommandTarget::default(); + + assert_eq!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SetPaused(false), + ) + .unwrap(), + vec![PlaybackEvent::StateChanged(PlaybackState::Playing)] + ); + assert_eq!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SeekAbsolute(Duration::from_secs(42)), + ) + .unwrap(), + vec![PlaybackEvent::PositionChanged(Duration::from_secs(42))] + ); + assert_eq!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SeekRelative(DurationDelta::Forward( + Duration::from_secs(8), + )), + ) + .unwrap(), + vec![PlaybackEvent::PositionChanged(Duration::from_secs(50))] + ); + assert_eq!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SeekRelative(DurationDelta::Backward( + Duration::from_secs(100), + )), + ) + .unwrap(), + vec![PlaybackEvent::PositionChanged(Duration::ZERO)] + ); + assert_eq!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SetVolume(1.5), + ) + .unwrap(), + vec![PlaybackEvent::VolumeChanged(1.0)] + ); + assert_eq!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SetMuted(true), + ) + .unwrap(), + vec![PlaybackEvent::MutedChanged(true)] + ); + assert_eq!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SetSpeed(1.25), + ) + .unwrap(), + vec![PlaybackEvent::SpeedChanged(1.25)] + ); + assert!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SelectAudio(4), + ) + .unwrap() + .is_empty() + ); + assert!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SelectSubtitle(None), + ) + .unwrap() + .is_empty() + ); + assert_eq!( + dispatch_subwave_command(&mut target, SubwaveAdapterCommand::Stop,) + .unwrap(), + vec![ + PlaybackEvent::StateChanged(PlaybackState::Stopping), + PlaybackEvent::Ended(EndReason::Stopped), + ] + ); + assert_eq!( + dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::Shutdown, + ) + .unwrap(), + vec![PlaybackEvent::StateChanged(PlaybackState::Terminated)] + ); + + assert_eq!( + target.calls, + vec![ + FakeCall::SetPaused(false), + FakeCall::Seek(Duration::from_secs(42)), + FakeCall::Seek(Duration::from_secs(50)), + FakeCall::Seek(Duration::ZERO), + FakeCall::SetVolume(1.0), + FakeCall::SetMuted(true), + FakeCall::SetSpeed(1.25), + FakeCall::SelectAudio(4), + FakeCall::SelectSubtitle(None), + FakeCall::SetPaused(true), + FakeCall::SetPaused(true), + ] + ); + } + + #[test] + fn core_observation_emits_eof_instead_of_overwriting_it_with_pause() { + assert_eq!( + subwave_core_events( + Duration::from_millis(9_900), + Duration::from_secs(10), + true, + true, + ), + vec![ + PlaybackEvent::PositionChanged(Duration::from_millis(9_900)), + PlaybackEvent::DurationChanged(Some(Duration::from_secs(10))), + PlaybackEvent::Ended(EndReason::Eof), + ] + ); + assert_eq!( + subwave_core_events( + Duration::from_secs(4), + Duration::from_secs(10), + true, + false, + ) + .last(), + Some(&PlaybackEvent::StateChanged(PlaybackState::Paused)) + ); + } + + #[test] + fn mpv_native_extensions_fail_as_explicit_subwave_capabilities() { + let error = unsupported_extension("native video screenshots"); + + assert_eq!(error.kind, PlaybackErrorKind::UnsupportedOperation); + assert_eq!(error.backend, Some(BackendKind::GStreamer)); + assert!(error.message.contains("unavailable")); + let capabilities = + capabilities_for_target(PlaybackTarget::GSTREAMER_EMBEDDED); + assert!(!capabilities.external_subtitle_loading); + assert!(!capabilities.screenshot); + assert!(!capabilities.video_shader_passthrough); + assert!(!capabilities.video_profile_passthrough); + } + + #[test] + fn invalid_values_and_backend_failures_are_structured() { + let mut target = FakeCommandTarget::default(); + + let invalid_volume = dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SetVolume(f64::NAN), + ) + .unwrap_err(); + let invalid_speed = dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SetSpeed(0.0), + ) + .unwrap_err(); + assert_eq!(invalid_volume.kind, PlaybackErrorKind::Command); + assert_eq!(invalid_speed.kind, PlaybackErrorKind::Command); + assert!(target.calls.is_empty()); + + target.fail_seek = true; + let seek_error = dispatch_subwave_command( + &mut target, + SubwaveAdapterCommand::SeekAbsolute(Duration::from_secs(1)), + ) + .unwrap_err(); + assert_eq!(seek_error.kind, PlaybackErrorKind::Command); + assert_eq!(seek_error.backend, Some(BackendKind::GStreamer)); + assert!(seek_error.message.contains("fake seek failure")); + } + + fn audio(index: i32, language: &str, title: &str) -> SubwaveAudioTrack { + SubwaveAudioTrack { + index, + language: Some(language.to_string()), + title: Some(title.to_string()), + codec: Some("aac".to_string()), + channels: Some(2), + sample_rate: Some(48_000), + } + } + + #[test] + fn audio_identity_survives_backend_index_reordering() { + let (first, _) = convert_audio_tracks(vec![ + audio(0, "eng", "Main"), + audio(1, "jpn", "Main"), + ]); + let (reloaded, _) = convert_audio_tracks(vec![ + audio(0, "jpn", "Main"), + audio(1, "eng", "Main"), + ]); + + let english = first + .iter() + .find(|track| track.language.as_deref() == Some("eng")) + .unwrap(); + let reloaded_english = reloaded + .iter() + .find(|track| track.language.as_deref() == Some("eng")) + .unwrap(); + assert_eq!(english.id, reloaded_english.id); + } + + #[test] + fn source_initialization_errors_redact_url_headers_and_cookies() { + let source = PlaybackSource::new( + "https://user:password@example.test/private?access_token=query-secret" + .parse() + .unwrap(), + ) + .with_header("Authorization", "header-secret") + .with_cookie("session", "cookie-secret"); + let raw_error = format!( + "failed {} Authorization=header-secret Cookie=cookie-secret", + source.uri() + ); + + let error = source_backend_error(&source, "open failed", raw_error); + let debug = format!("{error:?}"); + + for secret in [ + "password", + "private", + "query-secret", + "header-secret", + "cookie-secret", + ] { + assert!(!debug.contains(secret), "error leaked {secret}"); + } + } + + #[test] + fn duplicate_metadata_still_produces_distinct_track_ids() { + let (tracks, indices) = convert_audio_tracks(vec![ + audio(4, "eng", "Commentary"), + audio(9, "eng", "Commentary"), + ]); + + assert_ne!(tracks[0].id, tracks[1].id); + assert_eq!(indices[&tracks[0].id], 4); + assert_eq!(indices[&tracks[1].id], 9); + } +} From a9c9a4b8ccea95e921e0c003b4361fc97d737ec5 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:13:28 -0600 Subject: [PATCH 04/56] fix(player): harden external mpv IPC transport --- .../src/external_mpv.rs | 254 +++++++++++++----- 1 file changed, 182 insertions(+), 72 deletions(-) diff --git a/crates/ferrex-player-playback/src/external_mpv.rs b/crates/ferrex-player-playback/src/external_mpv.rs index 8c066da4..fc0676bb 100644 --- a/crates/ferrex-player-playback/src/external_mpv.rs +++ b/crates/ferrex-player-playback/src/external_mpv.rs @@ -3,7 +3,7 @@ use crate::diagnostics::{contains_access_token, redact_playback_url}; use serde_json::{Value, json}; -use std::io::{BufRead, BufReader, Read, Write}; +use std::io::{BufRead, BufReader, Write}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -17,7 +17,8 @@ use std::os::unix::net::UnixStream; #[derive(Debug)] pub struct ExternalMpvHandle { process: Child, - socket_path: String, + #[cfg(unix)] + _socket_guard: UnixIpcPath, #[cfg(unix)] connection: Arc>>, #[cfg(windows)] @@ -39,7 +40,9 @@ impl ExternalMpvHandle { resume_position: Option, ) -> Result> { #[cfg(unix)] - let socket_path = format!("/tmp/ferrex-mpv-{}", std::process::id()); + let socket_guard = create_private_ipc_path()?; + #[cfg(unix)] + let socket_path = socket_guard.socket.clone(); #[cfg(windows)] let socket_path = format!(r"\\.\pipe\ferrex-mpv-{}", std::process::id()); @@ -96,9 +99,11 @@ impl ExternalMpvHandle { .arg("--osd-duration=2000") // OSD display duration in ms .arg("--osc=yes"); // Enable on-screen controller - // Playback settings - cmd.arg("--keep-open=no") // Don't close at end - .arg("--idle=no") // Stay alive when done + // Playback settings. `idle=once` keeps mpv alive long enough for the + // private IPC load below, then preserves the historical behavior of + // exiting after the first playlist finishes. + cmd.arg("--keep-open=no") + .arg("--idle=once") .arg("--pause=no"); // Start playing immediately // Add resume position if provided @@ -128,11 +133,11 @@ impl ExternalMpvHandle { } } - // Add the URL - cmd.arg(url); - + // Do not put the media URL (and potentially its playback ticket) in + // the child process argument vector. It is submitted over the private + // IPC connection after startup instead. log::info!( - "Spawning external MPV with URL: {}", + "Spawning external MPV for URL: {}", redact_playback_url(url) ); // Pipe stdout/stderr so we can capture diagnostics cross‑platform @@ -148,36 +153,27 @@ impl ExternalMpvHandle { })?; // Stream MPV stdout/stderr into our logs and persistent file if configured - if let Some(mut out) = child.stdout.take() { + if let Some(out) = child.stdout.take() { let log_file = log_path.clone(); std::thread::spawn(move || { - let mut buf = [0u8; 4096]; + let mut out = BufReader::new(out); + let mut line = String::new(); loop { - match out.read(&mut buf) { + line.clear(); + match out.read_line(&mut line) { Ok(0) => break, - Ok(n) => { - if let Ok(s) = std::str::from_utf8(&buf[..n]) { - for line in s.lines() { - let redacted_line = - redact_playback_url(line); - log::debug!( - "mpv(stdout): {}", - redacted_line - ); - if let Some(ref path) = log_file - && let Ok(mut f) = - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - { - let _ = writeln!( - f, - "[stdout] {}", - redacted_line - ); - } - } + Ok(_) => { + let redacted_line = + redact_playback_url(line.trim_end()); + log::debug!("mpv(stdout): {}", redacted_line); + if let Some(ref path) = log_file + && let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = + writeln!(f, "[stdout] {}", redacted_line); } } Err(_) => break, @@ -185,36 +181,27 @@ impl ExternalMpvHandle { } }); } - if let Some(mut err) = child.stderr.take() { + if let Some(err) = child.stderr.take() { let log_file = log_path.clone(); std::thread::spawn(move || { - let mut buf = [0u8; 4096]; + let mut err = BufReader::new(err); + let mut line = String::new(); loop { - match err.read(&mut buf) { + line.clear(); + match err.read_line(&mut line) { Ok(0) => break, - Ok(n) => { - if let Ok(s) = std::str::from_utf8(&buf[..n]) { - for line in s.lines() { - let redacted_line = - redact_playback_url(line); - log::warn!( - "mpv(stderr): {}", - redacted_line - ); - if let Some(ref path) = log_file - && let Ok(mut f) = - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - { - let _ = writeln!( - f, - "[stderr] {}", - redacted_line - ); - } - } + Ok(_) => { + let redacted_line = + redact_playback_url(line.trim_end()); + log::warn!("mpv(stderr): {}", redacted_line); + if let Some(ref path) = log_file + && let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = + writeln!(f, "[stderr] {}", redacted_line); } } Err(_) => break, @@ -223,7 +210,7 @@ impl ExternalMpvHandle { }); } - let process = child; + let mut process = child; // Wait a moment for MPV to create the socket std::thread::sleep(Duration::from_millis(300)); @@ -231,9 +218,18 @@ impl ExternalMpvHandle { // Connect to IPC socket #[cfg(unix)] let connection = { - let stream = UnixStream::connect(&socket_path)?; + let stream = match UnixStream::connect(&socket_path) { + Ok(stream) => stream, + Err(error) => { + let _ = process.kill(); + return Err(error.into()); + } + }; // Set non-blocking mode to prevent UI freezing - stream.set_nonblocking(true)?; + if let Err(error) = stream.set_nonblocking(true) { + let _ = process.kill(); + return Err(error.into()); + } Arc::new(Mutex::new(BufReader::new(stream))) }; #[cfg(windows)] @@ -263,6 +259,7 @@ impl ExternalMpvHandle { .as_ref() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| "(no log file)".to_string()); + let _ = process.kill(); return Err(format!( "Failed to connect to MPV named pipe after retries: {}. \ IPC may be blocked or mpv failed to start. If antivirus is running, add an exception. \ @@ -355,7 +352,8 @@ See mpv log for details: {}", let mut handle = Self { process, - socket_path: socket_path.clone(), + #[cfg(unix)] + _socket_guard: socket_guard, #[cfg(unix)] connection, #[cfg(windows)] @@ -382,11 +380,14 @@ See mpv log for details: {}", handle.observe_property(3, "fullscreen")?; handle.observe_property(4, "duration")?; + // Keep authenticated media out of argv/process listings. The socket is + // local to this Ferrex process and is removed when the handle drops. + handle.send_command(&["loadfile", url, "replace"])?; + Ok(handle) } /// Send a command to MPV via IPC - #[allow(unused)] fn send_command( &mut self, args: &[&str], @@ -563,14 +564,58 @@ See mpv log for details: {}", impl Drop for ExternalMpvHandle { fn drop(&mut self) { self.kill(); - // Clean up socket file - #[cfg(unix)] - { - let _ = std::fs::remove_file(&self.socket_path); - } } } +#[cfg(unix)] +#[derive(Debug)] +struct UnixIpcPath { + socket: String, + directory: std::path::PathBuf, +} + +#[cfg(unix)] +impl Drop for UnixIpcPath { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.socket); + let _ = std::fs::remove_dir(&self.directory); + } +} + +#[cfg(unix)] +fn create_private_ipc_path() -> Result> +{ + use std::os::unix::fs::DirBuilderExt; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(1); + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let directory = std::env::temp_dir().join(format!( + "ferrex-mpv-{}-{nonce}-{sequence}", + std::process::id() + )); + std::fs::DirBuilder::new().mode(0o700).create(&directory)?; + let socket = directory.join("ipc.sock"); + let socket = match socket.into_os_string().into_string() { + Ok(socket) => socket, + Err(_) => { + let _ = std::fs::remove_dir(&directory); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "external mpv IPC path is not valid UTF-8", + ) + .into()); + } + }; + Ok(UnixIpcPath { socket, directory }) +} + /// Start external MPV playback with window settings, position, and resume position pub fn start_external_playback( url: &str, @@ -716,3 +761,68 @@ fn search_in_path(exe: &str) -> Option { } None } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn ipc_socket_parent_is_private_and_raii_cleaned() { + let guard = create_private_ipc_path().expect("create private IPC path"); + let directory = guard.directory.clone(); + let mode = std::fs::metadata(&directory) + .expect("IPC parent exists") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + + drop(guard); + assert!(!directory.exists()); + } + + #[test] + #[ignore = "requires FERREX_EXTERNAL_MPV_SMOKE_URL and a working desktop VO"] + fn media_is_loaded_over_ipc_without_argv_exposure() { + let url = std::env::var("FERREX_EXTERNAL_MPV_SMOKE_URL") + .expect("set FERREX_EXTERNAL_MPV_SMOKE_URL"); + assert!(!url.is_empty(), "smoke URL must not be empty"); + let mut handle = + ExternalMpvHandle::spawn(&url, false, Some((640, 360)), None, None) + .expect("external mpv starts and accepts the IPC load"); + + let argv = + std::fs::read(format!("/proc/{}/cmdline", handle.process.id())) + .expect("read child argv"); + assert!( + !argv + .windows(url.len()) + .any(|window| window == url.as_bytes()), + "media URL was exposed in the child argument vector" + ); + if let Some((_, ticket)) = url.split_once("access_token=") { + let ticket = ticket.split('&').next().unwrap_or(ticket); + assert!( + !ticket.is_empty() + && !argv + .windows(ticket.len()) + .any(|window| window == ticket.as_bytes()), + "playback ticket was exposed in the child argument vector" + ); + } + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut observed_media = false; + while std::time::Instant::now() < deadline && handle.is_alive() { + let (position, duration) = handle.poll_position(); + if position > 0.0 || duration > 0.0 { + observed_media = true; + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + assert!(observed_media, "mpv did not load media through IPC"); + handle.kill(); + } +} From a452f6ac666d65920fb2ef7ab070f36b3ed49219 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:14:04 -0600 Subject: [PATCH 05/56] feat(streaming): add protected HLS transcode jobs --- Cargo.lock | 1 + crates/ferrex-core/src/api/routes.rs | 10 + crates/ferrex-model/src/lib.rs | 2 + crates/ferrex-model/src/prelude.rs | 4 +- crates/ferrex-model/src/transcoding.rs | 116 ++++ crates/ferrex-player-api/Cargo.toml | 1 + .../src/services/streaming.rs | 349 +++++++++-- .../ferrex-server/src/handlers/stream/mod.rs | 1 + .../src/handlers/stream/stream_handlers.rs | 80 ++- .../src/handlers/stream/transcode_handlers.rs | 183 ++++++ crates/ferrex-server/src/infra/app_state.rs | 11 + crates/ferrex-server/src/infra/mod.rs | 2 + crates/ferrex-server/src/infra/transcode.rs | 577 ++++++++++++++++++ crates/ferrex-server/src/routes/v1.rs | 13 + .../ferrex-server/tests/transcode_routes.rs | 210 +++++++ 15 files changed, 1485 insertions(+), 75 deletions(-) create mode 100644 crates/ferrex-server/src/handlers/stream/transcode_handlers.rs create mode 100644 crates/ferrex-server/src/infra/transcode.rs create mode 100644 crates/ferrex-server/tests/transcode_routes.rs diff --git a/Cargo.lock b/Cargo.lock index 8859745a..6f4b902c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2256,6 +2256,7 @@ dependencies = [ "tokio", "urlencoding", "uuid", + "zeroize", ] [[package]] diff --git a/crates/ferrex-core/src/api/routes.rs b/crates/ferrex-core/src/api/routes.rs index 3369fc6f..056e7450 100644 --- a/crates/ferrex-core/src/api/routes.rs +++ b/crates/ferrex-core/src/api/routes.rs @@ -260,6 +260,16 @@ pub mod v1 { v1_path!("/stream/{media_type}/{id}/progress"); } + pub mod transcode { + /// Start or reuse one server-generated HLS quality rendition. + pub const START: &str = v1_path!("/transcode/{id}"); + /// Inspect a job created by the authenticated user. + pub const STATUS: &str = v1_path!("/transcode/jobs/{job_id}"); + /// Protected HLS manifest/segment delivery. `asset` is one safe file + /// name, never an arbitrary path. + pub const ASSET: &str = v1_path!("/transcode/{id}/{profile}/{asset}"); + } + pub mod sync { pub const WEBSOCKET: &str = v1_path!("/sync/ws"); } diff --git a/crates/ferrex-model/src/lib.rs b/crates/ferrex-model/src/lib.rs index 626b4def..294c4851 100644 --- a/crates/ferrex-model/src/lib.rs +++ b/crates/ferrex-model/src/lib.rs @@ -121,6 +121,8 @@ pub use rate_limit::{ }; pub use subject_key::{NormalizedPathKey, OpaqueSubjectKey, SubjectKey}; pub use transcoding::{ + ParseTranscodeQualityProfileError, StartTranscodeRequest, + TranscodeJobState, TranscodeJobStatusResponse, TranscodeQualityProfile, TranscodingJobResponse, TranscodingProgressDetails, TranscodingStatus, }; pub use watch::{ diff --git a/crates/ferrex-model/src/prelude.rs b/crates/ferrex-model/src/prelude.rs index 73a6184c..36b19271 100644 --- a/crates/ferrex-model/src/prelude.rs +++ b/crates/ferrex-model/src/prelude.rs @@ -37,7 +37,9 @@ pub use super::media_id::ArchivedMediaID; pub use super::media_id::MediaID; pub use super::media_type::{ImageMediaType, VideoMediaType}; pub use super::transcoding::{ - TranscodingJobResponse, TranscodingProgressDetails, TranscodingStatus, + StartTranscodeRequest, TranscodeJobState, TranscodeJobStatusResponse, + TranscodeQualityProfile, TranscodingJobResponse, + TranscodingProgressDetails, TranscodingStatus, }; pub use super::watch::{ EpisodeKey, EpisodeStatus, NextEpisode, NextReason, SeasonKey, diff --git a/crates/ferrex-model/src/transcoding.rs b/crates/ferrex-model/src/transcoding.rs index c33109d5..75867414 100644 --- a/crates/ferrex-model/src/transcoding.rs +++ b/crates/ferrex-model/src/transcoding.rs @@ -3,6 +3,122 @@ //! Server APIs use these shapes to report queued/running/completed transcode //! work and clients use them to render playback preparation progress. +use std::{fmt, str::FromStr}; + +/// Server-supported HLS quality profiles. +/// +/// These names are part of the v1 HTTP contract. Keeping the set closed makes +/// cache paths and FFmpeg arguments server-owned instead of accepting raw +/// encoder/filter strings from clients. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum TranscodeQualityProfile { + #[cfg_attr(feature = "serde", serde(rename = "original"))] + Original, + #[cfg_attr(feature = "serde", serde(rename = "1080p"))] + P1080, + #[cfg_attr(feature = "serde", serde(rename = "720p"))] + P720, + #[cfg_attr(feature = "serde", serde(rename = "480p"))] + P480, + #[cfg_attr(feature = "serde", serde(rename = "360p"))] + P360, +} + +impl TranscodeQualityProfile { + pub const ALL: [Self; 5] = [ + Self::Original, + Self::P1080, + Self::P720, + Self::P480, + Self::P360, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Original => "original", + Self::P1080 => "1080p", + Self::P720 => "720p", + Self::P480 => "480p", + Self::P360 => "360p", + } + } + + pub const fn display_name(self) -> &'static str { + match self { + Self::Original => "Original", + Self::P1080 => "1080p", + Self::P720 => "720p", + Self::P480 => "480p", + Self::P360 => "360p", + } + } +} + +impl fmt::Display for TranscodeQualityProfile { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for TranscodeQualityProfile { + type Err = ParseTranscodeQualityProfileError; + + fn from_str(value: &str) -> Result { + match value { + "original" => Ok(Self::Original), + "1080p" => Ok(Self::P1080), + "720p" => Ok(Self::P720), + "480p" => Ok(Self::P480), + "360p" => Ok(Self::P360), + _ => Err(ParseTranscodeQualityProfileError), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseTranscodeQualityProfileError; + +impl fmt::Display for ParseTranscodeQualityProfileError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("unsupported transcode quality profile") + } +} + +impl std::error::Error for ParseTranscodeQualityProfileError {} + +/// Request body for starting a server-generated HLS rendition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct StartTranscodeRequest { + pub profile: TranscodeQualityProfile, +} + +/// Stable public lifecycle states for an HLS transcode job. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum TranscodeJobState { + Queued, + Running, + Completed, + Failed, +} + +/// Privacy-safe v1 response for both transcode start and status endpoints. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TranscodeJobStatusResponse { + pub job_id: String, + pub media_id: String, + pub profile: TranscodeQualityProfile, + pub state: TranscodeJobState, + pub progress: Option, + pub message: Option, + /// Credential-free API path to the protected media playlist. + pub playback_path: Option, +} + #[derive(Debug, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct TranscodingJobResponse { diff --git a/crates/ferrex-player-api/Cargo.toml b/crates/ferrex-player-api/Cargo.toml index 9cc3d5ed..0bf8e114 100644 --- a/crates/ferrex-player-api/Cargo.toml +++ b/crates/ferrex-player-api/Cargo.toml @@ -61,3 +61,4 @@ serde_json.workspace = true tokio = { workspace = true, features = ["sync"] } urlencoding = "2" uuid.workspace = true +zeroize.workspace = true diff --git a/crates/ferrex-player-api/src/services/streaming.rs b/crates/ferrex-player-api/src/services/streaming.rs index dfcb7742..1dba4216 100644 --- a/crates/ferrex-player-api/src/services/streaming.rs +++ b/crates/ferrex-player-api/src/services/streaming.rs @@ -3,16 +3,97 @@ use anyhow::Result; use async_trait::async_trait; use ferrex_core::api::routes::{utils, v1}; -use std::sync::Arc; +pub use ferrex_model::TranscodeQualityProfile; +use ferrex_model::{ + StartTranscodeRequest, TranscodeJobState, TranscodeJobStatusResponse, +}; +use std::{fmt, sync::Arc}; +use zeroize::Zeroizing; use crate::ApiClient; +/// Credential-bearing stream source returned by the streaming API boundary. +/// +/// The URI is required to be credential-free. The bearer header is zeroized +/// on drop and intentionally omitted from `Debug`, so HLS/direct-stream +/// callers do not need to reconstruct query-token URLs. +#[derive(Clone, PartialEq, Eq)] +pub struct StreamingPlaybackSource { + uri: reqwest::Url, + authorization: Zeroizing, +} + +impl StreamingPlaybackSource { + /// Construct a credential-free HTTP(S) source with a bearer token. + /// + /// The token is moved immediately into zeroizing storage. Invalid input is + /// rejected without including the source or token in the error. + pub fn with_bearer_token(uri: String, token: String) -> Result { + let token = Zeroizing::new(token); + if token.trim().is_empty() + || token.bytes().any(|byte| byte.is_ascii_control()) + { + anyhow::bail!("playback ticket response was invalid"); + } + + let uri = reqwest::Url::parse(&uri) + .map_err(|_| anyhow::anyhow!("stream URL was invalid"))?; + if !matches!(uri.scheme(), "http" | "https") + || !uri.username().is_empty() + || uri.password().is_some() + || uri.query().is_some() + || uri.fragment().is_some() + { + anyhow::bail!("stream URL must be credential-free HTTP(S)"); + } + + Ok(Self { + uri, + authorization: Zeroizing::new(format!("Bearer {}", token.as_str())), + }) + } + + pub fn uri(&self) -> &reqwest::Url { + &self.uri + } + + /// Deliberately expose the authorization value to an in-process playback + /// transport. Callers must not log or persist the returned value. + pub fn authorization_header(&self) -> (&'static str, &str) { + ("Authorization", self.authorization.as_str()) + } +} + +impl fmt::Debug for StreamingPlaybackSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let authority = self + .uri + .host_str() + .map(|host| { + let port = self + .uri + .port() + .map(|port| format!(":{port}")) + .unwrap_or_default(); + format!("{}://{host}{port}/", self.uri.scheme()) + }) + .unwrap_or_else(|| "".to_string()); + formatter + .debug_struct("StreamingPlaybackSource") + .field("uri", &authority) + .field("authorization", &"") + .finish() + } +} + #[derive(Debug, Clone)] pub struct TranscodingStatus { pub job_id: String, + pub profile: TranscodeQualityProfile, pub state: String, // e.g., "pending", "running", "completed", "failed" pub progress: Option, pub message: Option, + pub playback_source: Option, } #[async_trait] @@ -20,13 +101,19 @@ pub trait StreamingApiService: Send + Sync { async fn start_transcoding( &self, media_id: &str, - profile: &str, + profile: TranscodeQualityProfile, ) -> Result; async fn check_transcoding_status( &self, job_id: &str, ) -> Result; - async fn get_master_playlist(&self, media_id: &str) -> Result; + /// Resolve the authenticated direct source used by the legacy HLS + /// boundary. Completed transcode jobs return their protected rendition + /// through [`Self::check_transcoding_status`]. + async fn get_master_playlist( + &self, + media_id: &str, + ) -> Result; } #[derive(Clone, Debug)] @@ -38,47 +125,22 @@ impl StreamingApiAdapter { pub fn new(client: Arc) -> Self { Self { client } } -} -#[async_trait] -impl StreamingApiService for StreamingApiAdapter { - async fn start_transcoding( + async fn authenticated_playback_source( &self, media_id: &str, - profile: &str, - ) -> Result { - // Transcoding provider is temporarily unavailable; signal cached job - let _ = profile; // profile selection is ignored for direct streaming - Ok(format!("cached_{}", media_id)) - } - - async fn check_transcoding_status( - &self, - job_id: &str, - ) -> Result { - Ok(TranscodingStatus { - job_id: job_id.to_string(), - state: "completed".to_string(), - progress: Some(1.0), - message: Some("Direct streaming available".to_string()), - }) - } - - async fn get_master_playlist(&self, media_id: &str) -> Result { - let stream_path = - utils::replace_param(v1::stream::PLAY, "{id}", media_id); - let base = self.client.build_url(&stream_path); - // Attach a short-lived playback ticket for HLS clients. Failing open - // to the protected stream URL would bypass the desktop auth contract. + playback_path: &str, + ) -> Result { #[derive(serde::Deserialize)] struct PlaybackTicketResponse { access_token: String, #[allow(dead_code)] expires_in: i64, } + let ticket_path = utils::replace_param(v1::stream::PLAYBACK_TICKET, "{id}", media_id); - let resp = self + let ticket = self .client .get::(&ticket_path) .await @@ -88,18 +150,74 @@ impl StreamingApiService for StreamingApiAdapter { error ) })?; + StreamingPlaybackSource::with_bearer_token( + self.client.build_url(playback_path), + ticket.access_token, + ) + } +} - if resp.access_token.trim().is_empty() { - anyhow::bail!( - "playback ticket response did not include an access token" - ); - } +#[async_trait] +impl StreamingApiService for StreamingApiAdapter { + async fn start_transcoding( + &self, + media_id: &str, + profile: TranscodeQualityProfile, + ) -> Result { + let path = utils::replace_param(v1::transcode::START, "{id}", media_id); + let response = self + .client + .post::<_, TranscodeJobStatusResponse>( + &path, + &StartTranscodeRequest { profile }, + ) + .await?; + Ok(response.job_id) + } - Ok(format!( - "{}?access_token={}", - base, - urlencoding::encode(&resp.access_token) - )) + async fn check_transcoding_status( + &self, + job_id: &str, + ) -> Result { + let path = + utils::replace_param(v1::transcode::STATUS, "{job_id}", job_id); + let response = + self.client.get::(&path).await?; + let playback_source = + match (response.state, response.playback_path.as_deref()) { + (TranscodeJobState::Completed, Some(playback_path)) => Some( + self.authenticated_playback_source( + &response.media_id, + playback_path, + ) + .await?, + ), + _ => None, + }; + let state = match response.state { + TranscodeJobState::Queued => "queued", + TranscodeJobState::Running => "running", + TranscodeJobState::Completed => "completed", + TranscodeJobState::Failed => "failed", + }; + Ok(TranscodingStatus { + job_id: response.job_id, + profile: response.profile, + state: state.to_string(), + progress: response.progress, + message: response.message, + playback_source, + }) + } + + async fn get_master_playlist( + &self, + media_id: &str, + ) -> Result { + let stream_path = + utils::replace_param(v1::stream::PLAY, "{id}", media_id); + self.authenticated_playback_source(media_id, &stream_path) + .await } } @@ -109,6 +227,7 @@ mod tests { use std::{ io::{Read, Write}, net::TcpListener, + sync::mpsc, thread, time::Duration, }; @@ -138,8 +257,108 @@ mod tests { format!("http://{addr}") } + fn serve_responses( + responses: Vec<(&str, &str)>, + ) -> (String, mpsc::Receiver) { + let listener = + TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let addr = listener.local_addr().expect("test server address"); + let responses = responses + .into_iter() + .map(|(status, body)| (status.to_string(), body.to_string())) + .collect::>(); + let (request_tx, request_rx) = mpsc::channel(); + + thread::spawn(move || { + for (status, body) in responses { + let (mut stream, _) = + listener.accept().expect("accept request"); + let _ = stream.set_read_timeout(Some(Duration::from_secs(1))); + let mut request = [0_u8; 8192]; + let read = stream.read(&mut request).unwrap_or_default(); + request_tx + .send( + String::from_utf8_lossy(&request[..read]).into_owned(), + ) + .expect("capture request"); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + } + }); + + (format!("http://{addr}"), request_rx) + } + + #[tokio::test] + async fn start_transcoding_submits_the_selected_server_profile() { + let (base_url, requests) = serve_responses(vec![( + "200 OK", + r#"{"status":"success","data":{"job_id":"job-7","media_id":"media-1","profile":"720p","state":"queued","progress":0.0,"message":"queued","playback_path":null}}"#, + )]); + let adapter = + StreamingApiAdapter::new(Arc::new(ApiClient::new(base_url))); + + let job_id = adapter + .start_transcoding("media-1", TranscodeQualityProfile::P720) + .await + .expect("start response"); + assert_eq!(job_id, "job-7"); + + let request = requests.recv().expect("captured start request"); + assert!(request.starts_with("POST /api/v1/transcode/media-1 ")); + assert!(request.contains(r#"{"profile":"720p"}"#)); + } + #[tokio::test] - async fn get_master_playlist_returns_ticketed_url() { + async fn completed_status_resolves_a_header_authenticated_hls_source() { + let (base_url, requests) = serve_responses(vec![ + ( + "200 OK", + r#"{"status":"success","data":{"job_id":"job-7","media_id":"media-1","profile":"480p","state":"completed","progress":1.0,"message":"ready","playback_path":"/api/v1/transcode/media-1/480p/index.m3u8"}}"#, + ), + ( + "200 OK", + r#"{"status":"success","data":{"access_token":"scoped ticket","expires_in":60}}"#, + ), + ]); + let adapter = StreamingApiAdapter::new(Arc::new(ApiClient::new( + base_url.clone(), + ))); + + let status = adapter + .check_transcoding_status("job-7") + .await + .expect("completed status"); + assert_eq!(status.profile, TranscodeQualityProfile::P480); + assert_eq!(status.state, "completed"); + let source = status.playback_source.expect("protected HLS source"); + assert_eq!( + source.uri().as_str(), + format!("{base_url}/api/v1/transcode/media-1/480p/index.m3u8") + ); + assert_eq!( + source.authorization_header(), + ("Authorization", "Bearer scoped ticket") + ); + assert!(!format!("{source:?}").contains("scoped ticket")); + + let status_request = requests.recv().expect("captured status request"); + assert!( + status_request.starts_with("GET /api/v1/transcode/jobs/job-7 ") + ); + let ticket_request = requests.recv().expect("captured ticket request"); + assert!( + ticket_request.starts_with("GET /api/v1/stream/media-1/ticket ") + ); + } + + #[tokio::test] + async fn get_master_playlist_returns_header_authenticated_source() { let base_url = serve_once( "200 OK", r#"{"status":"success","data":{"access_token":"ticket secret","expires_in":60}}"#, @@ -148,18 +367,48 @@ mod tests { base_url.clone(), ))); - let playlist_url = adapter + let source = adapter .get_master_playlist("media-1") .await .expect("ticket request succeeds"); assert_eq!( - playlist_url, - format!( - "{}/api/v1/stream/media-1?access_token=ticket%20secret", - base_url - ) + source.uri().as_str(), + format!("{}/api/v1/stream/media-1", base_url) ); + assert!(source.uri().query().is_none()); + assert_eq!( + source.authorization_header(), + ("Authorization", "Bearer ticket secret") + ); + let debug = format!("{source:?}"); + assert!(!debug.contains("ticket secret")); + assert!(!debug.contains("/api/v1/stream/media-1")); + } + + #[test] + fn streaming_source_rejects_embedded_credentials_and_header_injection() { + for (uri, token) in [ + ( + "https://ferrex.example/stream?access_token=url-secret", + "header-secret", + ), + ("https://user@ferrex.example/stream", "header-secret"), + ( + "https://ferrex.example/stream", + "header-secret\r\nX-Injected: value", + ), + ] { + let error = StreamingPlaybackSource::with_bearer_token( + uri.to_string(), + token.to_string(), + ) + .expect_err("credential-bearing or injectable source must fail") + .to_string(); + assert!(!error.contains("url-secret")); + assert!(!error.contains("header-secret")); + assert!(!error.contains("X-Injected")); + } } #[tokio::test] diff --git a/crates/ferrex-server/src/handlers/stream/mod.rs b/crates/ferrex-server/src/handlers/stream/mod.rs index ac36db8b..2254be54 100644 --- a/crates/ferrex-server/src/handlers/stream/mod.rs +++ b/crates/ferrex-server/src/handlers/stream/mod.rs @@ -1,2 +1,3 @@ pub mod handle_sync; pub mod stream_handlers; +pub mod transcode_handlers; diff --git a/crates/ferrex-server/src/handlers/stream/stream_handlers.rs b/crates/ferrex-server/src/handlers/stream/stream_handlers.rs index 5f3fb258..c63d9edf 100644 --- a/crates/ferrex-server/src/handlers/stream/stream_handlers.rs +++ b/crates/ferrex-server/src/handlers/stream/stream_handlers.rs @@ -52,15 +52,15 @@ impl PlaybackHttpError { } } - const fn missing_token() -> Self { + pub(super) const fn missing_token() -> Self { Self::plain(StatusCode::UNAUTHORIZED, "Missing token") } - const fn invalid_token() -> Self { + pub(super) const fn invalid_token() -> Self { Self::plain(StatusCode::UNAUTHORIZED, "Invalid token") } - const fn media_not_found() -> Self { + pub(super) const fn media_not_found() -> Self { Self::typed( StatusCode::NOT_FOUND, "media-not-found", @@ -84,7 +84,7 @@ impl PlaybackHttpError { ) } - const fn file_missing() -> Self { + pub(super) const fn file_missing() -> Self { Self::typed( StatusCode::NOT_FOUND, "file-missing", @@ -100,7 +100,7 @@ impl PlaybackHttpError { ) } - const fn internal() -> Self { + pub(super) const fn internal() -> Self { Self::typed( StatusCode::INTERNAL_SERVER_ERROR, "internal", @@ -141,7 +141,7 @@ impl IntoResponse for PlaybackHttpError { } } -async fn load_playback_source( +pub(super) async fn load_playback_source( state: &AppState, media_id: Uuid, ) -> Result { @@ -157,7 +157,7 @@ async fn load_playback_source( .ok_or_else(PlaybackHttpError::media_not_found) } -fn ensure_playback_source_available( +pub(super) fn ensure_playback_source_available( state: &AppState, source: &PlaybackMediaSource, ) -> Result<(), PlaybackHttpError> { @@ -181,6 +181,31 @@ fn ensure_playback_source_available( Ok(()) } +fn playback_content_type(path: &std::path::Path) -> &'static str { + match path.extension().and_then(|extension| extension.to_str()) { + Some("mp4") => "video/mp4", + Some("mkv") => "video/x-matroska", + Some("avi") => "video/x-msvideo", + Some("mov") => "video/quicktime", + Some("webm") => "video/webm", + Some("flv") => "video/x-flv", + Some("wmv") => "video/x-ms-wmv", + Some("m4v") => "video/x-m4v", + Some("mpg") | Some("mpeg") => "video/mpeg", + Some("3gp") => "video/3gpp", + Some("ogv") => "video/ogg", + // HLS assets can be registered as ordinary protected media files by + // the transcoding/cache boundary. Correct types matter to native + // demuxers and keep manifests from being treated as downloads. + Some("m3u8") | Some("m3u") => "application/vnd.apple.mpegurl", + Some("ts") => "video/mp2t", + Some("mts") | Some("m2ts") => "video/mp2t", + Some("m4s") => "video/iso.segment", + Some("aac") => "audio/aac", + _ => "application/octet-stream", + } +} + #[derive(Debug, Deserialize)] pub struct ProgressReport { pub position: f32, @@ -235,23 +260,7 @@ pub async fn stream_with_progress_handler( ensure_playback_source_available(&state, &media_file)?; let file_size = media_file.size; - let extension = media_file.path.extension().and_then(|ext| ext.to_str()); - let content_type = match extension { - Some("mp4") => "video/mp4", - Some("mkv") => "video/x-matroska", - Some("avi") => "video/x-msvideo", - Some("mov") => "video/quicktime", - Some("webm") => "video/webm", - Some("flv") => "video/x-flv", - Some("wmv") => "video/x-ms-wmv", - Some("m4v") => "video/x-m4v", - Some("mpg") | Some("mpeg") => "video/mpeg", - Some("3gp") => "video/3gpp", - Some("ogv") => "video/ogg", - Some("ts") => "video/mp2t", - Some("mts") | Some("m2ts") => "video/mp2t", - _ => "application/octet-stream", - }; + let content_type = playback_content_type(&media_file.path); debug!("Content-Type: {}", content_type); let file = tokio::fs::File::open(&media_file.path).await.map_err(|e| { @@ -432,3 +441,26 @@ fn parse_range_header(range_str: &str, file_size: u64) -> Option { None } } + +#[cfg(test)] +mod tests { + use super::playback_content_type; + use std::path::Path; + + #[test] + fn protected_hls_assets_use_demuxer_friendly_content_types() { + for (path, expected) in [ + ("index.m3u8", "application/vnd.apple.mpegurl"), + ("legacy.m3u", "application/vnd.apple.mpegurl"), + ("segment.ts", "video/mp2t"), + ("fragment.m4s", "video/iso.segment"), + ("audio.aac", "audio/aac"), + ] { + assert_eq!(playback_content_type(Path::new(path)), expected); + } + assert_eq!( + playback_content_type(Path::new("unknown.bin")), + "application/octet-stream" + ); + } +} diff --git a/crates/ferrex-server/src/handlers/stream/transcode_handlers.rs b/crates/ferrex-server/src/handlers/stream/transcode_handlers.rs new file mode 100644 index 00000000..497b4809 --- /dev/null +++ b/crates/ferrex-server/src/handlers/stream/transcode_handlers.rs @@ -0,0 +1,183 @@ +use std::{fmt, str::FromStr}; + +use axum::{ + Extension, Json, + extract::{Path as AxumPath, Query, State}, + http::{HeaderMap, StatusCode, header}, + response::Response, +}; +use ferrex_core::{ + api::types::ApiResponse, + domain::users::{auth::domain::value_objects::SessionScope, user::User}, +}; +use ferrex_model::{ + StartTranscodeRequest, TranscodeJobStatusResponse, TranscodeQualityProfile, +}; +use serde::Deserialize; +use tokio_util::io::ReaderStream; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::{ + handlers::stream::stream_handlers::{ + PlaybackHttpError, ensure_playback_source_available, + load_playback_source, + }, + infra::{ + app_state::AppState, + transcode::{TranscodeStatusLookupError, rendition_root}, + }, +}; + +#[derive(Deserialize)] +pub struct TranscodeAuthQuery { + #[serde(default)] + access_token: Option, +} + +impl fmt::Debug for TranscodeAuthQuery { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TranscodeAuthQuery") + .field( + "access_token", + &self.access_token.as_ref().map(|_| ""), + ) + .finish() + } +} + +pub async fn start_transcode_handler( + State(state): State, + Extension(user): Extension, + AxumPath(media_id): AxumPath, + Json(request): Json, +) -> Result>, PlaybackHttpError> { + let source = load_playback_source(&state, media_id).await?; + ensure_playback_source_available(&state, &source)?; + let status = state + .transcode_manager() + .start(user.id, source, request.profile) + .await; + Ok(Json(ApiResponse::success(status))) +} + +pub async fn transcode_status_handler( + State(state): State, + Extension(user): Extension, + AxumPath(job_id): AxumPath, +) -> Result>, StatusCode> { + match state.transcode_manager().status(user.id, job_id).await { + Ok(status) => Ok(Json(ApiResponse::success(status))), + Err(TranscodeStatusLookupError::NotFound) => Err(StatusCode::NOT_FOUND), + Err(TranscodeStatusLookupError::Forbidden) => { + Err(StatusCode::FORBIDDEN) + } + } +} + +pub async fn transcode_asset_handler( + State(state): State, + AxumPath((media_id, profile, asset)): AxumPath<(Uuid, String, String)>, + headers: HeaderMap, + Query(query): Query, +) -> Result { + validate_playback_token(&state, &headers, query.access_token.as_deref()) + .await?; + + let source = load_playback_source(&state, media_id).await?; + ensure_playback_source_available(&state, &source)?; + + let profile = TranscodeQualityProfile::from_str(&profile) + .map_err(|_| PlaybackHttpError::media_not_found())?; + if !safe_asset_name(&asset) { + return Err(PlaybackHttpError::media_not_found()); + } + + let root = + rendition_root(state.config().transcode_cache_dir(), media_id, profile); + let path = root.join(&asset); + let metadata = tokio::fs::symlink_metadata(&path) + .await + .map_err(|_| PlaybackHttpError::file_missing())?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(PlaybackHttpError::file_missing()); + } + let file = tokio::fs::File::open(&path).await.map_err(|err| { + warn!(?err, %media_id, profile = %profile, "could not open transcode asset"); + PlaybackHttpError::file_missing() + })?; + let content_type = if asset == "index.m3u8" { + "application/vnd.apple.mpegurl" + } else { + "video/mp2t" + }; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, metadata.len().to_string()) + .header(header::CACHE_CONTROL, "private, no-store") + .body(axum::body::Body::from_stream(ReaderStream::new(file))) + .map_err(|err| { + error!(?err, %media_id, "could not build transcode response"); + PlaybackHttpError::internal() + }) +} + +async fn validate_playback_token( + state: &AppState, + headers: &HeaderMap, + query_token: Option<&str>, +) -> Result<(), PlaybackHttpError> { + let token = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .or(query_token) + .ok_or_else(PlaybackHttpError::missing_token)?; + + let validated = state + .auth_service() + .validate_session_token(token) + .await + .map_err(|_| PlaybackHttpError::invalid_token())?; + match validated.scope { + SessionScope::Full | SessionScope::Playback => Ok(()), + } +} + +fn safe_asset_name(asset: &str) -> bool { + if asset == "index.m3u8" { + return true; + } + let Some(sequence) = asset + .strip_prefix("segment-") + .and_then(|value| value.strip_suffix(".ts")) + else { + return false; + }; + sequence.len() == 5 && sequence.bytes().all(|byte| byte.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transcode_asset_names_are_closed_and_non_traversable() { + for accepted in ["index.m3u8", "segment-00000.ts", "segment-99999.ts"] { + assert!(safe_asset_name(accepted), "{accepted}"); + } + for rejected in [ + "master.m3u8", + "segment-1.ts", + "segment-00000.m4s", + "../index.m3u8", + "%2e%2e", + "segment-0000a.ts", + ] { + assert!(!safe_asset_name(rejected), "{rejected}"); + } + } +} diff --git a/crates/ferrex-server/src/infra/app_state.rs b/crates/ferrex-server/src/infra/app_state.rs index 12e466f1..6f5cf186 100644 --- a/crates/ferrex-server/src/infra/app_state.rs +++ b/crates/ferrex-server/src/infra/app_state.rs @@ -12,6 +12,7 @@ use crate::infra::cache::{MovieBatchesCache, SeriesBundlesCache}; use crate::infra::config::Config; use crate::infra::scan::scan_manager::ScanControlPlane; use crate::infra::thumbnail_service::ThumbnailService; +use crate::infra::transcode::TranscodeManager; use crate::infra::websocket::ConnectionManager; use ferrex_core::application::{ intelligence_runtime::IntelligenceRunManager, unit_of_work::AppUnitOfWork, @@ -36,6 +37,7 @@ use crate::demo::DemoCoordinator; #[derive(Clone)] pub struct AppState { context: Arc, + transcode_manager: TranscodeManager, /// Track admin sessions per device for PIN authentication eligibility pub admin_sessions: Arc>>, pub series_bundles_cache: Arc, @@ -81,8 +83,13 @@ impl AppState { series_bundles_cache: Arc, movie_batches_cache: Arc, ) -> Self { + let transcode_manager = TranscodeManager::new( + context.config().ffmpeg.ffmpeg_path.clone(), + context.config().transcode_cache_dir().to_path_buf(), + ); Self { context, + transcode_manager, admin_sessions, series_bundles_cache, movie_batches_cache, @@ -125,6 +132,10 @@ impl AppState { self.context.thumbnail_service() } + pub fn transcode_manager(&self) -> &TranscodeManager { + &self.transcode_manager + } + pub fn image_service(&self) -> Arc { self.context.image_service() } diff --git a/crates/ferrex-server/src/infra/mod.rs b/crates/ferrex-server/src/infra/mod.rs index d2d79665..a2c04790 100644 --- a/crates/ferrex-server/src/infra/mod.rs +++ b/crates/ferrex-server/src/infra/mod.rs @@ -34,5 +34,7 @@ pub mod scan; pub mod startup; /// Thumbnail resolution and serving support. pub mod thumbnail_service; +/// Bounded FFmpeg HLS generation and cache publication. +pub mod transcode; /// Websocket connection management and event messages. pub mod websocket; diff --git a/crates/ferrex-server/src/infra/transcode.rs b/crates/ferrex-server/src/infra/transcode.rs new file mode 100644 index 00000000..c0fd732e --- /dev/null +++ b/crates/ferrex-server/src/infra/transcode.rs @@ -0,0 +1,577 @@ +//! Bounded server-side HLS transcoding and cache publication. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::{Duration, Instant}, +}; + +use ferrex_core::database::repository_ports::media_files::PlaybackMediaSource; +use ferrex_model::{ + TranscodeJobState, TranscodeJobStatusResponse, TranscodeQualityProfile, +}; +use tokio::sync::{RwLock, Semaphore}; +use tracing::{error, info, warn}; +use uuid::Uuid; + +const MAX_CONCURRENT_TRANSCODES: usize = 2; +const JOB_RETENTION: Duration = Duration::from_secs(6 * 60 * 60); +const QUEUE_TIMEOUT: Duration = Duration::from_secs(30 * 60); +const TRANSCODE_TIMEOUT: Duration = Duration::from_secs(6 * 60 * 60); + +#[derive(Debug, Clone)] +pub struct TranscodeManager { + inner: Arc, +} + +#[derive(Debug)] +struct TranscodeManagerInner { + ffmpeg_path: String, + cache_root: PathBuf, + jobs: RwLock>, + permits: Semaphore, +} + +#[derive(Debug, Clone)] +struct JobRecord { + owner_id: Uuid, + created_at: Instant, + response: TranscodeJobStatusResponse, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TranscodeStatusLookupError { + NotFound, + Forbidden, +} + +impl TranscodeManager { + pub fn new(ffmpeg_path: String, cache_root: PathBuf) -> Self { + Self { + inner: Arc::new(TranscodeManagerInner { + ffmpeg_path, + cache_root, + jobs: RwLock::new(HashMap::new()), + permits: Semaphore::new(MAX_CONCURRENT_TRANSCODES), + }), + } + } + + pub async fn start( + &self, + owner_id: Uuid, + source: PlaybackMediaSource, + profile: TranscodeQualityProfile, + ) -> TranscodeJobStatusResponse { + self.prune_expired_jobs().await; + + let playback_path = playback_path(source.id, profile); + if cache_is_current(&self.inner.cache_root, &source, profile).await { + let response = TranscodeJobStatusResponse { + job_id: Uuid::now_v7().to_string(), + media_id: source.id.to_string(), + profile, + state: TranscodeJobState::Completed, + progress: Some(1.0), + message: Some("Cached rendition is ready".to_string()), + playback_path: Some(playback_path), + }; + self.insert(owner_id, response.clone()).await; + return response; + } + + { + let jobs = self.inner.jobs.read().await; + let media_id = source.id.to_string(); + if let Some(existing) = jobs.values().find(|record| { + record.owner_id == owner_id + && record.response.media_id == media_id + && record.response.profile == profile + && matches!( + record.response.state, + TranscodeJobState::Queued | TranscodeJobState::Running + ) + }) { + return existing.response.clone(); + } + } + + let job_id = Uuid::now_v7(); + let response = TranscodeJobStatusResponse { + job_id: job_id.to_string(), + media_id: source.id.to_string(), + profile, + state: TranscodeJobState::Queued, + progress: Some(0.0), + message: Some("Waiting for a transcoder worker".to_string()), + playback_path: None, + }; + self.insert(owner_id, response.clone()).await; + + let inner = Arc::clone(&self.inner); + tokio::spawn(async move { + run_job(inner, job_id, source, profile, playback_path).await; + }); + + response + } + + pub async fn status( + &self, + owner_id: Uuid, + job_id: Uuid, + ) -> Result { + let jobs = self.inner.jobs.read().await; + let record = jobs + .get(&job_id) + .ok_or(TranscodeStatusLookupError::NotFound)?; + if record.owner_id != owner_id { + return Err(TranscodeStatusLookupError::Forbidden); + } + Ok(record.response.clone()) + } + + async fn insert( + &self, + owner_id: Uuid, + response: TranscodeJobStatusResponse, + ) { + let Ok(job_id) = Uuid::parse_str(&response.job_id) else { + return; + }; + self.inner.jobs.write().await.insert( + job_id, + JobRecord { + owner_id, + created_at: Instant::now(), + response, + }, + ); + } + + async fn prune_expired_jobs(&self) { + self.inner + .jobs + .write() + .await + .retain(|_, job| job.created_at.elapsed() < JOB_RETENTION); + } +} + +async fn run_job( + inner: Arc, + job_id: Uuid, + source: PlaybackMediaSource, + profile: TranscodeQualityProfile, + playback_path: String, +) { + let _permit = match tokio::time::timeout( + QUEUE_TIMEOUT, + inner.permits.acquire(), + ) + .await + { + Ok(Ok(permit)) => permit, + Ok(Err(_)) => { + update_failed(&inner, job_id, "Transcoder is shutting down").await; + return; + } + Err(_) => { + update_failed(&inner, job_id, "Transcoder queue timed out").await; + return; + } + }; + + update_job(&inner, job_id, |response| { + response.state = TranscodeJobState::Running; + response.progress = None; + response.message = Some("Generating HLS rendition".to_string()); + }) + .await; + + let staging_root = inner.cache_root.join(".jobs").join(job_id.to_string()); + let final_root = rendition_root(&inner.cache_root, source.id, profile); + + if let Err(err) = tokio::fs::create_dir_all(&staging_root).await { + error!(?err, %job_id, "could not create transcode staging directory"); + update_failed(&inner, job_id, "Could not prepare transcode output") + .await; + return; + } + + let playlist = staging_root.join("index.m3u8"); + let segment_pattern = staging_root.join("segment-%05d.ts"); + let args = ffmpeg_args(&source.path, profile, &segment_pattern, &playlist); + info!(%job_id, media_id = %source.id, profile = %profile, "starting HLS transcode"); + + let mut command = tokio::process::Command::new(&inner.ffmpeg_path); + command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + + let succeeded = match tokio::time::timeout( + TRANSCODE_TIMEOUT, + command.status(), + ) + .await + { + Ok(Ok(status)) if status.success() => true, + Ok(Ok(status)) => { + warn!(%job_id, status = ?status.code(), "FFmpeg transcode failed"); + false + } + Ok(Err(err)) => { + error!(?err, %job_id, "could not launch FFmpeg transcoder"); + false + } + Err(_) => { + warn!(%job_id, timeout_seconds = TRANSCODE_TIMEOUT.as_secs(), "FFmpeg transcode timed out and was terminated"); + false + } + }; + + if !succeeded || !valid_hls_output(&staging_root).await { + let _ = tokio::fs::remove_dir_all(&staging_root).await; + update_failed( + &inner, + job_id, + "FFmpeg could not generate this rendition", + ) + .await; + return; + } + + if let Some(parent) = final_root.parent() + && let Err(err) = tokio::fs::create_dir_all(parent).await + { + error!(?err, %job_id, "could not create transcode cache directory"); + let _ = tokio::fs::remove_dir_all(&staging_root).await; + update_failed(&inner, job_id, "Could not publish transcode output") + .await; + return; + } + if tokio::fs::try_exists(&final_root).await.unwrap_or(false) + && let Err(err) = tokio::fs::remove_dir_all(&final_root).await + { + error!(?err, %job_id, "could not replace stale transcode cache"); + let _ = tokio::fs::remove_dir_all(&staging_root).await; + update_failed(&inner, job_id, "Could not publish transcode output") + .await; + return; + } + if let Err(err) = tokio::fs::rename(&staging_root, &final_root).await { + error!(?err, %job_id, "could not atomically publish transcode output"); + let _ = tokio::fs::remove_dir_all(&staging_root).await; + update_failed(&inner, job_id, "Could not publish transcode output") + .await; + return; + } + + update_job(&inner, job_id, |response| { + response.state = TranscodeJobState::Completed; + response.progress = Some(1.0); + response.message = Some("HLS rendition is ready".to_string()); + response.playback_path = Some(playback_path); + }) + .await; + info!(%job_id, media_id = %source.id, profile = %profile, "HLS transcode completed"); +} + +async fn update_failed( + inner: &TranscodeManagerInner, + job_id: Uuid, + message: &'static str, +) { + update_job(inner, job_id, |response| { + response.state = TranscodeJobState::Failed; + response.progress = None; + response.message = Some(message.to_string()); + response.playback_path = None; + }) + .await; +} + +async fn update_job( + inner: &TranscodeManagerInner, + job_id: Uuid, + update: impl FnOnce(&mut TranscodeJobStatusResponse), +) { + if let Some(job) = inner.jobs.write().await.get_mut(&job_id) { + update(&mut job.response); + } +} + +fn playback_path(media_id: Uuid, profile: TranscodeQualityProfile) -> String { + format!( + "/api/v1/transcode/{}/{}/index.m3u8", + media_id, + profile.as_str() + ) +} + +pub fn rendition_root( + cache_root: &Path, + media_id: Uuid, + profile: TranscodeQualityProfile, +) -> PathBuf { + cache_root.join(media_id.to_string()).join(profile.as_str()) +} + +async fn cache_is_current( + cache_root: &Path, + source: &PlaybackMediaSource, + profile: TranscodeQualityProfile, +) -> bool { + let root = rendition_root(cache_root, source.id, profile); + if !valid_hls_output(&root).await { + return false; + } + + let Ok(source_modified) = tokio::fs::metadata(&source.path) + .await + .and_then(|metadata| metadata.modified()) + else { + return false; + }; + tokio::fs::metadata(root.join("index.m3u8")) + .await + .and_then(|metadata| metadata.modified()) + .is_ok_and(|manifest_modified| manifest_modified >= source_modified) +} + +async fn valid_hls_output(root: &Path) -> bool { + let Ok(manifest) = tokio::fs::read_to_string(root.join("index.m3u8")).await + else { + return false; + }; + if !manifest.starts_with("#EXTM3U") + || !manifest.lines().any(|line| line.ends_with(".ts")) + { + return false; + } + + let Ok(mut entries) = tokio::fs::read_dir(root).await else { + return false; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) == Some("ts") + && entry + .metadata() + .await + .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) + { + return true; + } + } + false +} + +fn ffmpeg_args( + source: &Path, + profile: TranscodeQualityProfile, + segment_pattern: &Path, + playlist: &Path, +) -> Vec { + let mut args = vec![ + "-hide_banner".to_string(), + "-nostdin".to_string(), + "-loglevel".to_string(), + "error".to_string(), + "-y".to_string(), + "-i".to_string(), + source.to_string_lossy().into_owned(), + "-map".to_string(), + "0:v:0".to_string(), + "-map".to_string(), + "0:a:0?".to_string(), + ]; + + let (dimensions, bitrate, maxrate, buffer) = match profile { + TranscodeQualityProfile::Original => (None, "12M", "14M", "24M"), + TranscodeQualityProfile::P1080 => { + (Some((1920, 1080)), "8M", "9M", "16M") + } + TranscodeQualityProfile::P720 => (Some((1280, 720)), "4M", "5M", "8M"), + TranscodeQualityProfile::P480 => { + (Some((854, 480)), "2M", "2400k", "4M") + } + TranscodeQualityProfile::P360 => { + (Some((640, 360)), "800k", "960k", "1600k") + } + }; + if let Some((width, height)) = dimensions { + args.extend([ + "-vf".to_string(), + format!( + "scale=w={width}:h={height}:force_original_aspect_ratio=decrease:force_divisible_by=2" + ), + ]); + } + + args.extend([ + "-c:v".to_string(), + "libx264".to_string(), + "-preset".to_string(), + "veryfast".to_string(), + "-pix_fmt".to_string(), + "yuv420p".to_string(), + "-b:v".to_string(), + bitrate.to_string(), + "-maxrate".to_string(), + maxrate.to_string(), + "-bufsize".to_string(), + buffer.to_string(), + "-force_key_frames".to_string(), + "expr:gte(t,n_forced*4)".to_string(), + "-c:a".to_string(), + "aac".to_string(), + "-b:a".to_string(), + "160k".to_string(), + "-ac".to_string(), + "2".to_string(), + "-max_muxing_queue_size".to_string(), + "2048".to_string(), + "-f".to_string(), + "hls".to_string(), + "-hls_time".to_string(), + "4".to_string(), + "-hls_playlist_type".to_string(), + "vod".to_string(), + "-hls_flags".to_string(), + "independent_segments".to_string(), + "-hls_segment_filename".to_string(), + segment_pattern.to_string_lossy().into_owned(), + playlist.to_string_lossy().into_owned(), + ]); + args +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + #[test] + fn profiles_map_to_argument_separated_ffmpeg_commands() { + for profile in TranscodeQualityProfile::ALL { + let args = ffmpeg_args( + Path::new("/media/a file;not-a-command.mkv"), + profile, + Path::new("/cache/segment-%05d.ts"), + Path::new("/cache/index.m3u8"), + ); + assert_eq!( + args.iter() + .position(|arg| arg == "-i") + .and_then(|index| args.get(index + 1)) + .map(String::as_str), + Some("/media/a file;not-a-command.mkv") + ); + assert!(args.iter().any(|arg| arg == "hls")); + assert!(args.iter().any(|arg| arg.ends_with("segment-%05d.ts"))); + } + } + + #[test] + fn rendition_paths_are_closed_over_the_typed_profile_set() { + let root = Path::new("/cache/transcode"); + let media_id = Uuid::nil(); + for profile in TranscodeQualityProfile::ALL { + let path = rendition_root(root, media_id, profile); + assert!(path.starts_with(root)); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some(profile.as_str()) + ); + } + } + + #[tokio::test] + #[ignore = "requires an FFmpeg binary with libx264 and AAC encoders"] + async fn real_ffmpeg_job_generates_and_reuses_a_complete_hls_rendition() { + let tempdir = tempfile::tempdir().expect("temporary transcode root"); + let source_path = tempdir.path().join("source.mkv"); + let generated = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "color=c=blue:s=640x360:d=1:r=24", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:v", + "libx264", + "-c:a", + "aac", + source_path.to_str().expect("UTF-8 test path"), + ]) + .status() + .expect("launch fixture FFmpeg"); + assert!(generated.success(), "fixture generation failed"); + + let media_id = Uuid::new_v4(); + let source = PlaybackMediaSource { + id: media_id, + path: source_path, + filename: "source.mkv".to_string(), + size: 1, + is_available: true, + }; + let manager = TranscodeManager::new( + "ffmpeg".to_string(), + tempdir.path().join("cache"), + ); + let owner = Uuid::new_v4(); + let started = manager + .start(owner, source.clone(), TranscodeQualityProfile::P360) + .await; + let job_id = Uuid::parse_str(&started.job_id).expect("job UUID"); + + let completed = tokio::time::timeout(Duration::from_secs(20), async { + loop { + let status = + manager.status(owner, job_id).await.expect("job status"); + match status.state { + TranscodeJobState::Completed => break status, + TranscodeJobState::Failed => { + panic!("real transcode failed: {:?}", status.message) + } + TranscodeJobState::Queued | TranscodeJobState::Running => { + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + } + }) + .await + .expect("real transcode timed out"); + assert_eq!(completed.progress, Some(1.0)); + assert!(completed.playback_path.is_some()); + assert!( + valid_hls_output(&rendition_root( + &manager.inner.cache_root, + media_id, + TranscodeQualityProfile::P360, + )) + .await + ); + + let cached = manager + .start(owner, source, TranscodeQualityProfile::P360) + .await; + assert_eq!(cached.state, TranscodeJobState::Completed); + assert_eq!(cached.progress, Some(1.0)); + assert_eq!(cached.playback_path, completed.playback_path); + } +} diff --git a/crates/ferrex-server/src/routes/v1.rs b/crates/ferrex-server/src/routes/v1.rs index 7567cfea..073badec 100644 --- a/crates/ferrex-server/src/routes/v1.rs +++ b/crates/ferrex-server/src/routes/v1.rs @@ -12,6 +12,7 @@ use crate::handlers::collections; use crate::handlers::discovery; use crate::handlers::media::collections as collection_media_handlers; use crate::handlers::stream::stream_handlers; +use crate::handlers::stream::transcode_handlers; use crate::handlers::users::admin_user_management; use crate::handlers::users::{ admin_handlers, auth, role_handlers, security_settings_handlers, @@ -100,6 +101,10 @@ pub fn create_v1_router(state: AppState) -> Router { v1::stream::PLAY, get(stream_handlers::stream_with_progress_handler), ) + .route( + v1::transcode::ASSET, + get(transcode_handlers::transcode_asset_handler), + ) // .merge(create_libraries_routes(state.clone())) .merge(create_scan_routes(state.clone())) @@ -421,6 +426,14 @@ fn create_protected_routes(state: AppState) -> Router { v1::stream::PLAYBACK_TICKET, get(stream_handlers::playback_ticket_handler), ) + .route( + v1::transcode::START, + post(transcode_handlers::start_transcode_handler), + ) + .route( + v1::transcode::STATUS, + get(transcode_handlers::transcode_status_handler), + ) // Sync session endpoints // Unimplemented //.route( diff --git a/crates/ferrex-server/tests/transcode_routes.rs b/crates/ferrex-server/tests/transcode_routes.rs new file mode 100644 index 00000000..4f4363e2 --- /dev/null +++ b/crates/ferrex-server/tests/transcode_routes.rs @@ -0,0 +1,210 @@ +use anyhow::Result; +use axum::Router; +use axum::http::{StatusCode, header}; +use axum_test::TestServer; +use ferrex_core::api::routes::{utils as route_utils, v1}; +use ferrex_server::infra::startup::NoopStartupHooks; +use serde_json::json; +use sqlx::PgPool; +use std::{net::SocketAddr, path::Path}; +use uuid::Uuid; + +mod common; +use common::build_test_app_with_hooks; + +fn bearer(token: &str) -> String { + format!("Bearer {token}") +} + +async fn register_user(server: &TestServer, username: &str) -> String { + let response = server + .post(v1::auth::REGISTER) + .json(&json!({ + "username": username, + "display_name": username, + "password": "Password#123" + })) + .await; + response.assert_status_ok(); + response.json::()["data"]["access_token"] + .as_str() + .expect("registration token") + .to_string() +} + +async fn seed_library(pool: &PgPool, id: Uuid) { + sqlx::query( + r#" + INSERT INTO libraries (id, name, library_type, paths) + VALUES ($1, $2, 'movies', ARRAY['/tmp']) + "#, + ) + .bind(id) + .bind(format!("transcode-test-{id}")) + .execute(pool) + .await + .expect("insert library"); +} + +async fn seed_media_file( + pool: &PgPool, + library_id: Uuid, + file_id: Uuid, + path: &Path, +) { + let size = tokio::fs::metadata(path) + .await + .expect("source metadata") + .len(); + sqlx::query( + r#" + INSERT INTO media_files ( + id, library_id, media_id, media_type, file_path, filename, + file_size, technical_metadata, is_available + ) VALUES ($1, $2, $3, 'movie', $4, $5, $6, '{}'::jsonb, true) + "#, + ) + .bind(file_id) + .bind(library_id) + .bind(Uuid::new_v4()) + .bind(path.to_string_lossy().to_string()) + .bind("transcode-source.mkv") + .bind(i64::try_from(size).expect("fixture size fits i64")) + .execute(pool) + .await + .expect("insert media file"); +} + +fn start_path(media_id: Uuid) -> String { + route_utils::replace_param( + v1::transcode::START, + "{id}", + media_id.to_string(), + ) +} + +fn status_path(job_id: &str) -> String { + route_utils::replace_param(v1::transcode::STATUS, "{job_id}", job_id) +} + +fn asset_path(media_id: Uuid, profile: &str, asset: &str) -> String { + v1::transcode::ASSET + .replace("{id}", &media_id.to_string()) + .replace("{profile}", profile) + .replace("{asset}", asset) +} + +#[sqlx::test(migrator = "ferrex_core::MIGRATOR")] +async fn cached_transcode_job_and_every_hls_asset_require_scoped_auth( + pool: PgPool, +) -> Result<()> { + let app = + build_test_app_with_hooks(pool.clone(), &NoopStartupHooks).await?; + let (router, state, _tempdir) = app.into_parts(); + + let library_id = Uuid::new_v4(); + let media_id = Uuid::new_v4(); + seed_library(&pool, library_id).await; + let source = state.config().cache_root().join("transcode-source.mkv"); + tokio::fs::write(&source, b"source fixture").await?; + seed_media_file(&pool, library_id, media_id, &source).await; + + let rendition = state + .config() + .transcode_cache_dir() + .join(media_id.to_string()) + .join("480p"); + tokio::fs::create_dir_all(&rendition).await?; + tokio::fs::write(rendition.join("segment-00000.ts"), b"segment bytes") + .await?; + tokio::fs::write( + rendition.join("index.m3u8"), + b"#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:1.0,\nsegment-00000.ts\n#EXT-X-ENDLIST\n", + ) + .await?; + + let router: Router<()> = router.with_state(state); + let server = TestServer::builder() + .http_transport() + .build(router.into_make_service_with_connect_info::()) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let owner_token = register_user(&server, "transcode_owner").await; + let other_token = register_user(&server, "transcode_other").await; + + let start = server + .post(&start_path(media_id)) + .add_header("Authorization", bearer(&owner_token)) + .json(&json!({ "profile": "480p" })) + .await; + start.assert_status_ok(); + let start_body: serde_json::Value = start.json(); + assert_eq!(start_body["data"]["state"], "completed"); + assert_eq!(start_body["data"]["profile"], "480p"); + assert_eq!( + start_body["data"]["playback_path"], + asset_path(media_id, "480p", "index.m3u8") + ); + let job_id = start_body["data"]["job_id"].as_str().expect("job id"); + + let forbidden_status = server + .get(&status_path(job_id)) + .add_header("Authorization", bearer(&other_token)) + .await; + forbidden_status.assert_status(StatusCode::FORBIDDEN); + + let status = server + .get(&status_path(job_id)) + .add_header("Authorization", bearer(&owner_token)) + .await; + status.assert_status_ok(); + assert_eq!( + status.json::()["data"]["state"], + "completed" + ); + + let ticket = server + .get(&route_utils::replace_param( + v1::stream::PLAYBACK_TICKET, + "{id}", + media_id.to_string(), + )) + .add_header("Authorization", bearer(&owner_token)) + .await; + ticket.assert_status_ok(); + let ticket = ticket.json::()["data"]["access_token"] + .as_str() + .expect("playback ticket") + .to_string(); + + for (asset, content_type, expected) in [ + ( + "index.m3u8", + "application/vnd.apple.mpegurl", + b"#EXTM3U".as_slice(), + ), + ( + "segment-00000.ts", + "video/mp2t", + b"segment bytes".as_slice(), + ), + ] { + let path = asset_path(media_id, "480p", asset); + let unauthenticated = server.get(&path).await; + unauthenticated.assert_status(StatusCode::UNAUTHORIZED); + + let response = server + .get(&path) + .add_header("Authorization", bearer(&ticket)) + .await; + response.assert_status_ok(); + assert_eq!( + response + .maybe_header(header::CONTENT_TYPE) + .expect("content type"), + content_type + ); + assert!(response.as_bytes().starts_with(expected)); + } + + Ok(()) +} From e3ee5f2bd3096572462bc88d4337ded375790cf2 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:14:29 -0600 Subject: [PATCH 06/56] feat(player): add the libmpv control-plane foundation --- Cargo.lock | 15 + Cargo.toml | 5 + crates/ferrex-player-mpv/Cargo.toml | 33 + crates/ferrex-player-mpv/build.rs | 110 + crates/ferrex-player-mpv/src/ffi.rs | 526 +++++ crates/ferrex-player-mpv/src/lib.rs | 33 + crates/ferrex-player-mpv/src/macos.rs | 278 +++ crates/ferrex-player-mpv/src/node.rs | 641 ++++++ crates/ferrex-player-mpv/src/owner.rs | 747 +++++++ crates/ferrex-player-mpv/src/raw.rs | 520 +++++ crates/ferrex-player-mpv/src/session.rs | 2532 +++++++++++++++++++++++ deny.toml | 16 +- 12 files changed, 5454 insertions(+), 2 deletions(-) create mode 100644 crates/ferrex-player-mpv/Cargo.toml create mode 100644 crates/ferrex-player-mpv/build.rs create mode 100644 crates/ferrex-player-mpv/src/ffi.rs create mode 100644 crates/ferrex-player-mpv/src/lib.rs create mode 100644 crates/ferrex-player-mpv/src/macos.rs create mode 100644 crates/ferrex-player-mpv/src/node.rs create mode 100644 crates/ferrex-player-mpv/src/owner.rs create mode 100644 crates/ferrex-player-mpv/src/raw.rs create mode 100644 crates/ferrex-player-mpv/src/session.rs diff --git a/Cargo.lock b/Cargo.lock index 6f4b902c..770b082d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2370,6 +2370,15 @@ dependencies = [ name = "ferrex-player-metadata" version = "0.1.2-alpha" +[[package]] +name = "ferrex-player-mpv" +version = "0.1.2-alpha" +dependencies = [ + "libmpv2-sys", + "pkg-config", + "thiserror 2.0.18", +] + [[package]] name = "ferrex-player-playback" version = "0.1.2-alpha" @@ -4494,6 +4503,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmpv2-sys" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb32cabe7176b7d270b0dca6ff418b75187ae8d3854423e3d06fbff376841e4" + [[package]] name = "libredox" version = "0.1.15" diff --git a/Cargo.toml b/Cargo.toml index c0a59e51..9fd51580 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/ferrex-player-library", "crates/ferrex-player-media", "crates/ferrex-player-metadata", + "crates/ferrex-player-mpv", "crates/ferrex-player-playback", "crates/ferrex-player-search", "crates/ferrex-player-settings", @@ -98,6 +99,10 @@ version = "0.1.2-alpha" path = "crates/ferrex-player-metadata" version = "0.1.2-alpha" +[workspace.dependencies.ferrex-player-mpv] +path = "crates/ferrex-player-mpv" +version = "0.1.2-alpha" + [workspace.dependencies.ferrex-player-playback] path = "crates/ferrex-player-playback" version = "0.1.2-alpha" diff --git a/crates/ferrex-player-mpv/Cargo.toml b/crates/ferrex-player-mpv/Cargo.toml new file mode 100644 index 00000000..885290a6 --- /dev/null +++ b/crates/ferrex-player-mpv/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "ferrex-player-mpv" +description = "Optional libmpv control-plane foundation for Ferrex desktop playback" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +readme = "../../README.md" +publish = true + +[lints] +workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[features] +default = [] +# Link the production function table to the libmpv shared library. Keeping this +# optional preserves backend-disabled builds and display-free unit tests. +linked = ["dep:libmpv2-sys"] + +[dependencies] +libmpv2-sys = { version = "=4.0.1", optional = true } +thiserror.workspace = true + +[build-dependencies] +pkg-config = "0.3" diff --git a/crates/ferrex-player-mpv/build.rs b/crates/ferrex-player-mpv/build.rs new file mode 100644 index 00000000..1fd2c4ad --- /dev/null +++ b/crates/ferrex-player-mpv/build.rs @@ -0,0 +1,110 @@ +//! Validate and locate the optional linked libmpv development library. + +use std::{env, path::PathBuf}; + +fn main() { + println!("cargo:rerun-if-env-changed=LIBMPV_LIB_DIR"); + println!("cargo:rerun-if-env-changed=LIBMPV_INCLUDE_DIR"); + println!("cargo:rerun-if-env-changed=LIBMPV_DLL_DIR"); + println!("cargo:rerun-if-env-changed=LIBMPV_DLL"); + println!("cargo:rerun-if-env-changed=DOCS_RS"); + + if env::var_os("CARGO_FEATURE_LINKED").is_none() + || env::var_os("DOCS_RS").is_some() + { + return; + } + + let target_os = env::var("CARGO_CFG_TARGET_OS") + .expect("Cargo must provide CARGO_CFG_TARGET_OS"); + + if target_os == "windows" { + let target_env = env::var("CARGO_CFG_TARGET_ENV") + .expect("Cargo must provide CARGO_CFG_TARGET_ENV"); + let lib_dir = + required_directory("LIBMPV_LIB_DIR", "libmpv import library"); + let include_dir = + required_directory("LIBMPV_INCLUDE_DIR", "libmpv client headers"); + let client_header = include_dir.join("mpv").join("client.h"); + if !client_header.is_file() { + panic!( + "LIBMPV_INCLUDE_DIR must contain mpv/client.h; missing {}", + client_header.display() + ) + } + + let import_names: &[&str] = if target_env == "msvc" { + &["mpv.lib"] + } else { + &["libmpv.dll.a", "mpv.dll.a"] + }; + if find_first(&lib_dir, import_names).is_none() { + panic!( + "LIBMPV_LIB_DIR does not contain the required {target_env} \ + import library (expected one of {} in {})", + import_names.join(", "), + lib_dir.display(), + ); + } + + let runtime = env::var_os("LIBMPV_DLL") + .map(PathBuf::from) + .or_else(|| { + env::var_os("LIBMPV_DLL_DIR").and_then(|directory| { + find_first( + &PathBuf::from(directory), + &["libmpv-2.dll", "mpv-2.dll", "mpv.dll"], + ) + }) + }) + .unwrap_or_else(|| { + panic!( + "the Windows `linked` feature requires the matching \ + libmpv runtime; set LIBMPV_DLL to the DLL path or \ + LIBMPV_DLL_DIR to its directory" + ) + }); + if !runtime.is_file() { + panic!("libmpv runtime DLL is missing: {}", runtime.display()); + } + + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + println!("cargo:rustc-env=FERREX_LIBMPV_DLL={}", runtime.display()); + return; + } + + if let Err(error) = pkg_config::Config::new() + .atleast_version("2.2.0") + .probe("mpv") + { + panic!( + "the `linked` feature requires libmpv client API 2.2 \ + (mpv >= 0.37) and its pkg-config metadata; install the development \ + package, enter `nix develop .#ferrex-player`, or disable the \ + higher-level `mpv` feature: {error}" + ); + } +} + +fn required_directory(variable: &str, purpose: &str) -> PathBuf { + let directory = env::var_os(variable).map(PathBuf::from).unwrap_or_else(|| { + panic!( + "the Windows `linked` feature requires {purpose}; set {variable} \ + to its directory, or disable the higher-level `mpv` feature" + ) + }); + if !directory.is_dir() { + panic!( + "{variable} does not name a directory: {}", + directory.display() + ); + } + directory +} + +fn find_first(directory: &std::path::Path, names: &[&str]) -> Option { + names + .iter() + .map(|name| directory.join(name)) + .find(|path| path.is_file()) +} diff --git a/crates/ferrex-player-mpv/src/ffi.rs b/crates/ferrex-player-mpv/src/ffi.rs new file mode 100644 index 00000000..2e0e4104 --- /dev/null +++ b/crates/ferrex-player-mpv/src/ffi.rs @@ -0,0 +1,526 @@ +//! Minimal, fakeable libmpv ABI boundary. +//! +//! Lifecycle symbols stay here while the P3 command/property/event symbols +//! live in the crate-private raw control table. Neither table exposes binding +//! types to the playback domain. + +use std::{ + ffi::{c_int, c_ulong, c_void}, + fmt, + marker::PhantomData, + ptr::NonNull, + rc::Rc, +}; + +use crate::raw::{MpvControlApi, RawMpvHandle}; + +/// Client API version represented by libmpv's packed major/minor integer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MpvClientApiVersion { + /// ABI major version. + pub major: u16, + /// Backwards-compatible API minor version. + pub minor: u16, +} + +impl MpvClientApiVersion { + /// Construct an API version. + pub const fn new(major: u16, minor: u16) -> Self { + Self { major, minor } + } + + /// Decode the value returned by `mpv_client_api_version`. + pub const fn from_packed(value: u64) -> Self { + Self { + major: ((value >> 16) & 0xffff) as u16, + minor: (value & 0xffff) as u16, + } + } + + /// Encode this version using libmpv's `MPV_MAKE_VERSION` layout. + pub const fn packed(self) -> u64 { + ((self.major as u64) << 16) | self.minor as u64 + } + + /// Whether this runtime can satisfy the required client ABI. + pub const fn satisfies(self, required: Self) -> bool { + self.major == required.major && self.minor >= required.minor + } +} + +impl fmt::Display for MpvClientApiVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}.{}", self.major, self.minor) + } +} + +/// Client API represented by the selected `libmpv2-sys` 4.0.1 bindings. +pub const BINDINGS_CLIENT_API: MpvClientApiVersion = + MpvClientApiVersion::new(2, 5); + +/// Oldest runtime accepted by the initial Ferrex libmpv integration. +/// +/// API 2.2 corresponds to mpv 0.37.0 and already contains every client symbol +/// required by P3. Release artifacts still target the selected mpv 0.41.0 +/// baseline (API 2.5) so this compatibility floor does not weaken packaging. +pub const MINIMUM_CLIENT_API: MpvClientApiVersion = + MpvClientApiVersion::new(2, 2); + +/// Runtime and binding compatibility details suitable for diagnostics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MpvCompatibilityReport { + /// API used to generate the raw Rust bindings. + pub bindings: MpvClientApiVersion, + /// API reported by the loaded libmpv runtime. + pub runtime: MpvClientApiVersion, + /// Minimum API accepted by Ferrex. + pub minimum: MpvClientApiVersion, + /// Whether the runtime satisfies the Ferrex requirement. + pub compatible: bool, +} + +/// Errors produced before the full mpv event/error mapper is available. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum MpvFfiError { + /// The loaded runtime has an incompatible client API. + #[error( + "incompatible libmpv client API {runtime}; Ferrex requires {minimum}" + )] + IncompatibleClientApi { + /// Version reported by the runtime. + runtime: MpvClientApiVersion, + /// Minimum version required by Ferrex. + minimum: MpvClientApiVersion, + }, + /// `mpv_create` returned a null handle. + #[error("libmpv could not allocate a client handle")] + CreateFailed, + /// `mpv_initialize` returned a native error code. + #[error("libmpv initialization failed with error code {code}")] + InitializationFailed { + /// Native negative `mpv_error` value. + code: i32, + }, + /// Initialization was requested more than once. + #[error("libmpv handle is already initialized")] + AlreadyInitialized, + /// The handle was already consumed by failed initialization or teardown. + #[error("libmpv handle is no longer usable")] + Destroyed, +} + +type ClientApiVersionFn = unsafe extern "C" fn() -> c_ulong; +type CreateFn = unsafe extern "C" fn() -> *mut RawMpvHandle; +type InitializeFn = unsafe extern "C" fn(*mut RawMpvHandle) -> c_int; +type DestroyFn = unsafe extern "C" fn(*mut RawMpvHandle); + +/// Required libmpv symbols, isolated so tests can supply a fake ABI. +/// +/// The associated control table covers commands, properties, nodes, events, +/// hooks, logs, and wakeup delivery. Keeping both tables by value avoids +/// process-global mock state in production code. +#[derive(Clone, Copy)] +pub struct MpvFunctionTable { + client_api_version: ClientApiVersionFn, + create: CreateFn, + initialize: InitializeFn, + destroy: DestroyFn, + terminate_destroy: DestroyFn, + control: Option, +} + +impl fmt::Debug for MpvFunctionTable { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MpvFunctionTable") + .finish_non_exhaustive() + } +} + +impl MpvFunctionTable { + /// Construct a table from a trusted set of ABI-compatible symbols. + /// + /// # Safety + /// + /// Every function must obey the corresponding libmpv client API contract, + /// remain valid for every handle created from this table, and originate + /// from one ABI-compatible library instance. + pub const unsafe fn from_raw_parts( + client_api_version: ClientApiVersionFn, + create: CreateFn, + initialize: InitializeFn, + destroy: DestroyFn, + terminate_destroy: DestroyFn, + ) -> Self { + Self { + client_api_version, + create, + initialize, + destroy, + terminate_destroy, + control: None, + } + } + + #[cfg(test)] + pub(crate) const unsafe fn with_control_api( + mut self, + control: MpvControlApi, + ) -> Self { + self.control = Some(control); + self + } + + pub(crate) const fn control_api(self) -> Option { + self.control + } + + /// Report compatibility without allocating an mpv handle. + pub fn compatibility_report(self) -> MpvCompatibilityReport { + // SAFETY: table construction guarantees a valid, argument-free version + // function for the lifetime of this copied table. + // `c_ulong` is u64 on LP64 targets and u32 on Windows/32-bit targets. + #[allow(clippy::unnecessary_cast)] + let packed = unsafe { (self.client_api_version)() } as u64; + let runtime = MpvClientApiVersion::from_packed(packed); + MpvCompatibilityReport { + bindings: BINDINGS_CLIENT_API, + runtime, + minimum: MINIMUM_CLIENT_API, + compatible: runtime.satisfies(MINIMUM_CLIENT_API), + } + } + + /// Use symbols linked from `libmpv2-sys`. + #[cfg(feature = "linked")] + pub const fn linked() -> Self { + Self { + client_api_version: linked_client_api_version, + create: linked_create, + initialize: linked_initialize, + destroy: linked_destroy, + terminate_destroy: linked_terminate_destroy, + control: Some(MpvControlApi::linked()), + } + } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_client_api_version() -> c_ulong { + // SAFETY: forwarded directly to the linked libmpv symbol. + unsafe { libmpv2_sys::mpv_client_api_version() } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_create() -> *mut RawMpvHandle { + // SAFETY: forwarded directly to the linked libmpv symbol; the opaque + // pointer representation is preserved by the cast. + unsafe { libmpv2_sys::mpv_create().cast() } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_initialize(handle: *mut RawMpvHandle) -> c_int { + // SAFETY: callers only pass handles returned by `linked_create`. + unsafe { libmpv2_sys::mpv_initialize(handle.cast()) } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_destroy(handle: *mut RawMpvHandle) { + // SAFETY: callers only pass live handles returned by `linked_create`. + unsafe { libmpv2_sys::mpv_destroy(handle.cast()) }; +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_terminate_destroy(handle: *mut RawMpvHandle) { + // SAFETY: callers only pass live handles returned by `linked_create`. + unsafe { libmpv2_sys::mpv_terminate_destroy(handle.cast()) }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HandleState { + Created, + Initialized, + Destroyed, +} + +/// RAII owner for one `mpv_handle`. +/// +/// The owner is deliberately `!Send` and `!Sync`: P3 creates it on the +/// serialized backend owner thread (or platform main thread) instead of moving +/// an initialized native handle between executors. +pub struct MpvHandle { + raw: Option>, + api: MpvFunctionTable, + report: MpvCompatibilityReport, + state: HandleState, + thread_affinity: PhantomData>, +} + +impl fmt::Debug for MpvHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MpvHandle") + .field("report", &self.report) + .field("state", &self.state) + .finish_non_exhaustive() + } +} + +impl MpvHandle { + /// Validate the runtime and call `mpv_create` without initializing it. + /// + /// Keeping creation and initialization separate leaves room for + /// deterministic pre-initialization options in P3. + pub fn create(api: MpvFunctionTable) -> Result { + let report = api.compatibility_report(); + if !report.compatible { + return Err(MpvFfiError::IncompatibleClientApi { + runtime: report.runtime, + minimum: report.minimum, + }); + } + + // SAFETY: `MpvFunctionTable` guarantees this is the matching create + // symbol and that it remains valid for the resulting owner. + let raw = NonNull::new(unsafe { (api.create)() }) + .ok_or(MpvFfiError::CreateFailed)?; + + Ok(Self { + raw: Some(raw), + api, + report, + state: HandleState::Created, + thread_affinity: PhantomData, + }) + } + + /// Initialize the playback core after all required options are set. + pub fn initialize(&mut self) -> Result<(), MpvFfiError> { + match self.state { + HandleState::Initialized => { + return Err(MpvFfiError::AlreadyInitialized); + } + HandleState::Destroyed => return Err(MpvFfiError::Destroyed), + HandleState::Created => {} + } + + let raw = self.raw.ok_or(MpvFfiError::Destroyed)?; + // SAFETY: `raw` is a live handle created by this exact table and no + // other owner can initialize or destroy it. + let code = unsafe { (self.api.initialize)(raw.as_ptr()) }; + if code < 0 { + // mpv's initialization contract requires terminate-destroy after + // an initialization failure, not the weaker uninitialized destroy. + // SAFETY: `raw` is still exclusively owned and live here. + unsafe { (self.api.terminate_destroy)(raw.as_ptr()) }; + self.raw = None; + self.state = HandleState::Destroyed; + return Err(MpvFfiError::InitializationFailed { code }); + } + + self.state = HandleState::Initialized; + Ok(()) + } + + /// Compatibility details captured before handle allocation. + pub const fn compatibility_report(&self) -> MpvCompatibilityReport { + self.report + } + + /// Whether `mpv_initialize` completed successfully. + pub const fn is_initialized(&self) -> bool { + matches!(self.state, HandleState::Initialized) + } + + /// Borrow the opaque native pointer through the raw extension boundary. + /// + /// # Safety + /// + /// The caller must not destroy the handle, retain it beyond this borrow, + /// race the serialized owner, or violate any libmpv client API contract. + pub const unsafe fn as_raw(&self) -> Option> { + self.raw + } + + pub(crate) const fn raw_ptr(&self) -> Option> { + self.raw + } +} + +impl Drop for MpvHandle { + fn drop(&mut self) { + let Some(raw) = self.raw.take() else { + return; + }; + + // SAFETY: this owner has exclusive ownership of the live handle. The + // correct destructor depends on whether initialization succeeded. + unsafe { + match self.state { + HandleState::Created => (self.api.destroy)(raw.as_ptr()), + HandleState::Initialized => { + (self.api.terminate_destroy)(raw.as_ptr()); + } + HandleState::Destroyed => {} + } + } + self.state = HandleState::Destroyed; + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Mutex, + atomic::{AtomicUsize, Ordering}, + }; + + use super::*; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + static CREATE_COUNT: AtomicUsize = AtomicUsize::new(0); + static INITIALIZE_COUNT: AtomicUsize = AtomicUsize::new(0); + static DESTROY_COUNT: AtomicUsize = AtomicUsize::new(0); + static TERMINATE_COUNT: AtomicUsize = AtomicUsize::new(0); + + unsafe extern "C" fn compatible_version() -> c_ulong { + MpvClientApiVersion::new(2, 7).packed() as c_ulong + } + + unsafe extern "C" fn old_version() -> c_ulong { + MpvClientApiVersion::new(2, 1).packed() as c_ulong + } + + unsafe extern "C" fn wrong_major_version() -> c_ulong { + MpvClientApiVersion::new(3, 0).packed() as c_ulong + } + + unsafe extern "C" fn fake_create() -> *mut RawMpvHandle { + CREATE_COUNT.fetch_add(1, Ordering::SeqCst); + NonNull::::dangling().as_ptr().cast() + } + + unsafe extern "C" fn fake_initialize(_handle: *mut RawMpvHandle) -> c_int { + INITIALIZE_COUNT.fetch_add(1, Ordering::SeqCst); + 0 + } + + unsafe extern "C" fn failing_initialize( + _handle: *mut RawMpvHandle, + ) -> c_int { + INITIALIZE_COUNT.fetch_add(1, Ordering::SeqCst); + -3 + } + + unsafe extern "C" fn fake_destroy(_handle: *mut RawMpvHandle) { + DESTROY_COUNT.fetch_add(1, Ordering::SeqCst); + } + + unsafe extern "C" fn fake_terminate(_handle: *mut RawMpvHandle) { + TERMINATE_COUNT.fetch_add(1, Ordering::SeqCst); + } + + fn table(version: ClientApiVersionFn) -> MpvFunctionTable { + // SAFETY: every fake follows the required ABI and accepts the opaque + // non-null sentinel returned by `fake_create`. + unsafe { + MpvFunctionTable::from_raw_parts( + version, + fake_create, + fake_initialize, + fake_destroy, + fake_terminate, + ) + } + } + + fn reset_counts() { + CREATE_COUNT.store(0, Ordering::SeqCst); + INITIALIZE_COUNT.store(0, Ordering::SeqCst); + DESTROY_COUNT.store(0, Ordering::SeqCst); + TERMINATE_COUNT.store(0, Ordering::SeqCst); + } + + #[test] + fn client_api_version_round_trips_packed_layout() { + let version = MpvClientApiVersion::new(2, 5); + assert_eq!(MpvClientApiVersion::from_packed(version.packed()), version); + assert!(MpvClientApiVersion::new(2, 7).satisfies(version)); + assert!(!MpvClientApiVersion::new(2, 4).satisfies(version)); + assert!(!MpvClientApiVersion::new(3, 5).satisfies(version)); + } + + #[test] + fn incompatible_runtime_is_rejected_before_allocation() { + assert!(matches!( + MpvHandle::create(table(old_version)), + Err(MpvFfiError::IncompatibleClientApi { .. }) + )); + assert!(matches!( + MpvHandle::create(table(wrong_major_version)), + Err(MpvFfiError::IncompatibleClientApi { .. }) + )); + } + + #[test] + fn lifecycle_uses_the_destructor_matching_initialization_state() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_counts(); + + drop(MpvHandle::create(table(compatible_version)).unwrap()); + assert_eq!(CREATE_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(TERMINATE_COUNT.load(Ordering::SeqCst), 0); + + let mut initialized = + MpvHandle::create(table(compatible_version)).unwrap(); + initialized.initialize().unwrap(); + assert!(initialized.is_initialized()); + drop(initialized); + + assert_eq!(CREATE_COUNT.load(Ordering::SeqCst), 2); + assert_eq!(INITIALIZE_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(TERMINATE_COUNT.load(Ordering::SeqCst), 1); + } + + #[test] + fn failed_initialization_terminates_once_and_invalidates_handle() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_counts(); + // SAFETY: these fake symbols obey the same contract as `table`, with + // initialization deliberately returning a native error. + let api = unsafe { + MpvFunctionTable::from_raw_parts( + compatible_version, + fake_create, + failing_initialize, + fake_destroy, + fake_terminate, + ) + }; + let mut handle = MpvHandle::create(api).unwrap(); + + assert_eq!( + handle.initialize(), + Err(MpvFfiError::InitializationFailed { code: -3 }) + ); + assert_eq!(handle.initialize(), Err(MpvFfiError::Destroyed)); + drop(handle); + + assert_eq!(TERMINATE_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 0); + } + + #[cfg(feature = "linked")] + #[test] + fn linked_libmpv_creates_initializes_and_destroys_a_handle() { + let api = MpvFunctionTable::linked(); + let report = api.compatibility_report(); + assert!(report.compatible, "{report:?}"); + + let mut handle = MpvHandle::create(api).unwrap(); + handle.initialize().unwrap(); + assert!(handle.is_initialized()); + } +} diff --git a/crates/ferrex-player-mpv/src/lib.rs b/crates/ferrex-player-mpv/src/lib.rs new file mode 100644 index 00000000..86966bc0 --- /dev/null +++ b/crates/ferrex-player-mpv/src/lib.rs @@ -0,0 +1,33 @@ +//! Optional libmpv control-plane foundation for Ferrex desktop playback. +//! +//! The default build has no native libmpv dependency. Enable the `linked` +//! feature only in environments that provide the selected shared library. The +//! safe owner is built over a small function table so lifecycle and failure +//! behavior remain testable without a display or libmpv installation. + +#![forbid(unsafe_op_in_unsafe_fn)] + +/// Raw function-table compatibility checks and libmpv handle ownership. +pub mod ffi; +/// AppKit main-thread and poll-driven native teardown boundary. +pub mod macos; +mod node; +mod owner; +mod raw; +mod session; + +pub use ffi::{ + BINDINGS_CLIENT_API, MINIMUM_CLIENT_API, MpvClientApiVersion, + MpvCompatibilityReport, MpvFfiError, MpvFunctionTable, MpvHandle, +}; +pub use node::{MpvFormat, MpvNode, MpvNodeError, MpvNodeLimits}; +pub use owner::{ + MpvShutdownReport, MpvWorker, MpvWorkerConfig, MpvWorkerError, +}; +pub use session::{ + MpvAsyncReply, MpvConfigPolicy, MpvEndFile, MpvEndFileReason, MpvError, + MpvEvent, MpvHook, MpvHookId, MpvHookRegistrationId, MpvLogLevel, + MpvLogMessage, MpvMessageLevel, MpvObservationId, MpvOption, + MpvPropertyChange, MpvRequestId, MpvRequestKind, MpvSession, + MpvSessionConfig, MpvSessionError, MpvWakeupSignal, +}; diff --git a/crates/ferrex-player-mpv/src/macos.rs b/crates/ferrex-player-mpv/src/macos.rs new file mode 100644 index 00000000..e0005e22 --- /dev/null +++ b/crates/ferrex-player-mpv/src/macos.rs @@ -0,0 +1,278 @@ +//! AppKit main-thread and non-blocking shutdown boundary for macOS. +//! +//! mpv's modern macOS VO performs synchronous dispatches onto the AppKit main +//! queue while configuring and tearing down its native window. The libmpv +//! owner may remain on its serialized worker thread, but a caller on AppKit's +//! main thread must never wait for that worker to terminate. This module makes +//! the required start/yield/poll sequence explicit without moving native +//! objects across threads. + +use std::{marker::PhantomData, rc::Rc, thread::ThreadId}; + +use crate::{MpvShutdownReport, MpvWorker, MpvWorkerError}; + +/// Failure to enter an AppKit-only operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum AppKitMainThreadError { + /// The operation is meaningful only in a macOS process. + #[error("AppKit main-thread access is available only on macOS")] + UnsupportedPlatform, + /// The current callback is not executing on AppKit's process main thread. + #[error( + "the native presenter operation must run on the AppKit main thread" + )] + NotMainThread, + /// A token was used from a different thread than the one that acquired it. + #[error("the AppKit main-thread token was used from another thread")] + WrongThread, +} + +/// Proof that the current callback is executing on the AppKit main thread. +/// +/// The token is deliberately neither `Send` nor `Sync`. Platform presenter +/// code should acquire it inside Iced's event-loop-local window callback and +/// require a borrow for every AppKit object access. +pub struct AppKitMainThreadToken { + thread: ThreadId, + _event_loop_local: PhantomData>, +} + +impl std::fmt::Debug for AppKitMainThreadToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AppKitMainThreadToken") + .finish_non_exhaustive() + } +} + +impl AppKitMainThreadToken { + /// Acquire a non-transferable token on the process's AppKit main thread. + pub fn acquire() -> Result { + #[cfg(target_os = "macos")] + { + // SAFETY: `pthread_main_np` takes no arguments and has no lifetime + // requirements. It returns non-zero only on the process main + // thread, which is AppKit's required UI thread. + if unsafe { pthread_main_np() } == 0 { + return Err(AppKitMainThreadError::NotMainThread); + } + Ok(Self::for_current_thread()) + } + + #[cfg(not(target_os = "macos"))] + { + Err(AppKitMainThreadError::UnsupportedPlatform) + } + } + + /// Verify that this token is still used by its acquiring callback thread. + pub fn verify(&self) -> Result<(), AppKitMainThreadError> { + if self.thread == std::thread::current().id() { + Ok(()) + } else { + Err(AppKitMainThreadError::WrongThread) + } + } + + #[cfg(any(target_os = "macos", test))] + fn for_current_thread() -> Self { + Self { + thread: std::thread::current().id(), + _event_loop_local: PhantomData, + } + } +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn pthread_main_np() -> std::os::raw::c_int; +} + +/// State of the AppKit-safe owner shutdown handshake. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppKitShutdownState { + /// No shutdown request has been sent. + Ready, + /// The owner is draining and/or waiting for AppKit teardown work. + WaitingForOwner, + /// Native teardown and the owner join both completed. + Complete, +} + +/// Result of one non-blocking shutdown poll. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppKitShutdownPoll { + /// Return to the AppKit run loop before polling again. + YieldToRunLoop, + /// The native core is fully destroyed. + Complete(MpvShutdownReport), +} + +/// Main-thread-local driver for libmpv teardown that must service AppKit. +#[derive(Debug)] +pub struct AppKitShutdownDriver { + state: AppKitShutdownState, + report: Option, + _event_loop_local: PhantomData>, +} + +impl Default for AppKitShutdownDriver { + fn default() -> Self { + Self { + state: AppKitShutdownState::Ready, + report: None, + _event_loop_local: PhantomData, + } + } +} + +impl AppKitShutdownDriver { + /// Current handshake state. + pub const fn state(&self) -> AppKitShutdownState { + self.state + } + + /// Begin ordered owner shutdown and immediately return to the caller. + pub fn begin( + &mut self, + main_thread: &AppKitMainThreadToken, + worker: &mut MpvWorker, + ) -> Result<(), MpvWorkerError> { + main_thread + .verify() + .map_err(|_| MpvWorkerError::AppKitMainThreadRequired)?; + self.begin_with(|| worker.begin_shutdown()) + } + + /// Poll once; pending work must yield back to AppKit rather than spin. + pub fn poll( + &mut self, + main_thread: &AppKitMainThreadToken, + worker: &mut MpvWorker, + ) -> Result { + main_thread + .verify() + .map_err(|_| MpvWorkerError::AppKitMainThreadRequired)?; + self.poll_with(|| worker.try_finish_shutdown()) + } + + fn begin_with( + &mut self, + begin: impl FnOnce() -> Result<(), MpvWorkerError>, + ) -> Result<(), MpvWorkerError> { + match self.state { + AppKitShutdownState::Ready => { + begin()?; + self.state = AppKitShutdownState::WaitingForOwner; + } + AppKitShutdownState::WaitingForOwner + | AppKitShutdownState::Complete => {} + } + Ok(()) + } + + fn poll_with( + &mut self, + poll: impl FnOnce() -> Result, MpvWorkerError>, + ) -> Result { + match self.state { + AppKitShutdownState::Ready => { + Ok(AppKitShutdownPoll::YieldToRunLoop) + } + AppKitShutdownState::WaitingForOwner => match poll()? { + Some(report) => { + self.state = AppKitShutdownState::Complete; + self.report = Some(report.clone()); + Ok(AppKitShutdownPoll::Complete(report)) + } + None => Ok(AppKitShutdownPoll::YieldToRunLoop), + }, + AppKitShutdownState::Complete => Ok(AppKitShutdownPoll::Complete( + self.report + .clone() + .expect("complete AppKit shutdown retains its report"), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn driver_requires_a_yield_between_begin_and_completion() { + let mut driver = AppKitShutdownDriver::default(); + let mut began = false; + driver + .begin_with(|| { + began = true; + Ok(()) + }) + .unwrap(); + + assert!(began); + assert_eq!(driver.state(), AppKitShutdownState::WaitingForOwner); + assert_eq!( + driver.poll_with(|| Ok(None)).unwrap(), + AppKitShutdownPoll::YieldToRunLoop + ); + + let report = MpvShutdownReport { + stop_reply_received: true, + ..MpvShutdownReport::default() + }; + assert_eq!( + driver.poll_with(|| Ok(Some(report.clone()))).unwrap(), + AppKitShutdownPoll::Complete(report.clone()) + ); + assert_eq!(driver.state(), AppKitShutdownState::Complete); + assert_eq!( + driver + .poll_with(|| panic!("completed driver must not poll again")) + .unwrap(), + AppKitShutdownPoll::Complete(report) + ); + } + + #[test] + fn duplicate_begin_does_not_send_another_shutdown() { + let mut driver = AppKitShutdownDriver::default(); + let mut begins = 0; + driver + .begin_with(|| { + begins += 1; + Ok(()) + }) + .unwrap(); + driver + .begin_with(|| { + begins += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(begins, 1); + } + + #[test] + fn token_rejects_cross_thread_use() { + let token = AppKitMainThreadToken::for_current_thread(); + assert_eq!(token.verify(), Ok(())); + + let owner = token.thread; + let other = + std::thread::spawn(move || owner == std::thread::current().id()) + .join() + .unwrap(); + assert!(!other); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn appkit_token_is_unavailable_off_macos() { + assert!(matches!( + AppKitMainThreadToken::acquire(), + Err(AppKitMainThreadError::UnsupportedPlatform) + )); + } +} diff --git a/crates/ferrex-player-mpv/src/node.rs b/crates/ferrex-player-mpv/src/node.rs new file mode 100644 index 00000000..6e308542 --- /dev/null +++ b/crates/ferrex-player-mpv/src/node.rs @@ -0,0 +1,641 @@ +//! Owned mpv node values and bounded raw-node conversion. + +use std::{ + ffi::{CStr, CString, c_char, c_int, c_void}, + ptr, +}; + +use crate::raw::{ + FORMAT_BYTE_ARRAY, FORMAT_DOUBLE, FORMAT_FLAG, FORMAT_INT64, FORMAT_NODE, + FORMAT_NODE_ARRAY, FORMAT_NODE_MAP, FORMAT_NONE, FORMAT_OSD_STRING, + FORMAT_STRING, RawMpvByteArray, RawMpvNode, RawMpvNodeList, + RawMpvNodeValue, +}; + +/// Ferrex-owned representation of any value supported by `mpv_node`. +#[derive(Debug, Clone, PartialEq)] +pub enum MpvNode { + /// `MPV_FORMAT_NONE`. + Null, + /// UTF-8 text. Invalid native bytes are copied with replacement characters. + String(String), + /// Boolean flag. + Bool(bool), + /// Signed integer. + Int(i64), + /// Floating-point value. + Double(f64), + /// Ordered node array. + Array(Vec), + /// Ordered key/value map. mpv does not guarantee source map order. + Map(Vec<(String, Self)>), + /// Untyped byte payload. + Bytes(Vec), +} + +impl From for MpvNode { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for MpvNode { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From for MpvNode { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl From for MpvNode { + fn from(value: i64) -> Self { + Self::Int(value) + } +} + +impl From for MpvNode { + fn from(value: f64) -> Self { + Self::Double(value) + } +} + +/// Native property format requested from libmpv. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MpvFormat { + /// Notify without a value. + None, + /// Raw string value. + String, + /// Human-readable OSD string. + OsdString, + /// Boolean flag. + Flag, + /// Signed 64-bit integer. + Int64, + /// Double-precision number. + Double, + /// Arbitrary `mpv_node` value. + Node, +} + +impl MpvFormat { + pub(crate) const fn raw(self) -> u32 { + match self { + Self::None => FORMAT_NONE, + Self::String => FORMAT_STRING, + Self::OsdString => FORMAT_OSD_STRING, + Self::Flag => FORMAT_FLAG, + Self::Int64 => FORMAT_INT64, + Self::Double => FORMAT_DOUBLE, + Self::Node => FORMAT_NODE, + } + } +} + +/// Bounds applied while copying pointer-backed native node trees. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MpvNodeLimits { + /// Maximum recursive array/map nesting. + pub max_depth: usize, + /// Maximum number of aggregate array/map entries. + pub max_items: usize, + /// Maximum aggregate string/key/byte payload size. + pub max_bytes: usize, +} + +impl Default for MpvNodeLimits { + fn default() -> Self { + Self { + max_depth: 64, + max_items: 100_000, + max_bytes: 16 * 1024 * 1024, + } + } +} + +/// Failure while validating or copying a raw mpv value. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum MpvNodeError { + /// A Rust string contained an interior NUL byte. + #[error("{context} contains an interior NUL byte")] + InteriorNul { + /// Value category, never the value itself. + context: &'static str, + }, + /// Native data violated a non-null pointer requirement. + #[error("libmpv returned a null {0} pointer")] + NullPointer(&'static str), + /// Native list count was negative or not representable. + #[error("libmpv returned invalid node-list count {0}")] + InvalidListCount(i64), + /// A configured copy limit was exceeded. + #[error("libmpv node exceeded the {kind} limit ({limit})")] + LimitExceeded { + /// Limit category. + kind: &'static str, + /// Configured limit. + limit: usize, + }, + /// Native data used an unknown node format. + #[error("libmpv returned unknown node format {0}")] + UnknownFormat(u32), +} + +#[derive(Debug)] +struct CopyBudget { + limits: MpvNodeLimits, + items: usize, + bytes: usize, +} + +impl CopyBudget { + const fn new(limits: MpvNodeLimits) -> Self { + Self { + limits, + items: 0, + bytes: 0, + } + } + + fn add_items(&mut self, count: usize) -> Result<(), MpvNodeError> { + self.items = self.items.checked_add(count).ok_or( + MpvNodeError::LimitExceeded { + kind: "item-count", + limit: self.limits.max_items, + }, + )?; + if self.items > self.limits.max_items { + return Err(MpvNodeError::LimitExceeded { + kind: "item-count", + limit: self.limits.max_items, + }); + } + Ok(()) + } + + fn add_bytes(&mut self, count: usize) -> Result<(), MpvNodeError> { + self.bytes = self.bytes.checked_add(count).ok_or( + MpvNodeError::LimitExceeded { + kind: "byte-count", + limit: self.limits.max_bytes, + }, + )?; + if self.bytes > self.limits.max_bytes { + return Err(MpvNodeError::LimitExceeded { + kind: "byte-count", + limit: self.limits.max_bytes, + }); + } + Ok(()) + } + + fn check_depth(&self, depth: usize) -> Result<(), MpvNodeError> { + if depth > self.limits.max_depth { + return Err(MpvNodeError::LimitExceeded { + kind: "depth", + limit: self.limits.max_depth, + }); + } + Ok(()) + } +} + +/// Copy a raw node while all pointers are still valid. +/// +/// # Safety +/// +/// `node` and every pointer reachable from it must obey libmpv's `mpv_node` +/// contract for the duration of this call. +pub(crate) unsafe fn copy_raw_node( + node: &RawMpvNode, + limits: MpvNodeLimits, +) -> Result { + let mut budget = CopyBudget::new(limits); + // SAFETY: delegated to the caller and recursively preserved below. + unsafe { copy_raw_node_inner(node, 0, &mut budget) } +} + +unsafe fn copy_raw_node_inner( + node: &RawMpvNode, + depth: usize, + budget: &mut CopyBudget, +) -> Result { + budget.check_depth(depth)?; + + match node.format { + FORMAT_NONE => Ok(MpvNode::Null), + FORMAT_STRING => { + // SAFETY: active union member follows `format` and pointer validity + // is part of the caller's raw-node contract. + let pointer = unsafe { node.value.string }; + copy_c_string(pointer.cast_const(), "string", budget) + .map(MpvNode::String) + } + FORMAT_FLAG => { + // SAFETY: active union member follows `format`. + Ok(MpvNode::Bool(unsafe { node.value.flag } != 0)) + } + FORMAT_INT64 => { + // SAFETY: active union member follows `format`. + Ok(MpvNode::Int(unsafe { node.value.int64 })) + } + FORMAT_DOUBLE => { + // SAFETY: active union member follows `format`. + Ok(MpvNode::Double(unsafe { node.value.double_ })) + } + FORMAT_NODE_ARRAY | FORMAT_NODE_MAP => { + // SAFETY: active union member follows `format`. + let list = unsafe { node.value.list }; + let list = unsafe { list.as_ref() } + .ok_or(MpvNodeError::NullPointer("node-list"))?; + let count = usize::try_from(list.count).map_err(|_| { + MpvNodeError::InvalidListCount(list.count.into()) + })?; + budget.add_items(count)?; + + if count > 0 && list.values.is_null() { + return Err(MpvNodeError::NullPointer("node-list values")); + } + + if node.format == FORMAT_NODE_ARRAY { + let mut values = Vec::with_capacity(count); + for index in 0..count { + // SAFETY: `values` contains `count` entries by contract. + let value = unsafe { &*list.values.add(index) }; + // SAFETY: nested pointers share the caller's validity. + values.push(unsafe { + copy_raw_node_inner(value, depth + 1, budget)? + }); + } + Ok(MpvNode::Array(values)) + } else { + if count > 0 && list.keys.is_null() { + return Err(MpvNodeError::NullPointer("node-map keys")); + } + let mut values = Vec::with_capacity(count); + for index in 0..count { + // SAFETY: map key/value arrays contain `count` entries. + let key_pointer = unsafe { *list.keys.add(index) }; + let key = copy_c_string( + key_pointer.cast_const(), + "node-map key", + budget, + )?; + // SAFETY: `values` contains `count` entries by contract. + let value = unsafe { &*list.values.add(index) }; + // SAFETY: nested pointers share the caller's validity. + let value = unsafe { + copy_raw_node_inner(value, depth + 1, budget)? + }; + values.push((key, value)); + } + Ok(MpvNode::Map(values)) + } + } + FORMAT_BYTE_ARRAY => { + // SAFETY: active union member follows `format`. + let bytes = unsafe { node.value.bytes }; + let bytes = unsafe { bytes.as_ref() } + .ok_or(MpvNodeError::NullPointer("byte-array"))?; + budget.add_bytes(bytes.size)?; + if bytes.size > 0 && bytes.data.is_null() { + return Err(MpvNodeError::NullPointer("byte-array data")); + } + // SAFETY: byte-array data contains `size` initialized bytes. + let slice = unsafe { + std::slice::from_raw_parts(bytes.data.cast::(), bytes.size) + }; + Ok(MpvNode::Bytes(slice.to_vec())) + } + format => Err(MpvNodeError::UnknownFormat(format)), + } +} + +fn copy_c_string( + pointer: *const c_char, + category: &'static str, + budget: &mut CopyBudget, +) -> Result { + if pointer.is_null() { + return Err(MpvNodeError::NullPointer(category)); + } + // SAFETY: callers only provide NUL-terminated strings from a valid raw mpv + // payload. The copy is completed before the next native event is fetched. + let bytes = unsafe { CStr::from_ptr(pointer) }.to_bytes(); + budget.add_bytes(bytes.len())?; + Ok(String::from_utf8_lossy(bytes).into_owned()) +} + +pub(crate) unsafe fn copy_raw_event_value( + format: u32, + data: *mut c_void, + limits: MpvNodeLimits, +) -> Result, MpvNodeError> { + if format == FORMAT_NONE { + return Ok(None); + } + if data.is_null() { + return Err(MpvNodeError::NullPointer("event value")); + } + + let mut budget = CopyBudget::new(limits); + let value = match format { + FORMAT_STRING | FORMAT_OSD_STRING => { + // SAFETY: string event data points to a `char *` value. + let pointer = unsafe { *data.cast::<*const c_char>() }; + MpvNode::String(copy_c_string( + pointer, + "event string", + &mut budget, + )?) + } + FORMAT_FLAG => { + // SAFETY: format identifies an `int` event value. + MpvNode::Bool(unsafe { *data.cast::() } != 0) + } + FORMAT_INT64 => { + // SAFETY: format identifies an `int64_t` event value. + MpvNode::Int(unsafe { *data.cast::() }) + } + FORMAT_DOUBLE => { + // SAFETY: format identifies a `double` event value. + MpvNode::Double(unsafe { *data.cast::() }) + } + FORMAT_NODE => { + // SAFETY: format identifies an `mpv_node` event value. + let node = unsafe { &*data.cast::() }; + // SAFETY: node pointers share the event payload lifetime. + unsafe { copy_raw_node_inner(node, 0, &mut budget)? } + } + FORMAT_BYTE_ARRAY => { + // A direct byte-array property is unusual but has the same payload + // shape as the node union member. + // SAFETY: format identifies an `mpv_byte_array` event value. + let bytes = unsafe { &*data.cast::() }; + budget.add_bytes(bytes.size)?; + if bytes.size > 0 && bytes.data.is_null() { + return Err(MpvNodeError::NullPointer("event byte-array data")); + } + // SAFETY: native data contains `size` initialized bytes. + let bytes = unsafe { + std::slice::from_raw_parts(bytes.data.cast::(), bytes.size) + }; + MpvNode::Bytes(bytes.to_vec()) + } + unknown => return Err(MpvNodeError::UnknownFormat(unknown)), + }; + Ok(Some(value)) +} + +/// Pointer-stable storage for one outbound node. libmpv copies this data before +/// every asynchronous submission function returns. +// Boxed list/byte descriptors are intentional: their addresses are embedded +// in parent nodes and must survive growth of the owning vectors. +#[allow(clippy::vec_box)] +pub(crate) struct RawNodeArena { + root: RawMpvNode, + strings: Vec, + node_blocks: Vec>, + key_blocks: Vec>, + lists: Vec>, + byte_blocks: Vec>, + byte_arrays: Vec>, + budget: CopyBudget, +} + +impl RawNodeArena { + pub(crate) fn new(value: &MpvNode) -> Result { + let mut arena = Self { + root: RawMpvNode { + value: RawMpvNodeValue { int64: 0 }, + format: FORMAT_NONE, + }, + strings: Vec::new(), + node_blocks: Vec::new(), + key_blocks: Vec::new(), + lists: Vec::new(), + byte_blocks: Vec::new(), + byte_arrays: Vec::new(), + budget: CopyBudget::new(MpvNodeLimits::default()), + }; + arena.root = arena.build(value, 0)?; + Ok(arena) + } + + pub(crate) fn root_mut(&mut self) -> *mut RawMpvNode { + &mut self.root + } + + fn build( + &mut self, + value: &MpvNode, + depth: usize, + ) -> Result { + self.budget.check_depth(depth)?; + let node = match value { + MpvNode::Null => RawMpvNode { + value: RawMpvNodeValue { int64: 0 }, + format: FORMAT_NONE, + }, + MpvNode::String(value) => { + self.budget.add_bytes(value.len())?; + let value = CString::new(value.as_bytes()).map_err(|_| { + MpvNodeError::InteriorNul { + context: "node string", + } + })?; + let pointer = value.as_ptr().cast_mut(); + self.strings.push(value); + RawMpvNode { + value: RawMpvNodeValue { string: pointer }, + format: FORMAT_STRING, + } + } + MpvNode::Bool(value) => RawMpvNode { + value: RawMpvNodeValue { + flag: c_int::from(*value), + }, + format: FORMAT_FLAG, + }, + MpvNode::Int(value) => RawMpvNode { + value: RawMpvNodeValue { int64: *value }, + format: FORMAT_INT64, + }, + MpvNode::Double(value) => RawMpvNode { + value: RawMpvNodeValue { double_: *value }, + format: FORMAT_DOUBLE, + }, + MpvNode::Array(values) => { + self.budget.add_items(values.len())?; + let mut raw_values = Vec::with_capacity(values.len()); + for value in values { + raw_values.push(self.build(value, depth + 1)?); + } + self.list_node(raw_values, None, FORMAT_NODE_ARRAY)? + } + MpvNode::Map(values) => { + self.budget.add_items(values.len())?; + let mut raw_values = Vec::with_capacity(values.len()); + let mut keys = Vec::with_capacity(values.len()); + for (key, value) in values { + self.budget.add_bytes(key.len())?; + let key = CString::new(key.as_bytes()).map_err(|_| { + MpvNodeError::InteriorNul { + context: "node-map key", + } + })?; + keys.push(key.as_ptr().cast_mut()); + self.strings.push(key); + raw_values.push(self.build(value, depth + 1)?); + } + self.list_node(raw_values, Some(keys), FORMAT_NODE_MAP)? + } + MpvNode::Bytes(bytes) => { + self.budget.add_bytes(bytes.len())?; + let mut block = bytes.clone().into_boxed_slice(); + let data = if block.is_empty() { + ptr::null_mut() + } else { + block.as_mut_ptr().cast() + }; + let mut byte_array = Box::new(RawMpvByteArray { + data, + size: block.len(), + }); + let pointer = byte_array.as_mut() as *mut RawMpvByteArray; + self.byte_blocks.push(block); + self.byte_arrays.push(byte_array); + RawMpvNode { + value: RawMpvNodeValue { bytes: pointer }, + format: FORMAT_BYTE_ARRAY, + } + } + }; + Ok(node) + } + + fn list_node( + &mut self, + values: Vec, + keys: Option>, + format: u32, + ) -> Result { + let count = c_int::try_from(values.len()).map_err(|_| { + MpvNodeError::InvalidListCount( + i64::try_from(values.len()).unwrap_or(i64::MAX), + ) + })?; + let mut values = values.into_boxed_slice(); + let values_pointer = if values.is_empty() { + ptr::null_mut() + } else { + values.as_mut_ptr() + }; + + let mut keys = keys.map(Vec::into_boxed_slice); + let keys_pointer = keys.as_mut().map_or(ptr::null_mut(), |keys| { + if keys.is_empty() { + ptr::null_mut() + } else { + keys.as_mut_ptr() + } + }); + + let mut list = Box::new(RawMpvNodeList { + count, + values: values_pointer, + keys: keys_pointer, + }); + let list_pointer = list.as_mut() as *mut RawMpvNodeList; + self.node_blocks.push(values); + if let Some(keys) = keys { + self.key_blocks.push(keys); + } + self.lists.push(list); + + Ok(RawMpvNode { + value: RawMpvNodeValue { list: list_pointer }, + format, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nested_nodes_round_trip_through_raw_layout() { + let original = MpvNode::Map(vec![ + ("null".into(), MpvNode::Null), + ( + "array".into(), + MpvNode::Array(vec![ + MpvNode::Bool(true), + MpvNode::Int(-7), + MpvNode::Double(2.5), + MpvNode::String("hello".into()), + MpvNode::Bytes(vec![0, 1, 2, 255]), + ]), + ), + ]); + let mut arena = RawNodeArena::new(&original).unwrap(); + // SAFETY: the arena owns every pointer reachable from its root. + let copied = unsafe { + copy_raw_node(&*arena.root_mut(), MpvNodeLimits::default()).unwrap() + }; + assert_eq!(copied, original); + } + + #[test] + fn conversion_rejects_null_nested_storage_and_limits() { + let raw = RawMpvNode { + value: RawMpvNodeValue { + list: ptr::null_mut(), + }, + format: FORMAT_NODE_ARRAY, + }; + // SAFETY: null is intentional and diagnosed before dereference. + assert_eq!( + unsafe { copy_raw_node(&raw, MpvNodeLimits::default()) }, + Err(MpvNodeError::NullPointer("node-list")) + ); + + let original = + MpvNode::Array(vec![MpvNode::Array(vec![MpvNode::Null])]); + let mut arena = RawNodeArena::new(&original).unwrap(); + // SAFETY: the arena owns every pointer reachable from its root. + let error = unsafe { + copy_raw_node( + &*arena.root_mut(), + MpvNodeLimits { + max_depth: 1, + ..MpvNodeLimits::default() + }, + ) + } + .unwrap_err(); + assert!(matches!( + error, + MpvNodeError::LimitExceeded { kind: "depth", .. } + )); + } + + #[test] + fn outbound_nodes_reject_interior_nul_without_echoing_value() { + let error = RawNodeArena::new(&MpvNode::String("secret\0tail".into())) + .err() + .unwrap(); + assert_eq!( + error, + MpvNodeError::InteriorNul { + context: "node string" + } + ); + assert!(!error.to_string().contains("secret")); + } +} diff --git a/crates/ferrex-player-mpv/src/owner.rs b/crates/ferrex-player-mpv/src/owner.rs new file mode 100644 index 00000000..78a0ac74 --- /dev/null +++ b/crates/ferrex-player-mpv/src/owner.rs @@ -0,0 +1,747 @@ +//! Dedicated serialized owner thread for [`crate::MpvSession`]. + +use std::{ + sync::mpsc::{ + self, Receiver, RecvTimeoutError, Sender, SyncSender, TryRecvError, + }, + thread::{self, JoinHandle, Thread}, + time::{Duration, Instant}, +}; + +use crate::{ + MpvEndFileReason, MpvEvent, MpvFormat, MpvFunctionTable, MpvHookId, + MpvHookRegistrationId, MpvLogLevel, MpvNode, MpvObservationId, + MpvRequestId, MpvSession, MpvSessionConfig, MpvSessionError, +}; + +/// Timing bounds for the serialized owner thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MpvWorkerConfig { + /// Maximum time to initialize the native core. + pub startup_timeout: Duration, + /// Maximum time a caller waits for native queue acceptance. + pub request_timeout: Duration, + /// Maximum time to drain stop/final events before native termination. + pub shutdown_timeout: Duration, + /// Recovery wake interval in case a platform loses an external signal. + pub recovery_wake_interval: Duration, +} + +impl Default for MpvWorkerConfig { + fn default() -> Self { + Self { + startup_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(2), + shutdown_timeout: Duration::from_secs(3), + recovery_wake_interval: Duration::from_secs(1), + } + } +} + +/// Result of ordered stop/event drain before the native core is destroyed. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct MpvShutdownReport { + /// Stop request submitted during shutdown, if the core was still running. + pub stop_request: Option, + /// Whether the matching async command reply was copied. + pub stop_reply_received: bool, + /// Whether an end-file event was copied during shutdown. + pub end_file_received: bool, + /// Whether the bounded drain reached its deadline. + pub timed_out: bool, + /// Number of copied native events forwarded during shutdown. + pub forwarded_events: usize, + /// Queueing failure, if stop could not be submitted. + pub stop_error: Option, +} + +/// Failure to start or communicate with the serialized owner. +#[derive(Debug, thiserror::Error)] +pub enum MpvWorkerError { + /// Native owner thread could not be spawned. + #[error("could not spawn libmpv owner thread: {0}")] + Spawn(#[source] std::io::Error), + /// Session initialization failed on the owner thread. + #[error("libmpv owner startup failed: {0}")] + Startup(MpvSessionError), + /// Owner did not complete startup within the configured bound. + #[error("timed out waiting for libmpv owner startup")] + StartupTimeout, + /// Owner command channel is closed. + #[error("libmpv owner command channel is closed")] + CommandChannelClosed, + /// Owner did not acknowledge a request within the configured bound. + #[error("timed out waiting for libmpv owner request acknowledgement")] + RequestTimeout, + /// A serialized native operation failed. + #[error(transparent)] + Session(#[from] MpvSessionError), + /// Owner thread panicked. + #[error("libmpv owner thread panicked")] + OwnerPanicked, + /// An AppKit-local operation was attempted outside its main-thread token. + #[error("the libmpv macOS operation requires the AppKit main thread")] + AppKitMainThreadRequired, +} + +type Response = SyncSender>; + +enum OwnerCommand { + Command(Vec, Response), + NodeCommand(MpvNode, Response), + Cancel(MpvRequestId, Response<()>), + SetProperty(String, MpvNode, Response), + GetProperty(String, MpvFormat, Response), + Observe(String, MpvFormat, Response), + Unobserve(MpvObservationId, Response<()>), + AddHook(String, i32, Response), + ContinueHook(MpvHookId, Response<()>), + SetEvent(u32, bool, Response<()>), + SetLogLevel(MpvLogLevel, Response<()>), + Shutdown(SyncSender), +} + +/// Thread-safe command side of one serialized libmpv owner. +/// +/// Native handles never cross this boundary. Every received [`MpvEvent`] owns +/// its complete payload and can be moved into an application reducer safely. +pub struct MpvWorker { + command_tx: Sender, + event_rx: Receiver, + owner_thread: Thread, + join: Option>, + config: MpvWorkerConfig, + shutdown_rx: Option>, + shutdown_report: Option, +} + +impl std::fmt::Debug for MpvWorker { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MpvWorker") + .field("owner_thread", &self.owner_thread.id()) + .field("config", &self.config) + .field("shutdown_report", &self.shutdown_report) + .finish_non_exhaustive() + } +} + +impl MpvWorker { + /// Spawn a thread, then create and initialize libmpv on that thread. + pub fn spawn( + functions: MpvFunctionTable, + session_config: MpvSessionConfig, + worker_config: MpvWorkerConfig, + ) -> Result { + Self::spawn_inner(functions, session_config, worker_config, None) + } + + /// Spawn an owner and emit a coalescible signal after copied events are + /// available. Notification delivery is best-effort and never blocks the + /// native owner; callers should use a bounded channel and drain all events + /// after each signal. + pub fn spawn_with_event_notifier( + functions: MpvFunctionTable, + session_config: MpvSessionConfig, + worker_config: MpvWorkerConfig, + event_notifier: SyncSender<()>, + ) -> Result { + Self::spawn_inner( + functions, + session_config, + worker_config, + Some(event_notifier), + ) + } + + fn spawn_inner( + functions: MpvFunctionTable, + session_config: MpvSessionConfig, + worker_config: MpvWorkerConfig, + event_notifier: Option>, + ) -> Result { + let (command_tx, command_rx) = mpsc::channel(); + let (event_tx, event_rx) = mpsc::channel(); + let (startup_tx, startup_rx) = mpsc::sync_channel(1); + + let join = thread::Builder::new() + .name("ferrex-libmpv-owner".into()) + .spawn(move || { + let session = MpvSession::create(functions, session_config); + match session { + Ok(session) => { + let _ = startup_tx.send(Ok(thread::current())); + run_owner( + session, + command_rx, + event_tx, + event_notifier, + worker_config, + ); + } + Err(error) => { + let _ = startup_tx.send(Err(error)); + } + } + }) + .map_err(MpvWorkerError::Spawn)?; + + let owner_thread = + match startup_rx.recv_timeout(worker_config.startup_timeout) { + Ok(Ok(owner_thread)) => owner_thread, + Ok(Err(error)) => { + let _ = join.join(); + return Err(MpvWorkerError::Startup(error)); + } + Err(RecvTimeoutError::Timeout) => { + // The thread may still be in native initialization. It owns all + // resources and will tear them down when command senders drop. + drop(command_tx); + return Err(MpvWorkerError::StartupTimeout); + } + Err(RecvTimeoutError::Disconnected) => { + let panicked = join.join().is_err(); + return Err(if panicked { + MpvWorkerError::OwnerPanicked + } else { + MpvWorkerError::CommandChannelClosed + }); + } + }; + + Ok(Self { + command_tx, + event_rx, + owner_thread, + join: Some(join), + config: worker_config, + shutdown_rx: None, + shutdown_report: None, + }) + } + + /// Submit a pre-split arbitrary command. + pub fn command_async( + &self, + arguments: I, + ) -> Result + where + I: IntoIterator, + S: Into, + { + let arguments = arguments.into_iter().map(Into::into).collect(); + self.request(|response| OwnerCommand::Command(arguments, response)) + } + + /// Submit an arbitrary node-valued command. + pub fn command_node_async( + &self, + command: MpvNode, + ) -> Result { + self.request(|response| OwnerCommand::NodeCommand(command, response)) + } + + /// Request cancellation of an in-flight command. + pub fn cancel_request( + &self, + id: MpvRequestId, + ) -> Result<(), MpvWorkerError> { + self.request(|response| OwnerCommand::Cancel(id, response)) + } + + /// Set any typed or node-valued property. + pub fn set_property_async( + &self, + name: impl Into, + value: MpvNode, + ) -> Result { + self.request(|response| { + OwnerCommand::SetProperty(name.into(), value, response) + }) + } + + /// Read any typed or node-valued property. + pub fn get_property_async( + &self, + name: impl Into, + format: MpvFormat, + ) -> Result { + self.request(|response| { + OwnerCommand::GetProperty(name.into(), format, response) + }) + } + + /// Observe any property. + pub fn observe_property( + &self, + name: impl Into, + format: MpvFormat, + ) -> Result { + self.request(|response| { + OwnerCommand::Observe(name.into(), format, response) + }) + } + + /// Remove one property observation. + pub fn unobserve_property( + &self, + id: MpvObservationId, + ) -> Result<(), MpvWorkerError> { + self.request(|response| OwnerCommand::Unobserve(id, response)) + } + + /// Register an arbitrary mpv hook. + pub fn add_hook( + &self, + name: impl Into, + priority: i32, + ) -> Result { + self.request(|response| { + OwnerCommand::AddHook(name.into(), priority, response) + }) + } + + /// Continue one received hook. + pub fn continue_hook(&self, id: MpvHookId) -> Result<(), MpvWorkerError> { + self.request(|response| OwnerCommand::ContinueHook(id, response)) + } + + /// Enable or disable a native event ID. + pub fn set_event_enabled( + &self, + event_id: u32, + enabled: bool, + ) -> Result<(), MpvWorkerError> { + self.request(|response| { + OwnerCommand::SetEvent(event_id, enabled, response) + }) + } + + /// Change native log filtering. + pub fn set_log_level( + &self, + level: MpvLogLevel, + ) -> Result<(), MpvWorkerError> { + self.request(|response| OwnerCommand::SetLogLevel(level, response)) + } + + /// Drain all currently forwarded native events without blocking. + pub fn drain_events(&self) -> Vec { + self.event_rx.try_iter().collect() + } + + /// Receive one forwarded native event with a deadline. + pub fn recv_event_timeout( + &self, + timeout: Duration, + ) -> Result { + self.event_rx.recv_timeout(timeout) + } + + /// Begin ordered shutdown without waiting for native teardown. + /// + /// This is the required first half of AppKit-safe shutdown. mpv's macOS VO + /// dispatches teardown work synchronously to the main queue, so an AppKit + /// event-loop callback must start shutdown, return to the run loop, and + /// poll [`Self::try_finish_shutdown`] later instead of blocking in + /// [`Self::shutdown`]. Calling this method repeatedly is idempotent. + pub fn begin_shutdown(&mut self) -> Result<(), MpvWorkerError> { + if self.shutdown_report.is_some() || self.shutdown_rx.is_some() { + return Ok(()); + } + let (response_tx, response_rx) = mpsc::sync_channel(1); + if self + .command_tx + .send(OwnerCommand::Shutdown(response_tx)) + .is_err() + { + return Err(MpvWorkerError::CommandChannelClosed); + } + self.shutdown_rx = Some(response_rx); + self.owner_thread.unpark(); + Ok(()) + } + + /// Poll an ordered shutdown without blocking the caller. + /// + /// `Ok(None)` means native teardown is still pending and the caller must + /// yield back to its platform event loop. A completed report is not made + /// observable until the owner has dropped `MpvSession`, including removal + /// of the wakeup callback and `mpv_terminate_destroy`. + pub fn try_finish_shutdown( + &mut self, + ) -> Result, MpvWorkerError> { + if let Some(report) = self.shutdown_report.clone() { + return Ok(Some(report)); + } + let Some(response_rx) = self.shutdown_rx.as_ref() else { + return Ok(None); + }; + match response_rx.try_recv() { + Ok(report) => { + self.shutdown_rx = None; + self.join_owner()?; + self.shutdown_report = Some(report.clone()); + Ok(Some(report)) + } + Err(TryRecvError::Empty) => Ok(None), + Err(TryRecvError::Disconnected) => { + self.shutdown_rx = None; + self.join_owner()?; + Err(MpvWorkerError::CommandChannelClosed) + } + } + } + + /// Whether ordered native teardown has started but has not completed. + pub const fn shutdown_pending(&self) -> bool { + self.shutdown_rx.is_some() && self.shutdown_report.is_none() + } + + /// Stop, drain final events, destroy libmpv on its owner thread, and join. + /// Calling this more than once returns the first report. + /// + /// Do not call this blocking convenience from the AppKit main thread once + /// a macOS VO has been created. Use [`Self::begin_shutdown`] and + /// [`Self::try_finish_shutdown`] so the main run loop remains serviceable. + pub fn shutdown(&mut self) -> Result { + if let Some(report) = self.shutdown_report.clone() { + return Ok(report); + } + self.begin_shutdown()?; + + let response_timeout = self + .config + .shutdown_timeout + .saturating_add(self.config.request_timeout); + let response_rx = self + .shutdown_rx + .as_ref() + .expect("begin_shutdown installs a completion receiver"); + let report = match response_rx.recv_timeout(response_timeout) { + Ok(report) => report, + Err(RecvTimeoutError::Timeout) => { + return Err(MpvWorkerError::RequestTimeout); + } + Err(RecvTimeoutError::Disconnected) => { + self.shutdown_rx = None; + self.join_owner()?; + return Err(MpvWorkerError::CommandChannelClosed); + } + }; + self.shutdown_rx = None; + self.join_owner()?; + self.shutdown_report = Some(report.clone()); + Ok(report) + } + + fn request( + &self, + make_command: impl FnOnce(Response) -> OwnerCommand, + ) -> Result { + let (response_tx, response_rx) = mpsc::sync_channel(1); + self.command_tx + .send(make_command(response_tx)) + .map_err(|_| MpvWorkerError::CommandChannelClosed)?; + self.owner_thread.unpark(); + match response_rx.recv_timeout(self.config.request_timeout) { + Ok(result) => result.map_err(MpvWorkerError::Session), + Err(RecvTimeoutError::Timeout) => { + Err(MpvWorkerError::RequestTimeout) + } + Err(RecvTimeoutError::Disconnected) => { + Err(MpvWorkerError::CommandChannelClosed) + } + } + } + + fn join_owner(&mut self) -> Result<(), MpvWorkerError> { + if let Some(join) = self.join.take() { + join.join().map_err(|_| MpvWorkerError::OwnerPanicked)?; + } + Ok(()) + } +} + +impl Drop for MpvWorker { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} + +fn run_owner( + mut session: MpvSession, + command_rx: Receiver, + event_tx: Sender, + event_notifier: Option>, + config: MpvWorkerConfig, +) { + let shutdown_completion = 'owner: loop { + loop { + match command_rx.try_recv() { + Ok(OwnerCommand::Shutdown(response)) => { + let report = ordered_shutdown( + &mut session, + &event_tx, + event_notifier.as_ref(), + config.shutdown_timeout, + ); + break 'owner Some((response, report)); + } + Ok(command) => process_command(&mut session, command), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + let _ = ordered_shutdown( + &mut session, + &event_tx, + event_notifier.as_ref(), + config.shutdown_timeout, + ); + break 'owner None; + } + } + } + + match session.drain_events() { + Ok(events) => { + for event in events { + if !forward_event(&event_tx, event_notifier.as_ref(), event) + { + let _ = ordered_shutdown( + &mut session, + &event_tx, + event_notifier.as_ref(), + config.shutdown_timeout, + ); + break 'owner None; + } + } + } + Err(_) => break 'owner None, + } + + session.wait_for_wakeup(config.recovery_wake_interval); + }; + + // mpv's macOS VO may synchronously dispatch AppKit teardown from this + // drop. Publish completion only afterwards so poll-driven callers can + // safely release native hosts and the worker once they observe the report. + drop(session); + if let Some((response, report)) = shutdown_completion { + let _ = response.send(report); + } +} + +fn process_command(session: &mut MpvSession, command: OwnerCommand) { + match command { + OwnerCommand::Command(arguments, response) => { + let _ = response.send(session.command_async(arguments)); + } + OwnerCommand::NodeCommand(command, response) => { + let _ = response.send(session.command_node_async(&command)); + } + OwnerCommand::Cancel(id, response) => { + let _ = response.send(session.cancel_request(id)); + } + OwnerCommand::SetProperty(name, value, response) => { + let _ = response.send(session.set_property_async(&name, &value)); + } + OwnerCommand::GetProperty(name, format, response) => { + let _ = response.send(session.get_property_async(&name, format)); + } + OwnerCommand::Observe(name, format, response) => { + let _ = response.send(session.observe_property(&name, format)); + } + OwnerCommand::Unobserve(id, response) => { + let _ = response.send(session.unobserve_property(id)); + } + OwnerCommand::AddHook(name, priority, response) => { + let _ = response.send(session.add_hook(&name, priority)); + } + OwnerCommand::ContinueHook(id, response) => { + let _ = response.send(session.continue_hook(id)); + } + OwnerCommand::SetEvent(event_id, enabled, response) => { + let _ = response.send(session.set_event_enabled(event_id, enabled)); + } + OwnerCommand::SetLogLevel(level, response) => { + let _ = response.send(session.set_log_level(level)); + } + OwnerCommand::Shutdown(_) => { + unreachable!("shutdown handled by owner loop") + } + } +} + +fn forward_event( + event_tx: &Sender, + event_notifier: Option<&SyncSender<()>>, + event: MpvEvent, +) -> bool { + if event_tx.send(event).is_err() { + return false; + } + if let Some(notifier) = event_notifier { + // A full channel already represents pending work. A disconnected + // notifier does not affect event ownership or ordered shutdown. + let _ = notifier.try_send(()); + } + true +} + +fn ordered_shutdown( + session: &mut MpvSession, + event_tx: &Sender, + event_notifier: Option<&SyncSender<()>>, + timeout: Duration, +) -> MpvShutdownReport { + let mut report = MpvShutdownReport::default(); + if session.is_shutting_down() { + return report; + } + + match session.command_async(["stop"]) { + Ok(id) => report.stop_request = Some(id), + Err(error) => { + report.stop_error = Some(error); + return report; + } + } + + let deadline = Instant::now() + timeout; + let mut quiet_since = None; + loop { + let events = match session.drain_events() { + Ok(events) => events, + Err(error) => { + report.stop_error = Some(error); + return report; + } + }; + if events.is_empty() { + if report.stop_reply_received { + let quiet = quiet_since.get_or_insert_with(Instant::now); + if quiet.elapsed() >= Duration::from_millis(25) { + return report; + } + } + } else { + quiet_since = None; + } + + for event in events { + if matches!( + &event, + MpvEvent::AsyncReply(reply) + if Some(reply.id) == report.stop_request + ) { + report.stop_reply_received = true; + } + if matches!( + &event, + MpvEvent::EndFile(end) + if matches!( + end.reason, + MpvEndFileReason::Stop + | MpvEndFileReason::Eof + | MpvEndFileReason::Quit + ) + ) { + report.end_file_received = true; + } + report.forwarded_events = report.forwarded_events.saturating_add(1); + let _ = forward_event(event_tx, event_notifier, event); + } + + let now = Instant::now(); + if now >= deadline { + report.timed_out = true; + return report; + } + session.wait_for_wakeup( + deadline + .saturating_duration_since(now) + .min(Duration::from_millis(25)), + ); + } +} + +// The macOS VO/AppKit ownership model remains an explicit P3 validation gate; +// do not claim a background-thread owner there from a headless unit test. +#[cfg(all(test, feature = "linked", not(target_os = "macos")))] +mod linked_tests { + use super::*; + + #[test] + fn worker_serializes_real_property_and_ordered_shutdown() { + let (notifier_tx, notifier_rx) = mpsc::sync_channel(1); + let mut worker = MpvWorker::spawn_with_event_notifier( + MpvFunctionTable::linked(), + MpvSessionConfig::default(), + MpvWorkerConfig::default(), + notifier_tx, + ) + .unwrap(); + let request = worker + .get_property_async("mpv-version", MpvFormat::String) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(3); + loop { + notifier_rx + .recv_timeout(Duration::from_secs(1)) + .expect("copied events notify the consumer"); + let events = worker.drain_events(); + if events.iter().any(|event| { + matches!( + event, + MpvEvent::AsyncReply(reply) if reply.id == request + ) + }) { + break; + } + assert!(Instant::now() < deadline, "property reply timed out"); + } + + let report = worker.shutdown().unwrap(); + assert!(report.stop_request.is_some()); + assert!(report.stop_reply_received, "{report:?}"); + assert!(!report.timed_out, "{report:?}"); + assert_eq!(worker.shutdown().unwrap(), report); + } +} + +#[cfg(all(test, feature = "linked"))] +mod linked_headless_shutdown_tests { + use super::*; + + #[test] + fn worker_supports_poll_driven_headless_shutdown() { + let mut worker = MpvWorker::spawn( + MpvFunctionTable::linked(), + MpvSessionConfig::default(), + MpvWorkerConfig::default(), + ) + .unwrap(); + + worker.begin_shutdown().unwrap(); + assert!(worker.shutdown_pending()); + let deadline = Instant::now() + Duration::from_secs(5); + let report = loop { + if let Some(report) = worker.try_finish_shutdown().unwrap() { + break report; + } + assert!( + Instant::now() < deadline, + "poll-driven headless shutdown timed out" + ); + thread::sleep(Duration::from_millis(1)); + }; + + assert!(!worker.shutdown_pending()); + assert!(report.stop_request.is_some()); + assert!(report.stop_reply_received, "{report:?}"); + assert!(!report.timed_out, "{report:?}"); + assert_eq!(worker.try_finish_shutdown().unwrap(), Some(report)); + } +} diff --git a/crates/ferrex-player-mpv/src/raw.rs b/crates/ferrex-player-mpv/src/raw.rs new file mode 100644 index 00000000..7f52ec22 --- /dev/null +++ b/crates/ferrex-player-mpv/src/raw.rs @@ -0,0 +1,520 @@ +//! Raw libmpv client ABI used by the safe Ferrex wrapper. +//! +//! These declarations intentionally mirror the small subset of `client.h` +//! needed by the control plane. Constructing a function table is unsafe; normal +//! callers should use [`crate::MpvFunctionTable::linked`] instead. + +use std::ffi::{c_char, c_double, c_int, c_uint, c_void}; + +pub(crate) type RawMpvHandle = c_void; + +pub(crate) const FORMAT_NONE: c_uint = 0; +pub(crate) const FORMAT_STRING: c_uint = 1; +pub(crate) const FORMAT_OSD_STRING: c_uint = 2; +pub(crate) const FORMAT_FLAG: c_uint = 3; +pub(crate) const FORMAT_INT64: c_uint = 4; +pub(crate) const FORMAT_DOUBLE: c_uint = 5; +pub(crate) const FORMAT_NODE: c_uint = 6; +pub(crate) const FORMAT_NODE_ARRAY: c_uint = 7; +pub(crate) const FORMAT_NODE_MAP: c_uint = 8; +pub(crate) const FORMAT_BYTE_ARRAY: c_uint = 9; + +pub(crate) const EVENT_NONE: c_uint = 0; +pub(crate) const EVENT_SHUTDOWN: c_uint = 1; +pub(crate) const EVENT_LOG_MESSAGE: c_uint = 2; +pub(crate) const EVENT_GET_PROPERTY_REPLY: c_uint = 3; +pub(crate) const EVENT_SET_PROPERTY_REPLY: c_uint = 4; +pub(crate) const EVENT_COMMAND_REPLY: c_uint = 5; +pub(crate) const EVENT_START_FILE: c_uint = 6; +pub(crate) const EVENT_END_FILE: c_uint = 7; +pub(crate) const EVENT_FILE_LOADED: c_uint = 8; +pub(crate) const EVENT_IDLE: c_uint = 11; +pub(crate) const EVENT_TICK: c_uint = 14; +pub(crate) const EVENT_CLIENT_MESSAGE: c_uint = 16; +pub(crate) const EVENT_VIDEO_RECONFIG: c_uint = 17; +pub(crate) const EVENT_AUDIO_RECONFIG: c_uint = 18; +pub(crate) const EVENT_SEEK: c_uint = 20; +pub(crate) const EVENT_PLAYBACK_RESTART: c_uint = 21; +pub(crate) const EVENT_PROPERTY_CHANGE: c_uint = 22; +pub(crate) const EVENT_QUEUE_OVERFLOW: c_uint = 24; +pub(crate) const EVENT_HOOK: c_uint = 25; + +pub(crate) const END_FILE_REASON_EOF: c_uint = 0; +pub(crate) const END_FILE_REASON_STOP: c_uint = 2; +pub(crate) const END_FILE_REASON_QUIT: c_uint = 3; +pub(crate) const END_FILE_REASON_ERROR: c_uint = 4; +pub(crate) const END_FILE_REASON_REDIRECT: c_uint = 5; + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvNode { + pub(crate) value: RawMpvNodeValue, + pub(crate) format: c_uint, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) union RawMpvNodeValue { + pub(crate) string: *mut c_char, + pub(crate) flag: c_int, + pub(crate) int64: i64, + pub(crate) double_: c_double, + pub(crate) list: *mut RawMpvNodeList, + pub(crate) bytes: *mut RawMpvByteArray, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvNodeList { + pub(crate) count: c_int, + pub(crate) values: *mut RawMpvNode, + pub(crate) keys: *mut *mut c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvByteArray { + pub(crate) data: *mut c_void, + pub(crate) size: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvEvent { + pub(crate) event_id: c_uint, + pub(crate) error: c_int, + pub(crate) reply_userdata: u64, + pub(crate) data: *mut c_void, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvEventProperty { + pub(crate) name: *const c_char, + pub(crate) format: c_uint, + pub(crate) data: *mut c_void, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvEventLogMessage { + pub(crate) prefix: *const c_char, + pub(crate) level: *const c_char, + pub(crate) text: *const c_char, + pub(crate) log_level: c_uint, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvEventStartFile { + pub(crate) playlist_entry_id: i64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvEventEndFile { + pub(crate) reason: c_uint, + pub(crate) error: c_int, + pub(crate) playlist_entry_id: i64, + pub(crate) playlist_insert_id: i64, + pub(crate) playlist_insert_num_entries: c_int, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvEventClientMessage { + pub(crate) count: c_int, + pub(crate) args: *mut *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvEventHook { + pub(crate) name: *const c_char, + pub(crate) id: u64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub(crate) struct RawMpvEventCommand { + pub(crate) result: RawMpvNode, +} + +pub(crate) type SetOptionStringFn = unsafe extern "C" fn( + *mut RawMpvHandle, + *const c_char, + *const c_char, +) -> c_int; +pub(crate) type CommandAsyncFn = + unsafe extern "C" fn(*mut RawMpvHandle, u64, *mut *const c_char) -> c_int; +pub(crate) type CommandNodeAsyncFn = + unsafe extern "C" fn(*mut RawMpvHandle, u64, *mut RawMpvNode) -> c_int; +pub(crate) type AbortAsyncCommandFn = + unsafe extern "C" fn(*mut RawMpvHandle, u64); +pub(crate) type SetPropertyAsyncFn = unsafe extern "C" fn( + *mut RawMpvHandle, + u64, + *const c_char, + c_uint, + *mut c_void, +) -> c_int; +pub(crate) type GetPropertyAsyncFn = unsafe extern "C" fn( + *mut RawMpvHandle, + u64, + *const c_char, + c_uint, +) -> c_int; +pub(crate) type ObservePropertyFn = unsafe extern "C" fn( + *mut RawMpvHandle, + u64, + *const c_char, + c_uint, +) -> c_int; +pub(crate) type UnobservePropertyFn = + unsafe extern "C" fn(*mut RawMpvHandle, u64) -> c_int; +pub(crate) type RequestLogMessagesFn = + unsafe extern "C" fn(*mut RawMpvHandle, *const c_char) -> c_int; +pub(crate) type WaitEventFn = + unsafe extern "C" fn(*mut RawMpvHandle, c_double) -> *mut RawMpvEvent; +pub(crate) type WakeupCallback = Option; +pub(crate) type SetWakeupCallbackFn = + unsafe extern "C" fn(*mut RawMpvHandle, WakeupCallback, *mut c_void); +pub(crate) type RequestEventFn = + unsafe extern "C" fn(*mut RawMpvHandle, c_uint, c_int) -> c_int; +pub(crate) type HookAddFn = + unsafe extern "C" fn(*mut RawMpvHandle, u64, *const c_char, c_int) -> c_int; +pub(crate) type HookContinueFn = + unsafe extern "C" fn(*mut RawMpvHandle, u64) -> c_int; + +/// Raw control-plane symbols from one ABI-compatible libmpv instance. +#[derive(Clone, Copy)] +pub(crate) struct MpvControlApi { + pub(crate) set_option_string: SetOptionStringFn, + pub(crate) command_async: CommandAsyncFn, + pub(crate) command_node_async: CommandNodeAsyncFn, + pub(crate) abort_async_command: AbortAsyncCommandFn, + pub(crate) set_property_async: SetPropertyAsyncFn, + pub(crate) get_property_async: GetPropertyAsyncFn, + pub(crate) observe_property: ObservePropertyFn, + pub(crate) unobserve_property: UnobservePropertyFn, + pub(crate) request_log_messages: RequestLogMessagesFn, + pub(crate) wait_event: WaitEventFn, + pub(crate) set_wakeup_callback: SetWakeupCallbackFn, + pub(crate) request_event: RequestEventFn, + pub(crate) hook_add: HookAddFn, + pub(crate) hook_continue: HookContinueFn, +} + +impl std::fmt::Debug for MpvControlApi { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MpvControlApi") + .finish_non_exhaustive() + } +} + +impl MpvControlApi { + /// Construct a control table from trusted ABI-compatible symbols. + /// + /// # Safety + /// + /// Every symbol must implement the matching libmpv client API function, + /// remain valid for all handles used with this table, and originate from + /// the same library instance as the lifecycle symbols. + #[cfg(test)] + #[allow(clippy::too_many_arguments)] + pub(crate) const unsafe fn from_raw_parts( + set_option_string: SetOptionStringFn, + command_async: CommandAsyncFn, + command_node_async: CommandNodeAsyncFn, + abort_async_command: AbortAsyncCommandFn, + set_property_async: SetPropertyAsyncFn, + get_property_async: GetPropertyAsyncFn, + observe_property: ObservePropertyFn, + unobserve_property: UnobservePropertyFn, + request_log_messages: RequestLogMessagesFn, + wait_event: WaitEventFn, + set_wakeup_callback: SetWakeupCallbackFn, + request_event: RequestEventFn, + hook_add: HookAddFn, + hook_continue: HookContinueFn, + ) -> Self { + Self { + set_option_string, + command_async, + command_node_async, + abort_async_command, + set_property_async, + get_property_async, + observe_property, + unobserve_property, + request_log_messages, + wait_event, + set_wakeup_callback, + request_event, + hook_add, + hook_continue, + } + } + + #[cfg(feature = "linked")] + pub(crate) const fn linked() -> Self { + Self { + set_option_string: linked_set_option_string, + command_async: linked_command_async, + command_node_async: linked_command_node_async, + abort_async_command: linked_abort_async_command, + set_property_async: linked_set_property_async, + get_property_async: linked_get_property_async, + observe_property: linked_observe_property, + unobserve_property: linked_unobserve_property, + request_log_messages: linked_request_log_messages, + wait_event: linked_wait_event, + set_wakeup_callback: linked_set_wakeup_callback, + request_event: linked_request_event, + hook_add: linked_hook_add, + hook_continue: linked_hook_continue, + } + } +} + +#[cfg(feature = "linked")] +const _: () = { + use std::mem::{align_of, offset_of, size_of}; + + assert!(size_of::() == size_of::()); + assert!(align_of::() == align_of::()); + assert!( + offset_of!(RawMpvNode, value) == offset_of!(libmpv2_sys::mpv_node, u) + ); + assert!( + offset_of!(RawMpvNode, format) + == offset_of!(libmpv2_sys::mpv_node, format) + ); + + assert!( + size_of::() == size_of::() + ); + assert!( + align_of::() + == align_of::() + ); + assert!( + offset_of!(RawMpvNodeList, count) + == offset_of!(libmpv2_sys::mpv_node_list, num) + ); + assert!( + offset_of!(RawMpvNodeList, values) + == offset_of!(libmpv2_sys::mpv_node_list, values) + ); + assert!( + offset_of!(RawMpvNodeList, keys) + == offset_of!(libmpv2_sys::mpv_node_list, keys) + ); + + assert!( + size_of::() + == size_of::() + ); + assert!( + align_of::() + == align_of::() + ); + assert!(size_of::() == size_of::()); + assert!(align_of::() == align_of::()); + assert!( + offset_of!(RawMpvEvent, data) + == offset_of!(libmpv2_sys::mpv_event, data) + ); + assert!( + size_of::() + == size_of::() + ); + assert!( + align_of::() + == align_of::() + ); + assert!( + size_of::() + == size_of::() + ); + assert!( + align_of::() + == align_of::() + ); + assert!( + size_of::() + == size_of::() + ); + assert!( + align_of::() + == align_of::() + ); +}; + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_set_option_string( + handle: *mut RawMpvHandle, + name: *const c_char, + value: *const c_char, +) -> c_int { + // SAFETY: the wrapper preserves the libmpv ABI and opaque handle identity. + unsafe { libmpv2_sys::mpv_set_option_string(handle.cast(), name, value) } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_command_async( + handle: *mut RawMpvHandle, + userdata: u64, + args: *mut *const c_char, +) -> c_int { + // SAFETY: arguments follow `mpv_command_async` and are copied by libmpv. + unsafe { libmpv2_sys::mpv_command_async(handle.cast(), userdata, args) } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_command_node_async( + handle: *mut RawMpvHandle, + userdata: u64, + args: *mut RawMpvNode, +) -> c_int { + // SAFETY: raw node layouts are asserted above and libmpv copies arguments. + unsafe { + libmpv2_sys::mpv_command_node_async( + handle.cast(), + userdata, + args.cast(), + ) + } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_abort_async_command( + handle: *mut RawMpvHandle, + userdata: u64, +) { + // SAFETY: forwarded to the matching handle's abort function. + unsafe { libmpv2_sys::mpv_abort_async_command(handle.cast(), userdata) }; +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_set_property_async( + handle: *mut RawMpvHandle, + userdata: u64, + name: *const c_char, + format: c_uint, + data: *mut c_void, +) -> c_int { + // SAFETY: format/data are built according to `client.h` and copied by mpv. + unsafe { + libmpv2_sys::mpv_set_property_async( + handle.cast(), + userdata, + name, + format, + data, + ) + } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_get_property_async( + handle: *mut RawMpvHandle, + userdata: u64, + name: *const c_char, + format: c_uint, +) -> c_int { + // SAFETY: forwarded with an ABI-compatible format value. + unsafe { + libmpv2_sys::mpv_get_property_async( + handle.cast(), + userdata, + name, + format, + ) + } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_observe_property( + handle: *mut RawMpvHandle, + userdata: u64, + name: *const c_char, + format: c_uint, +) -> c_int { + // SAFETY: forwarded with an ABI-compatible format value. + unsafe { + libmpv2_sys::mpv_observe_property(handle.cast(), userdata, name, format) + } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_unobserve_property( + handle: *mut RawMpvHandle, + userdata: u64, +) -> c_int { + // SAFETY: userdata belongs to an observation on this handle. + unsafe { libmpv2_sys::mpv_unobserve_property(handle.cast(), userdata) } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_request_log_messages( + handle: *mut RawMpvHandle, + level: *const c_char, +) -> c_int { + // SAFETY: level is a live NUL-terminated string for the duration of call. + unsafe { libmpv2_sys::mpv_request_log_messages(handle.cast(), level) } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_wait_event( + handle: *mut RawMpvHandle, + timeout: c_double, +) -> *mut RawMpvEvent { + // SAFETY: event layout is asserted above; lifetime remains owned by mpv. + unsafe { libmpv2_sys::mpv_wait_event(handle.cast(), timeout).cast() } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_set_wakeup_callback( + handle: *mut RawMpvHandle, + callback: WakeupCallback, + userdata: *mut c_void, +) { + // SAFETY: callback ABI exactly matches the libmpv declaration. + unsafe { + libmpv2_sys::mpv_set_wakeup_callback(handle.cast(), callback, userdata) + }; +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_request_event( + handle: *mut RawMpvHandle, + event: c_uint, + enabled: c_int, +) -> c_int { + // SAFETY: event IDs and enable values use the native representation. + unsafe { libmpv2_sys::mpv_request_event(handle.cast(), event, enabled) } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_hook_add( + handle: *mut RawMpvHandle, + userdata: u64, + name: *const c_char, + priority: c_int, +) -> c_int { + // SAFETY: name is live for the call and libmpv copies it. + unsafe { + libmpv2_sys::mpv_hook_add(handle.cast(), userdata, name, priority) + } +} + +#[cfg(feature = "linked")] +unsafe extern "C" fn linked_hook_continue( + handle: *mut RawMpvHandle, + id: u64, +) -> c_int { + // SAFETY: callers validate that the hook ID is outstanding on this handle. + unsafe { libmpv2_sys::mpv_hook_continue(handle.cast(), id) } +} diff --git a/crates/ferrex-player-mpv/src/session.rs b/crates/ferrex-player-mpv/src/session.rs new file mode 100644 index 00000000..33d69de7 --- /dev/null +++ b/crates/ferrex-player-mpv/src/session.rs @@ -0,0 +1,2532 @@ +//! Serialized libmpv control plane and owned event delivery. + +use std::{ + collections::{HashMap, HashSet}, + ffi::{CStr, CString, c_char, c_int, c_void}, + ptr::{self, NonNull}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + thread::{self, Thread}, + time::Duration, +}; + +use crate::{ + MpvFfiError, MpvFunctionTable, MpvHandle, + node::{ + MpvFormat, MpvNode, MpvNodeError, MpvNodeLimits, RawNodeArena, + copy_raw_event_value, copy_raw_node, + }, + raw::{ + END_FILE_REASON_EOF, END_FILE_REASON_ERROR, END_FILE_REASON_QUIT, + END_FILE_REASON_REDIRECT, END_FILE_REASON_STOP, EVENT_AUDIO_RECONFIG, + EVENT_CLIENT_MESSAGE, EVENT_COMMAND_REPLY, EVENT_END_FILE, + EVENT_FILE_LOADED, EVENT_GET_PROPERTY_REPLY, EVENT_HOOK, EVENT_IDLE, + EVENT_LOG_MESSAGE, EVENT_NONE, EVENT_PLAYBACK_RESTART, + EVENT_PROPERTY_CHANGE, EVENT_QUEUE_OVERFLOW, EVENT_SEEK, + EVENT_SET_PROPERTY_REPLY, EVENT_SHUTDOWN, EVENT_START_FILE, EVENT_TICK, + EVENT_VIDEO_RECONFIG, FORMAT_DOUBLE, FORMAT_FLAG, FORMAT_INT64, + FORMAT_NODE, FORMAT_STRING, MpvControlApi, RawMpvEvent, + RawMpvEventClientMessage, RawMpvEventCommand, RawMpvEventEndFile, + RawMpvEventHook, RawMpvEventLogMessage, RawMpvEventProperty, + RawMpvEventStartFile, RawMpvHandle, + }, +}; + +/// Correlation identity for an asynchronous command or property operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MpvRequestId(u64); + +impl MpvRequestId { + /// Construct an ID for diagnostics or a fake backend. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Native `reply_userdata` value. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Stable identity for a property observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MpvObservationId(u64); + +impl MpvObservationId { + /// Construct an ID for diagnostics or a fake backend. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Native `reply_userdata` value. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Stable identity for a registered mpv hook. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MpvHookRegistrationId(u64); + +impl MpvHookRegistrationId { + /// Native `reply_userdata` value. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Identity that must be continued exactly once after a hook event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MpvHookId(u64); + +impl MpvHookId { + /// Native hook identity. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Kind of asynchronous operation represented by a reply. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MpvRequestKind { + /// String-vector command. + Command, + /// Node-valued command. + NodeCommand, + /// Property read. + GetProperty, + /// Property write. + SetProperty, +} + +/// One native mpv error code with a non-sensitive static description. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("libmpv error {code}: {description}")] +pub struct MpvError { + /// Native negative `mpv_error` value. + pub code: i32, + /// Static description derived from the public error enum. + pub description: &'static str, +} + +impl MpvError { + fn from_code(code: i32) -> Self { + Self { + code, + description: mpv_error_description(code), + } + } +} + +const fn mpv_error_description(code: i32) -> &'static str { + match code { + 0.. => "success", + -1 => "event queue full", + -2 => "out of memory", + -3 => "uninitialized", + -4 => "invalid parameter", + -5 => "option not found", + -6 => "option format unsupported", + -7 => "option error", + -8 => "property not found", + -9 => "property format unsupported", + -10 => "property unavailable", + -11 => "property error", + -12 => "command error", + -13 => "loading failed", + -14 => "audio output initialization failed", + -15 => "video output initialization failed", + -16 => "nothing to play", + -17 => "unknown media format", + -18 => "unsupported", + -19 => "not implemented", + -20 => "generic error", + _ => "unknown error", + } +} + +/// Control-plane setup or submission failure. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum MpvSessionError { + /// Handle/version lifecycle failed. + #[error(transparent)] + Ffi(#[from] MpvFfiError), + /// The function table only contains P2 lifecycle symbols. + #[error("libmpv function table does not provide control-plane symbols")] + MissingControlApi, + /// A name/value could not cross the C string boundary. + #[error("{category} contains an interior NUL byte")] + InteriorNul { + /// Non-sensitive value category. + category: &'static str, + }, + /// An asynchronous command did not contain a command name. + #[error("mpv command must contain at least one argument")] + EmptyCommand, + /// A node command was not an array or map. + #[error("mpv node command root must be an array or map")] + InvalidNodeCommand, + /// A native API call rejected a request before it could be queued. + #[error("{operation} failed: {error}")] + NativeCall { + /// Static operation name. + operation: &'static str, + /// Native error. + error: MpvError, + }, + /// Request/observation/hook identities cannot be allocated anymore. + #[error("libmpv userdata identity space is exhausted")] + UserdataExhausted, + /// Node conversion failed. + #[error(transparent)] + Node(#[from] MpvNodeError), + /// The request is not currently pending. + #[error("mpv request {0} is not pending")] + UnknownRequest(u64), + /// The observation is not currently registered. + #[error("mpv observation {0} is not registered")] + UnknownObservation(u64), + /// The hook event has already been continued or was never received. + #[error("mpv hook {0} is not awaiting continuation")] + UnknownHook(u64), + /// mpv has entered shutdown and rejects new work. + #[error("libmpv session is shutting down")] + ShuttingDown, + /// `mpv_wait_event` violated its documented non-null contract. + #[error("libmpv returned a null event pointer")] + NullEvent, +} + +/// Log filtering requested from libmpv. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MpvLogLevel { + /// Disable native log events. + None, + /// Fatal only. + Fatal, + /// Errors and above. + Error, + /// Warnings and above. + Warn, + /// Informational messages and above. + Info, + /// Verbose messages and above. + Verbose, + /// Debug messages and above. + Debug, + /// All trace messages. + Trace, +} + +impl MpvLogLevel { + const fn as_str(self) -> &'static str { + match self { + Self::None => "no", + Self::Fatal => "fatal", + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Verbose => "v", + Self::Debug => "debug", + Self::Trace => "trace", + } + } +} + +/// One deterministic pre-initialization option. +#[derive(Clone, PartialEq, Eq)] +pub struct MpvOption { + name: String, + value: String, +} + +impl MpvOption { + /// Create an option. Validation happens at session construction. + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: value.into(), + } + } + + /// Option name without leading dashes. + pub fn name(&self) -> &str { + &self.name + } +} + +impl std::fmt::Debug for MpvOption { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MpvOption") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + +/// Policy controlling whether mpv may load configuration from the user's +/// standard mpv directories. +/// +/// The trusted mode is deliberately coarse-grained: mpv configuration can +/// reference scripts and other native resources, so enabling it is equivalent +/// to allowing trusted code to execute inside the Ferrex process. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum MpvConfigPolicy { + /// Use only Ferrex-owned options and do not discover user config or scripts. + #[default] + Deterministic, + /// Load the user's standard mpv config, input bindings, and scripts. + TrustedUser, +} + +impl MpvConfigPolicy { + /// Stable diagnostic/configuration label. + pub const fn as_str(self) -> &'static str { + match self { + Self::Deterministic => "deterministic", + Self::TrustedUser => "trusted-user", + } + } + + /// Whether standard user config discovery is enabled. + pub const fn user_config_enabled(self) -> bool { + matches!(self, Self::TrustedUser) + } + + /// Whether standard user script discovery is enabled. + pub const fn user_scripts_enabled(self) -> bool { + matches!(self, Self::TrustedUser) + } +} + +/// Deterministic initialization policy for one mpv core. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MpvSessionConfig { + options: Vec, + log_level: MpvLogLevel, + node_limits: MpvNodeLimits, + config_policy: MpvConfigPolicy, +} + +impl Default for MpvSessionConfig { + fn default() -> Self { + Self::native_window() + } +} + +impl MpvSessionConfig { + /// Built-in profile for mpv's ordinary native VO/window. + /// + /// No render context, user config, scripts, external URL resolver, OSC, or + /// default input bindings are enabled. A later native-window selector may + /// opt into controlled OSC/input behavior explicitly. + pub fn native_window() -> Self { + Self::native_window_with_config_policy(MpvConfigPolicy::Deterministic) + } + + /// Built-in native-window profile with an explicit user-config trust + /// policy. + /// + /// [`MpvConfigPolicy::TrustedUser`] enables mpv's standard config and + /// script discovery. Callers must expose that as an explicit trusted-code + /// opt-in; it must never be inferred from the presence of config files. + pub fn native_window_with_config_policy( + config_policy: MpvConfigPolicy, + ) -> Self { + let discovered_config = if config_policy.user_config_enabled() { + "yes" + } else { + "no" + }; + let discovered_scripts = if config_policy.user_scripts_enabled() { + "yes" + } else { + "no" + }; + + Self { + options: vec![ + MpvOption::new("config", discovered_config), + MpvOption::new("load-scripts", discovered_scripts), + // External URL resolvers remain disabled in both policies. + // Trusted users can still configure mpv-native functionality, + // but Ferrex does not package or invoke yt-dlp implicitly. + MpvOption::new("ytdl", "no"), + MpvOption::new("terminal", "no"), + MpvOption::new("osc", "no"), + MpvOption::new("input-default-bindings", "no"), + MpvOption::new("input-vo-keyboard", "no"), + MpvOption::new("vo", "gpu-next,gpu"), + MpvOption::new("hwdec", "auto-safe"), + ], + log_level: MpvLogLevel::Info, + node_limits: MpvNodeLimits::default(), + config_policy, + } + } + + /// Append or override an mpv option. mpv applies repeated options in order. + pub fn with_option( + mut self, + name: impl Into, + value: impl Into, + ) -> Self { + self.options.push(MpvOption::new(name, value)); + self + } + + /// Set native log filtering. + pub const fn with_log_level(mut self, level: MpvLogLevel) -> Self { + self.log_level = level; + self + } + + /// Set bounds for copied event/node payloads. + pub const fn with_node_limits(mut self, limits: MpvNodeLimits) -> Self { + self.node_limits = limits; + self + } + + /// Ordered options passed before `mpv_initialize`. + pub fn options(&self) -> &[MpvOption] { + &self.options + } + + /// User-config trust policy represented by this profile. + pub const fn config_policy(&self) -> MpvConfigPolicy { + self.config_policy + } +} + +/// Coalescing wakeup signal installed as libmpv's callback userdata. +/// +/// The foreign callback performs only an atomic transition and `Thread::unpark`; +/// it never calls libmpv, allocates, locks, blocks, or unwinds. +pub struct MpvWakeupSignal { + pending: AtomicBool, + notifications: AtomicU64, + owner: Thread, +} + +impl std::fmt::Debug for MpvWakeupSignal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MpvWakeupSignal") + .field("pending", &self.pending.load(Ordering::Relaxed)) + .field("notifications", &self.notifications.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +impl MpvWakeupSignal { + fn for_current_thread() -> Arc { + Arc::new(Self { + pending: AtomicBool::new(false), + notifications: AtomicU64::new(0), + owner: thread::current(), + }) + } + + /// Consume the current coalesced wakeup bit. + pub fn take_pending(&self) -> bool { + self.pending.swap(false, Ordering::AcqRel) + } + + /// Number of callback invocations, saturated on overflow. + pub fn notification_count(&self) -> u64 { + self.notifications.load(Ordering::Relaxed) + } + + fn notify(&self) { + let _ = self.notifications.fetch_update( + Ordering::Relaxed, + Ordering::Relaxed, + |value| Some(value.saturating_add(1)), + ); + if !self.pending.swap(true, Ordering::AcqRel) { + self.owner.unpark(); + } + } +} + +unsafe extern "C" fn wakeup_callback(userdata: *mut c_void) { + let Some(signal) = NonNull::new(userdata.cast::()) else { + return; + }; + // SAFETY: registration owns a strong Arc reference until after callback + // removal and native handle destruction. + unsafe { signal.as_ref() }.notify(); +} + +#[derive(Debug, Clone)] +struct PendingRequest { + kind: MpvRequestKind, + cancelled: bool, +} + +#[derive(Debug, Clone)] +struct Observation { + name: String, + format: MpvFormat, +} + +/// Completion of one asynchronous request. +#[derive(Debug, Clone, PartialEq)] +pub struct MpvAsyncReply { + /// Correlated request identity. + pub id: MpvRequestId, + /// Submitted operation kind. + pub kind: MpvRequestKind, + /// Whether cancellation was requested before this reply was drained. + pub cancellation_requested: bool, + /// Native result or copied command/property value. + pub result: Result, MpvError>, +} + +/// Copied property observation event. +#[derive(Debug, Clone, PartialEq)] +pub struct MpvPropertyChange { + /// Observation identity from registration. + pub id: MpvObservationId, + /// Copied property name. + pub name: String, + /// `None` means unavailable or notification-only format. + pub value: Option, + /// Whether this identity is still known to the session. + pub registered: bool, +} + +/// Severity mapped from mpv's numeric and textual log levels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MpvMessageLevel { + /// Fatal process/core condition. + Fatal, + /// Error. + Error, + /// Warning. + Warn, + /// Informational message. + Info, + /// Verbose informational message. + Verbose, + /// Debug message. + Debug, + /// Trace message. + Trace, + /// A future native level. + Unknown(u32), +} + +/// Redacted, Ferrex-owned native log message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MpvLogMessage { + /// Native component prefix. + pub prefix: String, + /// Mapped severity. + pub level: MpvMessageLevel, + /// Sanitized text with common credential forms removed. + pub text: String, +} + +/// Why one file ended according to mpv. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MpvEndFileReason { + /// Normal end of file/range. + Eof, + /// Explicit stop command. + Stop, + /// Core quit/shutdown. + Quit, + /// Playback error. + Error, + /// Playlist redirect. + Redirect, + /// Future native reason. + Unknown(u32), +} + +/// Copied end-file details. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MpvEndFile { + /// Native reason. + pub reason: MpvEndFileReason, + /// Native playback error when reason is `Error`. + pub error: Option, + /// Stable playlist entry identity. + pub playlist_entry_id: i64, + /// First inserted playlist identity for redirects. + pub playlist_insert_id: i64, + /// Number of inserted entries. + pub playlist_insert_count: u32, +} + +/// Copied hook event awaiting one continuation call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MpvHook { + /// Registration that produced the hook. + pub registration: MpvHookRegistrationId, + /// Native continuation identity. + pub id: MpvHookId, + /// Copied hook name. + pub name: String, +} + +/// Fully owned event; no variant borrows libmpv's event ring buffer. +#[derive(Debug, Clone, PartialEq)] +pub enum MpvEvent { + /// Core is shutting down. + Shutdown, + /// Redacted native log message. + Log(MpvLogMessage), + /// Correlated asynchronous completion. + AsyncReply(MpvAsyncReply), + /// Reply did not match a pending request. + UnmatchedAsyncReply { + /// Native identity. + id: MpvRequestId, + /// Kind inferred from event ID. + kind: MpvRequestKind, + /// Native error, if any. + error: Option, + }, + /// Property observation changed. + PropertyChanged(MpvPropertyChange), + /// Playback is starting a playlist entry. + StartFile { + /// Stable native playlist entry identity. + playlist_entry_id: i64, + }, + /// Headers loaded and decoding can start. + FileLoaded, + /// Playback ended/unloaded a file. + EndFile(MpvEndFile), + /// Core entered idle mode. + Idle, + /// Script/client message with copied arguments. + ClientMessage(Vec), + /// Video output was reconfigured. + VideoReconfigured, + /// Audio output was reconfigured. + AudioReconfigured, + /// Seek started. + Seek, + /// Playback restarted after load/seek. + PlaybackRestart, + /// Deprecated tick event retained for arbitrary event access. + Tick, + /// Native event queue overflowed. + QueueOverflow, + /// Hook awaiting continuation. + Hook(MpvHook), + /// Malformed native payload was copied into a safe diagnostic. + ProtocolError { + /// Native event ID. + event_id: u32, + /// Non-sensitive validation failure. + message: String, + }, + /// Future event ID not understood by this release. + Unknown { + /// Unrecognized native event identity. + event_id: u32, + }, +} + +/// Serialized owner of one initialized libmpv client handle. +/// +/// The value is deliberately `!Send` through [`MpvHandle`]. All normal calls +/// require `&mut self`, and only this owner drains the event queue. +pub struct MpvSession { + handle: Option, + control: MpvControlApi, + next_userdata: u64, + pending: HashMap, + observations: HashMap, + hook_registrations: HashMap, + outstanding_hooks: HashSet, + wakeup: Arc, + wakeup_userdata: *const MpvWakeupSignal, + node_limits: MpvNodeLimits, + shutting_down: bool, +} + +impl std::fmt::Debug for MpvSession { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MpvSession") + .field("handle", &self.handle) + .field("next_userdata", &self.next_userdata) + .field("pending_requests", &self.pending.len()) + .field("observations", &self.observations.len()) + .field("hook_registrations", &self.hook_registrations.len()) + .field("outstanding_hooks", &self.outstanding_hooks.len()) + .field("wakeup", &self.wakeup) + .field("shutting_down", &self.shutting_down) + .finish_non_exhaustive() + } +} + +impl MpvSession { + /// Create, configure, initialize, and install the signal-only wakeup callback. + pub fn create( + functions: MpvFunctionTable, + config: MpvSessionConfig, + ) -> Result { + let control = functions + .control_api() + .ok_or(MpvSessionError::MissingControlApi)?; + let mut handle = MpvHandle::create(functions)?; + let raw = handle + .raw_ptr() + .expect("newly created mpv handle must be present") + .as_ptr(); + + for option in &config.options { + let name = c_string(option.name(), "mpv option name")?; + let value = c_string(&option.value, "mpv option value")?; + // SAFETY: strings are live for this call and the table matches raw. + let code = unsafe { + (control.set_option_string)(raw, name.as_ptr(), value.as_ptr()) + }; + check_native("mpv_set_option_string", code)?; + } + + let level = CString::new(config.log_level.as_str()) + .expect("built-in mpv log level has no NUL"); + // Request messages before initialization so runtime versions and the + // compiled feature list are available to diagnostics. libmpv permits + // this client-local event setting on a newly created handle. + // SAFETY: the created handle and static-level CString are valid here. + let code = + unsafe { (control.request_log_messages)(raw, level.as_ptr()) }; + check_native("mpv_request_log_messages", code)?; + handle.initialize()?; + + let wakeup = MpvWakeupSignal::for_current_thread(); + let wakeup_userdata = Arc::into_raw(Arc::clone(&wakeup)); + // SAFETY: the leaked Arc keeps userdata valid until explicit teardown; + // callback only performs signal-safe Ferrex notification work. + unsafe { + (control.set_wakeup_callback)( + raw, + Some(wakeup_callback), + wakeup_userdata.cast_mut().cast(), + ) + }; + + Ok(Self { + handle: Some(handle), + control, + next_userdata: 1, + pending: HashMap::new(), + observations: HashMap::new(), + hook_registrations: HashMap::new(), + outstanding_hooks: HashSet::new(), + wakeup, + wakeup_userdata, + node_limits: config.node_limits, + shutting_down: false, + }) + } + + /// Coalescing callback state for integration with an owner/runtime loop. + pub fn wakeup_signal(&self) -> Arc { + Arc::clone(&self.wakeup) + } + + /// Park this owner thread until a callback arrives or `timeout` elapses. + /// + /// Commands from another queue should call `Thread::unpark` on their owner + /// too; callers must always re-check both queues after this returns. + pub fn wait_for_wakeup(&self, timeout: Duration) -> bool { + if self.wakeup.take_pending() { + return true; + } + thread::park_timeout(timeout); + self.wakeup.take_pending() + } + + /// Submit a standard pre-split mpv command. + pub fn command_async( + &mut self, + arguments: I, + ) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.ensure_running()?; + let arguments = arguments + .into_iter() + .map(|argument| c_string(argument.as_ref(), "mpv command argument")) + .collect::, _>>()?; + if arguments.is_empty() { + return Err(MpvSessionError::EmptyCommand); + } + let mut pointers = arguments + .iter() + .map(|argument| argument.as_ptr()) + .chain(std::iter::once(ptr::null())) + .collect::>(); + let id = self.allocate_userdata()?; + // SAFETY: pointer array is NUL-terminated and libmpv copies it before + // returning from the asynchronous submission call. + let code = unsafe { + (self.control.command_async)(self.raw(), id, pointers.as_mut_ptr()) + }; + check_native("mpv_command_async", code)?; + self.pending.insert( + id, + PendingRequest { + kind: MpvRequestKind::Command, + cancelled: false, + }, + ); + Ok(MpvRequestId(id)) + } + + /// Submit an arbitrary array/map node command. + pub fn command_node_async( + &mut self, + command: &MpvNode, + ) -> Result { + self.ensure_running()?; + if !matches!(command, MpvNode::Array(_) | MpvNode::Map(_)) { + return Err(MpvSessionError::InvalidNodeCommand); + } + let mut arena = RawNodeArena::new(command)?; + let id = self.allocate_userdata()?; + // SAFETY: arena preserves every pointer until libmpv copies the command. + let code = unsafe { + (self.control.command_node_async)(self.raw(), id, arena.root_mut()) + }; + check_native("mpv_command_node_async", code)?; + self.pending.insert( + id, + PendingRequest { + kind: MpvRequestKind::NodeCommand, + cancelled: false, + }, + ); + Ok(MpvRequestId(id)) + } + + /// Request cancellation of an in-flight command. Completion still arrives + /// through the normal correlated reply event. + pub fn cancel_request( + &mut self, + id: MpvRequestId, + ) -> Result<(), MpvSessionError> { + let pending = self + .pending + .get_mut(&id.0) + .ok_or(MpvSessionError::UnknownRequest(id.0))?; + if !matches!( + pending.kind, + MpvRequestKind::Command | MpvRequestKind::NodeCommand + ) { + return Err(MpvSessionError::UnknownRequest(id.0)); + } + pending.cancelled = true; + // SAFETY: request identity belongs to this initialized handle. + unsafe { (self.control.abort_async_command)(self.raw(), id.0) }; + Ok(()) + } + + /// Set a string, flag, integer, double, or arbitrary node property. + pub fn set_property_async( + &mut self, + name: &str, + value: &MpvNode, + ) -> Result { + self.ensure_running()?; + let name = c_string(name, "mpv property name")?; + let id = self.allocate_userdata()?; + + let code = match value { + MpvNode::String(value) => { + let value = c_string(value, "mpv property string")?; + let mut pointer = value.as_ptr(); + // SAFETY: libmpv copies data before this function returns. + unsafe { + (self.control.set_property_async)( + self.raw(), + id, + name.as_ptr(), + FORMAT_STRING, + (&mut pointer as *mut *const c_char).cast(), + ) + } + } + MpvNode::Bool(value) => { + let mut value = c_int::from(*value); + // SAFETY: data points to a live native `int` value. + unsafe { + (self.control.set_property_async)( + self.raw(), + id, + name.as_ptr(), + FORMAT_FLAG, + (&mut value as *mut c_int).cast(), + ) + } + } + MpvNode::Int(value) => { + let mut value = *value; + // SAFETY: data points to a live native `int64_t` value. + unsafe { + (self.control.set_property_async)( + self.raw(), + id, + name.as_ptr(), + FORMAT_INT64, + (&mut value as *mut i64).cast(), + ) + } + } + MpvNode::Double(value) => { + let mut value = *value; + // SAFETY: data points to a live native `double` value. + unsafe { + (self.control.set_property_async)( + self.raw(), + id, + name.as_ptr(), + FORMAT_DOUBLE, + (&mut value as *mut f64).cast(), + ) + } + } + MpvNode::Null + | MpvNode::Array(_) + | MpvNode::Map(_) + | MpvNode::Bytes(_) => { + let mut arena = RawNodeArena::new(value)?; + // SAFETY: arena remains live until libmpv copies the node. + unsafe { + (self.control.set_property_async)( + self.raw(), + id, + name.as_ptr(), + FORMAT_NODE, + arena.root_mut().cast(), + ) + } + } + }; + check_native("mpv_set_property_async", code)?; + self.pending.insert( + id, + PendingRequest { + kind: MpvRequestKind::SetProperty, + cancelled: false, + }, + ); + Ok(MpvRequestId(id)) + } + + /// Read a property in any supported typed or node format. + pub fn get_property_async( + &mut self, + name: &str, + format: MpvFormat, + ) -> Result { + self.ensure_running()?; + let name = c_string(name, "mpv property name")?; + let id = self.allocate_userdata()?; + // SAFETY: name is live for the call; no output pointer is retained. + let code = unsafe { + (self.control.get_property_async)( + self.raw(), + id, + name.as_ptr(), + format.raw(), + ) + }; + check_native("mpv_get_property_async", code)?; + self.pending.insert( + id, + PendingRequest { + kind: MpvRequestKind::GetProperty, + cancelled: false, + }, + ); + Ok(MpvRequestId(id)) + } + + /// Observe an arbitrary property with a stable identity. + pub fn observe_property( + &mut self, + name: &str, + format: MpvFormat, + ) -> Result { + self.ensure_running()?; + let native_name = c_string(name, "mpv property name")?; + let id = self.allocate_userdata()?; + // SAFETY: libmpv copies the property name during registration. + let code = unsafe { + (self.control.observe_property)( + self.raw(), + id, + native_name.as_ptr(), + format.raw(), + ) + }; + check_native("mpv_observe_property", code)?; + self.observations.insert( + id, + Observation { + name: name.to_owned(), + format, + }, + ); + Ok(MpvObservationId(id)) + } + + /// Remove one property observation. + pub fn unobserve_property( + &mut self, + id: MpvObservationId, + ) -> Result<(), MpvSessionError> { + if !self.observations.contains_key(&id.0) { + return Err(MpvSessionError::UnknownObservation(id.0)); + } + // SAFETY: observation identity belongs to this handle. + let code = + unsafe { (self.control.unobserve_property)(self.raw(), id.0) }; + check_native("mpv_unobserve_property", code)?; + self.observations.remove(&id.0); + Ok(()) + } + + /// Register a documented or future mpv hook. + pub fn add_hook( + &mut self, + name: &str, + priority: i32, + ) -> Result { + self.ensure_running()?; + let native_name = c_string(name, "mpv hook name")?; + let id = self.allocate_userdata()?; + // SAFETY: libmpv copies the hook name during registration. + let code = unsafe { + (self.control.hook_add)( + self.raw(), + id, + native_name.as_ptr(), + priority, + ) + }; + check_native("mpv_hook_add", code)?; + self.hook_registrations.insert(id, name.to_owned()); + Ok(MpvHookRegistrationId(id)) + } + + /// Continue one received hook exactly once. + pub fn continue_hook( + &mut self, + id: MpvHookId, + ) -> Result<(), MpvSessionError> { + if !self.outstanding_hooks.contains(&id.0) { + return Err(MpvSessionError::UnknownHook(id.0)); + } + // SAFETY: ID was copied from one outstanding hook on this handle. + let code = unsafe { (self.control.hook_continue)(self.raw(), id.0) }; + check_native("mpv_hook_continue", code)?; + self.outstanding_hooks.remove(&id.0); + Ok(()) + } + + /// Enable/disable an arbitrary native event ID. + pub fn set_event_enabled( + &mut self, + event_id: u32, + enabled: bool, + ) -> Result<(), MpvSessionError> { + self.ensure_running()?; + // SAFETY: libmpv validates unknown event IDs and enable is 0/1. + let code = unsafe { + (self.control.request_event)( + self.raw(), + event_id, + c_int::from(enabled), + ) + }; + check_native("mpv_request_event", code) + } + + /// Change native log filtering at runtime. + pub fn set_log_level( + &mut self, + level: MpvLogLevel, + ) -> Result<(), MpvSessionError> { + self.ensure_running()?; + let level = CString::new(level.as_str()) + .expect("built-in mpv log level has no NUL"); + // SAFETY: level is live for the call and handle is initialized. + let code = unsafe { + (self.control.request_log_messages)(self.raw(), level.as_ptr()) + }; + check_native("mpv_request_log_messages", code) + } + + /// Drain all currently queued events and copy every payload before asking + /// libmpv for the next event. + pub fn drain_events(&mut self) -> Result, MpvSessionError> { + let mut events = Vec::new(); + loop { + // SAFETY: this serialized owner is the only event-queue consumer. + let raw = unsafe { (self.control.wait_event)(self.raw(), 0.0) }; + let raw = NonNull::new(raw).ok_or(MpvSessionError::NullEvent)?; + // SAFETY: pointer remains valid until the next `wait_event`; this + // method completes the owned copy before continuing the loop. + let raw = unsafe { raw.as_ref() }; + if raw.event_id == EVENT_NONE { + self.wakeup.take_pending(); + break; + } + events.push(self.copy_event(raw)); + } + Ok(events) + } + + /// Number of requests still awaiting native replies. + pub fn pending_request_count(&self) -> usize { + self.pending.len() + } + + /// Number of active property observations. + pub fn observation_count(&self) -> usize { + self.observations.len() + } + + /// Whether a native shutdown event has been observed. + pub const fn is_shutting_down(&self) -> bool { + self.shutting_down + } + + /// Explicit raw/unsafe extension boundary for APIs not yet represented. + /// + /// # Safety + /// + /// The callback must not destroy or retain the handle, call + /// `mpv_wait_event`, race this serialized owner, alter the wakeup callback, + /// or violate any libmpv API contract. + pub unsafe fn with_raw_handle( + &mut self, + callback: impl FnOnce(NonNull) -> R, + ) -> R { + // SAFETY: caller accepts the extension-boundary obligations above. + let raw = unsafe { + self.handle + .as_ref() + .and_then(|handle| handle.as_raw()) + .expect("live mpv session must own a handle") + }; + callback(raw) + } + + fn copy_event(&mut self, event: &RawMpvEvent) -> MpvEvent { + match self.try_copy_event(event) { + Ok(event) => event, + Err(error) => MpvEvent::ProtocolError { + event_id: event.event_id, + message: error.to_string(), + }, + } + } + + fn try_copy_event( + &mut self, + event: &RawMpvEvent, + ) -> Result { + match event.event_id { + EVENT_SHUTDOWN => { + self.shutting_down = true; + Ok(MpvEvent::Shutdown) + } + EVENT_LOG_MESSAGE => self.copy_log_event(event), + EVENT_GET_PROPERTY_REPLY + | EVENT_SET_PROPERTY_REPLY + | EVENT_COMMAND_REPLY => self.copy_async_reply(event), + EVENT_PROPERTY_CHANGE => self.copy_property_change(event), + EVENT_START_FILE => { + let data = event_data::(event)?; + Ok(MpvEvent::StartFile { + playlist_entry_id: data.playlist_entry_id, + }) + } + EVENT_FILE_LOADED => Ok(MpvEvent::FileLoaded), + EVENT_END_FILE => { + let data = event_data::(event)?; + let reason = match data.reason { + END_FILE_REASON_EOF => MpvEndFileReason::Eof, + END_FILE_REASON_STOP => MpvEndFileReason::Stop, + END_FILE_REASON_QUIT => MpvEndFileReason::Quit, + END_FILE_REASON_ERROR => MpvEndFileReason::Error, + END_FILE_REASON_REDIRECT => MpvEndFileReason::Redirect, + unknown => MpvEndFileReason::Unknown(unknown), + }; + Ok(MpvEvent::EndFile(MpvEndFile { + reason, + error: (data.error < 0) + .then(|| MpvError::from_code(data.error)), + playlist_entry_id: data.playlist_entry_id, + playlist_insert_id: data.playlist_insert_id, + playlist_insert_count: u32::try_from( + data.playlist_insert_num_entries, + ) + .unwrap_or(0), + })) + } + EVENT_IDLE => Ok(MpvEvent::Idle), + EVENT_CLIENT_MESSAGE => self.copy_client_message(event), + EVENT_VIDEO_RECONFIG => Ok(MpvEvent::VideoReconfigured), + EVENT_AUDIO_RECONFIG => Ok(MpvEvent::AudioReconfigured), + EVENT_SEEK => Ok(MpvEvent::Seek), + EVENT_PLAYBACK_RESTART => Ok(MpvEvent::PlaybackRestart), + EVENT_TICK => Ok(MpvEvent::Tick), + EVENT_QUEUE_OVERFLOW => Ok(MpvEvent::QueueOverflow), + EVENT_HOOK => self.copy_hook(event), + event_id => Ok(MpvEvent::Unknown { event_id }), + } + } + + fn copy_log_event( + &self, + event: &RawMpvEvent, + ) -> Result { + let data = event_data::(event)?; + let prefix = copy_event_c_string(data.prefix, "log prefix")?; + let text = copy_event_c_string(data.text, "log text")?; + let level = match data.log_level { + 10 => MpvMessageLevel::Fatal, + 20 => MpvMessageLevel::Error, + 30 => MpvMessageLevel::Warn, + 40 => MpvMessageLevel::Info, + 50 => MpvMessageLevel::Verbose, + 60 => MpvMessageLevel::Debug, + 70 => MpvMessageLevel::Trace, + unknown => MpvMessageLevel::Unknown(unknown), + }; + Ok(MpvEvent::Log(MpvLogMessage { + prefix, + level, + text: redact_log_text(&text), + })) + } + + fn copy_async_reply( + &mut self, + event: &RawMpvEvent, + ) -> Result { + let inferred_kind = match event.event_id { + EVENT_GET_PROPERTY_REPLY => MpvRequestKind::GetProperty, + EVENT_SET_PROPERTY_REPLY => MpvRequestKind::SetProperty, + EVENT_COMMAND_REPLY => MpvRequestKind::Command, + _ => unreachable!(), + }; + let id = MpvRequestId(event.reply_userdata); + let Some(pending) = self.pending.remove(&event.reply_userdata) else { + return Ok(MpvEvent::UnmatchedAsyncReply { + id, + kind: inferred_kind, + error: (event.error < 0) + .then(|| MpvError::from_code(event.error)), + }); + }; + + let kind_matches = match event.event_id { + EVENT_GET_PROPERTY_REPLY => { + pending.kind == MpvRequestKind::GetProperty + } + EVENT_SET_PROPERTY_REPLY => { + pending.kind == MpvRequestKind::SetProperty + } + EVENT_COMMAND_REPLY => matches!( + pending.kind, + MpvRequestKind::Command | MpvRequestKind::NodeCommand + ), + _ => false, + }; + if !kind_matches { + return Ok(MpvEvent::ProtocolError { + event_id: event.event_id, + message: format!( + "reply kind {inferred_kind:?} did not match pending {:?}", + pending.kind + ), + }); + } + + let result = if event.error < 0 { + Err(MpvError::from_code(event.error)) + } else { + let value = match event.event_id { + EVENT_GET_PROPERTY_REPLY => { + let property = event_data::(event)?; + // SAFETY: property payload remains valid for this event. + unsafe { + copy_raw_event_value( + property.format, + property.data, + self.node_limits, + )? + } + } + EVENT_SET_PROPERTY_REPLY => None, + EVENT_COMMAND_REPLY => { + if event.data.is_null() { + None + } else { + let command = event_data::(event)?; + // SAFETY: command node remains valid for this event. + let value = unsafe { + copy_raw_node(&command.result, self.node_limits)? + }; + (value != MpvNode::Null).then_some(value) + } + } + _ => unreachable!(), + }; + Ok(value) + }; + + Ok(MpvEvent::AsyncReply(MpvAsyncReply { + id, + kind: pending.kind, + cancellation_requested: pending.cancelled, + result, + })) + } + + fn copy_property_change( + &self, + event: &RawMpvEvent, + ) -> Result { + let property = event_data::(event)?; + let observation = self.observations.get(&event.reply_userdata); + let native_name = if property.name.is_null() { + None + } else { + Some(copy_event_c_string(property.name, "property name")?) + }; + let name = native_name + .or_else(|| observation.map(|observation| observation.name.clone())) + .unwrap_or_else(|| "".to_owned()); + + // SAFETY: property data remains valid until the next wait call. + let value = unsafe { + copy_raw_event_value( + property.format, + property.data, + self.node_limits, + )? + }; + if let Some(observation) = observation { + // Keep the requested format read so diagnostics and future strict + // format checks cannot silently lose registration metadata. + let _requested_format = observation.format; + } + Ok(MpvEvent::PropertyChanged(MpvPropertyChange { + id: MpvObservationId(event.reply_userdata), + name, + value, + registered: observation.is_some(), + })) + } + + fn copy_client_message( + &self, + event: &RawMpvEvent, + ) -> Result { + let data = event_data::(event)?; + let count = usize::try_from(data.count) + .map_err(|_| MpvNodeError::InvalidListCount(data.count.into()))?; + if count > self.node_limits.max_items { + return Err(MpvNodeError::LimitExceeded { + kind: "item-count", + limit: self.node_limits.max_items, + }); + } + if count > 0 && data.args.is_null() { + return Err(MpvNodeError::NullPointer("client-message arguments")); + } + let mut arguments = Vec::with_capacity(count); + let mut bytes = 0usize; + for index in 0..count { + // SAFETY: native argument array contains `count` pointers. + let argument = unsafe { *data.args.add(index) }; + let argument = + copy_event_c_string(argument, "client-message argument")?; + bytes = bytes.saturating_add(argument.len()); + if bytes > self.node_limits.max_bytes { + return Err(MpvNodeError::LimitExceeded { + kind: "byte-count", + limit: self.node_limits.max_bytes, + }); + } + arguments.push(argument); + } + Ok(MpvEvent::ClientMessage(arguments)) + } + + fn copy_hook( + &mut self, + event: &RawMpvEvent, + ) -> Result { + let data = event_data::(event)?; + let name = if data.name.is_null() { + self.hook_registrations + .get(&event.reply_userdata) + .cloned() + .unwrap_or_else(|| "".to_owned()) + } else { + copy_event_c_string(data.name, "hook name")? + }; + self.outstanding_hooks.insert(data.id); + Ok(MpvEvent::Hook(MpvHook { + registration: MpvHookRegistrationId(event.reply_userdata), + id: MpvHookId(data.id), + name, + })) + } + + fn raw(&self) -> *mut RawMpvHandle { + self.handle + .as_ref() + .and_then(MpvHandle::raw_ptr) + .expect("live mpv session must own a handle") + .as_ptr() + } + + fn ensure_running(&self) -> Result<(), MpvSessionError> { + if self.shutting_down { + Err(MpvSessionError::ShuttingDown) + } else { + Ok(()) + } + } + + fn allocate_userdata(&mut self) -> Result { + let current = self.next_userdata; + if current == 0 { + return Err(MpvSessionError::UserdataExhausted); + } + self.next_userdata = current + .checked_add(1) + .ok_or(MpvSessionError::UserdataExhausted)?; + Ok(current) + } +} + +impl Drop for MpvSession { + fn drop(&mut self) { + if let Some(handle) = self.handle.as_ref() + && let Some(raw) = handle.raw_ptr() + { + // SAFETY: clearing the callback prevents future invocations; + // userdata Arc remains alive through native destruction below. + unsafe { + (self.control.set_wakeup_callback)( + raw.as_ptr(), + None, + ptr::null_mut(), + ) + }; + } + + // Destroy/terminate while callback userdata still owns a strong Arc. + drop(self.handle.take()); + // SAFETY: this exactly reclaims the strong reference created by + // `Arc::into_raw` after native code can no longer invoke the callback. + unsafe { drop(Arc::from_raw(self.wakeup_userdata)) }; + } +} + +fn check_native( + operation: &'static str, + code: i32, +) -> Result<(), MpvSessionError> { + if code < 0 { + Err(MpvSessionError::NativeCall { + operation, + error: MpvError::from_code(code), + }) + } else { + Ok(()) + } +} + +fn c_string( + value: &str, + category: &'static str, +) -> Result { + CString::new(value.as_bytes()) + .map_err(|_| MpvSessionError::InteriorNul { category }) +} + +fn event_data(event: &RawMpvEvent) -> Result<&T, MpvNodeError> { + // SAFETY: each caller chooses `T` from the native event ID. Null is checked. + unsafe { event.data.cast::().as_ref() } + .ok_or(MpvNodeError::NullPointer("event data")) +} + +fn copy_event_c_string( + pointer: *const c_char, + category: &'static str, +) -> Result { + if pointer.is_null() { + return Err(MpvNodeError::NullPointer(category)); + } + // SAFETY: libmpv event strings are NUL-terminated for the event lifetime. + let bytes = unsafe { CStr::from_ptr(pointer) }.to_bytes(); + Ok(String::from_utf8_lossy(bytes).into_owned()) +} + +fn redact_log_text(input: &str) -> String { + let mut output = input.to_owned(); + for marker in [ + "access_token=", + "api_key=", + "apikey=", + "token=", + "ticket=", + "signature=", + "sig=", + "auth=", + "session=", + ] { + output = redact_after_marker(&output, marker); + } + for marker in ["authorization:", "cookie:", "http-header-fields="] { + output = redact_line_after_marker(&output, marker); + } + redact_url_userinfo(&output) +} + +fn redact_after_marker(input: &str, marker: &str) -> String { + let lowercase = input.to_ascii_lowercase(); + let mut output = String::with_capacity(input.len()); + let mut source_offset = 0usize; + let mut search_offset = 0usize; + + while let Some(relative) = lowercase[search_offset..].find(marker) { + let marker_start = search_offset + relative; + let value_start = marker_start + marker.len(); + output.push_str(&input[source_offset..value_start]); + output.push_str(""); + let remainder = &input[value_start..]; + let value_len = remainder + .find(|character: char| { + matches!( + character, + '&' | '#' + | ' ' + | '\t' + | '\r' + | '\n' + | '"' + | '\'' + | ')' + | ']' + | '}' + | ',' + ) + }) + .unwrap_or(remainder.len()); + source_offset = value_start + value_len; + search_offset = source_offset; + } + output.push_str(&input[source_offset..]); + output +} + +fn redact_line_after_marker(input: &str, marker: &str) -> String { + let lowercase = input.to_ascii_lowercase(); + let mut output = String::with_capacity(input.len()); + let mut source_offset = 0usize; + let mut search_offset = 0usize; + + while let Some(relative) = lowercase[search_offset..].find(marker) { + let marker_start = search_offset + relative; + let value_start = marker_start + marker.len(); + output.push_str(&input[source_offset..value_start]); + output.push_str(""); + let remainder = &input[value_start..]; + let value_len = remainder.find(['\r', '\n']).unwrap_or(remainder.len()); + source_offset = value_start + value_len; + search_offset = source_offset; + } + output.push_str(&input[source_offset..]); + output +} + +fn redact_url_userinfo(input: &str) -> String { + let mut output = input.to_owned(); + let mut offset = 0usize; + while let Some(relative_scheme) = output[offset..].find("://") { + let authority_start = offset + relative_scheme + 3; + let authority_end = output[authority_start..] + .find(['/', '?', '#', ' ', '\t', '\r', '\n']) + .map_or(output.len(), |relative| authority_start + relative); + let Some(relative_at) = + output[authority_start..authority_end].rfind('@') + else { + offset = authority_end; + continue; + }; + let at = authority_start + relative_at; + output.replace_range(authority_start..at, ""); + offset = authority_start + "@".len(); + } + output +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, collections::VecDeque}; + + use super::*; + use crate::raw::{FORMAT_NONE, MpvControlApi, RawMpvNode, WakeupCallback}; + + enum FakeQueuedEvent { + CommandReply { + id: u64, + result: Option, + }, + GetReply { + id: u64, + name: String, + value: MpvNode, + }, + SetReply { + id: u64, + }, + PropertyChange { + id: u64, + name: String, + value: MpvNode, + }, + Log { + prefix: String, + text: String, + level: u32, + }, + ClientMessage(Vec), + Hook { + registration: u64, + name: String, + hook_id: u64, + }, + Shutdown, + } + + struct FakeEventStorage { + event: Box, + _property: Option>, + _command: Option>, + _log: Option>, + _client: Option>, + _hook: Option>, + _strings: Vec, + _string_pointer: Option>, + _argument_pointers: Option>, + _flag: Option>, + _integer: Option>, + _double: Option>, + _arena: Option>, + } + + impl FakeEventStorage { + fn none() -> Self { + Self::empty(EVENT_NONE, 0, 0) + } + + fn empty(event_id: u32, error: i32, userdata: u64) -> Self { + Self { + event: Box::new(RawMpvEvent { + event_id, + error, + reply_userdata: userdata, + data: ptr::null_mut(), + }), + _property: None, + _command: None, + _log: None, + _client: None, + _hook: None, + _strings: Vec::new(), + _string_pointer: None, + _argument_pointers: None, + _flag: None, + _integer: None, + _double: None, + _arena: None, + } + } + + fn from_queued(queued: FakeQueuedEvent) -> Self { + match queued { + FakeQueuedEvent::CommandReply { id, result } => { + let mut storage = Self::empty(EVENT_COMMAND_REPLY, 0, id); + if let Some(result) = result { + let mut arena = + Box::new(RawNodeArena::new(&result).unwrap()); + // SAFETY: root is initialized and copied by value while + // arena retains all pointer-backed children. + let result = unsafe { *arena.root_mut() }; + let mut command = + Box::new(RawMpvEventCommand { result }); + storage.event.data = command.as_mut() + as *mut RawMpvEventCommand + as *mut c_void; + storage._command = Some(command); + storage._arena = Some(arena); + } + storage + } + FakeQueuedEvent::GetReply { id, name, value } => { + Self::property_event( + EVENT_GET_PROPERTY_REPLY, + id, + name, + value, + ) + } + FakeQueuedEvent::SetReply { id } => { + Self::empty(EVENT_SET_PROPERTY_REPLY, 0, id) + } + FakeQueuedEvent::PropertyChange { id, name, value } => { + Self::property_event(EVENT_PROPERTY_CHANGE, id, name, value) + } + FakeQueuedEvent::Log { + prefix, + text, + level, + } => { + let mut storage = Self::empty(EVENT_LOG_MESSAGE, 0, 0); + let prefix = CString::new(prefix).unwrap(); + let text = CString::new(text).unwrap(); + let level_name = CString::new("info").unwrap(); + let mut log = Box::new(RawMpvEventLogMessage { + prefix: prefix.as_ptr(), + level: level_name.as_ptr(), + text: text.as_ptr(), + log_level: level, + }); + storage.event.data = log.as_mut() + as *mut RawMpvEventLogMessage + as *mut c_void; + storage._strings = vec![prefix, text, level_name]; + storage._log = Some(log); + storage + } + FakeQueuedEvent::ClientMessage(arguments) => { + let mut storage = Self::empty(EVENT_CLIENT_MESSAGE, 0, 0); + let strings = arguments + .into_iter() + .map(|argument| CString::new(argument).unwrap()) + .collect::>(); + let mut pointers = strings + .iter() + .map(|argument| argument.as_ptr()) + .collect::>() + .into_boxed_slice(); + let mut client = Box::new(RawMpvEventClientMessage { + count: c_int::try_from(pointers.len()).unwrap(), + args: pointers.as_mut_ptr(), + }); + storage.event.data = client.as_mut() + as *mut RawMpvEventClientMessage + as *mut c_void; + storage._strings = strings; + storage._argument_pointers = Some(pointers); + storage._client = Some(client); + storage + } + FakeQueuedEvent::Hook { + registration, + name, + hook_id, + } => { + let mut storage = Self::empty(EVENT_HOOK, 0, registration); + let name = CString::new(name).unwrap(); + let mut hook = Box::new(RawMpvEventHook { + name: name.as_ptr(), + id: hook_id, + }); + storage.event.data = + hook.as_mut() as *mut RawMpvEventHook as *mut c_void; + storage._strings.push(name); + storage._hook = Some(hook); + storage + } + FakeQueuedEvent::Shutdown => Self::empty(EVENT_SHUTDOWN, 0, 0), + } + } + + fn property_event( + event_id: u32, + id: u64, + name: String, + value: MpvNode, + ) -> Self { + let mut storage = Self::empty(event_id, 0, id); + let name = CString::new(name).unwrap(); + let (format, data) = match value { + MpvNode::Null => (FORMAT_NONE, ptr::null_mut()), + MpvNode::String(value) => { + let value = CString::new(value).unwrap(); + let mut pointer = Box::new(value.as_ptr()); + let data = + pointer.as_mut() as *mut *const c_char as *mut c_void; + storage._strings.push(value); + storage._string_pointer = Some(pointer); + (FORMAT_STRING, data) + } + MpvNode::Bool(value) => { + let mut value = Box::new(c_int::from(value)); + let data = value.as_mut() as *mut c_int as *mut c_void; + storage._flag = Some(value); + (FORMAT_FLAG, data) + } + MpvNode::Int(value) => { + let mut value = Box::new(value); + let data = value.as_mut() as *mut i64 as *mut c_void; + storage._integer = Some(value); + (FORMAT_INT64, data) + } + MpvNode::Double(value) => { + let mut value = Box::new(value); + let data = value.as_mut() as *mut f64 as *mut c_void; + storage._double = Some(value); + (FORMAT_DOUBLE, data) + } + node @ (MpvNode::Array(_) + | MpvNode::Map(_) + | MpvNode::Bytes(_)) => { + let mut arena = Box::new(RawNodeArena::new(&node).unwrap()); + let data = arena.root_mut().cast(); + storage._arena = Some(arena); + (FORMAT_NODE, data) + } + }; + let mut property = Box::new(RawMpvEventProperty { + name: name.as_ptr(), + format, + data, + }); + storage.event.data = + property.as_mut() as *mut RawMpvEventProperty as *mut c_void; + storage._strings.push(name); + storage._property = Some(property); + storage + } + } + + struct FakeState { + options: Vec<(String, String)>, + initialized_after_option_count: Option, + logs_requested_before_initialize: Option, + commands: Vec>, + node_commands: Vec, + property_sets: Vec<(String, MpvNode)>, + aborted: Vec, + observations: HashMap, + unobserved: Vec, + hooks: HashMap, + continued_hooks: Vec, + requested_events: Vec<(u32, bool)>, + log_levels: Vec, + events: VecDeque, + current: Option, + callback: WakeupCallback, + callback_userdata: *mut c_void, + destroy_count: usize, + terminate_count: usize, + } + + impl Default for FakeState { + fn default() -> Self { + Self { + options: Vec::new(), + initialized_after_option_count: None, + logs_requested_before_initialize: None, + commands: Vec::new(), + node_commands: Vec::new(), + property_sets: Vec::new(), + aborted: Vec::new(), + observations: HashMap::new(), + unobserved: Vec::new(), + hooks: HashMap::new(), + continued_hooks: Vec::new(), + requested_events: Vec::new(), + log_levels: Vec::new(), + events: VecDeque::new(), + current: None, + callback: None, + callback_userdata: ptr::null_mut(), + destroy_count: 0, + terminate_count: 0, + } + } + } + + thread_local! { + static FAKE: RefCell = RefCell::new(FakeState::default()); + } + + fn reset_fake() { + FAKE.with(|state| *state.borrow_mut() = FakeState::default()); + } + + fn enqueue(event: FakeQueuedEvent) { + let (callback, userdata) = FAKE.with(|state| { + let mut state = state.borrow_mut(); + state.events.push_back(event); + (state.callback, state.callback_userdata) + }); + if let Some(callback) = callback { + // SAFETY: fake registration preserves the callback contract. + unsafe { callback(userdata) }; + } + } + + unsafe extern "C" fn fake_version() -> std::ffi::c_ulong { + crate::MpvClientApiVersion::new(2, 5).packed() as std::ffi::c_ulong + } + + unsafe extern "C" fn fake_create() -> *mut RawMpvHandle { + NonNull::::dangling().as_ptr().cast() + } + + unsafe extern "C" fn fake_initialize(_handle: *mut RawMpvHandle) -> c_int { + FAKE.with(|state| { + let mut state = state.borrow_mut(); + state.initialized_after_option_count = Some(state.options.len()); + state.logs_requested_before_initialize = + Some(!state.log_levels.is_empty()); + }); + 0 + } + + unsafe extern "C" fn fake_destroy(_handle: *mut RawMpvHandle) { + FAKE.with(|state| state.borrow_mut().destroy_count += 1); + } + + unsafe extern "C" fn fake_terminate(_handle: *mut RawMpvHandle) { + FAKE.with(|state| state.borrow_mut().terminate_count += 1); + } + + unsafe extern "C" fn fake_set_option_string( + _handle: *mut RawMpvHandle, + name: *const c_char, + value: *const c_char, + ) -> c_int { + // SAFETY: session passes live C strings for the duration of this call. + let name = unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned(); + // SAFETY: same as `name`. + let value = unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned(); + FAKE.with(|state| state.borrow_mut().options.push((name, value))); + 0 + } + + unsafe extern "C" fn fake_command_async( + _handle: *mut RawMpvHandle, + id: u64, + arguments: *mut *const c_char, + ) -> c_int { + let mut copied = Vec::new(); + for index in 0..128 { + // SAFETY: session passes a NUL-terminated pointer vector. + let argument = unsafe { *arguments.add(index) }; + if argument.is_null() { + break; + } + // SAFETY: each non-null entry is a live C string. + copied.push( + unsafe { CStr::from_ptr(argument) } + .to_string_lossy() + .into_owned(), + ); + } + FAKE.with(|state| state.borrow_mut().commands.push(copied)); + enqueue(FakeQueuedEvent::CommandReply { id, result: None }); + 0 + } + + unsafe extern "C" fn fake_command_node_async( + _handle: *mut RawMpvHandle, + id: u64, + command: *mut RawMpvNode, + ) -> c_int { + // SAFETY: session's arena keeps the full node live for this call. + let command = unsafe { + copy_raw_node(&*command, MpvNodeLimits::default()).unwrap() + }; + FAKE.with(|state| { + state.borrow_mut().node_commands.push(command.clone()) + }); + enqueue(FakeQueuedEvent::CommandReply { + id, + result: Some(command), + }); + 0 + } + + unsafe extern "C" fn fake_abort(_handle: *mut RawMpvHandle, id: u64) { + FAKE.with(|state| state.borrow_mut().aborted.push(id)); + } + + unsafe extern "C" fn fake_set_property_async( + _handle: *mut RawMpvHandle, + id: u64, + name: *const c_char, + format: u32, + data: *mut c_void, + ) -> c_int { + // SAFETY: session passes a live property name. + let name = unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned(); + // SAFETY: format/data follow the native property ABI for this call. + let value = unsafe { + copy_raw_event_value(format, data, MpvNodeLimits::default()) + .unwrap() + .unwrap_or(MpvNode::Null) + }; + FAKE.with(|state| state.borrow_mut().property_sets.push((name, value))); + enqueue(FakeQueuedEvent::SetReply { id }); + 0 + } + + unsafe extern "C" fn fake_get_property_async( + _handle: *mut RawMpvHandle, + id: u64, + name: *const c_char, + format: u32, + ) -> c_int { + // SAFETY: session passes a live property name. + let name = unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned(); + let value = match format { + FORMAT_STRING => MpvNode::String("value".into()), + FORMAT_FLAG => MpvNode::Bool(true), + FORMAT_INT64 => MpvNode::Int(42), + FORMAT_DOUBLE => MpvNode::Double(12.5), + FORMAT_NODE => MpvNode::Map(vec![( + "array".into(), + MpvNode::Array(vec![MpvNode::Null, MpvNode::Int(7)]), + )]), + _ => MpvNode::Null, + }; + enqueue(FakeQueuedEvent::GetReply { id, name, value }); + 0 + } + + unsafe extern "C" fn fake_observe_property( + _handle: *mut RawMpvHandle, + id: u64, + name: *const c_char, + format: u32, + ) -> c_int { + // SAFETY: session passes a live property name. + let name = unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned(); + FAKE.with(|state| { + state + .borrow_mut() + .observations + .insert(id, (name.clone(), format)); + }); + enqueue(FakeQueuedEvent::PropertyChange { + id, + name, + value: MpvNode::Double(1.25), + }); + 0 + } + + unsafe extern "C" fn fake_unobserve_property( + _handle: *mut RawMpvHandle, + id: u64, + ) -> c_int { + FAKE.with(|state| { + let mut state = state.borrow_mut(); + state.observations.remove(&id); + state.unobserved.push(id); + }); + 1 + } + + unsafe extern "C" fn fake_request_logs( + _handle: *mut RawMpvHandle, + level: *const c_char, + ) -> c_int { + // SAFETY: session passes a live level C string. + let level = unsafe { CStr::from_ptr(level) } + .to_string_lossy() + .into_owned(); + FAKE.with(|state| state.borrow_mut().log_levels.push(level)); + 0 + } + + unsafe extern "C" fn fake_wait_event( + _handle: *mut RawMpvHandle, + _timeout: f64, + ) -> *mut RawMpvEvent { + FAKE.with(|state| { + let mut state = state.borrow_mut(); + let storage = state + .events + .pop_front() + .map(FakeEventStorage::from_queued) + .unwrap_or_else(FakeEventStorage::none); + state.current = Some(storage); + state.current.as_mut().unwrap().event.as_mut() as *mut RawMpvEvent + }) + } + + unsafe extern "C" fn fake_set_wakeup_callback( + _handle: *mut RawMpvHandle, + callback: WakeupCallback, + userdata: *mut c_void, + ) { + FAKE.with(|state| { + let mut state = state.borrow_mut(); + state.callback = callback; + state.callback_userdata = userdata; + }); + } + + unsafe extern "C" fn fake_request_event( + _handle: *mut RawMpvHandle, + event: u32, + enabled: c_int, + ) -> c_int { + FAKE.with(|state| { + state + .borrow_mut() + .requested_events + .push((event, enabled != 0)) + }); + 0 + } + + unsafe extern "C" fn fake_hook_add( + _handle: *mut RawMpvHandle, + id: u64, + name: *const c_char, + _priority: c_int, + ) -> c_int { + // SAFETY: session passes a live hook name. + let name = unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned(); + FAKE.with(|state| { + state.borrow_mut().hooks.insert(id, name.clone()); + }); + enqueue(FakeQueuedEvent::Hook { + registration: id, + name, + hook_id: 77, + }); + 0 + } + + unsafe extern "C" fn fake_hook_continue( + _handle: *mut RawMpvHandle, + hook_id: u64, + ) -> c_int { + FAKE.with(|state| state.borrow_mut().continued_hooks.push(hook_id)); + 0 + } + + fn fake_table() -> MpvFunctionTable { + // SAFETY: every fake uses the matching ABI and one thread-local state. + unsafe { + let control = MpvControlApi::from_raw_parts( + fake_set_option_string, + fake_command_async, + fake_command_node_async, + fake_abort, + fake_set_property_async, + fake_get_property_async, + fake_observe_property, + fake_unobserve_property, + fake_request_logs, + fake_wait_event, + fake_set_wakeup_callback, + fake_request_event, + fake_hook_add, + fake_hook_continue, + ); + MpvFunctionTable::from_raw_parts( + fake_version, + fake_create, + fake_initialize, + fake_destroy, + fake_terminate, + ) + .with_control_api(control) + } + } + + fn fake_session() -> MpvSession { + MpvSession::create(fake_table(), MpvSessionConfig::default()).unwrap() + } + + #[test] + fn deterministic_options_precede_initialization_and_drop_terminates() { + reset_fake(); + let config = MpvSessionConfig::default(); + let expected_options = config.options().len(); + let session = MpvSession::create(fake_table(), config).unwrap(); + + FAKE.with(|state| { + let state = state.borrow(); + assert_eq!(state.options.len(), expected_options); + assert_eq!( + state.initialized_after_option_count, + Some(expected_options) + ); + assert_eq!(state.log_levels, ["info"]); + assert_eq!(state.logs_requested_before_initialize, Some(true)); + assert!(state.callback.is_some()); + }); + drop(session); + FAKE.with(|state| { + let state = state.borrow(); + assert!(state.callback.is_none()); + assert_eq!(state.destroy_count, 0); + assert_eq!(state.terminate_count, 1); + }); + } + + #[test] + fn user_config_and_scripts_require_an_explicit_trusted_policy() { + let deterministic = MpvSessionConfig::native_window(); + assert_eq!( + deterministic.config_policy(), + MpvConfigPolicy::Deterministic + ); + assert_eq!( + deterministic + .options + .iter() + .find(|option| option.name == "config") + .map(|option| option.value.as_str()), + Some("no") + ); + assert_eq!( + deterministic + .options + .iter() + .find(|option| option.name == "load-scripts") + .map(|option| option.value.as_str()), + Some("no") + ); + + let trusted = MpvSessionConfig::native_window_with_config_policy( + MpvConfigPolicy::TrustedUser, + ); + assert_eq!(trusted.config_policy(), MpvConfigPolicy::TrustedUser); + assert_eq!(trusted.config_policy().as_str(), "trusted-user"); + assert_eq!( + trusted + .options + .iter() + .find(|option| option.name == "config") + .map(|option| option.value.as_str()), + Some("yes") + ); + assert_eq!( + trusted + .options + .iter() + .find(|option| option.name == "load-scripts") + .map(|option| option.value.as_str()), + Some("yes") + ); + assert_eq!( + trusted + .options + .iter() + .find(|option| option.name == "ytdl") + .map(|option| option.value.as_str()), + Some("no"), + "trusted config does not implicitly package an external resolver" + ); + } + + #[test] + fn typed_properties_nodes_async_replies_and_cancellation_correlate() { + reset_fake(); + let mut session = fake_session(); + + let command = session.command_async(["seek", "5", "relative"]).unwrap(); + session.cancel_request(command).unwrap(); + let node_command = MpvNode::Array(vec![ + MpvNode::String("expand-text".into()), + MpvNode::String("${mpv-version}".into()), + ]); + let node_command_id = + session.command_node_async(&node_command).unwrap(); + + let get_ids = [ + ( + session + .get_property_async("string", MpvFormat::String) + .unwrap(), + MpvNode::String("value".into()), + ), + ( + session.get_property_async("flag", MpvFormat::Flag).unwrap(), + MpvNode::Bool(true), + ), + ( + session + .get_property_async("integer", MpvFormat::Int64) + .unwrap(), + MpvNode::Int(42), + ), + ( + session + .get_property_async("double", MpvFormat::Double) + .unwrap(), + MpvNode::Double(12.5), + ), + ( + session.get_property_async("node", MpvFormat::Node).unwrap(), + MpvNode::Map(vec![( + "array".into(), + MpvNode::Array(vec![MpvNode::Null, MpvNode::Int(7)]), + )]), + ), + ]; + + let property_values = [ + MpvNode::String("text".into()), + MpvNode::Bool(false), + MpvNode::Int(9), + MpvNode::Double(1.5), + MpvNode::Map(vec![("nested".into(), MpvNode::Null)]), + ]; + for (index, value) in property_values.iter().enumerate() { + session + .set_property_async(&format!("property-{index}"), value) + .unwrap(); + } + + let events = session.drain_events().unwrap(); + assert_eq!(session.pending_request_count(), 0); + + let replies = events + .into_iter() + .filter_map(|event| match event { + MpvEvent::AsyncReply(reply) => Some(reply), + _ => None, + }) + .collect::>(); + let cancelled = + replies.iter().find(|reply| reply.id == command).unwrap(); + assert!(cancelled.cancellation_requested); + assert_eq!(cancelled.result, Ok(None)); + let node_reply = replies + .iter() + .find(|reply| reply.id == node_command_id) + .unwrap(); + assert_eq!(node_reply.result, Ok(Some(node_command.clone()))); + for (id, expected) in get_ids { + let reply = replies.iter().find(|reply| reply.id == id).unwrap(); + assert_eq!(reply.result, Ok(Some(expected))); + } + + FAKE.with(|state| { + let state = state.borrow(); + assert_eq!(state.commands, [vec!["seek", "5", "relative"]]); + assert_eq!(state.node_commands, [node_command]); + assert_eq!(state.aborted, [command.get()]); + assert_eq!( + state + .property_sets + .iter() + .map(|(_, value)| value) + .collect::>(), + property_values.iter().collect::>() + ); + }); + } + + #[test] + fn event_payloads_are_copied_before_the_next_wait_invalidates_them() { + reset_fake(); + let mut session = fake_session(); + let observation = session + .observe_property("time-pos", MpvFormat::Double) + .unwrap(); + let events = session.drain_events().unwrap(); + + // `drain_events` fetched EVENT_NONE after the property event, which + // dropped the fake's pointer-backed property storage. + assert_eq!( + events, + [MpvEvent::PropertyChanged(MpvPropertyChange { + id: observation, + name: "time-pos".into(), + value: Some(MpvNode::Double(1.25)), + registered: true, + })] + ); + session.unobserve_property(observation).unwrap(); + assert_eq!(session.observation_count(), 0); + } + + #[test] + fn logs_client_messages_hooks_and_shutdown_are_owned_and_safe() { + reset_fake(); + let mut session = fake_session(); + let registration = session.add_hook("on_load", 0).unwrap(); + enqueue(FakeQueuedEvent::Log { + prefix: "ffmpeg".into(), + text: "Opening https://user:pass@example.test/v?access_token=query-secret Authorization: Bearer-secret".into(), + level: 30, + }); + enqueue(FakeQueuedEvent::ClientMessage(vec![ + "ferrex".into(), + "ready".into(), + ])); + enqueue(FakeQueuedEvent::Shutdown); + + let events = session.drain_events().unwrap(); + let hook = events + .iter() + .find_map(|event| match event { + MpvEvent::Hook(hook) => Some(hook.clone()), + _ => None, + }) + .unwrap(); + assert_eq!(hook.registration, registration); + session.continue_hook(hook.id).unwrap(); + assert!(matches!( + session.continue_hook(hook.id), + Err(MpvSessionError::UnknownHook(77)) + )); + + let log = events + .iter() + .find_map(|event| match event { + MpvEvent::Log(log) => Some(log), + _ => None, + }) + .unwrap(); + for secret in ["user:pass", "query-secret", "Bearer-secret"] { + assert!(!log.text.contains(secret)); + } + assert!(events.contains(&MpvEvent::ClientMessage(vec![ + "ferrex".into(), + "ready".into() + ]))); + assert!(events.contains(&MpvEvent::Shutdown)); + assert!(session.is_shutting_down()); + assert!(matches!( + session.command_async(["stop"]), + Err(MpvSessionError::ShuttingDown) + )); + } + + #[test] + fn wakeup_storm_coalesces_without_losing_notification_count() { + let signal = MpvWakeupSignal::for_current_thread(); + let raw = Arc::into_raw(Arc::clone(&signal)); + for _ in 0..1_000 { + // SAFETY: raw owns one strong Arc and callback accepts this type. + unsafe { wakeup_callback(raw.cast_mut().cast()) }; + } + + assert!(signal.take_pending()); + assert!(!signal.take_pending()); + assert_eq!(signal.notification_count(), 1_000); + // SAFETY: reclaim exactly the reference leaked above. + unsafe { drop(Arc::from_raw(raw)) }; + } + + #[test] + fn repeated_sessions_clear_callbacks_and_destroy_once() { + reset_fake(); + for _ in 0..50 { + drop(fake_session()); + } + FAKE.with(|state| { + let state = state.borrow(); + assert!(state.callback.is_none()); + assert_eq!(state.terminate_count, 50); + assert_eq!(state.destroy_count, 0); + }); + } + + #[test] + fn log_redaction_removes_query_headers_cookies_and_userinfo() { + let input = "https://user:pass@example.test/v?access_token=secret&x=1 Authorization: Bearer-secret Cookie: sid=cookie-secret"; + let redacted = redact_log_text(input); + + for secret in ["user:pass", "secret", "Bearer-secret", "cookie-secret"] + { + assert!(!redacted.contains(secret), "leaked {secret}: {redacted}"); + } + assert!(redacted.contains("example.test")); + assert!(redacted.contains("access_token=")); + } + + #[cfg(feature = "linked")] + #[test] + fn linked_session_initializes_and_correlates_a_real_property_reply() { + use std::time::Instant; + + let mut session = MpvSession::create( + MpvFunctionTable::linked(), + MpvSessionConfig::default(), + ) + .unwrap(); + let id = session + .get_property_async("mpv-version", MpvFormat::String) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + + loop { + if let Some(reply) = session + .drain_events() + .unwrap() + .into_iter() + .find_map(|event| match event { + MpvEvent::AsyncReply(reply) if reply.id == id => { + Some(reply) + } + _ => None, + }) + { + let Some(MpvNode::String(version)) = reply.result.unwrap() + else { + panic!("mpv-version did not return a string"); + }; + assert!( + version.to_ascii_lowercase().contains("mpv"), + "{version}" + ); + break; + } + assert!(Instant::now() < deadline, "timed out waiting for libmpv"); + session.wait_for_wakeup(Duration::from_millis(20)); + } + } +} diff --git a/deny.toml b/deny.toml index e68767d3..a02fb191 100644 --- a/deny.toml +++ b/deny.toml @@ -42,7 +42,9 @@ targets = [ # If true, metadata will be collected with `--all-features`. Note that this can't # be toggled off if true, if you want to conditionally enable `--all-features` it # is recommended to pass `--all-features` on the cmd line instead -all-features = false +# Audit optional native backends too; otherwise their license exceptions are +# reported as unused in the default backend-disabled graph. +all-features = true # If true, metadata will be collected with `--no-default-features`. The same # caveat with `all-features` applies no-default-features = false @@ -118,7 +120,8 @@ confidence-threshold = 0.8 exceptions = [ # Each entry is the crate and version constraint, and its specific allow # list - #{ allow = ["Zlib"], crate = "adler32" }, + # Raw dynamically linked bindings selected by D-004; include in release notices. + { allow = ["LGPL-2.1-only"], crate = "libmpv2-sys@4.0.1" }, ] # Some crates don't have (easily) machine readable licensing information, @@ -139,6 +142,15 @@ exceptions = [ #{ path = "LICENSE", hash = 0xbd0eed23 } #] +# libmpv2-sys uses the deprecated SPDX spelling `LGPL-2.1`; pin its verbatim +# license text and normalize the expression until upstream metadata is updated. +[[licenses.clarify]] +crate = "libmpv2-sys@4.0.1" +expression = "LGPL-2.1-only" +license-files = [ + { path = "LICENSE", hash = 0x13b834cd }, +] + [licenses.private] # If true, ignores workspace crates that aren't published, or are only # published to private registries. From 9e92cd52a129b6aea40fe2a01484d203c7cba7f7 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:18:18 -0600 Subject: [PATCH 07/56] refactor(player): migrate playback through neutral sessions --- Cargo.lock | 41 +- crates/ferrex-player-playback/Cargo.toml | 4 + .../ferrex-player-playback/src/constants.rs | 8 + crates/ferrex-player-playback/src/controls.rs | 278 +- .../ferrex-player-playback/src/diagnostics.rs | 448 ++- crates/ferrex-player-playback/src/lib.rs | 29 + .../src/messages/mod.rs | 73 +- .../src/messages/subscriptions.rs | 121 +- .../src/native_presentation.rs | 1471 +++++++++ .../src/native_video_slot.rs | 1248 ++++++++ .../ferrex-player-playback/src/presenter.rs | 1469 +++++++++ crates/ferrex-player-playback/src/state.rs | 420 ++- crates/ferrex-player-playback/src/theme.rs | 19 + .../src/track_selection.rs | 509 +-- crates/ferrex-player-playback/src/update.rs | 2754 +++++++++++++---- crates/ferrex-player-playback/src/video.rs | 475 ++- crates/ferrex-player-playback/src/view.rs | 404 ++- .../src/common/messages/cross_domain.rs | 21 + .../src/common/messages/mod.rs | 10 +- .../domains/media/messages/subscriptions.rs | 4 +- crates/ferrex-player-ui/src/domains/mod.rs | 3 - .../domains/player/messages/subscriptions.rs | 13 +- .../src/domains/player/update.rs | 57 +- .../src/domains/streaming/messages/mod.rs | 18 +- .../streaming/update_handlers/transcoding.rs | 172 +- .../src/domains/ui/playback_ui/mod.rs | 7 + .../src/domains/ui/playback_ui/update.rs | 166 +- .../src/domains/ui/shell_ui/mod.rs | 37 + .../src/domains/ui/shell_ui/update.rs | 22 + .../ferrex-player-ui/src/domains/ui/theme.rs | 63 +- .../ui/views/tenfoot/player_overlay.rs | 102 +- .../src/domains/ui/views/tv/view_tv.rs | 2 +- .../src/domains/ui/windows.rs | 222 +- .../src/domains/ui/windows/controller.rs | 287 +- .../src/domains/ui/windows/subscriptions.rs | 29 + crates/ferrex-player-ui/src/state.rs | 4 - crates/ferrex-player-ui/src/update.rs | 2 - crates/ferrex-player-ui/src/view.rs | 19 +- 38 files changed, 9858 insertions(+), 1173 deletions(-) create mode 100644 crates/ferrex-player-playback/src/native_presentation.rs create mode 100644 crates/ferrex-player-playback/src/native_video_slot.rs create mode 100644 crates/ferrex-player-playback/src/presenter.rs diff --git a/Cargo.lock b/Cargo.lock index 770b082d..2d143f84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2387,17 +2387,21 @@ dependencies = [ "ferrex-core", "ferrex-player-api", "ferrex-player-foundation", + "futures", "iced", "iced_wgpu", "log", "lucide-icons", "profiling", + "serde", "serde_json", "subwave_core", "subwave_unified", + "thiserror 2.0.18", "tokio", "url", "urlencoding", + "zeroize", ] [[package]] @@ -3106,7 +3110,7 @@ dependencies = [ [[package]] name = "gio-sys" version = "0.23.0-alpha" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#c5f1c6e5185e8f1a0dc3e7d999ea786fabdea674" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#794ef152fa18d0dfd908471bd160c438aca8041c" dependencies = [ "glib-sys", "gobject-sys", @@ -3135,7 +3139,7 @@ checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" [[package]] name = "glib" version = "0.23.0-alpha" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#c5f1c6e5185e8f1a0dc3e7d999ea786fabdea674" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#794ef152fa18d0dfd908471bd160c438aca8041c" dependencies = [ "bitflags 2.11.0", "futures-channel", @@ -3155,7 +3159,7 @@ dependencies = [ [[package]] name = "glib-macros" version = "0.23.0-alpha" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#c5f1c6e5185e8f1a0dc3e7d999ea786fabdea674" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#794ef152fa18d0dfd908471bd160c438aca8041c" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3166,7 +3170,7 @@ dependencies = [ [[package]] name = "glib-sys" version = "0.23.0-alpha" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#c5f1c6e5185e8f1a0dc3e7d999ea786fabdea674" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#794ef152fa18d0dfd908471bd160c438aca8041c" dependencies = [ "libc", "system-deps", @@ -3202,7 +3206,7 @@ dependencies = [ [[package]] name = "gobject-sys" version = "0.23.0-alpha" -source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#c5f1c6e5185e8f1a0dc3e7d999ea786fabdea674" +source = "git+https://github.com/gtk-rs/gtk-rs-core?branch=main#794ef152fa18d0dfd908471bd160c438aca8041c" dependencies = [ "glib-sys", "libc", @@ -3263,7 +3267,7 @@ dependencies = [ [[package]] name = "gstreamer" version = "0.26.0-alpha" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#7922e962b267bdb645443615a5ae84239c71f19c" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#186dbc5dff6e5cc4330ef0287a83b37065cc4567" dependencies = [ "cfg-if", "futures-channel", @@ -3271,7 +3275,7 @@ dependencies = [ "futures-util", "glib", "gstreamer-sys", - "itertools 0.14.0", + "itertools 0.15.0", "kstring", "libc", "muldiv", @@ -3287,7 +3291,7 @@ dependencies = [ [[package]] name = "gstreamer-app" version = "0.26.0-alpha" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#7922e962b267bdb645443615a5ae84239c71f19c" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#186dbc5dff6e5cc4330ef0287a83b37065cc4567" dependencies = [ "futures-core", "futures-sink", @@ -3301,7 +3305,7 @@ dependencies = [ [[package]] name = "gstreamer-app-sys" version = "0.26.0-alpha" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#7922e962b267bdb645443615a5ae84239c71f19c" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#186dbc5dff6e5cc4330ef0287a83b37065cc4567" dependencies = [ "glib-sys", "gstreamer-base-sys", @@ -3313,7 +3317,7 @@ dependencies = [ [[package]] name = "gstreamer-base" version = "0.26.0-alpha" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#7922e962b267bdb645443615a5ae84239c71f19c" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#186dbc5dff6e5cc4330ef0287a83b37065cc4567" dependencies = [ "atomic_refcell", "cfg-if", @@ -3326,7 +3330,7 @@ dependencies = [ [[package]] name = "gstreamer-base-sys" version = "0.26.0-alpha" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#7922e962b267bdb645443615a5ae84239c71f19c" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#186dbc5dff6e5cc4330ef0287a83b37065cc4567" dependencies = [ "glib-sys", "gobject-sys", @@ -3338,7 +3342,7 @@ dependencies = [ [[package]] name = "gstreamer-sys" version = "0.26.0-alpha" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#7922e962b267bdb645443615a5ae84239c71f19c" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#186dbc5dff6e5cc4330ef0287a83b37065cc4567" dependencies = [ "cfg-if", "glib-sys", @@ -3350,7 +3354,7 @@ dependencies = [ [[package]] name = "gstreamer-video" version = "0.26.0-alpha" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#7922e962b267bdb645443615a5ae84239c71f19c" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#186dbc5dff6e5cc4330ef0287a83b37065cc4567" dependencies = [ "cfg-if", "futures-channel", @@ -3365,7 +3369,7 @@ dependencies = [ [[package]] name = "gstreamer-video-sys" version = "0.26.0-alpha" -source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#7922e962b267bdb645443615a5ae84239c71f19c" +source = "git+https://gitlab.freedesktop.org/gstreamer/gstreamer-rs.git?branch=main#186dbc5dff6e5cc4330ef0287a83b37065cc4567" dependencies = [ "glib-sys", "gobject-sys", @@ -4269,6 +4273,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/crates/ferrex-player-playback/Cargo.toml b/crates/ferrex-player-playback/Cargo.toml index dcebc5d4..4200c183 100644 --- a/crates/ferrex-player-playback/Cargo.toml +++ b/crates/ferrex-player-playback/Cargo.toml @@ -43,10 +43,14 @@ lucide-icons = { git = "https://github.com/Lowband21/lucide-icons-iced-0.14.git" subwave_unified = { git = "https://github.com/Lowband21/subwave.git", branch = "main" } subwave_core = { git = "https://github.com/Lowband21/subwave.git", branch = "main" } +futures.workspace = true log.workspace = true +serde.workspace = true serde_json.workspace = true +thiserror.workspace = true tokio.workspace = true url = "2" +zeroize.workspace = true urlencoding = "2" dirs = "6" profiling = { version = "1.0", optional = true } diff --git a/crates/ferrex-player-playback/src/constants.rs b/crates/ferrex-player-playback/src/constants.rs index 7fd6cd16..0182e5c2 100644 --- a/crates/ferrex-player-playback/src/constants.rs +++ b/crates/ferrex-player-playback/src/constants.rs @@ -2,10 +2,18 @@ /// Seeking shortcut defaults. pub mod seeking { + use std::time::Duration; + pub const SEEK_FORWARD_COURSE: f64 = 30.0; pub const SEEK_BACKWARD_COURSE: f64 = -15.0; pub const SEEK_FORWARD_FINE: f64 = 15.0; pub const SEEK_BACKWARD_FINE: f64 = -10.0; + + /// Minimum interval between seek-preview commands emitted while dragging. + /// + /// The mpv adapter adds a second bound by allowing only one absolute seek + /// request in flight and retaining only the newest queued position. + pub const SEEK_DRAG_THROTTLE: Duration = Duration::from_millis(100); } /// Player controls layout constants. diff --git a/crates/ferrex-player-playback/src/controls.rs b/crates/ferrex-player-playback/src/controls.rs index dd6d07b9..6c7f1835 100644 --- a/crates/ferrex-player-playback/src/controls.rs +++ b/crates/ferrex-player-playback/src/controls.rs @@ -4,13 +4,17 @@ //! affordances using the state owned by this crate. use super::theme; -use super::track_selection::format_subtitle_track; +use super::track_selection::{ + chapter_at_position, format_chapter, format_edition, format_subtitle_track, +}; use crate::{ constants::seeking::*, + contract::{AudioTrack, ChapterId, EditionId, SubtitleTrack}, messages::PlayerMessage, state::PlayerDomainState, ui_support::{icon_text, lucide_font}, }; +use ferrex_player_api::services::streaming::TranscodeQualityProfile; use iced::ContentFit; use iced::Theme; use iced::{ @@ -21,8 +25,6 @@ use iced::{ }, }; use lucide_icons::Icon; -use subwave_core::video::types::{AudioTrack, SubtitleTrack}; -use subwave_unified::video::BackendPreference; /// Helper function to create a control button with icon fn icon_button( @@ -220,13 +222,15 @@ impl PlayerDomainState { // Build a Wayland-only backend toggle element let backend_toggle: Element = if std::env::var("WAYLAND_DISPLAY").is_ok() { - let label = - match self.video_opt.as_ref().map(|v| v.backend()) { - Some(BackendPreference::ForceAppsink) => { - "Use Wayland" - } - _ => "Use AppSink", - }; + let label = if self + .video_opt + .as_ref() + .is_some_and(|video| video.is_appsink()) + { + "Use Wayland" + } else { + "Use AppSink" + }; button(text(label).size(14)) .on_press(PlayerMessage::ToggleAppsinkBackend) .style(theme::button_transparent) @@ -423,6 +427,13 @@ impl PlayerDomainState { ] .align_y(Alignment::Center), Space::new().height(Length::Fixed(15.0)), + // Evidence-qualified active backend diagnostics + text("Playback Diagnostics") + .size(15) + .style(theme::text_muted), + Space::new().height(Length::Fixed(8.0)), + self.build_playback_diagnostic_summary(), + Space::new().height(Length::Fixed(15.0)), // Audio & Subtitles section text("Audio & Subtitles").size(15).style(theme::text_muted), Space::new().height(Length::Fixed(8.0)), @@ -432,6 +443,9 @@ impl PlayerDomainState { // Subtitle controls self.build_subtitle_controls(), Space::new().height(Length::Fixed(10.0)), + // Chapters and editions exposed by the active backend + self.build_media_structure_controls(), + Space::new().height(Length::Fixed(10.0)), // HDR information if self.is_hdr_content { container( @@ -548,6 +562,76 @@ impl PlayerDomainState { .into() } + /// Build a concise, redacted session summary. Hardware decoding and HDR + /// are described only as configured policy or observed evidence. + fn build_playback_diagnostic_summary( + &self, + ) -> Element<'_, PlayerMessage, Theme, iced::Renderer> { + let Some(diagnostics) = self.playback_diagnostics() else { + return container( + text("No active playback session") + .size(11) + .style(theme::text_dim), + ) + .padding(6) + .style(theme::container_subtle) + .into(); + }; + let summary = diagnostics.summary(); + let mut details = column![ + text(format!("Requested: {}", summary.requested_backend)) + .size(11) + .style(theme::text_dim), + text(format!("Backend: {}", summary.selected_backend)) + .size(11) + .style(theme::text_bright), + text(format!("Presentation: {}", summary.presentation_mode)) + .size(11) + .style(theme::text_dim), + text(format!("Integrated: {}", summary.integrated_presentation)) + .size(11) + .width(Length::Fill) + .style(theme::text_dim), + text(format!("HDR content: {}", summary.hdr_content_evidence)) + .size(11) + .style(theme::text_dim), + text(format!( + "Native HDR evidence: {}", + summary.native_hdr_evidence + )) + .size(11) + .style(theme::text_dim), + text(format!( + "Hardware decode policy: {}", + summary.hardware_decode_expectation + )) + .size(11) + .style(theme::text_dim), + text(format!( + "Hardware decoder observed: {}", + summary.observed_hardware_decoder + )) + .size(11) + .style(theme::text_dim), + ] + .spacing(2); + + if let Some(reason) = summary.fallback_reason { + details = details.push( + text(format!("Fallback: {reason}")) + .size(11) + .width(Length::Fill) + .style(theme::text_bright), + ); + } + + container(details) + .width(Length::Fill) + .padding(6) + .style(theme::container_subtle) + .into() + } + /// Build audio track selector fn build_audio_track_selector( &self, @@ -563,10 +647,13 @@ impl PlayerDomainState { Space::new().width(Length::Fill), pick_list( self.available_audio_tracks.clone(), - self.available_audio_tracks - .get(self.current_audio_track as usize) - .cloned(), - |track| PlayerMessage::AudioTrackSelected(track.index) + self.current_audio_track.as_ref().and_then(|selected| { + self.available_audio_tracks + .iter() + .find(|track| &track.id == selected) + .cloned() + }), + |track| PlayerMessage::AudioTrackSelected(track.id) ) .width(Length::Fixed(200.0)) .style(theme::pick_list_dark::) @@ -597,10 +684,12 @@ impl PlayerDomainState { let current_selection = if !self.subtitles_enabled { Some(SubtitleOption::Disabled) } else { - self.current_subtitle_track.and_then(|idx| { + self.current_subtitle_track.as_ref().and_then(|selected| { self.available_subtitle_tracks - .get(idx as usize) - .map(|track| SubtitleOption::Track(track.clone())) + .iter() + .find(|track| &track.id == selected) + .cloned() + .map(SubtitleOption::Track) }) }; @@ -613,9 +702,7 @@ impl PlayerDomainState { PlayerMessage::SubtitleTrackSelected(None) } SubtitleOption::Track(track) => { - PlayerMessage::SubtitleTrackSelected(Some( - track.index, - )) + PlayerMessage::SubtitleTrackSelected(Some(track.id)) } } }) @@ -628,11 +715,103 @@ impl PlayerDomainState { } } + /// Build chapter and edition selectors when the active backend advertises + /// the corresponding capability. + fn build_media_structure_controls( + &self, + ) -> Element<'_, PlayerMessage, Theme, iced::Renderer> { + let Some(snapshot) = self.playback_snapshot() else { + return Space::new().height(Length::Fixed(0.0)).into(); + }; + let show_chapters = snapshot.capabilities.chapter_selection + && !snapshot.chapters.is_empty(); + let show_editions = snapshot.capabilities.edition_selection + && !snapshot.editions.is_empty(); + if !show_chapters && !show_editions { + return Space::new().height(Length::Fixed(0.0)).into(); + } + + let mut controls = column![ + text("Media Structure").size(15).style(theme::text_muted), + Space::new().height(Length::Fixed(8.0)), + ] + .spacing(5); + + if show_chapters { + let options = snapshot + .chapters + .iter() + .enumerate() + .map(|(index, chapter)| ChapterOption { + id: chapter.id.clone(), + label: format_chapter(chapter, index), + }) + .collect::>(); + let current_id = snapshot.current_chapter.as_ref().or_else(|| { + chapter_at_position(&snapshot.chapters, snapshot.position) + .map(|chapter| &chapter.id) + }); + let selected = current_id.and_then(|current| { + options.iter().find(|option| &option.id == current).cloned() + }); + controls = controls.push( + row![ + text("Chapter:").size(14), + Space::new().width(Length::Fill), + pick_list(options, selected, |option| { + PlayerMessage::ChapterSelected(option.id) + }) + .width(Length::Fixed(200.0)) + .style(theme::pick_list_dark::) + .text_size(14), + ] + .align_y(Alignment::Center), + ); + } + + if show_editions { + let options = snapshot + .editions + .iter() + .enumerate() + .map(|(index, edition)| EditionOption { + id: edition.id.clone(), + label: format_edition(edition, index), + }) + .collect::>(); + let current_id = snapshot.current_edition.as_ref().or_else(|| { + snapshot + .editions + .iter() + .find(|edition| edition.is_default) + .map(|edition| &edition.id) + }); + let selected = current_id.and_then(|current| { + options.iter().find(|option| &option.id == current).cloned() + }); + controls = controls.push( + row![ + text("Edition:").size(14), + Space::new().width(Length::Fill), + pick_list(options, selected, |option| { + PlayerMessage::EditionSelected(option.id) + }) + .width(Length::Fixed(200.0)) + .style(theme::pick_list_dark::) + .text_size(14), + ] + .align_y(Alignment::Center), + ); + } + + controls.into() + } + /// Build the quality/tone mapping menu popup pub fn build_quality_menu( &self, ) -> iced::Element<'_, PlayerMessage, Theme, iced::Renderer> { - let content = column![ + let mut content = column![ // Header row![ text("Video Settings").size(16).style(theme::text_bright), @@ -647,6 +826,33 @@ impl PlayerDomainState { ] .spacing(5); + for profile in TranscodeQualityProfile::ALL { + let selected = self.current_quality_profile.as_deref() + == Some(profile.as_str()); + let check: Element<'_, PlayerMessage> = if selected { + text(Icon::Check.unicode()) + .font(lucide_font()) + .size(14) + .into() + } else { + Space::new().width(Length::Fixed(14.0)).into() + }; + content = content.push( + button( + row![ + check, + Space::new().width(Length::Fixed(8.0)), + text(profile.display_name()).size(14), + ] + .align_y(Alignment::Center), + ) + .on_press(PlayerMessage::QualityProfileSelected(profile)) + .width(Length::Fill) + .style(theme::button_menu_item) + .padding([6, 10]), + ); + } + container(content.padding(20)) .style(theme::container_subtitle_menu) .width(Length::Fixed(350.0)) @@ -702,8 +908,8 @@ impl PlayerDomainState { .iter() .map(|track| { let is_selected = self.subtitles_enabled - && self.current_subtitle_track - == Some(track.index); + && self.current_subtitle_track.as_ref() + == Some(&track.id); button({ let check_icon: Element = @@ -726,7 +932,7 @@ impl PlayerDomainState { .align_y(Alignment::Center) }) .on_press(PlayerMessage::SubtitleTrackSelected( - Some(track.index), + Some(track.id.clone()), )) .width(Length::Fill) .style(theme::button_menu_item) @@ -746,6 +952,30 @@ impl PlayerDomainState { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ChapterOption { + id: ChapterId, + label: String, +} + +impl std::fmt::Display for ChapterOption { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.label) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EditionOption { + id: EditionId, + label: String, +} + +impl std::fmt::Display for EditionOption { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.label) + } +} + // SubtitleOption for pick_list in settings #[derive(Debug, Clone, PartialEq, Eq)] enum SubtitleOption { diff --git a/crates/ferrex-player-playback/src/diagnostics.rs b/crates/ferrex-player-playback/src/diagnostics.rs index 0fe8a9fb..da06b193 100644 --- a/crates/ferrex-player-playback/src/diagnostics.rs +++ b/crates/ferrex-player-playback/src/diagnostics.rs @@ -1,8 +1,334 @@ -//! Playback diagnostics helpers that avoid leaking bearer-style URL secrets. +//! Serializable playback diagnostics and helpers that avoid leaking secrets. + +use serde::Serialize; + +use crate::contract::{ + BackendRequest, FallbackReason, PlaybackCapabilities, PlaybackError, + PlaybackSnapshot, PlaybackState, PlaybackTarget, PresenterState, + SurfaceGeometry, VideoParameters, +}; const ACCESS_TOKEN_PARAM: &str = "access_token="; const REDACTED_TOKEN: &str = ""; +/// Version of the stable diagnostic JSON shape. +pub const PLAYBACK_DIAGNOSTIC_SCHEMA_VERSION: u16 = 6; + +/// Backend-owner lifecycle, separate from media playback state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PlaybackBackendLifecycle { + Initializing, + Ready, + Running, + Stopping, + Terminated, + Failed, +} + +/// libmpv client ABI compatibility details. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MpvClientApiDiagnostics { + pub bindings: String, + pub runtime: String, + pub minimum: String, + pub compatible: bool, +} + +/// mpv user-configuration trust policy selected for this session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MpvConfigurationPolicy { + /// Ferrex-owned options only; no user config or script discovery. + Deterministic, + /// Standard mpv user config and scripts are loaded as trusted code. + TrustedUser, +} + +/// Effective libmpv message verbosity for this session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MpvLogVerbosity { + None, + Fatal, + Error, + Warn, + Info, + Verbose, + Debug, + Trace, +} + +/// Effective high-level mpv configuration switches for issue reports. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MpvConfigurationDiagnostics { + pub policy: MpvConfigurationPolicy, + pub user_config_enabled: bool, + pub user_scripts_enabled: bool, + pub osc_enabled: bool, + pub input_bindings_enabled: bool, + pub external_url_resolver_enabled: bool, + /// Steady-state native message filter. This reports only the selected + /// level; log contents are never retained in the diagnostic snapshot. + pub log_verbosity: MpvLogVerbosity, + /// The default concise policy briefly raises native logging during file + /// initialization so version/VO/GPU capability lines can be observed. + pub startup_verbose_capture: bool, + /// Number of explicit native-VO shaders currently observed by mpv. Paths + /// are deliberately excluded from diagnostics. + pub active_video_shader_count: Option, +} + +/// Runtime versions and compiled mpv features observed in-process. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct PlaybackVersionDiagnostics { + pub client_api: Option, + pub mpv: Option, + pub ffmpeg: Option, + pub libplacebo: Option, + pub compiled_features: Vec, +} + +/// Native-VO frame timing counters reported by mpv. +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +pub struct PlaybackFrameDiagnostics { + pub decoder_dropped: Option, + pub output_dropped: Option, + pub mistimed: Option, + pub delayed: Option, + pub av_sync_seconds: Option, +} + +/// Video-output details. `None` means the backend did not expose an observation. +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +pub struct PlaybackOutputDiagnostics { + pub vo_configured: Option, + pub video_output: Option, + pub gpu_api: Option, + pub gpu_context: Option, + pub gpu_adapter: Option, + pub hardware_decoder: Option, + pub hardware_decoder_interop: Option, + pub input_video: Option, + pub output_video: Option, + pub frames: PlaybackFrameDiagnostics, +} + +/// Redacted, serializable snapshot used by diagnostics/settings and issue reports. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct PlaybackDiagnosticSnapshot { + pub schema_version: u16, + pub generation: u64, + pub requested_backend: BackendRequest, + pub selected_target: PlaybackTarget, + pub backend_lifecycle: PlaybackBackendLifecycle, + pub playback_state: PlaybackState, + pub presenter_state: PresenterState, + pub presenter_geometry: Option, + pub capabilities: PlaybackCapabilities, + pub position_millis: u64, + pub duration_millis: Option, + pub versions: PlaybackVersionDiagnostics, + pub mpv_configuration: Option, + pub output: PlaybackOutputDiagnostics, + pub fallback_chain: Vec, + pub last_fallback: Option, + pub last_error: Option, +} + +/// Evidence-oriented labels suitable for settings and issue-report UI. +/// +/// Content signaling, native-output evidence, configured decoder policy, and +/// the observed decoder remain separate so the UI never turns a backend choice +/// into an unsupported HDR, hardware-decoding, or zero-copy claim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlaybackDiagnosticSummary { + pub requested_backend: String, + pub selected_backend: String, + pub presentation_mode: String, + pub integrated_presentation: String, + pub hdr_content_evidence: String, + pub native_hdr_evidence: String, + pub hardware_decode_expectation: String, + pub observed_hardware_decoder: String, + pub fallback_reason: Option, +} + +impl PlaybackDiagnosticSnapshot { + pub(crate) fn from_snapshot( + snapshot: &PlaybackSnapshot, + requested_backend: BackendRequest, + ) -> Self { + let backend_lifecycle = match snapshot.state { + PlaybackState::Idle | PlaybackState::Ended => { + PlaybackBackendLifecycle::Ready + } + PlaybackState::Stopping => PlaybackBackendLifecycle::Stopping, + PlaybackState::Terminated => PlaybackBackendLifecycle::Terminated, + PlaybackState::Failed => PlaybackBackendLifecycle::Failed, + PlaybackState::Loading + | PlaybackState::Playing + | PlaybackState::Paused + | PlaybackState::Buffering + | PlaybackState::Seeking => PlaybackBackendLifecycle::Running, + }; + let input_video = snapshot.video.clone(); + let hardware_decoder = input_video + .as_ref() + .and_then(|video| video.hardware_decoder.clone()); + + Self { + schema_version: PLAYBACK_DIAGNOSTIC_SCHEMA_VERSION, + generation: snapshot.generation.get(), + requested_backend, + selected_target: snapshot.target, + backend_lifecycle, + playback_state: snapshot.state, + presenter_state: snapshot.presenter, + presenter_geometry: snapshot.presenter_geometry, + capabilities: snapshot.capabilities.clone(), + position_millis: duration_millis(snapshot.position), + duration_millis: snapshot.duration.map(duration_millis), + versions: PlaybackVersionDiagnostics::default(), + mpv_configuration: None, + output: PlaybackOutputDiagnostics { + hardware_decoder, + input_video, + ..PlaybackOutputDiagnostics::default() + }, + fallback_chain: snapshot.fallback_chain.clone(), + last_fallback: snapshot.last_fallback.clone(), + last_error: snapshot.last_error.clone(), + } + } + + /// Project the structured snapshot into concise, evidence-qualified labels + /// for user-facing diagnostics. The projection contains no source URI, + /// header, cookie, local path, or configuration path. + pub fn summary(&self) -> PlaybackDiagnosticSummary { + let fallback = self + .last_fallback + .as_ref() + .or_else(|| self.fallback_chain.last()); + let integrated_fallback = + self.fallback_chain.iter().rev().find(|reason| { + reason.from.is_some_and(|target| { + target.presentation + == crate::contract::PresentationMode::IntegratedNative + }) + }); + let integrated_presentation = if self.selected_target.presentation + == crate::contract::PresentationMode::IntegratedNative + { + "Active for this session".to_string() + } else if self.capabilities.integrated_presentation { + "Available, but not selected".to_string() + } else if let Some(reason) = integrated_fallback { + format!("Unavailable: {}", reason.detail) + } else { + "Not active for this session".to_string() + }; + + let input_hdr_observed = self + .output + .input_video + .as_ref() + .is_some_and(|video| video.hdr_metadata_observed); + let output_hdr_observed = self + .output + .output_video + .as_ref() + .is_some_and(|video| video.hdr_metadata_observed); + let native_hdr_evidence = if output_hdr_observed { + "Output metadata observed; display signaling not verified" + } else if self.capabilities.native_hdr { + "Backend reports support; output is not verified" + } else { + "Not verified for this session" + }; + + PlaybackDiagnosticSummary { + requested_backend: backend_request_label(self.requested_backend), + selected_backend: backend_label(self.selected_target.backend) + .to_string(), + presentation_mode: presentation_label( + self.selected_target.presentation, + ) + .to_string(), + integrated_presentation, + hdr_content_evidence: if input_hdr_observed { + "Observed in input metadata" + } else { + "Not observed in input metadata" + } + .to_string(), + native_hdr_evidence: native_hdr_evidence.to_string(), + hardware_decode_expectation: hardware_decode_expectation( + self.selected_target.backend, + ) + .to_string(), + observed_hardware_decoder: self + .output + .hardware_decoder + .as_deref() + .filter(|decoder| !decoder.trim().is_empty()) + .unwrap_or("Not observed") + .to_string(), + fallback_reason: fallback.map(|reason| reason.detail.clone()), + } + } +} + +fn backend_request_label(request: BackendRequest) -> String { + match request { + BackendRequest::Auto => "Auto".to_string(), + BackendRequest::Exact(target) => format!( + "Exact: {} / {}", + backend_label(target.backend), + presentation_label(target.presentation) + ), + } +} + +fn backend_label(backend: crate::contract::BackendKind) -> &'static str { + match backend { + crate::contract::BackendKind::GStreamer => "GStreamer", + crate::contract::BackendKind::Mpv => "mpv (in process)", + crate::contract::BackendKind::ExternalMpv => "mpv (external process)", + } +} + +fn presentation_label( + presentation: crate::contract::PresentationMode, +) -> &'static str { + match presentation { + crate::contract::PresentationMode::IntegratedNative => { + "Integrated native surface" + } + crate::contract::PresentationMode::EmbeddedFrames => { + "Embedded frame upload" + } + crate::contract::PresentationMode::NativeWindow => "Native window", + crate::contract::PresentationMode::ExternalWindow => "External window", + } +} + +fn hardware_decode_expectation( + backend: crate::contract::BackendKind, +) -> &'static str { + match backend { + crate::contract::BackendKind::Mpv => "auto-safe requested", + crate::contract::BackendKind::GStreamer => "Backend-managed policy", + crate::contract::BackendKind::ExternalMpv => { + "External player-owned policy" + } + } +} + +fn duration_millis(duration: std::time::Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + /// Redact access-token query values from playback URLs or log lines that contain them. pub fn redact_playback_url(input: &str) -> String { redact_query_value(input, ACCESS_TOKEN_PARAM, REDACTED_TOKEN) @@ -86,4 +412,124 @@ mod tests { assert_eq!(redact_playback_url(url), url); assert!(!contains_access_token(url)); } + + #[test] + fn diagnostic_summary_separates_selection_from_observed_evidence() { + let mut snapshot = PlaybackSnapshot::new( + crate::contract::SessionGeneration::new(6), + PlaybackTarget::MPV_NATIVE_WINDOW, + PlaybackCapabilities::default(), + ); + snapshot.video = Some(VideoParameters { + hardware_decoder: Some("vaapi".to_string()), + hdr_metadata_observed: true, + ..VideoParameters::default() + }); + snapshot.fallback_chain.push(FallbackReason { + code: crate::contract::FallbackReasonCode::MissingCapability, + from: Some(PlaybackTarget::MPV_INTEGRATED), + to: PlaybackTarget::MPV_NATIVE_WINDOW, + detail: "integrated presenter unavailable".to_string(), + }); + snapshot.last_fallback = snapshot.fallback_chain.last().cloned(); + + let mut diagnostics = PlaybackDiagnosticSnapshot::from_snapshot( + &snapshot, + BackendRequest::Exact(PlaybackTarget::MPV_INTEGRATED), + ); + diagnostics.output.output_video = Some(VideoParameters { + hdr_metadata_observed: true, + ..VideoParameters::default() + }); + + let summary = diagnostics.summary(); + + assert_eq!(summary.selected_backend, "mpv (in process)"); + assert_eq!(summary.presentation_mode, "Native window"); + assert_eq!( + summary.requested_backend, + "Exact: mpv (in process) / Integrated native surface" + ); + assert_eq!( + summary.integrated_presentation, + "Unavailable: integrated presenter unavailable" + ); + assert_eq!(summary.hdr_content_evidence, "Observed in input metadata"); + assert_eq!( + summary.native_hdr_evidence, + "Output metadata observed; display signaling not verified" + ); + assert_eq!(summary.hardware_decode_expectation, "auto-safe requested"); + assert_eq!(summary.observed_hardware_decoder, "vaapi"); + assert_eq!( + summary.fallback_reason.as_deref(), + Some("integrated presenter unavailable") + ); + + let rendered = format!("{summary:?}").to_ascii_lowercase(); + assert!(!rendered.contains("zero-copy")); + assert!(!rendered.contains("zero copy")); + } + + #[test] + fn diagnostic_snapshot_has_a_stable_serializable_shape() { + let mut snapshot = PlaybackSnapshot::new( + crate::contract::SessionGeneration::new(7), + PlaybackTarget::MPV_NATIVE_WINDOW, + PlaybackCapabilities { + seek: true, + external_subtitle_loading: true, + chapter_selection: true, + edition_selection: true, + screenshot: true, + video_shader_passthrough: true, + native_window_fallback: true, + ..PlaybackCapabilities::default() + }, + ); + snapshot.state = PlaybackState::Playing; + snapshot.position = std::time::Duration::from_millis(1_250); + snapshot.duration = Some(std::time::Duration::from_secs(90)); + snapshot.presenter = PresenterState::Attached; + snapshot.presenter_geometry = Some(SurfaceGeometry::new( + crate::contract::GeometryRevision::new(3), + crate::contract::LogicalRect::new(0.0, 0.0, 1920.0, 1080.0), + Some(crate::contract::LogicalRect::new(0.0, 0.0, 1920.0, 1040.0)), + 1.5, + )); + snapshot.fallback_chain.push(FallbackReason { + code: crate::contract::FallbackReasonCode::MissingCapability, + from: Some(PlaybackTarget::MPV_INTEGRATED), + to: PlaybackTarget::MPV_NATIVE_WINDOW, + detail: "integrated presenter unavailable".to_string(), + }); + snapshot.last_fallback = snapshot.fallback_chain.last().cloned(); + + let diagnostics = PlaybackDiagnosticSnapshot::from_snapshot( + &snapshot, + BackendRequest::Exact(PlaybackTarget::MPV_NATIVE_WINDOW), + ); + let json = serde_json::to_value(diagnostics).unwrap(); + + assert_eq!(json["schema_version"], 6); + assert_eq!(json["generation"], 7); + assert_eq!(json["playback_state"], "playing"); + assert_eq!(json["backend_lifecycle"], "running"); + assert_eq!(json["position_millis"], 1_250); + assert_eq!(json["duration_millis"], 90_000); + assert_eq!(json["selected_target"]["backend"], "mpv"); + assert_eq!(json["selected_target"]["presentation"], "native_window"); + assert_eq!(json["capabilities"]["external_subtitle_loading"], true); + assert_eq!(json["capabilities"]["chapter_selection"], true); + assert_eq!(json["capabilities"]["edition_selection"], true); + assert_eq!(json["capabilities"]["screenshot"], true); + assert_eq!(json["capabilities"]["video_shader_passthrough"], true); + assert_eq!(json["capabilities"]["video_profile_passthrough"], false); + assert_eq!(json["presenter_state"], "attached"); + assert_eq!(json["presenter_geometry"]["revision"], 3); + assert_eq!(json["presenter_geometry"]["scale_factor"], 1.5); + assert!(json["mpv_configuration"].is_null()); + assert_eq!(json["fallback_chain"].as_array().unwrap().len(), 1); + assert_eq!(json["fallback_chain"][0]["code"], "missing_capability"); + } } diff --git a/crates/ferrex-player-playback/src/lib.rs b/crates/ferrex-player-playback/src/lib.rs index 62723707..c6e48647 100644 --- a/crates/ferrex-player-playback/src/lib.rs +++ b/crates/ferrex-player-playback/src/lib.rs @@ -7,6 +7,8 @@ /// Playback constants shared by controls, shortcuts, and update logic. pub mod constants; +/// Backend-neutral playback commands, events, snapshots, and selection policy. +pub mod contract; /// UI controls for playback overlays. #[cfg(feature = "ui")] pub mod controls; @@ -15,8 +17,26 @@ mod diagnostics; pub mod external_mpv; /// Playback message and subscription DTOs. pub mod messages; +#[cfg(feature = "mpv")] +mod mpv_adapter; +/// Event-loop-local orchestration between native slots and platform +/// presenters. It is compiled only where an integrated presenter exists. +#[cfg(all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos", test) +))] +mod native_presentation; +/// Renderer-neutral Iced slot and raw host capture for native presentation. +#[cfg(feature = "ui")] +pub mod native_video_slot; +/// Platform-neutral native presenter lifecycle and geometry model. +pub mod presenter; +/// Backend-neutral active playback session handle. +pub mod session; /// Playback state container and notification DTOs. pub mod state; +mod subwave_adapter; /// Playback UI theme helpers. #[cfg(feature = "ui")] pub mod theme; @@ -37,6 +57,15 @@ use std::sync::Arc; /// Redact sensitive playback URLs for diagnostics. pub use diagnostics::redact_playback_url; +/// Serializable playback diagnostics and native-output observations. +pub use diagnostics::{ + MpvClientApiDiagnostics, MpvConfigurationDiagnostics, + MpvConfigurationPolicy, MpvLogVerbosity, + PLAYBACK_DIAGNOSTIC_SCHEMA_VERSION, PlaybackBackendLifecycle, + PlaybackDiagnosticSnapshot, PlaybackDiagnosticSummary, + PlaybackFrameDiagnostics, PlaybackOutputDiagnostics, + PlaybackVersionDiagnostics, +}; /// Playback message type. pub use messages::PlayerMessage; /// Playback state and track-notification DTOs. diff --git a/crates/ferrex-player-playback/src/messages/mod.rs b/crates/ferrex-player-playback/src/messages/mod.rs index ac4547fc..eea6e587 100644 --- a/crates/ferrex-player-playback/src/messages/mod.rs +++ b/crates/ferrex-player-playback/src/messages/mod.rs @@ -6,7 +6,9 @@ /// Playback subscription DTOs. pub mod subscriptions; +use crate::contract::{ChapterId, EditionId, PlaybackSource, TrackId}; use ferrex_core::player_prelude::{MediaFile, MediaID}; +use ferrex_player_api::services::streaming::TranscodeQualityProfile; use iced::ContentFit; use std::fmt; use std::time::Duration; @@ -50,13 +52,30 @@ pub enum PlayerMessage { VideoLoaded(bool), // Success flag VideoReadyToPlay, // Video is ready to be loaded and played (from streaming domain) EndOfStream, - NewFrame, + /// Synchronize a legacy adapter snapshot on the bounded controls timer, + /// independently of decoded-frame presentation. + PlaybackSnapshotTick, + /// Drain copied events from asynchronous native backends without tying + /// state updates to decoded-frame redraws. + PlaybackEventsReady, + /// Capture the renderer window's raw host on the Iced event-loop thread. + CaptureNativeVideoHost(iced::window::Id), + /// Completion of one raw-host capture. The native handle itself never + /// enters the message channel. + NativeVideoHostCaptured { + window_id: iced::window::Id, + result: Result<(), String>, + }, + /// Drain UI-thread-local presenter effects into `PlaybackSnapshot`. + NativePresenterUpdated, + /// Refresh native-root geometry/visibility independently of mpv events. + NativePresenterRefresh, Reload, // External player control PlayExternal, - // Internal: set resolved stream URL and trigger playback - SetStreamUrl(String), + // Internal: set a resolved, redacted source and trigger playback. + SetStreamSource(PlaybackSource), // Internal: surface stream authorization failures before opening a renderer StreamUrlResolutionFailed(String), @@ -72,10 +91,13 @@ pub enum PlayerMessage { // Settings SetPlaybackSpeed(f64), SetContentFit(ContentFit), + QualityProfileSelected(TranscodeQualityProfile), // Track selection - AudioTrackSelected(i32), - SubtitleTrackSelected(Option), + AudioTrackSelected(TrackId), + SubtitleTrackSelected(Option), + ChapterSelected(ChapterId), + EditionSelected(EditionId), ToggleSubtitles, ToggleSubtitleMenu, ToggleQualityMenu, @@ -90,7 +112,10 @@ pub enum PlayerMessage { // External player status messages ExternalPlaybackStarted, - ExternalPlaybackUpdate { position: f64, duration: f64 }, + ExternalPlaybackUpdate { + position: f64, + duration: f64, + }, ExternalPlaybackEnded, PollExternalMpv, ProgressHeartbeat, @@ -148,13 +173,34 @@ impl fmt::Debug for PlayerMessage { } PlayerMessage::VideoReadyToPlay => write!(f, "VideoReadyToPlay"), PlayerMessage::EndOfStream => write!(f, "EndOfStream"), - PlayerMessage::NewFrame => write!(f, "NewFrame"), + PlayerMessage::PlaybackSnapshotTick => { + write!(f, "PlaybackSnapshotTick") + } + PlayerMessage::PlaybackEventsReady => { + write!(f, "PlaybackEventsReady") + } + PlayerMessage::CaptureNativeVideoHost(_) => { + write!(f, "CaptureNativeVideoHost()") + } + PlayerMessage::NativeVideoHostCaptured { result, .. } => { + write!( + f, + "NativeVideoHostCaptured({})", + if result.is_ok() { "ok" } else { "error" } + ) + } + PlayerMessage::NativePresenterUpdated => { + write!(f, "NativePresenterUpdated") + } + PlayerMessage::NativePresenterRefresh => { + write!(f, "NativePresenterRefresh") + } PlayerMessage::Reload => write!(f, "Reload"), // External player control PlayerMessage::PlayExternal => write!(f, "PlayExternal"), - PlayerMessage::SetStreamUrl(_) => { - write!(f, "SetStreamUrl()") + PlayerMessage::SetStreamSource(_) => { + write!(f, "SetStreamSource()") } PlayerMessage::StreamUrlResolutionFailed(_) => { write!(f, "StreamUrlResolutionFailed()") @@ -180,6 +226,9 @@ impl fmt::Debug for PlayerMessage { PlayerMessage::SetContentFit(fit) => { write!(f, "SetContentFit({:?})", fit) } + PlayerMessage::QualityProfileSelected(profile) => { + write!(f, "QualityProfileSelected({profile})") + } // Track selection PlayerMessage::AudioTrackSelected(track) => { @@ -189,6 +238,12 @@ impl fmt::Debug for PlayerMessage { Some(t) => write!(f, "SubtitleTrackSelected(Some({}))", t), None => write!(f, "SubtitleTrackSelected(None)"), }, + PlayerMessage::ChapterSelected(chapter) => { + write!(f, "ChapterSelected({})", chapter.as_str()) + } + PlayerMessage::EditionSelected(edition) => { + write!(f, "EditionSelected({})", edition.as_str()) + } PlayerMessage::ToggleSubtitles => write!(f, "ToggleSubtitles"), PlayerMessage::ToggleSubtitleMenu => { write!(f, "ToggleSubtitleMenu") diff --git a/crates/ferrex-player-playback/src/messages/subscriptions.rs b/crates/ferrex-player-playback/src/messages/subscriptions.rs index 210617a5..b455aab3 100644 --- a/crates/ferrex-player-playback/src/messages/subscriptions.rs +++ b/crates/ferrex-player-playback/src/messages/subscriptions.rs @@ -1,15 +1,24 @@ -use crate::{constants::seeking::*, messages::PlayerMessage}; +use crate::{ + constants::seeking::*, + contract::{BackendKind, PlaybackEventSignal, PlaybackTarget}, + messages::PlayerMessage, +}; +use futures::{StreamExt, stream::BoxStream}; use iced::Subscription; use iced::event; use iced::keyboard::{self, Key, Modifiers, key::Named}; /// State snapshot needed to compose playback subscriptions. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] pub struct PlaybackSubscriptionState { pub is_player_view: bool, - pub has_video: bool, + /// An in-process session owns an Iced/native presentation surface. + pub has_internal_video: bool, + /// Any backend snapshot currently represents an active lifecycle. + pub has_active_playback: bool, + pub playback_target: Option, pub controls_visible: bool, - pub external_mpv_active: bool, + pub event_signal: Option, pub is_playing: bool, pub tenfoot_mode: bool, pub search_open: bool, @@ -21,24 +30,57 @@ pub fn subscription( ) -> Subscription { let mut subs = vec![]; - // Only run the controls visibility timer when overlay is visible and a video is present - if state.is_player_view && state.has_video && state.controls_visible { + // Only run the controls visibility timer when the Iced overlay is visible + // over an in-process presentation surface. + if state.is_player_view + && state.has_internal_video + && state.controls_visible + { subs.push( iced::time::every(std::time::Duration::from_millis(500)) .map(|_| PlayerMessage::CheckControlsVisibility), ); } - // If using external player, poll for position updates every second - if state.external_mpv_active { + // The retained process adapter has no push wakeup, so poll its private IPC + // while its backend-neutral snapshot remains active. + if state.has_active_playback + && state + .playback_target + .is_some_and(|target| target.backend == BackendKind::ExternalMpv) + { subs.push( iced::time::every(std::time::Duration::from_secs(1)) .map(|_| PlayerMessage::PollExternalMpv), ); } - // While playing internally, send a periodic heartbeat to persist progress - if state.is_player_view && state.has_video && state.is_playing { + // Native control planes wake Iced only after copied events are queued. The + // message then drains all pending events; no video-frame or timer-driven + // redraw loop is needed. + if state.has_internal_video + && let Some(event_signal) = state.event_signal.clone() + { + subs.push(Subscription::run_with(event_signal, playback_event_stream)); + } + + // A native-root presenter follows an mpv-owned OS window. Moving, + // minimizing, changing Spaces, or crossing a DPI boundary need not change + // Iced's slot revision or emit an mpv property event, so refresh the + // platform relationship while the integrated session is active. + if state.has_internal_video + && state.has_active_playback + && state.playback_target == Some(PlaybackTarget::MPV_INTEGRATED) + { + subs.push( + iced::time::every(std::time::Duration::from_millis(16)) + .map(|_| PlayerMessage::NativePresenterRefresh), + ); + } + + // Progress persistence consumes the same snapshot for in-process and + // external backends. + if state.is_player_view && state.has_active_playback && state.is_playing { subs.push( iced::time::every(std::time::Duration::from_secs(10)) .map(|_| PlayerMessage::ProgressHeartbeat), @@ -55,6 +97,21 @@ pub fn subscription( Subscription::batch(subs) } +fn playback_event_stream( + signal: &PlaybackEventSignal, +) -> BoxStream<'static, PlayerMessage> { + let signal = signal.clone(); + futures::stream::unfold(signal, |signal| async move { + let waiter = signal.clone(); + let notified = + tokio::task::spawn_blocking(move || waiter.wait_blocking()) + .await + .unwrap_or(false); + notified.then_some((PlayerMessage::PlaybackEventsReady, signal)) + }) + .boxed() +} + fn keyboard_shortcuts( state: PlaybackSubscriptionState, ) -> Subscription { @@ -62,9 +119,12 @@ fn keyboard_shortcuts( return Subscription::none(); } - let has_internal_video = state.has_video && !state.external_mpv_active; + let accepts_iced_input = state.has_internal_video + && state + .playback_target + .is_none_or(|target| target.backend != BackendKind::ExternalMpv); - if !(state.is_player_view && has_internal_video) { + if !(state.is_player_view && accepts_iced_input) { return Subscription::none(); } @@ -85,7 +145,7 @@ fn handle_player_key_press( key: Key, modifiers: Modifiers, ) -> Option { - let msg = match key { + match key { Key::Named(Named::Space) => Some(PlayerMessage::PlayPause), Key::Named(Named::ArrowLeft) => { if modifiers.shift() { @@ -122,6 +182,37 @@ fn handle_player_key_press( Some(PlayerMessage::CycleAudioTrack) } _ => None, - }; - msg + } +} + +#[cfg(test)] +mod tests { + use std::{sync::mpsc, time::Duration}; + + use super::*; + use crate::contract::SessionGeneration; + + #[tokio::test] + async fn native_event_stream_wakes_once_and_ends_on_disconnect() { + let (sender, receiver) = mpsc::sync_channel(1); + let signal = + PlaybackEventSignal::new(SessionGeneration::INITIAL, receiver); + let mut stream = playback_event_stream(&signal); + + sender.try_send(()).unwrap(); + let message = + tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .expect("event signal wakes subscription") + .expect("signal stream remains open"); + assert!(matches!(message, PlayerMessage::PlaybackEventsReady)); + + drop(sender); + assert!( + tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .expect("disconnect wakes waiter") + .is_none() + ); + } } diff --git a/crates/ferrex-player-playback/src/native_presentation.rs b/crates/ferrex-player-playback/src/native_presentation.rs new file mode 100644 index 00000000..6922eaee --- /dev/null +++ b/crates/ferrex-player-playback/src/native_presentation.rs @@ -0,0 +1,1471 @@ +//! UI-thread-local bridge from the neutral presenter lifecycle to a platform +//! native presenter. +//! +//! The bridge owns no decoded frames. It keeps native window objects on the +//! Iced event-loop thread, queues pointer-free presenter events for the normal +//! playback reducer, and exposes only a renderer-neutral video slot to views. + +#![cfg_attr( + all(test, not(any(target_os = "windows", target_os = "macos"))), + allow(dead_code) +)] + +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + fmt, + rc::Rc, + time::{Duration, Instant}, +}; + +use iced::window; + +use crate::{ + contract::{ + PlaybackError, PlaybackErrorKind, PlaybackTarget, PresenterEvent, + PresenterState, SessionGeneration, + }, + native_video_slot::{ + CapturedIcedHost, NativeVideoSlotDirective, NativeVideoSlotHandle, + }, + presenter::{ + PresenterCapabilities, PresenterCommand, PresenterEffect, + PresenterGeneration, PresenterIdentity, PresenterInput, + PresenterInputEnvelope, PresenterLifecycle, + }, +}; + +const PRESENTER_READINESS_TIMEOUT: Duration = Duration::from_secs(5); + +#[cfg(any(target_os = "windows", target_os = "macos"))] +use crate::{ + contract::{FallbackReason, FallbackReasonCode}, + presenter::NativePresenter, +}; + +trait PlatformPresenterDriver { + fn execute( + &mut self, + command: PresenterCommand, + host: Option<&CapturedIcedHost>, + ) -> Result<(), PlaybackError>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReadinessMissing { + Host, + VideoOutput, + HostAndVideoOutput, +} + +impl ReadinessMissing { + const fn label(self) -> &'static str { + match self { + Self::Host => "host", + Self::VideoOutput => "video_output", + Self::HostAndVideoOutput => "host,video_output", + } + } +} + +#[derive(Debug, Clone, Copy)] +struct ReadinessWait { + missing: ReadinessMissing, + started_at: Instant, +} + +struct BridgeInner { + lifecycle: PresenterLifecycle, + driver: Option>, + pending_events: VecDeque, + video_output_started: bool, + readiness_wait: Option, +} + +impl fmt::Debug for BridgeInner { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BridgeInner") + .field("identity", &self.lifecycle.identity()) + .field("state", &self.lifecycle.state()) + .field("geometry", &self.lifecycle.geometry()) + .field("driver_ready", &self.driver.is_some()) + .field("video_output_started", &self.video_output_started) + .field("readiness_wait", &self.readiness_wait) + .field("pending_events", &self.pending_events.len()) + .finish() + } +} + +impl BridgeInner { + fn handle( + &mut self, + host: Option<&CapturedIcedHost>, + envelope: PresenterInputEnvelope, + ) -> NativeVideoSlotDirective { + let input_kind = presenter_input_label(&envelope.input); + let previous_state = self.lifecycle.state(); + let previous_readiness = self.lifecycle.readiness(); + let refresh_after_fullscreen = + matches!(&envelope.input, PresenterInput::FullscreenConfirmed(_)); + let transition = self.lifecycle.handle(envelope); + let changed = !transition.effects.is_empty(); + self.apply_effects(host, transition.effects); + let state = self.lifecycle.state(); + let readiness = self.lifecycle.readiness(); + if previous_state != state || previous_readiness != readiness { + log::debug!( + "native presenter lifecycle transition: input={input_kind} state={previous_state:?}->{state:?} host_ready={} video_output_ready={} attached={}", + readiness.0, + readiness.1, + readiness.2, + ); + } + if self.readiness_missing().is_none() { + self.readiness_wait = None; + } + + // Native fullscreen can move or resize a backend-owned root without + // changing the Iced slot's logical rectangle. Refresh from the native + // root after mpv confirms the property instead of waiting for a + // coincidental Iced layout revision. + if refresh_after_fullscreen && self.refresh_platform_window(host) { + return NativeVideoSlotDirective::REDRAW.with_snapshot_sync(); + } + + if changed || !self.pending_events.is_empty() { + NativeVideoSlotDirective::REDRAW.with_snapshot_sync() + } else { + NativeVideoSlotDirective::IDLE + } + } + + /// Re-query the platform-owned video root even when Iced's logical slot + /// geometry has not changed. Win32 owned windows do not follow their owner + /// and AppKit child windows still need occlusion/Space refreshes, so this + /// deliberately bypasses lifecycle geometry-revision deduplication. + fn refresh_platform_window( + &mut self, + host: Option<&CapturedIcedHost>, + ) -> bool { + if self.check_readiness_timeout(host, Instant::now()) { + return true; + } + let Some(geometry) = self.lifecycle.geometry() else { + return false; + }; + let Some(driver) = self.driver.as_mut() else { + return false; + }; + let result = driver.execute( + PresenterCommand::Synchronize { + identity: self.lifecycle.identity(), + geometry, + }, + host, + ); + if let Err(error) = result { + self.fail(host, error); + return true; + } + false + } + + fn set_video_output_started(&mut self, started: bool) { + if self.video_output_started == started { + return; + } + self.video_output_started = started; + if !started { + self.readiness_wait = None; + } + log::debug!( + "native presenter video-output activity transition: started={started}" + ); + } + + fn readiness_missing(&self) -> Option { + if !self.video_output_started { + return None; + } + match self.lifecycle.state() { + PresenterState::AwaitingHost => Some(ReadinessMissing::Host), + PresenterState::AwaitingVideoOutput => { + Some(ReadinessMissing::VideoOutput) + } + PresenterState::Detached => { + Some(ReadinessMissing::HostAndVideoOutput) + } + PresenterState::Attached + | PresenterState::Hidden + | PresenterState::Suspended + | PresenterState::Failed => None, + } + } + + fn check_readiness_timeout( + &mut self, + host: Option<&CapturedIcedHost>, + now: Instant, + ) -> bool { + let Some(missing) = self.readiness_missing() else { + self.readiness_wait = None; + return false; + }; + let Some(wait) = + self.readiness_wait.filter(|wait| wait.missing == missing) + else { + self.readiness_wait = Some(ReadinessWait { + missing, + started_at: now, + }); + log::debug!( + "native presenter readiness watchdog armed: missing={} timeout_ms={}", + missing.label(), + PRESENTER_READINESS_TIMEOUT.as_millis(), + ); + return false; + }; + if now.saturating_duration_since(wait.started_at) + < PRESENTER_READINESS_TIMEOUT + { + return false; + } + + self.readiness_wait = None; + log::warn!( + "native presenter readiness timed out: missing={} timeout_ms={}", + missing.label(), + PRESENTER_READINESS_TIMEOUT.as_millis(), + ); + self.fail( + host, + presenter_error(format!( + "native presenter readiness timed out after {}ms (missing={})", + PRESENTER_READINESS_TIMEOUT.as_millis(), + missing.label(), + )), + ); + true + } + + fn apply_effects( + &mut self, + host: Option<&CapturedIcedHost>, + effects: Vec, + ) { + for effect in effects { + match effect { + PresenterEffect::Event(event) => { + self.pending_events.push_back(event); + } + PresenterEffect::Command(command) => { + let command_kind = presenter_command_label(&command); + // Detach is deliberately idempotent. A generation can be + // reset after a failed platform presenter construction, + // when there is no live driver left to receive it. + let result = if self.driver.is_none() + && matches!(&command, PresenterCommand::Detach { .. }) + { + Ok(()) + } else { + self.driver + .as_mut() + .ok_or_else(|| { + presenter_error( + "native presenter command arrived before the platform video output was ready", + ) + }) + .and_then(|driver| driver.execute(command, host)) + }; + match result { + Ok(()) => { + if command_kind == "attach" { + log::debug!( + "native presenter platform attach completed" + ); + } + } + Err(error) => { + log::warn!( + "native presenter platform command failed: command={command_kind} error={error}" + ); + self.fail(host, error); + break; + } + } + } + } + } + } + + fn fail(&mut self, host: Option<&CapturedIcedHost>, error: PlaybackError) { + let identity = self.lifecycle.identity(); + let transition = self.lifecycle.handle(PresenterInputEnvelope::new( + identity, + PresenterInput::Failed(error), + )); + // A failure transition can contain only an idempotent detach command + // and copied events. Execute it once without recursively failing an + // already-failed lifecycle if platform detach itself is best-effort. + for effect in transition.effects { + match effect { + PresenterEffect::Event(event) => { + self.pending_events.push_back(event); + } + PresenterEffect::Command(command) => { + if let Some(driver) = self.driver.as_mut() { + let _ = driver.execute(command, host); + } + } + } + } + } + + fn begin_generation(&mut self, identity: PresenterIdentity) { + self.video_output_started = false; + self.readiness_wait = None; + let capabilities = self.lifecycle.capabilities().clone(); + let transition = + self.lifecycle.begin_generation(identity, capabilities); + self.apply_effects(None, transition.effects); + } +} + +/// One integrated native presentation attempt owned by a playback session. +pub(crate) struct NativePresentation { + inner: Rc>, + slot: RefCell>, + slot_window: Cell>, + presenter_generation: Cell, + video_output_ready: Cell, + vo_configured: Cell, + native_output_id: Cell>, + host_visible: Cell, + confirmed_fullscreen: Cell>, + fullscreen_request: Rc>>, + #[cfg(target_os = "windows")] + windows_build_mode: crate::windows_presenter::WindowsPresenterBuildMode, + #[cfg(target_os = "macos")] + macos_build_mode: crate::macos_presenter::MacOsPresenterBuildMode, +} + +impl fmt::Debug for NativePresentation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("NativePresentation") + .field("inner", &self.inner.borrow()) + .field("slot_window", &self.slot_window.get()) + .field("video_output_ready", &self.video_output_ready.get()) + .field("vo_configured", &self.vo_configured.get()) + .field("host_visible", &self.host_visible.get()) + .field( + "native_output_id_observed", + &self.native_output_id.get().is_some(), + ) + .field("confirmed_fullscreen", &self.confirmed_fullscreen.get()) + .finish_non_exhaustive() + } +} + +impl NativePresentation { + fn from_capabilities( + generation: SessionGeneration, + capabilities: PresenterCapabilities, + #[cfg(target_os = "windows")] + windows_build_mode: crate::windows_presenter::WindowsPresenterBuildMode, + #[cfg(target_os = "macos")] + macos_build_mode: crate::macos_presenter::MacOsPresenterBuildMode, + ) -> Self { + let presenter_generation = PresenterGeneration::INITIAL; + let identity = PresenterIdentity::new(generation, presenter_generation); + Self { + inner: Rc::new(RefCell::new(BridgeInner { + lifecycle: PresenterLifecycle::new( + identity, + PlaybackTarget::MPV_INTEGRATED, + PlaybackTarget::MPV_NATIVE_WINDOW, + capabilities, + false, + ), + driver: None, + pending_events: VecDeque::new(), + video_output_started: false, + readiness_wait: None, + })), + slot: RefCell::new(None), + slot_window: Cell::new(None), + presenter_generation: Cell::new(presenter_generation), + video_output_ready: Cell::new(false), + vo_configured: Cell::new(false), + native_output_id: Cell::new(None), + host_visible: Cell::new(false), + confirmed_fullscreen: Cell::new(None), + fullscreen_request: Rc::new(Cell::new(None)), + #[cfg(target_os = "windows")] + windows_build_mode, + #[cfg(target_os = "macos")] + macos_build_mode, + } + } + + /// Preflight the target presenter before the adapter advertises integrated + /// presentation. Handle-dependent validation still runs immediately before + /// `VideoOutputReady` and again during attach. + #[cfg(target_os = "windows")] + pub(crate) fn try_new( + generation: SessionGeneration, + ) -> Result { + use crate::windows_presenter::{ + Win32WindowSystem, WindowsPresenterAvailability, + WindowsPresenterBuildMode, WindowsPresenterProbe, + windows_presenter_capabilities, + }; + + let build_mode = WindowsPresenterBuildMode::compiled(); + if matches!(build_mode, WindowsPresenterBuildMode::Disabled) { + return Err(WindowsPresenterAvailability::evaluate( + WindowsPresenterProbe { + build_mode, + ..WindowsPresenterProbe::default() + }, + ) + .fallback_reason() + .expect("disabled presenter has a fallback")); + } + let composition = Win32WindowSystem::composition_available() + .map_err(|error| platform_fallback(error.to_string()))?; + if !composition { + return Err(platform_fallback( + "Windows DWM composition is unavailable", + )); + } + + Ok(Self::from_capabilities( + generation, + windows_presenter_capabilities(build_mode), + build_mode, + )) + } + + /// Conservative macOS gate. This remains explicit-spike-only until the + /// representative Spaces/fullscreen/scale/teardown matrix is signed off. + #[cfg(target_os = "macos")] + pub(crate) fn try_new( + generation: SessionGeneration, + ) -> Result { + use crate::macos_presenter::{ + AppKitWindowSystem, MacOsPresenterBuildMode, + macos_presenter_capabilities, + }; + + let build_mode = MacOsPresenterBuildMode::compiled(); + if matches!(build_mode, MacOsPresenterBuildMode::Disabled) { + return Err(mac_platform_fallback( + FallbackReasonCode::MissingCapability, + "macOS integrated presenter is disabled in this build", + )); + } + if !AppKitWindowSystem::main_thread_available() { + return Err(mac_platform_fallback( + FallbackReasonCode::UnsupportedPlatform, + "macOS integrated presenter is not running on the AppKit main thread", + )); + } + + Ok(Self::from_capabilities( + generation, + macos_presenter_capabilities(build_mode), + build_mode, + )) + } + + pub(crate) fn capabilities(&self) -> PresenterCapabilities { + self.inner.borrow().lifecycle.capabilities().clone() + } + + pub(crate) fn slot_handle( + &self, + window_id: window::Id, + ) -> NativeVideoSlotHandle { + let cached = self.slot.borrow().as_ref().cloned(); + if let Some(handle) = cached.as_ref() + && self.slot_window.get() == Some(window_id) + && !handle.is_detached() + { + return handle.clone(); + } + + let previous_window = self.slot_window.get(); + let replacing_slot = cached.is_some() || previous_window.is_some(); + let replay_video_output = self.video_output_ready.get(); + let replay_host_visible = self.host_visible.get(); + let replay_fullscreen = self.confirmed_fullscreen.get(); + let replay_video_output_started = + self.inner.borrow().video_output_started; + + if cached + .as_ref() + .is_some_and(NativeVideoSlotHandle::is_detached) + { + log::debug!( + "native presenter recovering detached cached slot: same_window={}", + previous_window == Some(window_id), + ); + } + if let Some(old) = self.slot.borrow_mut().take() { + if !old.is_detached() { + let _ = old.detach(); + } + } + if replacing_slot { + self.advance_generation(); + } + + let identity = self.inner.borrow().lifecycle.identity(); + let inner = Rc::clone(&self.inner); + let handle = NativeVideoSlotHandle::new( + window_id, + identity, + move |host, envelope| inner.borrow_mut().handle(host, envelope), + ); + self.slot_window.set(Some(window_id)); + *self.slot.borrow_mut() = Some(handle.clone()); + + // A slot can be recreated while mpv's native output remains live. The + // lifecycle generation was reset above, so replay process-level + // readiness into the new slot instead of waiting for an mpv property + // transition that may never occur. + self.inner + .borrow_mut() + .set_video_output_started(replay_video_output_started); + let _ = handle + .notify(PresenterInput::HostVisibilityChanged(replay_host_visible)); + if replay_video_output { + let _ = handle.notify(PresenterInput::VideoOutputReady); + if let Some(fullscreen) = replay_fullscreen { + let _ = handle + .notify(PresenterInput::FullscreenConfirmed(fullscreen)); + } + } + + handle + } + + pub(crate) fn synchronize_native_output( + &self, + native_output_id: Option, + vo_configured: bool, + video_output_started: bool, + fullscreen: bool, + ) { + #[cfg(target_os = "windows")] + let ready = vo_configured && native_output_id.is_some(); + #[cfg(target_os = "macos")] + let ready = vo_configured && native_output_id.is_some(); + #[cfg(all(test, not(any(target_os = "windows", target_os = "macos"))))] + let ready = vo_configured; + + let previous_vo_configured = self.vo_configured.replace(vo_configured); + let previous_output_id = self.native_output_id.get(); + let output_changed = previous_output_id != native_output_id; + if previous_vo_configured != vo_configured || output_changed { + log::debug!( + "native presenter output observation transition: vo_configured={vo_configured} native_window_id_observed={} identity_changed={}", + native_output_id.is_some(), + previous_output_id.is_some() + && native_output_id.is_some() + && output_changed, + ); + } + if self.video_output_ready.get() && (!ready || output_changed) { + self.dispatch(PresenterInput::VideoOutputLost); + self.video_output_ready.set(false); + log::debug!( + "native presenter video output transition: ready=false identity_changed={output_changed}" + ); + self.inner.borrow_mut().driver = None; + self.confirmed_fullscreen.set(None); + self.rotate_slot_generation(); + } + self.native_output_id.set(native_output_id); + + if ready { + // A close/rebuild can detach the cached UI handle without changing + // mpv's already-ready output properties. Reconcile that condition + // on every ready observation, not only on the property edge. + self.recover_detached_slot(); + } + + let mut video_output_became_ready = false; + if ready && !self.video_output_ready.get() { + match self.create_platform_driver(native_output_id) { + Ok(driver) => { + self.inner.borrow_mut().driver = Some(driver); + log::debug!( + "native presenter platform driver created: native_window_id_observed=true" + ); + self.video_output_ready.set(true); + video_output_became_ready = true; + log::debug!( + "native presenter video output transition: ready=true identity_changed={output_changed}" + ); + self.dispatch(PresenterInput::VideoOutputReady); + } + Err(error) => { + log::warn!( + "native presenter platform driver creation failed: {error}" + ); + self.dispatch(PresenterInput::Failed(error)); + } + } + } + + self.inner.borrow_mut().set_video_output_started( + video_output_started && cfg!(any(target_os = "macos", test)), + ); + + let previous_fullscreen = + self.confirmed_fullscreen.replace(Some(fullscreen)); + if (previous_fullscreen != Some(fullscreen) + || video_output_became_ready) + && self.video_output_ready.get() + { + self.dispatch(PresenterInput::FullscreenConfirmed(fullscreen)); + } + } + + #[cfg(target_os = "windows")] + fn create_platform_driver( + &self, + native_output_id: Option, + ) -> Result, PlaybackError> { + use crate::windows_presenter::{ + Win32WindowSystem, WindowsHwnd, WindowsPresenter, + }; + + let value = native_output_id.ok_or_else(|| { + presenter_error("mpv did not expose a Windows window-id") + })?; + let video_root = WindowsHwnd::from_mpv_window_id(value) + .map_err(PlaybackError::from)?; + let windows = Win32WindowSystem; + if !windows.is_live(video_root) { + return Err(presenter_error( + "mpv exposed a stale or destroyed Windows window-id", + )); + } + + let fullscreen_request = Rc::clone(&self.fullscreen_request); + let fullscreen: WindowsFullscreenCallback = Box::new(move |value| { + fullscreen_request.set(Some(value)); + Ok(()) + }); + Ok(Box::new(WindowsPresenterDriver { + presenter: WindowsPresenter::new( + windows, + fullscreen, + video_root, + self.windows_build_mode, + ), + })) + } + + #[cfg(target_os = "macos")] + fn create_platform_driver( + &self, + native_output_id: Option, + ) -> Result, PlaybackError> { + use crate::macos_presenter::{ + AppKitWindowSystem, MacOsPresenter, MacOsWindow, + }; + + let value = native_output_id.ok_or_else(|| { + presenter_error("mpv did not expose a macOS window-id") + })?; + let video_root = MacOsWindow::from_mpv_window_id(value) + .map_err(PlaybackError::from)?; + let appkit = + AppKitWindowSystem::new(video_root).map_err(PlaybackError::from)?; + if !appkit.is_live(video_root) { + return Err(presenter_error( + "mpv exposed a stale or destroyed macOS window-id", + )); + } + + let fullscreen_request = Rc::clone(&self.fullscreen_request); + let fullscreen: MacOsFullscreenCallback = Box::new(move |value| { + fullscreen_request.set(Some(value)); + Ok(()) + }); + Ok(Box::new(MacOsPresenterDriver { + presenter: MacOsPresenter::new( + appkit, + fullscreen, + video_root, + self.macos_build_mode, + ), + })) + } + + #[cfg(all(test, not(any(target_os = "windows", target_os = "macos"))))] + fn create_platform_driver( + &self, + _native_output_id: Option, + ) -> Result, PlaybackError> { + Err(presenter_error( + "native presenter drivers are target-specific", + )) + } + + pub(crate) fn request_fullscreen(&self, fullscreen: bool) { + self.dispatch(PresenterInput::FullscreenRequested(fullscreen)); + } + + /// Reveal or hide the already-attached native controls host. Integrated + /// overlays start hidden so the shell can hide its retained main window + /// before this transition exposes a second native window. + pub(crate) fn set_host_visible(&self, visible: bool) { + self.host_visible.set(visible); + self.dispatch(PresenterInput::HostVisibilityChanged(visible)); + } + + pub(crate) fn refresh_platform_window(&self) { + self.inner.borrow_mut().refresh_platform_window(None); + } + + pub(crate) fn take_fullscreen_request(&self) -> Option { + self.fullscreen_request.take() + } + + pub(crate) fn fail_host_capture( + &self, + window_id: window::Id, + detail: String, + ) { + if self.slot_window.get() != Some(window_id) { + return; + } + self.dispatch(PresenterInput::Failed(presenter_error(format!( + "could not capture the native player overlay host: {detail}" + )))); + } + + pub(crate) fn drain_events(&self) -> Vec { + self.inner.borrow_mut().pending_events.drain(..).collect() + } + + pub(crate) fn begin_media_load(&self) { + if let Some(handle) = self.slot.borrow_mut().take() { + let _ = handle.detach(); + } + self.slot_window.set(None); + self.video_output_ready.set(false); + self.vo_configured.set(false); + self.native_output_id.set(None); + self.host_visible.set(false); + self.confirmed_fullscreen.set(None); + self.inner.borrow_mut().driver = None; + self.advance_generation(); + } + + pub(crate) fn detach(&self) { + if let Some(handle) = self.slot.borrow_mut().take() { + let _ = handle.detach(); + } else { + self.dispatch(PresenterInput::Detach); + } + self.slot_window.set(None); + self.video_output_ready.set(false); + self.vo_configured.set(false); + self.native_output_id.set(None); + self.host_visible.set(false); + self.confirmed_fullscreen.set(None); + self.inner.borrow_mut().set_video_output_started(false); + self.inner.borrow_mut().driver = None; + } + + fn dispatch(&self, input: PresenterInput) { + let cached = self.slot.borrow().as_ref().cloned(); + if let Some(handle) = cached { + if handle.is_detached() + && matches!(&input, PresenterInput::VideoOutputReady) + { + // Readiness must never disappear into a detached handle. This + // is a defensive path for callers that observe readiness + // between widget teardown and the next view reconstruction. + let replacement = self.slot_handle(handle.window_id()); + let _ = replacement.notify(input); + } else if handle.is_detached() { + log::debug!( + "native presenter bypassing detached cached slot: input={}", + presenter_input_label(&input), + ); + let identity = self.inner.borrow().lifecycle.identity(); + let _ = self + .inner + .borrow_mut() + .handle(None, PresenterInputEnvelope::new(identity, input)); + } else { + let _ = handle.notify(input); + } + } else { + let identity = self.inner.borrow().lifecycle.identity(); + let _ = self + .inner + .borrow_mut() + .handle(None, PresenterInputEnvelope::new(identity, input)); + } + } + + fn recover_detached_slot(&self) { + let detached_window = self + .slot + .borrow() + .as_ref() + .filter(|handle| handle.is_detached()) + .map(NativeVideoSlotHandle::window_id); + if let Some(window_id) = detached_window { + let _ = self.slot_handle(window_id); + } + } + + fn advance_generation(&self) { + let Some(next) = self.presenter_generation.get().next() else { + self.dispatch(PresenterInput::Failed(presenter_error( + "native presenter generation exhausted", + ))); + return; + }; + self.presenter_generation.set(next); + let session = self.inner.borrow().lifecycle.identity().session; + self.inner + .borrow_mut() + .begin_generation(PresenterIdentity::new(session, next)); + } + + fn rotate_slot_generation(&self) { + if let Some(handle) = self.slot.borrow_mut().take() { + let _ = handle.detach(); + } + self.slot_window.set(None); + self.advance_generation(); + } +} + +impl Drop for NativePresentation { + fn drop(&mut self) { + self.detach(); + } +} + +#[cfg(target_os = "windows")] +type WindowsFullscreenCallback = + Box Result<(), PlaybackError>>; + +#[cfg(target_os = "macos")] +type MacOsFullscreenCallback = + Box Result<(), PlaybackError>>; + +#[cfg(target_os = "windows")] +struct WindowsPresenterDriver { + presenter: crate::windows_presenter::WindowsPresenter< + crate::windows_presenter::Win32WindowSystem, + WindowsFullscreenCallback, + >, +} + +#[cfg(target_os = "windows")] +impl PlatformPresenterDriver for WindowsPresenterDriver { + fn execute( + &mut self, + command: PresenterCommand, + host: Option<&CapturedIcedHost>, + ) -> Result<(), PlaybackError> { + use crate::windows_presenter::WindowsPresenterHost; + + match command { + PresenterCommand::Attach { identity } => { + let host = host.ok_or_else(|| { + presenter_error( + "Windows presenter attach requires a captured Iced host", + ) + })?; + self.presenter.attach( + identity, + WindowsPresenterHost::from_captured_iced_host(host)?, + ) + } + PresenterCommand::Synchronize { identity, geometry } => { + self.presenter.synchronize(identity, geometry) + } + PresenterCommand::SetVisible { identity, visible } => { + self.presenter.set_visible(identity, visible) + } + PresenterCommand::SetSuspended { + identity, + suspended, + } => self.presenter.set_suspended(identity, suspended), + PresenterCommand::SetFullscreen { + identity, + owner, + fullscreen, + } => self.presenter.set_fullscreen(identity, owner, fullscreen), + PresenterCommand::Detach { identity } => { + self.presenter.detach(identity); + Ok(()) + } + } + } +} + +#[cfg(target_os = "macos")] +struct MacOsPresenterDriver { + presenter: crate::macos_presenter::MacOsPresenter< + crate::macos_presenter::AppKitWindowSystem, + MacOsFullscreenCallback, + >, +} + +#[cfg(target_os = "macos")] +impl PlatformPresenterDriver for MacOsPresenterDriver { + fn execute( + &mut self, + command: PresenterCommand, + host: Option<&CapturedIcedHost>, + ) -> Result<(), PlaybackError> { + use crate::macos_presenter::MacOsPresenterHost; + + match command { + PresenterCommand::Attach { identity } => { + let host = host.ok_or_else(|| { + presenter_error( + "macOS presenter attach requires a captured Iced host", + ) + })?; + self.presenter.attach( + identity, + MacOsPresenterHost::from_captured_iced_host(host)?, + ) + } + PresenterCommand::Synchronize { identity, geometry } => { + self.presenter.synchronize(identity, geometry) + } + PresenterCommand::SetVisible { identity, visible } => { + self.presenter.set_visible(identity, visible) + } + PresenterCommand::SetSuspended { + identity, + suspended, + } => self.presenter.set_suspended(identity, suspended), + PresenterCommand::SetFullscreen { + identity, + owner, + fullscreen, + } => self.presenter.set_fullscreen(identity, owner, fullscreen), + PresenterCommand::Detach { identity } => { + self.presenter.detach(identity); + Ok(()) + } + } + } +} + +fn presenter_input_label(input: &PresenterInput) -> &'static str { + match input { + PresenterInput::HostReady { .. } => "host_ready", + PresenterInput::VideoOutputReady => "video_output_ready", + PresenterInput::GeometryChanged(_) => "geometry_changed", + PresenterInput::HostVisibilityChanged(_) => "host_visibility_changed", + PresenterInput::SuspensionChanged(_) => "suspension_changed", + PresenterInput::FullscreenRequested(_) => "fullscreen_requested", + PresenterInput::FullscreenConfirmed(_) => "fullscreen_confirmed", + PresenterInput::HostLost => "host_lost", + PresenterInput::VideoOutputLost => "video_output_lost", + PresenterInput::Detach => "detach", + PresenterInput::Failed(_) => "failed", + } +} + +fn presenter_command_label(command: &PresenterCommand) -> &'static str { + match command { + PresenterCommand::Attach { .. } => "attach", + PresenterCommand::Synchronize { .. } => "synchronize", + PresenterCommand::SetVisible { .. } => "set_visible", + PresenterCommand::SetSuspended { .. } => "set_suspended", + PresenterCommand::SetFullscreen { .. } => "set_fullscreen", + PresenterCommand::Detach { .. } => "detach", + } +} + +fn presenter_error(message: impl Into) -> PlaybackError { + let mut error = PlaybackError::new(PlaybackErrorKind::Presenter, message); + error.backend = Some(crate::contract::BackendKind::Mpv); + error.recoverable = true; + error +} + +#[cfg(target_os = "windows")] +fn platform_fallback(detail: impl Into) -> FallbackReason { + FallbackReason { + code: FallbackReasonCode::UnsupportedPlatform, + from: Some(PlaybackTarget::MPV_INTEGRATED), + to: PlaybackTarget::MPV_NATIVE_WINDOW, + detail: detail.into(), + } +} + +#[cfg(target_os = "macos")] +fn mac_platform_fallback( + code: FallbackReasonCode, + detail: impl Into, +) -> FallbackReason { + FallbackReason { + code, + from: Some(PlaybackTarget::MPV_INTEGRATED), + to: PlaybackTarget::MPV_NATIVE_WINDOW, + detail: detail.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::presenter::{ + FullscreenOwner, GeometryRevision, LogicalRect, SurfaceGeometry, + }; + + #[derive(Default)] + struct FakeDriver { + commands: Rc>>, + fail_attach: bool, + } + + impl PlatformPresenterDriver for FakeDriver { + fn execute( + &mut self, + command: PresenterCommand, + _host: Option<&CapturedIcedHost>, + ) -> Result<(), PlaybackError> { + if self.fail_attach + && matches!(command, PresenterCommand::Attach { .. }) + { + return Err(presenter_error("fake attach failure")); + } + self.commands.borrow_mut().push(command); + Ok(()) + } + } + + fn geometry() -> SurfaceGeometry { + SurfaceGeometry::new( + GeometryRevision::INITIAL, + LogicalRect::new(0.0, 0.0, 1280.0, 720.0), + Some(LogicalRect::new(0.0, 0.0, 1280.0, 720.0)), + 1.0, + ) + } + + fn bridge(driver: FakeDriver) -> BridgeInner { + let capabilities = PresenterCapabilities { + integrated_overlay: true, + fullscreen_owner: Some(FullscreenOwner::VideoOutput), + native_window_fallback: true, + ..PresenterCapabilities::default() + }; + BridgeInner { + lifecycle: PresenterLifecycle::new( + PresenterIdentity::new( + SessionGeneration::new(9), + PresenterGeneration::INITIAL, + ), + PlaybackTarget::MPV_INTEGRATED, + PlaybackTarget::MPV_NATIVE_WINDOW, + capabilities, + true, + ), + driver: Some(Box::new(driver)), + pending_events: VecDeque::new(), + video_output_started: false, + readiness_wait: None, + } + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + fn presentation() -> NativePresentation { + NativePresentation::from_capabilities( + SessionGeneration::new(17), + PresenterCapabilities { + integrated_overlay: true, + fullscreen_owner: Some(FullscreenOwner::VideoOutput), + native_window_fallback: true, + ..PresenterCapabilities::default() + }, + ) + } + + #[test] + fn host_and_video_readiness_execute_attach_and_queue_snapshot_sync() { + let commands = Rc::new(RefCell::new(Vec::new())); + let mut bridge = bridge(FakeDriver { + commands: Rc::clone(&commands), + fail_attach: false, + }); + let identity = bridge.lifecycle.identity(); + + let host = bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::HostReady { + geometry: geometry(), + }, + ), + ); + assert!(host.requests_snapshot_sync()); + assert!(commands.borrow().is_empty()); + + let video = bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::VideoOutputReady, + ), + ); + assert!(video.requests_snapshot_sync()); + assert!(matches!( + commands.borrow().first(), + Some(PresenterCommand::Attach { .. }) + )); + assert!(bridge.pending_events.iter().any(|event| matches!( + event, + PresenterEvent::StateChanged( + crate::contract::PresenterState::Attached + ) + ))); + } + + #[test] + fn platform_attach_failure_queues_deterministic_native_window_fallback() { + let mut bridge = bridge(FakeDriver { + fail_attach: true, + ..FakeDriver::default() + }); + let identity = bridge.lifecycle.identity(); + bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::HostReady { + geometry: geometry(), + }, + ), + ); + bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::VideoOutputReady, + ), + ); + + assert!(bridge.pending_events.iter().any(|event| matches!( + event, + PresenterEvent::Failure(error) + if error.kind == PlaybackErrorKind::Presenter + ))); + assert!(bridge.pending_events.iter().any(|event| matches!( + event, + PresenterEvent::FallbackRequested(reason) + if reason.to == PlaybackTarget::MPV_NATIVE_WINDOW + ))); + } + + #[test] + fn platform_refresh_reissues_sync_for_unchanged_iced_geometry() { + let commands = Rc::new(RefCell::new(Vec::new())); + let mut bridge = bridge(FakeDriver { + commands: Rc::clone(&commands), + fail_attach: false, + }); + let identity = bridge.lifecycle.identity(); + bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::HostReady { + geometry: geometry(), + }, + ), + ); + bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::VideoOutputReady, + ), + ); + commands.borrow_mut().clear(); + + assert!(!bridge.refresh_platform_window(None)); + assert!(!bridge.refresh_platform_window(None)); + assert_eq!( + commands + .borrow() + .iter() + .filter(|command| matches!( + command, + PresenterCommand::Synchronize { .. } + )) + .count(), + 2 + ); + } + + #[test] + fn readiness_timeout_reports_missing_host_and_falls_back_once() { + let mut bridge = bridge(FakeDriver::default()); + let identity = bridge.lifecycle.identity(); + bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::VideoOutputReady, + ), + ); + bridge.set_video_output_started(true); + + let started_at = Instant::now(); + assert!(!bridge.check_readiness_timeout(None, started_at)); + assert!(!bridge.check_readiness_timeout( + None, + started_at + PRESENTER_READINESS_TIMEOUT - Duration::from_nanos(1), + )); + assert!(bridge.check_readiness_timeout( + None, + started_at + PRESENTER_READINESS_TIMEOUT, + )); + assert_eq!(bridge.lifecycle.state(), PresenterState::Failed); + assert!(bridge.pending_events.iter().any(|event| matches!( + event, + PresenterEvent::Failure(error) + if error.kind == PlaybackErrorKind::Presenter + && error.recoverable + && error.message.contains("missing=host") + ))); + assert_eq!( + bridge + .pending_events + .iter() + .filter(|event| matches!( + event, + PresenterEvent::FallbackRequested(reason) + if reason.to == PlaybackTarget::MPV_NATIVE_WINDOW + )) + .count(), + 1 + ); + + let event_count = bridge.pending_events.len(); + assert!(!bridge.check_readiness_timeout( + None, + started_at + PRESENTER_READINESS_TIMEOUT + Duration::from_secs(1), + )); + assert_eq!(bridge.pending_events.len(), event_count); + } + + #[test] + fn readiness_timeout_reports_missing_video_output_and_clears_geometry() { + let mut bridge = bridge(FakeDriver::default()); + let identity = bridge.lifecycle.identity(); + bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::HostReady { + geometry: geometry(), + }, + ), + ); + bridge.set_video_output_started(true); + + let started_at = Instant::now(); + assert!(!bridge.check_readiness_timeout(None, started_at)); + assert!(bridge.check_readiness_timeout( + None, + started_at + PRESENTER_READINESS_TIMEOUT, + )); + assert_eq!(bridge.lifecycle.state(), PresenterState::Failed); + assert!(bridge.pending_events.iter().any(|event| matches!( + event, + PresenterEvent::GeometryChanged(None) + ))); + assert!(bridge.pending_events.iter().any(|event| matches!( + event, + PresenterEvent::Failure(error) + if error.message.contains("missing=video_output") + ))); + } + + #[test] + fn completed_readiness_cancels_an_armed_timeout() { + let mut bridge = bridge(FakeDriver::default()); + let identity = bridge.lifecycle.identity(); + bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::VideoOutputReady, + ), + ); + bridge.set_video_output_started(true); + let started_at = Instant::now(); + assert!(!bridge.check_readiness_timeout(None, started_at)); + + bridge.handle( + None, + PresenterInputEnvelope::new( + identity, + PresenterInput::HostReady { + geometry: geometry(), + }, + ), + ); + assert_eq!(bridge.lifecycle.state(), PresenterState::Attached); + assert!(bridge.readiness_wait.is_none()); + assert!(!bridge.check_readiness_timeout( + None, + started_at + PRESENTER_READINESS_TIMEOUT + Duration::from_secs(1), + )); + assert!(!bridge.pending_events.iter().any(|event| matches!( + event, + PresenterEvent::Failure(_) | PresenterEvent::FallbackRequested(_) + ))); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[test] + fn recreated_slot_replays_live_video_output_before_host_readiness() { + let presentation = presentation(); + let window_id = window::Id::unique(); + let old_slot = presentation.slot_handle(window_id); + let old_identity = old_slot.identity(); + presentation.set_host_visible(true); + let commands = Rc::new(RefCell::new(Vec::new())); + presentation.inner.borrow_mut().driver = Some(Box::new(FakeDriver { + commands: Rc::clone(&commands), + fail_attach: false, + })); + presentation.video_output_ready.set(true); + presentation + .inner + .borrow_mut() + .set_video_output_started(true); + presentation.dispatch(PresenterInput::VideoOutputReady); + old_slot.notify(PresenterInput::HostReady { + geometry: geometry(), + }); + assert_eq!( + presentation.inner.borrow().lifecycle.state(), + PresenterState::Attached + ); + + old_slot.detach(); + let replacement = presentation.slot_handle(window_id); + + assert!(!replacement.is_detached()); + assert_ne!(replacement.identity(), old_identity); + assert_eq!( + presentation.inner.borrow().lifecycle.readiness(), + (false, true, false) + ); + assert!(presentation.host_visible.get()); + assert!(presentation.inner.borrow().video_output_started); + + replacement.notify(PresenterInput::HostReady { + geometry: geometry(), + }); + assert_eq!( + presentation.inner.borrow().lifecycle.readiness(), + (true, true, true) + ); + assert_eq!( + commands + .borrow() + .iter() + .filter(|command| matches!( + command, + PresenterCommand::Attach { .. } + )) + .count(), + 2 + ); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[test] + fn video_readiness_dispatch_recovers_a_detached_cached_handle() { + let presentation = presentation(); + let window_id = window::Id::unique(); + let old_slot = presentation.slot_handle(window_id); + let old_identity = old_slot.identity(); + old_slot.detach(); + presentation.inner.borrow_mut().driver = + Some(Box::new(FakeDriver::default())); + + presentation.dispatch(PresenterInput::VideoOutputReady); + + let replacement = presentation + .slot + .borrow() + .as_ref() + .expect("detached slot replaced") + .clone(); + assert!(old_slot.is_detached()); + assert!(!replacement.is_detached()); + assert_ne!(replacement.identity(), old_identity); + assert_eq!( + presentation.inner.borrow().lifecycle.readiness(), + (false, true, false) + ); + + replacement.notify(PresenterInput::HostReady { + geometry: geometry(), + }); + assert_eq!( + presentation.inner.borrow().lifecycle.readiness(), + (true, true, true) + ); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + #[test] + fn video_output_loss_rotates_the_slot_identity_before_recreation() { + let presentation = presentation(); + let window_id = window::Id::unique(); + let old_slot = presentation.slot_handle(window_id); + let old_identity = old_slot.identity(); + old_slot.notify(PresenterInput::HostReady { + geometry: geometry(), + }); + presentation.inner.borrow_mut().driver = + Some(Box::new(FakeDriver::default())); + presentation.video_output_ready.set(true); + presentation.dispatch(PresenterInput::VideoOutputReady); + + presentation.synchronize_native_output(None, false, false, false); + + assert!(old_slot.is_detached()); + assert!(presentation.slot.borrow().is_none()); + assert_eq!(presentation.slot_window.get(), None); + let replacement = presentation.slot_handle(window_id); + assert_ne!(replacement.identity(), old_identity); + assert_eq!( + replacement.identity().presenter, + old_identity + .presenter + .next() + .expect("next presenter generation") + ); + } +} diff --git a/crates/ferrex-player-playback/src/native_video_slot.rs b/crates/ferrex-player-playback/src/native_video_slot.rs new file mode 100644 index 00000000..5652b7fb --- /dev/null +++ b/crates/ferrex-player-playback/src/native_video_slot.rs @@ -0,0 +1,1248 @@ +//! Renderer-neutral Iced slot for platform-native video presentation. +//! +//! [`crate::native_video_slot::NativeVideoSlot`] reserves layout but never turns decoded video into an +//! Iced primitive. On redraw it reports generation-scoped host geometry to a +//! UI-thread-local callback. Raw host handles are captured through +//! [`iced::window::run`] and retained in a thread-local registry, so Wayland, +//! AppKit, and other event-loop-local resources do not acquire a `Send` bound. + +use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + fmt, + rc::{Rc, Weak}, +}; + +use iced::window::raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, + RawDisplayHandle, RawWindowHandle, WindowHandle, +}; +use iced::{ + Background, Color, Element, Event, Length, Rectangle, Size, Task, + advanced::{ + Clipboard, Layout, Shell, Widget, layout, mouse, renderer, + widget::{Tree, tree}, + }, + window::{self, Window as IcedWindow}, +}; + +use crate::{ + contract::{PlaybackError, PlaybackErrorKind}, + presenter::{ + GeometryRevision, LogicalRect, PresenterIdentity, PresenterInput, + PresenterInputEnvelope, SurfaceGeometry, + }, +}; + +thread_local! { + /// Raw handles are deliberately confined to the Iced event-loop thread. + static ICED_NATIVE_HOSTS: RefCell>> = + RefCell::new(HashMap::new()); + /// Weak registrations let explicit window-close handling detach every slot + /// before releasing the corresponding raw host lease. Cloning a slot + /// handle does not create duplicate registrations or keep it alive. + static ICED_NATIVE_VIDEO_SLOTS: RefCell< + HashMap>>, + > = RefCell::new(HashMap::new()); +} + +/// Desktop window system represented by a captured Iced host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeHostPlatform { + Wayland, + X11, + Windows, + MacOs, + Other, +} + +/// Failure to copy a valid raw host handle from Iced's current window. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum NativeHostCaptureError { + #[error("could not acquire Iced {kind} handle: {detail}")] + HandleUnavailable { kind: &'static str, detail: String }, + #[error( + "Iced returned incompatible native handles (window={window}, display={display})" + )] + IncompatiblePair { + window: &'static str, + display: &'static str, + }, +} + +/// Borrow-only native host captured from an Iced window. +/// +/// Values of this type live exclusively in a thread-local registry. Callers can +/// only borrow one through [`with_captured_iced_host`], and must release it +/// before the corresponding native window is destroyed. The raw handle types +/// intentionally keep this value from becoming `Send`. +pub struct CapturedIcedHost { + window_id: window::Id, + platform: NativeHostPlatform, + raw_window: RawWindowHandle, + raw_display: RawDisplayHandle, +} + +impl CapturedIcedHost { + pub const fn window_id(&self) -> window::Id { + self.window_id + } + + pub const fn platform(&self) -> NativeHostPlatform { + self.platform + } +} + +impl fmt::Debug for CapturedIcedHost { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CapturedIcedHost") + .field("window_id", &self.window_id) + .field("platform", &self.platform) + .finish_non_exhaustive() + } +} + +impl HasWindowHandle for CapturedIcedHost { + fn window_handle(&self) -> Result, HandleError> { + // SAFETY: capture copies a handle while Iced owns a live window. The + // registry only lends this wrapper and explicit close handling removes + // it before platform teardown. + Ok(unsafe { WindowHandle::borrow_raw(self.raw_window) }) + } +} + +impl HasDisplayHandle for CapturedIcedHost { + fn display_handle(&self) -> Result, HandleError> { + // SAFETY: identical lifetime argument to `window_handle` above; the + // display handle remains tied to the borrowed registry entry. + Ok(unsafe { DisplayHandle::borrow_raw(self.raw_display) }) + } +} + +/// Capture raw handles for `window_id` on Iced's native event-loop thread. +/// +/// The task output is intentionally pointer-free and therefore can cross the +/// normal Iced task channel. The copied raw handles themselves never leave the +/// thread-local registry. +pub fn capture_iced_native_host( + window_id: window::Id, +) -> Task> { + window::run(window_id, move |window| { + capture_iced_native_host_from_window(window_id, window) + }) +} + +/// Whether the current event-loop thread has a captured host for `window_id`. +pub fn has_captured_iced_host(window_id: window::Id) -> bool { + ICED_NATIVE_HOSTS.with(|hosts| hosts.borrow().contains_key(&window_id)) +} + +/// Borrow a captured native host without allowing it to escape the callback. +/// +/// Platform presenter code should call this only from Iced's event-loop thread. +pub fn with_captured_iced_host( + window_id: window::Id, + callback: impl FnOnce(&CapturedIcedHost) -> T, +) -> Option { + let host = + ICED_NATIVE_HOSTS.with(|hosts| hosts.borrow().get(&window_id).cloned()); + host.map(|host| callback(&host)) +} + +/// Remove a captured host after presenter detach and before window destruction. +pub fn release_captured_iced_host(window_id: window::Id) -> bool { + ICED_NATIVE_HOSTS + .with(|hosts| hosts.borrow_mut().remove(&window_id).is_some()) +} + +/// Result of preparing one Iced host for deterministic native teardown. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NativeHostCloseResult { + /// Number of still-live slot generations explicitly detached. + pub detached_slots: usize, + /// Whether a captured raw host lease was released. + pub released_host: bool, +} + +/// Detach every native video slot registered for `window_id`. +/// +/// This is the explicit pre-destruction path used by the daemon window +/// manager. Ordinary Iced widget-tree churn is not a host-lifetime signal and +/// deliberately leaves these registrations attached. +pub fn detach_native_video_slots(window_id: window::Id) -> usize { + let slots = ICED_NATIVE_VIDEO_SLOTS.with(|slots| { + slots.borrow_mut().remove(&window_id).unwrap_or_default() + }); + + slots + .into_iter() + .filter_map(|slot| slot.upgrade()) + .filter(|slot| { + let handle = NativeVideoSlotHandle { + inner: Rc::clone(slot), + }; + if handle.is_detached() { + false + } else { + let _ = handle.detach(); + true + } + }) + .count() +} + +/// Detach native presentation and then release the raw Iced host lease. +/// +/// Call this before issuing `window::close`; the ordering is intentional so no +/// presenter callback can retain or use a native handle after host teardown. +pub fn prepare_iced_native_host_close( + window_id: window::Id, +) -> NativeHostCloseResult { + let detached_slots = detach_native_video_slots(window_id); + let released_host = release_captured_iced_host(window_id); + NativeHostCloseResult { + detached_slots, + released_host, + } +} + +fn capture_iced_native_host_from_window( + window_id: window::Id, + window: &dyn IcedWindow, +) -> Result { + let raw_window = window + .window_handle() + .map(|handle| handle.as_raw()) + .map_err(|error| NativeHostCaptureError::HandleUnavailable { + kind: "window", + detail: error.to_string(), + })?; + let raw_display = window + .display_handle() + .map(|handle| handle.as_raw()) + .map_err(|error| NativeHostCaptureError::HandleUnavailable { + kind: "display", + detail: error.to_string(), + })?; + let platform = native_host_platform(raw_window, raw_display)?; + + ICED_NATIVE_HOSTS.with(|hosts| { + hosts.borrow_mut().insert( + window_id, + Rc::new(CapturedIcedHost { + window_id, + platform, + raw_window, + raw_display, + }), + ); + }); + log::debug!( + "native presenter host capture completed: registered=true platform={platform:?}" + ); + + Ok(platform) +} + +fn native_host_platform( + window: RawWindowHandle, + display: RawDisplayHandle, +) -> Result { + match (window, display) { + (RawWindowHandle::Wayland(_), RawDisplayHandle::Wayland(_)) => { + Ok(NativeHostPlatform::Wayland) + } + (RawWindowHandle::Xlib(_), RawDisplayHandle::Xlib(_)) + | (RawWindowHandle::Xcb(_), RawDisplayHandle::Xcb(_)) => { + Ok(NativeHostPlatform::X11) + } + (RawWindowHandle::Win32(_), RawDisplayHandle::Windows(_)) => { + Ok(NativeHostPlatform::Windows) + } + (RawWindowHandle::AppKit(_), RawDisplayHandle::AppKit(_)) => { + Ok(NativeHostPlatform::MacOs) + } + (window @ RawWindowHandle::Wayland(_), display) + | (window @ RawWindowHandle::Xlib(_), display) + | (window @ RawWindowHandle::Xcb(_), display) + | (window @ RawWindowHandle::Win32(_), display) + | (window @ RawWindowHandle::AppKit(_), display) => { + Err(NativeHostCaptureError::IncompatiblePair { + window: raw_window_label(window), + display: raw_display_label(display), + }) + } + _ => Ok(NativeHostPlatform::Other), + } +} + +fn raw_window_label(handle: RawWindowHandle) -> &'static str { + match handle { + RawWindowHandle::Wayland(_) => "wayland", + RawWindowHandle::Xlib(_) => "xlib", + RawWindowHandle::Xcb(_) => "xcb", + RawWindowHandle::Win32(_) => "win32", + RawWindowHandle::AppKit(_) => "appkit", + _ => "other", + } +} + +fn raw_display_label(handle: RawDisplayHandle) -> &'static str { + match handle { + RawDisplayHandle::Wayland(_) => "wayland", + RawDisplayHandle::Xlib(_) => "xlib", + RawDisplayHandle::Xcb(_) => "xcb", + RawDisplayHandle::Windows(_) => "windows", + RawDisplayHandle::AppKit(_) => "appkit", + _ => "other", + } +} + +/// Host work requested after one slot lifecycle input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct NativeVideoSlotDirective { + request_redraw: bool, + request_host_capture: bool, + request_snapshot_sync: bool, +} + +impl NativeVideoSlotDirective { + pub const IDLE: Self = Self { + request_redraw: false, + request_host_capture: false, + request_snapshot_sync: false, + }; + pub const REDRAW: Self = Self { + request_redraw: true, + request_host_capture: false, + request_snapshot_sync: false, + }; + pub const CAPTURE_HOST: Self = Self { + request_redraw: false, + request_host_capture: true, + request_snapshot_sync: false, + }; + + pub const fn new(request_redraw: bool, request_host_capture: bool) -> Self { + Self { + request_redraw, + request_host_capture, + request_snapshot_sync: false, + } + } + + /// Request an application update that drains presenter events into the + /// backend-neutral playback snapshot. + pub const fn with_snapshot_sync(mut self) -> Self { + self.request_snapshot_sync = true; + self + } + + pub const fn requests_redraw(self) -> bool { + self.request_redraw + } + + pub const fn requests_host_capture(self) -> bool { + self.request_host_capture + } + + pub const fn requests_snapshot_sync(self) -> bool { + self.request_snapshot_sync + } + + pub const fn merge(self, other: Self) -> Self { + Self { + request_redraw: self.request_redraw || other.request_redraw, + request_host_capture: self.request_host_capture + || other.request_host_capture, + request_snapshot_sync: self.request_snapshot_sync + || other.request_snapshot_sync, + } + } +} + +type SlotCallback = dyn for<'host> Fn( + Option<&'host CapturedIcedHost>, + PresenterInputEnvelope, +) -> NativeVideoSlotDirective; + +struct NativeVideoSlotHandleInner { + window_id: window::Id, + identity: PresenterIdentity, + callback: Box, + detached: Cell, +} + +impl Drop for NativeVideoSlotHandleInner { + fn drop(&mut self) { + // Do not retain one dead weak registration per playback generation. + // `try_with` also makes event-loop thread shutdown harmless. + let _ = ICED_NATIVE_VIDEO_SLOTS.try_with(|slots| { + let mut slots = slots.borrow_mut(); + let remove_window = + slots.get_mut(&self.window_id).is_some_and(|registered| { + registered.retain(|slot| slot.strong_count() > 0); + registered.is_empty() + }); + if remove_window { + slots.remove(&self.window_id); + } + }); + } +} + +/// Cloneable UI-thread-local bridge between a slot and presenter lifecycle. +/// +/// The callback receives a borrowed host when one has been captured. Inputs +/// such as `VideoOutputReady` may legitimately arrive before that capture and +/// therefore receive `None`; `HostReady` emitted by the widget always carries a +/// host. A handle is single-generation and cannot be reused after detach. +#[derive(Clone)] +pub struct NativeVideoSlotHandle { + inner: Rc, +} + +impl NativeVideoSlotHandle { + pub fn new( + window_id: window::Id, + identity: PresenterIdentity, + callback: F, + ) -> Self + where + F: for<'host> Fn( + Option<&'host CapturedIcedHost>, + PresenterInputEnvelope, + ) -> NativeVideoSlotDirective + + 'static, + { + let inner = Rc::new(NativeVideoSlotHandleInner { + window_id, + identity, + callback: Box::new(callback), + detached: Cell::new(false), + }); + ICED_NATIVE_VIDEO_SLOTS.with(|slots| { + slots + .borrow_mut() + .entry(window_id) + .or_default() + .push(Rc::downgrade(&inner)); + }); + Self { inner } + } + + pub fn window_id(&self) -> window::Id { + self.inner.window_id + } + + pub fn identity(&self) -> PresenterIdentity { + self.inner.identity + } + + pub fn host_is_captured(&self) -> bool { + has_captured_iced_host(self.window_id()) + } + + pub fn is_detached(&self) -> bool { + self.inner.detached.get() + } + + /// Deliver an input on the UI thread, borrowing the captured host if it is + /// already available. + pub fn notify(&self, input: PresenterInput) -> NativeVideoSlotDirective { + if self.is_detached() { + return NativeVideoSlotDirective::IDLE; + } + self.dispatch(input) + } + + /// Explicitly detach before the native host window closes. + pub fn detach(&self) -> NativeVideoSlotDirective { + if self.inner.detached.replace(true) { + return NativeVideoSlotDirective::IDLE; + } + self.dispatch(PresenterInput::Detach) + } + + fn dispatch(&self, input: PresenterInput) -> NativeVideoSlotDirective { + let mut envelope = + Some(PresenterInputEnvelope::new(self.identity(), input)); + let with_host = with_captured_iced_host(self.window_id(), |host| { + (self.inner.callback)( + Some(host), + envelope.take().expect("envelope"), + ) + }); + + with_host.unwrap_or_else(|| { + (self.inner.callback)( + None, + envelope.take().expect("envelope not consumed without host"), + ) + }) + } + + fn same_instance(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.inner, &other.inner) + } +} + +impl fmt::Debug for NativeVideoSlotHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("NativeVideoSlotHandle") + .field("window_id", &self.window_id()) + .field("identity", &self.identity()) + .field("detached", &self.is_detached()) + .finish_non_exhaustive() + } +} + +/// Non-video fallback painted by the slot's generic renderer. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum NativeVideoSlotAppearance { + /// Preserve alpha once native presentation is attached. + Transparent, + /// Opaque neutral loading plate; controls may be stacked above it. + #[default] + Loading, + /// Distinct failure plate; error text may be stacked above it. + Failed, + /// Caller-selected solid fallback color. + Solid(Color), +} + +impl NativeVideoSlotAppearance { + fn background(self) -> Option { + match self { + Self::Transparent => None, + Self::Loading => Some(Color::BLACK.into()), + Self::Failed => Some(Color::from_rgb(0.18, 0.03, 0.03).into()), + Self::Solid(color) => Some(color.into()), + } + } +} + +/// An axis-aligned Iced layout slot for independently rendered native video. +/// +/// The widget is renderer-generic and records no decoded image or custom wgpu +/// primitive. `on_host_capture` should map to an application message that runs +/// [`capture_iced_native_host`]. Processing that task causes a normal Iced +/// update/redraw, at which point the first `HostReady` input is delivered. +pub struct NativeVideoSlot<'a, Message> { + handle: NativeVideoSlotHandle, + width: Length, + height: Length, + scale_factor: Option, + appearance: NativeVideoSlotAppearance, + on_host_capture: Box Message + 'a>, + on_presenter_update: Option Message + 'a>>, +} + +impl<'a, Message> NativeVideoSlot<'a, Message> { + pub fn new( + handle: NativeVideoSlotHandle, + on_host_capture: impl Fn(window::Id) -> Message + 'a, + ) -> Self { + Self { + handle, + width: Length::Fill, + height: Length::Fill, + scale_factor: None, + appearance: NativeVideoSlotAppearance::default(), + on_host_capture: Box::new(on_host_capture), + on_presenter_update: None, + } + } + + /// Publish an application message whenever the presenter callback queues + /// snapshot-visible state, geometry, fullscreen, or fallback events. + pub fn on_presenter_update( + mut self, + message: impl Fn() -> Message + 'a, + ) -> Self { + self.on_presenter_update = Some(Box::new(message)); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + /// Override the scale observed from `Window::Rescaled` (useful when the + /// host applies an additional application-level logical scale). + pub fn scale_factor(mut self, scale_factor: f64) -> Self { + self.scale_factor = Some(scale_factor); + self + } + + pub fn appearance(mut self, appearance: NativeVideoSlotAppearance) -> Self { + self.appearance = appearance; + self + } +} + +impl fmt::Debug for NativeVideoSlot<'_, Message> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("NativeVideoSlot") + .field("handle", &self.handle) + .field("width", &self.width) + .field("height", &self.height) + .field("scale_factor", &self.scale_factor) + .field("appearance", &self.appearance) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct GeometryFingerprint { + logical_bounds: LogicalRect, + visible_bounds: Option, + scale_factor: f64, +} + +#[derive(Debug)] +struct NativeVideoSlotState { + handle: NativeVideoSlotHandle, + host_announced: bool, + capture_requested: bool, + last_fingerprint: Option, + last_revision: Option, + observed_scale_factor: f64, +} + +impl NativeVideoSlotState { + fn new(handle: NativeVideoSlotHandle) -> Self { + Self { + handle, + host_announced: false, + capture_requested: false, + last_fingerprint: None, + last_revision: None, + observed_scale_factor: 1.0, + } + } + + fn redraw( + &mut self, + bounds: Rectangle, + viewport: Rectangle, + configured_scale_factor: Option, + ) -> NativeVideoSlotDirective { + if self.handle.is_detached() { + return NativeVideoSlotDirective::IDLE; + } + if !self.handle.host_is_captured() { + return NativeVideoSlotDirective::CAPTURE_HOST; + } + + if self.capture_requested { + log::debug!( + "native presenter post-capture redraw observed: host_captured=true" + ); + } + self.capture_requested = false; + let fingerprint = geometry_fingerprint( + bounds, + viewport, + configured_scale_factor.unwrap_or(self.observed_scale_factor), + ); + if self.last_fingerprint == Some(fingerprint) { + return NativeVideoSlotDirective::IDLE; + } + + let revision = match self.last_revision { + None => GeometryRevision::INITIAL, + Some(revision) => match revision.next() { + Some(revision) => revision, + None => { + let error = PlaybackError::new( + PlaybackErrorKind::Presenter, + "native video slot geometry revision exhausted", + ); + return self.handle.notify(PresenterInput::Failed(error)); + } + }, + }; + self.last_fingerprint = Some(fingerprint); + self.last_revision = Some(revision); + + let geometry = SurfaceGeometry::new( + revision, + fingerprint.logical_bounds, + fingerprint.visible_bounds, + fingerprint.scale_factor, + ); + let input = if self.host_announced { + PresenterInput::GeometryChanged(geometry) + } else { + self.host_announced = true; + log::debug!( + "native presenter host readiness transition: host_ready=true" + ); + PresenterInput::HostReady { geometry } + }; + self.handle.notify(input) + } + + fn update_scale_factor(&mut self, scale_factor: f32) -> bool { + let scale_factor = f64::from(scale_factor); + if !scale_factor.is_finite() + || scale_factor <= 0.0 + || self.observed_scale_factor == scale_factor + { + return false; + } + self.observed_scale_factor = scale_factor; + true + } + + fn detach(&mut self) { + let _ = self.handle.detach(); + } +} + +// Do not detach from `Drop`: Iced may discard and rebuild widget state while +// the same native host and presenter generation remain live. Real replacement +// and native-window close paths call `detach` explicitly. +fn geometry_fingerprint( + bounds: Rectangle, + viewport: Rectangle, + scale_factor: f64, +) -> GeometryFingerprint { + let logical_bounds = logical_rect(bounds); + let visible_bounds = (bounds.width > 0.0 && bounds.height > 0.0) + .then(|| bounds.intersection(&viewport)) + .flatten() + .filter(|bounds| bounds.width > 0.0 && bounds.height > 0.0) + .map(logical_rect); + + GeometryFingerprint { + logical_bounds, + visible_bounds, + scale_factor, + } +} + +fn logical_rect(bounds: Rectangle) -> LogicalRect { + LogicalRect::new( + f64::from(bounds.x), + f64::from(bounds.y), + f64::from(bounds.width), + f64::from(bounds.height), + ) +} + +impl Widget + for NativeVideoSlot<'_, Message> +where + Renderer: iced::advanced::Renderer, +{ + fn size(&self) -> Size { + Size::new(self.width, self.height) + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic(limits, self.width, self.height) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let Some(background) = self.appearance.background() else { + return; + }; + let Some(bounds) = layout.bounds().intersection(viewport) else { + return; + }; + renderer.fill_quad( + renderer::Quad { + bounds, + ..renderer::Quad::default() + }, + background, + ); + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(NativeVideoSlotState::new(self.handle.clone())) + } + + fn diff(&self, tree: &mut Tree) { + let state = tree.state.downcast_mut::(); + if !state.handle.same_instance(&self.handle) { + state.detach(); + *state = NativeVideoSlotState::new(self.handle.clone()); + } + tree.children.clear(); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_mut::(); + + match event { + Event::Window(window::Event::RedrawRequested(_)) => { + let directive = + state.redraw(layout.bounds(), *viewport, self.scale_factor); + if directive.requests_host_capture() && !state.capture_requested + { + state.capture_requested = true; + log::debug!( + "native presenter host capture requested: host_captured=false" + ); + shell.publish((self.on_host_capture)( + self.handle.window_id(), + )); + } + if directive.requests_redraw() { + shell.request_redraw(); + } + if directive.requests_snapshot_sync() + && let Some(message) = self.on_presenter_update.as_ref() + { + shell.publish(message()); + } + } + Event::Window(window::Event::Rescaled(scale_factor)) => { + if self.scale_factor.is_none() + && state.update_scale_factor(*scale_factor) + { + shell.request_redraw(); + } + } + Event::Window( + window::Event::CloseRequested | window::Event::Closed, + ) => { + // The close request is the deterministic pre-destruction path; + // `Closed` is a defensive backup for hosts that bypass it. + let _ = prepare_iced_native_host_close(self.handle.window_id()); + state.detach(); + } + _ => {} + } + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: iced::advanced::Renderer + 'a, +{ + fn from(slot: NativeVideoSlot<'a, Message>) -> Self { + Element::new(slot) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + use iced::window::raw_window_handle::{ + XlibDisplayHandle, XlibWindowHandle, + }; + use iced::{Point, advanced::Widget}; + + use crate::{contract::SessionGeneration, presenter::PresenterGeneration}; + + struct FakeXlibWindow; + + impl HasWindowHandle for FakeXlibWindow { + fn window_handle(&self) -> Result, HandleError> { + let raw = RawWindowHandle::Xlib(XlibWindowHandle::new(7)); + // SAFETY: tests only compare the copied numeric XID and never call + // a native API with it. + Ok(unsafe { WindowHandle::borrow_raw(raw) }) + } + } + + impl HasDisplayHandle for FakeXlibWindow { + fn display_handle(&self) -> Result, HandleError> { + let raw = RawDisplayHandle::Xlib(XlibDisplayHandle::new(None, 0)); + // SAFETY: tests only compare the copied inert handle. + Ok(unsafe { DisplayHandle::borrow_raw(raw) }) + } + } + + fn identity() -> PresenterIdentity { + PresenterIdentity::new( + SessionGeneration::new(4), + PresenterGeneration::new(2), + ) + } + + fn capture(window_id: window::Id) { + release_captured_iced_host(window_id); + assert_eq!( + capture_iced_native_host_from_window(window_id, &FakeXlibWindow,), + Ok(NativeHostPlatform::X11) + ); + } + + #[test] + fn generic_iced_host_capture_stays_borrowed_in_local_registry() { + let window_id = window::Id::unique(); + capture(window_id); + + let observed = with_captured_iced_host(window_id, |host| { + assert_eq!(host.window_id(), window_id); + assert_eq!(host.platform(), NativeHostPlatform::X11); + ( + host.window_handle().unwrap().as_raw(), + host.display_handle().unwrap().as_raw(), + ) + }); + + assert!(matches!( + observed, + Some((RawWindowHandle::Xlib(_), RawDisplayHandle::Xlib(_))) + )); + assert!(release_captured_iced_host(window_id)); + assert!(!has_captured_iced_host(window_id)); + } + + #[test] + fn redraw_emits_only_monotonic_changed_geometry() { + let window_id = window::Id::unique(); + capture(window_id); + let events = Rc::new(RefCell::new(Vec::new())); + let events_for_callback = Rc::clone(&events); + let handle = NativeVideoSlotHandle::new( + window_id, + identity(), + move |host, envelope| { + assert!(host.is_some()); + events_for_callback.borrow_mut().push(envelope); + NativeVideoSlotDirective::IDLE + }, + ); + let mut state = NativeVideoSlotState::new(handle); + let bounds = + Rectangle::new(Point::new(10.0, 20.0), Size::new(1280.0, 720.0)); + let viewport = Rectangle::new(Point::ORIGIN, Size::new(1920.0, 1080.0)); + + assert_eq!( + state.redraw(bounds, viewport, None), + NativeVideoSlotDirective::IDLE + ); + assert_eq!( + state.redraw(bounds, viewport, None), + NativeVideoSlotDirective::IDLE + ); + state.observed_scale_factor = 2.0; + assert_eq!( + state.redraw(bounds, viewport, None), + NativeVideoSlotDirective::IDLE + ); + + let events = events.borrow(); + assert_eq!(events.len(), 2); + let PresenterInput::HostReady { geometry: first } = events[0].input + else { + panic!("first redraw must announce the host"); + }; + let PresenterInput::GeometryChanged(second) = events[1].input else { + panic!("scale change must produce one geometry revision"); + }; + assert_eq!(first.revision, GeometryRevision::new(1)); + assert_eq!(second.revision, GeometryRevision::new(2)); + assert_eq!(first.scale_factor, 1.0); + assert_eq!(second.scale_factor, 2.0); + drop(events); + drop(state); + release_captured_iced_host(window_id); + } + + #[test] + fn clipping_and_zero_size_are_reported_as_hidden_geometry() { + let window_id = window::Id::unique(); + capture(window_id); + let events = Rc::new(RefCell::new(Vec::new())); + let events_for_callback = Rc::clone(&events); + let handle = NativeVideoSlotHandle::new( + window_id, + identity(), + move |_host, envelope| { + events_for_callback.borrow_mut().push(envelope); + NativeVideoSlotDirective::IDLE + }, + ); + let mut state = NativeVideoSlotState::new(handle); + let bounds = + Rectangle::new(Point::new(10.0, 20.0), Size::new(100.0, 80.0)); + let clipped = + Rectangle::new(Point::new(50.0, 0.0), Size::new(30.0, 50.0)); + + state.redraw(bounds, clipped, Some(1.5)); + state.redraw( + Rectangle::new(Point::new(10.0, 20.0), Size::ZERO), + clipped, + Some(1.5), + ); + + let events = events.borrow(); + let PresenterInput::HostReady { geometry: first } = events[0].input + else { + panic!("expected host geometry"); + }; + assert_eq!( + first.visible_bounds, + Some(LogicalRect::new(50.0, 20.0, 30.0, 30.0)) + ); + assert!(first.is_visible()); + + let PresenterInput::GeometryChanged(zero) = events[1].input else { + panic!("expected changed zero-size geometry"); + }; + assert_eq!(zero.visible_bounds, None); + assert!(!zero.is_visible()); + drop(events); + drop(state); + release_captured_iced_host(window_id); + } + + #[test] + fn missing_host_requests_one_capture_without_consuming_revision() { + let window_id = window::Id::unique(); + release_captured_iced_host(window_id); + let events = Rc::new(RefCell::new(Vec::new())); + let events_for_callback = Rc::clone(&events); + let handle = NativeVideoSlotHandle::new( + window_id, + identity(), + move |_host, envelope| { + events_for_callback.borrow_mut().push(envelope); + NativeVideoSlotDirective::IDLE + }, + ); + let mut state = NativeVideoSlotState::new(handle); + let bounds = Rectangle::new(Point::ORIGIN, Size::new(640.0, 360.0)); + + assert_eq!( + state.redraw(bounds, bounds, None), + NativeVideoSlotDirective::CAPTURE_HOST + ); + assert!(events.borrow().is_empty()); + assert_eq!(state.last_revision, None); + + capture(window_id); + state.redraw(bounds, bounds, None); + assert!(matches!( + events.borrow()[0].input, + PresenterInput::HostReady { .. } + )); + drop(state); + release_captured_iced_host(window_id); + } + + #[test] + fn transient_tree_state_drop_preserves_slot_generation_and_readiness() { + let window_id = window::Id::unique(); + capture(window_id); + let events = Rc::new(RefCell::new(Vec::new())); + let events_for_callback = Rc::clone(&events); + let handle = NativeVideoSlotHandle::new( + window_id, + identity(), + move |_host, envelope| { + events_for_callback.borrow_mut().push(envelope.input); + NativeVideoSlotDirective::IDLE + }, + ); + let slot = NativeVideoSlot::new(handle.clone(), |_| ()); + let mut tree = Tree::new(&slot as &dyn Widget<(), (), iced::Renderer>); + let bounds = Rectangle::new(Point::ORIGIN, Size::new(1280.0, 720.0)); + + assert_eq!( + tree.state + .downcast_mut::() + .redraw(bounds, bounds, None), + NativeVideoSlotDirective::IDLE + ); + drop(tree); + assert!(!handle.is_detached()); + + assert_eq!( + handle.notify(PresenterInput::VideoOutputReady), + NativeVideoSlotDirective::IDLE + ); + assert!(matches!( + events.borrow().as_slice(), + [ + PresenterInput::HostReady { .. }, + PresenterInput::VideoOutputReady + ] + )); + + assert_eq!(handle.detach(), NativeVideoSlotDirective::IDLE); + assert!(handle.is_detached()); + assert!(matches!( + events.borrow().as_slice(), + [ + PresenterInput::HostReady { .. }, + PresenterInput::VideoOutputReady, + PresenterInput::Detach + ] + )); + release_captured_iced_host(window_id); + } + + #[test] + fn diff_to_a_real_replacement_explicitly_detaches_old_generation() { + let window_id = window::Id::unique(); + let events = Rc::new(RefCell::new(Vec::new())); + let old_events = Rc::clone(&events); + let old_handle = NativeVideoSlotHandle::new( + window_id, + identity(), + move |_host, envelope| { + old_events.borrow_mut().push(envelope.input); + NativeVideoSlotDirective::IDLE + }, + ); + let old_slot = NativeVideoSlot::new(old_handle.clone(), |_| ()); + let mut tree = + Tree::new(&old_slot as &dyn Widget<(), (), iced::Renderer>); + + let replacement = NativeVideoSlotHandle::new( + window_id, + PresenterIdentity::new( + SessionGeneration::new(4), + PresenterGeneration::new(3), + ), + |_host, _envelope| NativeVideoSlotDirective::IDLE, + ); + let replacement_slot = + NativeVideoSlot::new(replacement.clone(), |_| ()); + as Widget<(), (), iced::Renderer>>::diff( + &replacement_slot, + &mut tree, + ); + + assert!(old_handle.is_detached()); + assert!(!replacement.is_detached()); + assert_eq!(events.borrow().as_slice(), &[PresenterInput::Detach]); + } + + #[test] + fn dropped_generations_do_not_accumulate_slot_registrations() { + let window_id = window::Id::unique(); + let handle = NativeVideoSlotHandle::new( + window_id, + identity(), + |_host, _envelope| NativeVideoSlotDirective::IDLE, + ); + assert_eq!( + ICED_NATIVE_VIDEO_SLOTS.with(|slots| slots + .borrow() + .get(&window_id) + .map_or(0, Vec::len)), + 1 + ); + + drop(handle); + assert_eq!( + ICED_NATIVE_VIDEO_SLOTS.with(|slots| slots + .borrow() + .get(&window_id) + .map_or(0, Vec::len)), + 0 + ); + } + + #[test] + fn explicit_host_close_detaches_all_slots_before_releasing_host() { + let window_id = window::Id::unique(); + capture(window_id); + let events = Rc::new(RefCell::new(Vec::new())); + + let first_events = Rc::clone(&events); + let first = NativeVideoSlotHandle::new( + window_id, + identity(), + move |host, envelope| { + first_events + .borrow_mut() + .push((host.is_some(), envelope.input)); + NativeVideoSlotDirective::IDLE + }, + ); + let second_events = Rc::clone(&events); + let second = NativeVideoSlotHandle::new( + window_id, + PresenterIdentity::new( + SessionGeneration::new(5), + PresenterGeneration::new(3), + ), + move |host, envelope| { + second_events + .borrow_mut() + .push((host.is_some(), envelope.input)); + NativeVideoSlotDirective::IDLE + }, + ); + + let result = prepare_iced_native_host_close(window_id); + assert_eq!( + result, + NativeHostCloseResult { + detached_slots: 2, + released_host: true, + } + ); + assert!(first.is_detached()); + assert!(second.is_detached()); + assert!(!has_captured_iced_host(window_id)); + assert_eq!( + events.borrow().as_slice(), + &[ + (true, PresenterInput::Detach), + (true, PresenterInput::Detach), + ] + ); + + assert_eq!( + prepare_iced_native_host_close(window_id), + NativeHostCloseResult { + detached_slots: 0, + released_host: false, + } + ); + } +} diff --git a/crates/ferrex-player-playback/src/presenter.rs b/crates/ferrex-player-playback/src/presenter.rs new file mode 100644 index 00000000..33e99e67 --- /dev/null +++ b/crates/ferrex-player-playback/src/presenter.rs @@ -0,0 +1,1469 @@ +//! Platform-neutral native presenter lifecycle and geometry model. +//! +//! This module deliberately contains no raw window-system types. A widget or +//! event-loop-local platform adapter keeps native host/VO objects locally and +//! executes [`crate::presenter::PresenterCommand`] values on the UI thread. +//! Consequently, [`crate::presenter::NativePresenter`] does not require its +//! host resource or implementation to +//! be `Send`. + +use crate::contract::{ + BackendKind, FallbackReason, FallbackReasonCode, PlaybackError, + PlaybackErrorKind, PlaybackTarget, PresenterEvent, PresenterState, + SessionGeneration, +}; +pub use crate::contract::{ + GeometryRevision, LogicalRect, SurfaceGeometry, SurfaceGeometryError, +}; + +/// Monotonically increasing identity for one presenter attachment attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PresenterGeneration(u64); + +impl PresenterGeneration { + /// First valid presenter generation. + pub const INITIAL: Self = Self(1); + + /// Construct a presenter generation. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Return the numeric generation. + pub const fn get(self) -> u64 { + self.0 + } + + /// Advance without wrapping. + pub fn next(self) -> Option { + self.0.checked_add(1).map(Self) + } +} + +/// Session and presenter generations that scope every lifecycle operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PresenterIdentity { + /// Playback session that owns the video output. + pub session: SessionGeneration, + /// Attachment attempt within/across host window recreation. + pub presenter: PresenterGeneration, +} + +impl PresenterIdentity { + /// Construct one scoped presenter identity. + pub const fn new( + session: SessionGeneration, + presenter: PresenterGeneration, + ) -> Self { + Self { session, presenter } + } +} + +/// Native object that owns confirmed fullscreen state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FullscreenOwner { + /// The Iced host/top-level owns fullscreen (for example, Wayland). + HostWindow, + /// mpv's native root video window owns fullscreen. + VideoOutput, +} + +/// Capabilities reported by a platform presenter implementation. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PresenterCapabilities { + pub integrated_overlay: bool, + pub embedded_surface: bool, + pub native_hdr: bool, + pub fractional_scaling: bool, + pub native_window_fallback: bool, + pub fullscreen_owner: Option, + pub compositor_requirement: Option, +} + +/// UI-thread-local native presenter interface. +/// +/// The generic associated host may be a borrowed `Rc`/AppKit/Wayland value and +/// intentionally has no `Send` bound. +pub trait NativePresenter { + /// Borrowed host representation used only while attaching. + type Host<'host> + where + Self: 'host; + + fn attach( + &mut self, + identity: PresenterIdentity, + host: Self::Host<'_>, + ) -> Result<(), PlaybackError>; + + fn synchronize( + &mut self, + identity: PresenterIdentity, + geometry: SurfaceGeometry, + ) -> Result<(), PlaybackError>; + + fn set_visible( + &mut self, + identity: PresenterIdentity, + visible: bool, + ) -> Result<(), PlaybackError>; + + fn set_suspended( + &mut self, + identity: PresenterIdentity, + suspended: bool, + ) -> Result<(), PlaybackError>; + + fn set_fullscreen( + &mut self, + identity: PresenterIdentity, + owner: FullscreenOwner, + fullscreen: bool, + ) -> Result<(), PlaybackError>; + + fn detach(&mut self, identity: PresenterIdentity); + + fn capabilities(&self) -> &PresenterCapabilities; +} + +/// Event-loop-local operation emitted by [`PresenterLifecycle`]. +#[derive(Debug, Clone, PartialEq)] +pub enum PresenterCommand { + Attach { + identity: PresenterIdentity, + }, + Synchronize { + identity: PresenterIdentity, + geometry: SurfaceGeometry, + }, + SetVisible { + identity: PresenterIdentity, + visible: bool, + }, + SetSuspended { + identity: PresenterIdentity, + suspended: bool, + }, + SetFullscreen { + identity: PresenterIdentity, + owner: FullscreenOwner, + fullscreen: bool, + }, + Detach { + identity: PresenterIdentity, + }, +} + +/// Host, VO, or user input consumed by the presenter lifecycle reducer. +#[derive(Debug, Clone, PartialEq)] +pub enum PresenterInput { + HostReady { geometry: SurfaceGeometry }, + VideoOutputReady, + GeometryChanged(SurfaceGeometry), + HostVisibilityChanged(bool), + SuspensionChanged(bool), + FullscreenRequested(bool), + FullscreenConfirmed(bool), + HostLost, + VideoOutputLost, + Detach, + Failed(PlaybackError), +} + +/// Generation-scoped presenter input. +#[derive(Debug, Clone, PartialEq)] +pub struct PresenterInputEnvelope { + pub identity: PresenterIdentity, + pub input: PresenterInput, +} + +impl PresenterInputEnvelope { + /// Wrap one input with its attachment identity. + pub const fn new( + identity: PresenterIdentity, + input: PresenterInput, + ) -> Self { + Self { identity, input } + } +} + +/// Side effect produced by the pure lifecycle model. +#[derive(Debug, Clone, PartialEq)] +pub enum PresenterEffect { + /// Execute on the native UI/event-loop thread. + Command(PresenterCommand), + /// Wrap in `PlaybackEvent::Presenter` for snapshot reduction. + Event(PresenterEvent), +} + +/// Why a presenter input did or did not mutate lifecycle state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PresenterDisposition { + Applied, + IgnoredStaleGeneration, + IgnoredDuplicateOrOutOfOrderGeometry, + IgnoredNoChange, + Unsupported, +} + +/// Result of reducing one presenter input. +#[derive(Debug, Clone, PartialEq)] +pub struct PresenterTransition { + pub disposition: PresenterDisposition, + pub effects: Vec, +} + +impl PresenterTransition { + fn new( + disposition: PresenterDisposition, + effects: Vec, + ) -> Self { + Self { + disposition, + effects, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GeometryAcceptance { + Changed, + Equivalent, + Stale, +} + +/// Deterministic host/VO readiness, geometry, visibility, and teardown model. +#[derive(Debug, Clone)] +pub struct PresenterLifecycle { + identity: PresenterIdentity, + source_target: PlaybackTarget, + fallback_target: PlaybackTarget, + capabilities: PresenterCapabilities, + state: PresenterState, + host_ready: bool, + video_output_ready: bool, + attached: bool, + attached_once: bool, + explicitly_detached: bool, + geometry: Option, + host_visible: bool, + suspended: bool, + commanded_visible: Option, + commanded_suspended: Option, + actual_fullscreen: bool, + requested_fullscreen: Option, + commanded_fullscreen: Option, + initial_host_visible: bool, + failed: bool, +} + +impl PresenterLifecycle { + /// Create one presenter attempt with an explicit deterministic fallback. + pub fn new( + identity: PresenterIdentity, + source_target: PlaybackTarget, + fallback_target: PlaybackTarget, + capabilities: PresenterCapabilities, + initial_host_visible: bool, + ) -> Self { + Self { + identity, + source_target, + fallback_target, + capabilities, + state: PresenterState::Detached, + host_ready: false, + video_output_ready: false, + attached: false, + attached_once: false, + explicitly_detached: false, + geometry: None, + host_visible: initial_host_visible, + suspended: false, + commanded_visible: None, + commanded_suspended: None, + actual_fullscreen: false, + requested_fullscreen: None, + commanded_fullscreen: None, + initial_host_visible, + failed: false, + } + } + + pub const fn identity(&self) -> PresenterIdentity { + self.identity + } + + pub const fn state(&self) -> PresenterState { + self.state + } + + pub const fn geometry(&self) -> Option { + self.geometry + } + + pub const fn actual_fullscreen(&self) -> bool { + self.actual_fullscreen + } + + pub const fn requested_fullscreen(&self) -> Option { + self.requested_fullscreen + } + + #[cfg(all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos", test) + ))] + pub(crate) const fn readiness(&self) -> (bool, bool, bool) { + (self.host_ready, self.video_output_ready, self.attached) + } + + pub fn capabilities(&self) -> &PresenterCapabilities { + &self.capabilities + } + + /// Detach an old host and begin a strictly newer attachment generation. + pub fn begin_generation( + &mut self, + identity: PresenterIdentity, + capabilities: PresenterCapabilities, + ) -> PresenterTransition { + if identity <= self.identity { + return PresenterTransition::new( + PresenterDisposition::IgnoredStaleGeneration, + Vec::new(), + ); + } + + let old_identity = self.identity; + let old_state = self.state; + let mut effects = Vec::new(); + if self.attached { + effects.push(PresenterEffect::Command(PresenterCommand::Detach { + identity: old_identity, + })); + } + self.clear_geometry(&mut effects); + + self.identity = identity; + self.capabilities = capabilities; + self.state = PresenterState::Detached; + self.host_ready = false; + self.video_output_ready = false; + self.attached = false; + self.attached_once = false; + self.explicitly_detached = false; + self.geometry = None; + self.host_visible = self.initial_host_visible; + self.suspended = false; + self.commanded_visible = None; + self.commanded_suspended = None; + self.actual_fullscreen = false; + self.requested_fullscreen = None; + self.commanded_fullscreen = None; + self.failed = false; + + if old_state != PresenterState::Detached { + effects.push(PresenterEffect::Event(PresenterEvent::StateChanged( + PresenterState::Detached, + ))); + } + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + + /// Reduce one generation-scoped lifecycle input. + pub fn handle( + &mut self, + envelope: PresenterInputEnvelope, + ) -> PresenterTransition { + if envelope.identity != self.identity { + return PresenterTransition::new( + PresenterDisposition::IgnoredStaleGeneration, + Vec::new(), + ); + } + if self.failed { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + + match envelope.input { + PresenterInput::HostReady { geometry } => { + self.handle_host_ready(geometry) + } + PresenterInput::VideoOutputReady => { + if self.video_output_ready { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + self.video_output_ready = true; + let mut effects = Vec::new(); + self.reconcile(&mut effects); + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + PresenterInput::GeometryChanged(geometry) => { + self.handle_geometry(geometry) + } + PresenterInput::HostVisibilityChanged(visible) => { + if self.host_visible == visible { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + self.host_visible = visible; + let mut effects = Vec::new(); + self.reconcile(&mut effects); + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + PresenterInput::SuspensionChanged(suspended) => { + if self.suspended == suspended { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + self.suspended = suspended; + let mut effects = Vec::new(); + self.reconcile(&mut effects); + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + PresenterInput::FullscreenRequested(fullscreen) => { + self.request_fullscreen(fullscreen) + } + PresenterInput::FullscreenConfirmed(fullscreen) => { + self.confirm_fullscreen(fullscreen) + } + PresenterInput::HostLost => self.lose_host(), + PresenterInput::VideoOutputLost => self.lose_video_output(), + PresenterInput::Detach => self.detach(), + PresenterInput::Failed(error) => self.fail(error), + } + } + + fn handle_host_ready( + &mut self, + geometry: SurfaceGeometry, + ) -> PresenterTransition { + let was_ready = self.host_ready; + let acceptance = match self.accept_geometry(geometry) { + Ok(acceptance) => acceptance, + Err(error) => return self.fail(error), + }; + self.host_ready = true; + + let mut effects = Vec::new(); + if acceptance == GeometryAcceptance::Changed { + effects.push(PresenterEffect::Event( + PresenterEvent::GeometryChanged(Some(geometry)), + )); + if self.attached { + effects.push(PresenterEffect::Command( + PresenterCommand::Synchronize { + identity: self.identity, + geometry, + }, + )); + } + } + self.reconcile(&mut effects); + + let disposition = if acceptance == GeometryAcceptance::Stale + && was_ready + && effects.is_empty() + { + PresenterDisposition::IgnoredDuplicateOrOutOfOrderGeometry + } else if was_ready + && acceptance == GeometryAcceptance::Equivalent + && effects.is_empty() + { + PresenterDisposition::IgnoredNoChange + } else { + PresenterDisposition::Applied + }; + PresenterTransition::new(disposition, effects) + } + + fn handle_geometry( + &mut self, + geometry: SurfaceGeometry, + ) -> PresenterTransition { + let acceptance = match self.accept_geometry(geometry) { + Ok(acceptance) => acceptance, + Err(error) => return self.fail(error), + }; + if acceptance == GeometryAcceptance::Stale { + return PresenterTransition::new( + PresenterDisposition::IgnoredDuplicateOrOutOfOrderGeometry, + Vec::new(), + ); + } + + let mut effects = Vec::new(); + if acceptance == GeometryAcceptance::Changed { + effects.push(PresenterEffect::Event( + PresenterEvent::GeometryChanged(Some(geometry)), + )); + if self.attached { + effects.push(PresenterEffect::Command( + PresenterCommand::Synchronize { + identity: self.identity, + geometry, + }, + )); + } + } + self.reconcile(&mut effects); + PresenterTransition::new( + if acceptance == GeometryAcceptance::Equivalent + && effects.is_empty() + { + PresenterDisposition::IgnoredNoChange + } else { + PresenterDisposition::Applied + }, + effects, + ) + } + + fn accept_geometry( + &mut self, + geometry: SurfaceGeometry, + ) -> Result { + geometry.validate().map_err(|error| { + self.presenter_error(format!( + "native presenter rejected host geometry: {error}" + )) + })?; + + let Some(previous) = self.geometry else { + self.geometry = Some(geometry); + return Ok(GeometryAcceptance::Changed); + }; + if geometry.revision <= previous.revision { + return Ok(GeometryAcceptance::Stale); + } + + let acceptance = if previous.same_layout(geometry) { + GeometryAcceptance::Equivalent + } else { + GeometryAcceptance::Changed + }; + self.geometry = Some(geometry); + Ok(acceptance) + } + + fn request_fullscreen(&mut self, fullscreen: bool) -> PresenterTransition { + let Some(_) = self.capabilities.fullscreen_owner else { + return PresenterTransition::new( + PresenterDisposition::Unsupported, + Vec::new(), + ); + }; + if self.requested_fullscreen == Some(fullscreen) + || (self.requested_fullscreen.is_none() + && self.actual_fullscreen == fullscreen) + { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + + self.requested_fullscreen = Some(fullscreen); + let mut effects = Vec::new(); + self.reconcile_fullscreen(&mut effects); + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + + fn confirm_fullscreen(&mut self, fullscreen: bool) -> PresenterTransition { + if self.actual_fullscreen == fullscreen + && self.requested_fullscreen.is_none() + { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + + let actual_changed = self.actual_fullscreen != fullscreen; + let pending_matches_confirmation = + self.requested_fullscreen == Some(fullscreen); + let had_no_pending_request = self.requested_fullscreen.is_none(); + self.actual_fullscreen = fullscreen; + if pending_matches_confirmation || had_no_pending_request { + self.requested_fullscreen = None; + self.commanded_fullscreen = None; + } + + let mut effects = Vec::new(); + if actual_changed { + effects.push(PresenterEffect::Event( + PresenterEvent::FullscreenChanged(fullscreen), + )); + } + // An initial or stale opposite property observation must not consume a + // request made before attach. Once attached, reconcile it immediately; + // otherwise the ordinary host/VO readiness transition will do so. + self.reconcile_fullscreen(&mut effects); + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + + fn lose_host(&mut self) -> PresenterTransition { + if !self.host_ready { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + self.host_ready = false; + let mut effects = Vec::new(); + self.detach_platform(&mut effects); + self.clear_geometry(&mut effects); + self.reconcile(&mut effects); + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + + fn lose_video_output(&mut self) -> PresenterTransition { + if !self.video_output_ready { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + self.video_output_ready = false; + let mut effects = Vec::new(); + self.detach_platform(&mut effects); + self.reconcile(&mut effects); + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + + fn detach(&mut self) -> PresenterTransition { + if self.explicitly_detached && self.state == PresenterState::Detached { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + + let mut effects = Vec::new(); + self.detach_platform(&mut effects); + self.clear_geometry(&mut effects); + self.host_ready = false; + self.video_output_ready = false; + self.explicitly_detached = true; + self.requested_fullscreen = None; + self.commanded_fullscreen = None; + self.set_state(PresenterState::Detached, &mut effects); + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + + fn fail(&mut self, mut error: PlaybackError) -> PresenterTransition { + if self.failed { + return PresenterTransition::new( + PresenterDisposition::IgnoredNoChange, + Vec::new(), + ); + } + + error.kind = PlaybackErrorKind::Presenter; + error.backend.get_or_insert(self.source_target.backend); + error.recoverable = true; + + let mut effects = Vec::new(); + self.detach_platform(&mut effects); + self.clear_geometry(&mut effects); + self.failed = true; + self.state = PresenterState::Failed; + let reason = FallbackReason { + code: FallbackReasonCode::PresenterFailed, + from: Some(self.source_target), + to: self.fallback_target, + detail: error.message.clone(), + }; + effects.push(PresenterEffect::Event(PresenterEvent::Failure(error))); + effects.push(PresenterEffect::Event( + PresenterEvent::FallbackRequested(reason), + )); + PresenterTransition::new(PresenterDisposition::Applied, effects) + } + + fn reconcile(&mut self, effects: &mut Vec) { + if self.failed { + return; + } + + if !self.host_ready || !self.video_output_ready { + let state = match (self.host_ready, self.video_output_ready) { + (true, false) => PresenterState::AwaitingVideoOutput, + (false, true) => PresenterState::AwaitingHost, + (false, false) => PresenterState::Detached, + (true, true) => unreachable!(), + }; + self.set_state(state, effects); + return; + } + + if !self.attached { + if self.attached_once || self.explicitly_detached { + let error = self.presenter_error( + "presenter host or video output was recreated without a new generation", + ); + self.append_failure(error, effects); + return; + } + + self.attached = true; + self.attached_once = true; + self.commanded_suspended = (!self.suspended).then_some(false); + effects.push(PresenterEffect::Command(PresenterCommand::Attach { + identity: self.identity, + })); + if let Some(geometry) = self.geometry { + effects.push(PresenterEffect::Command( + PresenterCommand::Synchronize { + identity: self.identity, + geometry, + }, + )); + } + } + + self.reconcile_suspension_and_visibility(effects); + self.reconcile_fullscreen(effects); + } + + fn reconcile_suspension_and_visibility( + &mut self, + effects: &mut Vec, + ) { + if !self.attached { + return; + } + + if self.commanded_suspended != Some(self.suspended) { + effects.push(PresenterEffect::Command( + PresenterCommand::SetSuspended { + identity: self.identity, + suspended: self.suspended, + }, + )); + self.commanded_suspended = Some(self.suspended); + } + + let visible = !self.suspended + && self.host_visible + && self.geometry.is_some_and(SurfaceGeometry::is_visible); + if self.commanded_visible != Some(visible) { + effects.push(PresenterEffect::Command( + PresenterCommand::SetVisible { + identity: self.identity, + visible, + }, + )); + self.commanded_visible = Some(visible); + } + + let state = if self.suspended { + PresenterState::Suspended + } else if visible { + PresenterState::Attached + } else { + PresenterState::Hidden + }; + self.set_state(state, effects); + } + + fn reconcile_fullscreen(&mut self, effects: &mut Vec) { + if !self.attached { + return; + } + let (Some(fullscreen), Some(owner)) = ( + self.requested_fullscreen, + self.capabilities.fullscreen_owner, + ) else { + return; + }; + if self.commanded_fullscreen == Some(fullscreen) { + return; + } + + effects.push(PresenterEffect::Command( + PresenterCommand::SetFullscreen { + identity: self.identity, + owner, + fullscreen, + }, + )); + self.commanded_fullscreen = Some(fullscreen); + } + + fn detach_platform(&mut self, effects: &mut Vec) { + if self.attached { + effects.push(PresenterEffect::Command(PresenterCommand::Detach { + identity: self.identity, + })); + } + self.attached = false; + self.commanded_visible = None; + self.commanded_suspended = None; + self.commanded_fullscreen = None; + } + + fn clear_geometry(&mut self, effects: &mut Vec) { + if self.geometry.take().is_some() { + effects.push(PresenterEffect::Event( + PresenterEvent::GeometryChanged(None), + )); + } + } + + fn set_state( + &mut self, + state: PresenterState, + effects: &mut Vec, + ) { + if self.state == state { + return; + } + self.state = state; + effects + .push(PresenterEffect::Event(PresenterEvent::StateChanged(state))); + } + + fn append_failure( + &mut self, + mut error: PlaybackError, + effects: &mut Vec, + ) { + error.kind = PlaybackErrorKind::Presenter; + error.backend.get_or_insert(self.source_target.backend); + error.recoverable = true; + self.detach_platform(effects); + self.clear_geometry(effects); + self.failed = true; + self.state = PresenterState::Failed; + let reason = FallbackReason { + code: FallbackReasonCode::PresenterFailed, + from: Some(self.source_target), + to: self.fallback_target, + detail: error.message.clone(), + }; + effects.push(PresenterEffect::Event(PresenterEvent::Failure(error))); + effects.push(PresenterEffect::Event( + PresenterEvent::FallbackRequested(reason), + )); + } + + fn presenter_error(&self, message: impl Into) -> PlaybackError { + let mut error = + PlaybackError::new(PlaybackErrorKind::Presenter, message); + error.backend = Some(match self.source_target.backend { + BackendKind::GStreamer => BackendKind::GStreamer, + BackendKind::Mpv => BackendKind::Mpv, + BackendKind::ExternalMpv => BackendKind::ExternalMpv, + }); + error.recoverable = true; + error + } +} + +#[cfg(test)] +mod tests { + use std::{cell::Cell, rc::Rc}; + + use super::*; + + fn identity(presenter: u64) -> PresenterIdentity { + PresenterIdentity::new( + SessionGeneration::new(7), + PresenterGeneration::new(presenter), + ) + } + + fn capabilities() -> PresenterCapabilities { + PresenterCapabilities { + integrated_overlay: true, + native_window_fallback: true, + fullscreen_owner: Some(FullscreenOwner::VideoOutput), + ..PresenterCapabilities::default() + } + } + + fn geometry(revision: u64) -> SurfaceGeometry { + SurfaceGeometry::new( + GeometryRevision::new(revision), + LogicalRect::new(10.0, 20.0, 1280.0, 720.0), + Some(LogicalRect::new(10.0, 20.0, 1280.0, 720.0)), + 1.0, + ) + } + + fn lifecycle() -> PresenterLifecycle { + PresenterLifecycle::new( + identity(1), + PlaybackTarget::MPV_INTEGRATED, + PlaybackTarget::MPV_NATIVE_WINDOW, + capabilities(), + true, + ) + } + + fn input( + lifecycle: &mut PresenterLifecycle, + input: PresenterInput, + ) -> PresenterTransition { + lifecycle + .handle(PresenterInputEnvelope::new(lifecycle.identity(), input)) + } + + fn command_count( + transition: &PresenterTransition, + predicate: impl Fn(&PresenterCommand) -> bool, + ) -> usize { + transition + .effects + .iter() + .filter(|effect| { + matches!(effect, PresenterEffect::Command(command) if predicate(command)) + }) + .count() + } + + fn geometry_events( + transition: &PresenterTransition, + ) -> Vec> { + transition + .effects + .iter() + .filter_map(|effect| match effect { + PresenterEffect::Event(PresenterEvent::GeometryChanged( + geometry, + )) => Some(*geometry), + _ => None, + }) + .collect() + } + + #[test] + fn host_and_video_output_can_arrive_in_either_order_and_attach_once() { + let mut host_first = lifecycle(); + let host = input( + &mut host_first, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + assert_eq!(host_first.state(), PresenterState::AwaitingVideoOutput); + assert_eq!(geometry_events(&host), vec![Some(geometry(1))]); + assert_eq!( + command_count(&host, |command| matches!( + command, + PresenterCommand::Attach { .. } + )), + 0 + ); + let video = input(&mut host_first, PresenterInput::VideoOutputReady); + assert_eq!(host_first.state(), PresenterState::Attached); + assert_eq!( + command_count(&video, |command| matches!( + command, + PresenterCommand::Attach { .. } + )), + 1 + ); + let duplicate = + input(&mut host_first, PresenterInput::VideoOutputReady); + assert_eq!( + duplicate.disposition, + PresenterDisposition::IgnoredNoChange + ); + + let mut video_first = lifecycle(); + input(&mut video_first, PresenterInput::VideoOutputReady); + assert_eq!(video_first.state(), PresenterState::AwaitingHost); + let host = input( + &mut video_first, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + assert_eq!(video_first.state(), PresenterState::Attached); + assert_eq!( + command_count(&host, |command| matches!( + command, + PresenterCommand::Attach { .. } + )), + 1 + ); + } + + #[test] + fn initially_hidden_host_attaches_without_becoming_visible_until_handoff() { + let mut lifecycle = PresenterLifecycle::new( + identity(1), + PlaybackTarget::MPV_INTEGRATED, + PlaybackTarget::MPV_NATIVE_WINDOW, + capabilities(), + false, + ); + input( + &mut lifecycle, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + let attached = input(&mut lifecycle, PresenterInput::VideoOutputReady); + + assert_eq!(lifecycle.state(), PresenterState::Hidden); + assert_eq!( + command_count(&attached, |command| matches!( + command, + PresenterCommand::SetVisible { visible: false, .. } + )), + 1 + ); + assert_eq!( + command_count(&attached, |command| matches!( + command, + PresenterCommand::SetVisible { visible: true, .. } + )), + 0 + ); + + let shown = + input(&mut lifecycle, PresenterInput::HostVisibilityChanged(true)); + assert_eq!(lifecycle.state(), PresenterState::Attached); + assert_eq!( + command_count(&shown, |command| matches!( + command, + PresenterCommand::SetVisible { visible: true, .. } + )), + 1 + ); + } + + #[test] + fn visibility_handoff_survives_transient_suspension() { + let mut lifecycle = PresenterLifecycle::new( + identity(1), + PlaybackTarget::MPV_INTEGRATED, + PlaybackTarget::MPV_NATIVE_WINDOW, + capabilities(), + false, + ); + input( + &mut lifecycle, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + input(&mut lifecycle, PresenterInput::VideoOutputReady); + input(&mut lifecycle, PresenterInput::SuspensionChanged(true)); + + let handoff = + input(&mut lifecycle, PresenterInput::HostVisibilityChanged(true)); + assert_eq!(lifecycle.state(), PresenterState::Suspended); + assert_eq!( + command_count(&handoff, |command| matches!( + command, + PresenterCommand::SetVisible { visible: true, .. } + )), + 0 + ); + + let resumed = + input(&mut lifecycle, PresenterInput::SuspensionChanged(false)); + assert_eq!(lifecycle.state(), PresenterState::Attached); + assert_eq!( + command_count(&resumed, |command| matches!( + command, + PresenterCommand::SetVisible { visible: true, .. } + )), + 1 + ); + } + + #[test] + fn duplicate_geometry_is_suppressed_but_scale_changes_are_synchronized() { + let mut lifecycle = lifecycle(); + input( + &mut lifecycle, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + input(&mut lifecycle, PresenterInput::VideoOutputReady); + + let mut scaled = geometry(2); + scaled.scale_factor = 1.5; + let transition = + input(&mut lifecycle, PresenterInput::GeometryChanged(scaled)); + assert_eq!( + command_count(&transition, |command| matches!( + command, + PresenterCommand::Synchronize { .. } + )), + 1 + ); + assert_eq!(geometry_events(&transition), vec![Some(scaled)]); + + let stale = + input(&mut lifecycle, PresenterInput::GeometryChanged(scaled)); + assert_eq!( + stale.disposition, + PresenterDisposition::IgnoredDuplicateOrOutOfOrderGeometry + ); + assert!(stale.effects.is_empty()); + + let mut equivalent = scaled; + equivalent.revision = GeometryRevision::new(3); + let equivalent = + input(&mut lifecycle, PresenterInput::GeometryChanged(equivalent)); + assert_eq!( + equivalent.disposition, + PresenterDisposition::IgnoredNoChange + ); + assert_eq!( + command_count(&equivalent, |command| matches!( + command, + PresenterCommand::Synchronize { .. } + )), + 0 + ); + } + + #[test] + fn clipping_zero_size_visibility_and_suspension_are_explicit() { + let mut lifecycle = lifecycle(); + input( + &mut lifecycle, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + input(&mut lifecycle, PresenterInput::VideoOutputReady); + + let mut clipped = geometry(2); + clipped.visible_bounds = None; + let hidden = + input(&mut lifecycle, PresenterInput::GeometryChanged(clipped)); + assert_eq!(lifecycle.state(), PresenterState::Hidden); + assert_eq!( + command_count(&hidden, |command| matches!( + command, + PresenterCommand::SetVisible { visible: false, .. } + )), + 1 + ); + + let shown = + input(&mut lifecycle, PresenterInput::GeometryChanged(geometry(3))); + assert_eq!(lifecycle.state(), PresenterState::Attached); + assert_eq!( + command_count(&shown, |command| matches!( + command, + PresenterCommand::SetVisible { visible: true, .. } + )), + 1 + ); + + let mut zero_sized = geometry(4); + zero_sized.logical_bounds.width = 0.0; + zero_sized.visible_bounds = + Some(LogicalRect::new(10.0, 20.0, 0.0, 720.0)); + input(&mut lifecycle, PresenterInput::GeometryChanged(zero_sized)); + assert_eq!(lifecycle.state(), PresenterState::Hidden); + + input(&mut lifecycle, PresenterInput::GeometryChanged(geometry(5))); + assert_eq!(lifecycle.state(), PresenterState::Attached); + + let suspended = + input(&mut lifecycle, PresenterInput::SuspensionChanged(true)); + assert_eq!(lifecycle.state(), PresenterState::Suspended); + assert_eq!( + command_count(&suspended, |command| matches!( + command, + PresenterCommand::SetSuspended { + suspended: true, + .. + } + )), + 1 + ); + assert_eq!( + command_count(&suspended, |command| matches!( + command, + PresenterCommand::SetVisible { visible: false, .. } + )), + 1 + ); + } + + #[test] + fn window_recreation_requires_a_new_generation_and_rejects_stale_events() { + let mut lifecycle = lifecycle(); + input( + &mut lifecycle, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + input(&mut lifecycle, PresenterInput::VideoOutputReady); + + let replacement = + lifecycle.begin_generation(identity(2), capabilities()); + assert_eq!( + command_count(&replacement, |command| matches!( + command, + PresenterCommand::Detach { + identity: detached, + } if *detached == identity(1) + )), + 1 + ); + assert_eq!(geometry_events(&replacement), vec![None]); + assert_eq!(lifecycle.state(), PresenterState::Detached); + + let stale = lifecycle.handle(PresenterInputEnvelope::new( + identity(1), + PresenterInput::GeometryChanged(geometry(2)), + )); + assert_eq!( + stale.disposition, + PresenterDisposition::IgnoredStaleGeneration + ); + assert!(stale.effects.is_empty()); + + input( + &mut lifecycle, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + let attached = input(&mut lifecycle, PresenterInput::VideoOutputReady); + assert_eq!( + command_count(&attached, |command| matches!( + command, + PresenterCommand::Attach { + identity: attached, + } if *attached == identity(2) + )), + 1 + ); + } + + #[test] + fn fullscreen_changes_only_after_native_confirmation() { + let mut lifecycle = lifecycle(); + input( + &mut lifecycle, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + input(&mut lifecycle, PresenterInput::VideoOutputReady); + + let requested = + input(&mut lifecycle, PresenterInput::FullscreenRequested(true)); + assert!(!lifecycle.actual_fullscreen()); + assert_eq!(lifecycle.requested_fullscreen(), Some(true)); + assert_eq!( + command_count(&requested, |command| matches!( + command, + PresenterCommand::SetFullscreen { + owner: FullscreenOwner::VideoOutput, + fullscreen: true, + .. + } + )), + 1 + ); + assert!(!requested.effects.iter().any(|effect| matches!( + effect, + PresenterEffect::Event(PresenterEvent::FullscreenChanged(_)) + ))); + + let confirmed = + input(&mut lifecycle, PresenterInput::FullscreenConfirmed(true)); + assert!(lifecycle.actual_fullscreen()); + assert_eq!(lifecycle.requested_fullscreen(), None); + assert!(confirmed.effects.contains(&PresenterEffect::Event( + PresenterEvent::FullscreenChanged(true) + ))); + } + + #[test] + fn opposite_initial_fullscreen_observation_preserves_pre_attach_request() { + let mut lifecycle = lifecycle(); + let requested = + input(&mut lifecycle, PresenterInput::FullscreenRequested(true)); + assert!(requested.effects.is_empty()); + assert_eq!(lifecycle.requested_fullscreen(), Some(true)); + + let initial = + input(&mut lifecycle, PresenterInput::FullscreenConfirmed(false)); + assert!(initial.effects.is_empty()); + assert_eq!(lifecycle.requested_fullscreen(), Some(true)); + + input( + &mut lifecycle, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + let attached = input(&mut lifecycle, PresenterInput::VideoOutputReady); + assert_eq!( + command_count(&attached, |command| matches!( + command, + PresenterCommand::SetFullscreen { + owner: FullscreenOwner::VideoOutput, + fullscreen: true, + .. + } + )), + 1 + ); + assert_eq!(lifecycle.requested_fullscreen(), Some(true)); + + input(&mut lifecycle, PresenterInput::FullscreenConfirmed(true)); + assert_eq!(lifecycle.requested_fullscreen(), None); + assert!(lifecycle.actual_fullscreen()); + } + + #[test] + fn presenter_failure_detaches_and_requests_deterministic_fallback() { + let mut lifecycle = lifecycle(); + input( + &mut lifecycle, + PresenterInput::HostReady { + geometry: geometry(1), + }, + ); + input(&mut lifecycle, PresenterInput::VideoOutputReady); + + let transition = input( + &mut lifecycle, + PresenterInput::Failed(PlaybackError::new( + PlaybackErrorKind::Protocol, + "native overlay relationship was lost", + )), + ); + assert_eq!(lifecycle.state(), PresenterState::Failed); + assert_eq!( + command_count(&transition, |command| matches!( + command, + PresenterCommand::Detach { .. } + )), + 1 + ); + assert_eq!(geometry_events(&transition), vec![None]); + assert!(transition.effects.iter().any(|effect| matches!( + effect, + PresenterEffect::Event(PresenterEvent::Failure(error)) + if error.kind == PlaybackErrorKind::Presenter + && error.backend == Some(BackendKind::Mpv) + && error.recoverable + ))); + assert!(transition.effects.iter().any(|effect| matches!( + effect, + PresenterEffect::Event(PresenterEvent::FallbackRequested(reason)) + if reason.code == FallbackReasonCode::PresenterFailed + && reason.from == Some(PlaybackTarget::MPV_INTEGRATED) + && reason.to == PlaybackTarget::MPV_NATIVE_WINDOW + ))); + } + + struct LocalHost(Rc<()>); + + struct FakeNativePresenter { + attached: bool, + detached_before_drop: Rc>, + capabilities: PresenterCapabilities, + } + + impl NativePresenter for FakeNativePresenter { + type Host<'host> = &'host LocalHost; + + fn attach( + &mut self, + _identity: PresenterIdentity, + host: Self::Host<'_>, + ) -> Result<(), PlaybackError> { + let _ = Rc::strong_count(&host.0); + self.attached = true; + self.detached_before_drop.set(false); + Ok(()) + } + + fn synchronize( + &mut self, + _identity: PresenterIdentity, + _geometry: SurfaceGeometry, + ) -> Result<(), PlaybackError> { + Ok(()) + } + + fn set_visible( + &mut self, + _identity: PresenterIdentity, + _visible: bool, + ) -> Result<(), PlaybackError> { + Ok(()) + } + + fn set_suspended( + &mut self, + _identity: PresenterIdentity, + _suspended: bool, + ) -> Result<(), PlaybackError> { + Ok(()) + } + + fn set_fullscreen( + &mut self, + _identity: PresenterIdentity, + _owner: FullscreenOwner, + _fullscreen: bool, + ) -> Result<(), PlaybackError> { + Ok(()) + } + + fn detach(&mut self, _identity: PresenterIdentity) { + self.attached = false; + self.detached_before_drop.set(true); + } + + fn capabilities(&self) -> &PresenterCapabilities { + &self.capabilities + } + } + + impl Drop for FakeNativePresenter { + fn drop(&mut self) { + assert!(!self.attached, "presenter dropped while still attached"); + } + } + + #[test] + fn local_only_host_is_explicitly_detached_before_presenter_drop() { + let detached = Rc::new(Cell::new(false)); + let host = LocalHost(Rc::new(())); + let mut presenter = FakeNativePresenter { + attached: false, + detached_before_drop: Rc::clone(&detached), + capabilities: capabilities(), + }; + + presenter.attach(identity(1), &host).unwrap(); + assert!(presenter.attached); + presenter.detach(identity(1)); + assert!(detached.get()); + drop(presenter); + } +} diff --git a/crates/ferrex-player-playback/src/state.rs b/crates/ferrex-player-playback/src/state.rs index 2e8a0fc2..195f31d2 100644 --- a/crates/ferrex-player-playback/src/state.rs +++ b/crates/ferrex-player-playback/src/state.rs @@ -4,15 +4,22 @@ //! UI/control flags so reducers and app shells can reason about playback without //! coupling every caller to the concrete video backend. -use crate::diagnostics::redact_playback_url; +use crate::{ + contract::{ + AudioTrack, BackendKind, BackendRequest, EndReason, + PlaybackCapabilities, PlaybackCommand, PlaybackSnapshot, + PlaybackSource, PlaybackState, PlaybackTarget, SessionGeneration, + SubtitleTrack, TrackId, + }, + diagnostics::{PlaybackDiagnosticSnapshot, redact_playback_url}, + session::PlaybackSession, +}; use ferrex_core::player_prelude::{MediaFile, MediaID}; use iced::ContentFit; use std::{ fmt, time::{Duration, Instant}, }; -use subwave_core::video::types::{AudioTrack, SubtitleTrack}; -use subwave_unified::video::SubwaveVideo; // Seek bar interaction constants /// Visible height of the seek bar in pixels. @@ -25,12 +32,32 @@ pub struct PlayerDomainState { // Current media pub current_media: Option, pub current_media_id: Option, + /// Compatibility URI used by legacy streaming/external-player code. + /// In-process backends consume `current_source` so credentials can travel + /// in headers instead of this URL. pub current_url: Option, + pub current_source: Option, pub is_resolving_stream_url: bool, pub stream_url_resolution_failed: bool, // Video instance (unified) - pub video_opt: Option, + pub video_opt: Option, + pub playback_generation: SessionGeneration, + /// Explicit backend request for the next/current load. Auto preserves the + /// migration default; native-window mpv is opt-in. + pub backend_request: BackendRequest, + /// Prevent repeated terminal handling while a polling subscription remains + /// alive for one final turn of the UI event loop. + pub terminal_generation_handled: Option, + /// Playback generation for which the shell has already received the + /// native-presenter attached handoff. Presenter snapshots can be delivered + /// repeatedly without repeating the window side effect. + pub native_presenter_attached_generation: Option, + /// Playback generation for which the shell has already received the + /// native-presenter unavailable handoff. This remains separate from the + /// attached marker because an attached presenter may still fail over later + /// in the same playback generation. + pub native_presenter_unavailable_generation: Option, // Watch progress tracking pub last_progress_update: Option, @@ -69,10 +96,13 @@ pub struct PlayerDomainState { // Track selection (NEW) pub available_audio_tracks: Vec, - pub current_audio_track: i32, + /// Generation that established the mirrored track catalog. Selection + /// notices are emitted only for later changes in this same generation. + pub track_catalog_generation: Option, + pub current_audio_track: Option, pub available_subtitle_tracks: Vec, - pub current_subtitle_track: Option, - pub last_subtitle_track: Option, + pub current_subtitle_track: Option, + pub last_subtitle_track: Option, pub subtitles_enabled: bool, pub track_notification: Option, @@ -92,9 +122,15 @@ pub struct PlayerDomainState { pub is_loading_video: bool, // Flag to prevent duplicate video loading pub source_duration: Option, // Original source video duration (never changes) + /// Process owner for the explicit legacy external-mpv compatibility path. + /// Playback state exposed to domain/view policy lives in + /// `external_mpv_snapshot`, not in this native handle. pub external_mpv_handle: Option>, - pub external_mpv_active: bool, + /// Backend-neutral projection of the retained external process. Keeping a + /// snapshot beside the process owner lets views, progress persistence, and + /// episode policy use the same state model as in-process backends. + pub external_mpv_snapshot: Option, } impl fmt::Debug for PlayerDomainState { @@ -103,18 +139,34 @@ impl fmt::Debug for PlayerDomainState { .current_url .as_ref() .map(|url| redact_playback_url(url.as_str())); - let video_opt = self.video_opt.as_ref().map(|_| "SubwaveVideo(..)"); + let current_source = self.current_source.as_ref(); + let video_opt = self.video_opt.as_ref().map(|_| "PlaybackSession(..)"); f.debug_struct("PlayerDomainState") .field("current_media", &self.current_media) .field("current_media_id", &self.current_media_id) .field("current_url", ¤t_url) + .field("current_source", ¤t_source) .field("is_resolving_stream_url", &self.is_resolving_stream_url) .field( "stream_url_resolution_failed", &self.stream_url_resolution_failed, ) .field("video_opt", &video_opt) + .field("playback_generation", &self.playback_generation) + .field("backend_request", &self.backend_request) + .field( + "terminal_generation_handled", + &self.terminal_generation_handled, + ) + .field( + "native_presenter_attached_generation", + &self.native_presenter_attached_generation, + ) + .field( + "native_presenter_unavailable_generation", + &self.native_presenter_unavailable_generation, + ) .field("last_progress_update", &self.last_progress_update) .field("last_progress_sent", &self.last_progress_sent) .field("pending_resume_position", &self.pending_resume_position) @@ -137,6 +189,7 @@ impl fmt::Debug for PlayerDomainState { .field("show_settings", &self.show_settings) .field("last_click_time", &self.last_click_time) .field("available_audio_tracks", &self.available_audio_tracks) + .field("track_catalog_generation", &self.track_catalog_generation) .field("current_audio_track", &self.current_audio_track) .field("available_subtitle_tracks", &self.available_subtitle_tracks) .field("current_subtitle_track", &self.current_subtitle_track) @@ -154,7 +207,7 @@ impl fmt::Debug for PlayerDomainState { .field("is_loading_video", &self.is_loading_video) .field("source_duration", &self.source_duration) .field("external_mpv_handle", &self.external_mpv_handle) - .field("external_mpv_active", &self.external_mpv_active) + .field("external_mpv_snapshot", &self.external_mpv_snapshot) .finish() } } @@ -174,9 +227,15 @@ impl Default for PlayerDomainState { current_media: None, current_media_id: None, current_url: None, + current_source: None, is_resolving_stream_url: false, stream_url_resolution_failed: false, video_opt: None, + playback_generation: SessionGeneration::new(0), + backend_request: BackendRequest::Auto, + terminal_generation_handled: None, + native_presenter_attached_generation: None, + native_presenter_unavailable_generation: None, last_progress_update: None, last_progress_sent: 0.0, pending_resume_position: None, @@ -199,7 +258,8 @@ impl Default for PlayerDomainState { show_settings: false, last_click_time: None, available_audio_tracks: Vec::new(), - current_audio_track: 0, + track_catalog_generation: None, + current_audio_track: None, available_subtitle_tracks: Vec::new(), current_subtitle_track: None, last_subtitle_track: None, @@ -216,31 +276,11 @@ impl Default for PlayerDomainState { is_loading_video: false, source_duration: None, external_mpv_handle: None, - external_mpv_active: false, + external_mpv_snapshot: None, } } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn debug_redacts_current_stream_access_token() { - let mut state = PlayerDomainState::default(); - state.current_url = Some( - "https://ferrex.example/api/v1/stream/file?access_token=raw-secret" - .parse() - .expect("valid url"), - ); - - let debug = format!("{state:?}"); - - assert!(debug.contains("access_token=")); - assert!(!debug.contains("raw-secret")); - } -} - #[cfg_attr( any( feature = "profile-with-puffin", @@ -254,9 +294,14 @@ impl PlayerDomainState { self.current_media = None; self.current_media_id = None; self.current_url = None; + self.current_source = None; self.is_resolving_stream_url = false; self.stream_url_resolution_failed = false; self.video_opt = None; + self.backend_request = BackendRequest::Auto; + self.terminal_generation_handled = None; + self.native_presenter_attached_generation = None; + self.native_presenter_unavailable_generation = None; self.last_progress_update = None; self.last_progress_sent = 0.0; self.pending_resume_position = None; @@ -269,7 +314,8 @@ impl PlayerDomainState { self.seeking = false; self.seek_started_time = None; self.available_audio_tracks.clear(); - self.current_audio_track = 0; + self.track_catalog_generation = None; + self.current_audio_track = None; self.available_subtitle_tracks.clear(); self.current_subtitle_track = None; self.last_subtitle_track = None; @@ -279,13 +325,175 @@ impl PlayerDomainState { self.is_loading_video = false; self.source_duration = None; self.content_fit = ContentFit::Contain; + self.clear_external_playback(); } - pub fn is_playing(&self) -> bool { + /// Replace the current in-process source and keep the compatibility URI + /// synchronized for code that does not yet understand authenticated + /// headers. + pub fn set_playback_source(&mut self, source: PlaybackSource) { + self.current_url = Some(source.uri().clone()); + self.current_source = Some(source); + } + + /// Set a URI-only source for unauthenticated compatibility paths. + /// Credential-bearing HTTP streams must use [`Self::set_playback_source`] + /// so authentication cannot be lost or reconstructed in the URI. + pub fn set_playback_url(&mut self, url: url::Url) { + self.set_playback_source(PlaybackSource::new(url)); + } + + pub fn playback_snapshot(&self) -> Option<&PlaybackSnapshot> { self.video_opt .as_ref() - .map(|v| !v.paused()) - .unwrap_or(false) + .map(PlaybackSession::snapshot) + .or(self.external_mpv_snapshot.as_ref()) + } + + /// Whether an in-process backend currently owns a presentation session. + pub fn has_internal_session(&self) -> bool { + self.video_opt.is_some() + } + + /// Build the backend-owned presentation element without exposing its + /// session handle to player views. Playback state still comes exclusively + /// from `playback_snapshot`. + #[cfg(feature = "ui")] + pub fn playback_widget<'a>( + &'a self, + native_host_window: Option, + ) -> Option< + iced::Element< + 'a, + crate::PlayerMessage, + iced::Theme, + iced_wgpu::Renderer, + >, + > { + self.video_opt + .as_ref() + .map(|session| session.widget(self.content_fit, native_host_window)) + } + + /// Whether the selected snapshot belongs to the retained external process. + pub fn is_external_playback(&self) -> bool { + self.playback_snapshot().is_some_and(|snapshot| { + snapshot.target.backend == BackendKind::ExternalMpv + }) + } + + /// Whether external-mpv polling should remain active. + pub fn external_playback_active(&self) -> bool { + self.external_mpv_snapshot.as_ref().is_some_and(|snapshot| { + snapshot.target.backend == BackendKind::ExternalMpv + && snapshot.state.is_active() + }) + } + + /// Whether any backend-neutral playback lifecycle is currently active. + pub fn has_active_playback(&self) -> bool { + self.playback_snapshot() + .is_some_and(PlaybackSnapshot::has_active_session) + } + + /// Start the reduced external-player lifecycle after process creation. + pub fn begin_external_playback( + &mut self, + generation: SessionGeneration, + position: f64, + duration: f64, + fullscreen: bool, + ) { + let mut snapshot = PlaybackSnapshot::new( + generation, + PlaybackTarget::EXTERNAL_MPV, + PlaybackCapabilities { + seek: true, + fullscreen: true, + native_window_fallback: true, + ..PlaybackCapabilities::default() + }, + ); + snapshot.state = PlaybackState::Loading; + snapshot.position = + valid_external_duration(position).unwrap_or(Duration::ZERO); + snapshot.duration = valid_external_duration(duration) + .filter(|duration| *duration > Duration::ZERO); + snapshot.fullscreen = fullscreen; + self.external_mpv_snapshot = Some(snapshot); + } + + /// Mark the spawned process ready without exposing its native handle. + pub fn mark_external_playback_started(&mut self) { + if let Some(snapshot) = self.external_mpv_snapshot.as_mut() + && snapshot.state == PlaybackState::Loading + { + snapshot.state = PlaybackState::Playing; + snapshot.end_reason = None; + } + } + + /// Reduce one copied IPC observation into the external snapshot. + pub fn update_external_playback_snapshot( + &mut self, + position: f64, + duration: f64, + ) { + let Some(snapshot) = self.external_mpv_snapshot.as_mut() else { + return; + }; + if let Some(position) = valid_external_duration(position) { + snapshot.position = position; + } + if let Some(duration) = valid_external_duration(duration) + && duration > Duration::ZERO + { + snapshot.duration = Some(duration); + } + if snapshot.state.is_active() { + snapshot.state = PlaybackState::Playing; + } + } + + /// Capture the final copied IPC state before dropping the process owner. + pub fn finish_external_playback( + &mut self, + position: f64, + duration: f64, + fullscreen: bool, + reason: EndReason, + ) { + self.update_external_playback_snapshot(position, duration); + if let Some(snapshot) = self.external_mpv_snapshot.as_mut() { + snapshot.state = PlaybackState::Ended; + snapshot.end_reason = Some(reason); + snapshot.fullscreen = fullscreen; + } + } + + /// Drop the external process owner and its reduced lifecycle together. + pub fn clear_external_playback(&mut self) { + self.external_mpv_handle = None; + self.external_mpv_snapshot = None; + } + + pub fn playback_diagnostics(&self) -> Option { + self.video_opt + .as_ref() + .map(PlaybackSession::diagnostics) + .or_else(|| { + self.external_mpv_snapshot.as_ref().map(|snapshot| { + PlaybackDiagnosticSnapshot::from_snapshot( + snapshot, + BackendRequest::Exact(PlaybackTarget::EXTERNAL_MPV), + ) + }) + }) + } + + pub fn is_playing(&self) -> bool { + self.playback_snapshot() + .is_some_and(PlaybackSnapshot::is_playing) } pub fn update_controls(&mut self, in_use: bool) { @@ -324,7 +532,7 @@ impl PlayerDomainState { /// Stop native/internal playback and release the video handle without resetting all state pub fn stop_native_playback(&mut self) { if let Some(mut video) = self.video_opt.take() { - video.set_paused(true); + let _ = video.apply_command(PlaybackCommand::Stop); drop(video); } self.seeking = false; @@ -334,3 +542,141 @@ impl PlayerDomainState { self.last_seek_time = None; } } + +fn valid_external_duration(seconds: f64) -> Option { + (seconds.is_finite() && seconds >= 0.0) + .then(|| Duration::try_from_secs_f64(seconds).ok()) + .flatten() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reset_does_not_reuse_session_generation() { + let mut state = PlayerDomainState { + playback_generation: SessionGeneration::new(42), + native_presenter_attached_generation: Some(SessionGeneration::new( + 41, + )), + native_presenter_unavailable_generation: Some( + SessionGeneration::new(41), + ), + current_source: Some( + PlaybackSource::new( + "https://ferrex.example/api/v1/stream/media" + .parse() + .unwrap(), + ) + .with_header("Authorization", "Bearer secret"), + ), + ..PlayerDomainState::default() + }; + + state.reset(); + + assert_eq!(state.playback_generation, SessionGeneration::new(42)); + assert_eq!(state.backend_request, BackendRequest::Auto); + assert_eq!(state.terminal_generation_handled, None); + assert_eq!(state.native_presenter_attached_generation, None); + assert_eq!(state.native_presenter_unavailable_generation, None); + assert!(state.current_url.is_none()); + assert!(state.current_source.is_none()); + } + + #[test] + fn external_process_lifecycle_reduces_into_the_neutral_snapshot() { + let mut state = PlayerDomainState::default(); + let generation = SessionGeneration::new(9); + + state.begin_external_playback( + generation, + f64::NAN, + f64::INFINITY, + true, + ); + let snapshot = state.playback_snapshot().expect("external snapshot"); + assert_eq!(snapshot.generation, generation); + assert_eq!(snapshot.target, PlaybackTarget::EXTERNAL_MPV); + assert_eq!(snapshot.state, PlaybackState::Loading); + assert_eq!(snapshot.position, Duration::ZERO); + assert_eq!(snapshot.duration, None); + assert!(snapshot.fullscreen); + assert!(state.external_playback_active()); + + state.mark_external_playback_started(); + state.update_external_playback_snapshot(12.5, 100.0); + let snapshot = state.playback_snapshot().expect("updated snapshot"); + assert_eq!(snapshot.state, PlaybackState::Playing); + assert_eq!(snapshot.position, Duration::from_millis(12_500)); + assert_eq!(snapshot.duration, Some(Duration::from_secs(100))); + assert!(state.is_external_playback()); + assert!(state.has_active_playback()); + + state.finish_external_playback(42.0, 100.0, false, EndReason::Eof); + let snapshot = state.playback_snapshot().expect("terminal snapshot"); + assert_eq!(snapshot.state, PlaybackState::Ended); + assert_eq!(snapshot.end_reason, Some(EndReason::Eof)); + assert!(!state.external_playback_active()); + let diagnostics = state + .playback_diagnostics() + .expect("external playback remains diagnosable after exit"); + assert_eq!(diagnostics.selected_target, PlaybackTarget::EXTERNAL_MPV); + assert_eq!( + diagnostics.requested_backend, + BackendRequest::Exact(PlaybackTarget::EXTERNAL_MPV) + ); + assert_eq!( + diagnostics.summary().selected_backend, + "mpv (external process)" + ); + + state.clear_external_playback(); + assert!(state.playback_snapshot().is_none()); + } + + #[test] + fn reset_clears_external_snapshot_ownership() { + let mut state = PlayerDomainState::default(); + state.begin_external_playback( + SessionGeneration::new(4), + 3.0, + 20.0, + false, + ); + + state.reset(); + + assert!(state.external_mpv_snapshot.is_none()); + assert!(state.external_mpv_handle.is_none()); + assert!(!state.is_external_playback()); + } + + #[test] + fn debug_redacts_current_stream_access_token() { + let state = PlayerDomainState { + current_url: Some( + "https://ferrex.example/api/v1/stream/file?access_token=raw-secret" + .parse() + .expect("valid url"), + ), + current_source: Some( + PlaybackSource::new( + "https://ferrex.example/api/v1/stream/file" + .parse() + .unwrap(), + ) + .with_header("Authorization", "Bearer header-secret"), + ), + ..PlayerDomainState::default() + }; + + let debug = format!("{state:?}"); + + assert!(debug.contains("access_token=")); + assert!(debug.contains("Authorization")); + assert!(!debug.contains("raw-secret")); + assert!(!debug.contains("header-secret")); + } +} diff --git a/crates/ferrex-player-playback/src/theme.rs b/crates/ferrex-player-playback/src/theme.rs index e32881d1..1922cc56 100644 --- a/crates/ferrex-player-playback/src/theme.rs +++ b/crates/ferrex-player-playback/src/theme.rs @@ -90,6 +90,25 @@ pub fn container_notification(_theme: &iced::Theme) -> container::Style { } } +pub fn container_playback_status(_theme: &iced::Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color::from_rgba( + 0.02, 0.02, 0.02, 0.82, + ))), + border: Border { + color: Color::from_rgba(1.0, 1.0, 1.0, 0.18), + width: 1.0, + radius: 10.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba(0.0, 0.0, 0.0, 0.55), + offset: Vector::new(0.0, 4.0), + blur_radius: 14.0, + }, + ..Default::default() + } +} + pub fn container_settings_panel(_theme: &iced::Theme) -> container::Style { container::Style { background: Some(Background::Color(Color::from_rgba( diff --git a/crates/ferrex-player-playback/src/track_selection.rs b/crates/ferrex-player-playback/src/track_selection.rs index fcbe3d5a..587a0e0d 100644 --- a/crates/ferrex-player-playback/src/track_selection.rs +++ b/crates/ferrex-player-playback/src/track_selection.rs @@ -1,23 +1,42 @@ //! Audio and subtitle track-selection helpers. //! -//! Helpers in this module format track labels and update `PlayerDomainState` -//! when users switch audio/subtitle tracks. - -use super::state::PlayerDomainState; -use subwave_core::video::types::{AudioTrack, SubtitleTrack}; +//! Helpers in this module format Ferrex-owned track models and update +//! `PlayerDomainState` without exposing backend track DTOs. + +use std::time::Duration; + +use super::{ + contract::{ + AudioTrack, Chapter, ChapterId, Edition, EditionId, SubtitleTrack, + TrackId, + }, + state::PlayerDomainState, +}; + +fn subtitle_enable_target( + current: Option<&TrackId>, + last: Option<&TrackId>, + available: &[SubtitleTrack], +) -> Option { + current + .or(last) + .cloned() + .filter(|target| available.iter().any(|track| &track.id == target)) + .or_else(|| available.first().map(|track| track.id.clone())) +} impl PlayerDomainState { - /// Query and update available tracks from the video + /// Query and update available tracks from the active adapter. pub fn update_available_tracks(&mut self) { if let Some(video) = &mut self.video_opt { - // Query audio tracks - self.available_audio_tracks = video.audio_tracks(); - self.current_audio_track = video.current_audio_track(); - - // Query subtitle tracks - self.available_subtitle_tracks = video.subtitle_tracks(); - self.current_subtitle_track = video.current_subtitle_track(); - self.subtitles_enabled = video.subtitles_enabled(); + let catalog = video.refresh_tracks(); + self.track_catalog_generation = Some(video.snapshot().generation); + self.current_audio_track = catalog.selected_audio.clone(); + self.current_subtitle_track = catalog.selected_subtitle.clone(); + self.subtitles_enabled = catalog.selected_subtitle.is_some() + || video.subtitles_enabled(); + self.available_audio_tracks = catalog.audio; + self.available_subtitle_tracks = catalog.subtitles; log::info!( "Available audio tracks: {}", @@ -30,210 +49,283 @@ impl PlayerDomainState { } } - /// Select an audio track by index - pub fn select_audio_track(&mut self, index: i32) -> Result<(), String> { - if let Some(video) = &mut self.video_opt { - if let Err(e) = video.select_audio_track(index) { - return Err(format!( - "Failed to select audio track {}: {}", - index, e - )); - } - self.current_audio_track = index; - - // Show notification - let track_name = self.format_audio_track(index); - self.show_track_notification(format!("Audio: {}", track_name)); + /// Select an audio track by stable Ferrex identity. + pub fn select_audio_track( + &mut self, + track_id: TrackId, + ) -> Result<(), String> { + let track_name = self.format_audio_track(&track_id); + let video = self + .video_opt + .as_mut() + .ok_or_else(|| "No video loaded".to_string())?; + video.select_audio_track(&track_id).map_err(|error| { + format!("Failed to select audio track {track_id}: {error}") + })?; + self.current_audio_track = Some(track_id); + self.show_track_notification(format!("Audio: {track_name}")); + Ok(()) + } - Ok(()) - } else { - Err("No video loaded".to_string()) + /// Select a subtitle track by identity, or `None` to disable subtitles. + pub fn select_subtitle_track( + &mut self, + track_id: Option, + ) -> Result<(), String> { + log::info!("Selecting subtitle track: {track_id:?}"); + let message = track_id.as_ref().map_or_else( + || "Subtitles: Disabled".to_string(), + |track_id| { + format!("Subtitles: {}", self.format_subtitle_track(track_id)) + }, + ); + + let video = self + .video_opt + .as_mut() + .ok_or_else(|| "No video loaded".to_string())?; + video + .select_subtitle_track(track_id.as_ref()) + .map_err(|error| { + format!("Failed to select subtitle track {track_id:?}: {error}") + })?; + + if let Some(selected) = track_id.as_ref() { + self.last_subtitle_track = Some(selected.clone()); + } else if let Some(previous) = self.current_subtitle_track.as_ref() { + self.last_subtitle_track = Some(previous.clone()); } + self.subtitles_enabled = track_id.is_some(); + self.current_subtitle_track = track_id; + self.show_track_notification(message); + Ok(()) } - /// Select a subtitle track by index, or None to disable - pub fn select_subtitle_track( + /// Select a chapter by its stable Ferrex identity. + pub fn select_chapter( &mut self, - index: Option, + chapter_id: ChapterId, ) -> Result<(), String> { - if let Some(video) = &mut self.video_opt { - log::info!("Selecting subtitle track: {:?}", index); - - // Debug: print available tracks - if let Some(idx) = index - && let Some(track) = - self.available_subtitle_tracks.get(idx as usize) - { - log::info!( - "Track {} details: lang={:?}, codec={:?}, title={:?}", - idx, - track.language, - track.codec, - track.title - ); - } + let chapter_name = self + .playback_snapshot() + .and_then(|snapshot| { + snapshot + .chapters + .iter() + .enumerate() + .find(|(_, chapter)| chapter.id == chapter_id) + }) + .map(|(index, chapter)| format_chapter(chapter, index)) + .ok_or_else(|| { + format!("Unknown chapter {}", chapter_id.as_str()) + })?; + let video = self + .video_opt + .as_mut() + .ok_or_else(|| "No video loaded".to_string())?; + video.select_chapter(&chapter_id).map_err(|error| { + format!("Failed to select chapter {}: {error}", chapter_id.as_str()) + })?; + self.show_track_notification(format!("Chapter: {chapter_name}")); + Ok(()) + } - if let Err(e) = video.select_subtitle_track(index) { - return Err(format!( - "Failed to select subtitle track {:?}: {}", - index, e - )); - } - self.current_subtitle_track = index; - - // Update subtitle enabled state based on selection without sending a second SelectStreams - // event. select_subtitle_track() already applied the change at the backend. - self.subtitles_enabled = index.is_some(); - if self.subtitles_enabled { - log::info!("Subtitles Enabled for track {:?}", index); - } else { - log::info!("Subtitles Disabled"); - } + /// Select a media edition by its stable Ferrex identity. + pub fn select_edition( + &mut self, + edition_id: EditionId, + ) -> Result<(), String> { + let edition_name = self + .playback_snapshot() + .and_then(|snapshot| { + snapshot + .editions + .iter() + .enumerate() + .find(|(_, edition)| edition.id == edition_id) + }) + .map(|(index, edition)| format_edition(edition, index)) + .ok_or_else(|| { + format!("Unknown edition {}", edition_id.as_str()) + })?; + let video = self + .video_opt + .as_mut() + .ok_or_else(|| "No video loaded".to_string())?; + video.select_edition(&edition_id).map_err(|error| { + format!("Failed to select edition {}: {error}", edition_id.as_str()) + })?; + self.show_track_notification(format!("Edition: {edition_name}")); + Ok(()) + } - // Show notification - let message = if let Some(idx) = index { - let track_name = self.format_subtitle_track(idx); - format!("Subtitles: {}", track_name) - } else { - "Subtitles: Disabled".to_string() - }; - self.show_track_notification(message); + /// Toggle subtitles on/off. + pub fn toggle_subtitles(&mut self) -> Result<(), String> { + if self.video_opt.is_none() { + return Err("No video loaded".to_string()); + } - Ok(()) - } else { - Err("No video loaded".to_string()) + if self.subtitles_enabled { + return self.select_subtitle_track(None); } - } - /// Toggle subtitles on/off - pub fn toggle_subtitles(&mut self) -> Result<(), String> { - if let Some(video) = &mut self.video_opt { - let enable = !self.subtitles_enabled; - if enable { - // When enabling via toggle, ensure a concrete track is selected for reliable behavior - let target_index = - if let Some(cur) = self.current_subtitle_track { - Some(cur) - } else if !self.available_subtitle_tracks.is_empty() { - Some(0) - } else { - None - }; - - if let Some(idx) = target_index { - // This updates backend and UI state, and shows the toast - self.select_subtitle_track(Some(idx))?; - } else { - // No tracks, just set enabled flag - self.subtitles_enabled = true; - video.set_subtitles_enabled(true); - self.show_track_notification("Subtitles: On".to_string()); - } - } else { - // Disable reliably by selecting None (updates backend and UI state) - self.select_subtitle_track(None)?; - } - Ok(()) + let target = subtitle_enable_target( + self.current_subtitle_track.as_ref(), + self.last_subtitle_track.as_ref(), + &self.available_subtitle_tracks, + ); + + if let Some(track_id) = target { + self.select_subtitle_track(Some(track_id)) } else { - Err("No video loaded".to_string()) + let video = self + .video_opt + .as_mut() + .ok_or_else(|| "No video loaded".to_string())?; + video.set_subtitles_enabled(true); + self.subtitles_enabled = true; + self.show_track_notification("Subtitles: On".to_string()); + Ok(()) } } - /// Cycle to the next audio track + /// Cycle to the next audio track. pub fn cycle_audio_track(&mut self) -> Result<(), String> { if self.available_audio_tracks.is_empty() { return Err("No audio tracks available".to_string()); } - let next_index = (self.current_audio_track + 1) - % self.available_audio_tracks.len() as i32; - self.select_audio_track(next_index) + let current_index = self.current_audio_track.as_ref().and_then(|id| { + self.available_audio_tracks + .iter() + .position(|track| &track.id == id) + }); + let next_index = current_index + .map(|index| (index + 1) % self.available_audio_tracks.len()) + .unwrap_or(0); + self.select_audio_track( + self.available_audio_tracks[next_index].id.clone(), + ) } - /// Cycle to the next subtitle track (including None) + /// Cycle to the next subtitle track (including disabled). pub fn cycle_subtitle_track(&mut self) -> Result<(), String> { if self.available_subtitle_tracks.is_empty() { - return Ok(()); // No subtitle tracks, nothing to cycle + return Ok(()); } - let next_index = match self.current_subtitle_track { - None => Some(0), // Start with first track - Some(idx) => { - let next = idx + 1; - if next >= self.available_subtitle_tracks.len() as i32 { - None // Wrap to "Off" - } else { - Some(next) - } + let next = match self.current_subtitle_track.as_ref().and_then(|id| { + self.available_subtitle_tracks + .iter() + .position(|track| &track.id == id) + }) { + None => Some(self.available_subtitle_tracks[0].id.clone()), + Some(index) if index + 1 < self.available_subtitle_tracks.len() => { + Some(self.available_subtitle_tracks[index + 1].id.clone()) } + Some(_) => None, }; - self.select_subtitle_track(next_index) + self.select_subtitle_track(next) } - /// Simple subtitle cycling: Off -> First -> Off -> Last Used -> Off + /// Simple subtitle cycling: off -> first/last-used -> off. pub fn cycle_subtitle_simple(&mut self) -> Result<(), String> { if self.available_subtitle_tracks.is_empty() { - return Ok(()); // No subtitle tracks, nothing to cycle + return Ok(()); } - let next_state = if !self.subtitles_enabled { - // Currently off -> Enable with first track - Some(0) - } else if self.current_subtitle_track == Some(0) { - // Currently showing first track -> Turn off and remember this track - self.last_subtitle_track = Some(0); - None - } else if self.current_subtitle_track.is_none() - && self.last_subtitle_track.is_some() - { - // Currently off but we have a last track -> Restore last track - self.last_subtitle_track - } else { - // Any other state -> Turn off - if let Some(current) = self.current_subtitle_track { + if self.subtitles_enabled { + if let Some(current) = self.current_subtitle_track.clone() { self.last_subtitle_track = Some(current); } - None - }; + self.select_subtitle_track(None) + } else { + let target = + self.last_subtitle_track.clone().unwrap_or_else(|| { + self.available_subtitle_tracks[0].id.clone() + }); + self.select_subtitle_track(Some(target)) + } + } - self.select_subtitle_track(next_state) + pub fn format_audio_track(&self, track_id: &TrackId) -> String { + self.available_audio_tracks + .iter() + .find(|track| &track.id == track_id) + .map(format_audio_track) + .unwrap_or_else(|| format!("Track {track_id}")) } - /// Format audio track for display - pub fn format_audio_track(&self, index: i32) -> String { - if let Some(track) = self.available_audio_tracks.get(index as usize) { - format_audio_track(track) - } else { - format!("Track {}", index + 1) - } + pub fn format_subtitle_track(&self, track_id: &TrackId) -> String { + self.available_subtitle_tracks + .iter() + .find(|track| &track.id == track_id) + .map(format_subtitle_track) + .unwrap_or_else(|| format!("Track {track_id}")) } +} - /// Format subtitle track for display - pub fn format_subtitle_track(&self, index: i32) -> String { - if let Some(track) = self.available_subtitle_tracks.get(index as usize) - { - format_subtitle_track(track) - } else { - format!("Track {}", index + 1) - } +/// Return the chapter containing an observed playback position. +pub fn chapter_at_position( + chapters: &[Chapter], + position: Duration, +) -> Option<&Chapter> { + chapters.iter().rev().find(|chapter| { + chapter.start <= position + && chapter.end.is_none_or(|end| position < end) + }) +} + +/// Format a chapter for display. +pub fn format_chapter(chapter: &Chapter, index: usize) -> String { + let title = chapter + .title + .as_deref() + .filter(|title| !title.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| format!("Chapter {}", index + 1)); + format!("{title} ({})", format_structure_time(chapter.start)) +} + +/// Format an edition for display. +pub fn format_edition(edition: &Edition, index: usize) -> String { + let mut title = edition + .title + .as_deref() + .filter(|title| !title.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| format!("Edition {}", index + 1)); + if edition.is_default { + title.push_str(" (Default)"); + } + title +} + +fn format_structure_time(duration: Duration) -> String { + let seconds = duration.as_secs(); + let hours = seconds / 3_600; + let minutes = (seconds % 3_600) / 60; + let seconds = seconds % 60; + if hours > 0 { + format!("{hours:02}:{minutes:02}:{seconds:02}") + } else { + format!("{minutes:02}:{seconds:02}") } } -/// Format an audio track for display +/// Format an audio track for display. pub fn format_audio_track(track: &AudioTrack) -> String { let mut parts = Vec::new(); - // Add language or title - if let Some(lang) = &track.language { - parts.push(format_language_code(lang)); + if let Some(language) = &track.language { + parts.push(format_language_code(language)); } else if let Some(title) = &track.title { parts.push(title.clone()); } else { - parts.push(format!("Track {}", track.index + 1)); + parts.push(track.id.to_string()); } - // Add codec and channel info in parentheses let mut details = Vec::new(); if let Some(codec) = &track.codec { details.push(format_audio_codec(codec)); @@ -249,20 +341,18 @@ pub fn format_audio_track(track: &AudioTrack) -> String { parts.join(" ") } -/// Format a subtitle track for display +/// Format a subtitle track for display. pub fn format_subtitle_track(track: &SubtitleTrack) -> String { let mut parts = Vec::new(); - // Add language or title - if let Some(lang) = &track.language { - parts.push(format_language_code(lang)); + if let Some(language) = &track.language { + parts.push(format_language_code(language)); } else if let Some(title) = &track.title { parts.push(title.clone()); } else { - parts.push(format!("Track {}", track.index + 1)); + parts.push(track.id.to_string()); } - // Add codec in parentheses if let Some(codec) = &track.codec { parts.push(format!("({})", format_subtitle_codec(codec))); } @@ -270,7 +360,6 @@ pub fn format_subtitle_track(track: &SubtitleTrack) -> String { parts.join(" ") } -/// Convert language code to human-readable name fn format_language_code(code: &str) -> String { match code.to_lowercase().as_str() { "en" | "eng" => "English", @@ -299,7 +388,6 @@ fn format_language_code(code: &str) -> String { .to_string() } -/// Format audio codec name fn format_audio_codec(codec: &str) -> String { match codec.to_lowercase().as_str() { codec if codec.contains("aac") => "AAC", @@ -317,7 +405,6 @@ fn format_audio_codec(codec: &str) -> String { .to_string() } -/// Format subtitle codec name fn format_subtitle_codec(codec: &str) -> String { match codec.to_lowercase().as_str() { codec if codec.contains("srt") => "SRT", @@ -331,13 +418,81 @@ fn format_subtitle_codec(codec: &str) -> String { .to_string() } -/// Format channel configuration -fn format_channels(channels: i32) -> String { +fn format_channels(channels: u16) -> String { match channels { 1 => "Mono".to_string(), 2 => "Stereo".to_string(), 6 => "5.1".to_string(), 8 => "7.1".to_string(), - _ => format!("{} ch", channels), + _ => format!("{channels} ch"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::SubtitleKind; + + fn subtitle(id: &str) -> SubtitleTrack { + SubtitleTrack { + id: TrackId::new(id), + title: None, + language: None, + codec: None, + kind: SubtitleKind::Unknown, + is_default: false, + is_forced: false, + is_external: false, + } + } + + #[test] + fn subtitle_enable_restores_last_available_selection() { + let english = subtitle("subtitle:eng"); + let japanese = subtitle("subtitle:jpn"); + let available = vec![english.clone(), japanese.clone()]; + + assert_eq!( + subtitle_enable_target(None, Some(&japanese.id), &available,), + Some(japanese.id.clone()) + ); + assert_eq!( + subtitle_enable_target( + None, + Some(&TrackId::new("subtitle:removed")), + &available, + ), + Some(english.id) + ); + } + + #[test] + fn chapter_projection_uses_half_open_boundaries() { + let chapters = vec![ + Chapter { + id: ChapterId::new("chapter:one"), + title: Some("Opening".to_string()), + start: Duration::ZERO, + end: Some(Duration::from_secs(10)), + }, + Chapter { + id: ChapterId::new("chapter:two"), + title: None, + start: Duration::from_secs(10), + end: None, + }, + ]; + + assert_eq!( + chapter_at_position(&chapters, Duration::from_millis(9_999)) + .map(|chapter| &chapter.id), + Some(&chapters[0].id) + ); + assert_eq!( + chapter_at_position(&chapters, Duration::from_secs(10)) + .map(|chapter| &chapter.id), + Some(&chapters[1].id) + ); + assert_eq!(format_chapter(&chapters[1], 1), "Chapter 2 (00:10)"); } } diff --git a/crates/ferrex-player-playback/src/update.rs b/crates/ferrex-player-playback/src/update.rs index daf8b6bb..eccc8ba0 100644 --- a/crates/ferrex-player-playback/src/update.rs +++ b/crates/ferrex-player-playback/src/update.rs @@ -5,23 +5,31 @@ use crate::{ constants::player_controls, + contract::{ + BackendKind, BackendRequest, DurationDelta, EndReason, PlaybackCommand, + PlaybackContentFit, PlaybackSnapshot, PlaybackSource, PlaybackState, + }, diagnostics::redact_playback_url, messages::PlayerMessage, state::PlayerDomainState, - video::{close_video, load_video}, + video::{close_video, load_video, media_file_metadata_indicates_hdr}, }; use ferrex_core::player_prelude::{EpisodeID, MediaID, MovieID}; use ferrex_player_api::services::api::ApiService; use ferrex_player_foundation::domain::DomainUpdateResult; use iced::{Task, window::Mode}; -use log::{debug, error, info, trace, warn}; -use std::{sync::Arc, time::Duration}; -use subwave_unified::video::BackendPreference; +use log::{debug, error, info, warn}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; /// App-shell playback route used when playback asks the shell to start another item. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlaybackStartMode { Internal, + MpvNativeWindow, External, } @@ -30,6 +38,17 @@ pub enum PlaybackStartMode { pub enum PlaybackWindowEvent { SetWindowMode(Mode), RestoreWindow(bool), + /// A platform presenter has attached the hidden controls host and it is + /// now safe for the shell to perform the single-visible-window handoff. + NativePresenterAttached, + /// Integrated presentation is unavailable or failed. Dismiss the hidden + /// controls host while the selected native-window fallback keeps playing. + NativePresenterUnavailable, + /// Playback has fully exited. App shells use this idempotent signal to + /// dismiss any dedicated controls host and restore the retained main + /// window without coupling the playback reducer to a concrete window + /// manager. + PlaybackExited, } /// UI/view side effects required by video playback without depending on the final app facade. @@ -83,13 +102,76 @@ pub struct PlaybackUpdateContext<'a> { pub window_position: Option, } -async fn resolve_playback_stream_url( +fn take_native_presenter_window_event( + state: &mut PlayerDomainState, +) -> Option { + let (generation, event) = { + let snapshot = state.playback_snapshot()?; + let event = if snapshot.target + == crate::contract::PlaybackTarget::MPV_INTEGRATED + { + match snapshot.presenter { + crate::contract::PresenterState::Attached + | crate::contract::PresenterState::Hidden + | crate::contract::PresenterState::Suspended => { + Some(PlaybackWindowEvent::NativePresenterAttached) + } + crate::contract::PresenterState::Failed => { + Some(PlaybackWindowEvent::NativePresenterUnavailable) + } + crate::contract::PresenterState::Detached + | crate::contract::PresenterState::AwaitingHost + | crate::contract::PresenterState::AwaitingVideoOutput => None, + } + } else { + snapshot + .fallback_chain + .iter() + .any(|reason| { + reason.from + == Some(crate::contract::PlaybackTarget::MPV_INTEGRATED) + }) + .then_some(PlaybackWindowEvent::NativePresenterUnavailable) + }?; + (snapshot.generation, event) + }; + + let emitted_generation = match event { + PlaybackWindowEvent::NativePresenterAttached => { + &mut state.native_presenter_attached_generation + } + PlaybackWindowEvent::NativePresenterUnavailable => { + &mut state.native_presenter_unavailable_generation + } + PlaybackWindowEvent::SetWindowMode(_) + | PlaybackWindowEvent::RestoreWindow(_) + | PlaybackWindowEvent::PlaybackExited => { + unreachable!( + "native presenter reducer produced a non-presenter event" + ) + } + }; + + if *emitted_generation == Some(generation) { + None + } else { + *emitted_generation = Some(generation); + Some(event) + } +} + +async fn resolve_playback_stream_source( api: Arc, server_url: String, media_id_string: String, -) -> Result { +) -> Result { let base = build_protected_stream_url(&server_url, &media_id_string); - let token = + let uri = url::Url::parse(&base).map_err(|_| { + warn!("Could not construct a valid protected playback URL"); + "Could not prepare playback. Check the configured server URL." + .to_string() + })?; + let token = Zeroizing::new( api.fetch_playback_ticket(&media_id_string) .await .map_err(|error| { @@ -99,7 +181,8 @@ async fn resolve_playback_stream_url( redact_playback_url(&error) ); playback_ticket_failure_message(&error) - })?; + })?, + ); if token.trim().is_empty() { warn!("Playback ticket endpoint returned an empty access token"); @@ -108,12 +191,20 @@ async fn resolve_playback_stream_url( .to_string(), ); } + if token.bytes().any(|byte| byte.is_ascii_control()) { + warn!("Playback ticket endpoint returned an invalid access token"); + return Err( + "Could not authorize playback. Retry playback in a moment." + .to_string(), + ); + } - Ok(format!( - "{}?access_token={}", - base, - urlencoding::encode(&token) - )) + // The Ferrex stream endpoint accepts playback-scoped bearer tokens. Keep + // the credential out of URLs and process arguments for every in-process + // backend; the explicit legacy external-player boundary converts it only + // when that compatibility path is selected. + Ok(PlaybackSource::new(uri) + .with_header("Authorization", format!("Bearer {}", token.as_str()))) } fn build_protected_stream_url( @@ -143,6 +234,249 @@ fn playback_ticket_failure_message(error: &str) -> String { } } +fn drag_seek_is_due(last_dispatch: Option, now: Instant) -> bool { + last_dispatch.is_none_or(|last_dispatch| { + now.saturating_duration_since(last_dispatch) + >= crate::constants::seeking::SEEK_DRAG_THROTTLE + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SnapshotDelivery { + LegacyPolled, + EventDriven, +} + +fn snapshot_track_notifications( + state: &PlayerDomainState, + snapshot: &PlaybackSnapshot, +) -> Vec { + // Initial discovery and replacement-file loading are not user-visible + // track changes. Once a catalog is established, selection changes from + // scripts, demuxer updates, or backend policy use the same notification as + // an Iced-issued selection. + if matches!(snapshot.state, PlaybackState::Idle | PlaybackState::Loading) + || state.track_catalog_generation != Some(snapshot.generation) + { + return Vec::new(); + } + + let mut notifications = Vec::new(); + if !state.available_audio_tracks.is_empty() + && state.current_audio_track != snapshot.tracks.selected_audio + { + notifications.push( + snapshot + .tracks + .selected_audio + .as_ref() + .and_then(|selected| { + snapshot + .tracks + .audio + .iter() + .find(|track| &track.id == selected) + }) + .map(crate::track_selection::format_audio_track) + .map(|track| format!("Audio: {track}")) + .unwrap_or_else(|| "Audio: Unavailable".to_string()), + ); + } + + if !state.available_subtitle_tracks.is_empty() + && state.current_subtitle_track != snapshot.tracks.selected_subtitle + { + notifications.push( + snapshot + .tracks + .selected_subtitle + .as_ref() + .and_then(|selected| { + snapshot + .tracks + .subtitles + .iter() + .find(|track| &track.id == selected) + }) + .map(crate::track_selection::format_subtitle_track) + .map(|track| format!("Subtitles: {track}")) + .unwrap_or_else(|| "Subtitles: Disabled".to_string()), + ); + } + notifications +} + +/// Project one backend-owned snapshot into the temporary UI compatibility +/// fields. Event-driven backends also own seek completion; the legacy adapter +/// retains its bounded timeout because Subwave does not expose a seeking +/// property. +fn apply_snapshot_to_domain( + state: &mut PlayerDomainState, + snapshot: &PlaybackSnapshot, + delivery: SnapshotDelivery, +) { + let track_notifications = snapshot_track_notifications(state, snapshot); + let backend_seeking = snapshot.state == PlaybackState::Seeking; + let terminal = matches!( + snapshot.state, + PlaybackState::Ended + | PlaybackState::Failed + | PlaybackState::Terminated + ); + let position_is_previewed = state.dragging + || match delivery { + SnapshotDelivery::LegacyPolled => state.seeking, + SnapshotDelivery::EventDriven => backend_seeking, + }; + + if !position_is_previewed + && (snapshot.position > Duration::ZERO + || state.last_valid_position <= 0.0) + { + state.last_valid_position = snapshot.position.as_secs_f64(); + } + if let Some(duration) = snapshot.duration + && duration > Duration::ZERO + { + state.last_valid_duration = duration.as_secs_f64(); + } + + state.buffered_percentage = snapshot.buffer.percentage.unwrap_or_default(); + state.available_audio_tracks = snapshot.tracks.audio.clone(); + state.current_audio_track = snapshot.tracks.selected_audio.clone(); + state.available_subtitle_tracks = snapshot.tracks.subtitles.clone(); + if !snapshot.tracks.audio.is_empty() + || !snapshot.tracks.subtitles.is_empty() + { + state.track_catalog_generation = Some(snapshot.generation); + } + + if let Some(selected) = snapshot.tracks.selected_subtitle.as_ref() { + state.last_subtitle_track = Some(selected.clone()); + } else if let Some(previous) = state.current_subtitle_track.as_ref() { + state.last_subtitle_track = Some(previous.clone()); + } + state.current_subtitle_track = snapshot.tracks.selected_subtitle.clone(); + state.subtitles_enabled = snapshot.tracks.selected_subtitle.is_some(); + if !track_notifications.is_empty() { + state.show_track_notification(track_notifications.join(" • ")); + } + + state.volume = snapshot.volume; + state.is_muted = snapshot.muted; + state.playback_speed = snapshot.speed; + state.content_fit = match snapshot.content_fit { + PlaybackContentFit::Contain => iced::ContentFit::Contain, + PlaybackContentFit::Cover => iced::ContentFit::Cover, + PlaybackContentFit::Fill => iced::ContentFit::Fill, + PlaybackContentFit::None => iced::ContentFit::None, + PlaybackContentFit::ScaleDown => iced::ContentFit::ScaleDown, + }; + state.is_fullscreen = snapshot.fullscreen; + state.is_loading_video = snapshot.state == PlaybackState::Loading; + + if delivery == SnapshotDelivery::EventDriven { + state.seeking = backend_seeking; + if !backend_seeking { + state.seek_started_time = None; + } + } + if terminal { + state.seeking = false; + state.seek_started_time = None; + state.dragging = false; + } +} + +/// Handle a terminal snapshot exactly once for its generation. `Some` means +/// the snapshot was terminal, including terminal states that intentionally +/// produce no follow-up message. +fn handle_synchronized_terminal

( + state: &mut PlayerDomainState, + ui: &mut dyn PlaybackUiShell, + snapshot: &PlaybackSnapshot, +) -> Option> +where + P: PlaybackUpdatePort + 'static, +{ + if !matches!( + snapshot.state, + PlaybackState::Ended + | PlaybackState::Failed + | PlaybackState::Terminated + ) { + return None; + } + if state.terminal_generation_handled == Some(snapshot.generation) { + return Some(Task::none()); + } + state.terminal_generation_handled = Some(snapshot.generation); + + match snapshot.state { + PlaybackState::Ended => match snapshot.end_reason { + Some(EndReason::Eof) => Some(Task::done(P::playback_message( + PlayerMessage::EndOfStream, + ))), + Some(EndReason::Closed) | Some(EndReason::BackendTerminated) => { + Some(finish_terminated_playback::

(state, snapshot)) + } + Some(EndReason::Stopped) | Some(EndReason::Replaced) | None => { + Some(Task::none()) + } + }, + PlaybackState::Terminated => { + Some(finish_terminated_playback::

(state, snapshot)) + } + PlaybackState::Failed + if snapshot.target.backend == BackendKind::Mpv => + { + let reason = snapshot + .last_error + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "unknown mpv failure".to_string()); + warn!( + "playback_fallback code=backend_failure from=mpv-native-window to=gstreamer-auto detail={reason}" + ); + let final_progress = final_snapshot_progress(state, snapshot); + let progress = final_progress.map_or_else( + Task::none, + |(media_id, position, duration)| { + Task::done(P::send_progress_update( + media_id, position, duration, + )) + }, + ); + if let Some((_, position, _)) = final_progress + && position > 0.0 + { + state.pending_resume_position = Some(position as f32); + } + close_video(state); + state.backend_request = BackendRequest::Auto; + let fallback = load_video::

(state, ui); + Some(sequence_tasks([progress, fallback])) + } + PlaybackState::Failed => { + let message = snapshot + .last_error + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "Playback backend failed".to_string()); + let progress = final_snapshot_progress(state, snapshot) + .map_or_else(Task::none, |(media_id, position, duration)| { + Task::done(P::send_progress_update( + media_id, position, duration, + )) + }); + close_video(state); + ui.set_video_error(message); + Some(progress) + } + _ => Some(Task::none()), + } +} + /// Handle player domain messages /// Returns a DomainUpdateResult containing both the task and any events to emit #[cfg_attr( @@ -186,25 +520,16 @@ where } PlayerMessage::NavigateBack => { + let progress = current_playback_progress(state); let update_task = if let Some(media_id) = state.current_media_id { - let position = if let Some(video) = &mut state.video_opt { - video.position().as_secs_f64() - } else { - state.last_valid_position - }; - let duration = if let Some(video) = &mut state.video_opt { - video.duration().as_secs_f64() - } else { - state.last_valid_duration - }; Task::done(P::send_progress_update( - media_id, position, duration, + media_id, progress.0, progress.1, )) } else { Task::none() }; - let tasks = Task::batch(vec![ + let tasks = sequence_tasks([ update_task, Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), Task::done(P::navigate_back()), @@ -214,25 +539,16 @@ where } PlayerMessage::NavigateHome => { + let progress = current_playback_progress(state); let update_task = if let Some(media_id) = state.current_media_id { - let position = if let Some(video) = &mut state.video_opt { - video.position().as_secs_f64() - } else { - state.last_valid_position - }; - let duration = if let Some(video) = &mut state.video_opt { - video.duration().as_secs_f64() - } else { - state.last_valid_duration - }; Task::done(P::send_progress_update( - media_id, position, duration, + media_id, progress.0, progress.1, )) } else { Task::none() }; - let tasks = Task::batch(vec![ + let tasks = sequence_tasks([ update_task, Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), Task::done(P::navigate_home()), @@ -242,51 +558,43 @@ where } PlayerMessage::Play => { - if let Some(video) = &mut state.video_opt { - video.set_paused(false); - DomainUpdateResult::task(Task::done(P::send_progress_update( - state.current_media_id.unwrap(), - video.position().as_secs_f64(), - video.duration().as_secs_f64(), - ))) - } else { - DomainUpdateResult::task(Task::none()) - } + let Some(video) = state.video_opt.as_mut() else { + return DomainUpdateResult::task(Task::none()); + }; + video.set_paused(false); + DomainUpdateResult::task(progress_checkpoint_task::

(state)) } PlayerMessage::Pause => { - if let Some(video) = &mut state.video_opt { - video.set_paused(true); - DomainUpdateResult::task(Task::done(P::send_progress_update( - state.current_media_id.unwrap(), - video.position().as_secs_f64(), - video.duration().as_secs_f64(), - ))) - } else { - DomainUpdateResult::task(Task::none()) - } + let Some(video) = state.video_opt.as_mut() else { + return DomainUpdateResult::task(Task::none()); + }; + video.set_paused(true); + DomainUpdateResult::task(progress_checkpoint_task::

(state)) } PlayerMessage::PlayPause => { let task = if let Some(video) = &mut state.video_opt { let is_paused = video.paused(); video.set_paused(!is_paused); - DomainUpdateResult::task(Task::done(P::send_progress_update( - state.current_media_id.unwrap(), - video.position().as_secs_f64(), - video.duration().as_secs_f64(), - ))) + progress_checkpoint_task::

(state) } else { - DomainUpdateResult::task(Task::none()) + Task::none() }; state.update_controls(true); - task + DomainUpdateResult::task(task) } PlayerMessage::ResetAfterStop => { - // Reset the player state after progress update has been sent + // Reset only after the final progress task has been constructed. + // Every completed exit path funnels through this message, so one + // idempotent shell event also restores a dedicated native-player + // host for stop, EOF, close, back, and home navigation. state.reset(); - DomainUpdateResult::task(Task::none()) + DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::PlaybackExited], + ) } PlayerMessage::Stop => { @@ -303,8 +611,9 @@ where Task::none() }; - // Store tasks before reset - let tasks = Task::batch(vec![ + // Serialize progress, reset, and navigation messages so the shell + // cannot tear down the player before persistence is dispatched. + let tasks = sequence_tasks([ update_task, Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), Task::done(P::navigate_back()), @@ -431,7 +740,9 @@ where if let Some(video) = &mut state.video_opt { // Prefer backend position, then state.position, then last_valid_position let backend_pos = video.position().as_secs_f64(); - let base_pos = if backend_pos > 0.0 { + let base_pos = if state.seeking { + state.last_valid_position + } else if backend_pos > 0.0 { backend_pos } else { state.last_valid_position @@ -454,22 +765,30 @@ where new_position = new_position.min(clamp_duration); } - // Perform the seek - state.seeking = true; - state.seek_started_time = Some(std::time::Instant::now()); - let seek_to = Duration::try_from_secs_f64(new_position) - .unwrap_or_default(); - if let Err(err) = video.seek(seek_to, false) { - error!( - "Failed to seek video to {:.3}s: {}", - seek_to.as_secs_f64(), - err - ); + let effective_delta = new_position - base_pos; + let Some(delta) = DurationDelta::from_seconds(effective_delta) + else { + return DomainUpdateResult::task(Task::none()); + }; + if delta.magnitude() == Duration::ZERO { + state.last_valid_position = new_position; + state.update_controls(true); + return DomainUpdateResult::task(Task::none()); } - // Update position immediately for better UX and remember as last valid - - if new_position > 0.0 { + if let Err(err) = + video.apply_command(PlaybackCommand::SeekRelative(delta)) + { + error!( + "Failed to seek video by {:.3}s: {}", + effective_delta, err + ); + } else { + state.seeking = true; + state.seek_started_time = Some(Instant::now()); + // Update position immediately for responsive controls. The + // event-driven snapshot confirms completion and replaces + // this prediction with the observed position. state.last_valid_position = new_position; } @@ -507,7 +826,11 @@ where // Query available tracks state.update_available_tracks(); context.ui.set_player_view(); - DomainUpdateResult::task(Task::none()) + let mut result = DomainUpdateResult::task(Task::none()); + if let Some(event) = take_native_presenter_window_event(state) { + result = result.add_event(event); + } + result } else { context .ui @@ -520,8 +843,8 @@ where info!( "[Player] Video ready to play - loading with internal backend" ); - // Keep VideoReadyToPlay on the internal provider; explicit MPV - // handoff is triggered via UI::PlayMediaWithIdInMpv / Player::PlayExternal + // Load the selected in-process provider. The separate external + // process handoff remains explicit through Player::PlayExternal. DomainUpdateResult::task(load_video::

(state, context.ui)) } @@ -530,23 +853,17 @@ where // Capture position and duration for final progress update if let Some(media_id) = state.current_media_id { - let (position, duration) = - if let Some(video) = &mut state.video_opt { - ( - video.position().as_secs_f64(), - video.duration().as_secs_f64(), - ) - } else { - (state.last_valid_position, state.last_valid_duration) - }; + let (position, duration) = current_playback_progress(state); // If current is an episode, attempt to play the next; else exit if let MediaID::Episode(current_ep) = media_id { let next_opt = context.episodes.next_episode(current_ep); if let Some(next_ep) = next_opt { - // Persist final progress, then start next episode (internal provider) - let tasks = Task::batch(vec![ + let mode = current_playback_start_mode(state); + // Persist final progress, then retain the selected + // backend for the next episode. + let tasks = sequence_tasks([ Task::done(P::send_progress_update( MediaID::Episode(current_ep), position, @@ -554,7 +871,7 @@ where )), Task::done(P::play_media_with_id( MediaID::Episode(next_ep), - PlaybackStartMode::Internal, + mode, )), ]); return DomainUpdateResult::task(tasks); @@ -562,7 +879,7 @@ where } // Fallback: no next episode -> reset and navigate back - let tasks = Task::batch(vec![ + let tasks = sequence_tasks([ Task::done(P::send_progress_update( media_id, position, duration, )), @@ -574,7 +891,7 @@ where DomainUpdateResult::task(tasks) } else { // No media id - just reset and navigate back - let tasks = Task::batch(vec![ + let tasks = sequence_tasks([ Task::done(P::playback_message( PlayerMessage::ResetAfterStop, )), @@ -584,93 +901,160 @@ where } } - PlayerMessage::NewFrame => { - // Also advance transient notifications (e.g., track toast) + PlayerMessage::PlaybackSnapshotTick => { + if state + .video_opt + .as_ref() + .is_some_and(|video| video.uses_event_driven_snapshots()) + { + return DomainUpdateResult::task(Task::none()); + } + state.update_track_notification(); - let mut update_tks = false; - if let Some(video) = &mut state.video_opt { - if state.is_loading_video { - state.is_loading_video = false; + if state.seeking + && let Some(start_time) = state.seek_started_time + && start_time.elapsed() > Duration::from_secs(1) + { + warn!("Seek timeout: clearing seeking flag after 1s"); + state.seeking = false; + state.seek_started_time = None; + } + + let refresh_tracks = state.available_audio_tracks.is_empty() + || state.available_subtitle_tracks.is_empty(); + let snapshot = state.video_opt.as_mut().map(|video| { + video.synchronize_snapshot(); + if refresh_tracks { + video.refresh_tracks(); } + video.snapshot().clone() + }); + let Some(snapshot) = snapshot else { + return DomainUpdateResult::task(Task::none()); + }; - let num_aud_tks = state.available_audio_tracks.len(); - let num_sub_tks = state.available_subtitle_tracks.len(); + apply_snapshot_to_domain( + state, + &snapshot, + SnapshotDelivery::LegacyPolled, + ); + if let Some(task) = + handle_synchronized_terminal::

(state, context.ui, &snapshot) + { + DomainUpdateResult::task(task) + } else { + DomainUpdateResult::task(Task::none()) + } + } - if num_aud_tks == 0 || num_sub_tks == 0 { - update_tks = true; - } + PlayerMessage::PlaybackEventsReady => { + let snapshot = state.video_opt.as_mut().map(|video| { + video.synchronize_snapshot(); + video.snapshot().clone() + }); + let Some(snapshot) = snapshot else { + return DomainUpdateResult::task(Task::none()); + }; - // Check for seek timeout (500ms) - if state.seeking - && let Some(start_time) = state.seek_started_time - && start_time.elapsed() > Duration::from_millis(1000) - { - warn!("Seek timeout: clearing seeking flag after 1s"); - state.seeking = false; - state.seek_started_time = None; - } + apply_snapshot_to_domain( + state, + &snapshot, + SnapshotDelivery::EventDriven, + ); + let mut result = if let Some(task) = + handle_synchronized_terminal::

(state, context.ui, &snapshot) + { + DomainUpdateResult::task(task) + } else { + DomainUpdateResult::task(Task::none()) + }; + if let Some(event) = take_native_presenter_window_event(state) { + result = result.add_event(event); + } + result + } - // Update duration if it wasn't available during load - if state.last_valid_duration <= 0.0 { - let new_duration = video.duration().as_secs_f64(); - if new_duration > 0.0 { - info!( - "Duration now available: {} seconds", - new_duration - ); - state.last_valid_duration = new_duration; - state.last_valid_duration = new_duration; - } else { - debug!( - "NewFrame: Duration still not available from video" - ); - } - } + PlayerMessage::CaptureNativeVideoHost(window_id) => { + #[cfg(feature = "ui")] + { + return DomainUpdateResult::task( + crate::native_video_slot::capture_iced_native_host( + window_id, + ) + .map(move |result| { + P::playback_message( + PlayerMessage::NativeVideoHostCaptured { + window_id, + result: result + .map(|_| ()) + .map_err(|error| error.to_string()), + }, + ) + }), + ); + } - // Update position when not dragging or seeking - if !state.dragging && !state.seeking { - // Normal position update - let new_position = video.position().as_secs_f64(); - let old_position = state.last_valid_position; - - // Only update if we got a valid position - if new_position > 0.0 { - state.last_valid_position = new_position; - - // Log significant position changes - if (new_position - old_position).abs() > 0.5 { - debug!( - "NewFrame: Position updated from {:.2}s to {:.2}s (duration: {:.2}s, source_duration: {:?})", - old_position, - new_position, - state.last_valid_duration, - state.source_duration - ); - } - } else { - trace!( - "NewFrame: No valid position update (current: {:.2}s, new: {:.2}s)", - state.last_valid_position, new_position - ); - } - } else { - if state.seeking { - let video_pos = video.position().as_secs_f64(); - debug!( - "NewFrame during seek: video reports {:.2}s, UI shows {:.2}s", - video_pos, state.last_valid_position - ); - } - trace!( - "NewFrame: Skipping position update (dragging: {}, seeking: {})", - state.dragging, state.seeking - ); - } + #[cfg(not(feature = "ui"))] + { + let _ = window_id; + DomainUpdateResult::task(Task::none()) } - if update_tks { - state.update_available_tracks(); + } + + PlayerMessage::NativeVideoHostCaptured { window_id, result } => { + log::debug!( + "native presenter host capture task completed: success={}", + result.is_ok() + ); + if let Err(detail) = result + && let Some(video) = state.video_opt.as_mut() + { + video.native_host_capture_failed(window_id, detail); + video.synchronize_snapshot(); } - DomainUpdateResult::task(Task::none()) + let mut update = DomainUpdateResult::task(Task::none()); + if let Some(event) = take_native_presenter_window_event(state) { + update = update.add_event(event); + } + update + } + + PlayerMessage::NativePresenterUpdated => { + if let Some(video) = state.video_opt.as_mut() { + video.synchronize_snapshot(); + } + let mut result = DomainUpdateResult::task(Task::none()); + if let Some(event) = take_native_presenter_window_event(state) { + result = result.add_event(event); + } + result + } + + PlayerMessage::NativePresenterRefresh => { + let before = state.playback_snapshot().map(|snapshot| { + ( + snapshot.target, + snapshot.presenter, + snapshot.fallback_chain.len(), + ) + }); + if let Some(video) = state.video_opt.as_mut() { + video.refresh_native_presenter(); + } + let mut result = DomainUpdateResult::task(Task::none()); + let after = state.playback_snapshot().map(|snapshot| { + ( + snapshot.target, + snapshot.presenter, + snapshot.fallback_chain.len(), + ) + }); + if before != after + && let Some(event) = take_native_presenter_window_event(state) + { + result = result.add_event(event); + } + result } PlayerMessage::Reload => { @@ -680,26 +1064,70 @@ where PlayerMessage::ShowControls => { state.update_controls(true); - DomainUpdateResult::task(Task::none()) + if state + .video_opt + .as_ref() + .is_some_and(|video| !video.uses_event_driven_snapshots()) + { + update_player::

(context, PlayerMessage::PlaybackSnapshotTick) + } else { + DomainUpdateResult::task(Task::none()) + } } PlayerMessage::ToggleFullscreen => { - state.is_fullscreen = !state.is_fullscreen; - let mode = if state.is_fullscreen { - Mode::Fullscreen + let native_mpv_fullscreen = + state.playback_snapshot().and_then(|snapshot| { + (snapshot.target.backend == BackendKind::Mpv) + .then_some(snapshot.fullscreen) + }); + + if let Some(confirmed_fullscreen) = native_mpv_fullscreen { + let requested_fullscreen = !confirmed_fullscreen; + if let Some(video) = state.video_opt.as_mut() + && let Err(error) = video.apply_command( + crate::contract::PlaybackCommand::SetFullscreen( + requested_fullscreen, + ), + ) + { + warn!("Could not change mpv fullscreen state: {error}"); + } + DomainUpdateResult::task(Task::none()) } else { - Mode::Windowed - }; + state.is_fullscreen = !state.is_fullscreen; + let mode = if state.is_fullscreen { + Mode::Fullscreen + } else { + Mode::Windowed + }; - // Emit SetWindowMode event instead of managing window directly - DomainUpdateResult::with_events( - Task::none(), - vec![PlaybackWindowEvent::SetWindowMode(mode)], - ) + // Emit SetWindowMode event instead of managing window directly + DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::SetWindowMode(mode)], + ) + } } PlayerMessage::DisableFullscreen => { - if state.is_fullscreen { + let native_mpv_fullscreen = + state.playback_snapshot().and_then(|snapshot| { + (snapshot.target.backend == BackendKind::Mpv) + .then_some(snapshot.fullscreen) + }); + if native_mpv_fullscreen == Some(true) { + if let Some(video) = state.video_opt.as_mut() + && let Err(error) = video.apply_command( + crate::contract::PlaybackCommand::SetFullscreen(false), + ) + { + warn!("Could not leave mpv fullscreen state: {error}"); + } + DomainUpdateResult::task(Task::none()) + } else if native_mpv_fullscreen.is_some() { + DomainUpdateResult::task(Task::none()) + } else if state.is_fullscreen { DomainUpdateResult::with_events( Task::none(), vec![PlaybackWindowEvent::SetWindowMode(Mode::Windowed)], @@ -720,8 +1148,6 @@ where } PlayerMessage::MouseMoved(point) => { - use std::time::{Duration, Instant}; - // Update controls visibility state.update_controls(true); @@ -767,15 +1193,11 @@ where state.last_valid_position = seek_position; state.update_controls(true); - // Throttle actual seeks to prevent overwhelming the network - let should_seek = match state.last_seek_time { - Some(last_time) => { - last_time.elapsed() > Duration::from_millis(100) - } - None => true, - }; - - if should_seek { + // Throttle preview seeks at the UI boundary. Native adapters + // also coalesce in-flight absolute seeks so delayed async + // replies cannot turn pointer motion into an unbounded queue. + let now = Instant::now(); + if drag_seek_is_due(state.last_seek_time, now) { // Perform the actual seek if let Some(video) = state.video_opt.as_mut() { let duration = @@ -788,7 +1210,7 @@ where err ); } - state.last_seek_time = Some(Instant::now()); + state.last_seek_time = Some(now); // Clear pending seek since we just performed it state.pending_seek_position = None; } @@ -834,6 +1256,15 @@ where PlayerMessage::SetContentFit(fit) => { state.content_fit = fit; + if let Some(video) = state.video_opt.as_mut() + && let Err(error) = video.apply_command( + crate::contract::PlaybackCommand::SetContentFit( + crate::video::playback_content_fit(fit), + ), + ) + { + warn!("Could not change playback content fit: {error}"); + } DomainUpdateResult::task(Task::none()) } @@ -854,12 +1285,26 @@ where DomainUpdateResult::task(Task::none()) } - PlayerMessage::ToggleSubtitles => { - if let Err(e) = state.toggle_subtitles() { - error!("{}", e); + PlayerMessage::ChapterSelected(chapter_id) => { + if let Err(error) = state.select_chapter(chapter_id) { + error!("{error}"); } - // Close subtitle menu after toggling - state.show_subtitle_menu = false; + DomainUpdateResult::task(Task::none()) + } + + PlayerMessage::EditionSelected(edition_id) => { + if let Err(error) = state.select_edition(edition_id) { + error!("{error}"); + } + DomainUpdateResult::task(Task::none()) + } + + PlayerMessage::ToggleSubtitles => { + if let Err(e) = state.toggle_subtitles() { + error!("{}", e); + } + // Close subtitle menu after toggling + state.show_subtitle_menu = false; DomainUpdateResult::task(Task::none()) } @@ -882,28 +1327,28 @@ where DomainUpdateResult::task(Task::none()) } + PlayerMessage::QualityProfileSelected(profile) => { + state.current_quality_profile = Some(profile.as_str().to_string()); + state.show_quality_menu = false; + DomainUpdateResult::task(Task::none()) + } + PlayerMessage::ToggleAppsinkBackend => { if let Some(video) = state.video_opt.as_mut() { - if std::env::var("WAYLAND_DISPLAY").is_ok() { - let current = video.backend(); - let target = match current { - BackendPreference::ForceAppsink => { - BackendPreference::ForceWayland - } - _ => BackendPreference::ForceAppsink, - }; - if let Err(e) = video.set_preference(target) { - error!("Failed to switch backend: {}", e); - } else { - info!("Switched backend to {:?}", target); - } + let result = if std::env::var("WAYLAND_DISPLAY").is_ok() { + video.toggle_diagnostic_backend() } else { - // Not on Wayland; ensure Appsink - if let Err(e) = video.set_preference( - subwave_unified::video::BackendPreference::ForceAppsink, - ) { - error!("Failed to switch backend: {}", e); - } + video.force_appsink() + }; + if let Err(error) = result { + error!( + "Failed to switch Subwave diagnostic backend: {error}" + ); + } else { + info!( + "Switched Subwave diagnostic backend (appsink: {})", + video.is_appsink() + ); } } DomainUpdateResult::task(Task::none()) @@ -937,21 +1382,49 @@ where } PlayerMessage::CheckControlsVisibility => { - // Periodically clear notifications and hide controls if idle + // Periodically clear notifications and hide controls if idle. + // Legacy adapters reuse this bounded UI timer to synchronize their + // snapshot; event-driven native backends never poll here. state.update_track_notification(); + let synchronize_legacy_snapshot = state + .video_opt + .as_ref() + .is_some_and(|video| !video.uses_event_driven_snapshots()); if state.controls && state.controls_time.elapsed() > Duration::from_secs(3) { state.controls = false; } - DomainUpdateResult::task(Task::none()) + if synchronize_legacy_snapshot { + update_player::

(context, PlayerMessage::PlaybackSnapshotTick) + } else { + DomainUpdateResult::task(Task::none()) + } } // New Phase 2 direct command handlers PlayerMessage::SeekTo(duration) => { - // Convert Duration to f64 seconds and delegate to existing Seek handler - let position = duration.as_secs_f64(); - update_player::

(context, PlayerMessage::Seek(position)) + let Some(video) = state.video_opt.as_mut() else { + return DomainUpdateResult::task(Task::none()); + }; + if let Err(error) = + video.apply_command(PlaybackCommand::SeekAbsolute(duration)) + { + error!( + "Failed to seek video to {:.3}s: {error}", + duration.as_secs_f64() + ); + } else { + state.dragging = false; + state.seeking = true; + state.seek_started_time = Some(Instant::now()); + state.last_valid_position = duration.as_secs_f64(); + state.last_seek_position = None; + state.pending_seek_position = None; + state.last_seek_time = None; + state.update_controls(true); + } + DomainUpdateResult::task(Task::none()) } PlayerMessage::ToggleShuffle => { @@ -970,29 +1443,15 @@ where // Episode navigation: Next by ordering, Prev = restart or previous by ordering (<5%) PlayerMessage::NextEpisode => { - let (current_episode_id, external_active, mid_opt, pos, dur) = { - if let Some(ferrex_core::player_prelude::MediaID::Episode(ep)) = - state.current_media_id + let mode = current_playback_start_mode(state); + let (current_episode_id, mid_opt) = + if let Some(MediaID::Episode(episode)) = state.current_media_id { - let (p, d) = if let Some(video) = &mut state.video_opt { - ( - video.position().as_secs_f64(), - video.duration().as_secs_f64(), - ) - } else { - (state.last_valid_position, state.last_valid_duration) - }; - ( - ep, - state.external_mpv_active, - state.current_media_id, - p, - d, - ) + (episode, state.current_media_id) } else { return DomainUpdateResult::task(Task::none()); - } - }; + }; + let (pos, dur) = current_playback_progress(state); // Resolve next strictly by ordering using repo accessor let next_opt = context.episodes.next_episode(current_episode_id); @@ -1004,13 +1463,7 @@ where Task::none() }; - let mode = if external_active { - PlaybackStartMode::External - } else { - PlaybackStartMode::Internal - }; - - let tasks = Task::batch(vec![ + let tasks = sequence_tasks([ progress_task, Task::done(P::play_media_with_id( MediaID::Episode(next_ep_id), @@ -1031,33 +1484,17 @@ where }; // Determine progress ratio using the most reliable numbers - let (position, mut duration) = - if let Some(video) = &mut state.video_opt { - ( - video.position().as_secs_f64(), - video.duration().as_secs_f64(), - ) - } else { - (state.last_valid_position, state.last_valid_duration) - }; + let (position, mut duration) = current_playback_progress(state); if let Some(src) = state.source_duration && src > 0.0 { duration = src; } - let ratio = if duration > 0.0 { - position / duration - } else { - 1.0 - }; - - if ratio >= 0.05 { + if should_restart_current_episode(position, duration) { // Restart current episode from beginning - if let Some(base) = prepare_restart_current_episode( - state, - state.external_mpv_active, - position, - ) { + if let Some(base) = + prepare_restart_current_episode(state, position) + { // Use immediate relative seek to 0 for internal player update_player::

( context, @@ -1068,17 +1505,9 @@ where } } else { // Less than 5% watched: go to previous episode by ordering - let (external_active, mid_opt, p, d) = { - let (p, d) = if let Some(video) = &mut state.video_opt { - ( - video.position().as_secs_f64(), - video.duration().as_secs_f64(), - ) - } else { - (state.last_valid_position, state.last_valid_duration) - }; - (state.external_mpv_active, state.current_media_id, p, d) - }; + let mode = current_playback_start_mode(state); + let mid_opt = state.current_media_id; + let (p, d) = current_playback_progress(state); let prev_opt = context.episodes.previous_episode(current_episode_id); @@ -1090,13 +1519,7 @@ where Task::none() }; - let mode = if external_active { - PlaybackStartMode::External - } else { - PlaybackStartMode::Internal - }; - - let tasks = Task::batch(vec![ + let tasks = sequence_tasks([ progress_task, Task::done(P::play_media_with_id( MediaID::Episode(prev_ep_id), @@ -1106,11 +1529,9 @@ where DomainUpdateResult::task(tasks) } else { // No previous episode -> restart current instead - if let Some(base) = prepare_restart_current_episode( - state, - external_active, - p, - ) { + if let Some(base) = + prepare_restart_current_episode(state, p) + { update_player::

( context, PlayerMessage::SeekRelative(-base), @@ -1144,35 +1565,19 @@ where state.last_valid_duration = duration; } - // HDR detection heuristics - let is_hdr_content = if let Some(metadata) = - &media.media_file_metadata - { - if let Some(bit_depth) = metadata.bit_depth { - bit_depth > 8 - } else if let Some(color_transfer) = &metadata.color_transfer { - ["smpte2084", "arib-std-b67", "smpte2086"] - .iter() - .any(|t| color_transfer.contains(t)) - } else if let Some(color_primaries) = &metadata.color_primaries - { - color_primaries.contains("bt2020") - } else { - false - } - } else { - let f = media.filename.as_str(); - f.contains("2160p") - || f.contains("UHD") - || f.contains("HDR") - || f.contains("DV") - }; - state.is_hdr_content = is_hdr_content; + // Content labeling uses server/decoder metadata only. Native HDR + // output remains a separate observed diagnostic and is never + // inferred from a filename or backend name. + state.is_hdr_content = media + .media_file_metadata + .as_ref() + .is_some_and(media_file_metadata_indicates_hdr); // Clear any previous stream before resolving a new authenticated URL. // External MPV may be requested before this async task completes, so a // stale URL must not be available for handoff. state.current_url = None; + state.current_source = None; state.is_resolving_stream_url = true; state.stream_url_resolution_failed = false; @@ -1180,11 +1585,15 @@ where let media_id_string = media.id.to_string(); let api = Arc::clone(&context.api_service); DomainUpdateResult::task(Task::perform( - resolve_playback_stream_url(api, server_url, media_id_string), + resolve_playback_stream_source( + api, + server_url, + media_id_string, + ), |result| match result { - Ok(url) => { - P::playback_message(PlayerMessage::SetStreamUrl(url)) - } + Ok(source) => P::playback_message( + PlayerMessage::SetStreamSource(source), + ), Err(message) => P::playback_message( PlayerMessage::StreamUrlResolutionFailed(message), ), @@ -1195,15 +1604,17 @@ where // External MPV player messages PlayerMessage::ExternalPlaybackStarted => { info!("External MPV playback started"); + state.mark_external_playback_started(); DomainUpdateResult::task(Task::none()) } PlayerMessage::ExternalPlaybackUpdate { position, duration } => { - // Update state with position from external MPV + // Reduce copied IPC values into the same snapshot consumed by + // progress, episode, subscription, and view policy. + state.update_external_playback_snapshot(position, duration); state.last_valid_position = position; state.last_valid_duration = duration; - // Save watch progress if position > 0.0 && duration > 0.0 { state.last_progress_sent = position; } @@ -1214,162 +1625,170 @@ where PlayerMessage::ExternalPlaybackEnded => { info!("External MPV playback ended"); - // Save final position and fullscreen state - if let (Some(handle), Some(media_id)) = - (&state.external_mpv_handle, state.current_media_id) - { - let final_position = handle.get_final_position(); - let final_fullscreen = handle.get_final_fullscreen(); - state.last_valid_position = final_position; - state.is_fullscreen = final_fullscreen; - - // Decide: next episode (external) or exit - if let MediaID::Episode(current_ep) = media_id { - let next_opt = context.episodes.next_episode(current_ep); - - // Clear external MPV state before proceeding - state.external_mpv_handle = None; - state.external_mpv_active = false; - - if let Some(next_ep) = next_opt { - // Persist final progress, then start next in external MPV - let tasks = Task::batch(vec![ - Task::done(P::send_progress_update( - MediaID::Episode(current_ep), - final_position, - state.last_valid_duration, - )), - Task::done(P::play_media_with_id( - MediaID::Episode(next_ep), - PlaybackStartMode::External, - )), - ]); - return DomainUpdateResult::task(tasks); - } - - // No next episode -> fall through to exit path below - } - - // Fallback: no next episode or not an episode -> send progress and exit - let progress_task = Task::done(P::send_progress_update( - media_id, - final_position, - state.last_valid_duration, - )); - - // Clear external MPV state - state.external_mpv_handle = None; - state.external_mpv_active = false; - - let all_tasks = Task::batch(vec![ - progress_task, + // Polling captures the final IPC values into the snapshot before + // dropping the process handle, so terminal behavior never depends + // on a native object surviving into this message turn. + let final_snapshot = state.external_mpv_snapshot.clone(); + let final_fullscreen = final_snapshot + .as_ref() + .map(|snapshot| snapshot.fullscreen) + .unwrap_or(state.is_fullscreen); + let final_position = final_snapshot + .as_ref() + .map(|snapshot| snapshot.position.as_secs_f64()) + .unwrap_or(state.last_valid_position); + let final_duration = final_snapshot + .as_ref() + .and_then(|snapshot| snapshot.duration) + .map(|duration| duration.as_secs_f64()) + .unwrap_or(state.last_valid_duration); + state.last_valid_position = final_position; + state.last_valid_duration = final_duration; + state.is_fullscreen = final_fullscreen; + + let Some(media_id) = state.current_media_id else { + state.clear_external_playback(); + let tasks = sequence_tasks([ Task::done(P::playback_message( PlayerMessage::ResetAfterStop, )), Task::done(P::navigate_back()), ]); - - DomainUpdateResult::with_events( - all_tasks, + return DomainUpdateResult::with_events( + tasks, vec![PlaybackWindowEvent::RestoreWindow(final_fullscreen)], - ) - } else { - let tasks = Task::batch(vec![ - Task::done(P::playback_message( - PlayerMessage::ResetAfterStop, + ); + }; + + let next_episode = match media_id { + MediaID::Episode(current) => { + context.episodes.next_episode(current) + } + _ => None, + }; + state.clear_external_playback(); + + if let (MediaID::Episode(current), Some(next)) = + (media_id, next_episode) + { + let tasks = sequence_tasks([ + Task::done(P::send_progress_update( + MediaID::Episode(current), + final_position, + final_duration, + )), + Task::done(P::play_media_with_id( + MediaID::Episode(next), + PlaybackStartMode::External, )), - Task::done(P::navigate_back()), ]); - DomainUpdateResult::task(tasks) + return DomainUpdateResult::with_events( + tasks, + vec![PlaybackWindowEvent::RestoreWindow(final_fullscreen)], + ); } + + let tasks = sequence_tasks([ + Task::done(P::send_progress_update( + media_id, + final_position, + final_duration, + )), + Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), + Task::done(P::navigate_back()), + ]); + DomainUpdateResult::with_events( + tasks, + vec![PlaybackWindowEvent::RestoreWindow(final_fullscreen)], + ) } PlayerMessage::ProgressHeartbeat => { - // Periodic progress checkpoint from internal player - if let Some(video) = state.video_opt.as_mut() - && let Some(media_id) = state.current_media_id + // Periodic progress checkpoint from the backend-neutral snapshot. + // This low-rate synchronization is also the terminal fallback for + // legacy Subwave playback while the controls timer is inactive. + let in_process = state.video_opt.is_some(); + let delivery = if state + .video_opt + .as_ref() + .is_some_and(|video| video.uses_event_driven_snapshots()) { - let position = video.position().as_secs_f64(); - let duration = video.duration().as_secs_f64(); - - if position > 0.0 && duration > 0.0 { - state.last_valid_position = position; - state.last_valid_duration = duration; + SnapshotDelivery::EventDriven + } else { + SnapshotDelivery::LegacyPolled + }; + let snapshot = if let Some(video) = state.video_opt.as_mut() { + video.synchronize_snapshot(); + Some(video.snapshot().clone()) + } else { + state.external_mpv_snapshot.clone() + }; + let Some(snapshot) = snapshot else { + return DomainUpdateResult::task(Task::none()); + }; - return DomainUpdateResult::task(Task::done( - P::send_progress_update(media_id, position, duration), - )); + if in_process { + apply_snapshot_to_domain(state, &snapshot, delivery); + if let Some(task) = handle_synchronized_terminal::

( + state, context.ui, &snapshot, + ) { + return DomainUpdateResult::task(task); } } + + if let Some((media_id, position, duration)) = + final_snapshot_progress(state, &snapshot) + && position > 0.0 + && duration > 0.0 + { + return DomainUpdateResult::task(Task::done( + P::send_progress_update(media_id, position, duration), + )); + } DomainUpdateResult::task(Task::none()) } PlayerMessage::PollExternalMpv => { - match (state.external_mpv_handle.take(), state.current_media_id) { - (Some(mut handle), Some(media_id)) => { - // Check if MPV is still alive - if !handle.is_alive() { - info!("External MPV process has ended"); - - // Get final state before dropping the handle - let (position, duration) = handle.poll_position(); - let final_fullscreen = handle.get_final_fullscreen(); - - info!( - "position: {:?}, duration: {:?}", - position, duration - ); - - state.last_valid_position = position; - state.is_fullscreen = final_fullscreen; + let Some(mut handle) = state.external_mpv_handle.take() else { + return DomainUpdateResult::task(Task::none()); + }; - // Clear external MPV state - state.external_mpv_active = false; + // Drain pending IPC observations before checking the process result + // so the final snapshot survives process-handle teardown. + let (position, duration) = handle.poll_position(); + let fullscreen = handle.get_final_fullscreen(); + if !handle.is_alive() { + info!( + "External MPV process ended at {:.3}s / {:.3}s", + position, duration + ); + state.finish_external_playback( + position, + duration, + fullscreen, + EndReason::Eof, + ); + state.last_valid_position = position; + state.last_valid_duration = duration; + state.is_fullscreen = fullscreen; + drop(handle); - // Send final progress update - let end_playback_task = - Task::done(P::playback_message( - PlayerMessage::ExternalPlaybackEnded, - )); - let progress_task = - Task::done(P::send_progress_update( - media_id, position, duration, - )); - - // Navigate back to previous view - let nav_task = Task::done(P::navigate_back()); - - // Emit RestoreWindow event and return tasks - DomainUpdateResult::with_events( - end_playback_task - .chain(progress_task) - .chain(nav_task), - vec![PlaybackWindowEvent::RestoreWindow( - final_fullscreen, - )], - ) - } else { - // Poll for position updates - let (position, duration) = handle.poll_position(); - - // Put the handle back - state.external_mpv_handle = Some(handle); - - // Update state if we got valid data - if position >= 0.0 && duration > 0.0 { - update_player::

( - context, - PlayerMessage::ExternalPlaybackUpdate { - position, - duration, - }, - ) - } else { - DomainUpdateResult::task(Task::none()) - } - } + DomainUpdateResult::task(Task::done(P::playback_message( + PlayerMessage::ExternalPlaybackEnded, + ))) + } else { + state.external_mpv_handle = Some(handle); + state.update_external_playback_snapshot(position, duration); + if position >= 0.0 { + state.last_valid_position = position; + } + if duration > 0.0 { + state.last_valid_duration = duration; + } + if position > 0.0 && duration > 0.0 { + state.last_progress_sent = position; } - _ => DomainUpdateResult::task(Task::none()), + DomainUpdateResult::task(Task::none()) } } @@ -1379,6 +1798,7 @@ where PlayerMessage::StreamUrlResolutionFailed(message) => { state.current_url = None; + state.current_source = None; state.is_resolving_stream_url = false; state.stream_url_resolution_failed = true; state.is_loading_video = false; @@ -1386,66 +1806,168 @@ where DomainUpdateResult::task(Task::none()) } - // Accept resolved URL and kick off playback - PlayerMessage::SetStreamUrl(video_url) => { + // Accept a resolved source and kick off playback. The source keeps its + // playback ticket in an Authorization header rather than the URI. + PlayerMessage::SetStreamSource(source) => { state.is_resolving_stream_url = false; state.stream_url_resolution_failed = false; + let display_url = source.uri().as_str().to_string(); + state.set_playback_source(source); - if video_url.is_empty() { - // Should not happen; guard to avoid parsing panics - context.ui.set_video_error( - "Failed to resolve stream URL".to_string(), - ); - return DomainUpdateResult::task(Task::none()); - } + // If we're already in the Player view (e.g., next/prev episode while playing), + // keep the Player view and swap streams seamlessly without showing the loading page. + // Otherwise (e.g., initial play from library), show the loading view. + let in_player_already = + context.ui.is_player_view() || state.video_opt.is_some(); - match url::Url::parse(&video_url) { - Ok(url) => { - // Set the new URL for the player - state.current_url = Some(url); - - // If we're already in the Player view (e.g., next/prev episode while playing), - // keep the Player view and swap streams seamlessly without showing the loading page. - // Otherwise (e.g., initial play from library), show the loading view. - let in_player_already = context.ui.is_player_view() - || state.video_opt.is_some(); - - // Clear any previous error - context.ui.clear_error(); - - if in_player_already { - // Ensure we stay on the Player view for near-instant transitions - context.ui.set_player_view(); - // Explicitly close the existing provider so load_video doesn't early-return - close_video(state); - } else { - // First-time play or not currently in player: show loading view briefly - context.ui.set_loading_video_view(video_url); - } + // Clear any previous error + context.ui.clear_error(); - // Load the new video URL - DomainUpdateResult::task(load_video::

(state, context.ui)) - } - Err(e) => { - context.ui.set_video_error(format!("Invalid URL: {}", e)); - DomainUpdateResult::task(Task::none()) - } + if in_player_already { + // Ensure we stay on the Player view for near-instant transitions + context.ui.set_player_view(); + // Explicitly close the existing provider so load_video doesn't early-return + close_video(state); + } else { + // The displayed/loading URL contains no playback credential. + context.ui.set_loading_video_view(display_url); } + + DomainUpdateResult::task(load_video::

(state, context.ui)) } } } -/// Prepare a restart of the current episode. -/// If `external_active` is true, seeks external MPV to the start and updates state, -/// returning None (no further action needed). If using the internal player and a -/// video is loaded, returns the normalized seek base so the caller can issue a -/// relative seek via `update_player`. +fn finish_terminated_playback

( + state: &PlayerDomainState, + snapshot: &crate::contract::PlaybackSnapshot, +) -> Task +where + P: PlaybackUpdatePort + 'static, +{ + let progress = final_snapshot_progress(state, snapshot).map_or_else( + Task::none, + |(media_id, position, duration)| { + Task::done(P::send_progress_update(media_id, position, duration)) + }, + ); + + // A native-window close is a user exit, not EOF. In particular, do not + // auto-advance an episode after mpv's top-level window is closed. + sequence_tasks([ + progress, + Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), + Task::done(P::navigate_back()), + ]) +} + +/// Read one backend-neutral progress pair. In-process adapters get one bounded +/// synchronization turn; external playback is already reduced by its IPC poll. +fn current_playback_progress(state: &mut PlayerDomainState) -> (f64, f64) { + if let Some(video) = state.video_opt.as_mut() { + video.synchronize_snapshot(); + } + + let Some(snapshot) = state.playback_snapshot() else { + return (state.last_valid_position, state.last_valid_duration); + }; + let observed_position = snapshot.position.as_secs_f64(); + let position = + if observed_position > 0.0 || state.last_valid_position <= 0.0 { + observed_position + } else { + state.last_valid_position + }; + let duration = snapshot + .duration + .map(|duration| duration.as_secs_f64()) + .filter(|duration| *duration > 0.0) + .unwrap_or(state.last_valid_duration); + (position, duration) +} + +/// Chain message-producing tasks in semantic order. Iced's `Task::batch` +/// intentionally runs streams in parallel, which is unsafe for final progress +/// followed by reset/navigation or replacement playback. +fn sequence_tasks( + tasks: impl IntoIterator>, +) -> Task +where + Message: Send + 'static, +{ + tasks + .into_iter() + .fold(Task::none(), |sequence, task| sequence.chain(task)) +} + +fn should_restart_current_episode(position: f64, duration: f64) -> bool { + // Preserve the established player rule: at or beyond five percent, + // Previous restarts the current episode. With no trustworthy duration, + // restarting is safer than unexpectedly leaving the current item. + !position.is_finite() + || !duration.is_finite() + || duration <= 0.0 + || position.max(0.0) / duration >= 0.05 +} + +fn progress_checkpoint_task

( + state: &mut PlayerDomainState, +) -> Task +where + P: PlaybackUpdatePort + 'static, +{ + let progress = current_playback_progress(state); + state.current_media_id.map_or_else(Task::none, |media_id| { + Task::done(P::send_progress_update(media_id, progress.0, progress.1)) + }) +} + +fn final_snapshot_progress( + state: &PlayerDomainState, + snapshot: &crate::contract::PlaybackSnapshot, +) -> Option<(MediaID, f64, f64)> { + let snapshot_position = snapshot.position.as_secs_f64(); + let position = + if snapshot_position > 0.0 || state.last_valid_position <= 0.0 { + snapshot_position + } else { + state.last_valid_position + }; + Some(( + state.current_media_id?, + position, + snapshot + .duration + .map(|duration| duration.as_secs_f64()) + .filter(|duration| *duration > 0.0) + .unwrap_or(state.last_valid_duration), + )) +} + +fn current_playback_start_mode(state: &PlayerDomainState) -> PlaybackStartMode { + if state.is_external_playback() { + PlaybackStartMode::External + } else if state + .playback_snapshot() + .is_some_and(|snapshot| snapshot.target.backend == BackendKind::Mpv) + || matches!( + state.backend_request, + BackendRequest::Exact(target) if target.backend == BackendKind::Mpv + ) + { + PlaybackStartMode::MpvNativeWindow + } else { + PlaybackStartMode::Internal + } +} + +/// Prepare a restart of the current episode. External-process selection is +/// read from the same backend-neutral snapshot used by episode policy. fn prepare_restart_current_episode( state: &mut PlayerDomainState, - external_active: bool, position: f64, ) -> Option { - if external_active { + if state.is_external_playback() { if let Some(handle) = state.external_mpv_handle.as_mut() { if let Err(e) = handle.seek_absolute(0.0) { error!("Failed to seek external MPV to start: {}", e); @@ -1481,24 +2003,24 @@ where context.window_position.map(|p| (p.x as i32, p.y as i32)); // Ensure handoff starts at the current native player position - let resume_position = if let Some(video) = state.video_opt.as_ref() { - let pos = video.position().as_secs_f64(); - if pos > 0.0 { - Some(pos as f32) - } else { - state.pending_resume_position - } - } else if state.last_valid_position > 0.0 { - Some(state.last_valid_position as f32) + let (observed_position, _) = current_playback_progress(state); + let resume_position = if observed_position > 0.0 { + Some(observed_position as f32) } else { state.pending_resume_position }; let url = state - .current_url + .current_source .as_ref() - .map(|u| u.to_string()) - .unwrap_or_default(); + .map(external_mpv_url) + .or_else(|| { + state + .current_url + .as_ref() + .map(|url| Zeroizing::new(url.to_string())) + }) + .unwrap_or_else(|| Zeroizing::new(String::new())); if url.is_empty() { if state.is_resolving_stream_url { @@ -1527,15 +2049,27 @@ where state.stop_native_playback(); match crate::external_mpv::start_external_playback( - &url, + url.as_str(), is_fullscreen, window_size, window_position, resume_position, ) { Ok(handle) => { - state.external_mpv_active = true; + let Some(generation) = state.playback_generation.next() else { + context.ui.set_video_error( + "Playback session generation exhausted".to_string(), + ); + return DomainUpdateResult::task(Task::none()); + }; + state.playback_generation = generation; state.external_mpv_handle = Some(Box::new(handle)); + state.begin_external_playback( + generation, + resume_position.unwrap_or_default() as f64, + state.last_valid_duration, + is_fullscreen, + ); context.ui.set_player_view(); DomainUpdateResult::task(Task::done(P::playback_message( @@ -1548,24 +2082,1137 @@ where "Failed to start external MPV (falling back to internal): {}", e ); - state.external_mpv_active = false; + state.clear_external_playback(); DomainUpdateResult::task(load_video::

(state, context.ui)) } } } +/// Convert a header-authenticated Ferrex source only at the explicit legacy +/// external-process boundary. In-process Subwave/libmpv loads never place the +/// playback ticket in their URL. The returned buffer is zeroized after spawn. +fn external_mpv_url(source: &PlaybackSource) -> Zeroizing { + let already_ticketed = source + .uri() + .query_pairs() + .any(|(name, _)| name == "access_token"); + let bearer_ticket = (!already_ticketed) + .then(|| { + source.headers().iter().find_map(|header| { + if !header.name.eq_ignore_ascii_case("authorization") { + return None; + } + header + .value + .expose_secret() + .strip_prefix("Bearer ") + .filter(|ticket| !ticket.is_empty()) + }) + }) + .flatten(); + + let Some(ticket) = bearer_ticket else { + return Zeroizing::new(source.uri().to_string()); + }; + + // Serialize directly into the buffer that will be zeroized. Avoid putting + // the header credential into a temporary `Url` allocation. + let mut url = source.uri().clone(); + let fragment = url.fragment().map(str::to_owned); + url.set_fragment(None); + let mut output = url.to_string(); + output.push(if url.query().is_some() { '&' } else { '?' }); + let query_start = output.len(); + let mut serializer = + url::form_urlencoded::Serializer::for_suffix(output, query_start); + serializer.append_pair("access_token", ticket); + let mut output = serializer.finish(); + if let Some(fragment) = fragment { + output.push('#'); + output.push_str(&fragment); + } + Zeroizing::new(output) +} + #[cfg(test)] mod tests { use super::*; use ferrex_player_api::testing::TestApiService; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; + + #[derive(Debug, Clone, PartialEq)] + enum RecordedMessage { + Playback(String), + Progress(MediaID, f64, f64), + Back, + Home, + Play(MediaID, PlaybackStartMode), + } + + static RECORDS: Mutex> = Mutex::new(Vec::new()); + static RECORDING_TEST: Mutex<()> = Mutex::new(()); + + struct RecordingPort; + + impl PlaybackUpdatePort for RecordingPort { + type AppMessage = RecordedMessage; + + fn playback_message(message: PlayerMessage) -> Self::AppMessage { + let message = RecordedMessage::Playback(format!("{message:?}")); + RECORDS.lock().unwrap().push(message.clone()); + message + } + + fn send_progress_update( + media_id: MediaID, + position: f64, + duration: f64, + ) -> Self::AppMessage { + let message = + RecordedMessage::Progress(media_id, position, duration); + RECORDS.lock().unwrap().push(message.clone()); + message + } + + fn navigate_back() -> Self::AppMessage { + RECORDS.lock().unwrap().push(RecordedMessage::Back); + RecordedMessage::Back + } + + fn navigate_home() -> Self::AppMessage { + RECORDS.lock().unwrap().push(RecordedMessage::Home); + RecordedMessage::Home + } + + fn play_media_with_id( + media_id: MediaID, + mode: PlaybackStartMode, + ) -> Self::AppMessage { + let message = RecordedMessage::Play(media_id, mode); + RECORDS.lock().unwrap().push(message.clone()); + message + } + } + + #[derive(Default)] + struct TestUi { + player: bool, + error: Option, + } + + impl PlaybackUiShell for TestUi { + fn is_player_view(&self) -> bool { + self.player + } + + fn set_player_view(&mut self) { + self.player = true; + } + + fn set_loading_video_view(&mut self, _url: String) { + self.player = false; + } + + fn set_video_error(&mut self, message: String) { + self.error = Some(message); + } + + fn clear_error(&mut self) { + self.error = None; + } + } + + struct NoopWatchProgress; + + impl PlaybackWatchProgressPort for NoopWatchProgress { + fn take_pending_resume_position(&mut self) -> Option { + None + } + } + + struct NoopEpisodes; + + impl PlaybackEpisodeNavigator for NoopEpisodes { + fn next_episode(&self, _current: EpisodeID) -> Option { + None + } + + fn previous_episode(&self, _current: EpisodeID) -> Option { + None + } + } + + struct TestEpisodes { + next: EpisodeID, + previous: EpisodeID, + } + + impl PlaybackEpisodeNavigator for TestEpisodes { + fn next_episode(&self, _current: EpisodeID) -> Option { + Some(self.next) + } + + fn previous_episode(&self, _current: EpisodeID) -> Option { + Some(self.previous) + } + } + + fn record_terminal_update(message: PlayerMessage) -> Vec { + RECORDS.lock().unwrap().clear(); + let media_id = MediaID::Movie(MovieID::new_uuid()); + let mut playback = PlayerDomainState { + current_media_id: Some(media_id), + last_valid_position: 42.5, + last_valid_duration: 100.0, + ..PlayerDomainState::default() + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; + let episodes = NoopEpisodes; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + + drop(update_player::(&mut context, message)); + std::mem::take(&mut *RECORDS.lock().unwrap()) + } + + #[test] + fn stop_and_eof_persist_final_progress_before_exit() { + let _serial = RECORDING_TEST.lock().unwrap(); + for message in [PlayerMessage::Stop, PlayerMessage::EndOfStream] { + let records = record_terminal_update(message); + assert!(matches!( + records.first(), + Some(RecordedMessage::Progress(_, 42.5, 100.0)) + )); + assert!(records.contains(&RecordedMessage::Playback( + "ResetAfterStop".to_string() + ))); + assert!(records.contains(&RecordedMessage::Back)); + } + } + + #[test] + fn back_and_home_navigation_checkpoint_then_exit_playback() { + let _serial = RECORDING_TEST.lock().unwrap(); + for (message, destination) in [ + (PlayerMessage::NavigateBack, RecordedMessage::Back), + (PlayerMessage::NavigateHome, RecordedMessage::Home), + ] { + let records = record_terminal_update(message); + assert!(matches!( + records.first(), + Some(RecordedMessage::Progress(_, 42.5, 100.0)) + )); + assert!(records.contains(&RecordedMessage::Playback( + "ResetAfterStop".to_string() + ))); + assert!(records.contains(&destination)); + } + } + + #[test] + fn reset_after_stop_emits_one_backend_neutral_host_restore() { + let _serial = RECORDING_TEST.lock().unwrap(); + RECORDS.lock().unwrap().clear(); + let mut playback = PlayerDomainState { + current_media_id: Some(MediaID::Movie(MovieID::new_uuid())), + last_valid_position: 42.5, + ..PlayerDomainState::default() + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi::default(); + let episodes = NoopEpisodes; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + + let result = update_player::( + &mut context, + PlayerMessage::ResetAfterStop, + ); + + assert!(playback.current_media_id.is_none()); + assert!(matches!( + result.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited] + )); + } + + fn state_with_presenter_snapshot( + generation: crate::contract::SessionGeneration, + target: crate::contract::PlaybackTarget, + presenter: crate::contract::PresenterState, + ) -> PlayerDomainState { + let mut snapshot = crate::contract::PlaybackSnapshot::new( + generation, + target, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.presenter = presenter; + PlayerDomainState { + external_mpv_snapshot: Some(snapshot), + ..PlayerDomainState::default() + } + } + + #[test] + fn native_presenter_handoff_events_are_once_per_outcome_and_generation() { + let first_generation = crate::contract::SessionGeneration::new(7); + let mut state = state_with_presenter_snapshot( + first_generation, + crate::contract::PlaybackTarget::MPV_INTEGRATED, + crate::contract::PresenterState::Attached, + ); + + assert!(matches!( + take_native_presenter_window_event(&mut state), + Some(PlaybackWindowEvent::NativePresenterAttached) + )); + + for presenter in [ + crate::contract::PresenterState::Hidden, + crate::contract::PresenterState::Suspended, + crate::contract::PresenterState::Attached, + ] { + state + .external_mpv_snapshot + .as_mut() + .expect("presenter snapshot") + .presenter = presenter; + assert!(take_native_presenter_window_event(&mut state).is_none()); + } + + state + .external_mpv_snapshot + .as_mut() + .expect("presenter snapshot") + .presenter = crate::contract::PresenterState::Failed; + assert!(matches!( + take_native_presenter_window_event(&mut state), + Some(PlaybackWindowEvent::NativePresenterUnavailable) + )); + assert!(take_native_presenter_window_event(&mut state).is_none()); + + let next_generation = first_generation.next().expect("next generation"); + let snapshot = state + .external_mpv_snapshot + .as_mut() + .expect("presenter snapshot"); + snapshot.generation = next_generation; + snapshot.presenter = crate::contract::PresenterState::Attached; + assert!(matches!( + take_native_presenter_window_event(&mut state), + Some(PlaybackWindowEvent::NativePresenterAttached) + )); + state + .external_mpv_snapshot + .as_mut() + .expect("presenter snapshot") + .presenter = crate::contract::PresenterState::Failed; + assert!(matches!( + take_native_presenter_window_event(&mut state), + Some(PlaybackWindowEvent::NativePresenterUnavailable) + )); + } + + #[test] + fn native_presenter_fallback_unavailable_event_is_not_repeated() { + let generation = crate::contract::SessionGeneration::new(11); + let mut state = state_with_presenter_snapshot( + generation, + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + crate::contract::PresenterState::Detached, + ); + state + .external_mpv_snapshot + .as_mut() + .expect("fallback snapshot") + .fallback_chain + .push(crate::contract::FallbackReason { + code: crate::contract::FallbackReasonCode::PresenterFailed, + from: Some(crate::contract::PlaybackTarget::MPV_INTEGRATED), + to: crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + detail: "presenter unavailable".to_string(), + }); + + assert!(matches!( + take_native_presenter_window_event(&mut state), + Some(PlaybackWindowEvent::NativePresenterUnavailable) + )); + assert!(take_native_presenter_window_event(&mut state).is_none()); + + state + .external_mpv_snapshot + .as_mut() + .expect("fallback snapshot") + .generation = generation.next().expect("next generation"); + assert!(matches!( + take_native_presenter_window_event(&mut state), + Some(PlaybackWindowEvent::NativePresenterUnavailable) + )); + } + + #[test] + fn previous_episode_restart_policy_has_a_precise_five_percent_boundary() { + assert!(!should_restart_current_episode(4.999, 100.0)); + assert!(should_restart_current_episode(5.0, 100.0)); + assert!(should_restart_current_episode(80.0, 100.0)); + assert!(should_restart_current_episode(0.0, 0.0)); + assert!(should_restart_current_episode(f64::NAN, 100.0)); + assert!(should_restart_current_episode(1.0, f64::INFINITY)); + } + + #[test] + fn backend_error_progress_uses_last_owned_snapshot_values() { + let media_id = MediaID::Movie(MovieID::new_uuid()); + let state = PlayerDomainState { + current_media_id: Some(media_id), + last_valid_position: 8.0, + last_valid_duration: 90.0, + ..PlayerDomainState::default() + }; + let mut snapshot = crate::contract::PlaybackSnapshot::new( + crate::contract::SessionGeneration::INITIAL, + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.position = Duration::from_millis(12_500); + snapshot.duration = Some(Duration::from_secs(100)); + snapshot.state = crate::contract::PlaybackState::Failed; + + assert_eq!( + final_snapshot_progress(&state, &snapshot), + Some((media_id, 12.5, 100.0)) + ); + + snapshot.position = Duration::ZERO; + snapshot.duration = None; + assert_eq!( + final_snapshot_progress(&state, &snapshot), + Some((media_id, 8.0, 90.0)) + ); + } + + #[test] + fn native_window_close_persists_progress_and_does_not_advance_episode() { + let _serial = RECORDING_TEST.lock().unwrap(); + let media_id = MediaID::Episode(EpisodeID::new()); + let state = PlayerDomainState { + current_media_id: Some(media_id), + ..PlayerDomainState::default() + }; + + for reason in [ + crate::contract::EndReason::Closed, + crate::contract::EndReason::BackendTerminated, + ] { + RECORDS.lock().unwrap().clear(); + let mut snapshot = crate::contract::PlaybackSnapshot::new( + crate::contract::SessionGeneration::INITIAL, + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.position = Duration::from_millis(42_500); + snapshot.duration = Some(Duration::from_secs(100)); + snapshot.state = crate::contract::PlaybackState::Ended; + snapshot.end_reason = Some(reason); + + drop(finish_terminated_playback::( + &state, &snapshot, + )); + let records = std::mem::take(&mut *RECORDS.lock().unwrap()); + + assert!( + records.contains(&RecordedMessage::Progress( + media_id, 42.5, 100.0, + )) + ); + assert!(records.contains(&RecordedMessage::Playback( + "ResetAfterStop".to_string() + ))); + assert!(records.contains(&RecordedMessage::Back)); + assert!( + !records + .iter() + .any(|record| matches!(record, RecordedMessage::Play(..))) + ); + } + } + + #[test] + fn episode_transitions_persist_progress_and_preserve_backend_mode() { + let _serial = RECORDING_TEST.lock().unwrap(); + let cases = [ + (BackendRequest::Auto, false, PlaybackStartMode::Internal), + ( + BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + ), + false, + PlaybackStartMode::MpvNativeWindow, + ), + (BackendRequest::Auto, true, PlaybackStartMode::External), + ]; + + for (request, external, expected_mode) in cases { + let current = EpisodeID::new(); + let next = EpisodeID::new(); + let previous = EpisodeID::new(); + let episodes = TestEpisodes { next, previous }; + + for (message, expected_episode) in [ + (PlayerMessage::NextEpisode, next), + (PlayerMessage::PreviousEpisode, previous), + (PlayerMessage::EndOfStream, next), + ] { + RECORDS.lock().unwrap().clear(); + let external_mpv_snapshot = external.then(|| { + let mut snapshot = PlaybackSnapshot::new( + crate::contract::SessionGeneration::new(7), + crate::contract::PlaybackTarget::EXTERNAL_MPV, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.state = PlaybackState::Playing; + snapshot.position = Duration::from_secs(2); + snapshot.duration = Some(Duration::from_secs(100)); + snapshot + }); + let mut playback = PlayerDomainState { + current_media_id: Some(MediaID::Episode(current)), + backend_request: request, + external_mpv_snapshot, + last_valid_position: 2.0, + last_valid_duration: 100.0, + ..PlayerDomainState::default() + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + + drop(update_player::(&mut context, message)); + let records = std::mem::take(&mut *RECORDS.lock().unwrap()); + + assert!(records.contains(&RecordedMessage::Progress( + MediaID::Episode(current), + 2.0, + 100.0, + ))); + assert!(records.contains(&RecordedMessage::Play( + MediaID::Episode(expected_episode), + expected_mode, + ))); + } + } + } + + #[cfg(feature = "mpv")] + #[test] + #[ignore = "requires generated fixtures, linked libmpv, and a working desktop VO"] + fn linked_native_window_eof_reloads_next_episode_with_same_backend() { + let _serial = RECORDING_TEST.lock().unwrap(); + RECORDS.lock().unwrap().clear(); + + let fixture = std::env::var_os("FERREX_MPV_SMOKE_MEDIA") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../target/native-playback-fixtures/h264-sdr-8bit.mkv", + ) + }); + let fixture = std::fs::canonicalize(fixture).expect( + "native playback fixture is missing; run native_playback_fixtures.py generate", + ); + let fixture_uri = url::Url::from_file_path(&fixture) + .expect("fixture path should convert to a file URL"); + + let current = EpisodeID::new(); + let next = EpisodeID::new(); + let episodes = TestEpisodes { + next, + previous: EpisodeID::new(), + }; + let mut playback = PlayerDomainState { + current_media_id: Some(MediaID::Episode(current)), + backend_request: BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + ), + ..PlayerDomainState::default() + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + + drop(update_player::( + &mut context, + PlayerMessage::SetStreamSource( + PlaybackSource::new(fixture_uri.clone()) + .with_title("Synthetic episode one"), + ), + )); + let first_generation = context + .playback + .playback_snapshot() + .expect("first episode session") + .generation; + assert_eq!( + context + .playback + .playback_snapshot() + .map(|snapshot| snapshot.target), + Some(crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW) + ); + + let eof_deadline = Instant::now() + Duration::from_secs(10); + loop { + drop(update_player::( + &mut context, + PlayerMessage::PlaybackEventsReady, + )); + if RECORDS + .lock() + .unwrap() + .contains(&RecordedMessage::Playback("EndOfStream".to_string())) + { + break; + } + let snapshot = context + .playback + .playback_snapshot() + .expect("first episode snapshot"); + assert_ne!( + snapshot.state, + PlaybackState::Failed, + "first episode failed: {:?}", + snapshot.last_error + ); + assert!( + Instant::now() < eof_deadline, + "first episode did not reach EOF: {snapshot:?}" + ); + std::thread::sleep(Duration::from_millis(20)); + } + let eof = context + .playback + .playback_snapshot() + .expect("terminal first episode snapshot"); + assert_eq!(eof.state, PlaybackState::Ended); + assert_eq!(eof.end_reason, Some(EndReason::Eof)); + + RECORDS.lock().unwrap().clear(); + drop(update_player::( + &mut context, + PlayerMessage::EndOfStream, + )); + let transition = std::mem::take(&mut *RECORDS.lock().unwrap()); + assert!(transition.iter().any(|message| matches!( + message, + RecordedMessage::Progress(MediaID::Episode(id), position, duration) + if *id == current && *position > 0.0 && *duration > 0.0 + ))); + assert!(transition.contains(&RecordedMessage::Play( + MediaID::Episode(next), + PlaybackStartMode::MpvNativeWindow, + ))); + + // Simulate the app shell resolving the emitted next-episode request. + // SetStreamSource owns the real close/reopen path and must preserve the + // exact mpv backend while advancing the session generation. + context.playback.current_media_id = Some(MediaID::Episode(next)); + context.playback.pending_resume_position = None; + drop(update_player::( + &mut context, + PlayerMessage::SetStreamSource( + PlaybackSource::new(fixture_uri) + .with_title("Synthetic episode two"), + ), + )); + + let second_deadline = Instant::now() + Duration::from_secs(8); + let second_generation = loop { + drop(update_player::( + &mut context, + PlayerMessage::PlaybackEventsReady, + )); + let snapshot = context + .playback + .playback_snapshot() + .expect("second episode snapshot"); + assert_ne!( + snapshot.state, + PlaybackState::Failed, + "second episode failed: {:?}", + snapshot.last_error + ); + if matches!( + snapshot.state, + PlaybackState::Playing | PlaybackState::Paused + ) && snapshot.duration.is_some() + { + assert!(snapshot.generation > first_generation); + assert_eq!( + snapshot.target, + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW + ); + break snapshot.generation; + } + assert!( + Instant::now() < second_deadline, + "second episode did not start: {snapshot:?}" + ); + std::thread::sleep(Duration::from_millis(20)); + }; + + eprintln!( + "native-mpv episode replacement passed: generation={} -> {}", + first_generation.get(), + second_generation.get() + ); + close_video(context.playback); + } + + #[test] + fn external_terminal_snapshot_survives_handle_drop_and_advances_episode() { + let _serial = RECORDING_TEST.lock().unwrap(); + RECORDS.lock().unwrap().clear(); + let current = EpisodeID::new(); + let next = EpisodeID::new(); + let previous = EpisodeID::new(); + let episodes = TestEpisodes { next, previous }; + let mut snapshot = PlaybackSnapshot::new( + crate::contract::SessionGeneration::new(13), + crate::contract::PlaybackTarget::EXTERNAL_MPV, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.state = PlaybackState::Ended; + snapshot.end_reason = Some(EndReason::Eof); + snapshot.position = Duration::from_millis(42_500); + snapshot.duration = Some(Duration::from_secs(100)); + + let mut playback = PlayerDomainState { + current_media_id: Some(MediaID::Episode(current)), + external_mpv_snapshot: Some(snapshot), + ..PlayerDomainState::default() + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + + drop(update_player::( + &mut context, + PlayerMessage::ExternalPlaybackEnded, + )); + let records = std::mem::take(&mut *RECORDS.lock().unwrap()); + + assert!(records.contains(&RecordedMessage::Progress( + MediaID::Episode(current), + 42.5, + 100.0, + ))); + assert!(records.contains(&RecordedMessage::Play( + MediaID::Episode(next), + PlaybackStartMode::External, + ))); + assert!(!records.contains(&RecordedMessage::Back)); + assert!(playback.external_mpv_snapshot.is_none()); + } + + #[test] + fn external_snapshot_participates_in_progress_heartbeat() { + let _serial = RECORDING_TEST.lock().unwrap(); + RECORDS.lock().unwrap().clear(); + let media_id = MediaID::Movie(MovieID::new_uuid()); + let mut snapshot = PlaybackSnapshot::new( + crate::contract::SessionGeneration::new(14), + crate::contract::PlaybackTarget::EXTERNAL_MPV, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.state = PlaybackState::Playing; + snapshot.position = Duration::from_secs(17); + snapshot.duration = Some(Duration::from_secs(90)); + + let mut playback = PlayerDomainState { + current_media_id: Some(media_id), + external_mpv_snapshot: Some(snapshot), + ..PlayerDomainState::default() + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; + let episodes = NoopEpisodes; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + + drop(update_player::( + &mut context, + PlayerMessage::ProgressHeartbeat, + )); + + assert!( + RECORDS + .lock() + .unwrap() + .contains(&RecordedMessage::Progress(media_id, 17.0, 90.0)) + ); + } + + #[test] + fn event_snapshot_projection_waits_for_confirmed_seek_completion() { + let mut state = PlayerDomainState { + last_valid_position: 30.0, + seeking: true, + seek_started_time: Some(Instant::now()), + ..PlayerDomainState::default() + }; + let mut snapshot = PlaybackSnapshot::new( + crate::contract::SessionGeneration::INITIAL, + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.state = PlaybackState::Seeking; + snapshot.position = Duration::from_secs(12); + snapshot.duration = Some(Duration::from_secs(100)); + snapshot.volume = 0.4; + snapshot.muted = true; + snapshot.speed = 1.25; + snapshot.content_fit = PlaybackContentFit::Cover; + + apply_snapshot_to_domain( + &mut state, + &snapshot, + SnapshotDelivery::EventDriven, + ); + + assert!(state.seeking); + assert_eq!(state.last_valid_position, 30.0); + assert_eq!(state.last_valid_duration, 100.0); + + snapshot.state = PlaybackState::Playing; + snapshot.position = Duration::from_millis(42_500); + apply_snapshot_to_domain( + &mut state, + &snapshot, + SnapshotDelivery::EventDriven, + ); + + assert!(!state.seeking); + assert!(state.seek_started_time.is_none()); + assert_eq!(state.last_valid_position, 42.5); + assert_eq!(state.volume, 0.4); + assert!(state.is_muted); + assert_eq!(state.playback_speed, 1.25); + assert_eq!(state.content_fit, iced::ContentFit::Cover); + } + + #[test] + fn established_snapshot_track_changes_emit_one_backend_neutral_notice() { + let english_audio = crate::contract::AudioTrack { + id: crate::contract::TrackId::new("audio:eng"), + title: None, + language: Some("eng".to_string()), + codec: Some("aac".to_string()), + channels: Some(2), + sample_rate: None, + is_default: true, + is_forced: false, + }; + let japanese_audio = crate::contract::AudioTrack { + id: crate::contract::TrackId::new("audio:jpn"), + title: None, + language: Some("jpn".to_string()), + codec: Some("aac".to_string()), + channels: Some(2), + sample_rate: None, + is_default: false, + is_forced: false, + }; + let english_subtitle = crate::contract::SubtitleTrack { + id: crate::contract::TrackId::new("subtitle:eng"), + title: None, + language: Some("eng".to_string()), + codec: Some("ass".to_string()), + kind: crate::contract::SubtitleKind::Text, + is_default: true, + is_forced: false, + is_external: false, + }; + let mut state = PlayerDomainState { + track_catalog_generation: Some( + crate::contract::SessionGeneration::INITIAL, + ), + available_audio_tracks: vec![english_audio.clone()], + current_audio_track: Some(english_audio.id.clone()), + available_subtitle_tracks: vec![english_subtitle.clone()], + current_subtitle_track: Some(english_subtitle.id.clone()), + subtitles_enabled: true, + ..PlayerDomainState::default() + }; + let mut snapshot = PlaybackSnapshot::new( + crate::contract::SessionGeneration::INITIAL, + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.state = PlaybackState::Playing; + snapshot.tracks.audio = + vec![english_audio.clone(), japanese_audio.clone()]; + snapshot.tracks.selected_audio = Some(japanese_audio.id.clone()); + snapshot.tracks.subtitles = vec![english_subtitle]; + snapshot.tracks.selected_subtitle = None; + + apply_snapshot_to_domain( + &mut state, + &snapshot, + SnapshotDelivery::EventDriven, + ); + + let notice = state + .track_notification + .as_ref() + .expect("selection changes are visible") + .message + .clone(); + assert!(notice.contains("Audio: Japanese")); + assert!(notice.contains("Subtitles: Disabled")); + + state.track_notification = None; + apply_snapshot_to_domain( + &mut state, + &snapshot, + SnapshotDelivery::EventDriven, + ); + assert!(state.track_notification.is_none()); + + let mut initial = PlayerDomainState::default(); + apply_snapshot_to_domain( + &mut initial, + &snapshot, + SnapshotDelivery::EventDriven, + ); + assert!(initial.track_notification.is_none()); + + let mut replacement = snapshot.clone(); + replacement.generation = crate::contract::SessionGeneration::new(2); + replacement.tracks.selected_audio = Some(english_audio.id); + apply_snapshot_to_domain( + &mut initial, + &replacement, + SnapshotDelivery::EventDriven, + ); + assert!(initial.track_notification.is_none()); + assert_eq!( + initial.track_catalog_generation, + Some(crate::contract::SessionGeneration::new(2)) + ); + } + + #[test] + fn loading_snapshot_does_not_erase_a_pending_resume_hint() { + let mut state = PlayerDomainState { + last_valid_position: 45.0, + pending_resume_position: Some(45.0), + ..PlayerDomainState::default() + }; + let mut snapshot = PlaybackSnapshot::new( + crate::contract::SessionGeneration::INITIAL, + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.state = PlaybackState::Loading; + + apply_snapshot_to_domain( + &mut state, + &snapshot, + SnapshotDelivery::EventDriven, + ); + + assert_eq!(state.last_valid_position, 45.0); + assert!(state.is_loading_video); + } + + #[test] + fn synchronized_eof_and_backend_error_have_one_terminal_dispatch() { + let _serial = RECORDING_TEST.lock().unwrap(); + let media_id = MediaID::Movie(MovieID::new_uuid()); + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; + + RECORDS.lock().unwrap().clear(); + let mut eof_state = PlayerDomainState { + current_media_id: Some(media_id), + ..PlayerDomainState::default() + }; + let mut eof = PlaybackSnapshot::new( + crate::contract::SessionGeneration::new(11), + crate::contract::PlaybackTarget::GSTREAMER_EMBEDDED, + crate::contract::PlaybackCapabilities::default(), + ); + eof.state = PlaybackState::Ended; + eof.end_reason = Some(EndReason::Eof); + assert!( + handle_synchronized_terminal::( + &mut eof_state, + &mut ui, + &eof, + ) + .is_some() + ); + assert!( + handle_synchronized_terminal::( + &mut eof_state, + &mut ui, + &eof, + ) + .is_some() + ); + assert_eq!( + RECORDS + .lock() + .unwrap() + .iter() + .filter(|message| matches!( + message, + RecordedMessage::Playback(name) if name == "EndOfStream" + )) + .count(), + 1 + ); + + RECORDS.lock().unwrap().clear(); + let mut failed_state = PlayerDomainState { + current_media_id: Some(media_id), + last_valid_duration: 100.0, + ..PlayerDomainState::default() + }; + let mut failed = PlaybackSnapshot::new( + crate::contract::SessionGeneration::new(12), + crate::contract::PlaybackTarget::GSTREAMER_EMBEDDED, + crate::contract::PlaybackCapabilities::default(), + ); + failed.state = PlaybackState::Failed; + failed.position = Duration::from_millis(12_500); + failed.duration = Some(Duration::from_secs(100)); + failed.last_error = Some(crate::contract::PlaybackError::new( + crate::contract::PlaybackErrorKind::Unknown, + "backend failed safely", + )); + + assert!( + handle_synchronized_terminal::( + &mut failed_state, + &mut ui, + &failed, + ) + .is_some() + ); + assert!( + RECORDS + .lock() + .unwrap() + .contains(&RecordedMessage::Progress(media_id, 12.5, 100.0)) + ); + assert_eq!(ui.error.as_deref(), Some("Unknown: backend failed safely")); + } + + #[test] + fn drag_seek_preview_uses_a_deterministic_dispatch_interval() { + let first = Instant::now(); + + assert!(drag_seek_is_due(None, first)); + assert!(!drag_seek_is_due( + Some(first), + first + crate::constants::seeking::SEEK_DRAG_THROTTLE + - Duration::from_millis(1), + )); + assert!(drag_seek_is_due( + Some(first), + first + crate::constants::seeking::SEEK_DRAG_THROTTLE, + )); + } #[tokio::test] - async fn resolve_playback_stream_url_returns_ticketed_url() { + async fn resolve_playback_stream_source_uses_a_bearer_header() { let api = TestApiService::new("https://ferrex.example"); api.set_playback_ticket("ticket secret/with symbols"); - let resolved = resolve_playback_stream_url( + let source = resolve_playback_stream_source( Arc::new(api), "https://ferrex.example/".to_string(), "media file".to_string(), @@ -1574,25 +3221,64 @@ mod tests { .expect("ticket resolution succeeds"); assert_eq!( - resolved, - "https://ferrex.example/api/v1/stream/media%20file?access_token=ticket%20secret%2Fwith%20symbols" + source.uri().as_str(), + "https://ferrex.example/api/v1/stream/media%20file" + ); + assert!(source.uri().query().is_none()); + assert_eq!(source.headers().len(), 1); + assert_eq!(source.headers()[0].name, "Authorization"); + assert_eq!( + source.headers()[0].value.expose_secret(), + "Bearer ticket secret/with symbols" + ); + assert!(!format!("{source:?}").contains("ticket secret")); + + let external_url = external_mpv_url(&source); + let external_url = url::Url::parse(external_url.as_str()).unwrap(); + assert_eq!( + external_url + .query_pairs() + .find(|(name, _)| name == "access_token") + .map(|(_, value)| value.into_owned()) + .as_deref(), + Some("ticket secret/with symbols") ); + assert!(source.uri().query().is_none()); } #[tokio::test] - async fn resolve_playback_stream_url_fails_closed_on_ticket_error() { + async fn resolve_playback_stream_source_fails_closed_on_ticket_error() { let api = TestApiService::new("https://ferrex.example"); api.set_playback_ticket_error("Unauthorized - please login again"); - let error = resolve_playback_stream_url( + let error = resolve_playback_stream_source( Arc::new(api), "https://ferrex.example".to_string(), "media-file".to_string(), ) .await - .expect_err("ticket failure must not return a bare stream URL"); + .expect_err("ticket failure must not return a bare stream source"); assert!(error.contains("Sign in again")); assert!(!error.contains("/api/v1/stream/media-file")); } + + #[tokio::test] + async fn resolve_playback_stream_source_rejects_header_injection() { + let api = TestApiService::new("https://ferrex.example"); + api.set_playback_ticket("ticket\r\nX-Injected: secret"); + + let error = resolve_playback_stream_source( + Arc::new(api), + "https://ferrex.example".to_string(), + "media-file".to_string(), + ) + .await + .expect_err("control characters must not enter a playback header"); + + assert!(error.contains("Could not authorize playback")); + assert!(!error.contains("ticket")); + assert!(!error.contains("X-Injected")); + assert!(!error.contains("secret")); + } } diff --git a/crates/ferrex-player-playback/src/video.rs b/crates/ferrex-player-playback/src/video.rs index 508b7333..264036e7 100644 --- a/crates/ferrex-player-playback/src/video.rs +++ b/crates/ferrex-player-playback/src/video.rs @@ -5,13 +5,21 @@ use crate::{ PlayerMessage, - diagnostics::redact_playback_url, + contract::{ + BackendKind, BackendRequest, FallbackReason, FallbackReasonCode, + PlaybackCommand, PlaybackContentFit, PlaybackError, PlaybackSource, + SessionGeneration, + }, + session::PlaybackSession, state::PlayerDomainState, + subwave_adapter::SubwavePlaybackAdapter, update::{PlaybackUiShell, PlaybackUpdatePort}, }; +#[cfg(feature = "mpv")] +use crate::{contract::PlaybackErrorKind, mpv_adapter::MpvPlaybackAdapter}; use iced::Task; -use subwave_unified::video::SubwaveVideo; +use std::time::Duration; // Helper functions #[cfg_attr( @@ -25,7 +33,7 @@ use subwave_unified::video::SubwaveVideo; pub fn close_video(state: &mut PlayerDomainState) { if let Some(mut video) = state.video_opt.take() { log::info!("Closing video"); - video.set_paused(true); + let _ = video.apply_command(PlaybackCommand::Stop); drop(video); } state.last_valid_position = 0.0; @@ -87,116 +95,114 @@ where state.last_valid_duration = duration_hint_before_close; } - let url = match &state.current_url { - Some(url) => url.clone(), + let source = match state.current_source.clone().or_else(|| { + state + .current_url + .clone() + .map(crate::contract::PlaybackSource::new) + }) { + Some(source) => source, None => { - ui.set_video_error("No URL provided".to_string()); + ui.set_video_error("No playback source provided".to_string()); state.is_loading_video = false; return Task::none(); } }; - - let redacted_url = redact_playback_url(url.as_str()); + let url = source.uri().clone(); log::info!("=== VIDEO LOADING DEBUG ==="); - log::info!("Loading video URL: {}", redacted_url); - log::info!("URL scheme: {}", url.scheme()); - log::info!("URL host: {:?}", url.host()); - log::info!("URL path: {}", url.path()); - - // Check if this is HDR content based on server metadata - let (use_hdr_pipeline, needs_metadata_fetch) = if let Some(current_media) = - &state.current_media - { + log::info!("Loading playback source: {source:?}"); + + // Seed duration from server metadata. Backend/presenter selection must not + // use a filename as HDR evidence; native output capability is confirmed by + // the selected backend's observed snapshot/diagnostics. + if let Some(current_media) = &state.current_media { if let Some(metadata) = ¤t_media.media_file_metadata && let Some(duration) = metadata.duration { state.last_valid_duration = duration; } - // Always log metadata for debugging - log::info!("Checking HDR status for: {}", current_media.filename); - - let has_color_metadata = - if let Some(metadata) = ¤t_media.media_file_metadata { - log::info!(" Color transfer: {:?}", metadata.color_transfer); - log::info!(" Color space: {:?}", metadata.color_space); - log::info!(" Color primaries: {:?}", metadata.color_primaries); - log::info!(" Bit depth: {:?}", metadata.bit_depth); - - // Check if we have any color metadata - metadata.color_transfer.is_some() - || metadata.color_space.is_some() - || metadata.color_primaries.is_some() - || metadata.bit_depth.is_some() - } else { - log::warn!(" No metadata available from server!"); - false - }; - - // If no color metadata and filename suggests HDR, we need to fetch metadata - let filename_suggests_hdr = current_media.filename.contains("2160p") - || current_media.filename.contains("UHD") - || current_media.filename.contains("HDR") - || current_media.filename.contains("DV"); - - let needs_fetch = !has_color_metadata && filename_suggests_hdr; - - if needs_fetch { - log::warn!( - " No color metadata for potential HDR file, metadata fetch needed!" - ); - } - - (false, needs_fetch) - } else { - (false, false) - }; - - // Override HDR decision if filename suggests HDR but metadata is missing - let use_hdr_pipeline_final = if needs_metadata_fetch { - log::warn!( - "No HDR metadata available, using filename heuristics for provider selection" - ); - true // Use HDR provider for likely HDR content even without metadata - } else { - use_hdr_pipeline - }; + } // Validate URL is valid UTF-8 before using let url_string = url.as_str(); if !url_string.is_ascii() { + let non_ascii_bytes = + url_string.bytes().filter(|byte| !byte.is_ascii()).count(); log::warn!( - "URL contains non-ASCII characters: {}", - redact_playback_url(url_string) + "Playback source URI contains {non_ascii_bytes} non-ASCII byte(s)" ); - // Check each byte - for (i, byte) in url_string.bytes().enumerate() { - if byte > 127 { - log::warn!("Non-ASCII byte at position {}: 0x{:02x}", i, byte); - } - } } log::info!( - "Creating Video object with URL: {} (HDR: {})", - redact_playback_url(url_string), - use_hdr_pipeline_final + "Creating backend-neutral playback provider (source: {source:?})" ); ui.set_player_view(); let res_pos: f64 = state.pending_resume_position.unwrap_or(0.0).into(); - // Create video synchronously on the UI thread and update state immediately - match SubwaveVideo::open_at_seconds(&url, res_pos) { - Ok(video) => { - let duration = video.duration().as_secs_f64(); - if duration > 0.0 { - state.last_valid_duration = duration; + let Some(generation) = state.playback_generation.next() else { + log::error!("Playback session generation exhausted"); + ui.set_video_error("Playback session generation exhausted".to_string()); + state.is_loading_video = false; + return Task::done(P::playback_message(PlayerMessage::VideoLoaded( + false, + ))); + }; + state.playback_generation = generation; + + let source = source.with_title( + state + .current_media + .as_ref() + .map(|media| media.filename.as_str()) + .unwrap_or("Ferrex playback"), + ); + let start = Duration::try_from_secs_f64(res_pos).unwrap_or_default(); + + // Create the selected adapter synchronously and update state immediately. + // Auto deliberately remains Subwave during the staged rollout; only an + // exact mpv request enters the in-process native-window path. + match open_playback_session( + &source, + start, + generation, + state.backend_request, + ) { + Ok(mut video) => { + if matches!( + state.backend_request, + BackendRequest::Exact(target) if target.backend == BackendKind::Mpv + ) && video.snapshot().target.backend != BackendKind::Mpv + { + state.backend_request = BackendRequest::Auto; } - state.video_opt = Some(video); + let _ = + video.apply_command(PlaybackCommand::SetVolume(state.volume)); + let _ = + video.apply_command(PlaybackCommand::SetMuted(state.is_muted)); + let _ = video + .apply_command(PlaybackCommand::SetSpeed(state.playback_speed)); + let _ = video.apply_command(PlaybackCommand::SetContentFit( + playback_content_fit(state.content_fit), + )); + if state.is_fullscreen { + let _ = + video.apply_command(PlaybackCommand::SetFullscreen(true)); + } + video.synchronize_snapshot(); + if let Some(duration) = video.snapshot().duration { + let duration = duration.as_secs_f64(); + if duration > 0.0 { + state.last_valid_duration = duration; + } + } + + state.terminal_generation_handled = None; + state.video_opt = Some(video); state.is_loading_video = false; ui.set_player_view(); @@ -210,3 +216,306 @@ where } } } + +/// Open a backend-neutral playback session for an already resolved source. +/// +/// Callers must keep authentication in [`PlaybackSource`] headers or cookies. +/// Exact backend requests still follow Ferrex's deterministic fallback policy, +/// which is reflected by the returned session snapshot and diagnostics. +pub fn open_playback_session( + source: &PlaybackSource, + start: Duration, + generation: SessionGeneration, + request: BackendRequest, +) -> Result { + let requested_mpv_target = match request { + BackendRequest::Exact(target) if target.backend == BackendKind::Mpv => { + Some(target) + } + BackendRequest::Auto | BackendRequest::Exact(_) => None, + }; + let mut fallback = None; + + #[cfg(feature = "mpv")] + if let Some(requested_target) = requested_mpv_target { + if let Some(detail) = packaged_mpv_platform_unavailability() { + log::warn!( + "playback_fallback code=unsupported_platform from={} to=gstreamer-auto detail={detail}", + playback_target_label(requested_target), + ); + fallback = Some(( + FallbackReasonCode::UnsupportedPlatform, + detail.to_string(), + )); + } else { + let presentation = PlaybackSession::preflight_mpv_presentation( + requested_target, + generation, + ); + let effective_target = presentation.target(); + match MpvPlaybackAdapter::open_for_target( + source, + start, + generation, + effective_target, + ) { + Ok(adapter) => { + let report = adapter.compatibility_report(); + log::info!( + "Using in-process mpv backend target={} (client API {}, bindings {})", + playback_target_label(effective_target), + report.runtime, + report.bindings + ); + if effective_target != requested_target { + let detail = "integrated presenter preflight selected native-window compatibility mode"; + log::warn!( + "playback_fallback code=missing_capability from={} to={} detail={detail}", + playback_target_label(requested_target), + playback_target_label(effective_target), + ); + } + return Ok(PlaybackSession::from_mpv( + adapter, + request, + presentation, + )); + } + Err(error) => { + let code = mpv_initialization_fallback_code(&error); + log::warn!( + "playback_fallback code={} from={} to=gstreamer-auto detail={error}", + fallback_reason_code_label(code), + playback_target_label(requested_target), + ); + fallback = Some((code, error.to_string())); + } + } + } + } + + #[cfg(not(feature = "mpv"))] + if let Some(requested_target) = requested_mpv_target { + let detail = "in-process mpv support is disabled in this build"; + log::warn!( + "playback_fallback code=backend_disabled from={} to=gstreamer-auto detail={detail}", + playback_target_label(requested_target), + ); + fallback = Some((FallbackReasonCode::BackendDisabled, detail.into())); + } + + let mut adapter = SubwavePlaybackAdapter::open(source, start, generation)?; + if let Some((code, detail)) = fallback { + adapter.record_fallback(FallbackReason { + code, + from: requested_mpv_target, + to: adapter.snapshot().target, + detail, + }); + } + Ok(PlaybackSession::from_subwave(adapter, request)) +} + +#[cfg(all(feature = "mpv", target_os = "linux"))] +fn packaged_mpv_platform_unavailability() -> Option<&'static str> { + mpv_linux_platform_unavailability( + option_env!("FERREX_MPV_X11"), + std::env::var_os("WAYLAND_DISPLAY").is_some(), + std::env::var_os("WAYLAND_SOCKET").is_some(), + std::env::var_os("DISPLAY").is_some(), + ) +} + +#[cfg(all(feature = "mpv", not(target_os = "linux")))] +fn packaged_mpv_platform_unavailability() -> Option<&'static str> { + None +} + +#[cfg(all(feature = "mpv", target_os = "linux"))] +fn mpv_linux_platform_unavailability( + x11_profile: Option<&str>, + has_wayland_display: bool, + has_wayland_socket: bool, + has_x11_display: bool, +) -> Option<&'static str> { + if x11_profile == Some("disabled") + && !has_wayland_display + && !has_wayland_socket + && has_x11_display + { + Some( + "the packaged LGPL libmpv profile excludes mpv 0.41's GPL-only X11 video output", + ) + } else { + None + } +} + +#[cfg(feature = "mpv")] +fn fallback_reason_code_label(code: FallbackReasonCode) -> &'static str { + match code { + FallbackReasonCode::RequestedUnavailable => "requested_unavailable", + FallbackReasonCode::MissingCapability => "missing_capability", + FallbackReasonCode::BackendDisabled => "backend_disabled", + FallbackReasonCode::RuntimeIncompatible => "runtime_incompatible", + FallbackReasonCode::InitializationFailed => "initialization_failed", + FallbackReasonCode::PresenterFailed => "presenter_failed", + FallbackReasonCode::UnsupportedPlatform => "unsupported_platform", + FallbackReasonCode::Policy => "policy", + } +} + +fn playback_target_label( + target: crate::contract::PlaybackTarget, +) -> &'static str { + match target { + crate::contract::PlaybackTarget::GSTREAMER_INTEGRATED => { + "gstreamer-integrated" + } + crate::contract::PlaybackTarget::GSTREAMER_EMBEDDED => { + "gstreamer-embedded" + } + crate::contract::PlaybackTarget::MPV_INTEGRATED => "mpv-integrated", + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW => { + "mpv-native-window" + } + crate::contract::PlaybackTarget::EXTERNAL_MPV => "external-mpv", + _ => "custom-target", + } +} + +#[cfg(feature = "mpv")] +fn mpv_initialization_fallback_code( + error: &PlaybackError, +) -> FallbackReasonCode { + match error.kind { + PlaybackErrorKind::BackendUnavailable + if error.message.contains("incompatible libmpv") => + { + FallbackReasonCode::RuntimeIncompatible + } + PlaybackErrorKind::BackendUnavailable => { + FallbackReasonCode::UnsupportedPlatform + } + PlaybackErrorKind::BackendInitialization => { + FallbackReasonCode::InitializationFailed + } + _ => FallbackReasonCode::InitializationFailed, + } +} + +pub(crate) fn playback_content_fit( + fit: iced::ContentFit, +) -> PlaybackContentFit { + match fit { + iced::ContentFit::Contain => PlaybackContentFit::Contain, + iced::ContentFit::Cover => PlaybackContentFit::Cover, + iced::ContentFit::Fill => PlaybackContentFit::Fill, + iced::ContentFit::None => PlaybackContentFit::None, + iced::ContentFit::ScaleDown => PlaybackContentFit::ScaleDown, + } +} + +/// Treat server/decoder metadata as content evidence without using parsed +/// filenames or assuming that the selected output can signal HDR. +pub(crate) fn media_file_metadata_indicates_hdr( + metadata: &ferrex_player_api::api_types::MediaFileMetadata, +) -> bool { + let transfer_is_hdr = metadata.color_transfer.as_deref().is_some_and(|v| { + let value = v.to_ascii_lowercase(); + value.contains("smpte2084") + || value.contains("arib-std-b67") + || value.contains("smpte2086") + || value.contains("pq") + || value.contains("hlg") + }); + let primaries_are_wide = metadata + .color_primaries + .as_deref() + .is_some_and(|v| v.to_ascii_lowercase().contains("bt2020")); + + metadata.bit_depth.is_some_and(|depth| depth > 8) + || transfer_is_hdr + || primaries_are_wide +} + +#[cfg(all(test, feature = "mpv", target_os = "linux"))] +mod tests { + use super::{ + media_file_metadata_indicates_hdr, mpv_linux_platform_unavailability, + }; + use ferrex_player_api::api_types::MediaFileMetadata; + + fn metadata() -> MediaFileMetadata { + MediaFileMetadata { + duration: None, + width: None, + height: None, + video_codec: None, + audio_codec: None, + bitrate: None, + framerate: None, + file_size: 0, + color_primaries: None, + color_transfer: None, + color_space: None, + bit_depth: None, + parsed_info: None, + } + } + + #[test] + fn hdr_content_evidence_uses_metadata_not_parsed_filename() { + let mut sdr = metadata(); + sdr.bit_depth = Some(8); + assert!(!media_file_metadata_indicates_hdr(&sdr)); + + let mut pq = metadata(); + pq.bit_depth = Some(8); + pq.color_transfer = Some("SMPTE2084".into()); + assert!(media_file_metadata_indicates_hdr(&pq)); + + let mut hlg = metadata(); + hlg.color_transfer = Some("arib-std-b67".into()); + assert!(media_file_metadata_indicates_hdr(&hlg)); + + let mut wide = metadata(); + wide.color_primaries = Some("BT2020".into()); + assert!(media_file_metadata_indicates_hdr(&wide)); + + let mut ten_bit = metadata(); + ten_bit.bit_depth = Some(10); + assert!(media_file_metadata_indicates_hdr(&ten_bit)); + } + + #[test] + fn lgpl_package_rejects_x11_only_session_before_starting_mpv() { + assert!( + mpv_linux_platform_unavailability( + Some("disabled"), + false, + false, + true, + ) + .is_some() + ); + + for supported in [ + (Some("enabled"), false, false, true), + (None, false, false, true), + (Some("disabled"), true, false, true), + (Some("disabled"), false, true, true), + (Some("disabled"), false, false, false), + ] { + assert_eq!( + mpv_linux_platform_unavailability( + supported.0, + supported.1, + supported.2, + supported.3, + ), + None + ); + } + } +} diff --git a/crates/ferrex-player-playback/src/view.rs b/crates/ferrex-player-playback/src/view.rs index 38fefa87..fa478d86 100644 --- a/crates/ferrex-player-playback/src/view.rs +++ b/crates/ferrex-player-playback/src/view.rs @@ -6,12 +6,184 @@ use super::messages::PlayerMessage; use super::state::{PlayerDomainState, TrackNotification}; use super::theme; +use crate::contract::{ + BackendKind, PlaybackSnapshot, PlaybackState, PresentationMode, + PresenterState, +}; use iced::Theme; use iced::{ Element, Length, Padding, widget::{Space, column, container, mouse_area, row, text}, }; +/// Backend-neutral status plate rendered over a playback surface. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlaybackSurfaceStatus { + Loading, + Buffering { percentage: Option }, + Seeking, + Stopping, + AwaitingHost, + AwaitingVideoOutput, + PresenterFailed(String), + PlaybackFailed(String), +} + +impl PlaybackSurfaceStatus { + /// Derive transient presentation UI exclusively from the reduced playback + /// snapshot. Backend adapters never participate in rendering decisions. + pub fn from_snapshot(snapshot: &PlaybackSnapshot) -> Option { + if snapshot.presenter == PresenterState::Failed { + return Some(Self::PresenterFailed(snapshot_error_message( + snapshot, + "The native video surface could not be attached.", + ))); + } + + match snapshot.state { + PlaybackState::Loading => Some(Self::Loading), + PlaybackState::Buffering => Some(Self::Buffering { + percentage: snapshot + .buffer + .percentage + .filter(|percentage| percentage.is_finite()) + .map(|percentage| { + (percentage.clamp(0.0, 1.0) * 100.0).round() as u8 + }), + }), + PlaybackState::Seeking => Some(Self::Seeking), + PlaybackState::Stopping => Some(Self::Stopping), + PlaybackState::Failed => Some(Self::PlaybackFailed( + snapshot_error_message(snapshot, "Playback failed."), + )), + PlaybackState::Idle + | PlaybackState::Playing + | PlaybackState::Paused + | PlaybackState::Ended + | PlaybackState::Terminated => { + if snapshot.target.presentation + != PresentationMode::IntegratedNative + { + return None; + } + + match snapshot.presenter { + PresenterState::AwaitingHost => Some(Self::AwaitingHost), + PresenterState::AwaitingVideoOutput => { + Some(Self::AwaitingVideoOutput) + } + PresenterState::Detached + | PresenterState::Attached + | PresenterState::Hidden + | PresenterState::Suspended + | PresenterState::Failed => None, + } + } + } + } + + pub const fn title(&self) -> &'static str { + match self { + Self::Loading => "Loading video…", + Self::Buffering { .. } => "Buffering…", + Self::Seeking => "Seeking…", + Self::Stopping => "Stopping playback…", + Self::AwaitingHost => "Preparing video surface…", + Self::AwaitingVideoOutput => "Waiting for video output…", + Self::PresenterFailed(_) => "Video presentation failed", + Self::PlaybackFailed(_) => "Playback failed", + } + } + + pub const fn symbol(&self) -> &'static str { + match self { + Self::Loading | Self::Buffering { .. } => "◌", + Self::Seeking => "↦", + Self::Stopping => "■", + Self::AwaitingHost | Self::AwaitingVideoOutput => "▣", + Self::PresenterFailed(_) | Self::PlaybackFailed(_) => "!", + } + } + + pub fn detail(&self) -> Option { + match self { + Self::Buffering { + percentage: Some(percentage), + } => Some(format!("{percentage}% buffered")), + Self::PresenterFailed(message) | Self::PlaybackFailed(message) => { + Some(message.clone()) + } + Self::Loading + | Self::Buffering { percentage: None } + | Self::Seeking + | Self::Stopping + | Self::AwaitingHost + | Self::AwaitingVideoOutput => None, + } + } +} + +fn snapshot_error_message( + snapshot: &PlaybackSnapshot, + fallback: &'static str, +) -> String { + snapshot + .last_error + .as_ref() + .map(|error| error.message.trim()) + .filter(|message| !message.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +/// Status for the active surface. In-process and retained external playback +/// both expose the same reduced snapshot; pre-session URL loading is the only +/// compatibility state read here. +pub fn playback_surface_status( + state: &PlayerDomainState, +) -> Option { + if let Some(snapshot) = state.playback_snapshot() { + PlaybackSurfaceStatus::from_snapshot(snapshot) + } else if state.is_loading_video || state.is_resolving_stream_url { + Some(PlaybackSurfaceStatus::Loading) + } else { + None + } +} + +/// Render a static status plate without requesting a frame-rate redraw loop. +pub fn playback_status_overlay( + status: PlaybackSurfaceStatus, +) -> Element<'static, PlayerMessage, Theme, iced_wgpu::Renderer> { + let mut content = column![ + text(status.symbol()) + .size(44) + .color(iced::Color::from_rgb(0.85, 0.85, 0.85)), + text(status.title()).size(20).color(iced::Color::WHITE), + ] + .align_x(iced::Alignment::Center) + .spacing(10); + + if let Some(detail) = status.detail() { + content = content.push( + text(detail) + .size(14) + .color(iced::Color::from_rgb(0.72, 0.72, 0.72)), + ); + } + + container( + container(content) + .padding([18, 26]) + .style(theme::container_playback_status), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + #[cfg_attr( any( feature = "profile-with-puffin", @@ -23,23 +195,38 @@ use iced::{ impl PlayerDomainState { /// Build the main player view /// Note: Returns wgpu renderer elements since waylandsink video playback requires GPU acceleration - pub fn view(&self) -> iced::Element<'_, PlayerMessage, Theme> { + pub fn view( + &self, + native_host_window: Option, + ) -> iced::Element<'_, PlayerMessage, Theme> { + let playback = self.playback_snapshot(); log::trace!( - "PlayerState::view() called - position: {:.2}s, duration: {:.2}s, source_duration: {:?}, controls: {}", - self.last_valid_position, - self.last_valid_duration, - self.source_duration, + "PlayerState::view() called - position: {:.2}s, duration: {:?}, target: {:?}, controls: {}", + playback + .map(|snapshot| snapshot.position.as_secs_f64()) + .unwrap_or_default(), + playback.and_then(|snapshot| snapshot.duration), + playback.map(|snapshot| snapshot.target), self.controls ); - // If external player is active, show a dedicated placeholder view - if self.external_mpv_active { + // The retained process path also projects into PlaybackSnapshot. The + // view chooses presentation from the neutral target and never reads + // process liveness or IPC fields directly. + if let Some(snapshot) = playback.filter(|snapshot| { + snapshot.target.backend == BackendKind::ExternalMpv + }) { + let duration = snapshot + .duration + .map(|duration| duration.as_secs_f64()) + .unwrap_or_default(); return container( column![ text("Playing Externally").size(24), text(format!( "Position: {:.0}s / {:.0}s", - self.last_valid_position, self.last_valid_duration + snapshot.position.as_secs_f64(), + duration )) .size(18), Space::new().height(Length::Fixed(20.0)), @@ -56,28 +243,24 @@ impl PlayerDomainState { .into(); } - if let Some(video) = &self.video_opt { + if let Some(video) = self.playback_widget(native_host_window) { let clickable_video = self.video_view(video); - // Overlay stack: video, then controls - let player_with_overlay: iced::Element = - if self.controls { - let controls = self.controls_overlay(); - - let mut children: Vec< - iced::Element< - PlayerMessage, - Theme, - iced_wgpu::Renderer, - >, - > = vec![clickable_video]; + // Overlay stack: video, snapshot-derived transient status, then + // controls. The status plate is static and never drives redraws. + let mut children: Vec< + iced::Element, + > = vec![clickable_video]; - children.push(controls); + if let Some(status) = playback_surface_status(self) { + children.push(playback_status_overlay(status)); + } + if self.controls { + children.push(self.controls_overlay()); + } - iced::widget::Stack::with_children(children).into() - } else { - clickable_video - }; + let player_with_overlay: iced::Element = + iced::widget::Stack::with_children(children).into(); let player_with_settings: iced::Element = if self.show_settings { @@ -154,26 +337,8 @@ impl PlayerDomainState { .width(Length::Fill) .height(Length::Fill) .into() - } else if self.is_loading_video { - // Show loading spinner in player view - let loading_content = column![ - // Static loading icon for now - animate later - text("⟳").size(64), - Space::new().height(Length::Fixed(20.0)), - text("Loading video...") - .size(18) - .color(iced::Color::from_rgb(0.7, 0.7, 0.7)), - ] - .align_x(iced::Alignment::Center) - .spacing(10); - - container(loading_content) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(theme::container_player) - .into() + } else if let Some(status) = playback_surface_status(self) { + playback_status_overlay(status) } else { container( column![ @@ -196,28 +361,11 @@ impl PlayerDomainState { /// Build the video player view fn video_view<'a>( &self, - video: &'a subwave_unified::video::SubwaveVideo, + player: Element<'a, PlayerMessage>, ) -> Element<'a, PlayerMessage> { - // Create the appropriate video player widget based on the backend - // Determine if overlay is active (controls or menus visible) - let overlay_active = self.controls - || self.show_settings - || self.show_subtitle_menu - || self.show_quality_menu - || self.track_notification.is_some(); - - let player: iced::Element = { - let on_new_frame = if overlay_active { - Some(PlayerMessage::NewFrame) - } else { - None - }; - video.widget(self.content_fit, on_new_frame) - }; - - // Wrap in a black background container first, then a mouse area to handle clicks - //let video_with_background = container(player).width(Length::Fill).height(Length::Fill); - + // Presentation redraw cadence is backend-owned. Player state arrives + // through copied event signals or the bounded legacy snapshot timer, + // never through a decoded-frame callback. iced::widget::mouse_area(player) .on_press(PlayerMessage::VideoClicked) .into() @@ -304,11 +452,7 @@ impl PlayerDomainState { /// Build a minimal player view for embedding (e.g., in library view) pub fn minimal_view(&self) -> Option> { - self.video_opt.as_ref().map(|video| { - let player = video - .widget(self.content_fit, Some(PlayerMessage::NewFrame)) - .map(|m| m); - + self.playback_widget(None).map(|player| { container(player) .width(Length::Fill) .style(theme::container_player) @@ -336,3 +480,119 @@ pub fn calculate_seek_position(x: f32, width: f32, duration: f64) -> f64 { let normalized = (x / width).clamp(0.0, 1.0); normalized as f64 * duration } + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::{ + BufferState, PlaybackCapabilities, PlaybackError, PlaybackErrorKind, + PlaybackTarget, SessionGeneration, + }; + + fn snapshot(target: PlaybackTarget) -> PlaybackSnapshot { + PlaybackSnapshot::new( + SessionGeneration::INITIAL, + target, + PlaybackCapabilities::default(), + ) + } + + #[test] + fn transient_surface_status_is_derived_from_snapshot_state() { + let mut snapshot = snapshot(PlaybackTarget::MPV_NATIVE_WINDOW); + + snapshot.state = PlaybackState::Loading; + assert_eq!( + PlaybackSurfaceStatus::from_snapshot(&snapshot), + Some(PlaybackSurfaceStatus::Loading) + ); + + snapshot.state = PlaybackState::Buffering; + snapshot.buffer = BufferState { + buffering: true, + percentage: Some(0.426), + ..BufferState::default() + }; + let status = PlaybackSurfaceStatus::from_snapshot(&snapshot) + .expect("buffering has a status plate"); + assert_eq!( + status, + PlaybackSurfaceStatus::Buffering { + percentage: Some(43) + } + ); + assert_eq!(status.detail().as_deref(), Some("43% buffered")); + + snapshot.state = PlaybackState::Seeking; + assert_eq!( + PlaybackSurfaceStatus::from_snapshot(&snapshot), + Some(PlaybackSurfaceStatus::Seeking) + ); + + snapshot.state = PlaybackState::Playing; + assert_eq!(PlaybackSurfaceStatus::from_snapshot(&snapshot), None); + } + + #[test] + fn integrated_presenter_readiness_has_an_explicit_status() { + let mut snapshot = snapshot(PlaybackTarget::MPV_INTEGRATED); + snapshot.state = PlaybackState::Playing; + + snapshot.presenter = PresenterState::AwaitingHost; + assert_eq!( + PlaybackSurfaceStatus::from_snapshot(&snapshot), + Some(PlaybackSurfaceStatus::AwaitingHost) + ); + + snapshot.presenter = PresenterState::AwaitingVideoOutput; + assert_eq!( + PlaybackSurfaceStatus::from_snapshot(&snapshot), + Some(PlaybackSurfaceStatus::AwaitingVideoOutput) + ); + + snapshot.presenter = PresenterState::Attached; + assert_eq!(PlaybackSurfaceStatus::from_snapshot(&snapshot), None); + + // Detached is neutral until a presenter lifecycle explicitly begins; + // this preserves the legacy integrated GStreamer path during rollout. + snapshot.target = PlaybackTarget::GSTREAMER_INTEGRATED; + snapshot.presenter = PresenterState::Detached; + assert_eq!(PlaybackSurfaceStatus::from_snapshot(&snapshot), None); + + // A native-window target does not wait on an Iced presenter either. + snapshot.target = PlaybackTarget::MPV_NATIVE_WINDOW; + assert_eq!(PlaybackSurfaceStatus::from_snapshot(&snapshot), None); + } + + #[test] + fn failures_use_structured_snapshot_errors_with_safe_fallback_text() { + let mut snapshot = snapshot(PlaybackTarget::MPV_NATIVE_WINDOW); + snapshot.state = PlaybackState::Failed; + assert_eq!( + PlaybackSurfaceStatus::from_snapshot(&snapshot), + Some(PlaybackSurfaceStatus::PlaybackFailed( + "Playback failed.".to_string() + )) + ); + + snapshot.last_error = Some(PlaybackError::new( + PlaybackErrorKind::UnsupportedMedia, + "Unsupported codec", + )); + assert_eq!( + PlaybackSurfaceStatus::from_snapshot(&snapshot), + Some(PlaybackSurfaceStatus::PlaybackFailed( + "Unsupported codec".to_string() + )) + ); + + snapshot.state = PlaybackState::Playing; + snapshot.presenter = PresenterState::Failed; + assert_eq!( + PlaybackSurfaceStatus::from_snapshot(&snapshot), + Some(PlaybackSurfaceStatus::PresenterFailed( + "Unsupported codec".to_string() + )) + ); + } +} diff --git a/crates/ferrex-player-ui/src/common/messages/cross_domain.rs b/crates/ferrex-player-ui/src/common/messages/cross_domain.rs index 9f4f0216..3045454e 100644 --- a/crates/ferrex-player-ui/src/common/messages/cross_domain.rs +++ b/crates/ferrex-player-ui/src/common/messages/cross_domain.rs @@ -352,6 +352,27 @@ pub fn handle_event( }) } + CrossDomainEvent::NativePresenterAttached => { + log::info!( + "[CrossDomain] Native presenter attached; activating player overlay" + ); + ui::windows::controller::activate_player_overlay(state).task + } + + CrossDomainEvent::NativePresenterUnavailable => { + log::warn!( + "[CrossDomain] Native presenter unavailable; dismissing player overlay" + ); + ui::windows::controller::dismiss_player_overlay(state).task + } + + CrossDomainEvent::PlaybackExited => { + log::info!( + "[CrossDomain] Playback exited; restoring the retained main window" + ); + ui::windows::controller::dismiss_player_overlay(state).task + } + // Media playback events CrossDomainEvent::MediaStartedPlaying(media_file) => { log::info!("[CrossDomain] Media started playing"); diff --git a/crates/ferrex-player-ui/src/common/messages/mod.rs b/crates/ferrex-player-ui/src/common/messages/mod.rs index 5150269f..16131d20 100644 --- a/crates/ferrex-player-ui/src/common/messages/mod.rs +++ b/crates/ferrex-player-ui/src/common/messages/mod.rs @@ -8,7 +8,6 @@ use crate::domains::media; use crate::domains::metadata; use crate::domains::player; use crate::domains::settings; -#[cfg(feature = "unimplemented")] use crate::domains::streaming; use crate::domains::ui; @@ -61,7 +60,6 @@ pub enum DomainMessage { Metadata(metadata::messages::MetadataMessage), /// Streaming/Transcoding domain - #[cfg(feature = "unimplemented")] Streaming(streaming::messages::StreamingMessage), /// Settings domain @@ -120,7 +118,6 @@ impl From for DomainMessage { } } -#[cfg(feature = "unimplemented")] impl From for DomainMessage { fn from(msg: streaming::messages::StreamingMessage) -> Self { DomainMessage::Streaming(msg) @@ -160,7 +157,6 @@ impl DomainMessage { Self::Player(_) => "Player", // PlayerMessage doesn't have name() method yet Self::Ui(msg) => msg.name(), Self::Metadata(msg) => msg.name(), - #[cfg(feature = "unimplemented")] Self::Streaming(msg) => msg.name(), Self::Settings(msg) => msg.name(), Self::UserManagement(msg) => msg.name(), @@ -187,7 +183,6 @@ impl std::fmt::Debug for DomainMessage { Self::Metadata(msg) => { write!(f, "DomainMessage::Metadata({:?})", msg) } - #[cfg(feature = "unimplemented")] Self::Streaming(msg) => { write!(f, "DomainMessage::Streaming({:?})", msg) } @@ -245,6 +240,11 @@ pub enum CrossDomainEvent { HideWindow, // Hide the application window (e.g., for external MPV) RestoreWindow(bool), // Restore window with fullscreen state SetWindowMode(iced::window::Mode), // Set specific window mode + NativePresenterAttached, + NativePresenterUnavailable, + /// Playback teardown completed; dismiss any dedicated native-player host + /// and restore the retained main window. + PlaybackExited, WindowResized(iced::Size), DatabaseCleared, // Database was cleared, refresh needed diff --git a/crates/ferrex-player-ui/src/domains/media/messages/subscriptions.rs b/crates/ferrex-player-ui/src/domains/media/messages/subscriptions.rs index 48cb2871..2b16cc81 100644 --- a/crates/ferrex-player-ui/src/domains/media/messages/subscriptions.rs +++ b/crates/ferrex-player-ui/src/domains/media/messages/subscriptions.rs @@ -13,7 +13,7 @@ pub fn subscription(state: &State) -> Subscription { let mut subscriptions = vec![]; // Poll external MPV cross‑platform when active - if state.domains.player.state.external_mpv_active { + if state.domains.player.state.external_playback_active() { subscriptions.push( iced::time::every(std::time::Duration::from_secs(1)).map(|_| { DomainMessage::Player( @@ -62,7 +62,7 @@ pub fn subscription(state: &State) -> Subscription { iced::time::every(std::time::Duration::from_secs(10)).map( |_| { DomainMessage::Player( - crate::domains::player::messages::PlayerMessage::NewFrame, + crate::domains::player::messages::PlayerMessage::PlaybackSnapshotTick, ) }, ), diff --git a/crates/ferrex-player-ui/src/domains/mod.rs b/crates/ferrex-player-ui/src/domains/mod.rs index 5dddac1a..47589aca 100644 --- a/crates/ferrex-player-ui/src/domains/mod.rs +++ b/crates/ferrex-player-ui/src/domains/mod.rs @@ -14,7 +14,6 @@ pub mod settings; pub mod ui; pub mod user_management; -#[cfg(feature = "unimplemented")] pub mod streaming; use crate::common::{ @@ -51,7 +50,6 @@ pub struct DomainRegistry { pub ui: ui::UIDomain, pub user_management: user_management::UserManagementDomain, pub search: search::SearchDomain, - #[cfg(feature = "unimplemented")] pub streaming: streaming::StreamingDomain, } @@ -77,7 +75,6 @@ impl DomainRegistry { .map(DomainMessage::Metadata), self.player.handle_event(&event).map(DomainMessage::Player), self.settings.handle_event(&event), - #[cfg(feature = "unimplemented")] self.streaming.handle_event(&event), self.ui.handle_event(&event), self.user_management.handle_event(&event), diff --git a/crates/ferrex-player-ui/src/domains/player/messages/subscriptions.rs b/crates/ferrex-player-ui/src/domains/player/messages/subscriptions.rs index 6ce6988f..e87ec4bc 100644 --- a/crates/ferrex-player-ui/src/domains/player/messages/subscriptions.rs +++ b/crates/ferrex-player-ui/src/domains/player/messages/subscriptions.rs @@ -10,15 +10,22 @@ use iced::Subscription; /// Creates all player-related subscriptions (keyboard + overlay timers). pub fn subscription(state: &State) -> Subscription { let player = &state.domains.player.state; + let playback = player.playback_snapshot(); let snapshot = PlaybackSubscriptionState { is_player_view: matches!( &state.domains.ui.state.view, ViewState::Player ), - has_video: player.video_opt.is_some(), + has_internal_video: player.video_opt.is_some(), + has_active_playback: playback + .is_some_and(|snapshot| snapshot.has_active_session()), + playback_target: playback.map(|snapshot| snapshot.target), controls_visible: player.controls, - external_mpv_active: player.external_mpv_active, - is_playing: player.is_playing(), + event_signal: player + .video_opt + .as_ref() + .and_then(|session| session.event_signal()), + is_playing: playback.is_some_and(|snapshot| snapshot.is_playing()), tenfoot_mode: state.interface_mode.is_tenfoot(), search_open: state.domains.search.state.presentation.is_open(), }; diff --git a/crates/ferrex-player-ui/src/domains/player/update.rs b/crates/ferrex-player-ui/src/domains/player/update.rs index a500462a..105a77eb 100644 --- a/crates/ferrex-player-ui/src/domains/player/update.rs +++ b/crates/ferrex-player-ui/src/domains/player/update.rs @@ -7,6 +7,7 @@ use crate::{ }, state::State, }; +use ferrex_core::player_prelude::TranscodingStatus; use ferrex_core::player_prelude::{EpisodeID, MediaID}; use ferrex_player_api::services::api::ApiService; use ferrex_player_playback::update::{ @@ -114,9 +115,12 @@ impl PlaybackUpdatePort for PlayerPlaybackPort { PlaybackStartMode::Internal => { PlaybackMessage::PlayMediaWithId(media_id) } - PlaybackStartMode::External => { + PlaybackStartMode::MpvNativeWindow => { PlaybackMessage::PlayMediaWithIdInMpv(media_id) } + PlaybackStartMode::External => { + PlaybackMessage::PlayMediaWithIdExternally(media_id) + } }; DomainMessage::Ui(crate::domains::ui::messages::UiMessage::Playback( message, @@ -137,6 +141,48 @@ pub fn update_player( state: &mut State, message: PlayerMessage, ) -> DomainUpdateResult { + if let PlayerMessage::QualityProfileSelected(profile) = &message { + let profile = *profile; + let Some(media_id) = state + .domains + .player + .state + .current_media + .as_ref() + .map(|media| media.id.to_string()) + else { + state.domains.ui.state.error_message = + Some("No media is available for quality switching".to_string()); + return DomainUpdateResult::task(iced::Task::none()); + }; + + state.domains.player.state.current_quality_profile = + Some(profile.as_str().to_string()); + state.domains.player.state.show_quality_menu = false; + state.domains.streaming.state.using_hls = true; + state.domains.streaming.state.transcoding_job_id = None; + state.domains.streaming.state.transcoding_status = + Some(TranscodingStatus::Queued); + state.domains.streaming.state.transcoding_check_count = 0; + + let service = state.domains.streaming.state.streaming_service.clone(); + return DomainUpdateResult::task(iced::Task::perform( + async move { + service + .start_transcoding(&media_id, profile) + .await + .map_err(|error| error.to_string()) + }, + |result| { + DomainMessage::Streaming( + crate::domains::streaming::messages::StreamingMessage::TranscodingStarted( + result, + ), + ) + }, + )); + } + let navigator = PlayerEpisodeNavigator { accessor: state.domains.ui.state.repo_accessor.clone(), }; @@ -177,6 +223,15 @@ pub fn update_player( PlaybackWindowEvent::RestoreWindow(fullscreen) => { CrossDomainEvent::RestoreWindow(fullscreen) } + PlaybackWindowEvent::NativePresenterAttached => { + CrossDomainEvent::NativePresenterAttached + } + PlaybackWindowEvent::NativePresenterUnavailable => { + CrossDomainEvent::NativePresenterUnavailable + } + PlaybackWindowEvent::PlaybackExited => { + CrossDomainEvent::PlaybackExited + } }) .collect(); diff --git a/crates/ferrex-player-ui/src/domains/streaming/messages/mod.rs b/crates/ferrex-player-ui/src/domains/streaming/messages/mod.rs index 18042753..5bac8d85 100644 --- a/crates/ferrex-player-ui/src/domains/streaming/messages/mod.rs +++ b/crates/ferrex-player-ui/src/domains/streaming/messages/mod.rs @@ -1,11 +1,19 @@ use ferrex_core::player_prelude::TranscodingStatus; +use ferrex_player_api::services::streaming::StreamingPlaybackSource; #[derive(Clone)] pub enum StreamingMessage { // Transcoding TranscodingStarted(Result), // job_id or error TranscodingStatusUpdate( - Result<(TranscodingStatus, Option, Option), String>, + Result< + ( + TranscodingStatus, + Option, + Option, + ), + String, + >, ), CheckTranscodingStatus, // Periodic check for transcoding status @@ -91,8 +99,8 @@ impl StreamingMessage { /// Streaming domain events #[derive(Clone, Debug)] pub enum StreamingEvent { - TranscodingStarted(String), // job_id - TranscodingCompleted(String), // job_id - StreamReady(String), // stream_url - BandwidthChanged(u64), // new bandwidth + TranscodingStarted(String), // job_id + TranscodingCompleted(String), // job_id + StreamReady(StreamingPlaybackSource), // authenticated stream source + BandwidthChanged(u64), // new bandwidth } diff --git a/crates/ferrex-player-ui/src/domains/streaming/update_handlers/transcoding.rs b/crates/ferrex-player-ui/src/domains/streaming/update_handlers/transcoding.rs index ff1bdb74..3e0b0041 100644 --- a/crates/ferrex-player-ui/src/domains/streaming/update_handlers/transcoding.rs +++ b/crates/ferrex-player-ui/src/domains/streaming/update_handlers/transcoding.rs @@ -6,9 +6,18 @@ use crate::{ state::State, }; use ferrex_core::player_prelude::TranscodingStatus; -use ferrex_player_playback::redact_playback_url; +use ferrex_player_api::services::streaming::StreamingPlaybackSource; +use ferrex_player_playback::contract::PlaybackSource; use iced::Task; +fn playback_source_from_streaming( + source: &StreamingPlaybackSource, +) -> PlaybackSource { + let (header_name, header_value) = source.authorization_header(); + PlaybackSource::new(source.uri().clone()) + .with_header(header_name, header_value) +} + /// Handle transcoding started event pub fn handle_transcoding_started( state: &mut State, @@ -80,6 +89,7 @@ pub fn handle_check_transcoding_status(state: &State) -> DomainUpdateResult { // Map service result into legacy tuple expected downstream match service.check_transcoding_status(&job_id_clone).await { Ok(status) => { + let playback_source = status.playback_source; let converted = match status.state.as_str() { "pending" => TranscodingStatus::Pending, "queued" => TranscodingStatus::Queued, @@ -93,7 +103,7 @@ pub fn handle_check_transcoding_status(state: &State) -> DomainUpdateResult { progress: status.progress.unwrap_or(0.0), }, }; - Ok((converted, None, None)) + Ok((converted, None, playback_source)) } Err(e) => Err(e.to_string()), } @@ -112,10 +122,17 @@ pub fn handle_check_transcoding_status(state: &State) -> DomainUpdateResult { /// Handle transcoding status update pub fn handle_transcoding_status_update( state: &mut State, - result: Result<(TranscodingStatus, Option, Option), String>, + result: Result< + ( + TranscodingStatus, + Option, + Option, + ), + String, + >, ) -> DomainUpdateResult { match result { - Ok((status, duration, playlist_path)) => { + Ok((status, duration, playback_source)) => { let should_continue_checking = match &status { TranscodingStatus::Pending | TranscodingStatus::Queued => true, TranscodingStatus::Processing { progress } => { @@ -163,67 +180,89 @@ pub fn handle_transcoding_status_update( } } - // Update playlist URL if provided (when transcoding is ready) - if let Some(playlist_path) = playlist_path { - let playlist_url = if playlist_path.starts_with("http") { - playlist_path - } else { - format!("{}{}", state.server_url, playlist_path) - }; + // Prepare an authenticated source-replacement message when the + // server publishes the selected rendition. Player::SetStreamSource + // owns the ordered close/reload path and preserves backend choice. + let reload_source = playback_source.map(|streaming_source| { log::info!( - "Updating playlist URL from job: {}", - redact_playback_url(&playlist_url) + "Updating authenticated playlist source from job: {streaming_source:?}" ); - - // Update the URL to the actual playlist path - if let Ok(url) = url::Url::parse(&playlist_url) { - state.domains.player.state.current_url = Some(url); + playback_source_from_streaming(&streaming_source) + }); + + if reload_source.is_some() { + let resume_position = state + .domains + .player + .state + .playback_snapshot() + .map(|snapshot| snapshot.position.as_secs_f64()) + .unwrap_or(state.domains.player.state.last_valid_position); + if resume_position.is_finite() && resume_position >= 0.0 { + state.domains.player.state.pending_resume_position = + Some(resume_position as f32); } + state.domains.streaming.state.quality_switch_count = state + .domains + .streaming + .state + .quality_switch_count + .saturating_add(1); } // Increment check count state.domains.streaming.state.transcoding_check_count += 1; - // If we've checked too many times (30 checks = ~1 minute), give up and load video - if state.domains.streaming.state.transcoding_check_count > 30 { - log::warn!( - "Transcoding status checks exceeded limit - loading video anyway" - ); + // Do not silently fall back to the previous source when the + // selected rendition never becomes ready. + if should_continue_checking + && state.domains.streaming.state.transcoding_check_count > 30 + { + log::warn!("Transcoding status checks exceeded limit"); state.domains.streaming.state.transcoding_status = - Some(TranscodingStatus::Completed); + Some(TranscodingStatus::Failed { + error: "Timed out waiting for the selected quality" + .to_string(), + }); state.domains.streaming.state.transcoding_job_id = None; - - if state.domains.player.state.video_opt.is_none() - && state.domains.streaming.state.using_hls - { - return DomainUpdateResult::task(Task::done( - DomainMessage::Player(PlayerMessage::VideoReadyToPlay), - )); - } else { - return DomainUpdateResult::task(Task::none()); - } + state.domains.ui.state.error_message = Some( + "Timed out waiting for the selected quality".to_string(), + ); + state.domains.ui.state.view = ViewState::VideoError { + message: "Timed out waiting for the selected quality" + .to_string(), + }; + return DomainUpdateResult::task(Task::none()); } // For HLS streaming, try to start playback during processing if we have segments - let should_try_playback = match &status { - TranscodingStatus::Processing { progress } => { - // Start playback when we have at least 1% transcoded (ensures initial segments exist) - // With 4-second segments, 2 segments = 8 seconds, which is <1% of most videos - *progress >= 0.01 - && state.domains.player.state.video_opt.is_none() - && state.domains.streaming.state.using_hls - } - TranscodingStatus::Completed => { - // Also try when completed if not already playing - state.domains.player.state.video_opt.is_none() - && state.domains.streaming.state.using_hls - } - _ => false, - }; + let should_try_playback = reload_source.is_none() + && match &status { + TranscodingStatus::Processing { progress } => { + // Start playback when we have at least 1% transcoded (ensures initial segments exist) + // With 4-second segments, 2 segments = 8 seconds, which is <1% of most videos + *progress >= 0.01 + && state.domains.player.state.video_opt.is_none() + && state.domains.streaming.state.using_hls + } + TranscodingStatus::Completed => { + // Also try when completed if not already playing + state.domains.player.state.video_opt.is_none() + && state.domains.streaming.state.using_hls + } + _ => false, + }; let mut tasks: Vec> = Vec::new(); let events: Vec = Vec::new(); + if let Some(source) = reload_source { + state.domains.streaming.state.transcoding_job_id = None; + tasks.push(Task::done(DomainMessage::Player( + PlayerMessage::SetStreamSource(source), + ))); + } + if should_continue_checking { // Continue checking every 2 seconds tasks.push(Task::perform( @@ -316,6 +355,7 @@ pub fn handle_transcoding_status_update( // Handle transcoding failures match &status { TranscodingStatus::Failed { error } => { + state.domains.streaming.state.transcoding_job_id = None; log::error!("Transcoding failed: {}", error); state.domains.ui.state.error_message = Some(format!("Transcoding failed: {}", error)); @@ -324,6 +364,7 @@ pub fn handle_transcoding_status_update( }; } TranscodingStatus::Cancelled => { + state.domains.streaming.state.transcoding_job_id = None; log::warn!("Transcoding was cancelled"); state.domains.ui.state.error_message = Some("Transcoding was cancelled".to_string()); @@ -450,3 +491,38 @@ pub fn handle_transcoding_status_update( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn transcoded_source_reload_keeps_ticket_in_redacted_header_transport() + { + let mut state = State::default(); + let ticket = "streaming-ticket-secret"; + let source = StreamingPlaybackSource::with_bearer_token( + "https://ferrex.example/api/v1/stream/manifest".to_string(), + ticket.to_string(), + ) + .expect("valid source"); + assert!(!format!("{source:?}").contains(ticket)); + + let projected = playback_source_from_streaming(&source); + drop(handle_transcoding_status_update( + &mut state, + Ok((TranscodingStatus::Completed, Some(4.0), Some(source))), + )); + + assert!(projected.uri().query().is_none()); + assert_eq!(projected.headers().len(), 1); + assert_eq!(projected.headers()[0].name, "Authorization"); + assert_eq!( + projected.headers()[0].value.expose_secret(), + format!("Bearer {ticket}") + ); + assert!(!format!("{projected:?}").contains(ticket)); + assert_eq!(state.domains.streaming.state.quality_switch_count, 1); + assert!(state.domains.streaming.state.transcoding_job_id.is_none()); + } +} diff --git a/crates/ferrex-player-ui/src/domains/ui/playback_ui/mod.rs b/crates/ferrex-player-ui/src/domains/ui/playback_ui/mod.rs index 0d2ac3a0..0d2b1706 100644 --- a/crates/ferrex-player-ui/src/domains/ui/playback_ui/mod.rs +++ b/crates/ferrex-player-ui/src/domains/ui/playback_ui/mod.rs @@ -10,6 +10,7 @@ pub enum PlaybackMessage { PlayMediaWithId(MediaID), PlayMediaWithIdFromStart(MediaID), PlayMediaWithIdInMpv(MediaID), + PlayMediaWithIdExternally(MediaID), PlaySeriesNextEpisode(SeriesID), } @@ -25,6 +26,9 @@ impl PlaybackMessage { Self::PlayMediaWithId(_) => "UI::PlayMediaWithId", Self::PlayMediaWithIdFromStart(_) => "UI::PlayMediaWithIdFromStart", Self::PlayMediaWithIdInMpv(_) => "UI::PlayMediaWithIdInMpv", + Self::PlayMediaWithIdExternally(_) => { + "UI::PlayMediaWithIdExternally" + } Self::PlaySeriesNextEpisode(_) => "UI::PlaySeriesNextEpisode", } } @@ -42,6 +46,9 @@ impl std::fmt::Debug for PlaybackMessage { Self::PlayMediaWithIdInMpv(id) => { write!(f, "UI::PlayMediaWithIdInMpv({:?})", id) } + Self::PlayMediaWithIdExternally(id) => { + write!(f, "UI::PlayMediaWithIdExternally({:?})", id) + } Self::PlaySeriesNextEpisode(series) => { write!(f, "UI::PlaySeriesNextEpisode({:?})", series) } diff --git a/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs b/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs index ffe1ab06..1aca516c 100644 --- a/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs +++ b/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs @@ -1,11 +1,15 @@ use crate::{ common::messages::{CrossDomainEvent, DomainMessage, DomainUpdateResult}, - domains::ui::{messages::UiMessage, playback_ui::PlaybackMessage}, + domains::ui::{ + messages::UiMessage, playback_ui::PlaybackMessage, + shell_ui::UiShellMessage, + }, state::State, }; use ferrex_core::player_prelude::{ EpisodeID, EpisodeLike, Media, MediaID, MovieLike, }; +use ferrex_player_playback::contract::{BackendRequest, PlaybackTarget}; use iced::Task; fn play_media_with_position( @@ -13,6 +17,7 @@ fn play_media_with_position( media_id: MediaID, position: f32, ) -> DomainUpdateResult { + state.domains.player.state.backend_request = BackendRequest::Auto; match state.domains.ui.state.repo_accessor.get(&media_id) { Ok(media) => { let media_file = match media { @@ -49,12 +54,99 @@ fn play_media_with_position( } } +fn play_media_with_mpv_mode( + state: &mut State, + media_id: MediaID, + external_process: bool, +) -> DomainUpdateResult { + // Backend-disabled builds retain the historical external-process action. + let external_process = external_process || !cfg!(feature = "mpv"); + state.domains.player.state.backend_request = if external_process { + BackendRequest::Auto + } else { + BackendRequest::Exact(in_process_mpv_target()) + }; + + let media_file = match state.domains.ui.state.repo_accessor.get(&media_id) { + Ok(Media::Movie(movie)) => movie.file(), + Ok(Media::Episode(episode)) => episode.file(), + Ok(_) => { + log::error!("Media not playable type {media_id}"); + return DomainUpdateResult::task(Task::none()); + } + Err(_) => { + log::error!("Failed to get media with id {media_id}"); + return DomainUpdateResult::task(Task::none()); + } + }; + + let mut resume_opt = None; + let mut watch_duration_hint = None; + if let Some(watch_state) = &state.domains.media.state.user_watch_state + && let Some(item) = watch_state.get_by_media_id(media_id.as_uuid()) + { + if item.position > 0.0 && item.duration > 0.0 { + resume_opt = Some(item.position); + } + if item.duration > 0.0 { + watch_duration_hint = Some(item.duration as f64); + } + } + let metadata_duration_hint = media_file + .media_file_metadata + .as_ref() + .and_then(|metadata| metadata.duration) + .filter(|duration| *duration > 0.0); + let duration_hint = watch_duration_hint.or(metadata_duration_hint); + + state.domains.player.state.last_valid_position = + resume_opt.map(|position| position as f64).unwrap_or(0.0); + state.domains.player.state.last_valid_duration = + duration_hint.unwrap_or(0.0); + state.domains.media.state.pending_resume_position = resume_opt; + state.domains.player.state.pending_resume_position = resume_opt; + + let play = Task::done(DomainMessage::Player( + crate::domains::player::messages::PlayerMessage::PlayMediaWithId( + media_file, media_id, + ), + )); + if external_process { + DomainUpdateResult::task(Task::batch(vec![ + play, + Task::done(DomainMessage::Player( + crate::domains::player::messages::PlayerMessage::PlayExternal, + )), + ])) + } else if in_process_mpv_target() == PlaybackTarget::MPV_INTEGRATED { + // Allocate the transparent controls host before playback attachment. + // It remains invisible until the presenter reports `Attached`. + DomainUpdateResult::task( + Task::done(DomainMessage::Ui( + UiShellMessage::OpenPlayerOverlay.into(), + )) + .chain(play), + ) + } else { + DomainUpdateResult::task(play) + } +} + +const fn in_process_mpv_target() -> PlaybackTarget { + if cfg!(any(target_os = "windows", target_os = "macos")) { + PlaybackTarget::MPV_INTEGRATED + } else { + PlaybackTarget::MPV_NATIVE_WINDOW + } +} + pub fn update_playback_ui( state: &mut State, message: PlaybackMessage, ) -> DomainUpdateResult { match message { PlaybackMessage::PlayMediaWithId(media_id) => { + state.domains.player.state.backend_request = BackendRequest::Auto; match state.domains.ui.state.repo_accessor.get(&media_id) { Ok(media) => match media { Media::Movie(movie) => DomainUpdateResult::with_events( @@ -86,72 +178,12 @@ pub fn update_playback_ui( play_media_with_position(state, media_id, 0.0) } PlaybackMessage::PlayMediaWithIdInMpv(media_id) => { - match state.domains.ui.state.repo_accessor.get(&media_id) { - Ok(media) => { - // Extract the concrete media file for playback - let media_file = match media { - Media::Movie(movie) => movie.file(), - Media::Episode(episode) => episode.file(), - _ => { - log::error!("Media not playable type {}", media_id); - return DomainUpdateResult::task(Task::none()); - } - }; - - // Seed resume/duration hints similarly to CrossDomainEvent::MediaPlayWithId - let mut resume_opt: Option = None; - let mut watch_duration_hint: Option = None; - if let Some(watch_state) = - &state.domains.media.state.user_watch_state - && let Some(item) = - watch_state.get_by_media_id(media_id.as_uuid()) - { - if item.position > 0.0 && item.duration > 0.0 { - resume_opt = Some(item.position); - } - if item.duration > 0.0 { - watch_duration_hint = Some(item.duration as f64); - } - } - - let metadata_duration_hint = media_file - .media_file_metadata - .as_ref() - .and_then(|meta| meta.duration) - .filter(|d| *d > 0.0); - - let duration_hint = - watch_duration_hint.or(metadata_duration_hint); - - state.domains.player.state.last_valid_position = - resume_opt.map(|pos| pos as f64).unwrap_or(0.0); - state.domains.player.state.last_valid_duration = - duration_hint.unwrap_or(0.0); - state.domains.media.state.pending_resume_position = - resume_opt; - state.domains.player.state.pending_resume_position = - resume_opt; - - // First seed the player with PlayMediaWithId, then switch to external player - let tasks = Task::batch(vec![ - Task::done(DomainMessage::Player( - crate::domains::player::messages::PlayerMessage::PlayMediaWithId( - media_file, - media_id, - ), - )), - Task::done(DomainMessage::Player( - crate::domains::player::messages::PlayerMessage::PlayExternal, - )), - ]); - - DomainUpdateResult::task(tasks) - } - Err(_) => { - log::error!("Failed to get media with id {}", media_id); - DomainUpdateResult::task(Task::none()) - } - } + // Explicit opt-in to in-process libmpv native-window mode. Auto + // remains Subwave, and initialization failures fall back there. + play_media_with_mpv_mode(state, media_id, false) + } + PlaybackMessage::PlayMediaWithIdExternally(media_id) => { + play_media_with_mpv_mode(state, media_id, true) } PlaybackMessage::PlaySeriesNextEpisode(series_id) => { // Prefer identity-based next-episode from server, fall back to local selection diff --git a/crates/ferrex-player-ui/src/domains/ui/shell_ui/mod.rs b/crates/ferrex-player-ui/src/domains/ui/shell_ui/mod.rs index 145e8cde..25a957e2 100644 --- a/crates/ferrex-player-ui/src/domains/ui/shell_ui/mod.rs +++ b/crates/ferrex-player-ui/src/domains/ui/shell_ui/mod.rs @@ -62,6 +62,15 @@ pub enum UiShellMessage { MainWindowUnfocused, RawWindowClosed(window::Id), + // Dedicated native-player controls overlay lifecycle + OpenPlayerOverlay, + PlayerOverlayOpened(window::Id), + PlayerOverlayResized(window::Id, iced::Size), + ActivatePlayerOverlay, + PlayerOverlayHandoffReady, + PlayerOverlayFocused, + ClosePlayerOverlay, + // Search surface and query management UpdateSearchQuery(String), ExecuteSearch, @@ -106,6 +115,13 @@ impl UiShellMessage { Self::MainWindowFocused => "UI::MainWindowFocused", Self::MainWindowUnfocused => "UI::MainWindowUnfocused", Self::RawWindowClosed(_) => "UI::RawWindowClosed", + Self::OpenPlayerOverlay => "UI::OpenPlayerOverlay", + Self::PlayerOverlayOpened(_) => "UI::PlayerOverlayOpened", + Self::PlayerOverlayResized(_, _) => "UI::PlayerOverlayResized", + Self::ActivatePlayerOverlay => "UI::ActivatePlayerOverlay", + Self::PlayerOverlayHandoffReady => "UI::PlayerOverlayHandoffReady", + Self::PlayerOverlayFocused => "UI::PlayerOverlayFocused", + Self::ClosePlayerOverlay => "UI::ClosePlayerOverlay", // Search surface and query management Self::UpdateSearchQuery(_) => "UI::UpdateSearchQuery", @@ -162,6 +178,27 @@ impl std::fmt::Debug for UiShellMessage { UiShellMessage::RawWindowClosed(id) => { write!(f, "UI::RawWindowClosed({:?})", id) } + UiShellMessage::OpenPlayerOverlay => { + write!(f, "UI::OpenPlayerOverlay") + } + UiShellMessage::PlayerOverlayOpened(id) => { + write!(f, "UI::PlayerOverlayOpened({:?})", id) + } + UiShellMessage::PlayerOverlayResized(id, size) => { + write!(f, "UI::PlayerOverlayResized({id:?}, {size:?})") + } + UiShellMessage::ActivatePlayerOverlay => { + write!(f, "UI::ActivatePlayerOverlay") + } + UiShellMessage::PlayerOverlayHandoffReady => { + write!(f, "UI::PlayerOverlayHandoffReady") + } + UiShellMessage::PlayerOverlayFocused => { + write!(f, "UI::PlayerOverlayFocused") + } + UiShellMessage::ClosePlayerOverlay => { + write!(f, "UI::ClosePlayerOverlay") + } UiShellMessage::UpdateSearchQuery(_) => { write!(f, "UI::UpdateSearchQuery") } diff --git a/crates/ferrex-player-ui/src/domains/ui/shell_ui/update.rs b/crates/ferrex-player-ui/src/domains/ui/shell_ui/update.rs index 9dcf31cc..a4195fcf 100644 --- a/crates/ferrex-player-ui/src/domains/ui/shell_ui/update.rs +++ b/crates/ferrex-player-ui/src/domains/ui/shell_ui/update.rs @@ -384,6 +384,28 @@ pub fn update_shell_ui( // No special handling currently; keep behavior simple DomainUpdateResult::task(Task::none()) } + UiShellMessage::OpenPlayerOverlay => { + windows::controller::open_player_overlay(state) + } + UiShellMessage::PlayerOverlayOpened(id) => { + windows::controller::on_player_overlay_opened(state, id) + } + UiShellMessage::PlayerOverlayResized(id, size) => { + state.windows.set_player_overlay_size(id, size); + DomainUpdateResult::task(Task::none()) + } + UiShellMessage::ActivatePlayerOverlay => { + windows::controller::activate_player_overlay(state) + } + UiShellMessage::PlayerOverlayHandoffReady => { + windows::controller::finish_player_overlay_activation(state) + } + UiShellMessage::PlayerOverlayFocused => { + windows::controller::on_player_overlay_focused(state) + } + UiShellMessage::ClosePlayerOverlay => { + windows::controller::close_player_overlay(state) + } UiShellMessage::RawWindowClosed(id) => { windows::controller::on_raw_window_closed(state, id) } diff --git a/crates/ferrex-player-ui/src/domains/ui/theme.rs b/crates/ferrex-player-ui/src/domains/ui/theme.rs index 59e2a382..ad8ff345 100644 --- a/crates/ferrex-player-ui/src/domains/ui/theme.rs +++ b/crates/ferrex-player-ui/src/domains/ui/theme.rs @@ -67,9 +67,11 @@ impl MediaServerTheme { /// Choose a theme based on application state and window. /// /// - Uses an opaque background everywhere by default. - /// - Switches to a transparent background only for the main window - /// when showing the Player view with the Subwave Wayland backend - /// active (so the video subsurface can render behind the controls). + /// - Gives the dedicated native-player overlay an explicitly transparent + /// surface. + /// - Switches the main window to a transparent background when showing the + /// Player view with the Subwave Wayland backend active (so its video + /// subsurface can render behind the controls). pub fn theme_for_state( state: &State, window: Option, @@ -79,31 +81,20 @@ impl MediaServerTheme { // Default to opaque background let mut use_transparent_bg = false; - // Only consider transparency on the main window when actually - // presenting the player view with a Wayland subsurface backend. - if let Some(main_id) = state + let is_player_overlay = window.is_some_and(|window| { + state.windows.is_player_overlay_window(window) + }); + if is_player_overlay { + use_transparent_bg = true; + } else if let Some(main_id) = state .windows .get(crate::domains::ui::windows::WindowKind::Main) && window.map(|w| w == main_id).unwrap_or(true) && matches!(state.domains.ui.state.view, ViewState::Player) && let Some(video) = state.domains.player.state.video_opt.as_ref() { - // Only make the background transparent when using Wayland. - // Treat any preference other than ForceAppsink on Wayland as Wayland-backed, - // which includes PreferWayland. - let pref = video.backend(); - if std::env::var("WAYLAND_DISPLAY").is_ok() { - use_transparent_bg = !matches!( - pref, - subwave_unified::video::BackendPreference::ForceAppsink - ); - } else { - // On non-Wayland, only enable if explicitly forced to Wayland - use_transparent_bg = matches!( - pref, - subwave_unified::video::BackendPreference::ForceWayland - ); - } + // The legacy integrated Wayland surface also needs host alpha. + use_transparent_bg = video.uses_wayland_surface(); } palette.background = if use_transparent_bg { @@ -120,6 +111,34 @@ impl MediaServerTheme { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::domains::ui::windows::WindowKind; + + #[tokio::test(flavor = "current_thread")] + async fn dedicated_player_overlay_has_explicit_surface_alpha() { + let mut state = State::default(); + let main = iced::window::Id::unique(); + let overlay = iced::window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + + assert_eq!( + MediaServerTheme::theme_for_state(&state, Some(overlay)) + .palette() + .background, + Color::TRANSPARENT + ); + assert_eq!( + MediaServerTheme::theme_for_state(&state, Some(main)) + .palette() + .background, + MediaServerTheme::BACKGROUND + ); + } +} + // Container styles using closures #[derive(Debug)] pub enum Container { diff --git a/crates/ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs b/crates/ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs index 32be9bbb..1bee9794 100644 --- a/crates/ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs +++ b/crates/ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs @@ -29,6 +29,10 @@ use crate::{ }, state::State, }; +use ferrex_player_playback::{ + contract::{BackendKind, PlaybackSnapshot, PlaybackState}, + view::{playback_status_overlay, playback_surface_status}, +}; use iced::{ Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Subscription, Theme, Vector, @@ -59,6 +63,13 @@ static OVERLAY_RUNTIME: Mutex = Mutex::new(OverlayRuntime { controls_hidden: false, }); +fn player_viewport_size(state: &State) -> iced::Size { + state + .windows + .player_overlay_size() + .unwrap_or(state.window_size) +} + fn overlay_runtime() -> MutexGuard<'static, OverlayRuntime> { OVERLAY_RUNTIME .lock() @@ -317,16 +328,17 @@ struct TenFootPlayerInputSnapshot { impl TenFootPlayerInputSnapshot { fn from_state(state: &State) -> Self { let player = &state.domains.player.state; - let external_active = player.external_mpv_active; - let has_internal_video = player.video_opt.is_some() && !external_active; + let external_active = player.external_playback_active(); + let has_internal_video = player.has_internal_session(); let overlay_visible = overlay_controls_visible(player); + let viewport = player_viewport_size(state); Self { has_internal_video, external_active, overlay_visible, - viewport_width_bits: state.window_size.width.to_bits(), - viewport_height_bits: state.window_size.height.to_bits(), + viewport_width_bits: viewport.width.to_bits(), + viewport_height_bits: viewport.height.to_bits(), } } @@ -464,29 +476,36 @@ pub fn keyboard_subscription(state: &State) -> Subscription { } /// Build the 10-foot player view while preserving the existing video widget. -pub fn view_player(state: &State) -> Element<'_, PlayerMessage, Theme> { +pub fn view_player( + state: &State, + native_host_window: Option, +) -> Element<'_, PlayerMessage, Theme> { let player = &state.domains.player.state; - if player.external_mpv_active { - return external_player_view(player); + if let Some(snapshot) = player + .playback_snapshot() + .filter(|snapshot| snapshot.target.backend == BackendKind::ExternalMpv) + { + return external_player_view(snapshot); } - if let Some(video) = &player.video_opt { + if let Some(video) = player.playback_widget(native_host_window) { let video_surface: Element< '_, PlayerMessage, Theme, iced_wgpu::Renderer, - > = mouse_area( - video.widget(player.content_fit, Some(PlayerMessage::NewFrame)), - ) - .on_press(PlayerMessage::VideoClicked) - .into(); + > = mouse_area(video) + .on_press(PlayerMessage::VideoClicked) + .into(); let mut layers: Vec< Element<'_, PlayerMessage, Theme, iced_wgpu::Renderer>, > = vec![video_surface]; + if let Some(status) = playback_surface_status(player) { + layers.push(playback_status_overlay(status)); + } if overlay_controls_visible(player) { layers.push(overlay(state)); } @@ -533,11 +552,9 @@ fn overlay( state: &State, ) -> Element<'_, PlayerMessage, Theme, iced_wgpu::Renderer> { let player = &state.domains.player.state; - let focused_id = focused_id_for_viewport( - state.window_size.width, - state.window_size.height, - true, - ); + let viewport = player_viewport_size(state); + let focused_id = + focused_id_for_viewport(viewport.width, viewport.height, true); let title = player .current_media .as_ref() @@ -574,10 +591,8 @@ fn overlay( .padding(PlayerOverlayLayout::top_bar_padding()) .style(top_gradient_style); - let progress_layout = PlayerOverlayLayout::progress_rect( - state.window_size.width, - state.window_size.height, - ); + let progress_layout = + PlayerOverlayLayout::progress_rect(viewport.width, viewport.height); let seek = container( mouse_area(progress_bar(ratio)).on_press(PlayerMessage::SeekBarPressed), ) @@ -721,14 +736,22 @@ fn overlay( } fn player_status_label(player: &PlayerDomainState) -> &'static str { - if player.seeking { - "SEEKING" - } else if player.dragging { - "SCRUBBING" - } else if player.is_playing() { - "PLAYING" - } else { - "PAUSED" + if player.dragging { + return "SCRUBBING"; + } + + match player.playback_snapshot().map(|snapshot| snapshot.state) { + Some(PlaybackState::Loading) => "LOADING", + Some(PlaybackState::Buffering) => "BUFFERING", + Some(PlaybackState::Seeking) => "SEEKING", + Some(PlaybackState::Playing) => "PLAYING", + Some(PlaybackState::Paused) => "PAUSED", + Some(PlaybackState::Stopping) => "STOPPING", + Some(PlaybackState::Failed) => "ERROR", + Some(PlaybackState::Ended | PlaybackState::Terminated) => "ENDED", + Some(PlaybackState::Idle) => "READY", + None if player.seeking => "SEEKING", + None => "PAUSED", } } @@ -862,12 +885,17 @@ fn notification_overlay<'a>( } fn external_player_view( - player: &PlayerDomainState, + snapshot: &PlaybackSnapshot, ) -> Element<'static, PlayerMessage, Theme> { - let position = - crate::domains::player::view::format_time(player.last_valid_position); - let duration = - crate::domains::player::view::format_time(player.last_valid_duration); + let position = crate::domains::player::view::format_time( + snapshot.position.as_secs_f64(), + ); + let duration = crate::domains::player::view::format_time( + snapshot + .duration + .map(|duration| duration.as_secs_f64()) + .unwrap_or_default(), + ); centered_status_view( "Playing externally", format!("{position} / {duration} • MPV is handling playback"), @@ -908,9 +936,7 @@ fn centered_status_view( } fn overlay_controls_visible(player: &PlayerDomainState) -> bool { - let has_internal_video = - player.video_opt.is_some() && !player.external_mpv_active; - if !has_internal_video { + if !player.has_internal_session() { return false; } diff --git a/crates/ferrex-player-ui/src/domains/ui/views/tv/view_tv.rs b/crates/ferrex-player-ui/src/domains/ui/views/tv/view_tv.rs index d010d69f..39a4d41a 100644 --- a/crates/ferrex-player-ui/src/domains/ui/views/tv/view_tv.rs +++ b/crates/ferrex-player-ui/src/domains/ui/views/tv/view_tv.rs @@ -1063,7 +1063,7 @@ mod tests { } #[test] - fn series_mpv_action_plays_selected_next_episode_externally() { + fn series_mpv_action_selects_the_explicit_mpv_route() { let series_id = SeriesID(Uuid::from_u128(1)); let episode_id = EpisodeID(Uuid::from_u128(2)); let actions = series_actions(series_id, Some(episode_id)); diff --git a/crates/ferrex-player-ui/src/domains/ui/windows.rs b/crates/ferrex-player-ui/src/domains/ui/windows.rs index 1d41f44e..ab9f6433 100644 --- a/crates/ferrex-player-ui/src/domains/ui/windows.rs +++ b/crates/ferrex-player-ui/src/domains/ui/windows.rs @@ -2,19 +2,39 @@ pub mod controller; pub mod focus; pub mod subscriptions; -use iced::window; +use iced::{Size, window}; use std::collections::HashMap; #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum WindowKind { Main, Search, + /// Transparent Iced controls hosted above a native-root video window. + PlayerOverlay, +} + +/// Lifecycle of the dedicated player overlay window. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] +pub enum PlayerOverlayWindowState { + #[default] + Closed, + /// Allocated with `visible = false`; native attachment may proceed safely. + Hidden, + /// Native attachment is ready and the retained main window is being + /// hidden; the overlay itself is still invisible. + Activating, + /// Native attachment was confirmed and the overlay is visible. + Active, + /// Native slots were detached and a close action has been queued. + Closing, } #[derive(Debug, Default)] pub struct WindowManager { by_kind: HashMap, by_id: HashMap, + player_overlay: PlayerOverlayWindowState, + player_overlay_size: Option, pub focused: Option, } @@ -24,8 +44,23 @@ impl WindowManager { } pub fn set(&mut self, kind: WindowKind, id: window::Id) { + if let Some(previous_id) = self.by_kind.remove(&kind) { + self.by_id.remove(&previous_id); + } + if let Some(previous_kind) = self.by_id.remove(&id) { + self.by_kind.remove(&previous_kind); + if previous_kind == WindowKind::PlayerOverlay { + self.player_overlay = PlayerOverlayWindowState::Closed; + self.player_overlay_size = None; + } + } + self.by_kind.insert(kind, id); self.by_id.insert(id, kind); + if kind == WindowKind::PlayerOverlay { + self.player_overlay = PlayerOverlayWindowState::Hidden; + self.player_overlay_size = None; + } } pub fn get(&self, kind: WindowKind) -> Option { @@ -39,6 +74,10 @@ impl WindowManager { pub fn remove_by_id(&mut self, id: window::Id) -> Option { if let Some(kind) = self.by_id.remove(&id) { let _ = self.by_kind.remove(&kind); + if kind == WindowKind::PlayerOverlay { + self.player_overlay = PlayerOverlayWindowState::Closed; + self.player_overlay_size = None; + } if self.focused == Some(id) { self.focused = None; } @@ -48,6 +87,68 @@ impl WindowManager { } } + pub const fn player_overlay_state(&self) -> PlayerOverlayWindowState { + self.player_overlay + } + + pub const fn player_overlay_size(&self) -> Option { + self.player_overlay_size + } + + /// Record the actual native overlay viewport without overwriting the + /// retained main-window geometry used when playback exits. + pub fn set_player_overlay_size( + &mut self, + id: window::Id, + size: Size, + ) -> bool { + if self.get(WindowKind::PlayerOverlay) != Some(id) + || !size.width.is_finite() + || !size.height.is_finite() + || size.width <= 0.0 + || size.height <= 0.0 + { + return false; + } + self.player_overlay_size = Some(size); + true + } + + /// Confirm native attachment and begin the hidden-to-visible handoff. + pub fn activate_player_overlay(&mut self) -> bool { + if self.get(WindowKind::PlayerOverlay).is_some() + && self.player_overlay == PlayerOverlayWindowState::Hidden + { + self.player_overlay = PlayerOverlayWindowState::Activating; + true + } else { + false + } + } + + /// Record that the native presenter has synchronously revealed the host. + pub fn finish_player_overlay_activation(&mut self) -> bool { + if self.get(WindowKind::PlayerOverlay).is_some() + && self.player_overlay == PlayerOverlayWindowState::Activating + { + self.player_overlay = PlayerOverlayWindowState::Active; + true + } else { + false + } + } + + /// Record deterministic pre-close teardown and return the prior lifecycle. + pub fn begin_player_overlay_close(&mut self) -> PlayerOverlayWindowState { + let previous = self.player_overlay; + if self.get(WindowKind::PlayerOverlay).is_some() + && previous != PlayerOverlayWindowState::Closing + { + self.player_overlay = PlayerOverlayWindowState::Closing; + } + previous + } + pub fn is_search_window(&self, id: window::Id) -> bool { matches!(self.get_kind(id), Some(WindowKind::Search)) } @@ -55,4 +156,123 @@ impl WindowManager { pub fn is_main_window(&self, id: window::Id) -> bool { matches!(self.get_kind(id), Some(WindowKind::Main)) } + + pub fn is_player_overlay_window(&self, id: window::Id) -> bool { + matches!(self.get_kind(id), Some(WindowKind::PlayerOverlay)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kind_and_id_maps_remain_bijective_when_windows_are_replaced() { + let mut windows = WindowManager::new(); + let first = window::Id::unique(); + let replacement = window::Id::unique(); + + windows.set(WindowKind::Search, first); + windows.set(WindowKind::Search, replacement); + + assert_eq!(windows.get(WindowKind::Search), Some(replacement)); + assert_eq!(windows.get_kind(first), None); + assert_eq!(windows.get_kind(replacement), Some(WindowKind::Search)); + + windows.set(WindowKind::Main, replacement); + assert_eq!(windows.get(WindowKind::Search), None); + assert_eq!(windows.get(WindowKind::Main), Some(replacement)); + assert_eq!(windows.get_kind(replacement), Some(WindowKind::Main)); + } + + #[test] + fn player_overlay_requires_hidden_attach_reveal_active_close_order() { + let mut windows = WindowManager::new(); + let overlay = window::Id::unique(); + + assert_eq!( + windows.player_overlay_state(), + PlayerOverlayWindowState::Closed + ); + assert!(!windows.activate_player_overlay()); + + windows.set(WindowKind::PlayerOverlay, overlay); + assert_eq!( + windows.player_overlay_state(), + PlayerOverlayWindowState::Hidden, + ); + assert!(windows.activate_player_overlay()); + assert!(!windows.activate_player_overlay()); + assert_eq!( + windows.player_overlay_state(), + PlayerOverlayWindowState::Activating, + ); + assert!(windows.finish_player_overlay_activation()); + assert!(!windows.finish_player_overlay_activation()); + assert_eq!( + windows.begin_player_overlay_close(), + PlayerOverlayWindowState::Active + ); + assert_eq!( + windows.player_overlay_state(), + PlayerOverlayWindowState::Closing + ); + + assert_eq!( + windows.remove_by_id(overlay), + Some(WindowKind::PlayerOverlay) + ); + assert_eq!( + windows.player_overlay_state(), + PlayerOverlayWindowState::Closed + ); + } + + #[test] + fn player_overlay_size_is_scoped_validated_and_cleared() { + let mut windows = WindowManager::new(); + let overlay = window::Id::unique(); + let unrelated = window::Id::unique(); + + windows.set(WindowKind::PlayerOverlay, overlay); + assert_eq!(windows.player_overlay_size(), None); + assert!( + !windows.set_player_overlay_size( + unrelated, + Size::new(1_920.0, 1_080.0) + ) + ); + assert!( + !windows + .set_player_overlay_size(overlay, Size::new(f32::NAN, 1_080.0)) + ); + assert!( + !windows.set_player_overlay_size(overlay, Size::new(1_920.0, 0.0)) + ); + + let actual = Size::new(1_920.0, 1_080.0); + assert!(windows.set_player_overlay_size(overlay, actual)); + assert_eq!(windows.player_overlay_size(), Some(actual)); + + windows.remove_by_id(overlay); + assert_eq!(windows.player_overlay_size(), None); + } + + #[test] + fn activating_overlay_can_close_before_reveal_completion() { + let mut windows = WindowManager::new(); + let overlay = window::Id::unique(); + windows.set(WindowKind::PlayerOverlay, overlay); + + assert!(windows.activate_player_overlay()); + assert_eq!( + windows.begin_player_overlay_close(), + PlayerOverlayWindowState::Activating + ); + assert_eq!( + windows.player_overlay_state(), + PlayerOverlayWindowState::Closing + ); + assert!(!windows.finish_player_overlay_activation()); + } } diff --git a/crates/ferrex-player-ui/src/domains/ui/windows/controller.rs b/crates/ferrex-player-ui/src/domains/ui/windows/controller.rs index ec2313e5..a00bc3e8 100644 --- a/crates/ferrex-player-ui/src/domains/ui/windows/controller.rs +++ b/crates/ferrex-player-ui/src/domains/ui/windows/controller.rs @@ -3,8 +3,12 @@ use iced::{Point, Task, window}; use crate::{ common::messages::{DomainMessage, DomainUpdateResult}, domains::{ + player::messages::PlayerMessage, search::types::SearchPresentation, - ui::{shell_ui::UiShellMessage, windows::WindowKind}, + ui::{ + shell_ui::UiShellMessage, + windows::{PlayerOverlayWindowState, WindowKind}, + }, }, infra::constants::layout, state::State, @@ -41,6 +45,215 @@ fn search_window_settings(state: &State) -> window::Settings { } } +fn player_overlay_window_settings(state: &State) -> window::Settings { + window::Settings { + size: state.window_size, + position: state + .window_position + .map(window::Position::Specific) + .unwrap_or(window::Position::Centered), + // The host must exist before any native relationship is attached, but + // must not flash or claim a second visible player identity first. + visible: false, + // Native-root presenters are the sole geometry authority after attach. + resizable: false, + decorations: false, + transparent: true, + level: window::Level::Normal, + exit_on_close_request: false, + ..Default::default() + } +} + +/// Allocate the dedicated controls overlay in a hidden state. +/// +/// Showing it is a separate operation that must only happen after a presenter +/// confirms native attachment. +pub fn open_player_overlay(state: &mut State) -> DomainUpdateResult { + if let Some(id) = state.windows.get(WindowKind::PlayerOverlay) { + return if state.windows.player_overlay_state() + == PlayerOverlayWindowState::Active + { + DomainUpdateResult::task(window::gain_focus(id)) + } else { + DomainUpdateResult::task(Task::none()) + }; + } + + let (id, open) = window::open(player_overlay_window_settings(state)); + state.windows.set(WindowKind::PlayerOverlay, id); + state.windows.set_player_overlay_size(id, state.window_size); + DomainUpdateResult::task(open.map(|opened| { + DomainMessage::Ui(UiShellMessage::PlayerOverlayOpened(opened).into()) + })) +} + +/// Keep a newly allocated overlay hidden while native attachment is pending. +pub fn on_player_overlay_opened( + state: &mut State, + id: window::Id, +) -> DomainUpdateResult { + if state.windows.get(WindowKind::PlayerOverlay) == Some(id) { + log::debug!( + "native player overlay handoff: hidden controls host allocated" + ); + DomainUpdateResult::task(Task::none()) + } else { + // A superseded asynchronous open result must not leave an orphan + // controls window visible later. + DomainUpdateResult::task(window::close(id)) + } +} + +/// Reveal the overlay only after native attachment has been confirmed. +pub fn activate_player_overlay(state: &mut State) -> DomainUpdateResult { + let Some(_overlay_id) = state.windows.get(WindowKind::PlayerOverlay) else { + return DomainUpdateResult::task(Task::none()); + }; + if !state.windows.activate_player_overlay() { + return DomainUpdateResult::task(Task::none()); + } + log::debug!( + "native player overlay handoff: presenter attached and positioned; queuing retained main-window hide" + ); + + // The platform presenter already positioned the hidden overlay against + // mpv's content rectangle. Hide the retained main window first, then let a + // follow-up update reveal that exact native window without overwriting its + // geometry from stale main-window state. + let handoff: Task = state + .windows + .get(WindowKind::Main) + .map(|main_id| window::set_mode(main_id, window::Mode::Hidden)) + .unwrap_or_else(Task::none) + .chain(Task::done(DomainMessage::Ui( + UiShellMessage::PlayerOverlayHandoffReady.into(), + ))); + + DomainUpdateResult::task(handoff) +} + +/// Reveal the native-positioned overlay after the retained main window has +/// completed its hide command. +pub fn finish_player_overlay_activation( + state: &mut State, +) -> DomainUpdateResult { + let Some(overlay_id) = state.windows.get(WindowKind::PlayerOverlay) else { + return DomainUpdateResult::task(Task::none()); + }; + if state.windows.player_overlay_state() + != PlayerOverlayWindowState::Activating + { + return DomainUpdateResult::task(Task::none()); + } + log::debug!( + "native player overlay handoff: retained main-window hide completed" + ); + + let presenter_visible = state + .domains + .player + .state + .video_opt + .as_mut() + .is_some_and(|video| video.set_native_presenter_host_visible(true)); + if !presenter_visible { + return dismiss_player_overlay(state); + } + if !state.windows.finish_player_overlay_activation() { + return dismiss_player_overlay(state); + } + log::debug!( + "native player overlay handoff: presenter host visible; overlay focus requested" + ); + + DomainUpdateResult::task(window::gain_focus(overlay_id)) +} + +/// Retain an observable, pointer-free confirmation that the platform delivered +/// focus after the native host became visible. +pub fn on_player_overlay_focused(state: &State) -> DomainUpdateResult { + if state.windows.player_overlay_state() == PlayerOverlayWindowState::Active + { + log::debug!("native player overlay handoff: overlay focus confirmed"); + } + DomainUpdateResult::task(Task::none()) +} + +fn restore_main_window(state: &State) -> Task { + let Some(main_id) = state.windows.get(WindowKind::Main) else { + return Task::none(); + }; + + let mut restore: Task = + window::resize(main_id, state.window_size); + if let Some(position) = state.window_position { + restore = restore.chain(window::move_to(main_id, position)); + } + let mode = if state.is_fullscreen { + window::Mode::Fullscreen + } else { + window::Mode::Windowed + }; + restore + .chain(window::set_mode(main_id, mode)) + .chain(window::gain_focus(main_id)) +} + +fn prepare_player_overlay_close(id: window::Id) { + let result = ferrex_player_playback::native_video_slot:: + prepare_iced_native_host_close(id); + log::debug!( + "Prepared player overlay close: detached_slots={}, released_host={}", + result.detached_slots, + result.released_host + ); +} + +fn close_player_overlay_with_policy( + state: &mut State, + stop_playback: bool, +) -> DomainUpdateResult { + let Some(id) = state.windows.get(WindowKind::PlayerOverlay) else { + return DomainUpdateResult::task(Task::none()); + }; + let previous = state.windows.begin_player_overlay_close(); + if previous == PlayerOverlayWindowState::Closing { + return DomainUpdateResult::task(Task::none()); + } + + // Presenter detach and raw-handle release happen synchronously in this + // update, before the native close action can be processed. + prepare_player_overlay_close(id); + + let mut close: Task = window::close(id); + let handoff_started = matches!( + previous, + PlayerOverlayWindowState::Activating | PlayerOverlayWindowState::Active + ); + if handoff_started { + close = close.chain(restore_main_window(state)); + } + + let mut tasks = vec![close]; + if stop_playback && handoff_started { + tasks.push(Task::done(DomainMessage::Player(PlayerMessage::Stop))); + } + DomainUpdateResult::task(Task::batch(tasks)) +} + +/// Close a user-visible player overlay, stop playback, and restore the main +/// window geometry/focus. Presenter fallback can use +/// [`dismiss_player_overlay`] without stopping the replacement backend. +pub fn close_player_overlay(state: &mut State) -> DomainUpdateResult { + close_player_overlay_with_policy(state, true) +} + +/// Remove the overlay while allowing playback fallback to continue. +pub fn dismiss_player_overlay(state: &mut State) -> DomainUpdateResult { + close_player_overlay_with_policy(state, false) +} + pub fn open_search( state: &mut State, seed: Option, @@ -133,6 +346,7 @@ pub fn on_raw_window_closed( id: window::Id, ) -> DomainUpdateResult { let mut tasks: Vec> = Vec::new(); + let overlay_state = state.windows.player_overlay_state(); if let Some(kind) = state.windows.remove_by_id(id) { if matches!(kind, WindowKind::Main) { return DomainUpdateResult::task(iced::exit()); @@ -143,6 +357,22 @@ pub fn on_raw_window_closed( SearchPresentation::Hidden; push_main_focus(&mut tasks, state); } + if matches!(kind, WindowKind::PlayerOverlay) { + // Normally this is idempotent because the ClosePlayerOverlay + // update performed teardown before queueing `window::close`. + prepare_player_overlay_close(id); + if matches!( + overlay_state, + PlayerOverlayWindowState::Activating + | PlayerOverlayWindowState::Active + ) { + // Defensive path for a platform that bypassed CloseRequested. + tasks.push(Task::done(DomainMessage::Player( + PlayerMessage::Stop, + ))); + tasks.push(restore_main_window(state)); + } + } } if tasks.is_empty() { DomainUpdateResult::task(Task::none()) @@ -157,3 +387,58 @@ fn push_main_focus(tasks: &mut Vec>, state: &State) { tasks.push(window::gain_focus(main_id)); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "current_thread")] + async fn player_overlay_is_transparent_undecorated_and_initially_hidden() { + let state = State::default(); + let settings = player_overlay_window_settings(&state); + + assert!(!settings.visible); + assert!(settings.transparent); + assert!(!settings.decorations); + assert!(!settings.resizable); + assert!(!settings.exit_on_close_request); + assert_eq!(settings.level, window::Level::Normal); + assert_eq!(settings.size, state.window_size); + } + + #[tokio::test(flavor = "current_thread")] + async fn opening_overlay_records_hidden_lifecycle_before_task_completion() { + let mut state = State::default(); + let result = open_player_overlay(&mut state); + + assert!(state.windows.get(WindowKind::PlayerOverlay).is_some()); + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Hidden + ); + drop(result); + } + + #[tokio::test(flavor = "current_thread")] + async fn playback_exit_dismisses_active_overlay_without_mutating_player() { + let mut state = State::default(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.activate_player_overlay()); + assert!(state.windows.finish_player_overlay_activation()); + state.domains.player.state.last_valid_position = 42.5; + + let result = dismiss_player_overlay(&mut state); + + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Closing + ); + assert_eq!(state.domains.player.state.last_valid_position, 42.5); + assert_eq!(state.windows.get(WindowKind::Main), Some(main)); + assert_eq!(state.windows.get(WindowKind::PlayerOverlay), Some(overlay)); + drop(result); + } +} diff --git a/crates/ferrex-player-ui/src/domains/ui/windows/subscriptions.rs b/crates/ferrex-player-ui/src/domains/ui/windows/subscriptions.rs index 8579a963..a748e670 100644 --- a/crates/ferrex-player-ui/src/domains/ui/windows/subscriptions.rs +++ b/crates/ferrex-player-ui/src/domains/ui/windows/subscriptions.rs @@ -71,5 +71,34 @@ pub fn subscription(state: &State) -> Subscription { )); } + if let Some(overlay_id) = state.windows.get(WindowKind::PlayerOverlay) { + subs.push(iced::window::events().with(overlay_id).map( + |(tracked_id, (id, event))| match event { + iced::window::Event::Resized(size) if id == tracked_id => { + DomainMessage::Ui( + UiShellMessage::PlayerOverlayResized(id, size).into(), + ) + } + iced::window::Event::Focused if id == tracked_id => { + DomainMessage::Ui( + UiShellMessage::PlayerOverlayFocused.into(), + ) + } + _ => DomainMessage::NoOp, + }, + )); + // `exit_on_close_request` is disabled for this window so presenter + // detach and host-lease release run before native destruction. + subs.push(iced::window::close_requests().with(overlay_id).map( + |(tracked_id, id)| { + if id == tracked_id { + DomainMessage::Ui(UiShellMessage::ClosePlayerOverlay.into()) + } else { + DomainMessage::NoOp + } + }, + )); + } + Subscription::batch(subs) } diff --git a/crates/ferrex-player-ui/src/state.rs b/crates/ferrex-player-ui/src/state.rs index 3d9f1389..584abe8f 100644 --- a/crates/ferrex-player-ui/src/state.rs +++ b/crates/ferrex-player-ui/src/state.rs @@ -50,9 +50,7 @@ use ferrex_player_api::{ }, }; -#[cfg(feature = "unimplemented")] use crate::domains::streaming::StreamingDomainState; -#[cfg(feature = "unimplemented")] use ferrex_player_api::services::streaming::StreamingApiAdapter; use ferrex_core::player_prelude::{ @@ -296,7 +294,6 @@ impl State { settings_service, ); - #[cfg(feature = "unimplemented")] let streaming_state = { // Create streaming service adapter let api_arc_stream = Arc::new(api_client.clone()); @@ -343,7 +340,6 @@ impl State { settings_state, ), - #[cfg(feature = "unimplemented")] streaming: crate::domains::streaming::StreamingDomain::new( streaming_state, ), diff --git a/crates/ferrex-player-ui/src/update.rs b/crates/ferrex-player-ui/src/update.rs index 5151c949..71459935 100644 --- a/crates/ferrex-player-ui/src/update.rs +++ b/crates/ferrex-player-ui/src/update.rs @@ -48,7 +48,6 @@ use crate::domains::user_management::update::update_user_management; use crate::state::State; use iced::Task; -#[cfg(feature = "unimplemented")] use crate::domains::streaming::update::update_streaming; /// Domain-aware update function that routes messages to appropriate handlers @@ -124,7 +123,6 @@ pub fn update( DomainMessage::Ui(ui_msg) => update_ui(state, ui_msg), // Route streaming messages to the streaming domain handler - #[cfg(feature = "unimplemented")] DomainMessage::Streaming(streaming_msg) => { update_streaming(state, streaming_msg) } diff --git a/crates/ferrex-player-ui/src/view.rs b/crates/ferrex-player-ui/src/view.rs index 7beab61d..721dd18c 100644 --- a/crates/ferrex-player-ui/src/view.rs +++ b/crates/ferrex-player-ui/src/view.rs @@ -69,6 +69,12 @@ pub fn view( state, ); } + if state.windows.is_player_overlay_window(window_id) { + // A dedicated native-root overlay renders no library shell, header, or + // background scene. Its surface consists solely of player controls, + // status plates, and the transparent native-video slot region. + return view_player(state, Some(window_id)).map(DomainMessage::Player); + } // debug timing disabled in tests to simplify renderer unification // Check for first-run setup // Check authentication state @@ -124,7 +130,9 @@ pub fn view( ViewState::AdminUsers => { view_admin_users(state).map(DomainMessage::from) } - ViewState::Player => view_player(state).map(DomainMessage::Player), + ViewState::Player => { + view_player(state, None).map(DomainMessage::Player) + } ViewState::LoadingVideo { url } => { if state.interface_mode.is_tenfoot() { view_tenfoot_loading_status(url).map(DomainMessage::Player) @@ -848,11 +856,14 @@ mod tests { ), profiling::function )] -fn view_player(state: &State) -> Element<'_, player::messages::PlayerMessage> { +fn view_player( + state: &State, + native_host_window: Option, +) -> Element<'_, player::messages::PlayerMessage> { if state.interface_mode.is_tenfoot() { - view_tenfoot_player(state) + view_tenfoot_player(state, native_host_window) } else { - state.domains.player.state.view() + state.domains.player.state.view(native_host_window) } } From ae8eec1bd6cfadc53fec82bebc2b30854ba50737 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:19:08 -0600 Subject: [PATCH 08/56] feat(player): add in-process mpv native-window playback --- Cargo.lock | 2 + crates/ferrex-player-app/Cargo.toml | 1 + crates/ferrex-player-playback/Cargo.toml | 2 + crates/ferrex-player-playback/build.rs | 7 + .../ferrex-player-playback/src/mpv_adapter.rs | 4692 +++++++++++++++++ crates/ferrex-player-ui/Cargo.toml | 1 + crates/ferrex-player/Cargo.toml | 1 + crates/ferrex-server/Cargo.toml | 6 + .../tests/playback_stream_failures.rs | 676 ++- scripts/check-native-mpv-boundaries.sh | 83 + 10 files changed, 5466 insertions(+), 5 deletions(-) create mode 100644 crates/ferrex-player-playback/build.rs create mode 100644 crates/ferrex-player-playback/src/mpv_adapter.rs create mode 100755 scripts/check-native-mpv-boundaries.sh diff --git a/Cargo.lock b/Cargo.lock index 2d143f84..ddeb6c30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2387,6 +2387,7 @@ dependencies = [ "ferrex-core", "ferrex-player-api", "ferrex-player-foundation", + "ferrex-player-mpv", "futures", "iced", "iced_wgpu", @@ -2576,6 +2577,7 @@ dependencies = [ "ferrex-core", "ferrex-flatbuffers", "ferrex-model", + "ferrex-player-playback", "ferrexctl", "ffmpeg-next", "flatbuffers", diff --git a/crates/ferrex-player-app/Cargo.toml b/crates/ferrex-player-app/Cargo.toml index b03767ee..18dc5841 100644 --- a/crates/ferrex-player-app/Cargo.toml +++ b/crates/ferrex-player-app/Cargo.toml @@ -73,6 +73,7 @@ tracy-client = { version = "0.18", default-features = false, optional = true } [features] default = [] +mpv = ["ferrex-player-ui/mpv"] legacy-settings = ["ferrex-player-ui/legacy-settings"] iced_tester = ["iced/tester", "ferrex-player-ui/iced_tester"] hot = ["iced/hot", "ferrex-player-ui/hot"] diff --git a/crates/ferrex-player-playback/Cargo.toml b/crates/ferrex-player-playback/Cargo.toml index 4200c183..df226a60 100644 --- a/crates/ferrex-player-playback/Cargo.toml +++ b/crates/ferrex-player-playback/Cargo.toml @@ -28,6 +28,7 @@ missing_debug_implementations = "warn" [features] default = ["ui"] ui = ["dep:iced_wgpu", "dep:lucide-icons"] +mpv = ["dep:ferrex-player-mpv", "ferrex-player-mpv/linked"] profile-with-puffin = ["dep:profiling", "profiling/profile-with-puffin"] profile-with-tracy = ["dep:profiling"] profile-with-tracing = ["dep:profiling"] @@ -36,6 +37,7 @@ profile-with-tracing = ["dep:profiling"] ferrex-core = { path = "../ferrex-core", default-features = false, features = ["rkyv"] } ferrex-player-api = { workspace = true } ferrex-player-foundation = { workspace = true } +ferrex-player-mpv = { workspace = true, optional = true } iced = { git = "https://github.com/Lowband21/iced-ferrex.git", default-features = false, features = ["tokio", "image", "canvas", "advanced", "wgpu", "wayland", "batch", "lazy", "crisp"] } iced_wgpu = { git = "https://github.com/Lowband21/iced-ferrex.git", features = ["image", "batch"], optional = true } diff --git a/crates/ferrex-player-playback/build.rs b/crates/ferrex-player-playback/build.rs new file mode 100644 index 00000000..1a227aba --- /dev/null +++ b/crates/ferrex-player-playback/build.rs @@ -0,0 +1,7 @@ +fn main() { + // Release profiles can intentionally omit platform VOs for licensing. + // Rebuild the capability preflight whenever that package contract changes. + println!("cargo:rerun-if-env-changed=FERREX_MPV_X11"); + println!("cargo:rerun-if-env-changed=FERREX_MPV_WINDOWS_PRESENTER"); + println!("cargo:rerun-if-env-changed=FERREX_MPV_MACOS_PRESENTER"); +} diff --git a/crates/ferrex-player-playback/src/mpv_adapter.rs b/crates/ferrex-player-playback/src/mpv_adapter.rs new file mode 100644 index 00000000..f5aebaa1 --- /dev/null +++ b/crates/ferrex-player-playback/src/mpv_adapter.rs @@ -0,0 +1,4692 @@ +//! In-process libmpv adapter using mpv's ordinary native video-output window. +//! +//! This is the P3 compatibility path: libmpv owns decoding, rendering, and the +//! top-level video window. No render context or decoded frame crosses into +//! Iced/wgpu. The adapter translates only Ferrex-owned commands and copied mpv +//! events. + +use std::{collections::HashMap, ffi::OsStr, sync::mpsc, time::Duration}; + +#[cfg(target_os = "macos")] +use std::sync::{Arc, Mutex}; + +use ferrex_player_mpv::{ + MpvAsyncReply, MpvCompatibilityReport, MpvConfigPolicy, MpvEndFileReason, + MpvEvent, MpvFormat, MpvFunctionTable, MpvLogLevel, MpvMessageLevel, + MpvNode, MpvPropertyChange, MpvRequestId, MpvSessionConfig, MpvWorker, + MpvWorkerConfig, +}; +use zeroize::Zeroizing; + +use crate::{ + contract::{ + AudioTrack, BackendKind, BackendRequest, BufferState, Chapter, + ChapterId, Edition, EditionId, EndReason, EventSequence, + PlaybackCapabilities, PlaybackCommand, PlaybackError, + PlaybackErrorKind, PlaybackEvent, PlaybackEventEnvelope, + PlaybackEventSignal, PlaybackFilePath, PlaybackScreenshotMode, + PlaybackSnapshot, PlaybackSource, PlaybackState, PlaybackTarget, + SessionGeneration, SubtitleKind, SubtitleTrack, TrackCatalog, TrackId, + VideoParameters, VideoProfileName, reduce_event, + }, + diagnostics::{ + MpvClientApiDiagnostics, MpvConfigurationDiagnostics, + MpvConfigurationPolicy, MpvLogVerbosity, PlaybackDiagnosticSnapshot, + redact_playback_url, + }, +}; + +const MPV_CONFIG_POLICY_ENV: &str = "FERREX_MPV_CONFIG_POLICY"; +const MPV_LOG_LEVEL_ENV: &str = "FERREX_MPV_LOG_LEVEL"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MpvLoggingPolicy { + initial: MpvLogLevel, + steady: MpvLogLevel, + startup_verbose_capture: bool, +} + +impl Default for MpvLoggingPolicy { + fn default() -> Self { + Self { + initial: MpvLogLevel::Verbose, + steady: MpvLogLevel::Info, + startup_verbose_capture: true, + } + } +} + +impl MpvLoggingPolicy { + const fn fixed(level: MpvLogLevel) -> Self { + Self { + initial: level, + steady: level, + startup_verbose_capture: false, + } + } +} + +const OBSERVED_PROPERTIES: &[(&str, MpvFormat)] = &[ + ("pause", MpvFormat::Flag), + ("time-pos", MpvFormat::Double), + ("duration", MpvFormat::Double), + ("paused-for-cache", MpvFormat::Flag), + ("cache-buffering-state", MpvFormat::Double), + ("demuxer-cache-duration", MpvFormat::Double), + ("demuxer-cache-state", MpvFormat::Node), + ("core-idle", MpvFormat::Flag), + ("seeking", MpvFormat::Flag), + ("eof-reached", MpvFormat::Flag), + ("idle-active", MpvFormat::Flag), + ("track-list", MpvFormat::Node), + ("chapter-list", MpvFormat::Node), + ("chapter", MpvFormat::Int64), + ("edition-list", MpvFormat::Node), + ("edition", MpvFormat::Int64), + ("video-params", MpvFormat::Node), + ("video-out-params", MpvFormat::Node), + ("vo-configured", MpvFormat::Flag), + ("current-vo", MpvFormat::String), + ("current-gpu-context", MpvFormat::String), + ("hwdec-current", MpvFormat::String), + ("hwdec-interop", MpvFormat::String), + ("frame-drop-count", MpvFormat::Int64), + ("decoder-frame-drop-count", MpvFormat::Int64), + ("mistimed-frame-count", MpvFormat::Int64), + ("vo-delayed-frame-count", MpvFormat::Int64), + ("avsync", MpvFormat::Double), + ("mpv-version", MpvFormat::String), + ("ffmpeg-version", MpvFormat::String), + ("volume", MpvFormat::Double), + ("mute", MpvFormat::Flag), + ("speed", MpvFormat::Double), + ("fullscreen", MpvFormat::Flag), + ("glsl-shaders", MpvFormat::Node), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingAction { + Load, + Stop, + AbsoluteSeek, + Control(&'static str), + NativeWindowIdRefresh(NativeWindowIdRefreshTicket), + #[cfg(any(target_os = "windows", target_os = "macos"))] + NativeControl(NativeControl), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NativeWindowIdRefreshTicket { + output_epoch: u64, + observation_revision: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NativeWindowIdRefreshResult { + value_available: bool, + stale: bool, + applied: Option, +} + +#[cfg(any(target_os = "windows", target_os = "macos"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NativeControl { + Osc, + DefaultBindings, + VoKeyboard, +} + +#[cfg(any(target_os = "windows", target_os = "macos"))] +impl NativeControl { + const fn operation(self) -> &'static str { + match self { + Self::Osc => "enable native-window OSC", + Self::DefaultBindings => { + "enable native-window default input bindings" + } + Self::VoKeyboard => "enable native-window keyboard input", + } + } +} + +/// Keep pointer-driven absolute seeking bounded independently of Iced's event +/// cadence. One request may be in flight; newer positions replace the single +/// queued value until its reply arrives. +#[derive(Debug, Default)] +struct AbsoluteSeekCoalescer { + active: Option, + queued: Option, +} + +impl AbsoluteSeekCoalescer { + fn enqueue(&mut self, position: Duration) -> Option { + if self.active.is_some() { + self.queued = Some(position); + None + } else { + Some(position) + } + } + + fn submitted(&mut self, id: MpvRequestId) { + debug_assert!(self.active.is_none()); + self.active = Some(id); + } + + fn completed(&mut self, id: MpvRequestId) -> Option { + if self.active != Some(id) { + return None; + } + self.active = None; + self.queued.take() + } + + fn clear(&mut self) { + self.active = None; + self.queued = None; + } +} + +/// Native-window libmpv provider adapted to the Ferrex playback contract. +pub(crate) struct MpvPlaybackAdapter { + worker: Option, + snapshot: PlaybackSnapshot, + next_sequence: EventSequence, + pending: HashMap, + absolute_seeks: AbsoluteSeekCoalescer, + mapper: MpvEventMapper, + redactor: MpvSourceRedactor, + compatibility: MpvCompatibilityReport, + config_policy: MpvConfigPolicy, + logging_policy: MpvLoggingPolicy, + osc_enabled: bool, + input_default_bindings_enabled: bool, + input_vo_keyboard_enabled: bool, + event_signal: PlaybackEventSignal, + startup_diagnostics_active: bool, + native_output_epoch: u64, + native_window_observation_revision: u64, + native_window_id_refresh: Option, +} + +impl std::fmt::Debug for MpvPlaybackAdapter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MpvPlaybackAdapter") + .field("snapshot", &self.snapshot) + .field("compatibility", &self.compatibility) + .field("config_policy", &self.config_policy) + .field("logging_policy", &self.logging_policy) + .field("osc_enabled", &self.osc_enabled) + .field( + "input_bindings_enabled", + &(self.input_default_bindings_enabled + && self.input_vo_keyboard_enabled), + ) + .field("mpv_version", &self.mapper.mpv_version) + .field("ffmpeg_version", &self.mapper.ffmpeg_version) + .field("current_vo", &self.mapper.current_vo) + .field("current_gpu_context", &self.mapper.current_gpu_context) + .field("vo_configured", &self.mapper.vo_configured) + .field( + "native_window_id_observed", + &self.mapper.native_window_id.is_some(), + ) + .field("core_idle", &self.mapper.core_idle) + .field( + "startup_diagnostics_active", + &self.startup_diagnostics_active, + ) + .field( + "native_window_id_refresh_pending", + &self.native_window_id_refresh.is_some(), + ) + .field("pending_requests", &self.pending.len()) + .field("absolute_seeks", &self.absolute_seeks) + .finish_non_exhaustive() + } +} + +impl MpvPlaybackAdapter { + /// Start one serialized libmpv owner and submit a per-file native-window + /// load. Source credentials stay in process memory and are redacted before + /// any copied mpv log reaches the application logger. + #[cfg(test)] + pub(crate) fn open( + source: &PlaybackSource, + start: Duration, + generation: SessionGeneration, + ) -> Result { + Self::open_for_target( + source, + start, + generation, + PlaybackTarget::MPV_NATIVE_WINDOW, + ) + } + + /// Open mpv for an explicit presentation target. Integrated presenters + /// keep OSC and VO input disabled so Iced remains the sole controls/input + /// owner; ordinary native-window compatibility retains mpv's controls. + pub(crate) fn open_for_target( + source: &PlaybackSource, + start: Duration, + generation: SessionGeneration, + target: PlaybackTarget, + ) -> Result { + let functions = MpvFunctionTable::linked(); + let compatibility = functions.compatibility_report(); + if !compatibility.compatible { + return Err(mpv_error( + PlaybackErrorKind::BackendUnavailable, + format!( + "incompatible libmpv client API {}; Ferrex requires {}", + compatibility.runtime, compatibility.minimum + ), + true, + )); + } + + // OSC and native input are enabled only for this explicit + // native-window compatibility mode. User config and arbitrary + // scripts remain disabled unless trusted-code mode was explicitly + // selected through the developer-only policy switch. + let config_policy = configured_mpv_config_policy(); + let logging_policy = configured_mpv_logging_policy(); + let native_controls = mpv_native_controls_enabled(target); + let mut config = + MpvSessionConfig::native_window_with_config_policy(config_policy) + // Capture startup-only version, feature, GPU API, and adapter + // lines, then return to concise informational logging after + // the first file finishes initializing. + .with_log_level(logging_policy.initial) + .with_option("idle", "yes") + .with_option("keep-open", "no") + .with_option("save-position-on-quit", "no"); + if target == PlaybackTarget::MPV_NATIVE_WINDOW { + config = config + .with_option("osc", "yes") + .with_option("input-default-bindings", "yes") + .with_option("input-vo-keyboard", "yes"); + } + let (event_notifier, event_notifications) = mpsc::sync_channel(1); + let worker = MpvWorker::spawn_with_event_notifier( + functions, + config, + MpvWorkerConfig::default(), + event_notifier, + ) + .map_err(|error| { + worker_error( + PlaybackErrorKind::BackendInitialization, + "could not initialize in-process mpv", + error, + ) + })?; + + let mut adapter = Self { + worker: Some(worker), + snapshot: PlaybackSnapshot::new( + generation, + target, + mpv_capabilities(config_policy), + ), + next_sequence: EventSequence::FIRST, + pending: HashMap::new(), + absolute_seeks: AbsoluteSeekCoalescer::default(), + mapper: MpvEventMapper::default(), + redactor: MpvSourceRedactor::new(source), + compatibility, + config_policy, + logging_policy, + osc_enabled: native_controls, + input_default_bindings_enabled: native_controls, + input_vo_keyboard_enabled: native_controls, + event_signal: PlaybackEventSignal::new( + generation, + event_notifications, + ), + startup_diagnostics_active: logging_policy.startup_verbose_capture, + native_output_epoch: 0, + native_window_observation_revision: 0, + native_window_id_refresh: None, + }; + adapter.register_observations()?; + adapter.submit_load(source, start)?; + adapter.poll_events(); + Ok(adapter) + } + + pub(crate) fn snapshot(&self) -> &PlaybackSnapshot { + &self.snapshot + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + pub(crate) fn configure_integrated_presentation( + &mut self, + capabilities: &crate::presenter::PresenterCapabilities, + ) { + self.snapshot.target = PlaybackTarget::MPV_INTEGRATED; + self.snapshot.capabilities.integrated_presentation = + capabilities.integrated_overlay; + self.snapshot.capabilities.native_window_fallback = + capabilities.native_window_fallback; + self.snapshot.capabilities.native_hdr = capabilities.native_hdr; + self.snapshot.capabilities.fractional_scaling = + capabilities.fractional_scaling; + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + pub(crate) fn vo_configured(&self) -> bool { + self.mapper.vo_configured + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + pub(crate) fn native_window_id(&self) -> Option { + self.mapper.native_window_id + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + pub(crate) fn native_video_output_started(&self) -> bool { + self.mapper.vo_configured + || self.mapper.native_window_id.is_some() + || self.mapper.current_vo.is_some() + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + pub(crate) fn record_event(&mut self, event: PlaybackEvent) { + self.record(event); + } + + pub(crate) const fn compatibility_report(&self) -> MpvCompatibilityReport { + self.compatibility + } + + pub(crate) fn diagnostics( + &self, + requested_backend: BackendRequest, + ) -> PlaybackDiagnosticSnapshot { + let mut diagnostics = PlaybackDiagnosticSnapshot::from_snapshot( + &self.snapshot, + requested_backend, + ); + diagnostics.versions.client_api = Some(MpvClientApiDiagnostics { + bindings: self.compatibility.bindings.to_string(), + runtime: self.compatibility.runtime.to_string(), + minimum: self.compatibility.minimum.to_string(), + compatible: self.compatibility.compatible, + }); + diagnostics.mpv_configuration = Some(mpv_configuration_diagnostics( + self.config_policy, + self.logging_policy, + self.osc_enabled, + self.input_default_bindings_enabled + && self.input_vo_keyboard_enabled, + )); + self.mapper.populate_diagnostics(&mut diagnostics); + diagnostics + } + + pub(crate) fn record_fallback( + &mut self, + reason: crate::contract::FallbackReason, + ) { + self.record(PlaybackEvent::Fallback(reason)); + } + + /// Transfer input ownership back to mpv before exposing a runtime + /// presenter failure as ordinary native-window playback. + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + pub(crate) fn enable_native_window_controls( + &mut self, + ) -> Result<(), PlaybackError> { + if !self.osc_enabled { + self.submit_native_control("osc", NativeControl::Osc)?; + } + if !self.input_default_bindings_enabled { + self.submit_native_control( + "input-default-bindings", + NativeControl::DefaultBindings, + )?; + } + if !self.input_vo_keyboard_enabled { + self.submit_native_control( + "input-vo-keyboard", + NativeControl::VoKeyboard, + )?; + } + Ok(()) + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + pub(crate) fn commit_native_window_fallback( + &mut self, + reason: crate::contract::FallbackReason, + ) { + if let Err(error) = self.enable_native_window_controls() { + // Native-window playback remains controllable through Ferrex even + // if mpv cannot restore one of its optional in-window controls. + // Keep the fallback alive and expose the control state through + // diagnostics instead of turning a presenter failure into a + // terminal playback failure. + log::warn!( + "could not restore optional mpv native-window controls: {error}" + ); + } + self.record(PlaybackEvent::CapabilitiesChanged(mpv_capabilities( + self.config_policy, + ))); + self.record(PlaybackEvent::Fallback(reason)); + } + + pub(crate) fn event_signal(&self) -> PlaybackEventSignal { + self.event_signal.clone() + } + + pub(crate) fn apply_command( + &mut self, + command: PlaybackCommand, + ) -> Result<(), PlaybackError> { + self.poll_events(); + + match command { + PlaybackCommand::Load(source) => { + self.absolute_seeks.clear(); + self.begin_startup_diagnostics()?; + self.redactor.replace_source(&source); + self.mapper.reset_for_load(); + let result = self.submit_load(&source, Duration::ZERO); + if result.is_err() { + self.finish_startup_diagnostics(); + } + result + } + PlaybackCommand::SetPaused(paused) => { + self.submit_property( + "pause", + MpvNode::Bool(paused), + "set pause", + )?; + Ok(()) + } + PlaybackCommand::SeekAbsolute(position) => { + // The UI already limits preview cadence, but replies can be + // slower than that interval. Keep only the newest target while + // one asynchronous seek is outstanding. + if let Some(position) = self.absolute_seeks.enqueue(position) { + self.submit_absolute_seek(position)?; + } + self.mapper.seeking = true; + self.record(PlaybackEvent::StateChanged( + PlaybackState::Seeking, + )); + Ok(()) + } + PlaybackCommand::SeekRelative(delta) => { + let seconds = delta.as_seconds_f64(); + if !seconds.is_finite() { + return Err(mpv_error( + PlaybackErrorKind::Command, + "relative seek must be finite", + false, + )); + } + let request = self.worker()?.command_async([ + "seek".to_string(), + seconds.to_string(), + "relative+exact".to_string(), + ]); + self.track_request( + request, + PendingAction::Control("relative seek"), + )?; + self.mapper.seeking = true; + self.record(PlaybackEvent::StateChanged( + PlaybackState::Seeking, + )); + Ok(()) + } + PlaybackCommand::SetVolume(volume) => { + if !volume.is_finite() { + return Err(mpv_error( + PlaybackErrorKind::Command, + "volume must be finite", + false, + )); + } + self.submit_property( + "volume", + MpvNode::Double(volume.clamp(0.0, 1.0) * 100.0), + "set volume", + )?; + Ok(()) + } + PlaybackCommand::SetMuted(muted) => { + self.submit_property("mute", MpvNode::Bool(muted), "set mute")?; + Ok(()) + } + PlaybackCommand::SetSpeed(speed) => { + if !speed.is_finite() || speed <= 0.0 { + return Err(mpv_error( + PlaybackErrorKind::Command, + "playback speed must be finite and positive", + false, + )); + } + self.submit_property( + "speed", + MpvNode::Double(speed), + "set speed", + )?; + Ok(()) + } + PlaybackCommand::SelectAudio(track_id) => { + let native_id = self + .mapper + .audio_ids + .get(&track_id) + .copied() + .ok_or_else(|| { + mpv_error( + PlaybackErrorKind::Command, + format!("unknown mpv audio track identity: {track_id}"), + false, + ) + })?; + self.submit_property( + "aid", + MpvNode::Int(native_id), + "select audio track", + )?; + Ok(()) + } + PlaybackCommand::SelectSubtitle(track_id) => { + let value = match track_id { + Some(track_id) => { + let native_id = self + .mapper + .subtitle_ids + .get(&track_id) + .copied() + .ok_or_else(|| { + mpv_error( + PlaybackErrorKind::Command, + format!( + "unknown mpv subtitle track identity: {track_id}" + ), + false, + ) + })?; + MpvNode::Int(native_id) + } + None => MpvNode::String("no".to_string()), + }; + self.submit_property("sid", value, "select subtitle track")?; + Ok(()) + } + PlaybackCommand::AddExternalSubtitle { source, select } => { + if !self.snapshot.capabilities.external_subtitle_loading { + return Err(unsupported_mpv_extension( + "external subtitle loading is unavailable", + )); + } + let command = build_external_subtitle_command(&source, select)?; + self.redactor.remember_local_values([command[1].as_str()])?; + let request = self.worker()?.command_async(command); + self.track_request( + request, + PendingAction::Control("add external subtitle"), + )?; + Ok(()) + } + PlaybackCommand::SelectChapter(chapter_id) => { + let native_id = self + .mapper + .chapter_ids + .get(&chapter_id) + .copied() + .ok_or_else(|| { + mpv_error( + PlaybackErrorKind::Command, + format!( + "unknown mpv chapter identity: {}", + chapter_id.as_str() + ), + false, + ) + })?; + self.submit_property( + "chapter", + MpvNode::Int(native_id), + "select chapter", + )?; + Ok(()) + } + PlaybackCommand::SelectEdition(edition_id) => { + // mpv reports the `edition` property as unavailable for files + // with a single default edition. Treat selecting that already + // active catalog entry as an idempotent command. + if self.snapshot.current_edition.as_ref() == Some(&edition_id) { + return Ok(()); + } + let native_id = self + .mapper + .edition_ids + .get(&edition_id) + .copied() + .ok_or_else(|| { + mpv_error( + PlaybackErrorKind::Command, + format!( + "unknown mpv edition identity: {}", + edition_id.as_str() + ), + false, + ) + })?; + self.submit_property( + "edition", + MpvNode::Int(native_id), + "select edition", + )?; + Ok(()) + } + PlaybackCommand::SetContentFit(content_fit) => { + for (name, value) in content_fit_properties(content_fit) { + self.submit_property(name, value, "set content fit")?; + } + self.record(PlaybackEvent::ContentFitChanged(content_fit)); + Ok(()) + } + PlaybackCommand::SetFullscreen(fullscreen) => { + self.submit_property( + "fullscreen", + MpvNode::Bool(fullscreen), + "set fullscreen", + )?; + Ok(()) + } + PlaybackCommand::ApplyVideoProfile(profile) => { + if !self.snapshot.capabilities.video_profile_passthrough { + return Err(unsupported_mpv_extension( + "user video profiles require the trusted-user mpv configuration policy", + )); + } + let command = build_apply_profile_command(&profile)?; + self.redactor.remember_local_values([command[1].as_str()])?; + let request = self.worker()?.command_async(command); + self.track_request( + request, + PendingAction::Control("apply video profile"), + )?; + Ok(()) + } + PlaybackCommand::SetVideoShaders(shaders) => { + if !self.snapshot.capabilities.video_shader_passthrough { + return Err(unsupported_mpv_extension( + "video shader passthrough is unavailable", + )); + } + let commands = build_shader_commands(&shaders)?; + self.redactor.remember_local_values( + commands.iter().map(|command| command[3].as_str()), + )?; + for command in commands { + let request = self.worker()?.command_async(command); + self.track_request( + request, + PendingAction::Control("set video shaders"), + )?; + } + Ok(()) + } + PlaybackCommand::CaptureScreenshot { output, mode } => { + if !self.snapshot.capabilities.screenshot { + return Err(unsupported_mpv_extension( + "native video screenshots are unavailable", + )); + } + let command = build_screenshot_command(&output, mode)?; + self.redactor.remember_local_values([command[1].as_str()])?; + let request = self.worker()?.command_async(command); + self.track_request( + request, + PendingAction::Control("capture screenshot"), + )?; + Ok(()) + } + PlaybackCommand::Stop => { + self.absolute_seeks.clear(); + if self.worker.is_none() { + return Ok(()); + } + self.mapper.stopping = true; + self.record(PlaybackEvent::StateChanged( + PlaybackState::Stopping, + )); + let request = self.worker()?.command_async(["stop"]); + self.track_request(request, PendingAction::Stop)?; + Ok(()) + } + PlaybackCommand::Shutdown => self.shutdown(), + } + } + + pub(crate) fn poll_events(&mut self) { + let events = self + .worker + .as_ref() + .map(MpvWorker::drain_events) + .unwrap_or_default(); + for event in events { + self.handle_event(event); + } + } + + pub(crate) fn refresh_tracks(&mut self) -> TrackCatalog { + self.poll_events(); + self.snapshot.tracks.clone() + } + + pub(crate) fn has_video(&self) -> bool { + !matches!( + self.snapshot.state, + PlaybackState::Idle + | PlaybackState::Failed + | PlaybackState::Terminated + ) + } + + pub(crate) fn subtitles_enabled(&self) -> bool { + self.snapshot.tracks.selected_subtitle.is_some() + } + + pub(crate) fn set_subtitles_enabled(&mut self, enabled: bool) { + if !enabled { + let _ = self.apply_command(PlaybackCommand::SelectSubtitle(None)); + } + } + + fn register_observations(&self) -> Result<(), PlaybackError> { + for (name, format) in OBSERVED_PROPERTIES { + self.worker()?.observe_property(*name, *format).map_err( + |error| { + worker_error( + PlaybackErrorKind::BackendInitialization, + "could not observe required mpv property", + error, + ) + }, + )?; + } + #[cfg(any(target_os = "windows", target_os = "macos"))] + self.worker()? + .observe_property("window-id", MpvFormat::Int64) + .map_err(|error| { + worker_error( + PlaybackErrorKind::BackendInitialization, + "could not observe mpv's native window-id", + error, + ) + })?; + Ok(()) + } + + fn submit_load( + &mut self, + source: &PlaybackSource, + start: Duration, + ) -> Result<(), PlaybackError> { + let command = build_load_command(source, start)?; + self.invalidate_native_output_refresh(); + self.mapper.reset_for_load(); + // A replacement file must not expose selectable identities from the + // previous demuxer while mpv is rebuilding its property catalogs. + self.record(PlaybackEvent::TracksChanged(TrackCatalog::default())); + self.record(PlaybackEvent::ChaptersChanged(Vec::new())); + self.record(PlaybackEvent::ChapterChanged(None)); + self.record(PlaybackEvent::EditionsChanged(Vec::new())); + self.record(PlaybackEvent::EditionChanged(None)); + self.record(PlaybackEvent::VideoParametersChanged(None)); + self.record(PlaybackEvent::DurationChanged(None)); + self.record(PlaybackEvent::StateChanged(PlaybackState::Loading)); + let request = self.worker()?.command_node_async(command); + self.track_request(request, PendingAction::Load)?; + Ok(()) + } + + fn submit_absolute_seek( + &mut self, + position: Duration, + ) -> Result<(), PlaybackError> { + let seconds = finite_seconds(position)?; + let request = self.worker()?.command_async([ + "seek".to_string(), + seconds, + "absolute+exact".to_string(), + ]); + let id = self.track_request(request, PendingAction::AbsoluteSeek)?; + self.absolute_seeks.submitted(id); + Ok(()) + } + + fn submit_property( + &mut self, + name: &str, + value: MpvNode, + operation: &'static str, + ) -> Result { + let request = self.worker()?.set_property_async(name, value); + self.track_request(request, PendingAction::Control(operation)) + } + + #[cfg(all( + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + fn submit_native_control( + &mut self, + name: &str, + control: NativeControl, + ) -> Result { + let request = + self.worker()?.set_property_async(name, MpvNode::Bool(true)); + self.track_request(request, PendingAction::NativeControl(control)) + } + + fn invalidate_native_output_refresh(&mut self) { + self.native_output_epoch = self.native_output_epoch.wrapping_add(1); + self.native_window_id_refresh = None; + } + + fn submit_native_window_id_refresh(&mut self) -> Result<(), PlaybackError> { + if self.native_window_id_refresh.is_some() + || self.mapper.native_window_id.is_some() + || !self.mapper.vo_configured + { + return Ok(()); + } + let ticket = NativeWindowIdRefreshTicket { + output_epoch: self.native_output_epoch, + observation_revision: self.native_window_observation_revision, + }; + let request = self + .worker()? + .get_property_async("window-id", MpvFormat::Int64); + let id = self.track_request( + request, + PendingAction::NativeWindowIdRefresh(ticket), + )?; + self.native_window_id_refresh = Some(id); + log::debug!("mpv native window-id refresh submitted"); + Ok(()) + } + + fn handle_native_window_id_refresh_reply( + &mut self, + reply: &MpvAsyncReply, + ticket: NativeWindowIdRefreshTicket, + ) { + let request_is_current = + self.native_window_id_refresh == Some(reply.id); + if request_is_current { + self.native_window_id_refresh = None; + } + let value = reply.result.as_ref().ok().and_then(|value| value.as_ref()); + let result = evaluate_native_window_id_refresh( + ticket, + self.native_output_epoch, + self.native_window_observation_revision, + request_is_current, + self.mapper.vo_configured, + self.mapper.native_window_id, + value, + cfg!(target_os = "macos"), + ); + if let Some(native_window_id) = result.applied { + self.mapper.native_window_id = Some(native_window_id); + } + log::debug!( + "mpv native window-id refresh completed: request_succeeded={} value_available={} stale={} applied={}", + reply.result.is_ok(), + result.value_available, + result.stale, + result.applied.is_some(), + ); + } + + fn track_request( + &mut self, + result: Result, + action: PendingAction, + ) -> Result { + let id = result.map_err(|error| { + worker_error( + if matches!(action, PendingAction::Load) { + PlaybackErrorKind::BackendInitialization + } else { + PlaybackErrorKind::Command + }, + "libmpv rejected an asynchronous request", + error, + ) + })?; + self.pending.insert(id, action); + Ok(id) + } + + fn handle_event(&mut self, event: MpvEvent) { + match event { + MpvEvent::Log(message) => { + let text = self.redactor.redact(&message.text); + self.mapper.observe_log(&message.prefix, &text); + let line = format!("mpv[{}]: {text}", message.prefix); + match message.level { + MpvMessageLevel::Fatal | MpvMessageLevel::Error => { + log::error!("{line}") + } + MpvMessageLevel::Warn => log::warn!("{line}"), + MpvMessageLevel::Info => log::info!("{line}"), + MpvMessageLevel::Verbose | MpvMessageLevel::Debug => { + log::debug!("{line}") + } + MpvMessageLevel::Trace => log::trace!("{line}"), + MpvMessageLevel::Unknown(_) => log::debug!("{line}"), + } + } + MpvEvent::AsyncReply(reply) => { + let action = self.pending.remove(&reply.id); + if let Some(PendingAction::NativeWindowIdRefresh(ticket)) = + action + { + self.handle_native_window_id_refresh_reply(&reply, ticket); + return; + } + let queued_absolute_seek = + matches!(action, Some(PendingAction::AbsoluteSeek)) + .then(|| self.absolute_seeks.completed(reply.id)) + .flatten(); + + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + if reply.result.is_ok() + && let Some(PendingAction::NativeControl(control)) = + action + { + match control { + NativeControl::Osc => self.osc_enabled = true, + NativeControl::DefaultBindings => { + self.input_default_bindings_enabled = true; + } + NativeControl::VoKeyboard => { + self.input_vo_keyboard_enabled = true; + } + } + } + } + + if let Err(error) = reply.result { + if matches!(action, Some(PendingAction::Load)) { + self.mapper.terminal = true; + self.record(PlaybackEvent::Error(native_error( + PlaybackErrorKind::UnsupportedMedia, + "mpv could not load the media source", + error.code, + true, + ))); + self.finish_startup_diagnostics(); + } else { + let operation = match action { + Some(PendingAction::Stop) => "stop", + Some(PendingAction::AbsoluteSeek) => { + "absolute seek" + } + Some(PendingAction::Control(operation)) => { + operation + } + Some(PendingAction::NativeWindowIdRefresh(_)) => { + "refresh native window identity" + } + #[cfg(any( + target_os = "windows", + target_os = "macos" + ))] + Some(PendingAction::NativeControl(control)) => { + control.operation() + } + Some(PendingAction::Load) => "load", + None => "unknown request", + }; + #[cfg(any(target_os = "windows", target_os = "macos"))] + if matches!( + action, + Some(PendingAction::NativeControl(_)) + ) { + log::warn!( + "mpv could not restore optional native-window control `{operation}`; Ferrex controls remain available" + ); + } + log::warn!( + "mpv {operation} request failed with native error {} ({})", + error.code, + error.description + ); + } + } + + if let Some(position) = queued_absolute_seek + && let Err(error) = self.submit_absolute_seek(position) + { + self.absolute_seeks.clear(); + log::warn!( + "could not submit the coalesced mpv absolute seek: {error}" + ); + } + } + MpvEvent::PropertyChanged(change) => { + self.handle_property_change(change); + } + MpvEvent::UnmatchedAsyncReply { id, kind, error } => { + log::warn!( + "received unmatched mpv {kind:?} reply {}: {:?}", + id.get(), + error + ); + } + MpvEvent::Hook(hook) => { + if let Some(worker) = self.worker.as_ref() + && let Err(error) = worker.continue_hook(hook.id) + { + log::error!("could not continue mpv hook: {error}"); + } + } + MpvEvent::ClientMessage(arguments) => { + log::debug!( + "received mpv client message with {} argument(s)", + arguments.len() + ); + } + MpvEvent::ProtocolError { event_id, message } => { + self.mapper.terminal = true; + self.record(PlaybackEvent::Error(mpv_error( + PlaybackErrorKind::Protocol, + format!("invalid mpv event {event_id}: {message}"), + true, + ))); + self.finish_startup_diagnostics(); + } + MpvEvent::QueueOverflow => { + self.mapper.terminal = true; + self.record(PlaybackEvent::Error(mpv_error( + PlaybackErrorKind::Protocol, + "mpv event queue overflowed", + true, + ))); + self.finish_startup_diagnostics(); + } + other => { + if matches!(&other, MpvEvent::StartFile { .. }) { + self.invalidate_native_output_refresh(); + } + let startup_complete = matches!( + &other, + MpvEvent::FileLoaded + | MpvEvent::EndFile(_) + | MpvEvent::Shutdown + ); + for event in self.mapper.map_event(&other) { + self.record(event); + } + if startup_complete { + self.finish_startup_diagnostics(); + } + } + } + } + + fn handle_property_change(&mut self, change: MpvPropertyChange) { + let previous_vo_configured = self.mapper.vo_configured; + let previous_native_window_id = self.mapper.native_window_id; + if change.name == "window-id" { + self.native_window_observation_revision = + self.native_window_observation_revision.wrapping_add(1); + } + + for event in self.mapper.map_property(&change) { + self.record(event); + } + + let vo_changed = previous_vo_configured != self.mapper.vo_configured; + let window_id_changed = + previous_native_window_id != self.mapper.native_window_id; + if (change.name == "vo-configured" && vo_changed) + || (change.name == "window-id" && window_id_changed) + { + log::debug!( + "mpv native-output readiness transition: property={} vo_configured={} native_window_id_observed={} identity_changed={}", + change.name, + self.mapper.vo_configured, + self.mapper.native_window_id.is_some(), + previous_native_window_id.is_some() + && self.mapper.native_window_id.is_some() + && window_id_changed, + ); + } + + if previous_vo_configured && !self.mapper.vo_configured { + self.invalidate_native_output_refresh(); + } + if cfg!(target_os = "macos") + && native_window_id_refresh_needed( + previous_vo_configured, + self.mapper.vo_configured, + self.mapper.native_window_id, + self.native_window_id_refresh.is_some(), + ) + && let Err(error) = self.submit_native_window_id_refresh() + { + log::warn!( + "could not submit one-shot mpv native window-id refresh: {error}" + ); + } + } + + fn shutdown(&mut self) -> Result<(), PlaybackError> { + self.absolute_seeks.clear(); + let Some(worker) = self.worker.take() else { + return Ok(()); + }; + + // mpv's macOS VO synchronously dispatches parts of teardown to the + // AppKit main queue. PlaybackSession::shutdown is called from Iced's + // AppKit callback, so waiting for the owner here can deadlock both + // threads. Move the worker to a named reaper and return immediately; + // the event loop can then service the native teardown dispatch. + #[cfg(target_os = "macos")] + { + let result = reap_macos_worker(worker); + self.mapper.terminal = true; + self.record(PlaybackEvent::StateChanged(PlaybackState::Terminated)); + return result; + } + + #[cfg(not(target_os = "macos"))] + { + let mut worker = worker; + let report = worker.shutdown().map_err(|error| { + worker_error( + PlaybackErrorKind::Shutdown, + "ordered libmpv shutdown failed", + error, + ) + })?; + for event in worker.drain_events() { + self.handle_event(event); + } + if report.timed_out { + log::warn!("libmpv stop drain reached its shutdown deadline"); + } + self.mapper.terminal = true; + self.record(PlaybackEvent::StateChanged(PlaybackState::Terminated)); + Ok(()) + } + } + + fn begin_startup_diagnostics(&mut self) -> Result<(), PlaybackError> { + if !self.logging_policy.startup_verbose_capture { + return Ok(()); + } + if self.startup_diagnostics_active { + return Ok(()); + } + self.worker()?.set_log_level(MpvLogLevel::Verbose).map_err( + |error| { + worker_error( + PlaybackErrorKind::Command, + "could not enable mpv startup diagnostics", + error, + ) + }, + )?; + self.startup_diagnostics_active = true; + Ok(()) + } + + fn finish_startup_diagnostics(&mut self) { + if !self.startup_diagnostics_active { + return; + } + self.startup_diagnostics_active = false; + if let Some(worker) = self.worker.as_ref() + && let Err(error) = worker.set_log_level(self.logging_policy.steady) + { + log::warn!( + "could not restore concise mpv logging after startup diagnostics: {error}" + ); + } + } + + fn worker(&self) -> Result<&MpvWorker, PlaybackError> { + self.worker.as_ref().ok_or_else(|| { + mpv_error( + PlaybackErrorKind::Shutdown, + "libmpv owner has already terminated", + false, + ) + }) + } + + fn record(&mut self, event: PlaybackEvent) { + let sequence = self.next_sequence; + let Some(next) = sequence.next() else { + self.snapshot.state = PlaybackState::Failed; + self.snapshot.last_error = Some(mpv_error( + PlaybackErrorKind::Protocol, + "mpv event sequence exhausted", + false, + )); + return; + }; + self.next_sequence = next; + let generation = self.snapshot.generation; + let _ = reduce_event( + &mut self.snapshot, + PlaybackEventEnvelope { + generation, + sequence, + event, + }, + ); + } +} + +/// Hand a macOS libmpv owner to a background reaper without ever dropping the +/// worker on AppKit's main thread. The extra `Arc>>` matters on +/// the rare thread-spawn failure path: `Builder::spawn` consumes and drops its +/// closure on the caller, so capturing the worker directly would invoke its +/// blocking `Drop` exactly where it is unsafe. +#[cfg(target_os = "macos")] +fn reap_macos_worker(worker: MpvWorker) -> Result<(), PlaybackError> { + let worker = Arc::new(Mutex::new(Some(worker))); + let reaper_worker = Arc::clone(&worker); + let spawn = std::thread::Builder::new() + .name("ferrex-libmpv-appkit-reaper".to_string()) + .spawn(move || { + let mut worker = reaper_worker + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("macOS mpv reaper owns one worker"); + match worker.shutdown() { + Ok(report) if report.timed_out => { + log::warn!( + "libmpv macOS stop drain reached its shutdown deadline" + ); + } + Ok(_) => { + log::debug!("libmpv macOS reaper completed native teardown"); + } + Err(error) => { + log::error!( + "libmpv macOS reaper could not complete ordered shutdown: {error}" + ); + } + } + }); + + if let Err(error) = spawn { + let mut worker = worker + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("failed reaper spawn preserves the mpv worker"); + let begin_error = worker.begin_shutdown().err(); + + // `MpvWorker::drop` is intentionally blocking. If no reaper thread can + // be created, leaking this already-shutting-down handle is preferable + // to freezing AppKit. The owner receives the shutdown request and may + // still finish native teardown once this callback returns. + std::mem::forget(worker); + + let detail = begin_error.map_or_else( + || format!("could not start the macOS libmpv reaper: {error}"), + |begin_error| { + format!( + "could not start the macOS libmpv reaper ({error}) or begin ordered shutdown ({begin_error})" + ) + }, + ); + return Err(mpv_error(PlaybackErrorKind::Shutdown, detail, true)); + } + + Ok(()) +} + +impl Drop for MpvPlaybackAdapter { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} + +#[derive(Debug, Default)] +struct MpvEventMapper { + paused: bool, + buffering: bool, + paused_for_cache: bool, + cache_underrun: bool, + core_idle: bool, + seeking: bool, + stopping: bool, + file_loaded: bool, + terminal: bool, + buffer: BufferState, + audio_ids: HashMap, + subtitle_ids: HashMap, + chapter_ids: HashMap, + current_chapter_id: Option, + edition_ids: HashMap, + current_edition_id: Option, + video: Option, + input_video: Option, + output_video: Option, + vo_configured: bool, + /// Opaque platform window identity. Never log or serialize this value. + native_window_id: Option, + current_vo: Option, + current_gpu_api: Option, + current_gpu_context: Option, + active_video_shader_count: Option, + gpu_adapter: Option, + hardware_decoder: Option, + hardware_decoder_interop: Option, + decoder_frame_drop_count: Option, + frame_drop_count: Option, + mistimed_frame_count: Option, + delayed_frame_count: Option, + av_sync_seconds: Option, + mpv_version: Option, + ffmpeg_version: Option, + libplacebo_version: Option, + compiled_features: Vec, +} + +impl MpvEventMapper { + fn reset_for_load(&mut self) { + self.buffering = false; + self.paused_for_cache = false; + self.cache_underrun = false; + self.core_idle = true; + self.seeking = false; + self.stopping = false; + self.file_loaded = false; + self.terminal = false; + self.buffer = BufferState::default(); + self.audio_ids.clear(); + self.subtitle_ids.clear(); + self.chapter_ids.clear(); + self.current_chapter_id = None; + self.edition_ids.clear(); + self.current_edition_id = None; + self.video = None; + self.input_video = None; + self.output_video = None; + self.vo_configured = false; + self.native_window_id = None; + self.current_vo = None; + self.current_gpu_api = None; + self.current_gpu_context = None; + self.gpu_adapter = None; + self.hardware_decoder = None; + self.hardware_decoder_interop = None; + self.decoder_frame_drop_count = None; + self.frame_drop_count = None; + self.mistimed_frame_count = None; + self.delayed_frame_count = None; + self.av_sync_seconds = None; + } + + fn populate_diagnostics( + &self, + diagnostics: &mut PlaybackDiagnosticSnapshot, + ) { + diagnostics.versions.mpv = self.mpv_version.clone(); + diagnostics.versions.ffmpeg = self.ffmpeg_version.clone(); + diagnostics.versions.libplacebo = self.libplacebo_version.clone(); + diagnostics.versions.compiled_features = self.compiled_features.clone(); + diagnostics.output.vo_configured = Some(self.vo_configured); + diagnostics.output.video_output = self.current_vo.clone(); + diagnostics.output.gpu_api = self.current_gpu_api.clone(); + diagnostics.output.gpu_context = self.current_gpu_context.clone(); + diagnostics.output.gpu_adapter = self.gpu_adapter.clone(); + diagnostics.output.hardware_decoder = self.hardware_decoder.clone(); + diagnostics.output.hardware_decoder_interop = + self.hardware_decoder_interop.clone(); + diagnostics.output.input_video = self.input_video.clone(); + diagnostics.output.output_video = self.output_video.clone(); + diagnostics.output.frames.decoder_dropped = + self.decoder_frame_drop_count; + diagnostics.output.frames.output_dropped = self.frame_drop_count; + diagnostics.output.frames.mistimed = self.mistimed_frame_count; + diagnostics.output.frames.delayed = self.delayed_frame_count; + diagnostics.output.frames.av_sync_seconds = self.av_sync_seconds; + if let Some(configuration) = diagnostics.mpv_configuration.as_mut() { + configuration.active_video_shader_count = + self.active_video_shader_count; + } + } + + fn observe_log(&mut self, prefix: &str, text: &str) { + if self.libplacebo_version.is_none() + && (prefix.contains("libplacebo") + || text.to_ascii_lowercase().contains("libplacebo")) + { + self.libplacebo_version = extract_libplacebo_version(text); + } + if self.compiled_features.is_empty() { + self.compiled_features = parse_compiled_features(text); + } + if self.current_gpu_api.is_none() { + self.current_gpu_api = gpu_api_from_log_prefix(prefix); + } + if self.gpu_adapter.is_none() { + self.gpu_adapter = gpu_adapter_from_log(prefix, text); + } + } + + fn map_event(&mut self, event: &MpvEvent) -> Vec { + match event { + MpvEvent::StartFile { .. } => { + self.reset_for_load(); + vec![ + PlaybackEvent::StateChanged(PlaybackState::Loading), + PlaybackEvent::TracksChanged(TrackCatalog::default()), + PlaybackEvent::ChaptersChanged(Vec::new()), + PlaybackEvent::ChapterChanged(None), + PlaybackEvent::EditionsChanged(Vec::new()), + PlaybackEvent::EditionChanged(None), + PlaybackEvent::VideoParametersChanged(None), + PlaybackEvent::DurationChanged(None), + ] + } + MpvEvent::FileLoaded => { + self.file_loaded = true; + vec![PlaybackEvent::StateChanged(self.normal_state())] + } + MpvEvent::PropertyChanged(change) => self.map_property(change), + MpvEvent::Seek => { + self.seeking = true; + vec![PlaybackEvent::StateChanged(PlaybackState::Seeking)] + } + MpvEvent::PlaybackRestart => { + self.file_loaded = true; + self.seeking = false; + vec![PlaybackEvent::StateChanged(self.normal_state())] + } + MpvEvent::EndFile(end) => { + self.file_loaded = false; + self.seeking = false; + self.buffering = false; + self.stopping = false; + self.terminal = true; + match end.reason { + MpvEndFileReason::Eof => { + vec![PlaybackEvent::Ended(EndReason::Eof)] + } + MpvEndFileReason::Stop => { + vec![PlaybackEvent::Ended(EndReason::Stopped)] + } + MpvEndFileReason::Redirect => { + vec![PlaybackEvent::Ended(EndReason::Replaced)] + } + MpvEndFileReason::Quit => { + vec![PlaybackEvent::Ended(EndReason::Closed)] + } + MpvEndFileReason::Unknown(_) => { + vec![PlaybackEvent::Ended(EndReason::BackendTerminated)] + } + MpvEndFileReason::Error => { + let code = end.error.map(|error| i64::from(error.code)); + let mut error = mpv_error( + PlaybackErrorKind::UnsupportedMedia, + "mpv playback ended with a native media error", + true, + ); + error.code = code; + vec![PlaybackEvent::Error(error)] + } + } + } + MpvEvent::Idle if !self.terminal => { + self.file_loaded = false; + vec![PlaybackEvent::StateChanged(PlaybackState::Idle)] + } + MpvEvent::Idle => Vec::new(), + MpvEvent::Shutdown if self.terminal => Vec::new(), + MpvEvent::Shutdown => { + self.terminal = true; + vec![PlaybackEvent::Ended(EndReason::BackendTerminated)] + } + MpvEvent::VideoReconfigured + | MpvEvent::AudioReconfigured + | MpvEvent::Tick + | MpvEvent::Unknown { .. } + | MpvEvent::Log(_) + | MpvEvent::AsyncReply(_) + | MpvEvent::UnmatchedAsyncReply { .. } + | MpvEvent::ClientMessage(_) + | MpvEvent::QueueOverflow + | MpvEvent::Hook(_) + | MpvEvent::ProtocolError { .. } => Vec::new(), + } + } + + fn map_property( + &mut self, + change: &MpvPropertyChange, + ) -> Vec { + match change.name.as_str() { + "pause" => { + if let Some(paused) = node_bool(change.value.as_ref()) { + self.paused = paused; + if self.file_loaded && !self.terminal { + return vec![PlaybackEvent::StateChanged( + self.normal_state(), + )]; + } + } + Vec::new() + } + "time-pos" => node_nonnegative_f64(change.value.as_ref()) + .and_then(duration_from_seconds) + .map(PlaybackEvent::PositionChanged) + .into_iter() + .collect(), + "duration" => vec![PlaybackEvent::DurationChanged( + node_nonnegative_f64(change.value.as_ref()) + .and_then(duration_from_seconds), + )], + "paused-for-cache" => { + if let Some(paused_for_cache) = node_bool(change.value.as_ref()) + { + self.paused_for_cache = paused_for_cache; + self.buffering = + self.paused_for_cache || self.cache_underrun; + self.buffer.buffering = self.buffering; + let mut events = + vec![PlaybackEvent::BufferChanged(self.buffer.clone())]; + if self.file_loaded && !self.terminal { + events.push(PlaybackEvent::StateChanged( + self.normal_state(), + )); + } + events + } else { + Vec::new() + } + } + "cache-buffering-state" => { + self.buffer.percentage = node_f64(change.value.as_ref()) + .filter(|value| value.is_finite()) + .map(|value| (value / 100.0).clamp(0.0, 1.0)); + vec![PlaybackEvent::BufferChanged(self.buffer.clone())] + } + "demuxer-cache-duration" => { + self.buffer.cached_duration = + node_nonnegative_f64(change.value.as_ref()) + .and_then(duration_from_seconds); + vec![PlaybackEvent::BufferChanged(self.buffer.clone())] + } + "demuxer-cache-state" => { + if let Some(fields) = change.value.as_ref().and_then(node_map) { + self.buffer.cached_duration = + map_f64(fields, "cache-duration") + .and_then(duration_from_seconds); + if let Some(underrun) = map_bool(fields, "underrun") { + self.cache_underrun = underrun; + self.buffering = + self.paused_for_cache || self.cache_underrun; + self.buffer.buffering = self.buffering; + } + } else { + self.buffer.cached_duration = None; + } + let mut events = + vec![PlaybackEvent::BufferChanged(self.buffer.clone())]; + if self.file_loaded && !self.terminal { + events + .push(PlaybackEvent::StateChanged(self.normal_state())); + } + events + } + "core-idle" => { + if let Some(core_idle) = node_bool(change.value.as_ref()) { + self.core_idle = core_idle; + } + Vec::new() + } + "seeking" => { + if let Some(seeking) = node_bool(change.value.as_ref()) { + self.seeking = seeking; + if self.file_loaded && !self.terminal { + return vec![PlaybackEvent::StateChanged( + self.normal_state(), + )]; + } + } + Vec::new() + } + // End-file is the authoritative terminal event. Keeping these + // observations still exposes state and catches future reducer use. + "eof-reached" => Vec::new(), + "idle-active" => { + if node_bool(change.value.as_ref()) == Some(true) + && !self.terminal + { + self.file_loaded = false; + vec![PlaybackEvent::StateChanged(PlaybackState::Idle)] + } else { + Vec::new() + } + } + "track-list" => { + let (catalog, audio_ids, subtitle_ids, selected_video_codec) = + parse_track_list(change.value.as_ref()); + self.audio_ids = audio_ids; + self.subtitle_ids = subtitle_ids; + if let Some(codec) = selected_video_codec { + let video = + self.video.get_or_insert_with(VideoParameters::default); + video.codec = Some(codec.clone()); + let input = self + .input_video + .get_or_insert_with(VideoParameters::default); + input.codec = Some(codec); + } + let mut events = vec![PlaybackEvent::TracksChanged(catalog)]; + if self.video.is_some() { + events.push(PlaybackEvent::VideoParametersChanged( + self.video.clone(), + )); + } + events + } + "chapter-list" => { + let (chapters, chapter_ids) = + parse_chapters(change.value.as_ref()); + self.chapter_ids = chapter_ids; + vec![ + PlaybackEvent::ChaptersChanged(chapters), + PlaybackEvent::ChapterChanged(self.current_chapter()), + ] + } + "chapter" => { + self.current_chapter_id = node_i64(change.value.as_ref()) + .filter(|native_id| *native_id >= 0); + vec![PlaybackEvent::ChapterChanged(self.current_chapter())] + } + "edition-list" => { + let (editions, edition_ids) = + parse_editions(change.value.as_ref()); + if self.current_edition_id.is_none() { + self.current_edition_id = editions + .iter() + .find(|edition| edition.is_default) + .and_then(|edition| edition_ids.get(&edition.id)) + .copied(); + } + self.edition_ids = edition_ids; + vec![ + PlaybackEvent::EditionsChanged(editions), + PlaybackEvent::EditionChanged(self.current_edition()), + ] + } + "edition" => { + if let Some(native_id) = node_i64(change.value.as_ref()) + .filter(|native_id| *native_id >= 0) + { + self.current_edition_id = Some(native_id); + } else if self.edition_ids.is_empty() { + self.current_edition_id = None; + } + vec![PlaybackEvent::EditionChanged(self.current_edition())] + } + "video-params" => { + self.input_video = parse_video_parameters( + change.value.as_ref(), + self.input_video.take(), + ); + self.video = parse_video_parameters( + change.value.as_ref(), + self.video.take(), + ); + vec![PlaybackEvent::VideoParametersChanged(self.video.clone())] + } + "video-out-params" => { + self.output_video = parse_video_parameters( + change.value.as_ref(), + self.output_video.take(), + ); + self.video = parse_video_parameters( + change.value.as_ref(), + self.video.take(), + ); + vec![PlaybackEvent::VideoParametersChanged(self.video.clone())] + } + "vo-configured" => { + self.vo_configured = + node_bool(change.value.as_ref()).unwrap_or(false); + Vec::new() + } + "window-id" => { + self.native_window_id = node_i64(change.value.as_ref()) + .and_then(|value| { + normalize_native_window_id( + value, + cfg!(target_os = "macos"), + ) + }); + Vec::new() + } + "current-vo" => { + self.current_vo = node_string(change.value.as_ref()); + Vec::new() + } + "current-gpu-context" => { + self.current_gpu_context = node_string(change.value.as_ref()); + if self.current_gpu_api.is_none() { + self.current_gpu_api = self + .current_gpu_context + .as_deref() + .and_then(gpu_api_from_context); + } + Vec::new() + } + "mpv-version" => { + self.mpv_version = node_string(change.value.as_ref()); + Vec::new() + } + "ffmpeg-version" => { + self.ffmpeg_version = node_string(change.value.as_ref()); + Vec::new() + } + "hwdec-current" => { + let decoder = node_string(change.value.as_ref()); + self.hardware_decoder = decoder.clone(); + if decoder.is_some() || self.video.is_some() { + let video = + self.video.get_or_insert_with(VideoParameters::default); + video.hardware_decoder = decoder.clone(); + let input = self + .input_video + .get_or_insert_with(VideoParameters::default); + input.hardware_decoder = decoder; + vec![PlaybackEvent::VideoParametersChanged( + self.video.clone(), + )] + } else { + Vec::new() + } + } + "hwdec-interop" => { + self.hardware_decoder_interop = + node_string(change.value.as_ref()); + Vec::new() + } + "frame-drop-count" => { + self.frame_drop_count = + node_nonnegative_u64(change.value.as_ref()); + Vec::new() + } + "decoder-frame-drop-count" => { + self.decoder_frame_drop_count = + node_nonnegative_u64(change.value.as_ref()); + Vec::new() + } + "mistimed-frame-count" => { + self.mistimed_frame_count = + node_nonnegative_u64(change.value.as_ref()); + Vec::new() + } + "vo-delayed-frame-count" => { + self.delayed_frame_count = + node_nonnegative_u64(change.value.as_ref()); + Vec::new() + } + "avsync" => { + self.av_sync_seconds = node_f64(change.value.as_ref()) + .filter(|value| value.is_finite()); + Vec::new() + } + "volume" => node_f64(change.value.as_ref()) + .filter(|value| value.is_finite()) + .map(|value| PlaybackEvent::VolumeChanged(value / 100.0)) + .into_iter() + .collect(), + "mute" => node_bool(change.value.as_ref()) + .map(PlaybackEvent::MutedChanged) + .into_iter() + .collect(), + "speed" => node_f64(change.value.as_ref()) + .filter(|value| value.is_finite() && *value > 0.0) + .map(PlaybackEvent::SpeedChanged) + .into_iter() + .collect(), + "fullscreen" => node_bool(change.value.as_ref()) + .map(PlaybackEvent::FullscreenChanged) + .into_iter() + .collect(), + "glsl-shaders" => { + self.active_video_shader_count = match change.value.as_ref() { + Some(MpvNode::Array(shaders)) + if shaders.iter().all(|shader| { + matches!(shader, MpvNode::String(_)) + }) => + { + Some(shaders.len()) + } + Some(MpvNode::String(shaders)) if shaders.is_empty() => { + Some(0) + } + Some(MpvNode::Null) => Some(0), + _ => None, + }; + Vec::new() + } + _ => Vec::new(), + } + } + + fn current_chapter(&self) -> Option { + let native_id = self.current_chapter_id?; + self.chapter_ids.iter().find_map(|(id, candidate)| { + (*candidate == native_id).then(|| id.clone()) + }) + } + + fn current_edition(&self) -> Option { + let native_id = self.current_edition_id?; + self.edition_ids.iter().find_map(|(id, candidate)| { + (*candidate == native_id).then(|| id.clone()) + }) + } + + fn normal_state(&self) -> PlaybackState { + if self.stopping { + PlaybackState::Stopping + } else if self.seeking { + PlaybackState::Seeking + } else if self.paused { + // Explicit user pause takes precedence over cache state so the + // backend-neutral play/pause toggle retains the correct intent. + PlaybackState::Paused + } else if self.buffering { + PlaybackState::Buffering + } else { + PlaybackState::Playing + } + } +} + +fn build_external_subtitle_command( + source: &PlaybackFilePath, + select: bool, +) -> Result, PlaybackError> { + Ok(vec![ + "sub-add".to_string(), + local_path_argument("external subtitle path", source)?, + if select { "select" } else { "auto" }.to_string(), + ]) +} + +fn build_apply_profile_command( + profile: &VideoProfileName, +) -> Result, PlaybackError> { + let name = + validate_extension_text("video profile name", profile.as_str(), 256)?; + Ok(vec!["apply-profile".to_string(), name, "apply".to_string()]) +} + +fn build_shader_commands( + shaders: &[PlaybackFilePath], +) -> Result>, PlaybackError> { + if shaders.is_empty() { + return Ok(vec![vec![ + "change-list".to_string(), + "glsl-shaders".to_string(), + "clr".to_string(), + String::new(), + ]]); + } + + shaders + .iter() + .enumerate() + .map(|(index, path)| { + Ok(vec![ + "change-list".to_string(), + "glsl-shaders".to_string(), + if index == 0 { "set" } else { "append" }.to_string(), + local_path_argument("video shader path", path)?, + ]) + }) + .collect() +} + +fn build_screenshot_command( + output: &PlaybackFilePath, + mode: PlaybackScreenshotMode, +) -> Result, PlaybackError> { + let mode = match mode { + PlaybackScreenshotMode::VideoOnly => "video", + PlaybackScreenshotMode::VideoWithSubtitles => "subtitles", + PlaybackScreenshotMode::Window => "window", + }; + Ok(vec![ + "screenshot-to-file".to_string(), + local_path_argument("screenshot output path", output)?, + mode.to_string(), + ]) +} + +fn local_path_argument( + category: &'static str, + path: &PlaybackFilePath, +) -> Result { + let Some(path) = path.as_path().to_str() else { + return Err(mpv_error( + PlaybackErrorKind::Command, + format!("{category} must be valid Unicode for libmpv"), + false, + )); + }; + validate_extension_text(category, path, 32 * 1024) +} + +fn validate_extension_text( + category: &'static str, + value: &str, + maximum_bytes: usize, +) -> Result { + if value.is_empty() { + return Err(mpv_error( + PlaybackErrorKind::Command, + format!("{category} must not be empty"), + false, + )); + } + if value.len() > maximum_bytes { + return Err(mpv_error( + PlaybackErrorKind::Command, + format!("{category} exceeds the supported length"), + false, + )); + } + if value.bytes().any(|byte| matches!(byte, 0 | b'\r' | b'\n')) { + return Err(mpv_error( + PlaybackErrorKind::Command, + format!("{category} contains a forbidden control character"), + false, + )); + } + Ok(value.to_string()) +} + +fn build_load_command( + source: &PlaybackSource, + start: Duration, +) -> Result { + let mut options = Vec::new(); + if start > Duration::ZERO { + options.push(( + "start".to_string(), + MpvNode::String(start.as_secs_f64().to_string()), + )); + } + if let Some(title) = source.title() { + validate_single_line("media title", title)?; + options.push(( + "force-media-title".to_string(), + MpvNode::String(title.to_string()), + )); + } + + let mut headers = Vec::new(); + for header in source.headers() { + validate_header_name(&header.name)?; + let value = header.value.expose_secret(); + validate_single_line("HTTP header value", value)?; + headers.push(format!("{}: {value}", header.name)); + } + if !source.cookies().is_empty() { + let mut cookies = Vec::new(); + for cookie in source.cookies() { + validate_cookie_name(&cookie.name)?; + let value = cookie.value.expose_secret(); + validate_single_line("cookie value", value)?; + cookies.push(format!("{}={value}", cookie.name)); + } + headers.push(format!("Cookie: {}", cookies.join("; "))); + } + if !headers.is_empty() { + options.push(( + "http-header-fields".to_string(), + MpvNode::String(encode_mpv_string_list(&headers)), + )); + } + + Ok(MpvNode::Array(vec![ + MpvNode::String("loadfile".to_string()), + MpvNode::String(source.uri().as_str().to_string()), + MpvNode::String("replace".to_string()), + MpvNode::Int(-1), + MpvNode::Map(options), + ])) +} + +fn encode_mpv_string_list(values: &[String]) -> String { + values + .iter() + .map(|value| value.replace('\\', "\\\\").replace(',', "\\,")) + .collect::>() + .join(",") +} + +fn validate_header_name(name: &str) -> Result<(), PlaybackError> { + if name.is_empty() + || !name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) + { + return Err(mpv_error( + PlaybackErrorKind::InvalidSource, + "HTTP header name is not a valid token", + false, + )); + } + Ok(()) +} + +fn validate_cookie_name(name: &str) -> Result<(), PlaybackError> { + if name.is_empty() + || name.bytes().any(|byte| { + byte.is_ascii_control() + || matches!(byte, b' ' | b'\t' | b'=' | b';' | b',') + }) + { + return Err(mpv_error( + PlaybackErrorKind::InvalidSource, + "cookie name contains an invalid character", + false, + )); + } + Ok(()) +} + +fn validate_single_line( + category: &'static str, + value: &str, +) -> Result<(), PlaybackError> { + if value.bytes().any(|byte| matches!(byte, 0 | b'\r' | b'\n')) { + return Err(mpv_error( + PlaybackErrorKind::InvalidSource, + format!("{category} contains a forbidden control character"), + false, + )); + } + Ok(()) +} + +struct MpvSourceRedactor { + source_values: Vec>, + local_values: Vec>, +} + +impl MpvSourceRedactor { + const MAX_LOCAL_VALUES: usize = 4_096; + + fn new(source: &PlaybackSource) -> Self { + let mut source_values = Vec::new(); + source_values.push(Zeroizing::new(source.uri().as_str().to_string())); + if let Some(password) = source.uri().password() + && !password.is_empty() + { + source_values.push(Zeroizing::new(password.to_string())); + } + for (_, value) in source.uri().query_pairs() { + if !value.is_empty() { + source_values.push(Zeroizing::new(value.into_owned())); + } + } + source_values.extend( + source + .headers() + .iter() + .map(|header| { + Zeroizing::new(header.value.expose_secret().to_string()) + }) + .chain(source.cookies().iter().map(|cookie| { + Zeroizing::new(cookie.value.expose_secret().to_string()) + })) + .filter(|value| !value.is_empty()), + ); + Self { + source_values, + local_values: Vec::new(), + } + } + + fn replace_source(&mut self, source: &PlaybackSource) { + // Runtime profiles and shader options survive replacement loads. Keep + // their redactions, and retain prior source values because end-file + // logs copied after `loadfile replace` may still mention the old URI. + let mut values = Self::new(source).source_values; + for previous in std::mem::take(&mut self.source_values) { + if !values + .iter() + .any(|current| current.as_str() == previous.as_str()) + { + values.push(previous); + } + } + self.source_values = values; + } + + fn remember_local_values<'a>( + &mut self, + values: impl IntoIterator, + ) -> Result<(), PlaybackError> { + let mut additions = Vec::new(); + for value in values { + if value.is_empty() + || self + .source_values + .iter() + .chain(&self.local_values) + .any(|existing| existing.as_str() == value) + || additions.contains(&value) + { + continue; + } + additions.push(value); + } + if self.local_values.len() + additions.len() > Self::MAX_LOCAL_VALUES { + return Err(mpv_error( + PlaybackErrorKind::Command, + "local extension redaction capacity exhausted; restart playback before submitting more distinct paths", + false, + )); + } + self.local_values.extend( + additions + .into_iter() + .map(|value| Zeroizing::new(value.to_string())), + ); + Ok(()) + } + + fn redact(&self, input: &str) -> String { + let mut output = input.to_string(); + for value in self.source_values.iter().chain(&self.local_values) { + output = output.replace(value.as_str(), ""); + } + redact_playback_url(&output) + } +} + +fn parse_track_list( + value: Option<&MpvNode>, +) -> ( + TrackCatalog, + HashMap, + HashMap, + Option, +) { + let Some(MpvNode::Array(tracks)) = value else { + return ( + TrackCatalog::default(), + HashMap::new(), + HashMap::new(), + None, + ); + }; + + let mut catalog = TrackCatalog::default(); + let mut audio_ids = HashMap::new(); + let mut subtitle_ids = HashMap::new(); + let mut occurrences = HashMap::::new(); + let mut selected_video_codec = None; + + for track in tracks { + let Some(fields) = node_map(track) else { + continue; + }; + let Some(native_id) = map_i64(fields, "id") else { + continue; + }; + let kind = map_str(fields, "type").unwrap_or_default(); + let title = map_owned_string(fields, "title"); + let language = map_owned_string(fields, "lang"); + let codec = map_owned_string(fields, "codec"); + let selected = map_bool(fields, "selected").unwrap_or(false); + let source_id = map_i64(fields, "src-id"); + + if kind == "video" { + if selected { + selected_video_codec = codec; + } + continue; + } + if kind != "audio" && kind != "sub" { + continue; + } + + let base = format!( + "mpv:{kind}:{}:{}:{}:{}", + source_id + .map_or_else(|| "src-_".to_string(), |id| format!("src-{id}")), + identity_component(language.as_deref()), + identity_component(title.as_deref()), + identity_component(codec.as_deref()), + ); + let occurrence = occurrences.entry(base.clone()).or_default(); + let id = TrackId::new(format!("{base}#{occurrence}")); + *occurrence += 1; + + if kind == "audio" { + audio_ids.insert(id.clone(), native_id); + if selected { + catalog.selected_audio = Some(id.clone()); + } + catalog.audio.push(AudioTrack { + id, + title, + language, + codec, + channels: map_i64(fields, "demux-channel-count") + .or_else(|| map_i64(fields, "audio-channels")) + .and_then(|value| u16::try_from(value).ok()), + sample_rate: map_i64(fields, "demux-samplerate") + .and_then(|value| u32::try_from(value).ok()), + is_default: map_bool(fields, "default").unwrap_or(false), + is_forced: map_bool(fields, "forced").unwrap_or(false), + }); + } else { + subtitle_ids.insert(id.clone(), native_id); + let is_primary = map_i64(fields, "main-selection") + .is_none_or(|selection| selection == 0); + if selected && is_primary { + catalog.selected_subtitle = Some(id.clone()); + } + let subtitle_kind = subtitle_kind(codec.as_deref()); + catalog.subtitles.push(SubtitleTrack { + id, + title, + language, + codec, + kind: subtitle_kind, + is_default: map_bool(fields, "default").unwrap_or(false), + is_forced: map_bool(fields, "forced").unwrap_or(false), + is_external: map_bool(fields, "external").unwrap_or(false), + }); + } + } + + (catalog, audio_ids, subtitle_ids, selected_video_codec) +} + +fn parse_chapters( + value: Option<&MpvNode>, +) -> (Vec, HashMap) { + let Some(MpvNode::Array(chapters)) = value else { + return (Vec::new(), HashMap::new()); + }; + let mut parsed = chapters + .iter() + .enumerate() + .filter_map(|(native_index, chapter)| { + let native_index = i64::try_from(native_index).ok()?; + let fields = node_map(chapter)?; + let seconds = map_f64(fields, "time")?; + let start = duration_from_seconds(seconds)?; + Some((native_index, map_owned_string(fields, "title"), start)) + }) + .collect::>(); + parsed.sort_by_key(|(_, _, start)| *start); + + let mut native_ids = HashMap::new(); + let chapters = parsed + .iter() + .enumerate() + .map(|(sorted_index, (native_index, title, start))| { + // The public identity follows presentation order and timestamp; + // the separate map retains mpv's native list index for commands. + let id = ChapterId::new(format!( + "mpv:chapter:{sorted_index}:{}", + start.as_millis() + )); + native_ids.insert(id.clone(), *native_index); + Chapter { + id, + title: title.clone(), + start: *start, + end: parsed.get(sorted_index + 1).map(|(_, _, start)| *start), + } + }) + .collect(); + (chapters, native_ids) +} + +fn parse_editions( + value: Option<&MpvNode>, +) -> (Vec, HashMap) { + let Some(MpvNode::Array(editions)) = value else { + return (Vec::new(), HashMap::new()); + }; + let mut native_ids = HashMap::new(); + let editions = editions + .iter() + .filter_map(|edition| { + let fields = node_map(edition)?; + let native_id = map_i64(fields, "id")?; + let id = EditionId::new(format!("mpv:edition:{native_id}")); + native_ids.insert(id.clone(), native_id); + Some(Edition { + id, + title: map_owned_string(fields, "title"), + is_default: map_bool(fields, "default").unwrap_or(false), + }) + }) + .collect(); + (editions, native_ids) +} + +fn parse_video_parameters( + value: Option<&MpvNode>, + previous: Option, +) -> Option { + let fields = value.and_then(node_map)?; + let mut video = previous.unwrap_or_default(); + video.width = + map_i64(fields, "w").and_then(|value| u32::try_from(value).ok()); + video.height = + map_i64(fields, "h").and_then(|value| u32::try_from(value).ok()); + video.pixel_format = map_owned_string(fields, "pixelformat") + .or_else(|| map_owned_string(fields, "hw-pixelformat")); + video.bit_depth = video + .pixel_format + .as_deref() + .and_then(pixel_format_bit_depth); + video.color_primaries = map_owned_string(fields, "primaries"); + video.color_transfer = map_owned_string(fields, "gamma"); + video.color_matrix = map_owned_string(fields, "colormatrix"); + video.hdr_metadata_observed = + ["min-luma", "max-luma", "max-cll", "max-fall", "max-pq-y"] + .iter() + .any(|key| map_f64(fields, key).is_some()) + || video.color_primaries.as_deref().is_some_and(|value| { + value.to_ascii_lowercase().contains("2020") + }) + || video.color_transfer.as_deref().is_some_and(|value| { + let value = value.to_ascii_lowercase(); + value.contains("pq") + || value.contains("2084") + || value.contains("hlg") + || value.contains("arib") + }); + Some(video) +} + +fn pixel_format_bit_depth(format: &str) -> Option { + let format = format.to_ascii_lowercase(); + for (marker, depth) in [ + ("p016", 16), + ("p012", 12), + ("p010", 10), + ("p16", 16), + ("p14", 14), + ("p12", 12), + ("p10", 10), + ("p9", 9), + ] { + if format.contains(marker) { + return Some(depth); + } + } + matches!( + format.as_str(), + "nv12" | "yuv420p" | "yuv422p" | "yuv444p" | "rgb24" | "rgba" + ) + .then_some(8) +} + +fn subtitle_kind(codec: Option<&str>) -> SubtitleKind { + let codec = codec.unwrap_or_default().to_ascii_lowercase(); + if [ + "ass", "ssa", "subrip", "srt", "webvtt", "mov_text", "text", "microdvd", + ] + .iter() + .any(|candidate| codec.contains(candidate)) + { + SubtitleKind::Text + } else if ["pgs", "dvd_subtitle", "dvb_subtitle", "xsub", "vobsub"] + .iter() + .any(|candidate| codec.contains(candidate)) + { + SubtitleKind::Bitmap + } else { + SubtitleKind::Unknown + } +} + +fn identity_component(value: Option<&str>) -> String { + value + .filter(|value| !value.is_empty()) + .map(|value| { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() + || matches!(character, '-' | '_' | '.') + { + character.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() + }) + .unwrap_or_else(|| "_".to_string()) +} + +fn node_map(node: &MpvNode) -> Option<&[(String, MpvNode)]> { + match node { + MpvNode::Map(values) => Some(values), + _ => None, + } +} + +fn map_value<'a>( + values: &'a [(String, MpvNode)], + key: &str, +) -> Option<&'a MpvNode> { + values + .iter() + .find_map(|(candidate, value)| (candidate == key).then_some(value)) +} + +fn map_str<'a>(values: &'a [(String, MpvNode)], key: &str) -> Option<&'a str> { + match map_value(values, key) { + Some(MpvNode::String(value)) => Some(value), + _ => None, + } +} + +fn map_owned_string(values: &[(String, MpvNode)], key: &str) -> Option { + map_str(values, key).map(ToOwned::to_owned) +} + +fn map_i64(values: &[(String, MpvNode)], key: &str) -> Option { + match map_value(values, key) { + Some(MpvNode::Int(value)) => Some(*value), + _ => None, + } +} + +fn map_f64(values: &[(String, MpvNode)], key: &str) -> Option { + node_f64(map_value(values, key)) +} + +fn map_bool(values: &[(String, MpvNode)], key: &str) -> Option { + node_bool(map_value(values, key)) +} + +fn node_string(value: Option<&MpvNode>) -> Option { + match value { + Some(MpvNode::String(value)) => Some(value.clone()), + _ => None, + } +} + +fn node_bool(value: Option<&MpvNode>) -> Option { + match value { + Some(MpvNode::Bool(value)) => Some(*value), + _ => None, + } +} + +fn node_i64(value: Option<&MpvNode>) -> Option { + match value { + Some(MpvNode::Int(value)) => Some(*value), + _ => None, + } +} + +fn normalize_native_window_id( + value: i64, + macos_pointer_bits: bool, +) -> Option { + if value != 0 && (macos_pointer_bits || value > 0) { + Some(value) + } else { + None + } +} + +fn native_window_id_refresh_needed( + previous_vo_configured: bool, + vo_configured: bool, + native_window_id: Option, + refresh_pending: bool, +) -> bool { + !previous_vo_configured + && vo_configured + && native_window_id.is_none() + && !refresh_pending +} + +fn evaluate_native_window_id_refresh( + ticket: NativeWindowIdRefreshTicket, + current_output_epoch: u64, + current_observation_revision: u64, + request_is_current: bool, + vo_configured: bool, + current_native_window_id: Option, + value: Option<&MpvNode>, + macos_pointer_bits: bool, +) -> NativeWindowIdRefreshResult { + let value = node_i64(value).and_then(|value| { + normalize_native_window_id(value, macos_pointer_bits) + }); + let stale = !request_is_current + || ticket.output_epoch != current_output_epoch + || ticket.observation_revision != current_observation_revision + || !vo_configured + || current_native_window_id.is_some(); + NativeWindowIdRefreshResult { + value_available: value.is_some(), + stale, + applied: (!stale).then_some(value).flatten(), + } +} + +fn node_f64(value: Option<&MpvNode>) -> Option { + match value { + Some(MpvNode::Double(value)) => Some(*value), + Some(MpvNode::Int(value)) => Some(*value as f64), + _ => None, + } +} + +fn node_nonnegative_f64(value: Option<&MpvNode>) -> Option { + node_f64(value).filter(|value| value.is_finite() && *value >= 0.0) +} + +fn node_nonnegative_u64(value: Option<&MpvNode>) -> Option { + match value { + Some(MpvNode::Int(value)) => u64::try_from(*value).ok(), + _ => None, + } +} + +fn extract_libplacebo_version(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + let offset = lower.find("libplacebo")? + "libplacebo".len(); + text.get(offset..)? + .split_whitespace() + .map(|token| { + token.trim_matches(|character: char| { + matches!(character, ':' | ',' | '(' | ')' | '[' | ']') + }) + }) + .find(|token| { + let version = token.strip_prefix('v').unwrap_or(token); + !version.is_empty() + && version + .chars() + .next() + .is_some_and(|character| character.is_ascii_digit()) + && version.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '.' | '-' | '+' | '_') + }) + }) + .map(ToOwned::to_owned) +} + +fn parse_compiled_features(text: &str) -> Vec { + const MARKER: &str = "list of enabled features:"; + let lower = text.to_ascii_lowercase(); + let Some(offset) = lower.find(MARKER) else { + return Vec::new(); + }; + text[offset + MARKER.len()..] + .split_whitespace() + .map(|feature| feature.trim_matches(',')) + .filter(|feature| { + !feature.is_empty() + && feature.len() <= 64 + && feature.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '-' | '_' | '.' | '+') + }) + }) + .take(256) + .map(ToOwned::to_owned) + .collect() +} + +fn gpu_api_from_log_prefix(prefix: &str) -> Option { + prefix + .split('/') + .map(str::to_ascii_lowercase) + .find(|component| { + matches!( + component.as_str(), + "vulkan" | "opengl" | "d3d11" | "metal" + ) + }) +} + +fn gpu_api_from_context(context: &str) -> Option { + let context = context.to_ascii_lowercase(); + if context.contains("vulkan") || context.contains("vk") { + Some("vulkan".to_string()) + } else if context.contains("d3d") { + Some("d3d11".to_string()) + } else if context.contains("metal") { + Some("metal".to_string()) + } else if ["opengl", "angle", "egl", "wayland", "x11"] + .iter() + .any(|marker| context.contains(marker)) + { + Some("opengl".to_string()) + } else { + None + } +} + +fn gpu_adapter_from_log(prefix: &str, text: &str) -> Option { + let prefix = prefix.to_ascii_lowercase(); + if !["vo/", "gpu", "libplacebo"] + .iter() + .any(|marker| prefix.contains(marker)) + { + return None; + } + + [ + "GL_RENDERER=", + "GL_RENDERER:", + "Vulkan device:", + "deviceName:", + "Device name:", + "D3D11 adapter:", + "Metal device:", + ] + .iter() + .find_map(|marker| diagnostic_value_after_marker(text, marker)) +} + +fn diagnostic_value_after_marker(text: &str, marker: &str) -> Option { + let offset = text.find(marker)? + marker.len(); + let value = text[offset..] + .lines() + .next()? + .trim() + .trim_matches(|character| matches!(character, '\'' | '"')); + (!value.is_empty() + && value.len() <= 512 + && !value.chars().any(char::is_control)) + .then(|| value.to_string()) +} + +fn duration_from_seconds(seconds: f64) -> Option { + Duration::try_from_secs_f64(seconds).ok() +} + +fn finite_seconds(duration: Duration) -> Result { + let seconds = duration.as_secs_f64(); + if seconds.is_finite() { + Ok(seconds.to_string()) + } else { + Err(mpv_error( + PlaybackErrorKind::Command, + "seek position must be finite", + false, + )) + } +} + +fn content_fit_properties( + content_fit: crate::contract::PlaybackContentFit, +) -> [(&'static str, MpvNode); 3] { + use crate::contract::PlaybackContentFit; + + let (keep_aspect, video_unscaled, panscan) = match content_fit { + PlaybackContentFit::Contain => (true, "no", 0.0), + PlaybackContentFit::Cover => (true, "no", 1.0), + PlaybackContentFit::Fill => (false, "no", 0.0), + PlaybackContentFit::None => (true, "yes", 0.0), + PlaybackContentFit::ScaleDown => (true, "downscale-big", 0.0), + }; + + [ + ("keepaspect", MpvNode::Bool(keep_aspect)), + ( + "video-unscaled", + MpvNode::String(video_unscaled.to_string()), + ), + ("panscan", MpvNode::Double(panscan)), + ] +} + +fn mpv_configuration_diagnostics( + policy: MpvConfigPolicy, + logging: MpvLoggingPolicy, + osc_enabled: bool, + input_bindings_enabled: bool, +) -> MpvConfigurationDiagnostics { + MpvConfigurationDiagnostics { + policy: match policy { + MpvConfigPolicy::Deterministic => { + MpvConfigurationPolicy::Deterministic + } + MpvConfigPolicy::TrustedUser => MpvConfigurationPolicy::TrustedUser, + }, + user_config_enabled: policy.user_config_enabled(), + user_scripts_enabled: policy.user_scripts_enabled(), + osc_enabled, + input_bindings_enabled, + external_url_resolver_enabled: false, + log_verbosity: diagnostic_log_verbosity(logging.steady), + startup_verbose_capture: logging.startup_verbose_capture, + active_video_shader_count: None, + } +} + +fn mpv_native_controls_enabled(target: PlaybackTarget) -> bool { + target == PlaybackTarget::MPV_NATIVE_WINDOW +} + +const fn diagnostic_log_verbosity(level: MpvLogLevel) -> MpvLogVerbosity { + match level { + MpvLogLevel::None => MpvLogVerbosity::None, + MpvLogLevel::Fatal => MpvLogVerbosity::Fatal, + MpvLogLevel::Error => MpvLogVerbosity::Error, + MpvLogLevel::Warn => MpvLogVerbosity::Warn, + MpvLogLevel::Info => MpvLogVerbosity::Info, + MpvLogLevel::Verbose => MpvLogVerbosity::Verbose, + MpvLogLevel::Debug => MpvLogVerbosity::Debug, + MpvLogLevel::Trace => MpvLogVerbosity::Trace, + } +} + +fn parse_mpv_logging_policy( + value: Option<&OsStr>, +) -> Result { + let level = match value.and_then(OsStr::to_str) { + None if value.is_none() => return Ok(MpvLoggingPolicy::default()), + Some("none") => MpvLogLevel::None, + Some("fatal") => MpvLogLevel::Fatal, + Some("error") => MpvLogLevel::Error, + Some("warn") => MpvLogLevel::Warn, + Some("info") => MpvLogLevel::Info, + Some("verbose") => MpvLogLevel::Verbose, + Some("debug") => MpvLogLevel::Debug, + Some("trace") => MpvLogLevel::Trace, + Some(_) | None => return Err(()), + }; + Ok(MpvLoggingPolicy::fixed(level)) +} + +fn configured_mpv_logging_policy() -> MpvLoggingPolicy { + match parse_mpv_logging_policy( + std::env::var_os(MPV_LOG_LEVEL_ENV).as_deref(), + ) { + Ok(policy) => policy, + Err(()) => { + // Never echo an invalid value: environment configuration can be + // populated accidentally with sensitive material. + log::warn!( + "Ignoring invalid {MPV_LOG_LEVEL_ENV}; expected none, fatal, error, warn, info, verbose, debug, or trace" + ); + MpvLoggingPolicy::default() + } + } +} + +fn parse_mpv_config_policy( + value: Option<&OsStr>, +) -> Result { + match value.and_then(OsStr::to_str) { + None if value.is_none() => Ok(MpvConfigPolicy::Deterministic), + Some("deterministic") => Ok(MpvConfigPolicy::Deterministic), + Some("trusted-user") => Ok(MpvConfigPolicy::TrustedUser), + Some(_) | None => Err(()), + } +} + +fn configured_mpv_config_policy() -> MpvConfigPolicy { + match parse_mpv_config_policy( + std::env::var_os(MPV_CONFIG_POLICY_ENV).as_deref(), + ) { + Ok(MpvConfigPolicy::TrustedUser) => { + log::warn!( + "{MPV_CONFIG_POLICY_ENV}=trusted-user enables trusted mpv config and scripts inside the Ferrex process" + ); + MpvConfigPolicy::TrustedUser + } + Ok(policy) => policy, + Err(()) => { + // Do not echo an arbitrary environment value into diagnostics: it + // may have been populated accidentally with sensitive material. + log::warn!( + "Ignoring invalid {MPV_CONFIG_POLICY_ENV}; expected deterministic or trusted-user" + ); + MpvConfigPolicy::Deterministic + } + } +} + +fn mpv_capabilities(config_policy: MpvConfigPolicy) -> PlaybackCapabilities { + PlaybackCapabilities { + seek: true, + audio_track_selection: true, + subtitle_track_selection: true, + external_subtitle_loading: true, + chapter_selection: true, + edition_selection: true, + speed: true, + content_fit: true, + fullscreen: true, + screenshot: true, + video_shader_passthrough: true, + // Named user profiles come from standard mpv config and therefore + // exist only under the explicit trusted-code policy. + video_profile_passthrough: config_policy.user_config_enabled(), + integrated_presentation: false, + native_window_fallback: true, + // These are observations, not promises based only on backend choice. + native_hdr: false, + fractional_scaling: false, + } +} + +fn unsupported_mpv_extension(message: &'static str) -> PlaybackError { + let mut error = + PlaybackError::new(PlaybackErrorKind::UnsupportedOperation, message); + error.backend = Some(BackendKind::Mpv); + error +} + +fn worker_error( + kind: PlaybackErrorKind, + context: &str, + error: impl std::fmt::Display, +) -> PlaybackError { + mpv_error(kind, format!("{context}: {error}"), true) +} + +fn native_error( + kind: PlaybackErrorKind, + context: &str, + code: i32, + recoverable: bool, +) -> PlaybackError { + let mut error = mpv_error( + kind, + format!("{context} (libmpv error {code})"), + recoverable, + ); + error.code = Some(i64::from(code)); + error +} + +fn mpv_error( + kind: PlaybackErrorKind, + message: impl Into, + recoverable: bool, +) -> PlaybackError { + let mut error = PlaybackError::new(kind, message); + error.backend = Some(BackendKind::Mpv); + error.recoverable = recoverable; + error +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::{DurationDelta, PlaybackContentFit}; + use ferrex_player_mpv::{MpvEndFile, MpvObservationId}; + + fn property(name: &str, value: Option) -> MpvPropertyChange { + MpvPropertyChange { + id: MpvObservationId::new(1), + name: name.to_string(), + value, + registered: true, + } + } + + fn map(entries: Vec<(&str, MpvNode)>) -> MpvNode { + MpvNode::Map( + entries + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(), + ) + } + + #[derive(Debug, Clone, Copy)] + struct ProcessResourceSample { + resident_kib: u64, + open_fds: usize, + } + + #[cfg(target_os = "linux")] + fn process_resource_sample() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let resident_kib = status.lines().find_map(|line| { + line.strip_prefix("VmRSS:")? + .split_ascii_whitespace() + .next()? + .parse::() + .ok() + })?; + let open_fds = std::fs::read_dir("/proc/self/fd").ok()?.count(); + Some(ProcessResourceSample { + resident_kib, + open_fds, + }) + } + + #[cfg(not(target_os = "linux"))] + fn process_resource_sample() -> Option { + None + } + + fn optional_stress_limit(name: &str) -> Option { + std::env::var(name).ok().map(|value| { + value + .parse::() + .unwrap_or_else(|_| panic!("{name} is a non-negative integer")) + }) + } + + fn smoke_source_from_environment(title: &str) -> PlaybackSource { + let source = if let Ok(url) = std::env::var("FERREX_MPV_SMOKE_URL") { + let mut source = PlaybackSource::new( + url.parse().expect("FERREX_MPV_SMOKE_URL is a valid URL"), + ); + if let Ok(value) = std::env::var("FERREX_MPV_SMOKE_AUTHORIZATION") { + source = source.with_header("Authorization", value); + } + if let Ok(value) = std::env::var("FERREX_MPV_SMOKE_COOKIE") { + source = source.with_cookie("session", value); + } + source + } else { + let path = std::env::var("FERREX_MPV_SMOKE_MEDIA") + .expect("set FERREX_MPV_SMOKE_MEDIA or FERREX_MPV_SMOKE_URL"); + let uri = url::Url::from_file_path( + std::fs::canonicalize(path).expect("fixture path exists"), + ) + .expect("fixture path converts to a file URL"); + PlaybackSource::new(uri) + }; + source.with_title(title) + } + + fn wait_for_smoke_playback( + adapter: &mut MpvPlaybackAdapter, + operation: &str, + ) { + let deadline = std::time::Instant::now() + Duration::from_secs(8); + loop { + adapter.poll_events(); + match adapter.snapshot().state { + PlaybackState::Playing | PlaybackState::Paused + if adapter.snapshot().duration.is_some() => + { + break; + } + PlaybackState::Failed => panic!( + "mpv {operation} failed: {:?}", + adapter.snapshot().last_error + ), + _ if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(20)); + } + state => panic!("mpv {operation} timed out: {state:?}"), + } + } + } + + #[test] + fn absolute_seek_coalescer_bounds_requests_and_keeps_latest_position() { + let first = MpvRequestId::new(10); + let second = MpvRequestId::new(11); + let unrelated = MpvRequestId::new(99); + let mut coalescer = AbsoluteSeekCoalescer::default(); + + assert_eq!( + coalescer.enqueue(Duration::from_secs(1)), + Some(Duration::from_secs(1)) + ); + coalescer.submitted(first); + assert_eq!(coalescer.enqueue(Duration::from_secs(2)), None); + assert_eq!(coalescer.enqueue(Duration::from_secs(3)), None); + + assert_eq!(coalescer.completed(unrelated), None); + assert_eq!(coalescer.active, Some(first)); + assert_eq!(coalescer.queued, Some(Duration::from_secs(3))); + + assert_eq!(coalescer.completed(first), Some(Duration::from_secs(3))); + coalescer.submitted(second); + assert_eq!(coalescer.completed(second), None); + assert_eq!(coalescer.active, None); + assert_eq!(coalescer.queued, None); + } + + #[test] + fn clearing_absolute_seek_coalescer_rejects_late_replies() { + let request = MpvRequestId::new(12); + let mut coalescer = AbsoluteSeekCoalescer::default(); + assert!(coalescer.enqueue(Duration::from_secs(4)).is_some()); + coalescer.submitted(request); + assert!(coalescer.enqueue(Duration::from_secs(5)).is_none()); + + coalescer.clear(); + + assert_eq!(coalescer.completed(request), None); + assert_eq!(coalescer.active, None); + assert_eq!(coalescer.queued, None); + } + + #[test] + fn authenticated_load_uses_per_file_options_and_rejects_injection() { + let source = PlaybackSource::new( + "https://ferrex.example/media?id=1&access_token=query-secret" + .parse() + .unwrap(), + ) + .with_title("Episode 1") + .with_header("Authorization", "Bearer header-secret") + .with_header("X-Comma", "one,two") + .with_cookie("session", "cookie-secret"); + + let command = + build_load_command(&source, Duration::from_millis(2_500)).unwrap(); + let MpvNode::Array(arguments) = command else { + panic!("load command must be an array") + }; + assert_eq!(arguments[0], MpvNode::String("loadfile".into())); + assert_eq!(arguments[2], MpvNode::String("replace".into())); + assert_eq!(arguments[3], MpvNode::Int(-1)); + let MpvNode::Map(options) = &arguments[4] else { + panic!("per-file options must be a map") + }; + assert_eq!(map_str(options, "start"), Some("2.5")); + let headers = map_str(options, "http-header-fields").unwrap(); + assert!(headers.contains("Authorization: Bearer header-secret")); + assert!(headers.contains("X-Comma: one\\,two")); + assert!(headers.contains("Cookie: session=cookie-secret")); + + let injected = PlaybackSource::new( + "https://ferrex.example/media".parse().unwrap(), + ) + .with_header("Authorization", "safe\r\nX-Injected: secret"); + let error = build_load_command(&injected, Duration::ZERO).unwrap_err(); + assert_eq!(error.kind, PlaybackErrorKind::InvalidSource); + assert!(!error.message.contains("secret")); + } + + #[test] + fn source_aware_log_redaction_removes_url_and_custom_secrets() { + let source = PlaybackSource::new( + "https://user:password@example.test/private?ticket=query-secret" + .parse() + .unwrap(), + ) + .with_header("X-Private", "header-secret") + .with_cookie("session", "cookie-secret"); + let mut redactor = MpvSourceRedactor::new(&source); + redactor + .remember_local_values([ + "/home/private-user/shaders/private-name.hook", + "private-profile", + ]) + .unwrap(); + let output = redactor.redact(&format!( + "opening {} X-Private=header-secret Cookie=session=cookie-secret password shader=/home/private-user/shaders/private-name.hook profile=private-profile", + source.uri() + )); + + for secret in [ + "password", + "query-secret", + "header-secret", + "cookie-secret", + "/private", + "private-user", + "private-name", + "private-profile", + ] { + assert!(!output.contains(secret), "log leaked {secret}"); + } + + let replacement = PlaybackSource::new( + "https://example.test/replacement?ticket=new-secret" + .parse() + .unwrap(), + ); + redactor.replace_source(&replacement); + let replacement_log = redactor.redact( + "new-secret query-secret /home/private-user/shaders/private-name.hook private-profile", + ); + assert!(!replacement_log.contains("new-secret")); + assert!(!replacement_log.contains("query-secret")); + assert!(!replacement_log.contains("private-user")); + assert!(!replacement_log.contains("private-profile")); + } + + #[test] + fn observed_core_properties_map_to_contract_state() { + let mut mapper = MpvEventMapper::default(); + assert_eq!( + mapper.map_event(&MpvEvent::StartFile { + playlist_entry_id: 1, + }), + vec![ + PlaybackEvent::StateChanged(PlaybackState::Loading), + PlaybackEvent::TracksChanged(TrackCatalog::default()), + PlaybackEvent::ChaptersChanged(Vec::new()), + PlaybackEvent::ChapterChanged(None), + PlaybackEvent::EditionsChanged(Vec::new()), + PlaybackEvent::EditionChanged(None), + PlaybackEvent::VideoParametersChanged(None), + PlaybackEvent::DurationChanged(None), + ] + ); + mapper.map_property(&property("pause", Some(MpvNode::Bool(true)))); + assert_eq!( + mapper.map_event(&MpvEvent::FileLoaded), + vec![PlaybackEvent::StateChanged(PlaybackState::Paused)] + ); + assert_eq!( + mapper.map_property(&property( + "time-pos", + Some(MpvNode::Double(12.25)), + )), + vec![PlaybackEvent::PositionChanged(Duration::from_millis( + 12_250 + ))] + ); + assert_eq!( + mapper.map_property(&property( + "duration", + Some(MpvNode::Double(90.0)), + )), + vec![PlaybackEvent::DurationChanged(Some(Duration::from_secs( + 90 + )))] + ); + mapper.map_property(&property("pause", Some(MpvNode::Bool(false)))); + let buffering = mapper.map_property(&property( + "paused-for-cache", + Some(MpvNode::Bool(true)), + )); + assert!( + buffering.contains(&PlaybackEvent::StateChanged( + PlaybackState::Buffering + )) + ); + let cache = mapper.map_property(&property( + "demuxer-cache-state", + Some(map(vec![ + ("cache-duration", MpvNode::Double(4.5)), + ("underrun", MpvNode::Bool(false)), + ])), + )); + assert!(cache.contains(&PlaybackEvent::BufferChanged(BufferState { + buffering: true, + percentage: None, + cached_duration: Some(Duration::from_millis(4_500)), + }))); + assert_eq!( + mapper + .map_property(&property("seeking", Some(MpvNode::Bool(true)),)) + .last(), + Some(&PlaybackEvent::StateChanged(PlaybackState::Seeking)) + ); + + let pointer_width_value = i64::from(u32::MAX) + 17; + assert!( + mapper + .map_property(&property( + "window-id", + Some(MpvNode::Int(pointer_width_value)), + )) + .is_empty() + ); + assert_eq!(mapper.native_window_id, Some(pointer_width_value)); + assert_eq!(normalize_native_window_id(i64::MIN, true), Some(i64::MIN)); + assert_eq!(normalize_native_window_id(i64::MIN, false), None); + assert_eq!(normalize_native_window_id(0, true), None); + } + + #[test] + fn native_window_id_refresh_is_requested_only_on_a_missing_rising_edge() { + assert!(native_window_id_refresh_needed(false, true, None, false)); + assert!(!native_window_id_refresh_needed(true, true, None, false)); + assert!(!native_window_id_refresh_needed( + false, + true, + Some(7), + false + )); + assert!(!native_window_id_refresh_needed(false, true, None, true)); + assert!(!native_window_id_refresh_needed(false, false, None, false)); + } + + #[test] + fn native_window_id_refresh_never_overwrites_newer_observations() { + let ticket = NativeWindowIdRefreshTicket { + output_epoch: 4, + observation_revision: 8, + }; + let value = MpvNode::Int(42); + let current = evaluate_native_window_id_refresh( + ticket, + 4, + 8, + true, + true, + None, + Some(&value), + false, + ); + assert_eq!( + current, + NativeWindowIdRefreshResult { + value_available: true, + stale: false, + applied: Some(42), + } + ); + + for stale in [ + evaluate_native_window_id_refresh( + ticket, + 5, + 8, + true, + true, + None, + Some(&value), + false, + ), + evaluate_native_window_id_refresh( + ticket, + 4, + 9, + true, + true, + None, + Some(&value), + false, + ), + evaluate_native_window_id_refresh( + ticket, + 4, + 8, + true, + false, + None, + Some(&value), + false, + ), + evaluate_native_window_id_refresh( + ticket, + 4, + 8, + true, + true, + Some(99), + Some(&value), + false, + ), + ] { + assert!(stale.stale); + assert_eq!(stale.applied, None); + } + + let invalid = evaluate_native_window_id_refresh( + ticket, + 4, + 8, + true, + true, + None, + Some(&MpvNode::Int(0)), + false, + ); + assert!(!invalid.value_available); + assert!(!invalid.stale); + assert_eq!(invalid.applied, None); + } + + #[test] + fn tracks_chapters_editions_and_video_parameters_are_owned() { + let tracks = MpvNode::Array(vec![ + map(vec![ + ("id", MpvNode::Int(7)), + ("src-id", MpvNode::Int(42)), + ("type", MpvNode::String("audio".into())), + ("lang", MpvNode::String("eng".into())), + ("title", MpvNode::String("Main".into())), + ("codec", MpvNode::String("aac".into())), + ("demux-channel-count", MpvNode::Int(6)), + ("demux-samplerate", MpvNode::Int(48_000)), + ("selected", MpvNode::Bool(true)), + ]), + map(vec![ + ("id", MpvNode::Int(3)), + ("type", MpvNode::String("sub".into())), + ("lang", MpvNode::String("eng".into())), + ("codec", MpvNode::String("hdmv_pgs_subtitle".into())), + ("forced", MpvNode::Bool(true)), + ("selected", MpvNode::Bool(true)), + ]), + map(vec![ + ("id", MpvNode::Int(1)), + ("type", MpvNode::String("video".into())), + ("codec", MpvNode::String("hevc".into())), + ("selected", MpvNode::Bool(true)), + ]), + ]); + let (catalog, audio_ids, subtitle_ids, codec) = + parse_track_list(Some(&tracks)); + assert_eq!(catalog.audio[0].channels, Some(6)); + assert_eq!(catalog.audio[0].sample_rate, Some(48_000)); + assert_eq!(catalog.subtitles[0].kind, SubtitleKind::Bitmap); + assert!(catalog.subtitles[0].is_forced); + assert_eq!(audio_ids[&catalog.audio[0].id], 7); + assert_eq!(subtitle_ids[&catalog.subtitles[0].id], 3); + assert_eq!(codec.as_deref(), Some("hevc")); + + // Presentation order is chronological even if the native list is not; + // command lookup still retains the original mpv indices. + let chapters = MpvNode::Array(vec![ + map(vec![ + ("title", MpvNode::String("Two".into())), + ("time", MpvNode::Double(10.0)), + ]), + map(vec![ + ("title", MpvNode::String("One".into())), + ("time", MpvNode::Double(0.0)), + ]), + ]); + let (chapters, chapter_ids) = parse_chapters(Some(&chapters)); + assert_eq!(chapters[0].title.as_deref(), Some("One")); + assert_eq!(chapters[0].end, Some(Duration::from_secs(10))); + assert_eq!(chapter_ids[&chapters[0].id], 1); + assert_eq!(chapter_ids[&chapters[1].id], 0); + + let editions = MpvNode::Array(vec![map(vec![ + ("id", MpvNode::Int(2)), + ("title", MpvNode::String("Director".into())), + ("default", MpvNode::Bool(true)), + ])]); + let (editions, edition_ids) = parse_editions(Some(&editions)); + assert!(editions[0].is_default); + assert_eq!(edition_ids[&editions[0].id], 2); + + let params = map(vec![ + ("w", MpvNode::Int(3840)), + ("h", MpvNode::Int(2160)), + ("pixelformat", MpvNode::String("yuv420p10le".into())), + ("primaries", MpvNode::String("bt.2020".into())), + ("gamma", MpvNode::String("pq".into())), + ("max-cll", MpvNode::Double(1_000.0)), + ]); + let params = parse_video_parameters(Some(¶ms), None).unwrap(); + assert_eq!(params.width, Some(3840)); + assert_eq!(params.bit_depth, Some(10)); + assert!(params.hdr_metadata_observed); + } + + #[test] + fn observed_chapter_and_edition_selection_use_owned_identities() { + let mut mapper = MpvEventMapper::default(); + let chapter_list = MpvNode::Array(vec![ + map(vec![ + ("title", MpvNode::String("Opening".into())), + ("time", MpvNode::Double(0.0)), + ]), + map(vec![ + ("title", MpvNode::String("Feature".into())), + ("time", MpvNode::Double(10.0)), + ]), + ]); + let chapter_events = + mapper.map_property(&property("chapter-list", Some(chapter_list))); + let chapters = match &chapter_events[0] { + PlaybackEvent::ChaptersChanged(chapters) => chapters, + event => panic!("unexpected chapter event: {event:?}"), + }; + let selected_chapter = chapters[1].id.clone(); + assert_eq!( + mapper.map_property(&property("chapter", Some(MpvNode::Int(1)))), + vec![PlaybackEvent::ChapterChanged(Some( + selected_chapter.clone() + ))] + ); + + let edition_list = MpvNode::Array(vec![map(vec![ + ("id", MpvNode::Int(9)), + ("title", MpvNode::String("Extended".into())), + ])]); + let edition_events = + mapper.map_property(&property("edition-list", Some(edition_list))); + let editions = match &edition_events[0] { + PlaybackEvent::EditionsChanged(editions) => editions, + event => panic!("unexpected edition event: {event:?}"), + }; + let selected_edition = editions[0].id.clone(); + assert_eq!( + mapper.map_property(&property("edition", Some(MpvNode::Int(9)))), + vec![PlaybackEvent::EditionChanged(Some( + selected_edition.clone() + ))] + ); + + assert_eq!(mapper.chapter_ids[&selected_chapter], 1); + assert_eq!(mapper.edition_ids[&selected_edition], 9); + + // mpv exposes a one-edition Matroska catalog but reports the scalar + // `edition` property as unavailable. Its default entry is still the + // deterministic active selection. + let mut single_edition = MpvEventMapper::default(); + let events = single_edition.map_property(&property( + "edition-list", + Some(MpvNode::Array(vec![map(vec![ + ("id", MpvNode::Int(0)), + ("default", MpvNode::Bool(true)), + ])])), + )); + let inferred = match &events[1] { + PlaybackEvent::EditionChanged(Some(id)) => id.clone(), + event => panic!("unexpected inferred edition event: {event:?}"), + }; + assert_eq!( + single_edition.map_property(&property("edition", None)), + vec![PlaybackEvent::EditionChanged(Some(inferred))] + ); + } + + #[test] + fn track_identity_survives_native_id_reordering() { + fn audio(native_id: i64) -> MpvNode { + map(vec![ + ("id", MpvNode::Int(native_id)), + ("src-id", MpvNode::Int(99)), + ("type", MpvNode::String("audio".into())), + ("lang", MpvNode::String("jpn".into())), + ("title", MpvNode::String("Main".into())), + ("codec", MpvNode::String("flac".into())), + ]) + } + let first = MpvNode::Array(vec![audio(1)]); + let reloaded = MpvNode::Array(vec![audio(8)]); + let (first, _, _, _) = parse_track_list(Some(&first)); + let (reloaded, ids, _, _) = parse_track_list(Some(&reloaded)); + assert_eq!(first.audio[0].id, reloaded.audio[0].id); + assert_eq!(ids[&reloaded.audio[0].id], 8); + } + + #[test] + fn stop_during_load_seek_and_eof_have_deterministic_terminal_events() { + let mut mapper = MpvEventMapper::default(); + mapper.map_event(&MpvEvent::StartFile { + playlist_entry_id: 1, + }); + assert_eq!( + mapper.map_event(&MpvEvent::Seek), + vec![PlaybackEvent::StateChanged(PlaybackState::Seeking)] + ); + assert_eq!( + mapper.map_event(&MpvEvent::EndFile(MpvEndFile { + reason: MpvEndFileReason::Stop, + error: None, + playlist_entry_id: 1, + playlist_insert_id: 0, + playlist_insert_count: 0, + })), + vec![PlaybackEvent::Ended(EndReason::Stopped)] + ); + + mapper.reset_for_load(); + assert_eq!( + mapper.map_event(&MpvEvent::EndFile(MpvEndFile { + reason: MpvEndFileReason::Eof, + error: None, + playlist_entry_id: 2, + playlist_insert_id: 0, + playlist_insert_count: 0, + })), + vec![PlaybackEvent::Ended(EndReason::Eof)] + ); + } + + #[test] + fn native_window_quit_is_distinct_from_unexpected_core_shutdown() { + let mut mapper = MpvEventMapper::default(); + mapper.map_event(&MpvEvent::StartFile { + playlist_entry_id: 1, + }); + mapper.map_event(&MpvEvent::FileLoaded); + + assert_eq!( + mapper.map_event(&MpvEvent::EndFile(MpvEndFile { + reason: MpvEndFileReason::Quit, + error: None, + playlist_entry_id: 1, + playlist_insert_id: 0, + playlist_insert_count: 0, + })), + vec![PlaybackEvent::Ended(EndReason::Closed)] + ); + assert!(mapper.map_event(&MpvEvent::Shutdown).is_empty()); + + let mut unexpected = MpvEventMapper::default(); + assert_eq!( + unexpected.map_event(&MpvEvent::Shutdown), + vec![PlaybackEvent::Ended(EndReason::BackendTerminated)] + ); + } + + #[test] + fn mpv_string_list_escapes_separator_and_backslash() { + assert_eq!( + encode_mpv_string_list(&[ + "X-One: a,b".to_string(), + "X-Two: c\\d".to_string(), + ]), + "X-One: a\\,b,X-Two: c\\\\d" + ); + } + + #[test] + fn diagnostic_snapshot_captures_versions_vo_gpu_hwdec_and_timing() { + let mut mapper = MpvEventMapper::default(); + mapper.map_property(&property( + "mpv-version", + Some(MpvNode::String("mpv v0.41.0".into())), + )); + mapper.map_property(&property( + "ffmpeg-version", + Some(MpvNode::String("8.1".into())), + )); + mapper.map_property(&property( + "current-vo", + Some(MpvNode::String("gpu-next".into())), + )); + mapper.map_property(&property( + "current-gpu-context", + Some(MpvNode::String("waylandvk".into())), + )); + mapper.map_property(&property( + "vo-configured", + Some(MpvNode::Bool(true)), + )); + mapper.map_property(&property( + "video-params", + Some(map(vec![ + ("w", MpvNode::Int(3840)), + ("h", MpvNode::Int(2160)), + ("gamma", MpvNode::String("pq".into())), + ])), + )); + mapper.map_property(&property( + "video-out-params", + Some(map(vec![ + ("w", MpvNode::Int(1920)), + ("h", MpvNode::Int(1080)), + ("gamma", MpvNode::String("gamma2.2".into())), + ])), + )); + mapper.map_property(&property( + "hwdec-current", + Some(MpvNode::String("vaapi".into())), + )); + mapper.map_property(&property( + "hwdec-interop", + Some(MpvNode::String("dmabuf-wayland".into())), + )); + mapper.map_property(&property( + "decoder-frame-drop-count", + Some(MpvNode::Int(2)), + )); + mapper + .map_property(&property("frame-drop-count", Some(MpvNode::Int(3)))); + mapper.map_property(&property( + "mistimed-frame-count", + Some(MpvNode::Int(4)), + )); + mapper.map_property(&property( + "vo-delayed-frame-count", + Some(MpvNode::Int(5)), + )); + mapper.map_property(&property( + "glsl-shaders", + Some(MpvNode::Array(vec![ + MpvNode::String("/private/a.hook".into()), + MpvNode::String("/private/b.hook".into()), + ])), + )); + mapper.map_property(&property("avsync", Some(MpvNode::Double(-0.01)))); + mapper.observe_log( + "cplayer", + "List of enabled features: vulkan wayland libplacebo", + ); + mapper.observe_log( + "vo/gpu-next/vulkan", + "Initialized libplacebo v7.360.1 (API v360)", + ); + mapper.observe_log( + "vo/gpu-next/vulkan", + "Vulkan device: AMD Radeon RX 6800", + ); + + let snapshot = PlaybackSnapshot::new( + SessionGeneration::new(9), + PlaybackTarget::MPV_NATIVE_WINDOW, + mpv_capabilities(MpvConfigPolicy::Deterministic), + ); + let mut diagnostics = PlaybackDiagnosticSnapshot::from_snapshot( + &snapshot, + BackendRequest::Exact(PlaybackTarget::MPV_NATIVE_WINDOW), + ); + diagnostics.mpv_configuration = Some(mpv_configuration_diagnostics( + MpvConfigPolicy::Deterministic, + MpvLoggingPolicy::default(), + true, + true, + )); + mapper.populate_diagnostics(&mut diagnostics); + + assert_eq!(diagnostics.versions.mpv.as_deref(), Some("mpv v0.41.0")); + assert_eq!(diagnostics.versions.ffmpeg.as_deref(), Some("8.1")); + assert_eq!( + diagnostics.versions.libplacebo.as_deref(), + Some("v7.360.1") + ); + assert_eq!( + diagnostics.versions.compiled_features, + ["vulkan", "wayland", "libplacebo"] + ); + assert_eq!( + diagnostics.output.video_output.as_deref(), + Some("gpu-next") + ); + assert_eq!(diagnostics.output.gpu_api.as_deref(), Some("vulkan")); + assert_eq!( + diagnostics.output.gpu_context.as_deref(), + Some("waylandvk") + ); + assert_eq!( + diagnostics.output.gpu_adapter.as_deref(), + Some("AMD Radeon RX 6800") + ); + assert_eq!( + diagnostics.output.hardware_decoder.as_deref(), + Some("vaapi") + ); + assert_eq!( + diagnostics.output.hardware_decoder_interop.as_deref(), + Some("dmabuf-wayland") + ); + assert_eq!( + diagnostics + .output + .input_video + .as_ref() + .and_then(|video| video.width), + Some(3840) + ); + assert_eq!( + diagnostics + .output + .output_video + .as_ref() + .and_then(|video| video.width), + Some(1920) + ); + assert_eq!(diagnostics.output.frames.decoder_dropped, Some(2)); + assert_eq!(diagnostics.output.frames.output_dropped, Some(3)); + assert_eq!(diagnostics.output.frames.mistimed, Some(4)); + assert_eq!(diagnostics.output.frames.delayed, Some(5)); + assert_eq!(diagnostics.output.frames.av_sync_seconds, Some(-0.01)); + assert_eq!( + diagnostics + .mpv_configuration + .as_ref() + .and_then(|configuration| { + configuration.active_video_shader_count + }), + Some(2) + ); + let serialized = serde_json::to_string(&diagnostics).unwrap(); + assert!(!serialized.contains("/private/a.hook")); + assert!(!serialized.contains("/private/b.hook")); + } + + #[test] + fn extension_commands_use_argument_boundaries_and_redact_invalid_inputs() { + let subtitle = PlaybackFilePath::new("/tmp/external subtitle.srt"); + assert_eq!( + build_external_subtitle_command(&subtitle, true).unwrap(), + ["sub-add", "/tmp/external subtitle.srt", "select"] + ); + assert_eq!( + build_external_subtitle_command(&subtitle, false).unwrap(), + ["sub-add", "/tmp/external subtitle.srt", "auto"] + ); + + let profile = VideoProfileName::new("cinema"); + assert_eq!( + build_apply_profile_command(&profile).unwrap(), + ["apply-profile", "cinema", "apply"] + ); + + let shaders = [ + PlaybackFilePath::new("/tmp/a shader.hook"), + PlaybackFilePath::new("/tmp/b.shader.glsl"), + ]; + assert_eq!( + build_shader_commands(&shaders).unwrap(), + vec![ + vec![ + "change-list", + "glsl-shaders", + "set", + "/tmp/a shader.hook" + ], + vec![ + "change-list", + "glsl-shaders", + "append", + "/tmp/b.shader.glsl" + ], + ] + ); + assert_eq!( + build_shader_commands(&[]).unwrap(), + vec![vec!["change-list", "glsl-shaders", "clr", ""]] + ); + for (mode, flag) in [ + (PlaybackScreenshotMode::VideoOnly, "video"), + (PlaybackScreenshotMode::VideoWithSubtitles, "subtitles"), + (PlaybackScreenshotMode::Window, "window"), + ] { + assert_eq!( + build_screenshot_command( + &PlaybackFilePath::new("/tmp/frame with subtitles.png",), + mode, + ) + .unwrap(), + ["screenshot-to-file", "/tmp/frame with subtitles.png", flag,] + ); + } + + let invalid = VideoProfileName::new("private-profile\nsecret"); + let error = build_apply_profile_command(&invalid).unwrap_err(); + assert_eq!(error.kind, PlaybackErrorKind::Command); + let debug = format!("{error:?}"); + assert!(!debug.contains("private-profile")); + assert!(!debug.contains("secret")); + + let invalid_path = + PlaybackFilePath::new("/tmp/private-path\nsecret.png"); + let error = build_screenshot_command( + &invalid_path, + PlaybackScreenshotMode::VideoOnly, + ) + .unwrap_err(); + assert_eq!(error.kind, PlaybackErrorKind::Command); + let debug = format!("{error:?}"); + assert!(!debug.contains("private-path")); + assert!(!debug.contains("secret.png")); + } + + #[test] + fn native_window_capabilities_report_policy_gated_extensions() { + let deterministic = mpv_capabilities(MpvConfigPolicy::Deterministic); + assert!(!deterministic.integrated_presentation); + assert!(deterministic.native_window_fallback); + assert!(!deterministic.native_hdr); + assert!(deterministic.content_fit); + assert!(deterministic.external_subtitle_loading); + assert!(deterministic.chapter_selection); + assert!(deterministic.edition_selection); + assert!(deterministic.screenshot); + assert!(deterministic.video_shader_passthrough); + assert!(!deterministic.video_profile_passthrough); + + let trusted = mpv_capabilities(MpvConfigPolicy::TrustedUser); + assert!(trusted.video_profile_passthrough); + } + + #[test] + fn mpv_config_policy_parser_is_fail_closed_and_does_not_need_global_env() { + assert!(mpv_native_controls_enabled( + PlaybackTarget::MPV_NATIVE_WINDOW + )); + assert!(!mpv_native_controls_enabled(PlaybackTarget::MPV_INTEGRATED)); + assert_eq!( + parse_mpv_config_policy(None), + Ok(MpvConfigPolicy::Deterministic) + ); + assert_eq!( + parse_mpv_config_policy(Some(OsStr::new("deterministic"))), + Ok(MpvConfigPolicy::Deterministic) + ); + assert_eq!( + parse_mpv_config_policy(Some(OsStr::new("trusted-user"))), + Ok(MpvConfigPolicy::TrustedUser) + ); + assert_eq!(parse_mpv_config_policy(Some(OsStr::new("yes"))), Err(())); + + let deterministic = mpv_configuration_diagnostics( + MpvConfigPolicy::Deterministic, + MpvLoggingPolicy::default(), + true, + true, + ); + assert_eq!(deterministic.policy, MpvConfigurationPolicy::Deterministic); + assert!(!deterministic.user_config_enabled); + assert!(!deterministic.user_scripts_enabled); + assert!(deterministic.osc_enabled); + assert!(deterministic.input_bindings_enabled); + assert!(!deterministic.external_url_resolver_enabled); + assert_eq!(deterministic.log_verbosity, MpvLogVerbosity::Info); + assert!(deterministic.startup_verbose_capture); + assert_eq!(deterministic.active_video_shader_count, None); + + let integrated = mpv_configuration_diagnostics( + MpvConfigPolicy::Deterministic, + MpvLoggingPolicy::default(), + false, + false, + ); + assert!(!integrated.osc_enabled); + assert!(!integrated.input_bindings_enabled); + + let trusted = mpv_configuration_diagnostics( + MpvConfigPolicy::TrustedUser, + MpvLoggingPolicy::fixed(MpvLogLevel::Trace), + true, + true, + ); + assert_eq!(trusted.policy, MpvConfigurationPolicy::TrustedUser); + assert!(trusted.user_config_enabled); + assert!(trusted.user_scripts_enabled); + let serialized = serde_json::to_value(trusted).unwrap(); + assert_eq!(serialized["policy"], "trusted_user"); + assert_eq!(serialized["external_url_resolver_enabled"], false); + assert_eq!(serialized["log_verbosity"], "trace"); + assert_eq!(serialized["startup_verbose_capture"], false); + } + + #[test] + fn mpv_log_policy_is_explicit_fail_closed_and_secret_free() { + assert_eq!( + parse_mpv_logging_policy(None), + Ok(MpvLoggingPolicy::default()) + ); + for (name, expected) in [ + ("none", MpvLogLevel::None), + ("fatal", MpvLogLevel::Fatal), + ("error", MpvLogLevel::Error), + ("warn", MpvLogLevel::Warn), + ("info", MpvLogLevel::Info), + ("verbose", MpvLogLevel::Verbose), + ("debug", MpvLogLevel::Debug), + ("trace", MpvLogLevel::Trace), + ] { + let policy = + parse_mpv_logging_policy(Some(OsStr::new(name))).unwrap(); + assert_eq!(policy, MpvLoggingPolicy::fixed(expected)); + assert!(!policy.startup_verbose_capture); + } + assert_eq!( + parse_mpv_logging_policy(Some(OsStr::new("Bearer private"))), + Err(()) + ); + } + + #[test] + fn content_fit_maps_to_deterministic_native_vo_properties() { + assert_eq!( + content_fit_properties(PlaybackContentFit::Contain), + [ + ("keepaspect", MpvNode::Bool(true)), + ("video-unscaled", MpvNode::String("no".to_string())), + ("panscan", MpvNode::Double(0.0)), + ] + ); + assert_eq!( + content_fit_properties(PlaybackContentFit::Cover), + [ + ("keepaspect", MpvNode::Bool(true)), + ("video-unscaled", MpvNode::String("no".to_string())), + ("panscan", MpvNode::Double(1.0)), + ] + ); + assert_eq!( + content_fit_properties(PlaybackContentFit::Fill), + [ + ("keepaspect", MpvNode::Bool(false)), + ("video-unscaled", MpvNode::String("no".to_string())), + ("panscan", MpvNode::Double(0.0)), + ] + ); + assert_eq!( + content_fit_properties(PlaybackContentFit::None)[1], + ("video-unscaled", MpvNode::String("yes".to_string()),) + ); + assert_eq!( + content_fit_properties(PlaybackContentFit::ScaleDown)[1], + ( + "video-unscaled", + MpvNode::String("downscale-big".to_string()), + ) + ); + } + + #[test] + #[ignore = "requires FERREX_MPV_SMOKE_MEDIA or FERREX_MPV_SMOKE_URL and a working desktop VO"] + fn linked_native_window_load_control_fullscreen_stop_and_close_smoke() { + let source = smoke_source_from_environment("Ferrex mpv smoke"); + let resume_position = Duration::from_millis(250); + let mut adapter = MpvPlaybackAdapter::open( + &source, + resume_position, + SessionGeneration::INITIAL, + ) + .expect("native-window adapter starts"); + + let deadline = std::time::Instant::now() + Duration::from_secs(8); + loop { + adapter.poll_events(); + match adapter.snapshot().state { + PlaybackState::Playing | PlaybackState::Paused => break, + PlaybackState::Failed => { + panic!( + "mpv load failed: {:?}", + adapter.snapshot().last_error + ) + } + _ if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(20)); + } + state => panic!("mpv did not start before deadline: {state:?}"), + } + } + + let metadata_deadline = + std::time::Instant::now() + Duration::from_secs(3); + while (adapter.snapshot().duration.is_none() + || adapter.snapshot().position < resume_position + || adapter.snapshot().tracks.audio.is_empty() + || adapter.snapshot().video.is_none() + || adapter.mapper.mpv_version.is_none() + || adapter.mapper.ffmpeg_version.is_none() + || adapter.mapper.current_vo.is_none() + || !adapter.mapper.vo_configured) + && std::time::Instant::now() < metadata_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert!(adapter.snapshot().duration.is_some()); + assert!(adapter.snapshot().position >= resume_position); + assert!(!adapter.snapshot().tracks.audio.is_empty()); + assert!(adapter.snapshot().video.is_some()); + assert!(adapter.mapper.mpv_version.is_some()); + assert!(adapter.mapper.ffmpeg_version.is_some()); + assert!(adapter.mapper.current_vo.is_some()); + assert!(adapter.mapper.vo_configured); + let diagnostics = adapter.diagnostics(BackendRequest::Exact( + PlaybackTarget::MPV_NATIVE_WINDOW, + )); + assert!(diagnostics.versions.client_api.is_some()); + assert!(diagnostics.versions.mpv.is_some()); + assert!(diagnostics.versions.ffmpeg.is_some()); + assert!(diagnostics.versions.libplacebo.is_some()); + let configuration = diagnostics + .mpv_configuration + .as_ref() + .expect("mpv configuration policy is diagnostic"); + assert_eq!( + configuration.policy, + match adapter.config_policy { + MpvConfigPolicy::Deterministic => { + MpvConfigurationPolicy::Deterministic + } + MpvConfigPolicy::TrustedUser => { + MpvConfigurationPolicy::TrustedUser + } + } + ); + assert_eq!( + configuration.user_config_enabled, + adapter.config_policy.user_config_enabled() + ); + assert!(!configuration.external_url_resolver_enabled); + assert_eq!(diagnostics.output.vo_configured, Some(true)); + assert!(diagnostics.output.video_output.is_some()); + assert!(diagnostics.output.gpu_context.is_some()); + assert!(diagnostics.output.output_video.is_some()); + assert!(!adapter.startup_diagnostics_active); + + if let Some(path) = + std::env::var_os("FERREX_MPV_SMOKE_EXTERNAL_SUBTITLE") + { + let path = std::fs::canonicalize(std::path::PathBuf::from(path)) + .expect("external subtitle fixture exists"); + let existing = adapter + .snapshot() + .tracks + .subtitles + .iter() + .map(|track| track.id.clone()) + .collect::>(); + adapter + .apply_command(PlaybackCommand::AddExternalSubtitle { + source: PlaybackFilePath::new(path), + select: true, + }) + .expect("external subtitle request is accepted"); + let subtitle_deadline = + std::time::Instant::now() + Duration::from_secs(3); + while !adapter.snapshot().tracks.subtitles.iter().any(|track| { + track.is_external + && !existing.contains(&track.id) + && adapter.snapshot().tracks.selected_subtitle.as_ref() + == Some(&track.id) + }) && std::time::Instant::now() < subtitle_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + let external = adapter + .snapshot() + .tracks + .subtitles + .iter() + .find(|track| { + track.is_external && !existing.contains(&track.id) + }) + .expect("mpv exposes the added external subtitle"); + assert_eq!(external.kind, SubtitleKind::Text); + assert_eq!( + adapter.snapshot().tracks.selected_subtitle.as_ref(), + Some(&external.id) + ); + } + + adapter + .apply_command(PlaybackCommand::SetPaused(true)) + .expect("pause request is accepted"); + let pause_deadline = std::time::Instant::now() + Duration::from_secs(2); + while adapter.snapshot().state != PlaybackState::Paused + && std::time::Instant::now() < pause_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(adapter.snapshot().state, PlaybackState::Paused); + + adapter + .apply_command(PlaybackCommand::SetVolume(0.35)) + .expect("volume request is accepted"); + adapter + .apply_command(PlaybackCommand::SetMuted(true)) + .expect("mute request is accepted"); + adapter + .apply_command(PlaybackCommand::SetSpeed(1.25)) + .expect("speed request is accepted"); + adapter + .apply_command(PlaybackCommand::SetContentFit( + PlaybackContentFit::Cover, + )) + .expect("content-fit request is accepted"); + let controls_deadline = + std::time::Instant::now() + Duration::from_secs(2); + while ((adapter.snapshot().volume - 0.35).abs() > 0.001 + || !adapter.snapshot().muted + || (adapter.snapshot().speed - 1.25).abs() > 0.001) + && std::time::Instant::now() < controls_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert!((adapter.snapshot().volume - 0.35).abs() <= 0.001); + assert!(adapter.snapshot().muted); + assert!((adapter.snapshot().speed - 1.25).abs() <= 0.001); + assert_eq!(adapter.snapshot().content_fit, PlaybackContentFit::Cover); + + // Exercise explicit local extension inputs without relying on user + // config. The observed shader count proves mpv accepted the runtime + // list while diagnostics retain only the count, never the path. + let artifact_stem = format!( + "ferrex-mpv-smoke-{}-{}", + std::process::id(), + adapter.snapshot().generation.get() + ); + let shader_path = + std::env::temp_dir().join(format!("{artifact_stem}.hook")); + let screenshot_path = + std::env::temp_dir().join(format!("{artifact_stem}.png")); + let _ = std::fs::remove_file(&shader_path); + let _ = std::fs::remove_file(&screenshot_path); + std::fs::write( + &shader_path, + "#!HOOK MAIN\n#!BIND HOOKED\nvec4 hook() { return HOOKED_tex(HOOKED_pos); }\n", + ) + .expect("write temporary identity shader"); + adapter + .apply_command(PlaybackCommand::SetVideoShaders(vec![ + PlaybackFilePath::new(shader_path.clone()), + ])) + .expect("shader passthrough request is accepted"); + let shader_deadline = + std::time::Instant::now() + Duration::from_secs(3); + while adapter.mapper.active_video_shader_count != Some(1) + && std::time::Instant::now() < shader_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(adapter.mapper.active_video_shader_count, Some(1)); + + adapter + .apply_command(PlaybackCommand::CaptureScreenshot { + output: PlaybackFilePath::new(screenshot_path.clone()), + mode: PlaybackScreenshotMode::VideoWithSubtitles, + }) + .expect("screenshot request is accepted"); + let screenshot_deadline = + std::time::Instant::now() + Duration::from_secs(3); + while std::fs::metadata(&screenshot_path) + .map(|metadata| metadata.len() == 0) + .unwrap_or(true) + && std::time::Instant::now() < screenshot_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + std::fs::metadata(&screenshot_path) + .is_ok_and(|metadata| metadata.len() > 0), + "mpv did not write the requested screenshot" + ); + + adapter + .apply_command(PlaybackCommand::SetVideoShaders(Vec::new())) + .expect("shader clear request is accepted"); + let shader_clear_deadline = + std::time::Instant::now() + Duration::from_secs(3); + while adapter.mapper.active_video_shader_count != Some(0) + && std::time::Instant::now() < shader_clear_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(adapter.mapper.active_video_shader_count, Some(0)); + std::fs::remove_file(shader_path).expect("remove temporary shader"); + std::fs::remove_file(screenshot_path) + .expect("remove temporary screenshot"); + + let audio_target = adapter + .snapshot() + .tracks + .audio + .iter() + .find(|track| { + Some(&track.id) + != adapter.snapshot().tracks.selected_audio.as_ref() + }) + .or_else(|| adapter.snapshot().tracks.audio.first()) + .map(|track| track.id.clone()) + .expect("smoke fixture exposes an audio track"); + adapter + .apply_command(PlaybackCommand::SelectAudio(audio_target.clone())) + .expect("audio selection request is accepted"); + let audio_deadline = std::time::Instant::now() + Duration::from_secs(2); + while adapter.snapshot().tracks.selected_audio.as_ref() + != Some(&audio_target) + && std::time::Instant::now() < audio_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + adapter.snapshot().tracks.selected_audio.as_ref(), + Some(&audio_target) + ); + + if let Some(subtitle_target) = adapter + .snapshot() + .tracks + .subtitles + .first() + .map(|track| track.id.clone()) + { + adapter + .apply_command(PlaybackCommand::SelectSubtitle(Some( + subtitle_target.clone(), + ))) + .expect("subtitle selection request is accepted"); + let subtitle_deadline = + std::time::Instant::now() + Duration::from_secs(2); + while adapter.snapshot().tracks.selected_subtitle.as_ref() + != Some(&subtitle_target) + && std::time::Instant::now() < subtitle_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + adapter.snapshot().tracks.selected_subtitle.as_ref(), + Some(&subtitle_target) + ); + + adapter + .apply_command(PlaybackCommand::SelectSubtitle(None)) + .expect("subtitle disable request is accepted"); + let subtitle_off_deadline = + std::time::Instant::now() + Duration::from_secs(2); + while adapter.snapshot().tracks.selected_subtitle.is_some() + && std::time::Instant::now() < subtitle_off_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert!(adapter.snapshot().tracks.selected_subtitle.is_none()); + } + + if let Some(chapter_target) = adapter + .snapshot() + .chapters + .get(1) + .or_else(|| adapter.snapshot().chapters.first()) + .map(|chapter| chapter.id.clone()) + { + adapter + .apply_command(PlaybackCommand::SelectChapter( + chapter_target.clone(), + )) + .expect("chapter selection request is accepted"); + let chapter_deadline = + std::time::Instant::now() + Duration::from_secs(2); + while adapter.snapshot().current_chapter.as_ref() + != Some(&chapter_target) + && std::time::Instant::now() < chapter_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + adapter.snapshot().current_chapter.as_ref(), + Some(&chapter_target) + ); + } + + if let Some(edition_target) = adapter + .snapshot() + .editions + .first() + .map(|edition| edition.id.clone()) + { + adapter + .apply_command(PlaybackCommand::SelectEdition( + edition_target.clone(), + )) + .expect("edition selection request is accepted"); + let edition_deadline = + std::time::Instant::now() + Duration::from_secs(2); + while adapter.snapshot().current_edition.as_ref() + != Some(&edition_target) + && std::time::Instant::now() < edition_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + adapter.snapshot().current_edition.as_ref(), + Some(&edition_target) + ); + } + + for expected_fullscreen in [true, false] { + adapter + .apply_command(PlaybackCommand::SetFullscreen( + expected_fullscreen, + )) + .expect("fullscreen request is accepted"); + let fullscreen_deadline = + std::time::Instant::now() + Duration::from_secs(3); + while adapter.snapshot().fullscreen != expected_fullscreen + && std::time::Instant::now() < fullscreen_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + adapter.snapshot().fullscreen, + expected_fullscreen, + "mpv did not confirm the requested fullscreen state" + ); + } + + adapter + .apply_command(PlaybackCommand::SeekAbsolute( + Duration::from_millis(500), + )) + .expect("absolute seek request is accepted"); + let absolute_seek_deadline = + std::time::Instant::now() + Duration::from_secs(2); + while (adapter.snapshot().state == PlaybackState::Seeking + || adapter.snapshot().position < Duration::from_millis(400)) + && std::time::Instant::now() < absolute_seek_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + let absolute_position = adapter.snapshot().position; + assert!(absolute_position >= Duration::from_millis(400)); + + adapter + .apply_command(PlaybackCommand::SeekRelative( + DurationDelta::Forward(Duration::from_millis(250)), + )) + .expect("relative seek request is accepted"); + let relative_seek_deadline = + std::time::Instant::now() + Duration::from_secs(2); + while (adapter.snapshot().state == PlaybackState::Seeking + || adapter.snapshot().position + < absolute_position.saturating_add(Duration::from_millis(100))) + && std::time::Instant::now() < relative_seek_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + adapter.snapshot().position + >= absolute_position.saturating_add(Duration::from_millis(100)) + ); + + adapter + .apply_command(PlaybackCommand::SetPaused(false)) + .expect("play request is accepted"); + let play_deadline = std::time::Instant::now() + Duration::from_secs(2); + while adapter.snapshot().state != PlaybackState::Playing + && std::time::Instant::now() < play_deadline + { + adapter.poll_events(); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(adapter.snapshot().state, PlaybackState::Playing); + + adapter + .apply_command(PlaybackCommand::Stop) + .expect("stop request is accepted"); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + adapter.poll_events(); + if adapter.snapshot().end_reason == Some(EndReason::Stopped) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "mpv stop did not produce a terminal event: {:?}", + adapter.snapshot() + ); + std::thread::sleep(Duration::from_millis(20)); + } + + // Exercise natural EOF independently from explicit stop, then load one + // final generation to prove that a normal native-window close remains + // distinguishable from EOF and cannot auto-advance an episode. + adapter + .apply_command(PlaybackCommand::Load(source.clone())) + .expect("replacement load is accepted after stop"); + wait_for_smoke_playback(&mut adapter, "replacement load"); + let duration = adapter + .snapshot() + .duration + .expect("smoke fixture has a finite duration"); + adapter + .apply_command(PlaybackCommand::SeekAbsolute( + duration.saturating_sub(Duration::from_millis(300)), + )) + .expect("near-EOF seek is accepted"); + adapter + .apply_command(PlaybackCommand::SetPaused(false)) + .expect("near-EOF playback resumes"); + let eof_deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + adapter.poll_events(); + if adapter.snapshot().end_reason == Some(EndReason::Eof) { + break; + } + assert!( + std::time::Instant::now() < eof_deadline, + "mpv did not produce natural EOF: {:?}", + adapter.snapshot() + ); + std::thread::sleep(Duration::from_millis(20)); + } + + adapter + .apply_command(PlaybackCommand::Load(source.clone())) + .expect("post-EOF load is accepted"); + wait_for_smoke_playback(&mut adapter, "post-EOF load"); + + adapter + .worker() + .expect("native owner remains available") + .command_async(["quit"]) + .expect("native close-equivalent quit is accepted"); + let close_deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + adapter.poll_events(); + if adapter.snapshot().end_reason == Some(EndReason::Closed) { + break; + } + assert!( + std::time::Instant::now() < close_deadline, + "mpv close did not produce an orderly terminal event: {:?}", + adapter.snapshot() + ); + std::thread::sleep(Duration::from_millis(20)); + } + } + + #[test] + #[ignore = "requires FERREX_MPV_SMOKE_MEDIA or FERREX_MPV_SMOKE_URL and a working desktop VO"] + fn linked_native_window_load_stop_lifecycle_stress() { + let source = + smoke_source_from_environment("Ferrex mpv lifecycle stress"); + let cycles = std::env::var("FERREX_MPV_STRESS_CYCLES") + .map(|value| { + value + .parse::() + .expect("FERREX_MPV_STRESS_CYCLES is an integer") + }) + .unwrap_or(100); + assert!( + (1..=1_000).contains(&cycles), + "FERREX_MPV_STRESS_CYCLES must be between 1 and 1000" + ); + + let started = std::time::Instant::now(); + let mut baseline_resources = None; + let mut final_resources = None; + let mut peak_resident_kib = 0; + let mut peak_open_fds = 0; + for cycle in 1..=cycles { + let mut adapter = MpvPlaybackAdapter::open( + &source, + Duration::ZERO, + SessionGeneration::new(cycle), + ) + .unwrap_or_else(|error| { + panic!("native-window cycle {cycle}/{cycles} failed to start: {error}") + }); + wait_for_smoke_playback( + &mut adapter, + &format!("lifecycle cycle {cycle}/{cycles}"), + ); + assert!( + adapter.mapper.vo_configured, + "cycle {cycle}/{cycles} did not configure a native VO" + ); + + adapter.apply_command(PlaybackCommand::Stop).unwrap_or_else( + |error| panic!("cycle {cycle}/{cycles} rejected stop: {error}"), + ); + let stop_deadline = + std::time::Instant::now() + Duration::from_secs(5); + loop { + adapter.poll_events(); + if adapter.snapshot().end_reason == Some(EndReason::Stopped) { + break; + } + assert!( + std::time::Instant::now() < stop_deadline, + "cycle {cycle}/{cycles} did not stop cleanly: {:?}", + adapter.snapshot() + ); + std::thread::sleep(Duration::from_millis(20)); + } + drop(adapter); + + let resources = process_resource_sample(); + if let Some(resources) = resources { + baseline_resources.get_or_insert(resources); + final_resources = Some(resources); + peak_resident_kib = + peak_resident_kib.max(resources.resident_kib); + peak_open_fds = peak_open_fds.max(resources.open_fds); + } + if cycle == cycles || cycle % 10 == 0 { + if let Some(resources) = resources { + eprintln!( + "native-mpv lifecycle stress: {cycle}/{cycles} cycles complete; rss={} KiB, fds={}", + resources.resident_kib, resources.open_fds + ); + } else { + eprintln!( + "native-mpv lifecycle stress: {cycle}/{cycles} cycles complete" + ); + } + } + } + eprintln!( + "native-mpv lifecycle stress completed {cycles} fresh load/stop/window cycles in {:.2?}", + started.elapsed() + ); + + if let (Some(baseline), Some(final_sample)) = + (baseline_resources, final_resources) + { + let resident_growth_kib = final_sample + .resident_kib + .saturating_sub(baseline.resident_kib); + let fd_growth = + final_sample.open_fds.saturating_sub(baseline.open_fds); + eprintln!( + "native-mpv lifecycle resources: baseline_rss={} KiB, final_rss={} KiB, peak_rss={} KiB, rss_growth={} KiB, baseline_fds={}, final_fds={}, peak_fds={}, fd_growth={}", + baseline.resident_kib, + final_sample.resident_kib, + peak_resident_kib, + resident_growth_kib, + baseline.open_fds, + final_sample.open_fds, + peak_open_fds, + fd_growth + ); + + if let Some(limit_mib) = + optional_stress_limit("FERREX_MPV_STRESS_MAX_RSS_GROWTH_MIB") + { + assert!( + resident_growth_kib <= limit_mib.saturating_mul(1_024), + "native-mpv lifecycle RSS grew by {resident_growth_kib} KiB, above the configured {limit_mib} MiB limit" + ); + } + if let Some(limit) = + optional_stress_limit("FERREX_MPV_STRESS_MAX_FD_GROWTH") + { + let limit = usize::try_from(limit).unwrap_or(usize::MAX); + assert!( + fd_growth <= limit, + "native-mpv lifecycle file descriptors grew by {fd_growth}, above the configured limit {limit}" + ); + } + } + } + + #[test] + fn duration_delta_retains_signed_seconds_for_mpv_seek() { + assert_eq!( + DurationDelta::Backward(Duration::from_millis(1_500)) + .as_seconds_f64(), + -1.5 + ); + assert_eq!(PlaybackContentFit::Contain, PlaybackContentFit::Contain); + } +} diff --git a/crates/ferrex-player-ui/Cargo.toml b/crates/ferrex-player-ui/Cargo.toml index ac370f4e..17c72b1a 100644 --- a/crates/ferrex-player-ui/Cargo.toml +++ b/crates/ferrex-player-ui/Cargo.toml @@ -201,6 +201,7 @@ num-bigint = "0.4.6" [features] default = [] +mpv = ["ferrex-player-playback/mpv"] legacy-settings = [] iced_tester = ["iced/tester"] hot = ["iced/hot"] diff --git a/crates/ferrex-player/Cargo.toml b/crates/ferrex-player/Cargo.toml index 8c8ddbcc..c9507971 100644 --- a/crates/ferrex-player/Cargo.toml +++ b/crates/ferrex-player/Cargo.toml @@ -29,6 +29,7 @@ ferrex-player-app = { workspace = true } [features] default = [] +mpv = ["ferrex-player-app/mpv"] legacy-settings = ["ferrex-player-app/legacy-settings"] iced_tester = ["ferrex-player-app/iced_tester"] hot = ["ferrex-player-app/hot"] diff --git a/crates/ferrex-server/Cargo.toml b/crates/ferrex-server/Cargo.toml index b20f4161..1580e066 100644 --- a/crates/ferrex-server/Cargo.toml +++ b/crates/ferrex-server/Cargo.toml @@ -129,6 +129,9 @@ ferrex-core = { path = "../ferrex-core" } ferrex-flatbuffers = { path = "../ferrex-flatbuffers" } ferrexctl = { path = "../ferrexctl" } ferrex-model = { path = "../ferrex-model", features = ["serde"] } +# Display-backed Ferrex-router/libmpv acceptance only; excluded from normal +# server builds and tests unless `native-mpv-e2e` is requested explicitly. +ferrex-player-playback = { path = "../ferrex-player-playback", version = "0.1.2-alpha", optional = true, default-features = false, features = ["mpv"] } [dev-dependencies] tempfile.workspace = true @@ -143,4 +146,7 @@ ed25519-dalek = { version = "2", default-features = false } [features] # Enable long-running external end-to-end HTTP tests e2e = [] +# Enable the ignored, display-backed native-mpv test against a real Ferrex +# router and playback-scoped bearer ticket. +native-mpv-e2e = ["dep:ferrex-player-playback"] demo = ["ferrex-core/demo"] diff --git a/crates/ferrex-server/tests/playback_stream_failures.rs b/crates/ferrex-server/tests/playback_stream_failures.rs index 6e2a3d94..1f98fec1 100644 --- a/crates/ferrex-server/tests/playback_stream_failures.rs +++ b/crates/ferrex-server/tests/playback_stream_failures.rs @@ -3,11 +3,23 @@ use axum::Router; use axum::http::{HeaderName, StatusCode, header}; use axum_test::{TestResponse, TestServer}; use ferrex_core::api::routes::{utils as route_utils, v1}; +#[cfg(feature = "native-mpv-e2e")] +use ferrex_player_playback::{ + contract::{ + BackendRequest, EndReason, PlaybackCommand, PlaybackFilePath, + PlaybackScreenshotMode, PlaybackSnapshot, PlaybackSource, + PlaybackState, PlaybackTarget, SessionGeneration, + }, + session::PlaybackSession, + video::open_playback_session, +}; use ferrex_server::infra::startup::NoopStartupHooks; use serde_json::json; use sqlx::PgPool; use std::net::SocketAddr; use std::path::Path; +#[cfg(feature = "native-mpv-e2e")] +use std::{path::PathBuf, time::Duration}; use tempfile::TempDir; use uuid::Uuid; @@ -102,6 +114,14 @@ async fn seed_media_file( .file_name() .and_then(|name| name.to_str()) .unwrap_or("playback-test.mkv"); + // Most transport tests use ten-byte fixtures, while the display-backed + // acceptance test seeds a real generated media file. Preserve the + // deliberate missing-file case with the historical placeholder size. + let file_size = tokio::fs::metadata(path) + .await + .ok() + .and_then(|metadata| i64::try_from(metadata.len()).ok()) + .unwrap_or(10); sqlx::query( r#" INSERT INTO media_files ( @@ -115,7 +135,7 @@ async fn seed_media_file( .bind(logical_media_id) .bind(path.to_string_lossy().to_string()) .bind(filename) - .bind(10_i64) + .bind(file_size) .bind(json!({ "duration": "legacy-invalid-shape" })) .bind(is_available) .execute(pool) @@ -142,6 +162,198 @@ fn playback_stream_ticket_path(file_id: Uuid, access_token: &str) -> String { ) } +#[cfg(feature = "native-mpv-e2e")] +fn transcode_start_path(file_id: Uuid) -> String { + route_utils::replace_param( + v1::transcode::START, + "{id}", + file_id.to_string(), + ) +} + +#[cfg(feature = "native-mpv-e2e")] +fn transcode_status_path(job_id: &str) -> String { + route_utils::replace_param(v1::transcode::STATUS, "{job_id}", job_id) +} + +#[cfg(feature = "native-mpv-e2e")] +fn transcode_asset_path(file_id: Uuid, profile: &str, asset: &str) -> String { + v1::transcode::ASSET + .replace("{id}", &file_id.to_string()) + .replace("{profile}", profile) + .replace("{asset}", asset) +} + +#[cfg(feature = "native-mpv-e2e")] +async fn issue_playback_ticket( + server: &TestServer, + access_token: &str, + file_id: Uuid, +) -> Result { + let response = server + .get(&playback_ticket_path(file_id)) + .add_header("Authorization", bearer(access_token)) + .await; + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + let ticket = body["data"]["access_token"] + .as_str() + .context("ticket response should include a playback token")? + .to_owned(); + anyhow::ensure!(!ticket.is_empty()); + Ok(ticket) +} + +#[cfg(feature = "native-mpv-e2e")] +async fn generate_server_transcode( + server: &TestServer, + access_token: &str, + file_id: Uuid, + profile: &str, +) -> Result { + let start = server + .post(&transcode_start_path(file_id)) + .add_header("Authorization", bearer(access_token)) + .json(&json!({ "profile": profile })) + .await; + start.assert_status_ok(); + let start_body: serde_json::Value = start.json(); + let job_id = start_body["data"]["job_id"] + .as_str() + .context("transcode start response should include a job ID")? + .to_owned(); + anyhow::ensure!(!job_id.is_empty(), "transcode job ID was empty"); + + let deadline = std::time::Instant::now() + Duration::from_secs(45); + loop { + let status = server + .get(&transcode_status_path(&job_id)) + .add_header("Authorization", bearer(access_token)) + .await; + status.assert_status_ok(); + let body: serde_json::Value = status.json(); + let data = &body["data"]; + anyhow::ensure!(data["job_id"] == job_id); + anyhow::ensure!(data["media_id"] == file_id.to_string()); + anyhow::ensure!(data["profile"] == profile); + + match data["state"].as_str() { + Some("completed") => { + anyhow::ensure!(data["progress"] == 1.0); + let playback_path = data["playback_path"] + .as_str() + .context( + "completed transcode should publish a playback path", + )? + .to_owned(); + anyhow::ensure!( + playback_path + == transcode_asset_path(file_id, profile, "index.m3u8"), + "completed transcode published an unexpected playback path" + ); + anyhow::ensure!( + !playback_path.contains('?') + && !playback_path.contains('#'), + "transcode playback path contained credentials or a fragment" + ); + return Ok(playback_path); + } + Some("failed") => { + anyhow::bail!( + "server transcode failed: {}", + data["message"].as_str().unwrap_or("no status message") + ); + } + Some("queued" | "running") => {} + state => anyhow::bail!( + "server transcode returned an unknown state: {state:?}" + ), + } + + anyhow::ensure!( + std::time::Instant::now() < deadline, + "server transcode did not complete within 45 seconds" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[cfg(feature = "native-mpv-e2e")] +async fn verify_server_transcode_assets( + server: &TestServer, + playback_path: &str, + ticket: &str, +) -> Result { + let unauthenticated = server.get(playback_path).await; + unauthenticated.assert_status(StatusCode::UNAUTHORIZED); + + let manifest_response = server + .get(playback_path) + .add_header("Authorization", bearer(ticket)) + .await; + manifest_response.assert_status_ok(); + assert_eq!( + manifest_response + .maybe_header(header::CONTENT_TYPE) + .context("generated HLS manifest Content-Type is missing")? + .to_str()?, + "application/vnd.apple.mpegurl" + ); + assert_success_has_no_media_error(&manifest_response); + let manifest = manifest_response.text(); + anyhow::ensure!(manifest.starts_with("#EXTM3U")); + anyhow::ensure!(manifest.contains("#EXT-X-ENDLIST")); + anyhow::ensure!( + !manifest.contains(ticket), + "generated HLS manifest exposed its playback ticket" + ); + + let (asset_root, _) = playback_path + .rsplit_once('/') + .context("generated HLS playback path had no asset root")?; + let mut segment_count = 0_usize; + for line in manifest.lines() { + let asset = line.trim(); + if asset.is_empty() || asset.starts_with('#') { + continue; + } + anyhow::ensure!( + asset.starts_with("segment-") + && asset.ends_with(".ts") + && !asset.contains('/') + && !asset.contains('\\'), + "generated HLS manifest contained an unsafe asset reference" + ); + let asset_path = format!("{asset_root}/{asset}"); + + let unauthenticated = server.get(&asset_path).await; + unauthenticated.assert_status(StatusCode::UNAUTHORIZED); + let segment = server + .get(&asset_path) + .add_header("Authorization", bearer(ticket)) + .await; + segment.assert_status_ok(); + assert_eq!( + segment + .maybe_header(header::CONTENT_TYPE) + .context("generated HLS segment Content-Type is missing")? + .to_str()?, + "video/mp2t" + ); + assert_success_has_no_media_error(&segment); + anyhow::ensure!( + !segment.as_bytes().is_empty(), + "generated HLS segment was empty" + ); + segment_count += 1; + } + anyhow::ensure!( + segment_count > 0, + "generated HLS manifest contained no media segments" + ); + Ok(segment_count) +} + fn assert_success_has_no_media_error(response: &TestResponse) { assert!( response @@ -255,14 +467,26 @@ async fn playback_ticket_and_range_stream_ignore_corrupt_technical_metadata( ); assert_success_has_no_media_error(&ranged_ticket_stream); - let bearer_range = server + // In-process playback backends keep the scoped ticket out of the URL and + // send it as a bearer header. Verify the exact player transport form, not + // only the query compatibility path and a full account-session bearer. + let bearer_ticket_range = server + .get(&playback_stream_path(file_id)) + .add_header("Authorization", bearer(&ticket_token)) + .add_header("Range", "bytes=2-5") + .await; + bearer_ticket_range.assert_status(StatusCode::PARTIAL_CONTENT); + assert_eq!(bearer_ticket_range.as_bytes().as_ref(), b"2345"); + assert_success_has_no_media_error(&bearer_ticket_range); + + let bearer_session_range = server .get(&playback_stream_path(file_id)) .add_header("Authorization", bearer(&access_token)) .add_header("Range", "bytes=2-5") .await; - bearer_range.assert_status(StatusCode::PARTIAL_CONTENT); - assert_eq!(bearer_range.as_bytes().as_ref(), b"2345"); - assert_success_has_no_media_error(&bearer_range); + bearer_session_range.assert_status(StatusCode::PARTIAL_CONTENT); + assert_eq!(bearer_session_range.as_bytes().as_ref(), b"2345"); + assert_success_has_no_media_error(&bearer_session_range); Ok(()) } @@ -527,3 +751,445 @@ async fn playback_availability_failures_return_typed_recovery_headers( Ok(()) } + +#[cfg(feature = "native-mpv-e2e")] +async fn seed_router_backed_hls_fixture( + pool: &PgPool, + server: &TestServer, + library_id: Uuid, + source_manifest: &Path, + output_root: &Path, +) -> Result<(Uuid, Vec)> { + let source_root = source_manifest + .parent() + .context("HLS fixture manifest must have a parent directory")?; + let source_root = std::fs::canonicalize(source_root) + .context("could not canonicalize HLS fixture directory")?; + let manifest = std::fs::read_to_string(source_manifest) + .context("could not read HLS fixture manifest")?; + let mut protected_manifest = String::with_capacity(manifest.len() * 2); + let mut segment_ids = Vec::new(); + + for line in manifest.lines() { + let candidate = line.trim(); + if candidate.is_empty() || candidate.starts_with('#') { + protected_manifest.push_str(line); + protected_manifest.push('\n'); + continue; + } + + let relative = Path::new(candidate); + anyhow::ensure!( + !relative.is_absolute() + && relative.components().all(|component| matches!( + component, + std::path::Component::Normal(_) + )), + "HLS fixture contains a non-local segment path" + ); + let segment = std::fs::canonicalize(source_root.join(relative)) + .context("HLS fixture segment is missing")?; + anyhow::ensure!( + segment.starts_with(&source_root), + "HLS fixture segment escaped its fixture directory" + ); + + let segment_id = Uuid::new_v4(); + seed_media_file( + pool, + library_id, + Uuid::new_v4(), + segment_id, + &segment, + true, + ) + .await; + let segment_url = + server.server_url(&playback_stream_path(segment_id))?; + anyhow::ensure!( + segment_url.query().is_none(), + "protected HLS segment URL unexpectedly contains credentials" + ); + protected_manifest.push_str(segment_url.as_str()); + protected_manifest.push('\n'); + segment_ids.push(segment_id); + } + + anyhow::ensure!( + !segment_ids.is_empty(), + "HLS fixture manifest did not contain any segments" + ); + let manifest_path = output_root.join("router-backed-transcoded-hls.m3u8"); + std::fs::write(&manifest_path, protected_manifest) + .context("could not write router-backed HLS manifest")?; + let manifest_id = Uuid::new_v4(); + seed_media_file( + pool, + library_id, + Uuid::new_v4(), + manifest_id, + &manifest_path, + true, + ) + .await; + Ok((manifest_id, segment_ids)) +} + +#[cfg(feature = "native-mpv-e2e")] +fn wait_for_native_mpv( + session: &mut PlaybackSession, + label: &str, + timeout: Duration, + predicate: impl Fn(&PlaybackSnapshot) -> bool, +) -> Result<()> { + let deadline = std::time::Instant::now() + timeout; + loop { + session.synchronize_snapshot(); + let snapshot = session.snapshot(); + if predicate(snapshot) { + return Ok(()); + } + if snapshot.state == PlaybackState::Failed { + anyhow::bail!( + "{label} failed through native mpv: {:?}", + snapshot.last_error + ); + } + if std::time::Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for {label}: state={:?}, end={:?}", + snapshot.state, + snapshot.end_reason + ); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[cfg(feature = "native-mpv-e2e")] +fn run_ferrex_native_mpv_smoke( + source: PlaybackSource, + forbidden_ticket: String, + artifact_root: PathBuf, +) -> Result<()> { + let mut session = open_playback_session( + &source, + Duration::from_millis(250), + SessionGeneration::INITIAL, + BackendRequest::Exact(PlaybackTarget::MPV_NATIVE_WINDOW), + )?; + anyhow::ensure!( + session.snapshot().target == PlaybackTarget::MPV_NATIVE_WINDOW, + "exact native-mpv request fell back: {:?}", + session.diagnostics() + ); + + wait_for_native_mpv( + &mut session, + "authenticated Ferrex playback", + Duration::from_secs(10), + |snapshot| { + matches!( + snapshot.state, + PlaybackState::Playing | PlaybackState::Paused + ) && snapshot.duration.is_some() + && snapshot.video.is_some() + && !snapshot.tracks.audio.is_empty() + }, + )?; + anyhow::ensure!( + session.snapshot().position >= Duration::from_millis(200), + "mpv did not apply the requested resume offset" + ); + anyhow::ensure!(session.snapshot().capabilities.seek); + anyhow::ensure!(session.snapshot().capabilities.screenshot); + anyhow::ensure!(session.snapshot().capabilities.video_shader_passthrough); + + let diagnostics = session.diagnostics(); + anyhow::ensure!(diagnostics.versions.client_api.is_some()); + anyhow::ensure!(diagnostics.versions.mpv.is_some()); + anyhow::ensure!(diagnostics.versions.ffmpeg.is_some()); + anyhow::ensure!(diagnostics.output.vo_configured == Some(true)); + let serialized = serde_json::to_string(&diagnostics)?; + anyhow::ensure!( + !serialized.contains(&forbidden_ticket), + "playback diagnostics exposed the scoped bearer ticket" + ); + + session.apply_command(PlaybackCommand::SetPaused(true))?; + wait_for_native_mpv( + &mut session, + "pause confirmation", + Duration::from_secs(3), + |snapshot| snapshot.state == PlaybackState::Paused, + )?; + + let shader_path = artifact_root.join("ferrex-server-smoke.hook"); + let screenshot_path = artifact_root.join("ferrex-server-smoke.png"); + std::fs::write( + &shader_path, + "#!HOOK MAIN\n#!BIND HOOKED\nvec4 hook() { return HOOKED_tex(HOOKED_pos); }\n", + )?; + session + .set_video_shaders(vec![PlaybackFilePath::new(shader_path.clone())])?; + let shader_deadline = std::time::Instant::now() + Duration::from_secs(3); + while session + .diagnostics() + .mpv_configuration + .as_ref() + .and_then(|config| config.active_video_shader_count) + != Some(1) + && std::time::Instant::now() < shader_deadline + { + session.synchronize_snapshot(); + std::thread::sleep(Duration::from_millis(20)); + } + anyhow::ensure!( + session + .diagnostics() + .mpv_configuration + .as_ref() + .and_then(|config| config.active_video_shader_count) + == Some(1), + "mpv did not confirm the smoke shader" + ); + + session.capture_screenshot( + PlaybackFilePath::new(screenshot_path.clone()), + PlaybackScreenshotMode::VideoWithSubtitles, + )?; + let screenshot_deadline = + std::time::Instant::now() + Duration::from_secs(3); + while std::fs::metadata(&screenshot_path) + .map(|metadata| metadata.len() == 0) + .unwrap_or(true) + && std::time::Instant::now() < screenshot_deadline + { + session.synchronize_snapshot(); + std::thread::sleep(Duration::from_millis(20)); + } + anyhow::ensure!( + std::fs::metadata(&screenshot_path) + .is_ok_and(|metadata| metadata.len() > 0), + "mpv did not write the requested screenshot" + ); + session.set_video_shaders(Vec::new())?; + + session.apply_command(PlaybackCommand::SeekAbsolute( + Duration::from_millis(500), + ))?; + wait_for_native_mpv( + &mut session, + "authenticated range seek", + Duration::from_secs(3), + |snapshot| { + snapshot.state != PlaybackState::Seeking + && snapshot.position >= Duration::from_millis(400) + }, + )?; + session.apply_command(PlaybackCommand::SetPaused(false))?; + wait_for_native_mpv( + &mut session, + "play confirmation", + Duration::from_secs(3), + |snapshot| snapshot.state == PlaybackState::Playing, + )?; + + session.apply_command(PlaybackCommand::Stop)?; + wait_for_native_mpv( + &mut session, + "ordered stop", + Duration::from_secs(5), + |snapshot| snapshot.end_reason == Some(EndReason::Stopped), + )?; + Ok(()) +} + +#[cfg(feature = "native-mpv-e2e")] +#[ignore = "requires generated fixtures, PostgreSQL, linked libmpv, and a working desktop VO"] +#[sqlx::test(migrator = "ferrex_core::MIGRATOR")] +async fn playback_ticket_drives_display_backed_native_mpv_through_ferrex_router( + pool: PgPool, +) -> Result<()> { + let fixture = std::env::var_os("FERREX_MPV_SERVER_SMOKE_MEDIA") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/native-playback-fixtures/h264-sdr-8bit.mkv") + }); + let fixture = std::fs::canonicalize(&fixture).with_context(|| { + "native playback fixture is missing; run native_playback_fixtures.py generate" + })?; + + let (server, tempdir) = build_server(pool.clone()).await?; + let access_token = + register_user(&server, "native_mpv_ferrex_router").await?; + let library_id = Uuid::new_v4(); + seed_library(&pool, library_id).await; + let file_id = Uuid::new_v4(); + seed_media_file(&pool, library_id, Uuid::new_v4(), file_id, &fixture, true) + .await; + + let ticket = issue_playback_ticket(&server, &access_token, file_id).await?; + + let stream_url = server.server_url(&playback_stream_path(file_id))?; + let source = PlaybackSource::new(stream_url) + .with_header("Authorization", bearer(&ticket)) + .with_title("Ferrex authenticated native-mpv acceptance"); + let artifact_root = tempdir.path().to_path_buf(); + tokio::task::spawn_blocking(move || { + run_ferrex_native_mpv_smoke(source, ticket, artifact_root) + }) + .await + .context("native-mpv acceptance worker panicked")??; + + Ok(()) +} + +#[cfg(feature = "native-mpv-e2e")] +#[ignore = "requires generated fixtures, PostgreSQL, FFmpeg, linked libmpv, and a working desktop VO"] +#[sqlx::test(migrator = "ferrex_core::MIGRATOR")] +async fn server_generated_transcode_plays_through_display_backed_native_mpv( + pool: PgPool, +) -> Result<()> { + let fixture = std::env::var_os("FERREX_MPV_SERVER_TRANSCODE_MEDIA") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/native-playback-fixtures/h264-sdr-8bit.mkv") + }); + let fixture = std::fs::canonicalize(&fixture).with_context(|| { + "native playback fixture is missing; run native_playback_fixtures.py generate" + })?; + + let (server, tempdir) = build_server(pool.clone()).await?; + let access_token = + register_user(&server, "native_mpv_server_transcode").await?; + let library_id = Uuid::new_v4(); + seed_library(&pool, library_id).await; + let file_id = Uuid::new_v4(); + seed_media_file(&pool, library_id, Uuid::new_v4(), file_id, &fixture, true) + .await; + + let playback_path = + generate_server_transcode(&server, &access_token, file_id, "360p") + .await?; + let ticket = issue_playback_ticket(&server, &access_token, file_id).await?; + let segment_count = + verify_server_transcode_assets(&server, &playback_path, &ticket) + .await?; + + // Starting the same profile again must reuse the atomically published + // rendition rather than exposing a partially regenerated playlist. + let cached = server + .post(&transcode_start_path(file_id)) + .add_header("Authorization", bearer(&access_token)) + .json(&json!({ "profile": "360p" })) + .await; + cached.assert_status_ok(); + let cached_body: serde_json::Value = cached.json(); + anyhow::ensure!(cached_body["data"]["state"] == "completed"); + anyhow::ensure!(cached_body["data"]["progress"] == 1.0); + anyhow::ensure!(cached_body["data"]["playback_path"] == playback_path); + + let stream_url = server.server_url(&playback_path)?; + let source = PlaybackSource::new(stream_url) + .with_header("Authorization", bearer(&ticket)) + .with_title("Ferrex server-generated transcode acceptance"); + let artifact_root = tempdir.path().to_path_buf(); + tokio::task::spawn_blocking(move || { + run_ferrex_native_mpv_smoke(source, ticket, artifact_root) + }) + .await + .context("server-transcode native-mpv acceptance worker panicked")??; + + eprintln!( + "server-generated transcode acceptance passed: profile=360p, segments={segment_count}" + ); + Ok(()) +} + +#[cfg(feature = "native-mpv-e2e")] +#[ignore = "requires generated fixtures, PostgreSQL, linked libmpv, and a working desktop VO"] +#[sqlx::test(migrator = "ferrex_core::MIGRATOR")] +async fn playback_ticket_propagates_to_every_router_backed_hls_segment( + pool: PgPool, +) -> Result<()> { + let source_manifest = + std::env::var_os("FERREX_MPV_SERVER_SMOKE_HLS") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../../target/native-playback-fixtures/transcoded-hls/index.m3u8", + ) + }); + let source_manifest = std::fs::canonicalize(&source_manifest) + .with_context(|| { + "native HLS fixture is missing; run native_playback_fixtures.py generate" + })?; + + let (server, tempdir) = build_server(pool.clone()).await?; + let access_token = register_user(&server, "native_mpv_hls_router").await?; + let library_id = Uuid::new_v4(); + seed_library(&pool, library_id).await; + let (manifest_id, segment_ids) = seed_router_backed_hls_fixture( + &pool, + &server, + library_id, + &source_manifest, + tempdir.path(), + ) + .await?; + let ticket = + issue_playback_ticket(&server, &access_token, manifest_id).await?; + + let manifest_response = server + .get(&playback_stream_path(manifest_id)) + .add_header("Authorization", bearer(&ticket)) + .await; + manifest_response.assert_status_ok(); + assert_eq!( + manifest_response + .maybe_header(header::CONTENT_TYPE) + .context("HLS manifest Content-Type is missing")? + .to_str()?, + "application/vnd.apple.mpegurl" + ); + let protected_manifest = manifest_response.text(); + anyhow::ensure!( + !protected_manifest.contains(&ticket), + "router-backed HLS manifest exposed its playback ticket" + ); + + for segment_id in &segment_ids { + let unauthorized = server.get(&playback_stream_path(*segment_id)).await; + unauthorized.assert_status(StatusCode::UNAUTHORIZED); + + let segment = server + .get(&playback_stream_path(*segment_id)) + .add_header("Authorization", bearer(&ticket)) + .await; + segment.assert_status_ok(); + assert_eq!( + segment + .maybe_header(header::CONTENT_TYPE) + .context("HLS segment Content-Type is missing")? + .to_str()?, + "video/mp2t" + ); + assert_success_has_no_media_error(&segment); + } + + let stream_url = server.server_url(&playback_stream_path(manifest_id))?; + let source = PlaybackSource::new(stream_url) + .with_header("Authorization", bearer(&ticket)) + .with_title("Ferrex authenticated transcoded-HLS transport acceptance"); + let artifact_root = tempdir.path().to_path_buf(); + tokio::task::spawn_blocking(move || { + run_ferrex_native_mpv_smoke(source, ticket, artifact_root) + }) + .await + .context("native-mpv HLS acceptance worker panicked")??; + + Ok(()) +} diff --git a/scripts/check-native-mpv-boundaries.sh b/scripts/check-native-mpv-boundaries.sh new file mode 100755 index 00000000..45b1580e --- /dev/null +++ b/scripts/check-native-mpv-boundaries.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +fail_on_match() { + local pattern="$1" + local message="$2" + shift 2 + + local matches + matches="$(rg -n --glob '*.rs' "$pattern" "$@" || true)" + if [[ -n "$matches" ]]; then + printf '%s\n' "$matches" + printf '%s\n' "$message" >&2 + exit 1 + fi +} + +# The native-VO milestone deliberately links only mpv's client API. Adding a +# render-context symbol would silently move frame/swapchain responsibility into +# Ferrex and requires a specification amendment, not an incidental import. +fail_on_match \ + '\bmpv_render_(context|param|update|report|frame|api)\b|\bMPV_RENDER_' \ + 'native mpv code references the libmpv render API; native-VO must not create an mpv_render_context' \ + crates/ferrex-player-mpv/src \ + crates/ferrex-player-playback/src/mpv_adapter.rs + +# libmpv control and native presenters must not gain a decoded-frame upload path +# through wgpu. Iced-facing layout/host acquisition lives at the separate slot +# boundary and therefore is intentionally not searched here. +fail_on_match \ + '\b(wgpu|iced_wgpu)::|\buse[[:space:]]+(wgpu|iced_wgpu)\b' \ + 'native mpv control/presenter code imports wgpu; decoded frames must stay in the native VO' \ + crates/ferrex-player-mpv/src \ + crates/ferrex-player-playback/src/mpv_adapter.rs \ + crates/ferrex-player-playback/src/presenter.rs \ + crates/ferrex-player-playback/src/windows_presenter.rs \ + crates/ferrex-player-playback/src/macos_presenter.rs + +# Subwave is concrete adapter state. Domain/view policy may use only the +# Ferrex-owned session, snapshot, commands, events, and capability models. +subwave_matches="$( + rg -n --glob '*.rs' '\bSubwaveVideo\b|\bsubwave_unified::video::(AudioTrack|SubtitleTrack)\b' \ + crates/ferrex-player-playback/src \ + crates/ferrex-player-ui/src \ + crates/ferrex-player-app/src \ + | rg -v '^crates/ferrex-player-playback/src/subwave_adapter\.rs:' \ + || true +)" +if [[ -n "$subwave_matches" ]]; then + printf '%s\n' "$subwave_matches" + printf '%s\n' 'Subwave concrete types escaped the playback adapter boundary' >&2 + exit 1 +fi + +# Raw libmpv ownership stays in its isolated wrapper. The playback adapter may +# consume the wrapper, but UI/app/domain policy must not reach through it. +mpv_owner_matches="$( + rg -n --glob '*.rs' '\b(libmpv2_sys|ferrex_player_mpv)::' \ + crates/ferrex-player-playback/src \ + crates/ferrex-player-ui/src \ + crates/ferrex-player-app/src \ + | rg -v '^crates/ferrex-player-playback/src/mpv_adapter\.rs:' \ + || true +)" +if [[ -n "$mpv_owner_matches" ]]; then + printf '%s\n' "$mpv_owner_matches" + printf '%s\n' 'libmpv wrapper/raw bindings escaped the mpv adapter boundary' >&2 + exit 1 +fi + +# Player state is event/timer driven. A decoded-frame callback must not return +# as a progress/event polling mechanism during legacy cleanup. +fail_on_match \ + '\bon_new_frame\b|\bNewFrame\b' \ + 'player code contains a frame-driven state/progress callback' \ + crates/ferrex-player-playback/src \ + crates/ferrex-player-ui/src \ + crates/ferrex-player-app/src + +printf '%s\n' 'native mpv architecture boundary check passed' From e1f9f37367a0d3faaf9cc4df1d9ea7d2a0bc8f8d Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:19:17 -0600 Subject: [PATCH 09/56] test(player): record Wayland native-VO evidence and hybrid policy --- .../developer/native-mpv-wayland-spike.md | 196 +++++ scripts/qa/native_playback_wayland_trace.py | 677 ++++++++++++++++++ .../qa/test_native_playback_wayland_trace.py | 145 ++++ 3 files changed, 1018 insertions(+) create mode 100644 docs/src/content/docs/developer/native-mpv-wayland-spike.md create mode 100755 scripts/qa/native_playback_wayland_trace.py create mode 100644 scripts/qa/test_native_playback_wayland_trace.py diff --git a/docs/src/content/docs/developer/native-mpv-wayland-spike.md b/docs/src/content/docs/developer/native-mpv-wayland-spike.md new file mode 100644 index 00000000..66fa6e83 --- /dev/null +++ b/docs/src/content/docs/developer/native-mpv-wayland-spike.md @@ -0,0 +1,196 @@ +--- +title: "Native mpv Wayland spike" +description: "Pinned protocol-trace fixture, observed mpv protocol inventory, and W0 bridge-boundary findings." +sidebar: + order: 12 +--- + +This page records W0 research for the [native mpv integration specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md) and the resulting D-022 **HYBRID** decision. GStreamer remains the integrated Wayland backend and an mpv selection uses ordinary native-window presentation until a safer connection path exists. W1–W5 are retained as re-entry criteria rather than active implementation work. This Wayland-only decision does not change the plan to deliver fully integrated native-VO mpv presentation on Windows and macOS. + +## Pinned trace fixture + +The spike is pinned to **mpv 0.41.0**. The versioned capture tool refuses a different release so protocol drift is explicit: + +```bash +./scripts/qa/native_playback_fixtures.py verify +./scripts/qa/native_playback_wayland_trace.py \ + --environment-id wl-wlroots-amd \ + --fixture h264-sdr-8bit.mkv + +./scripts/qa/native_playback_wayland_trace.py \ + --environment-id wl-wlroots-amd \ + --fixture hdr10-pq.mkv + +./scripts/qa/native_playback_wayland_trace.py \ + --environment-id wl-wlroots-amd \ + --fixture hlg.mkv +``` + +The tool starts mpv with `--no-config`, `gpu-next`, Vulkan, `waylandvk`, and a private JSON IPC socket. `WAYLAND_DEBUG=client` captures ordinary native-VO traffic while IPC applies these operation markers in order: + +1. initial map and first configured VO; +2. pause, exact seek, and resume; +3. resize; +4. fullscreen enter and exit; +5. stop and replacement-file VO reload; and +6. quit and bounded teardown. + +This is a native mpv baseline, not bridge traffic. It does not transfer a decoded frame into Iced or wgpu. + +Each run is written under `target/native-playback-results///` with mode `0700`. `summary.json` contains the fixture hash, safe mpv diagnostics, operation timing, and protocol inventory. `wayland-client.log` contains the operation-correlated trace. Before retention, the tool replaces workspace/home/runtime paths, window titles, output connector/name/description/make/model strings, and seat names. Both artifacts are mode `0600` and remain ignored. Review traces before sharing them even after automatic redaction. + +The parser distinguishes: + +- **advertised globals**, which describe the compositor rather than mpv usage; +- **bound globals**, including the negotiated version mpv actually requested; and +- **used object interfaces and methods**, including non-global child objects. + +Its display-free unit tests cover parsing, path-segment validation, mpv version parsing, and trace redaction. + +## Initial wlroots/AMD observation + +Three local `wl-wlroots-amd` runs completed against the generated SDR, HDR10/PQ, and HLG fixtures. All selected mpv 0.41.0 `gpu-next`, Vulkan hardware decoding, and the expected input color parameters. Each trace had ten `wl_display.get_registry` requests across mpv/libplacebo/driver activity but exactly one `xdg_surface.get_toplevel` candidate. SDR, PQ, and HLG used the same protocol-interface set; their color-description values differed in the retained trace and mpv diagnostics. + +The exact bound-global set on this environment was: + +```text +ext_data_control_manager_v1 +wl_compositor +wl_data_device_manager +wl_output +wl_seat +wl_shm +wl_subcompositor +wp_color_manager_v1 +wp_commit_timing_manager_v1 +wp_content_type_manager_v1 +wp_cursor_shape_manager_v1 +wp_fifo_manager_v1 +wp_fractional_scale_manager_v1 +wp_linux_drm_syncobj_manager_v1 +wp_presentation +wp_single_pixel_buffer_manager_v1 +wp_tearing_control_manager_v1 +wp_viewporter +xdg_activation_v1 +xdg_wm_base +zwp_idle_inhibit_manager_v1 +zwp_linux_dmabuf_v1 +zwp_tablet_manager_v2 +zwp_text_input_manager_v3 +zxdg_decoration_manager_v1 +``` + +The complete observed object-interface set was: + +```text +ext_data_control_device_v1 +ext_data_control_manager_v1 +ext_data_control_offer_v1 +wl_buffer +wl_callback +wl_compositor +wl_data_device +wl_data_device_manager +wl_data_offer +wl_display +wl_keyboard +wl_output +wl_pointer +wl_region +wl_registry +wl_seat +wl_subcompositor +wl_subsurface +wl_surface +wp_color_management_surface_feedback_v1 +wp_color_management_surface_v1 +wp_color_manager_v1 +wp_commit_timer_v1 +wp_commit_timing_manager_v1 +wp_content_type_manager_v1 +wp_content_type_v1 +wp_cursor_shape_device_v1 +wp_cursor_shape_manager_v1 +wp_fifo_manager_v1 +wp_fifo_v1 +wp_fractional_scale_manager_v1 +wp_fractional_scale_v1 +wp_image_description_creator_params_v1 +wp_image_description_info_v1 +wp_image_description_v1 +wp_linux_drm_syncobj_manager_v1 +wp_linux_drm_syncobj_surface_v1 +wp_linux_drm_syncobj_timeline_v1 +wp_presentation +wp_presentation_feedback +wp_single_pixel_buffer_manager_v1 +wp_tearing_control_manager_v1 +wp_viewport +wp_viewporter +xdg_activation_v1 +xdg_surface +xdg_toplevel +xdg_wm_base +zwp_idle_inhibit_manager_v1 +zwp_idle_inhibitor_v1 +zwp_linux_buffer_params_v1 +zwp_linux_dmabuf_feedback_v1 +zwp_linux_dmabuf_v1 +zwp_tablet_manager_v2 +zwp_tablet_seat_v2 +zwp_text_input_manager_v3 +zwp_text_input_v3 +zxdg_decoration_manager_v1 +zxdg_toplevel_decoration_v1 +``` + +`summary.json` is the source of truth for per-method requests/events and negotiated versions. This inventory is initial evidence only: KDE, GNOME, NVIDIA, EGL fallback, missing optional globals, and later mpv versions can change it. + +## VO surface identification + +Connection order is not a safe identity. The trace contains registry activity from the ordinary Wayland client, Vulkan/libplacebo queries, and driver helper queues. Any future bridge must forward auxiliary connections normally and identify a VO candidate by protocol behavior: + +1. a private downstream client creates a `wl_surface`; +2. that client asks `xdg_wm_base.get_xdg_surface` for the surface; and +3. its `xdg_surface.get_toplevel` request establishes the candidate that would be virtualized. + +The bridge must accept exactly one candidate for the active presenter generation. Zero candidates time out to a structured fallback; a second candidate before explicit VO replacement is an ambiguity failure. Cursor/subsurface creation, registry order, process ID, title, and app ID are not sufficient identities. VO restart first tears down the prior virtual role and advances the generation. + +## `wl-proxy` evaluation + +Research was anchored to: + +- [`mahkoh/wl-proxy` 0.1.3 at `5874a0d`](https://github.com/mahkoh/wl-proxy/commit/5874a0d3d55ad6abfdb53a7e5a635951a9909a86), checked 2026-07-12; and +- [Jellyfin Desktop at `8722abd`](https://github.com/jellyfin/jellyfin-desktop/commit/8722abd2ce0f54e163a75928b72ca79e9b36b550), whose `jfn-wlproxy` wrapper demonstrates mpv shell-role interception. + +The upstream `wl-proxy` **crate** is MIT OR Apache-2.0 despite the repository-level GPL license used by its example applications. It has broad generated protocol coverage, file-descriptor forwarding, object handlers, a private acceptor, and current color-management/color-representation, dmabuf, syncobj, presentation, fractional-scale, viewport, content-type, tearing, and FIFO definitions. These are strong reasons to prefer a pinned upstream spike over writing a raw wire parser. + +It does not attach its upstream endpoint to an already established `wl_display` object namespace. `StateBuilder` opens/owns an upstream socket or file descriptor. Jellyfin directs mpv to the proxy, interposes `wl_display_connect` to capture mpv's foreign display, and creates its browser-overlay Wayland surfaces on that same mpv-owned downstream connection before handing their object IDs back to the proxy. Its existing browser connection is not the parent Ferrex needs. The application wrapper also changes `WAYLAND_DISPLAY` before mpv creation and leaves listener cleanup to process exit. Ferrex cannot copy those lifecycle and process-global assumptions: Iced already owns its renderer connection, input, and window lifecycle; playback sessions must tear down repeatedly; and changing process environment while another thread may connect is forbidden. + +A future re-entry spike may reuse the permissively licensed upstream crate only behind a Linux-only, non-default feature. Ferrex will not copy or fork Jellyfin's GPL application wrapper. A fork is justified only if a small, reviewable upstream-endpoint or teardown change is proven necessary; protocol virtualization remains Ferrex-owned. Pinning and license review are required before adding the dependency. + +## Connection-redirection blocker + +Stable libmpv has no per-context option that supplies a `wl_display` or a private Wayland socket to the native VO. `WAYLAND_DISPLAY` and `WAYLAND_SOCKET` are process-global, while the VO and Vulkan helper connections may be opened after initialization on internal threads. A temporary environment override around `mpv_initialize` is therefore not safe. + +The only candidate currently found that avoids that race is a **startup proxy topology**: + +1. start a private, mode-restricted proxy before Iced opens its first Wayland connection; +2. keep the process Wayland endpoint fixed for the application lifetime; +3. unlike Jellyfin's wrapper, accept Iced and all libmpv/driver helper clients into one shared proxy state and upstream namespace; +4. identify and virtualize only the mpv shell-role candidate by the sequence above; and +5. leave every other Iced/mpv protocol object transparently forwarded. + +This is broader than directing only mpv to a private socket and therefore conflicts with the current boundary; it is not selected. D-008 is deferred and D-022 records HYBRID rather than introducing thread-local environment tricks, symbol interposition, or an unrelated upstream connection. + +## Recorded outcome + +For Wayland releases under D-022: + +- Auto/integrated playback remains on GStreamer/Subwave; +- explicit mpv playback uses the ordinary native window; +- integrated mpv reports the connection-bootstrap limitation rather than silently selecting a CPU/wgpu frame path; and +- W1–W5 remain deferred until a compliant mechanism or explicit specification amendment establishes a maintainable topology. + +The decision is platform-specific. Windows P5 and macOS P6 continue toward fully integrated native-VO mpv presentation with Iced controls and their independent acceptance gates. diff --git a/scripts/qa/native_playback_wayland_trace.py b/scripts/qa/native_playback_wayland_trace.py new file mode 100755 index 00000000..7e8cdc4d --- /dev/null +++ b/scripts/qa/native_playback_wayland_trace.py @@ -0,0 +1,677 @@ +#!/usr/bin/env python3 +"""Capture a redacted, operation-correlated mpv Wayland protocol trace. + +This is the reproducible W0 research fixture for the native playback migration. +It exercises mpv's ordinary ``gpu-next`` Wayland VO; it does not implement or +validate the Ferrex protocol bridge. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Callable, Sequence + +SCHEMA_VERSION = 1 +PINNED_MPV_VERSION = "0.41.0" +DEFAULT_FIXTURE_ROOT = Path("target/native-playback-fixtures") +DEFAULT_FIXTURE = "h264-sdr-8bit.mkv" +DEFAULT_RESULTS_ROOT = Path("target/native-playback-results") +_ENVIRONMENT_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") +_TRACE_LINE = re.compile( + r"^\[\s*(?P[0-9.]+)\]\s+(?:\{[^}]+\}\s+)?" + r"(?P->\s+)?(?P[A-Za-z0-9_]+)[#@]\d+\." + r"(?P[A-Za-z0-9_]+)\((?P.*)\)$" +) +_GLOBAL = re.compile(r'^\d+, "(?P[A-Za-z0-9_]+)", (?P\d+)$') +_BIND = re.compile( + r'^\d+, "(?P[A-Za-z0-9_]+)", (?P\d+),' +) +_QUOTED_ARGUMENT = r'"(?:\\.|[^"\\])*"' +_TITLE = re.compile( + rf"(?Pxdg_toplevel[#@]\d+\.set_title\(){_QUOTED_ARGUMENT}(?P\))" +) +_OUTPUT_IDENTITY = re.compile( + rf"(?P(?:wl_output|zxdg_output_v1)[#@]\d+\." + rf"(?:name|description)\(){_QUOTED_ARGUMENT}(?P\))" +) +_OUTPUT_GEOMETRY = re.compile( + r"(?Pwl_output[#@]\d+\.geometry\()(?P.*)(?P\))" +) +_SEAT_IDENTITY = re.compile( + rf"(?Pwl_seat[#@]\d+\.name\(){_QUOTED_ARGUMENT}(?P\))" +) + + +class TraceError(RuntimeError): + """An actionable trace-capture failure.""" + + +@dataclass +class ProtocolInventory: + """Protocol interfaces and methods observed in a WAYLAND_DEBUG trace.""" + + advertised_globals: dict[str, int] = field(default_factory=dict) + bound_globals: dict[str, int] = field(default_factory=dict) + requests: dict[str, set[str]] = field(default_factory=dict) + events: dict[str, set[str]] = field(default_factory=dict) + message_count: int = 0 + registry_request_count: int = 0 + xdg_toplevel_candidate_count: int = 0 + + def as_dict(self) -> dict[str, Any]: + interfaces = sorted(set(self.requests) | set(self.events)) + return { + "advertised_globals": dict(sorted(self.advertised_globals.items())), + "bound_globals": dict(sorted(self.bound_globals.items())), + "interfaces": interfaces, + "requests": { + interface: sorted(methods) + for interface, methods in sorted(self.requests.items()) + }, + "events": { + interface: sorted(methods) + for interface, methods in sorted(self.events.items()) + }, + "message_count": self.message_count, + "topology": { + "registry_request_count": self.registry_request_count, + "xdg_toplevel_candidate_count": self.xdg_toplevel_candidate_count, + }, + } + + +class MpvIpc: + """Small synchronous client for the private mpv JSON IPC socket.""" + + def __init__(self, path: Path, timeout: float = 5.0) -> None: + self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.socket.settimeout(timeout) + self.socket.connect(os.fspath(path)) + self.buffer = bytearray() + self.request_id = 0 + self.timeout = timeout + + def close(self) -> None: + self.socket.close() + + def request( + self, command: Sequence[Any], *, allow_error: bool = False + ) -> dict[str, Any]: + self.request_id += 1 + request_id = self.request_id + payload = json.dumps( + {"command": list(command), "request_id": request_id}, + ensure_ascii=True, + separators=(",", ":"), + ).encode("utf-8") + b"\n" + self.socket.sendall(payload) + + deadline = time.monotonic() + self.timeout + while True: + while b"\n" not in self.buffer: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TraceError("timed out waiting for an mpv IPC reply") + self.socket.settimeout(remaining) + chunk = self.socket.recv(64 * 1024) + if not chunk: + raise TraceError("mpv closed its IPC socket before replying") + self.buffer.extend(chunk) + + line, _, tail = self.buffer.partition(b"\n") + self.buffer = bytearray(tail) + if not line: + continue + try: + message = json.loads(line) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise TraceError("mpv returned malformed JSON over IPC") from error + if message.get("request_id") != request_id: + continue + error_name = message.get("error", "unknown") + if error_name != "success" and not allow_error: + raise TraceError(f"mpv IPC command failed: {error_name}") + return message + + def property(self, name: str) -> Any | None: + reply = self.request(["get_property", name], allow_error=True) + return reply.get("data") if reply.get("error") == "success" else None + + +def validate_environment_id(value: str) -> str: + """Validate a stable, non-path environment identifier.""" + + if value in (".", "..") or _ENVIRONMENT_ID.fullmatch(value) is None: + raise TraceError( + "environment ID must be 1-64 lowercase letters, digits, '.', '_', or '-'" + ) + return value + + +def validate_run_id(value: str) -> str: + """Validate a UTC run identifier as one safe path segment.""" + + if value in (".", "..") or _RUN_ID.fullmatch(value) is None: + raise TraceError( + "run ID must be 1-96 letters, digits, '.', '_', or '-'" + ) + return value + + +def parse_mpv_version(output: str) -> str: + """Extract the release from mpv's first version line.""" + + match = re.search(r"(?m)^mpv v([0-9]+(?:\.[0-9]+){2})(?:\s|$)", output) + if match is None: + raise TraceError("could not parse mpv --version output") + return match.group(1) + + +def parse_protocol_inventory(trace: str) -> ProtocolInventory: + """Reduce WAYLAND_DEBUG output to a deterministic protocol inventory.""" + + inventory = ProtocolInventory() + for line in trace.splitlines(): + match = _TRACE_LINE.match(line) + if match is None: + continue + + interface = match.group("interface") + method = match.group("method") + arguments = match.group("arguments") + destination = inventory.requests if match.group("request") else inventory.events + destination.setdefault(interface, set()).add(method) + inventory.message_count += 1 + if match.group("request") and interface == "wl_display" and method == "get_registry": + inventory.registry_request_count += 1 + elif match.group("request") and interface == "xdg_surface" and method == "get_toplevel": + inventory.xdg_toplevel_candidate_count += 1 + + if interface == "wl_registry" and method == "global" and not match.group("request"): + global_match = _GLOBAL.fullmatch(arguments) + if global_match is not None: + name = global_match.group("interface") + version = int(global_match.group("version")) + inventory.advertised_globals[name] = max( + version, inventory.advertised_globals.get(name, 0) + ) + elif interface == "wl_registry" and method == "bind" and match.group("request"): + bind_match = _BIND.match(arguments) + if bind_match is not None: + name = bind_match.group("interface") + version = int(bind_match.group("version")) + inventory.bound_globals[name] = max( + version, inventory.bound_globals.get(name, 0) + ) + return inventory + + +def sanitize_trace(trace: str, replacements: Sequence[str | os.PathLike[str]]) -> str: + """Remove controlled paths plus window and output identity strings.""" + + sanitized = trace + values = { + os.fspath(value) + for value in replacements + if os.fspath(value) not in ("", "/") + } + for value in sorted(values, key=len, reverse=True): + sanitized = sanitized.replace(value, "") + sanitized = _TITLE.sub( + lambda match: f'{match.group("prefix")}\"\"{match.group("suffix")}', + sanitized, + ) + sanitized = _OUTPUT_IDENTITY.sub( + lambda match: f'{match.group("prefix")}\"\"{match.group("suffix")}', + sanitized, + ) + sanitized = _OUTPUT_GEOMETRY.sub( + lambda match: ( + match.group("prefix") + + re.sub(_QUOTED_ARGUMENT, '\"\"', match.group("arguments")) + + match.group("suffix") + ), + sanitized, + ) + sanitized = _SEAT_IDENTITY.sub( + lambda match: f'{match.group("prefix")}\"\"{match.group("suffix")}', + sanitized, + ) + return sanitized + + +def validate_capture_inventory(inventory: ProtocolInventory) -> None: + """Require the basic W0 map/frame/fullscreen/teardown evidence.""" + + required = ( + (inventory.requests, "wl_surface", "attach"), + (inventory.requests, "wl_surface", "commit"), + (inventory.requests, "wl_surface", "destroy"), + (inventory.requests, "wp_presentation", "feedback"), + (inventory.events, "wp_presentation_feedback", "presented"), + (inventory.requests, "xdg_toplevel", "set_fullscreen"), + (inventory.requests, "xdg_toplevel", "unset_fullscreen"), + (inventory.requests, "xdg_toplevel", "destroy"), + ) + missing = [ + f"{interface}.{method}" + for mapping, interface, method in required + if method not in mapping.get(interface, set()) + ] + if inventory.xdg_toplevel_candidate_count != 1: + missing.append( + "exactly one xdg_surface.get_toplevel candidate " + f"(observed {inventory.xdg_toplevel_candidate_count})" + ) + if missing: + raise TraceError( + "trace is missing required W0 protocol evidence: " + ", ".join(missing) + ) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def wait_for_socket(path: Path, process: subprocess.Popen[bytes], timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.exists(): + return + status = process.poll() + if status is not None: + raise TraceError(f"mpv exited before IPC became ready (status {status})") + time.sleep(0.025) + raise TraceError("timed out waiting for mpv's IPC socket") + + +def wait_for_property( + ipc: MpvIpc, name: str, expected: Any, timeout: float = 10.0 +) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if ipc.property(name) == expected: + return + time.sleep(0.05) + raise TraceError(f"timed out waiting for mpv property {name}") + + +def safe_mpv_diagnostics(ipc: MpvIpc) -> dict[str, Any]: + """Read only non-identifying VO/runtime properties.""" + + properties = ( + "mpv-version", + "ffmpeg-version", + "libplacebo-version", + "current-vo", + "gpu-api", + "gpu-context", + "hwdec-current", + "video-codec", + "video-format", + "video-params/primaries", + "video-params/gamma", + "video-params/colormatrix", + ) + result: dict[str, Any] = {} + for name in properties: + value = ipc.property(name) + if isinstance(value, (str, int, float, bool)) or value is None: + result[name] = value + return result + + +def marker(raw_fd: int, started: float, phase: str, operation: str) -> None: + elapsed_ms = int((time.monotonic() - started) * 1000) + line = f"# FERREX_OPERATION +{elapsed_ms}ms {phase} {operation}\n" + os.write(raw_fd, line.encode("ascii")) + + +def terminate_process(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + + +def run_operation( + operations: list[dict[str, Any]], + raw_fd: int, + started: float, + name: str, + action: Callable[[], Any], +) -> Any: + marker(raw_fd, started, "BEGIN", name) + operation = { + "name": name, + "started_ms": int((time.monotonic() - started) * 1000), + } + try: + result = action() + except Exception: + operation["result"] = "failed" + operation["finished_ms"] = int((time.monotonic() - started) * 1000) + operations.append(operation) + marker(raw_fd, started, "FAIL", name) + raise + operation["result"] = "passed" + operation["finished_ms"] = int((time.monotonic() - started) * 1000) + operations.append(operation) + marker(raw_fd, started, "END", name) + return result + + +def capture(args: argparse.Namespace) -> Path: + environment_id = validate_environment_id(args.environment_id) + if not os.environ.get("WAYLAND_DISPLAY"): + raise TraceError("WAYLAND_DISPLAY is not set; run capture in a Wayland session") + + mpv = Path(args.mpv).expanduser() if args.mpv else None + if mpv is None: + discovered = shutil.which("mpv") + if discovered is None: + raise TraceError("mpv was not found; pass --mpv /path/to/mpv") + mpv = Path(discovered) + mpv = mpv.resolve() + version_output = subprocess.run( + [os.fspath(mpv), "--no-config", "--version"], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ).stdout + mpv_version = parse_mpv_version(version_output) + if mpv_version != PINNED_MPV_VERSION: + raise TraceError( + f"W0 is pinned to mpv {PINNED_MPV_VERSION}, found {mpv_version}" + ) + + fixture_root = Path(args.fixture_root).expanduser().resolve() + fixture = (fixture_root / args.fixture).resolve() + try: + fixture_name = fixture.relative_to(fixture_root).as_posix() + except ValueError as error: + raise TraceError("fixture path must remain below --fixture-root") from error + if not fixture.is_file(): + raise TraceError("fixture is missing; generate native playback fixtures first") + + run_id = args.run_id or ( + datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ") + + f"-{secrets.token_hex(3)}" + ) + validate_run_id(run_id) + result_dir = ( + Path(args.results_root).expanduser().resolve() / environment_id / run_id + ) + result_dir.mkdir(mode=0o700, parents=True, exist_ok=False) + + runtime_root_value = os.environ.get("XDG_RUNTIME_DIR") + runtime_root = Path(runtime_root_value) if runtime_root_value else None + if runtime_root is not None and not runtime_root.is_dir(): + runtime_root = None + + raw_fd, raw_name = tempfile.mkstemp( + prefix="ferrex-wayland-trace-", suffix=".raw", dir=runtime_root + ) + os.fchmod(raw_fd, 0o600) + raw_path = Path(raw_name) + process: subprocess.Popen[bytes] | None = None + ipc: MpvIpc | None = None + operations: list[dict[str, Any]] = [] + diagnostics: dict[str, Any] = {} + outcome = "failed" + failure: Exception | None = None + started = time.monotonic() + + try: + with tempfile.TemporaryDirectory( + prefix="ferrex-mpv-wayland-", dir=runtime_root + ) as runtime_directory: + ipc_path = Path(runtime_directory) / "mpv.sock" + command = [ + os.fspath(mpv), + "--no-config", + "--terminal=no", + "--msg-level=all=warn", + "--idle=no", + "--force-window=yes", + "--title=Ferrex Wayland trace fixture", + "--audio=no", + "--osc=no", + "--input-default-bindings=no", + "--input-vo-keyboard=no", + "--keep-open=yes", + "--loop-file=inf", + "--vo=gpu-next", + "--gpu-api=vulkan", + "--gpu-context=waylandvk", + "--hwdec=auto-safe", + f"--input-ipc-server={ipc_path}", + "--", + os.fspath(fixture), + ] + environment = os.environ.copy() + environment["WAYLAND_DEBUG"] = "client" + marker(raw_fd, started, "BEGIN", "process-start") + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=raw_fd, + env=environment, + ) + wait_for_socket(ipc_path, process, args.startup_timeout) + ipc = MpvIpc(ipc_path) + run_operation( + operations, + raw_fd, + started, + "initial-map", + lambda: wait_for_property(ipc, "vo-configured", True), + ) + time.sleep(args.settle_seconds) + diagnostics = safe_mpv_diagnostics(ipc) + + run_operation( + operations, + raw_fd, + started, + "pause", + lambda: ipc.request(["set_property", "pause", True]), + ) + time.sleep(args.settle_seconds) + run_operation( + operations, + raw_fd, + started, + "seek", + lambda: ipc.request(["seek", 2.0, "absolute+exact"]), + ) + time.sleep(args.settle_seconds) + run_operation( + operations, + raw_fd, + started, + "resume", + lambda: ipc.request(["set_property", "pause", False]), + ) + run_operation( + operations, + raw_fd, + started, + "resize", + lambda: ipc.request(["set_property", "window-scale", 0.8]), + ) + time.sleep(args.settle_seconds) + run_operation( + operations, + raw_fd, + started, + "fullscreen-enter", + lambda: ipc.request(["set_property", "fullscreen", True]), + ) + wait_for_property(ipc, "fullscreen", True) + time.sleep(args.settle_seconds) + run_operation( + operations, + raw_fd, + started, + "fullscreen-exit", + lambda: ipc.request(["set_property", "fullscreen", False]), + ) + wait_for_property(ipc, "fullscreen", False) + time.sleep(args.settle_seconds) + run_operation( + operations, + raw_fd, + started, + "stop", + lambda: ipc.request(["stop"]), + ) + run_operation( + operations, + raw_fd, + started, + "vo-reload", + lambda: ipc.request(["loadfile", os.fspath(fixture), "replace"]), + ) + wait_for_property(ipc, "vo-configured", True) + time.sleep(args.settle_seconds) + run_operation( + operations, + raw_fd, + started, + "quit", + lambda: ipc.request(["quit"], allow_error=True), + ) + ipc.close() + ipc = None + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired as error: + raise TraceError("mpv did not terminate after quit") from error + marker(raw_fd, started, "END", "process-stop") + if process.returncode not in (0, None): + raise TraceError(f"mpv exited with status {process.returncode}") + outcome = "passed" + except Exception as error: # retain a redacted partial trace for diagnosis + failure = error + finally: + if ipc is not None: + try: + ipc.request(["quit"], allow_error=True) + except Exception: + pass + ipc.close() + if process is not None: + terminate_process(process) + os.close(raw_fd) + + raw_trace = raw_path.read_text(encoding="utf-8", errors="replace") + raw_path.unlink(missing_ok=True) + sanitized_trace = sanitize_trace( + raw_trace, + ( + fixture, + fixture_root, + Path.cwd().resolve(), + Path.home().resolve(), + runtime_root or "", + ), + ) + trace_path = result_dir / "wayland-client.log" + trace_path.write_text(sanitized_trace, encoding="utf-8") + trace_path.chmod(0o600) + + parsed_inventory = parse_protocol_inventory(sanitized_trace) + if failure is None: + try: + validate_capture_inventory(parsed_inventory) + except TraceError as error: + failure = error + outcome = "failed" + inventory = parsed_inventory.as_dict() + summary: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "outcome": outcome, + "environment_id": environment_id, + "run_id": run_id, + "captured_at_utc": datetime.now(UTC).isoformat(), + "fixture": { + "name": fixture_name, + "sha256": sha256_file(fixture), + }, + "mpv": { + "pinned_version": PINNED_MPV_VERSION, + "observed_version": mpv_version, + "diagnostics": diagnostics, + }, + "operations": operations, + "protocol_inventory": inventory, + "artifacts": {"wayland_client_trace": trace_path.name}, + } + if failure is not None: + failure_text = sanitize_trace(str(failure), (fixture, fixture_root, Path.home())) + summary["failure"] = failure_text + summary_path = result_dir / "summary.json" + summary_path.write_text( + json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + summary_path.chmod(0o600) + + print(result_dir) + if failure is not None: + if isinstance(failure, TraceError): + raise failure + raise TraceError("Wayland trace capture failed; inspect the redacted result") from failure + return result_dir + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--environment-id", required=True) + parser.add_argument("--fixture-root", default=DEFAULT_FIXTURE_ROOT) + parser.add_argument("--fixture", default=DEFAULT_FIXTURE) + parser.add_argument("--results-root", default=DEFAULT_RESULTS_ROOT) + parser.add_argument("--run-id") + parser.add_argument("--mpv") + parser.add_argument("--startup-timeout", type=float, default=10.0) + parser.add_argument("--settle-seconds", type=float, default=0.4) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + capture(args) + except (TraceError, OSError, subprocess.SubprocessError) as error: + parser.exit(1, f"error: {error}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/qa/test_native_playback_wayland_trace.py b/scripts/qa/test_native_playback_wayland_trace.py new file mode 100644 index 00000000..9f297c55 --- /dev/null +++ b/scripts/qa/test_native_playback_wayland_trace.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + + +def load_module(name: str, filename: str): + path = Path(__file__).with_name(filename) + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +wayland_trace = load_module( + "native_playback_wayland_trace", "native_playback_wayland_trace.py" +) + + +class NativePlaybackWaylandTraceTest(unittest.TestCase): + def test_protocol_inventory_separates_advertised_bound_and_used(self) -> None: + trace = """\ +[ 1.000] {Default Queue} wl_registry#2.global(3, "wl_compositor", 6) +[ 1.001] {Default Queue} wl_registry#2.global(7, "wp_viewporter", 1) +[ 1.002] {Default Queue} -> wl_display#1.get_registry(new id wl_registry#2) +[ 1.003] {Default Queue} -> wl_registry#2.bind(3, "wl_compositor", 6, new id [unknown]#4) +[ 1.004] {Default Queue} -> wl_compositor#4.create_surface(new id wl_surface#5) +[ 1.005] {Default Queue} -> xdg_surface#9.get_toplevel(new id xdg_toplevel#10) +[ 1.006] {Default Queue} wl_surface#5.enter(wl_output#8) +[ 1.007] {Default Queue} -> wl_surface#5.commit() +""" + inventory = wayland_trace.parse_protocol_inventory(trace).as_dict() + + self.assertEqual( + {"wl_compositor": 6, "wp_viewporter": 1}, + inventory["advertised_globals"], + ) + self.assertEqual({"wl_compositor": 6}, inventory["bound_globals"]) + self.assertEqual( + [ + "wl_compositor", + "wl_display", + "wl_registry", + "wl_surface", + "xdg_surface", + ], + inventory["interfaces"], + ) + self.assertEqual(["create_surface"], inventory["requests"]["wl_compositor"]) + self.assertEqual(["commit"], inventory["requests"]["wl_surface"]) + self.assertEqual(["enter"], inventory["events"]["wl_surface"]) + self.assertEqual(8, inventory["message_count"]) + self.assertEqual( + {"registry_request_count": 1, "xdg_toplevel_candidate_count": 1}, + inventory["topology"], + ) + + def test_protocol_inventory_accepts_pre_queue_at_object_format(self) -> None: + inventory = wayland_trace.parse_protocol_inventory( + '[ 2.000] -> wl_display@1.get_registry(new id wl_registry@2)\n' + ) + self.assertEqual(1, inventory.registry_request_count) + self.assertEqual({"wl_display": {"get_registry"}}, inventory.requests) + + def test_capture_inventory_requires_frame_fullscreen_and_teardown(self) -> None: + inventory = wayland_trace.ProtocolInventory( + requests={ + "wl_surface": {"attach", "commit", "destroy"}, + "wp_presentation": {"feedback"}, + "xdg_toplevel": {"set_fullscreen", "unset_fullscreen", "destroy"}, + }, + events={"wp_presentation_feedback": {"presented"}}, + xdg_toplevel_candidate_count=1, + ) + wayland_trace.validate_capture_inventory(inventory) + + inventory.events.clear() + with self.assertRaises(wayland_trace.TraceError): + wayland_trace.validate_capture_inventory(inventory) + + def test_trace_sanitization_removes_paths_titles_and_output_identity(self) -> None: + trace = """\ +[ 1.0] {Default Queue} -> xdg_toplevel#7.set_title("private title") +[ 1.1] {Default Queue} wl_output#8.name("DP-3") +[ 1.2] {Default Queue} zxdg_output_v1#9.description("serial 123") +[ 1.3] {Default Queue} wl_output#8.geometry(0, 0, 600, 340, 0, "Private Vendor", "Private Model", 0) +[ 1.4] {Default Queue} wl_seat#4.name("private-seat") +/home/example/media/video.mkv +""" + sanitized = wayland_trace.sanitize_trace( + trace, ["/home/example/media/video.mkv", "/home/example"] + ) + + for secret in ( + "private title", + "DP-3", + "serial 123", + "Private Vendor", + "Private Model", + "private-seat", + "/home/example", + ): + self.assertNotIn(secret, sanitized) + self.assertIn('set_title("")', sanitized) + self.assertIn('name("")', sanitized) + self.assertIn('geometry(0, 0, 600, 340, 0, ""', sanitized) + self.assertIn('name("")', sanitized) + self.assertIn("", sanitized) + + def test_environment_ids_are_single_safe_path_segments(self) -> None: + for value in ("wl-wlroots-amd", "wl.nvidia_1", "run-20260712t120000z"): + with self.subTest(value=value): + self.assertEqual(value, wayland_trace.validate_environment_id(value)) + + for value in ("", ".", "..", "../escape", "host/name", "UPPERCASE", "a" * 65): + with self.subTest(value=value): + with self.assertRaises(wayland_trace.TraceError): + wayland_trace.validate_environment_id(value) + + def test_run_ids_allow_utc_form_but_not_paths(self) -> None: + run_id = "20260713T003507.399434Z-89e48c" + self.assertEqual(run_id, wayland_trace.validate_run_id(run_id)) + for value in ("", ".", "..", "../escape", "run/name", "a" * 97): + with self.subTest(value=value): + with self.assertRaises(wayland_trace.TraceError): + wayland_trace.validate_run_id(value) + + def test_mpv_version_parser_requires_release_triplet(self) -> None: + self.assertEqual( + "0.41.0", + wayland_trace.parse_mpv_version( + "mpv v0.41.0 Copyright mpv project\nlibplacebo version: v7.360.1\n" + ), + ) + with self.assertRaises(wayland_trace.TraceError): + wayland_trace.parse_mpv_version("mpv development build\n") + + +if __name__ == "__main__": + unittest.main() From 21235042dd046abef708ff1a18dd0860a43a5087 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:19:28 -0600 Subject: [PATCH 10/56] build(player): package the LGPL libmpv profile on Linux --- .github/workflows/flatpak.yml | 21 ++ .../docs/release/flathub-submission.md | 29 +++ flake.nix | 124 ++++++++++- flatpak/io.github.lowband21.FerrexPlayer.yml | 192 +++++++++++++++++- 4 files changed, 355 insertions(+), 11 deletions(-) diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 3cf8ce5d..826adcda 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -8,6 +8,7 @@ on: paths: - 'flatpak/**' - 'crates/ferrex-player/**' + - 'crates/ferrex-player-*/**' - 'crates/ferrex-core/**' - 'crates/ferrex-model/**' - 'crates/ferrex-contracts/**' @@ -18,6 +19,7 @@ on: paths: - 'flatpak/**' - 'crates/ferrex-player/**' + - 'crates/ferrex-player-*/**' - 'crates/ferrex-core/**' - 'crates/ferrex-model/**' - 'crates/ferrex-contracts/**' @@ -48,6 +50,25 @@ jobs: bundle: ferrex-player.flatpak cache-key: flatpak-builder-${{ github.sha }} + - name: Smoke test bundled libmpv closure + shell: bash + run: | + set -euo pipefail + flatpak install --user --noninteractive ./ferrex-player.flatpak + trap 'flatpak uninstall --user --noninteractive io.github.lowband21.FerrexPlayer >/dev/null' EXIT + flatpak run --user --command=sh io.github.lowband21.FerrexPlayer -c ' + set -eu + test -x /app/bin/ferrex-player + test -e /app/lib/libmpv.so.2 + test -e /app/lib/libavcodec.so.62 + test -e /app/lib/libplacebo.so.360 + ldd /app/bin/ferrex-player | grep -q "libmpv.so.2 => /app/lib/libmpv.so.2" + grep -qx "gpl=false" /app/share/licenses/io.github.lowband21.FerrexPlayer/mpv/BUILD_PROFILE + grep -qx "x11=disabled" /app/share/licenses/io.github.lowband21.FerrexPlayer/mpv/BUILD_PROFILE + grep -qx "gpl=no" /app/share/licenses/io.github.lowband21.FerrexPlayer/ffmpeg/BUILD_PROFILE + grep -qx "vulkan=enabled" /app/share/licenses/io.github.lowband21.FerrexPlayer/libplacebo/BUILD_PROFILE + ' + - name: Upload Flatpak artifact uses: actions/upload-artifact@v4 with: diff --git a/docs/src/content/docs/release/flathub-submission.md b/docs/src/content/docs/release/flathub-submission.md index 319dd37f..32a3a684 100644 --- a/docs/src/content/docs/release/flathub-submission.md +++ b/docs/src/content/docs/release/flathub-submission.md @@ -16,6 +16,35 @@ This guide documents how to submit Ferrex Player to Flathub for distribution. Th - Desktop entry: `flatpak/io.github.lowband21.FerrexPlayer.desktop` - Icons: 128x128, 192x192, 512x512 in `flatpak/icons/` +## Native playback packaging profile + +The manifest builds Ferrex with the `mpv` feature and bundles pinned mpv +0.41.0, FFmpeg 8.1.2, libplacebo, libass, and LuaJIT. Its configure checks fail +the build if mpv resolves `gpl=true`, if FFmpeg enables GPL/nonfree/version-3 +code, or if the required Wayland/Vulkan/dmabuf feature set is missing. The final +binary must have a direct `libmpv.so.2` dependency, and the exact license files +and effective build profiles are installed below +`/app/share/licenses/io.github.lowband21.FerrexPlayer/`. + +mpv 0.41 gates its X11 VO on GPL sources. The reviewed Flatpak profile does not +ship those sources: Wayland can use the explicit native-window mpv backend, +while X11 retains the integrated GStreamer backend or the separate external +player action. Do not enable mpv's GPL option merely to make the X11 feature +check pass. + +Validate the finished artifact locally with: + +```bash +flatpak-builder --user --force-clean --repo=target/flatpak-repo \ + target/flatpak-build flatpak/io.github.lowband21.FerrexPlayer.yml +flatpak build-bundle target/flatpak-repo target/ferrex-player.flatpak \ + io.github.lowband21.FerrexPlayer +``` + +Install the bundle and verify that `ldd /app/bin/ferrex-player` resolves +`libmpv.so.2`, FFmpeg, libplacebo, libass, and LuaJIT from `/app/lib` before +publishing it. + ## Submission Steps ### 1. Fork the Flathub Repository diff --git a/flake.nix b/flake.nix index 093c0c83..18d956e9 100644 --- a/flake.nix +++ b/flake.nix @@ -122,6 +122,71 @@ gst_1_28_4 = gstSet; }; + # libmpv linked into Ferrex must use mpv's LGPL source profile. The + # nixpkgs default enables GPL code and GPL FFmpeg options, which is valid + # for the standalone player but not for this permissively licensed app. + mpvOverlay_0_41_0_lgpl = + final: prev: + let + ffmpegLgpl = prev.ffmpeg-headless.override { + withGPL = false; + withVersion3 = false; + withUnfree = false; + }; + + mpvBase = assert prev.lib.assertMsg (prev.mpv-unwrapped.version == "0.41.0") + "Ferrex's reviewed libmpv profile must be updated explicitly"; + prev.mpv-unwrapped; + + mpvLgpl = (mpvBase.override { + ffmpeg = ffmpegLgpl; + cddaSupport = false; + dvbinSupport = false; + dvdnavSupport = false; + rubberbandSupport = false; + }).overrideAttrs (old: { + mesonFlags = (old.mesonFlags or [ ]) ++ [ + (prev.lib.mesonBool "gpl" false) + ]; + postConfigure = (old.postConfigure or "") + '' + mpv_options="meson-info/intro-buildoptions.json" + ${prev.python3}/bin/python3 - "$mpv_options" <<'PY' + import json + import sys + + with open(sys.argv[1], encoding="utf-8") as options_file: + options = json.load(options_file) + if not any(option["name"] == "gpl" and option["value"] is False for option in options): + raise SystemExit("Ferrex libmpv profile did not resolve gpl=false") + PY + ''; + installCheckPhase = prev.lib.optionalString prev.stdenv.hostPlatform.isLinux '' + mpv_version="$($out/bin/mpv --no-config --version)" + printf '%s\n' "$mpv_version" | grep -q '^mpv v0\.41\.0 ' + test -e "$out/lib/libmpv.so.2" + + ffmpeg_license="$(${ffmpegLgpl.bin}/bin/ffmpeg -hide_banner -L 2>&1)" + printf '%s\n' "$ffmpeg_license" | grep -q 'GNU Lesser General Public' + ffmpeg_build="$(${ffmpegLgpl.bin}/bin/ffmpeg -hide_banner -buildconf 2>&1)" + if printf '%s\n' "$ffmpeg_build" | grep -Eq -- '--enable-(gpl|nonfree|version3)'; then + echo "Ferrex libmpv profile unexpectedly linked restricted FFmpeg options" >&2 + exit 1 + fi + echo "validated Ferrex LGPL-only mpv/FFmpeg profile" + ''; + passthru = (old.passthru or { }) // { + ferrexLicenseProfile = "LGPL-2.1-or-later"; + ferrexFfmpeg = ffmpegLgpl; + }; + meta = (old.meta or { }) // { + license = [ prev.lib.licenses.lgpl21Plus ]; + }; + }); + in + { + ferrexMpv_0_41_0 = mpvLgpl; + }; + workspaceToml = fromTOML (builtins.readFile ./Cargo.toml); workspaceVersion = workspaceToml.workspace.package.version or "0.0.0"; @@ -130,6 +195,11 @@ [ pkgs.pipewire + # libmpv runtime plus client headers/pkg-config metadata. The raw Rust + # bindings are feature-gated, but linked P2/P3 builds need both outputs. + pkgs.ferrexMpv_0_41_0 + pkgs.ferrexMpv_0_41_0.dev + # Include full outputs so setup hooks set `GST_PLUGIN_SYSTEM_PATH_1_0`. gst.gstreamer gst.gst-plugins-base @@ -183,6 +253,7 @@ "${pkgs.libxi}/lib" "${pkgs.libxrandr}/lib" "${pkgs.vulkan-loader}/lib" + "${pkgs.ferrexMpv_0_41_0}/lib" ]; in { @@ -228,6 +299,7 @@ in { overlays.gst_1_28_4 = gstOverlay_1_28_4; + overlays.mpv_0_41_0_lgpl = mpvOverlay_0_41_0_lgpl; packages = forAllSystems ( system: @@ -242,6 +314,7 @@ inherit system; overlays = [ self.overlays.gst_1_28_4 + self.overlays.mpv_0_41_0_lgpl rust-overlay.overlays.default ]; config.allowUnfree = true; @@ -262,6 +335,23 @@ src = let + sourceRoot = toString ./.; + generatedSourceRoots = map (name: "${sourceRoot}/${name}") [ + ".direnv" + ".flatpak-builder" + "cache" + "target" + "target-nix" + ]; + isGeneratedSource = path: + let + pathString = toString path; + in + nixpkgs.lib.any ( + prefix: + pathString == prefix + || nixpkgs.lib.hasPrefix "${prefix}/" pathString + ) generatedSourceRoots; sqlxFilter = path: _type: (builtins.match ".*\.sqlx/.*" path) != null; migrationsFilter = path: _type: (builtins.match ".*/migrations/.*\.sql$" path) != null; wgslFilter = path: _type: (builtins.match ".*\.wgsl$" path) != null; @@ -269,13 +359,19 @@ in nixpkgs.lib.cleanSourceWith { src = ./.; + # `path:.` package smokes include untracked implementation files, + # but must not ingest ignored Flatpak/target build trees whose + # vendored Cargo manifests are unrelated to this workspace. filter = path: type: - (sqlxFilter path type) - || (migrationsFilter path type) - || (wgslFilter path type) - || (ttfFilter path type) - || (craneLib.filterCargoSources path type); + !(isGeneratedSource path) + && ( + (sqlxFilter path type) + || (migrationsFilter path type) + || (wgslFilter path type) + || (ttfFilter path type) + || (craneLib.filterCargoSources path type) + ); }; mkCommonArgs = @@ -302,10 +398,15 @@ }; commonArgs = mkCommonArgs { inherit pkgs libclang ffmpegPkg; }; - playerCommonArgs = mkCommonArgs { + playerCommonArgs = (mkCommonArgs { pkgs = pkgsPlayer; libclang = libclangPlayer; ffmpegPkg = ffmpegPkgPlayer; + }) // { + # mpv 0.41 excludes its GPL-only X11 VO from this LGPL profile. + # Compile an early runtime preflight instead of letting explicit + # in-process mpv selection fail after session startup on X11. + FERREX_MPV_X11 = "disabled"; }; serverCargoArtifacts = craneLib.buildDepsOnly (commonArgs // { @@ -320,14 +421,14 @@ playerCargoArtifacts = craneLibPlayer.buildDepsOnly (playerCommonArgs // { pname = "ferrex-player-deps"; - cargoExtraArgs = "-p ferrex-player"; + cargoExtraArgs = "-p ferrex-player --features mpv"; buildInputs = playerCommonArgs.buildInputs ++ playerMediaBuildInputs; }); ferrexPlayerBin = craneLibPlayer.buildPackage (playerCommonArgs // { cargoArtifacts = playerCargoArtifacts; pname = "ferrex-player"; - cargoExtraArgs = "-p ferrex-player"; + cargoExtraArgs = "-p ferrex-player --features mpv"; doCheck = false; nativeBuildInputs = playerCommonArgs.nativeBuildInputs ++ (with pkgsPlayer; [ @@ -353,6 +454,7 @@ in { gstreamer_1_28_4 = gst.gstreamer; + libmpv = pkgsPlayer.ferrexMpv_0_41_0; gst_plugins_base_1_28_4 = gst.gst-plugins-base; gst_plugins_good_1_28_4 = gst.gst-plugins-good; gst_plugins_bad_1_28_4 = gst.gst-plugins-bad; @@ -425,6 +527,7 @@ inherit system; overlays = [ self.overlays.gst_1_28_4 + self.overlays.mpv_0_41_0_lgpl rust-overlay.overlays.default ]; config.allowUnfree = true; @@ -522,9 +625,10 @@ shellHook = '' export CARGO_TARGET_DIR="$PWD/target-nix" export LIBCLANG_PATH="${libclangPlayer.lib}/lib" + export FERREX_MPV_X11="disabled" - # Helps crates like ffmpeg-sys-next when building outside Nix's build sandbox. - export PKG_CONFIG_PATH="${ffmpegPkgPlayer.dev}/lib/pkgconfig:${ffmpegPkgPlayer.dev}/share/pkgconfig:''${PKG_CONFIG_PATH:-}" + # Helps native media bindings when building outside Nix's build sandbox. + export PKG_CONFIG_PATH="${pkgsPlayer.ferrexMpv_0_41_0.dev}/lib/pkgconfig:${ffmpegPkgPlayer.dev}/lib/pkgconfig:${ffmpegPkgPlayer.dev}/share/pkgconfig:''${PKG_CONFIG_PATH:-}" # Keep GStreamer plugin discovery consistent (avoid mixing system plugins # from other GStreamer versions via $NIX_PROFILES). diff --git a/flatpak/io.github.lowband21.FerrexPlayer.yml b/flatpak/io.github.lowband21.FerrexPlayer.yml index 5d522eed..d93d232b 100644 --- a/flatpak/io.github.lowband21.FerrexPlayer.yml +++ b/flatpak/io.github.lowband21.FerrexPlayer.yml @@ -96,6 +96,80 @@ modules: url: https://gstreamer.freedesktop.org/src/gst-plugins-ugly/gst-plugins-ugly-1.28.4.tar.xz sha256: 5486cd145c5af43259fd37caca59d048e2a67ddb07082ea8f50ef0f02a85f8a5 + # libmpv is linked into the permissively licensed Ferrex binary, so keep + # this profile synchronized with D-005 and the Nix LGPL profile. In + # particular, never inherit a distro mpv built with its default GPL option. + - name: libass + config-opts: + - --disable-static + - --enable-asm + - --enable-fontconfig + - --enable-harfbuzz + post-install: + - install -Dm644 COPYING /app/share/licenses/io.github.lowband21.FerrexPlayer/libass/COPYING + cleanup: + - /include/ass + - /lib/libass.la + - /lib/pkgconfig/libass.pc + sources: + - type: git + url: https://github.com/libass/libass.git + tag: 0.17.4 + commit: bbb3c7f1570a4a021e52683f3fbdf74fe492ae84 + + - name: ffmpeg-lgpl + config-opts: + - --disable-debug + - --disable-doc + - --disable-programs + - --disable-static + - --disable-gpl + - --disable-nonfree + - --disable-version3 + - --enable-shared + - --enable-gnutls + - --enable-libass + - --enable-libdrm + - --enable-vaapi + - --enable-vulkan + post-install: + - >- + if grep -Eq '^CONFIG_(GPL|NONFREE|VERSION3)=yes$' ffbuild/config.mak; + then echo 'FFmpeg resolved a restricted license option' >&2; exit 1; + fi + - install -Dm644 COPYING.LGPLv2.1 /app/share/licenses/io.github.lowband21.FerrexPlayer/ffmpeg/COPYING.LGPLv2.1 + - install -Dm644 LICENSE.md /app/share/licenses/io.github.lowband21.FerrexPlayer/ffmpeg/LICENSE.md + - >- + printf '%s\n' 'FFmpeg n8.1.2' 'gpl=no' 'nonfree=no' + 'version3=no' 'shared=yes' 'gnutls=yes' 'libass=yes' + 'libdrm=yes' 'vaapi=yes' 'vulkan=yes' + > /app/share/licenses/io.github.lowband21.FerrexPlayer/ffmpeg/BUILD_PROFILE + cleanup: + - /include/libavcodec + - /include/libavdevice + - /include/libavfilter + - /include/libavformat + - /include/libavutil + - /include/libpostproc + - /include/libswresample + - /include/libswscale + - /lib/pkgconfig/libavcodec.pc + - /lib/pkgconfig/libavdevice.pc + - /lib/pkgconfig/libavfilter.pc + - /lib/pkgconfig/libavformat.pc + - /lib/pkgconfig/libavutil.pc + - /lib/pkgconfig/libpostproc.pc + - /lib/pkgconfig/libswresample.pc + - /lib/pkgconfig/libswscale.pc + - /share/ffmpeg/examples + sources: + - type: git + url: https://github.com/FFmpeg/FFmpeg.git + tag: n8.1.2 + commit: 38b88335f99e76ed89ff3c93f877fdefce736c13 + + # Build gst-libav against the bundled FFmpeg ABI instead of loading a second + # runtime FFmpeg into the same process. - name: gst-libav buildsystem: meson config-opts: @@ -105,6 +179,117 @@ modules: url: https://gstreamer.freedesktop.org/src/gst-libav/gst-libav-1.28.4.tar.xz sha256: bd17a5df2874a7a58bcbaf7b940223379ad9613624db8ead783db03e74bb904b + - name: luajit + no-autogen: true + make-args: + - BUILDMODE=dynamic + - PREFIX=/app + make-install-args: + - PREFIX=/app + post-install: + - install -Dm644 COPYRIGHT /app/share/licenses/io.github.lowband21.FerrexPlayer/luajit/COPYRIGHT + cleanup: + - /bin/luajit* + - /include/luajit-* + - /lib/pkgconfig/luajit.pc + - /share/man/man1/luajit.1 + sources: + - type: git + url: https://github.com/openresty/luajit2.git + tag: v2.1-20260620 + commit: b411bec3ce550ef9968fc83bca094455cf812c1f + + - name: libplacebo + buildsystem: meson + config-opts: + - -Ddefault_library=shared + - -Dvulkan=enabled + - -Dshaderc=enabled + - -Dopengl=enabled + - -Dlcms=enabled + - -Ddemos=false + - -Dtests=false + - -Dbench=false + post-install: + - install -Dm644 ../LICENSE /app/share/licenses/io.github.lowband21.FerrexPlayer/libplacebo/LICENSE + - >- + printf '%s\n' 'libplacebo 7.360.1' 'shared=yes' 'vulkan=enabled' + 'shaderc=enabled' 'opengl=enabled' 'lcms=enabled' + > /app/share/licenses/io.github.lowband21.FerrexPlayer/libplacebo/BUILD_PROFILE + cleanup: + - /include/libplacebo + - /lib/pkgconfig/libplacebo.pc + sources: + - type: git + url: https://github.com/haasn/libplacebo.git + tag: v7.360.1 + commit: cee9b076f2c63104ccfd497fa79c39a867293ec4 + + - name: mpv-lgpl + buildsystem: meson + config-opts: + - -Dgpl=false + - -Dbuild-date=false + - -Dcplayer=false + - -Dlibmpv=true + - -Dtests=false + - -Dfuzzers=false + - -Dmanpage-build=disabled + - -Dhtml-build=disabled + - -Dpdf-build=disabled + - -Dcdda=disabled + - -Ddvbin=disabled + - -Ddvdnav=disabled + - -Djavascript=disabled + - -Dlua=enabled + - -Drubberband=disabled + - -Duchardet=disabled + - -Dvapoursynth=disabled + - -Dzimg=disabled + - -Dalsa=disabled + - -Dpipewire=enabled + - -Dpulse=enabled + - -Ddmabuf-wayland=enabled + - -Ddrm=disabled + - -Degl=enabled + - -Degl-wayland=enabled + - -Degl-x11=disabled + - -Dgbm=disabled + - -Dgl=enabled + - -Dplain-gl=enabled + - -Dshaderc=disabled + - -Dvaapi=enabled + - -Dvaapi-drm=disabled + - -Dvaapi-wayland=enabled + - -Dvaapi-x11=disabled + - -Dvulkan=enabled + - -Dwayland=enabled + # mpv 0.41's X11 VO sources are GPL-only. The Flatpak must keep this + # disabled under D-005 and use the existing GStreamer fallback on X11. + - -Dx11=disabled + post-install: + - >- + python3 -c "import json; + options={item['name']: item['value'] for item in json.load(open('meson-info/intro-buildoptions.json', encoding='utf-8'))}; + required={'gpl': False, 'libmpv': True, 'vulkan': 'enabled', 'wayland': 'enabled', 'x11': 'disabled', 'dmabuf-wayland': 'enabled'}; + missing={key: (options.get(key), value) for key, value in required.items() if options.get(key) != value}; + assert not missing, f'unexpected mpv build options: {missing}'" + - test -e /app/lib/libmpv.so.2 + - install -Dm644 ../LICENSE.LGPL /app/share/licenses/io.github.lowband21.FerrexPlayer/mpv/LICENSE.LGPL + - >- + printf '%s\n' 'mpv 0.41.0 / client API 2.5' + 'gpl=false' 'libmpv=true' 'vulkan=enabled' 'wayland=enabled' + 'dmabuf-wayland=enabled' 'x11=disabled' + > /app/share/licenses/io.github.lowband21.FerrexPlayer/mpv/BUILD_PROFILE + cleanup: + - /include/mpv + - /lib/pkgconfig/mpv.pc + sources: + - type: git + url: https://github.com/mpv-player/mpv.git + tag: v0.41.0 + commit: 41f6a645068483470267271e1d09966ca3b9f413 + - name: ferrex-player buildsystem: simple build-options: @@ -113,12 +298,17 @@ modules: append-path: /app/tools/rust/bin env: SQLX_OFFLINE: "true" + # Compiles the D-023 preflight that rejects X11-only sessions before + # starting this LGPL libmpv build, whose X11 VO is intentionally absent. + FERREX_MPV_X11: "disabled" CARGO_HOME: /run/build/ferrex-player/cargo-home CARGO_TARGET_DIR: /run/build/ferrex-player/target PKG_CONFIG_PATH: /app/lib/pkgconfig:/app/share/pkgconfig build-commands: - - cargo build --release -p ferrex-player + - cargo build --release -p ferrex-player --features mpv - install -Dm755 /run/build/ferrex-player/target/release/ferrex-player /app/bin/ferrex-player + - readelf -d /app/bin/ferrex-player | grep -q 'Shared library: \[libmpv.so.2\]' + - test "$(pkg-config --modversion mpv)" = 2.5.0 - install -Dm644 flatpak/io.github.lowband21.FerrexPlayer.desktop /app/share/applications/io.github.lowband21.FerrexPlayer.desktop - install -Dm644 flatpak/io.github.lowband21.FerrexPlayer.metainfo.xml /app/share/metainfo/io.github.lowband21.FerrexPlayer.metainfo.xml - install -Dm644 flatpak/icons/128x128/apps/io.github.lowband21.FerrexPlayer.png /app/share/icons/hicolor/128x128/apps/io.github.lowband21.FerrexPlayer.png From b04d6e9e5446b6f947d0ecb48a76e30fcefb1369 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:20:08 -0600 Subject: [PATCH 11/56] feat(player): add the Win32 presenter and distribution handoff --- .github/workflows/windows-dist.yml | 177 ++- Cargo.lock | 1 + crates/ferrex-player-playback/Cargo.toml | 9 + crates/ferrex-player-playback/src/lib.rs | 7 + .../src/windows_presenter.rs | 1228 +++++++++++++++++ crates/ferrexctl/src/bin/ferrexctl.rs | 4 + crates/ferrexctl/src/cli/package/mod.rs | 324 +++++ .../ferrexctl/tests/e2e_package_commands.rs | 1 + utils/build-windows/README.txt | 11 + utils/build-windows/build-libmpv-lgpl.sh | 415 ++++++ .../build-windows/gstreamer-plugin-roots.txt | 43 + utils/build-windows/install-gstreamer.ps1 | 60 + .../new-libmpv-import-library.ps1 | 39 + utils/build-windows/run-ferrex.bat | 11 +- utils/build-windows/run-ferrex.ps1 | 20 +- .../build-windows/stage-gstreamer-runtime.ps1 | 196 +++ .../build-windows/verify-runtime-closure.ps1 | 465 +++++++ 17 files changed, 2981 insertions(+), 30 deletions(-) create mode 100644 crates/ferrex-player-playback/src/windows_presenter.rs create mode 100755 utils/build-windows/build-libmpv-lgpl.sh create mode 100644 utils/build-windows/gstreamer-plugin-roots.txt create mode 100644 utils/build-windows/install-gstreamer.ps1 create mode 100644 utils/build-windows/new-libmpv-import-library.ps1 create mode 100644 utils/build-windows/stage-gstreamer-runtime.ps1 create mode 100644 utils/build-windows/verify-runtime-closure.ps1 diff --git a/.github/workflows/windows-dist.yml b/.github/workflows/windows-dist.yml index 41168c59..4b7d385a 100644 --- a/.github/workflows/windows-dist.yml +++ b/.github/workflows/windows-dist.yml @@ -3,14 +3,21 @@ name: Windows Dist on: workflow_dispatch: inputs: - gstreamer_version: - description: "GStreamer version (MSVC; newest official installer is 1.28.3)" - default: "1.28.3" - required: true profile: description: "Cargo profile" default: "release" required: true + presenter_mode: + description: "Native presenter build mode" + type: choice + default: "spike" + required: true + options: + - spike + - disabled + push: + tags: + - "v*" permissions: contents: write @@ -21,6 +28,10 @@ jobs: runs-on: windows-latest env: TARGET: x86_64-pc-windows-msvc + # The handoff procedure builds both the explicit presenter path and its + # closure-identical disabled control artifact. Tag builds always use the + # disabled release mode until representative hardware approves the spike. + FERREX_MPV_WINDOWS_PRESENTER: ${{ github.event_name == 'workflow_dispatch' && inputs.presenter_mode || 'disabled' }} steps: - name: Checkout uses: actions/checkout@v5 @@ -32,13 +43,85 @@ jobs: targets: ${{ env.TARGET }} components: clippy,rustfmt - - name: Setup GStreamer (Windows) - uses: marcpabst/setup-gstreamer@v.1.5.3 + - name: Cache pinned GStreamer MSVC SDK + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}\gstreamer-msvc-x86_64 + key: windows-gstreamer-msvc-1.28.4-1a745d67225e4339 + + - name: Install pinned GStreamer MSVC SDK + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $root = Join-Path $env:RUNNER_TEMP 'gstreamer-msvc-x86_64' + & .\utils\build-windows\install-gstreamer.ps1 -Destination $root + "GSTREAMER_1_0_ROOT_MSVC_X86_64=$root" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "PKG_CONFIG=$root\bin\pkg-config.exe" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "PKG_CONFIG_PATH=$root\lib\pkgconfig" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + (Join-Path $root 'bin') | Out-File $env:GITHUB_PATH -Append -Encoding utf8 + + - name: Setup MSYS2 UCRT64 for pinned libmpv + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + base-devel + autoconf-archive + git + mingw-w64-ucrt-x86_64-toolchain + mingw-w64-ucrt-x86_64-meson + mingw-w64-ucrt-x86_64-ninja + mingw-w64-ucrt-x86_64-pkgconf + mingw-w64-ucrt-x86_64-nasm + mingw-w64-ucrt-x86_64-python + mingw-w64-ucrt-x86_64-python-mako + mingw-w64-ucrt-x86_64-python-jinja + mingw-w64-ucrt-x86_64-python-glad + mingw-w64-ucrt-x86_64-harfbuzz + mingw-w64-ucrt-x86_64-freetype + mingw-w64-ucrt-x86_64-fribidi + mingw-w64-ucrt-x86_64-libpng + mingw-w64-ucrt-x86_64-lcms2 + mingw-w64-ucrt-x86_64-shaderc + mingw-w64-ucrt-x86_64-spirv-cross + mingw-w64-ucrt-x86_64-xxhash + mingw-w64-ucrt-x86_64-zlib + + - name: Cache pinned LGPL libmpv SDK + uses: actions/cache@v4 + with: + path: C:\ferrex-libmpv-sdk + key: windows-libmpv-lgpl-${{ hashFiles('utils/build-windows/build-libmpv-lgpl.sh') }} + + - name: Build pinned LGPL libmpv SDK + shell: msys2 {0} + run: | + if [[ ! -f /c/ferrex-libmpv-sdk/share/licenses/ferrex-libmpv/BUILD_PROFILE ]]; then + ./utils/build-windows/build-libmpv-lgpl.sh /c/ferrex-libmpv-sdk + fi + + - name: Enter MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 with: - # Official MSVC installers currently stop at 1.28.3, so Windows - # bundles intentionally trail the 1.28.4 source/Nix/Flatpak pin. - version: "${{ github.event.inputs.gstreamer_version || '1.28.3' }}" - arch: x86_64 + arch: x64 + + - name: Generate MSVC import library and export libmpv paths + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $root = 'C:\ferrex-libmpv-sdk' + & .\utils\build-windows\new-libmpv-import-library.ps1 -SdkRoot $root + $dll = Get-ChildItem (Join-Path $root 'bin') -File | + Where-Object { $_.Name -in @('libmpv-2.dll', 'mpv-2.dll', 'mpv.dll') } | + Select-Object -First 1 + if (-not $dll) { throw "libmpv runtime DLL missing from $root\bin" } + "LIBMPV_ROOT=$root" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "LIBMPV_LIB_DIR=$(Join-Path $root 'lib')" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "LIBMPV_INCLUDE_DIR=$(Join-Path $root 'include')" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "LIBMPV_DLL_DIR=$(Join-Path $root 'bin')" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "LIBMPV_DLL=$($dll.FullName)" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + (Join-Path $root 'bin') | Out-File $env:GITHUB_PATH -Append -Encoding utf8 - name: Show pkg-config shell: pwsh @@ -51,11 +134,15 @@ jobs: "gst-app: $(pkg-config --modversion gstreamer-app-1.0)" | Write-Host "gst-video: $(pkg-config --modversion gstreamer-video-1.0)" | Write-Host + - name: Check backend-disabled rollback build + shell: pwsh + run: cargo check -p ferrex-player --no-default-features --target $env:TARGET + - name: Build ferrex-player (release) shell: pwsh run: | $profile = if ('${{ github.event.inputs.profile }}') { '${{ github.event.inputs.profile }}' } else { 'release' } - cargo build -p ferrex-player --target $env:TARGET --profile $profile + cargo build -p ferrex-player --features mpv --target $env:TARGET --profile $profile - name: Stage distribution folder shell: pwsh @@ -65,7 +152,8 @@ jobs: $stage = Join-Path $root 'dist-windows' $bin = Join-Path $stage 'bin' $libg = Join-Path $stage 'lib\\gstreamer-1.0' - New-Item -ItemType Directory -Force -Path $stage, $bin, $libg | Out-Null + $libexec = Join-Path $stage 'libexec\\gstreamer-1.0' + New-Item -ItemType Directory -Force -Path $stage, $bin, $libg, $libexec | Out-Null # Copy launcher scripts and README from repo Copy-Item -Force utils\\build-windows\\run-ferrex.bat $stage @@ -76,20 +164,51 @@ jobs: $exe = Join-Path $root "target\\$env:TARGET\\${{ github.event.inputs.profile || 'release' }}\\ferrex-player.exe" if (-not (Test-Path $exe)) { throw "Built executable not found: $exe" } Copy-Item -Force $exe (Join-Path $stage 'ferrex-player.exe') - - # Bundle GStreamer runtime and plugins from official SDK installed by the action + $buildMetadata = Join-Path $stage 'share\\ferrex-player' + New-Item -ItemType Directory -Force -Path $buildMetadata | Out-Null + $env:FERREX_MPV_WINDOWS_PRESENTER | Set-Content (Join-Path $buildMetadata 'PRESENTER_BUILD_MODE') + + # Stage the complete hashed libmpv closure first. When an imported + # basename is shared, the reviewed libmpv build remains authoritative + # and the clean GStreamer registry smoke below proves compatibility. + Copy-Item -Force (Join-Path $env:LIBMPV_ROOT 'bin\\*.dll') $bin + + # Bundle only the reviewed GStreamer roots and their recursive PE + # import closure. The official installer contains restricted/GPL + # components that must never be copied wholesale. $gstroot = $env:GSTREAMER_1_0_ROOT_MSVC_X86_64 if (-not $gstroot) { throw 'GSTREAMER_1_0_ROOT_MSVC_X86_64 not set' } - - # Core DLLs - Copy-Item -Force -Recurse (Join-Path $gstroot 'bin\\*.dll') $bin - # Plugins - Copy-Item -Force -Recurse (Join-Path $gstroot 'lib\\gstreamer-1.0\\*.dll') $libg - - # Optional: include inspection tool for debugging - if (Test-Path (Join-Path $gstroot 'bin\\gst-inspect-1.0.exe')) { - Copy-Item -Force (Join-Path $gstroot 'bin\\gst-inspect-1.0.exe') $bin - } + $pluginRoots = Join-Path $root 'utils\\build-windows\\gstreamer-plugin-roots.txt' + & .\\utils\\build-windows\\stage-gstreamer-runtime.ps1 -SourceRoot $gstroot -StageRoot $stage -PluginManifest $pluginRoots + + $gstLicenseSource = @( + (Join-Path $gstroot 'share\\licenses'), + (Join-Path $gstroot 'share\\gstreamer-1.0\\licenses') + ) | Where-Object { Test-Path $_ -PathType Container } | Select-Object -First 1 + if (-not $gstLicenseSource) { throw 'GStreamer license directory is missing' } + $gstNotices = Join-Path $stage 'share\\licenses\\gstreamer' + New-Item -ItemType Directory -Force -Path $gstNotices | Out-Null + Copy-Item -Force -Recurse (Join-Path $gstLicenseSource '*') $gstNotices + @( + 'gstreamer=1.28.4', + 'abi=msvc-x86_64', + 'installer_sha256=1a745d67225e43394a4a5db929c97397cb56e74b1c38bb77c6ded4b037d3c040', + 'plugin_policy=explicit-recursive-pe-v1', + 'codec_policy=openh264-mediafoundation-v1', + "plugin_roots_sha256=$((Get-FileHash $pluginRoots -Algorithm SHA256).Hash.ToLowerInvariant())" + ) | Set-Content (Join-Path $gstNotices 'FERREX_BUILD_PROFILE') + + # Bundle the complete hashed libmpv DLL closure and corresponding + # source/runtime notices from the pinned SDK. + $notices = Join-Path $stage 'share\\licenses\\ferrex-libmpv' + New-Item -ItemType Directory -Force -Path $notices | Out-Null + Copy-Item -Force -Recurse (Join-Path $env:LIBMPV_ROOT 'share\\licenses\\ferrex-libmpv\\*') $notices + + - name: Audit staged Windows runtime closure + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + & .\\utils\\build-windows\\verify-runtime-closure.ps1 -StageRoot .\\dist-windows - name: Create zip artifact shell: pwsh @@ -97,7 +216,8 @@ jobs: $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $version = (Get-Content -Raw Cargo.toml | Select-String -Pattern 'version\s*=\s*"([0-9A-Za-z\.-]+)"' -AllMatches).Matches | ForEach-Object { $_.Groups[1].Value } | Select-Object -First 1 if (-not $version) { $version = '0.1.0' } - $zip = "ferrex-player_windows_${version}_$stamp.zip" + $mode = $env:FERREX_MPV_WINDOWS_PRESENTER + $zip = "ferrex-player_windows_${version}_${mode}_$stamp.zip" Compress-Archive -Path 'dist-windows/*' -DestinationPath $zip -Force "Artifact: $zip" | Write-Host (Get-FileHash $zip -Algorithm SHA256).Hash | Write-Host @@ -106,11 +226,14 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: ferrex-player-windows + name: ferrex-player-windows-${{ env.FERREX_MPV_WINDOWS_PRESENTER }} path: ${{ env.ZIP_FILE }} - name: Attach artifact to GitHub Release - if: startsWith(github.ref, 'refs/tags/v') + # The presenter spike is a retained QA artifact until representative + # hardware approval; only the disabled control may become a release + # attachment in the meantime. + if: startsWith(github.ref, 'refs/tags/v') && env.FERREX_MPV_WINDOWS_PRESENTER == 'disabled' uses: ncipollo/release-action@v1 with: allowUpdates: true diff --git a/Cargo.lock b/Cargo.lock index ddeb6c30..67776809 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2402,6 +2402,7 @@ dependencies = [ "tokio", "url", "urlencoding", + "windows-sys 0.61.2", "zeroize", ] diff --git a/crates/ferrex-player-playback/Cargo.toml b/crates/ferrex-player-playback/Cargo.toml index df226a60..df68e10b 100644 --- a/crates/ferrex-player-playback/Cargo.toml +++ b/crates/ferrex-player-playback/Cargo.toml @@ -56,3 +56,12 @@ zeroize.workspace = true urlencoding = "2" dirs = "6" profiling = { version = "1.0", optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", + "Win32_UI_HiDpi", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/crates/ferrex-player-playback/src/lib.rs b/crates/ferrex-player-playback/src/lib.rs index c6e48647..5499dd96 100644 --- a/crates/ferrex-player-playback/src/lib.rs +++ b/crates/ferrex-player-playback/src/lib.rs @@ -49,6 +49,13 @@ pub mod video; /// Playback overlay views. #[cfg(feature = "ui")] pub mod view; +/// Windows native-root/transparent-overlay presenter spike. +/// +/// The module is target-gated in normal builds. Unit tests compile its pure +/// relationship and capability model on every host so fallback behavior does +/// not require a Windows desktop. +#[cfg(any(target_os = "windows", test))] +pub mod windows_presenter; use ferrex_core::player_prelude::LibraryId; use ferrex_player_api::services::api::ApiService; diff --git a/crates/ferrex-player-playback/src/windows_presenter.rs b/crates/ferrex-player-playback/src/windows_presenter.rs new file mode 100644 index 00000000..a5e926a4 --- /dev/null +++ b/crates/ferrex-player-playback/src/windows_presenter.rs @@ -0,0 +1,1228 @@ +//! Windows native-root presenter spike and rollout capability model. +//! +//! mpv owns the video/root `HWND`; Iced owns a transparent, undecorated +//! overlay. The production gate is intentionally separate from the native +//! operations in this module: compiling the spike must not make Auto select an +//! unverified presenter. + +use std::{fmt, num::NonZeroIsize}; + +use crate::{ + contract::{ + BackendCandidate, FallbackReason, FallbackReasonCode, PlaybackError, + PlaybackErrorKind, PlaybackTarget, + }, + presenter::{ + FullscreenOwner, NativePresenter, PresenterCapabilities, + PresenterIdentity, SurfaceGeometry, + }, +}; + +/// Build-time switch used to compile developer-only Windows presenter work. +pub const WINDOWS_PRESENTER_BUILD_ENV: &str = "FERREX_MPV_WINDOWS_PRESENTER"; + +/// Whether the target contains the Windows presenter spike. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WindowsPresenterBuildMode { + /// Release-safe default. Integrated mpv is reported unavailable. + #[default] + Disabled, + /// Developer-only native relationship spike; never an Auto rollout gate. + Spike, +} + +impl WindowsPresenterBuildMode { + /// Parse the build environment value. Unknown values fail closed. + pub fn parse(value: Option<&str>) -> Result { + match value.map(str::trim) { + None | Some("") | Some("disabled") => Ok(Self::Disabled), + Some("spike") => Ok(Self::Spike), + Some(value) => { + Err(WindowsPresenterError::InvalidBuildMode(value.to_owned())) + } + } + } + + /// Mode compiled into this crate. + #[cfg(target_os = "windows")] + pub fn compiled() -> Self { + // Invalid release configuration must not silently enable integration. + Self::parse(option_env!("FERREX_MPV_WINDOWS_PRESENTER")) + .unwrap_or(Self::Disabled) + } +} + +/// Evidence available to the Windows presenter capability decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowsPresenterProbe { + pub build_mode: WindowsPresenterBuildMode, + pub mpv_window_id_observed: bool, + pub iced_overlay_handle_observed: bool, + pub dwm_composition_available: bool, + /// Set only by a reviewed decision after the manual Windows matrix passes. + pub production_gate_approved: bool, + /// Set only after HDR with the overlay visible/hidden is validated. + pub native_hdr_validated: bool, +} + +impl Default for WindowsPresenterProbe { + fn default() -> Self { + Self { + build_mode: WindowsPresenterBuildMode::Disabled, + mpv_window_id_observed: false, + iced_overlay_handle_observed: false, + dwm_composition_available: false, + production_gate_approved: false, + native_hdr_validated: false, + } + } +} + +/// Result of separating technical spike readiness from rollout approval. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WindowsPresenterAvailability { + Unavailable { + code: FallbackReasonCode, + detail: &'static str, + }, + /// Native handles and DWM are available for an explicit developer spike. + SpikeReady, + /// All code and manual rollout gates have been approved. + ProductionReady { native_hdr: bool }, +} + +impl WindowsPresenterAvailability { + /// Evaluate prerequisites in a stable order for actionable diagnostics. + pub const fn evaluate(probe: WindowsPresenterProbe) -> Self { + if matches!(probe.build_mode, WindowsPresenterBuildMode::Disabled) { + return Self::Unavailable { + code: FallbackReasonCode::MissingCapability, + detail: "the Windows integrated presenter is disabled in this build", + }; + } + if !probe.mpv_window_id_observed { + return Self::Unavailable { + code: FallbackReasonCode::MissingCapability, + detail: "mpv did not expose a live Windows window-id", + }; + } + if !probe.iced_overlay_handle_observed { + return Self::Unavailable { + code: FallbackReasonCode::MissingCapability, + detail: "Iced did not expose a live Win32 overlay handle", + }; + } + if !probe.dwm_composition_available { + return Self::Unavailable { + code: FallbackReasonCode::UnsupportedPlatform, + detail: "Windows DWM composition is unavailable for the transparent overlay", + }; + } + if !probe.production_gate_approved { + return Self::SpikeReady; + } + Self::ProductionReady { + native_hdr: probe.native_hdr_validated, + } + } + + /// Candidate consumed by the neutral backend selector. + /// + /// Spike readiness deliberately remains unavailable to Auto. It can be + /// exercised only by the dedicated spike harness until the production + /// decision is recorded. + pub const fn backend_candidate(&self) -> BackendCandidate { + match self { + Self::ProductionReady { native_hdr } => { + BackendCandidate::available( + PlaybackTarget::MPV_INTEGRATED, + *native_hdr, + ) + } + Self::Unavailable { code, .. } => BackendCandidate::unavailable( + PlaybackTarget::MPV_INTEGRATED, + *code, + ), + Self::SpikeReady => BackendCandidate::unavailable( + PlaybackTarget::MPV_INTEGRATED, + FallbackReasonCode::Policy, + ), + } + } + + /// Deterministic transition to mpv's ordinary native window. + pub fn fallback_reason(&self) -> Option { + let (code, detail) = match self { + Self::Unavailable { code, detail } => (*code, *detail), + Self::SpikeReady => ( + FallbackReasonCode::Policy, + "the Windows presenter is spike-only until its production gate passes", + ), + Self::ProductionReady { .. } => return None, + }; + Some(FallbackReason { + code, + from: Some(PlaybackTarget::MPV_INTEGRATED), + to: PlaybackTarget::MPV_NATIVE_WINDOW, + detail: detail.to_owned(), + }) + } +} + +/// Non-null Win32 window handle kept opaque outside the target adapter. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct WindowsHwnd(NonZeroIsize); + +impl WindowsHwnd { + /// Convert mpv's `window-id` property to a Win32 handle. + /// + /// mpv returns the Win32 `HWND` through an `intptr_t`, so the value must be + /// preserved at the target pointer width. Zero and values that do not fit + /// the target `isize` are rejected before any Win32 call; `IsWindow` then + /// validates the opaque handle immediately before presenter readiness. + pub fn from_mpv_window_id( + value: i64, + ) -> Result { + let pointer = + isize::try_from(value) + .ok() + .and_then(NonZeroIsize::new) + .ok_or(WindowsPresenterError::InvalidMpvWindowId(value))?; + Ok(Self(pointer)) + } + + /// Wrap a non-null raw `HWND` obtained from Iced. + pub const fn from_non_zero(value: NonZeroIsize) -> Self { + Self(value) + } + + pub const fn get(self) -> isize { + self.0.get() + } +} + +impl fmt::Debug for WindowsHwnd { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("WindowsHwnd()") + } +} + +/// Owned-window overlay is the preferred full-player spike relationship. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowsWindowRelationship { + OwnedOverlay, +} + +/// Lifecycle capabilities available before mpv has created its root HWND. +/// +/// This lets the host-side lifecycle begin in `AwaitingVideoOutput` without +/// duplicating target policy or manufacturing an invalid native handle. +pub fn windows_presenter_capabilities( + build_mode: WindowsPresenterBuildMode, +) -> PresenterCapabilities { + PresenterCapabilities { + integrated_overlay: matches!( + build_mode, + WindowsPresenterBuildMode::Spike + ), + embedded_surface: false, + // This remains false until the overlay-visible/hidden HDR hardware + // gate is recorded. Native-window mpv keeps its independent output + // capability in the fallback path. + native_hdr: false, + fractional_scaling: true, + native_window_fallback: true, + fullscreen_owner: Some(FullscreenOwner::VideoOutput), + compositor_requirement: Some( + "Windows 10+ with DWM composition".to_owned(), + ), + } +} + +/// Iced overlay handle borrowed by one attach operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowsPresenterHost { + pub overlay: WindowsHwnd, +} + +#[cfg(all(target_os = "windows", feature = "ui"))] +impl WindowsPresenterHost { + /// Extract a Win32 `HWND` from the event-loop-local Iced host lease. + pub fn from_captured_iced_host( + host: &crate::native_video_slot::CapturedIcedHost, + ) -> Result { + use iced::window::raw_window_handle::{ + HasWindowHandle, RawWindowHandle, + }; + + let raw = host.window_handle().map_err(|error| { + presenter_error(format!( + "could not borrow Iced Win32 overlay handle: {error}" + )) + })?; + let RawWindowHandle::Win32(handle) = raw.as_raw() else { + return Err(presenter_error( + "captured Iced host is not a Win32 window", + )); + }; + Ok(Self { + overlay: WindowsHwnd::from_non_zero(handle.hwnd), + }) + } +} + +/// Physical video-root client rectangle used to align the overlay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowsClientRect { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +/// Target observations retained by the spike harness. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WindowsWindowSnapshot { + pub client_rect: WindowsClientRect, + pub dpi: u32, + pub minimized: bool, +} + +impl WindowsWindowSnapshot { + pub fn scale_factor(self) -> f64 { + f64::from(self.dpi) / 96.0 + } +} + +/// Win32 operations isolated behind a fakeable function table. +pub trait WindowsWindowSystem { + fn is_window(&self, window: WindowsHwnd) -> bool; + fn owner(&self, window: WindowsHwnd) -> Option; + fn set_owner( + &mut self, + window: WindowsHwnd, + owner: Option, + ) -> Result<(), WindowsPresenterError>; + fn extended_style( + &self, + window: WindowsHwnd, + ) -> Result; + fn set_extended_style( + &mut self, + window: WindowsHwnd, + style: u32, + ) -> Result<(), WindowsPresenterError>; + fn snapshot( + &self, + video_root: WindowsHwnd, + ) -> Result; + fn position_overlay( + &mut self, + overlay: WindowsHwnd, + rect: WindowsClientRect, + ) -> Result<(), WindowsPresenterError>; + fn set_visible_without_activation( + &mut self, + overlay: WindowsHwnd, + visible: bool, + ) -> Result<(), WindowsPresenterError>; + fn activate( + &mut self, + window: WindowsHwnd, + ) -> Result<(), WindowsPresenterError>; +} + +const WS_EX_TOOLWINDOW_VALUE: u32 = 0x0000_0080; +const WS_EX_APPWINDOW_VALUE: u32 = 0x0004_0000; + +#[derive(Debug, Clone, Copy)] +struct WindowsAttachment { + identity: PresenterIdentity, + overlay: WindowsHwnd, + original_owner: Option, + original_extended_style: u32, +} + +/// UI-thread-local Windows owned-overlay presenter. +/// +/// The fullscreen callback is the intentional seam to mpv's serialized +/// control plane. The target adapter does not fake fullscreen with maximize; +/// it asks mpv and waits for the normal confirmation path. +pub struct WindowsPresenter { + windows: W, + fullscreen: F, + video_root: WindowsHwnd, + build_mode: WindowsPresenterBuildMode, + capabilities: PresenterCapabilities, + attachment: Option, + requested_visible: bool, + suspended: bool, + geometry_visible: bool, + last_snapshot: Option, + applied_visible: Option, +} + +impl fmt::Debug for WindowsPresenter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WindowsPresenter") + .field("video_root", &self.video_root) + .field("build_mode", &self.build_mode) + .field("capabilities", &self.capabilities) + .field("attachment", &self.attachment) + .field("requested_visible", &self.requested_visible) + .field("suspended", &self.suspended) + .field("geometry_visible", &self.geometry_visible) + .field("last_snapshot", &self.last_snapshot) + .field("applied_visible", &self.applied_visible) + .finish_non_exhaustive() + } +} + +impl WindowsPresenter +where + W: WindowsWindowSystem, + F: FnMut(bool) -> Result<(), PlaybackError>, +{ + pub fn new( + windows: W, + fullscreen: F, + video_root: WindowsHwnd, + build_mode: WindowsPresenterBuildMode, + ) -> Self { + Self { + windows, + fullscreen, + video_root, + build_mode, + capabilities: windows_presenter_capabilities(build_mode), + attachment: None, + requested_visible: false, + suspended: false, + geometry_visible: false, + last_snapshot: None, + applied_visible: None, + } + } + + pub const fn relationship(&self) -> WindowsWindowRelationship { + WindowsWindowRelationship::OwnedOverlay + } + + pub const fn last_snapshot(&self) -> Option { + self.last_snapshot + } + + /// Explicit focus handoff used by the overlay input policy spike. + pub fn focus_video_root(&mut self) -> Result<(), PlaybackError> { + self.windows + .activate(self.video_root) + .map_err(PlaybackError::from) + } + + fn ensure_identity( + &self, + identity: PresenterIdentity, + ) -> Result { + match self.attachment { + Some(attachment) if attachment.identity == identity => { + Ok(attachment) + } + Some(_) => Err(presenter_error( + "Windows presenter rejected a stale attachment generation", + )), + None => Err(presenter_error("Windows presenter is not attached")), + } + } + + fn refresh_position_and_visibility( + &mut self, + attachment: WindowsAttachment, + ) -> Result<(), PlaybackError> { + if !self.windows.is_window(self.video_root) + || !self.windows.is_window(attachment.overlay) + { + return Err(presenter_error( + "Windows presenter HWND was destroyed before synchronization", + )); + } + let snapshot = self.windows.snapshot(self.video_root)?; + if self.last_snapshot.map(|last| last.client_rect) + != Some(snapshot.client_rect) + { + self.windows + .position_overlay(attachment.overlay, snapshot.client_rect)?; + } + let visible = self.requested_visible + && !self.suspended + && self.geometry_visible + && !snapshot.minimized; + if self.applied_visible != Some(visible) { + self.windows + .set_visible_without_activation(attachment.overlay, visible)?; + self.applied_visible = Some(visible); + } + self.last_snapshot = Some(snapshot); + Ok(()) + } + + fn restore_attachment(&mut self, attachment: WindowsAttachment) { + if !self.windows.is_window(attachment.overlay) { + return; + } + let _ = self + .windows + .set_visible_without_activation(attachment.overlay, false); + self.applied_visible = Some(false); + let _ = self + .windows + .set_owner(attachment.overlay, attachment.original_owner); + let _ = self.windows.set_extended_style( + attachment.overlay, + attachment.original_extended_style, + ); + } +} + +impl NativePresenter for WindowsPresenter +where + W: WindowsWindowSystem + 'static, + F: FnMut(bool) -> Result<(), PlaybackError> + 'static, +{ + type Host<'host> + = WindowsPresenterHost + where + Self: 'host; + + fn attach( + &mut self, + identity: PresenterIdentity, + host: Self::Host<'_>, + ) -> Result<(), PlaybackError> { + if !matches!(self.build_mode, WindowsPresenterBuildMode::Spike) { + return Err(presenter_error( + "Windows integrated presenter is disabled in this build", + )); + } + if self.attachment.is_some() { + return Err(presenter_error( + "Windows presenter attach was requested more than once", + )); + } + if host.overlay == self.video_root + || !self.windows.is_window(host.overlay) + || !self.windows.is_window(self.video_root) + { + return Err(presenter_error( + "Windows presenter received invalid or identical HWNDs", + )); + } + + let attachment = WindowsAttachment { + identity, + overlay: host.overlay, + original_owner: self.windows.owner(host.overlay), + original_extended_style: self + .windows + .extended_style(host.overlay)?, + }; + + // Hide first, then establish ownership and task-switcher identity. + self.windows + .set_visible_without_activation(host.overlay, false)?; + self.applied_visible = Some(false); + self.windows + .set_owner(host.overlay, Some(self.video_root))?; + let overlay_style = (attachment.original_extended_style + | WS_EX_TOOLWINDOW_VALUE) + & !WS_EX_APPWINDOW_VALUE; + if let Err(error) = + self.windows.set_extended_style(host.overlay, overlay_style) + { + self.restore_attachment(attachment); + return Err(error.into()); + } + + self.attachment = Some(attachment); + if let Err(error) = self.refresh_position_and_visibility(attachment) { + self.attachment = None; + self.restore_attachment(attachment); + return Err(error); + } + Ok(()) + } + + fn synchronize( + &mut self, + identity: PresenterIdentity, + geometry: SurfaceGeometry, + ) -> Result<(), PlaybackError> { + geometry.validate().map_err(|error| { + presenter_error(format!( + "Windows presenter rejected host geometry: {error}" + )) + })?; + let attachment = self.ensure_identity(identity)?; + self.geometry_visible = geometry.is_visible(); + self.refresh_position_and_visibility(attachment) + } + + fn set_visible( + &mut self, + identity: PresenterIdentity, + visible: bool, + ) -> Result<(), PlaybackError> { + let attachment = self.ensure_identity(identity)?; + self.requested_visible = visible; + self.refresh_position_and_visibility(attachment) + } + + fn set_suspended( + &mut self, + identity: PresenterIdentity, + suspended: bool, + ) -> Result<(), PlaybackError> { + let attachment = self.ensure_identity(identity)?; + self.suspended = suspended; + self.refresh_position_and_visibility(attachment) + } + + fn set_fullscreen( + &mut self, + identity: PresenterIdentity, + owner: FullscreenOwner, + fullscreen: bool, + ) -> Result<(), PlaybackError> { + let _ = self.ensure_identity(identity)?; + if owner != FullscreenOwner::VideoOutput { + return Err(presenter_error( + "Windows native-root mode requires mpv to own fullscreen", + )); + } + (self.fullscreen)(fullscreen) + } + + fn detach(&mut self, identity: PresenterIdentity) { + let Some(attachment) = self.attachment else { + return; + }; + if attachment.identity != identity { + return; + } + self.attachment = None; + self.requested_visible = false; + self.geometry_visible = false; + self.last_snapshot = None; + self.restore_attachment(attachment); + } + + fn capabilities(&self) -> &PresenterCapabilities { + &self.capabilities + } +} + +/// Target-native Win32 implementation. All calls remain on the Iced event-loop +/// thread through the presenter's non-`Send` lifecycle. +#[cfg(target_os = "windows")] +#[derive(Debug, Default)] +pub struct Win32WindowSystem; + +#[cfg(target_os = "windows")] +impl Win32WindowSystem { + /// Query the DWM composition prerequisite before advertising spike + /// readiness. The probe is intentionally separate from construction so a + /// failed platform check can fall back without touching either HWND. + pub fn composition_available() -> Result { + let mut enabled = 0; + // SAFETY: DwmIsCompositionEnabled only initializes the stack BOOL. + let result = unsafe { + windows_sys::Win32::Graphics::Dwm::DwmIsCompositionEnabled( + &mut enabled, + ) + }; + if result < 0 { + return Err(WindowsPresenterError::Win32 { + operation: "DwmIsCompositionEnabled", + code: result as u32, + }); + } + Ok(enabled != 0) + } + + /// Validate an observed mpv HWND immediately before announcing video + /// output readiness to the lifecycle. + pub fn is_live(&self, window: WindowsHwnd) -> bool { + // SAFETY: IsWindow validates the opaque value without dereferencing it. + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::IsWindow(Self::raw( + window, + )) != 0 + } + } + + fn raw(window: WindowsHwnd) -> windows_sys::Win32::Foundation::HWND { + window.get() as windows_sys::Win32::Foundation::HWND + } + + fn last_error(operation: &'static str) -> WindowsPresenterError { + // SAFETY: GetLastError has no preconditions. + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + WindowsPresenterError::Win32 { operation, code } + } +} + +#[cfg(target_os = "windows")] +impl WindowsWindowSystem for Win32WindowSystem { + fn is_window(&self, window: WindowsHwnd) -> bool { + self.is_live(window) + } + + fn owner(&self, window: WindowsHwnd) -> Option { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GW_OWNER, GetWindow, + }; + // SAFETY: the caller already validated the HWND and GetWindow borrows it. + let owner = unsafe { GetWindow(Self::raw(window), GW_OWNER) } as isize; + NonZeroIsize::new(owner).map(WindowsHwnd::from_non_zero) + } + + fn set_owner( + &mut self, + window: WindowsHwnd, + owner: Option, + ) -> Result<(), WindowsPresenterError> { + use windows_sys::Win32::{ + Foundation::{ERROR_SUCCESS, GetLastError, SetLastError}, + UI::WindowsAndMessaging::{GWLP_HWNDPARENT, SetWindowLongPtrW}, + }; + // SAFETY: SetWindowLongPtrW updates only the owner slot of a live HWND. + unsafe { + SetLastError(ERROR_SUCCESS); + let previous = SetWindowLongPtrW( + Self::raw(window), + GWLP_HWNDPARENT, + owner.map_or(0, WindowsHwnd::get), + ); + if previous == 0 && GetLastError() != ERROR_SUCCESS { + return Err(Self::last_error("SetWindowLongPtrW(owner)")); + } + } + Ok(()) + } + + fn extended_style( + &self, + window: WindowsHwnd, + ) -> Result { + use windows_sys::Win32::{ + Foundation::{ERROR_SUCCESS, GetLastError, SetLastError}, + UI::WindowsAndMessaging::{GWL_EXSTYLE, GetWindowLongPtrW}, + }; + // SAFETY: reads the style word of a validated HWND. + let style = unsafe { + SetLastError(ERROR_SUCCESS); + let style = GetWindowLongPtrW(Self::raw(window), GWL_EXSTYLE); + if style == 0 && GetLastError() != ERROR_SUCCESS { + return Err(Self::last_error("GetWindowLongPtrW(exstyle)")); + } + style + }; + Ok(style as u32) + } + + fn set_extended_style( + &mut self, + window: WindowsHwnd, + style: u32, + ) -> Result<(), WindowsPresenterError> { + use windows_sys::Win32::{ + Foundation::{ERROR_SUCCESS, GetLastError, SetLastError}, + UI::WindowsAndMessaging::{GWL_EXSTYLE, SetWindowLongPtrW}, + }; + // SAFETY: updates the style word of a validated HWND. + unsafe { + SetLastError(ERROR_SUCCESS); + let previous = SetWindowLongPtrW( + Self::raw(window), + GWL_EXSTYLE, + style as isize, + ); + if previous == 0 && GetLastError() != ERROR_SUCCESS { + return Err(Self::last_error("SetWindowLongPtrW(exstyle)")); + } + } + use windows_sys::Win32::UI::WindowsAndMessaging::{ + SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + SWP_NOZORDER, SetWindowPos, + }; + // Extended style data may be cached. Force the non-client/task-switcher + // state to observe the new APPWINDOW/TOOLWINDOW bits without moving or + // activating the overlay. + // SAFETY: updates only cached frame state for the same validated HWND. + if unsafe { + SetWindowPos( + Self::raw(window), + std::ptr::null_mut(), + 0, + 0, + 0, + 0, + SWP_FRAMECHANGED + | SWP_NOACTIVATE + | SWP_NOMOVE + | SWP_NOSIZE + | SWP_NOZORDER, + ) + } == 0 + { + return Err(Self::last_error("SetWindowPos(frame change)")); + } + Ok(()) + } + + fn snapshot( + &self, + video_root: WindowsHwnd, + ) -> Result { + use windows_sys::Win32::{ + Foundation::{POINT, RECT}, + Graphics::Gdi::ClientToScreen, + UI::{ + HiDpi::GetDpiForWindow, + WindowsAndMessaging::{GetClientRect, IsIconic}, + }, + }; + let hwnd = Self::raw(video_root); + let mut rect = RECT::default(); + // SAFETY: all pointers refer to initialized stack values for the call. + if unsafe { GetClientRect(hwnd, &mut rect) } == 0 { + return Err(Self::last_error("GetClientRect")); + } + let mut top_left = POINT { + x: rect.left, + y: rect.top, + }; + let mut bottom_right = POINT { + x: rect.right, + y: rect.bottom, + }; + // SAFETY: same live HWND and stack values as above. + if unsafe { ClientToScreen(hwnd, &mut top_left) } == 0 + || unsafe { ClientToScreen(hwnd, &mut bottom_right) } == 0 + { + return Err(Self::last_error("ClientToScreen")); + } + // SAFETY: GetDpiForWindow and IsIconic only inspect the live HWND. + let dpi = unsafe { GetDpiForWindow(hwnd) }; + if dpi == 0 { + return Err(Self::last_error("GetDpiForWindow")); + } + Ok(WindowsWindowSnapshot { + client_rect: WindowsClientRect { + x: top_left.x, + y: top_left.y, + width: bottom_right.x.saturating_sub(top_left.x), + height: bottom_right.y.saturating_sub(top_left.y), + }, + dpi, + // SAFETY: same inspection-only contract. + minimized: unsafe { IsIconic(hwnd) != 0 }, + }) + } + + fn position_overlay( + &mut self, + overlay: WindowsHwnd, + rect: WindowsClientRect, + ) -> Result<(), WindowsPresenterError> { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + HWND_TOP, SWP_NOACTIVATE, SWP_NOOWNERZORDER, SetWindowPos, + }; + // SAFETY: moves a validated overlay using value-only coordinates. + if unsafe { + SetWindowPos( + Self::raw(overlay), + HWND_TOP, + rect.x, + rect.y, + rect.width.max(0), + rect.height.max(0), + SWP_NOACTIVATE | SWP_NOOWNERZORDER, + ) + } == 0 + { + return Err(Self::last_error("SetWindowPos")); + } + Ok(()) + } + + fn set_visible_without_activation( + &mut self, + overlay: WindowsHwnd, + visible: bool, + ) -> Result<(), WindowsPresenterError> { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + SW_HIDE, SW_SHOWNOACTIVATE, ShowWindow, + }; + // ShowWindow's return value is previous visibility, not success. + // SAFETY: the caller validates and owns the overlay lifecycle. + unsafe { + ShowWindow( + Self::raw(overlay), + if visible { SW_SHOWNOACTIVATE } else { SW_HIDE }, + ); + } + Ok(()) + } + + fn activate( + &mut self, + window: WindowsHwnd, + ) -> Result<(), WindowsPresenterError> { + use windows_sys::Win32::UI::WindowsAndMessaging::SetForegroundWindow; + // SAFETY: activates a validated top-level HWND. + if unsafe { SetForegroundWindow(Self::raw(window)) } == 0 { + return Err(Self::last_error("SetForegroundWindow")); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum WindowsPresenterError { + #[error("invalid {WINDOWS_PRESENTER_BUILD_ENV} value: {0}")] + InvalidBuildMode(String), + #[error("mpv returned an invalid Windows window-id: {0}")] + InvalidMpvWindowId(i64), + #[error("Win32 {operation} failed with error {code}")] + Win32 { operation: &'static str, code: u32 }, + #[error("Windows presenter operation failed: {0}")] + Operation(String), +} + +impl From for PlaybackError { + fn from(error: WindowsPresenterError) -> Self { + presenter_error(error.to_string()) + } +} + +fn presenter_error(message: impl Into) -> PlaybackError { + let mut error = PlaybackError::new(PlaybackErrorKind::Presenter, message); + error.backend = Some(crate::contract::BackendKind::Mpv); + error.recoverable = true; + error +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, collections::HashMap, rc::Rc}; + + use crate::{ + contract::{GeometryRevision, LogicalRect, SessionGeneration}, + presenter::{FullscreenOwner, NativePresenter, PresenterGeneration}, + }; + + use super::*; + + fn hwnd(value: isize) -> WindowsHwnd { + WindowsHwnd::from_non_zero(NonZeroIsize::new(value).unwrap()) + } + + fn identity(value: u64) -> PresenterIdentity { + PresenterIdentity::new( + SessionGeneration::new(value), + PresenterGeneration::new(value), + ) + } + + fn geometry(visible: bool) -> SurfaceGeometry { + SurfaceGeometry::new( + GeometryRevision::INITIAL, + LogicalRect::new(0.0, 0.0, 1920.0, 1080.0), + visible.then(|| LogicalRect::new(0.0, 0.0, 1920.0, 1080.0)), + 1.5, + ) + } + + #[derive(Debug, Clone)] + struct WindowState { + live: bool, + owner: Option, + style: u32, + visible: bool, + } + + #[derive(Debug, Clone)] + struct FakeWindows { + state: Rc>>, + operations: Rc>>, + snapshot: Rc>, + } + + impl FakeWindows { + fn new(video: WindowsHwnd, overlay: WindowsHwnd) -> Self { + let state = HashMap::from([ + ( + video, + WindowState { + live: true, + owner: None, + style: 0, + visible: true, + }, + ), + ( + overlay, + WindowState { + live: true, + owner: None, + style: WS_EX_APPWINDOW_VALUE, + visible: false, + }, + ), + ]); + Self { + state: Rc::new(RefCell::new(state)), + operations: Rc::new(RefCell::new(Vec::new())), + snapshot: Rc::new(RefCell::new(WindowsWindowSnapshot { + client_rect: WindowsClientRect { + x: 10, + y: 20, + width: 1280, + height: 720, + }, + dpi: 144, + minimized: false, + })), + } + } + + fn operation(&self, value: impl Into) { + self.operations.borrow_mut().push(value.into()); + } + } + + impl WindowsWindowSystem for FakeWindows { + fn is_window(&self, window: WindowsHwnd) -> bool { + self.state + .borrow() + .get(&window) + .is_some_and(|state| state.live) + } + + fn owner(&self, window: WindowsHwnd) -> Option { + self.state + .borrow() + .get(&window) + .and_then(|state| state.owner) + } + + fn set_owner( + &mut self, + window: WindowsHwnd, + owner: Option, + ) -> Result<(), WindowsPresenterError> { + self.operation(format!("owner:{:?}", owner.map(WindowsHwnd::get))); + self.state.borrow_mut().get_mut(&window).unwrap().owner = owner; + Ok(()) + } + + fn extended_style( + &self, + window: WindowsHwnd, + ) -> Result { + Ok(self.state.borrow()[&window].style) + } + + fn set_extended_style( + &mut self, + window: WindowsHwnd, + style: u32, + ) -> Result<(), WindowsPresenterError> { + self.operation(format!("style:{style:#x}")); + self.state.borrow_mut().get_mut(&window).unwrap().style = style; + Ok(()) + } + + fn snapshot( + &self, + _video_root: WindowsHwnd, + ) -> Result { + Ok(*self.snapshot.borrow()) + } + + fn position_overlay( + &mut self, + _overlay: WindowsHwnd, + rect: WindowsClientRect, + ) -> Result<(), WindowsPresenterError> { + self.operation(format!( + "position:{},{},{},{}", + rect.x, rect.y, rect.width, rect.height + )); + Ok(()) + } + + fn set_visible_without_activation( + &mut self, + overlay: WindowsHwnd, + visible: bool, + ) -> Result<(), WindowsPresenterError> { + self.operation(format!("visible:{visible}")); + self.state.borrow_mut().get_mut(&overlay).unwrap().visible = + visible; + Ok(()) + } + + fn activate( + &mut self, + window: WindowsHwnd, + ) -> Result<(), WindowsPresenterError> { + self.operation(format!("activate:{}", window.get())); + Ok(()) + } + } + + #[test] + fn build_mode_and_mpv_window_id_fail_closed() { + assert_eq!( + WindowsPresenterBuildMode::parse(None).unwrap(), + WindowsPresenterBuildMode::Disabled + ); + assert_eq!( + WindowsPresenterBuildMode::parse(Some("spike")).unwrap(), + WindowsPresenterBuildMode::Spike + ); + assert!(WindowsPresenterBuildMode::parse(Some("production")).is_err()); + assert!(WindowsHwnd::from_mpv_window_id(0).is_err()); + assert_eq!(WindowsHwnd::from_mpv_window_id(42).unwrap().get(), 42); + #[cfg(target_pointer_width = "64")] + { + let wide = i64::from(u32::MAX) + 1; + assert_eq!( + WindowsHwnd::from_mpv_window_id(wide).unwrap().get(), + wide as isize + ); + } + } + + #[test] + fn capability_requires_each_probe_and_keeps_spike_out_of_auto() { + let probe = WindowsPresenterProbe { + build_mode: WindowsPresenterBuildMode::Spike, + mpv_window_id_observed: true, + iced_overlay_handle_observed: true, + dwm_composition_available: true, + production_gate_approved: false, + native_hdr_validated: false, + }; + let availability = WindowsPresenterAvailability::evaluate(probe); + assert_eq!(availability, WindowsPresenterAvailability::SpikeReady); + assert!(!availability.backend_candidate().available); + let fallback = availability.fallback_reason().unwrap(); + assert_eq!(fallback.code, FallbackReasonCode::Policy); + assert_eq!(fallback.to, PlaybackTarget::MPV_NATIVE_WINDOW); + + let production = + WindowsPresenterAvailability::evaluate(WindowsPresenterProbe { + production_gate_approved: true, + native_hdr_validated: true, + ..probe + }); + assert_eq!( + production.backend_candidate(), + BackendCandidate::available(PlaybackTarget::MPV_INTEGRATED, true) + ); + assert!(production.fallback_reason().is_none()); + } + + #[test] + fn owned_overlay_attaches_hidden_positions_and_restores_on_detach() { + let video = hwnd(10); + let overlay = hwnd(20); + let windows = FakeWindows::new(video, overlay); + let observed = windows.clone(); + let fullscreen_values = Rc::new(RefCell::new(Vec::new())); + let fullscreen_values_for_callback = Rc::clone(&fullscreen_values); + let mut presenter = WindowsPresenter::new( + windows, + move |fullscreen| { + fullscreen_values_for_callback.borrow_mut().push(fullscreen); + Ok(()) + }, + video, + WindowsPresenterBuildMode::Spike, + ); + let id = identity(1); + presenter + .attach(id, WindowsPresenterHost { overlay }) + .unwrap(); + + let state = observed.state.borrow(); + assert_eq!(state[&overlay].owner, Some(video)); + assert_eq!(state[&overlay].style, WS_EX_TOOLWINDOW_VALUE); + assert!(!state[&overlay].visible); + drop(state); + + presenter.synchronize(id, geometry(true)).unwrap(); + presenter.set_visible(id, true).unwrap(); + assert!(observed.state.borrow()[&overlay].visible); + assert_eq!(presenter.last_snapshot().unwrap().dpi, 144); + assert_eq!(presenter.last_snapshot().unwrap().scale_factor(), 1.5); + let operation_count = observed.operations.borrow().len(); + presenter.synchronize(id, geometry(true)).unwrap(); + assert_eq!(observed.operations.borrow().len(), operation_count); + presenter + .set_fullscreen(id, FullscreenOwner::VideoOutput, true) + .unwrap(); + assert_eq!(&*fullscreen_values.borrow(), &[true]); + + presenter.detach(id); + let state = observed.state.borrow(); + assert_eq!(state[&overlay].owner, None); + assert_eq!(state[&overlay].style, WS_EX_APPWINDOW_VALUE); + assert!(!state[&overlay].visible); + let operations = observed.operations.borrow(); + let owner_index = operations + .iter() + .position(|operation| operation == "owner:Some(10)") + .unwrap(); + let first_show_index = operations + .iter() + .position(|operation| operation == "visible:true") + .unwrap(); + assert!(owner_index < first_show_index); + } + + #[test] + fn minimize_and_stale_detach_do_not_break_live_attachment() { + let video = hwnd(30); + let overlay = hwnd(40); + let windows = FakeWindows::new(video, overlay); + let observed = windows.clone(); + let mut presenter = WindowsPresenter::new( + windows, + |_| Ok(()), + video, + WindowsPresenterBuildMode::Spike, + ); + let id = identity(2); + presenter + .attach(id, WindowsPresenterHost { overlay }) + .unwrap(); + presenter.synchronize(id, geometry(true)).unwrap(); + presenter.set_visible(id, true).unwrap(); + let previous_snapshot = *observed.snapshot.borrow(); + *observed.snapshot.borrow_mut() = WindowsWindowSnapshot { + minimized: true, + ..previous_snapshot + }; + presenter.synchronize(id, geometry(true)).unwrap(); + assert!(!observed.state.borrow()[&overlay].visible); + + presenter.detach(identity(99)); + assert_eq!(observed.state.borrow()[&overlay].owner, Some(video)); + presenter.detach(id); + assert_eq!(observed.state.borrow()[&overlay].owner, None); + } +} diff --git a/crates/ferrexctl/src/bin/ferrexctl.rs b/crates/ferrexctl/src/bin/ferrexctl.rs index f858ea67..273c4bed 100644 --- a/crates/ferrexctl/src/bin/ferrexctl.rs +++ b/crates/ferrexctl/src/bin/ferrexctl.rs @@ -205,6 +205,8 @@ enum PackageAction { profile: String, #[arg(long, help = "Override GStreamer root path")] gst_root: Option, + #[arg(long, help = "Pinned LGPL libmpv SDK root (or set LIBMPV_ROOT)")] + libmpv_root: Option, #[arg(long, help = "Output directory for zip")] out: Option, #[arg(long, help = "Show what would be done without executing")] @@ -870,6 +872,7 @@ async fn main() -> Result<()> { target, profile, gst_root, + libmpv_root, out, dry_run, } => { @@ -877,6 +880,7 @@ async fn main() -> Result<()> { &target, &profile, gst_root.as_deref(), + libmpv_root.as_deref(), out.as_deref(), dry_run, ) diff --git a/crates/ferrexctl/src/cli/package/mod.rs b/crates/ferrexctl/src/cli/package/mod.rs index 46732b93..76c188b7 100644 --- a/crates/ferrexctl/src/cli/package/mod.rs +++ b/crates/ferrexctl/src/cli/package/mod.rs @@ -1287,6 +1287,219 @@ fn validate_gstreamer_root(gst_root: &Path) -> Result<()> { Ok(()) } +fn locate_libmpv_root(override_path: Option<&Path>) -> Result { + if let Some(path) = override_path { + return Ok(path.to_path_buf()); + } + if let Ok(path) = std::env::var("LIBMPV_ROOT") + && !path.is_empty() + { + return Ok(PathBuf::from(path)); + } + bail!( + "Windows native-mpv packaging requires the pinned LGPL SDK. Provide \ + --libmpv-root or set LIBMPV_ROOT" + ) +} + +const REQUIRED_LIBMPV_PROFILE: &[&str] = &[ + "mpv=0.41.0", + "mpv_commit=41f6a645068483470267271e1d09966ca3b9f413", + "client_api=2.5", + "gpl=false", + "libmpv=true", + "gpu_next=true", + "d3d11=true", + "direct3d=false", + "d3d_hwaccel=true", + "d3d9_hwaccel=true", + "wasapi=true", + "gl=false", + "vulkan=false", + "lua=luajit", + "ffmpeg=8.1.2", + "ffmpeg_commit=38b88335f99e76ed89ff3c93f877fdefce736c13", + "ffmpeg_gpl=false", + "ffmpeg_nonfree=false", + "ffmpeg_version3=false", + "libass=0.17.4", + "libass_commit=bbb3c7f1570a4a021e52683f3fbdf74fe492ae84", + "libplacebo=7.360.1", + "libplacebo_commit=cee9b076f2c63104ccfd497fa79c39a867293ec4", + "luajit_commit=b411bec3ce550ef9968fc83bca094455cf812c1f", + "toolchain=msys2-ucrt64", +]; + +#[derive(Debug)] +struct WindowsLibmpvSdk { + root: PathBuf, + runtime_dll: PathBuf, + license_root: PathBuf, + runtime_dlls: Vec, +} + +fn validate_libmpv_root(root: &Path, target: &str) -> Result { + let include = root.join("include").join("mpv").join("client.h"); + if !include.is_file() { + bail!("libmpv SDK is missing header: {}", include.display()); + } + let import = if detect_windows_flavor(target)? == "msvc" { + root.join("lib").join("mpv.lib") + } else { + root.join("lib").join("libmpv.dll.a") + }; + if !import.is_file() { + bail!( + "libmpv SDK is missing the target import library: {}", + import.display() + ); + } + + let bin = root.join("bin"); + let runtime_dll = ["libmpv-2.dll", "mpv-2.dll", "mpv.dll"] + .into_iter() + .map(|name| bin.join(name)) + .find(|path| path.is_file()) + .with_context(|| { + format!( + "libmpv SDK has no versioned runtime DLL in {}", + bin.display() + ) + })?; + let license_root = + root.join("share").join("licenses").join("ferrex-libmpv"); + let profile_path = license_root.join("BUILD_PROFILE"); + let profile = fs::read_to_string(&profile_path).with_context(|| { + format!("missing libmpv build profile: {}", profile_path.display()) + })?; + let profile_lines: std::collections::HashSet<&str> = + profile.lines().map(str::trim).collect(); + for required in REQUIRED_LIBMPV_PROFILE { + if !profile_lines.contains(required) { + bail!( + "libmpv BUILD_PROFILE is missing required assertion `{required}`" + ); + } + } + for notice in [ + "mpv/LICENSE.LGPL", + "ffmpeg/COPYING.LGPLv2.1", + "ffmpeg/LICENSE.md", + "libass/COPYING", + "libplacebo/LICENSE", + "luajit/COPYRIGHT", + "runtime-packages/MANIFEST", + ] { + let path = license_root.join(notice); + if !path.is_file() { + bail!("libmpv SDK is missing notice: {}", path.display()); + } + } + + let hashes_path = license_root.join("RUNTIME_DLLS.sha256"); + let hashes = fs::read_to_string(&hashes_path).with_context(|| { + format!("missing libmpv DLL manifest: {}", hashes_path.display()) + })?; + let mut runtime_dlls = Vec::new(); + let mut manifested = std::collections::HashSet::new(); + for (index, line) in hashes.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let mut fields = line.split_whitespace(); + let expected = fields.next().unwrap_or_default(); + let name = fields.next().unwrap_or_default().trim_start_matches('*'); + if fields.next().is_some() + || expected.len() != 64 + || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) + || Path::new(name).file_name() != Some(OsStr::new(name)) + || !name.to_ascii_lowercase().ends_with(".dll") + { + bail!("invalid libmpv DLL manifest entry on line {}", index + 1); + } + let dll = bin.join(name); + if !dll.is_file() { + bail!("manifested libmpv dependency is missing: {}", dll.display()); + } + let actual = compute_sha256(&dll)?; + if !actual.eq_ignore_ascii_case(expected) { + bail!("libmpv dependency hash mismatch: {}", dll.display()); + } + let normalized = name.to_ascii_lowercase(); + if !manifested.insert(normalized) { + bail!("duplicate libmpv DLL manifest entry: {name}"); + } + runtime_dlls.push(dll); + } + if runtime_dlls.is_empty() { + bail!("libmpv DLL manifest is empty"); + } + + for entry in fs::read_dir(&bin)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() + && path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("dll")) + { + let name = path + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_ascii_lowercase(); + if !manifested.contains(&name) { + bail!("unmanifested DLL in libmpv SDK: {}", path.display()); + } + } + } + + Ok(WindowsLibmpvSdk { + root: root.to_path_buf(), + runtime_dll, + license_root, + runtime_dlls, + }) +} + +fn copy_file_without_collision( + source: &Path, + destination: &Path, +) -> Result<()> { + if destination.is_file() { + if compute_sha256(source)? != compute_sha256(destination)? { + bail!( + "conflicting Windows runtime DLLs share the name {}", + destination.display() + ); + } + return Ok(()); + } + fs::copy(source, destination).with_context(|| { + format!( + "failed to copy {} to {}", + source.display(), + destination.display() + ) + })?; + Ok(()) +} + +fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + if source_path.is_dir() { + copy_dir_recursive(&source_path, &destination_path)?; + } else if source_path.is_file() { + fs::copy(&source_path, &destination_path)?; + } + } + Ok(()) +} + fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> { fs::create_dir_all(dst)?; @@ -1343,17 +1556,21 @@ pub async fn package_windows( target: &str, profile: &str, gst_root_override: Option<&Path>, + libmpv_root_override: Option<&Path>, out_dir: Option<&Path>, dry_run: bool, ) -> Result<()> { let flavor = detect_windows_flavor(target)?; let gst_root = locate_gstreamer_root(target, gst_root_override)?; + let libmpv_root = locate_libmpv_root(libmpv_root_override)?; + let libmpv = validate_libmpv_root(&libmpv_root, target)?; println!("Windows packaging configuration:"); println!(" Target: {}", target); println!(" Profile: {}", profile); println!(" Flavor: {}", flavor); println!(" GStreamer root: {}", gst_root.display()); + println!(" libmpv SDK root: {}", libmpv.root.display()); if dry_run { println!(" Mode: dry-run"); } @@ -1378,6 +1595,10 @@ pub async fn package_windows( let stage_dir = workspace.join("dist-windows"); let bin_dir = stage_dir.join("bin"); let plugins_dir = stage_dir.join("lib").join("gstreamer-1.0"); + let libmpv_license_dir = stage_dir + .join("share") + .join("licenses") + .join("ferrex-libmpv"); let build_windows_dir = workspace.join("utils").join("build-windows"); if dry_run { @@ -1412,6 +1633,17 @@ pub async fn package_windows( gst_root.display(), plugins_dir.display() ); + println!( + " - {} and {} hashed dependency DLLs → {}/", + libmpv.runtime_dll.display(), + libmpv.runtime_dlls.len().saturating_sub(1), + bin_dir.display() + ); + println!( + " - {} → {}/", + libmpv.license_root.display(), + libmpv_license_dir.display() + ); } else { if stage_dir.exists() { fs::remove_dir_all(&stage_dir)?; @@ -1438,6 +1670,13 @@ pub async fn package_windows( &gst_root.join("lib").join("gstreamer-1.0"), &plugins_dir, )?; + for dll in &libmpv.runtime_dlls { + let name = dll.file_name().with_context(|| { + format!("invalid libmpv dependency path: {}", dll.display()) + })?; + copy_file_without_collision(dll, &bin_dir.join(name))?; + } + copy_dir_recursive(&libmpv.license_root, &libmpv_license_dir)?; println!("Staged distribution at: {}", stage_dir.display()); } @@ -1463,3 +1702,88 @@ pub async fn package_windows( Ok(()) } + +#[cfg(test)] +mod windows_package_tests { + use super::*; + + fn write_test_sdk(root: &Path, target: &str) { + let bin = root.join("bin"); + let lib = root.join("lib"); + let include = root.join("include/mpv"); + let licenses = root.join("share/licenses/ferrex-libmpv"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(&lib).unwrap(); + fs::create_dir_all(&include).unwrap(); + for directory in [ + "mpv", + "ffmpeg", + "libass", + "libplacebo", + "luajit", + "runtime-packages", + ] { + fs::create_dir_all(licenses.join(directory)).unwrap(); + } + fs::write( + include.join("client.h"), + "#define MPV_CLIENT_API_VERSION 0x00020005\n", + ) + .unwrap(); + let import = if target.contains("msvc") { + "mpv.lib" + } else { + "libmpv.dll.a" + }; + fs::write(lib.join(import), b"import").unwrap(); + let dll = bin.join("libmpv-2.dll"); + fs::write(&dll, b"runtime").unwrap(); + fs::write( + licenses.join("BUILD_PROFILE"), + REQUIRED_LIBMPV_PROFILE.join("\n"), + ) + .unwrap(); + for notice in [ + "mpv/LICENSE.LGPL", + "ffmpeg/COPYING.LGPLv2.1", + "ffmpeg/LICENSE.md", + "libass/COPYING", + "libplacebo/LICENSE", + "luajit/COPYRIGHT", + "runtime-packages/MANIFEST", + ] { + fs::write(licenses.join(notice), b"notice").unwrap(); + } + fs::write( + licenses.join("RUNTIME_DLLS.sha256"), + format!("{} libmpv-2.dll\n", compute_sha256(&dll).unwrap()), + ) + .unwrap(); + } + + #[test] + fn validates_exact_windows_libmpv_sdk_contract() { + let temp = tempfile::tempdir().unwrap(); + write_test_sdk(temp.path(), "x86_64-pc-windows-msvc"); + let sdk = validate_libmpv_root(temp.path(), "x86_64-pc-windows-msvc") + .unwrap(); + assert_eq!(sdk.runtime_dlls.len(), 1); + assert_eq!(sdk.runtime_dll.file_name().unwrap(), "libmpv-2.dll"); + } + + #[test] + fn rejects_tampered_or_unmanifested_runtime_dll() { + let temp = tempfile::tempdir().unwrap(); + write_test_sdk(temp.path(), "x86_64-pc-windows-gnu"); + fs::write(temp.path().join("bin/libmpv-2.dll"), b"tampered").unwrap(); + assert!( + validate_libmpv_root(temp.path(), "x86_64-pc-windows-gnu").is_err() + ); + + write_test_sdk(temp.path(), "x86_64-pc-windows-gnu"); + fs::write(temp.path().join("bin/untracked.dll"), b"extra").unwrap(); + assert!( + validate_libmpv_root(temp.path(), "x86_64-pc-windows-gnu").is_err() + ); + } +} diff --git a/crates/ferrexctl/tests/e2e_package_commands.rs b/crates/ferrexctl/tests/e2e_package_commands.rs index e79c3b22..f1b7f4ff 100644 --- a/crates/ferrexctl/tests/e2e_package_commands.rs +++ b/crates/ferrexctl/tests/e2e_package_commands.rs @@ -357,6 +357,7 @@ fn test_windows_help() { .stdout(predicates::str::contains("--target")) .stdout(predicates::str::contains("--profile")) .stdout(predicates::str::contains("--gst-root")) + .stdout(predicates::str::contains("--libmpv-root")) .stdout(predicates::str::contains("--out")) .stdout(predicates::str::contains("--dry-run")); } diff --git a/utils/build-windows/README.txt b/utils/build-windows/README.txt index fb6aebe0..c8fb0736 100644 --- a/utils/build-windows/README.txt +++ b/utils/build-windows/README.txt @@ -21,5 +21,16 @@ Advanced Usage: - Set GST_DEBUG=3 for GStreamer debugging - Check logs in %APPDATA%\ferrex-player\ +Native mpv presenter status: +- The package's presenter mode is recorded in + share\ferrex-player\PRESENTER_BUILD_MODE +- "spike" contains the developer-only Win32 owned-overlay presenter; "disabled" + is the control/release build and falls back without attempting attachment +- The production/Auto gate remains disabled until the Windows hardware, HDR, + focus, fullscreen, taskbar, and lifecycle test matrix passes +- Developers rebuilding the package must set + FERREX_MPV_WINDOWS_PRESENTER=spike before cargo build +- Presenter failure falls back deterministically to mpv's native window + Support: Visit https://github.com/Lowband21/ferrex for issues and discussions diff --git a/utils/build-windows/build-libmpv-lgpl.sh b/utils/build-windows/build-libmpv-lgpl.sh new file mode 100755 index 00000000..7c84d228 --- /dev/null +++ b/utils/build-windows/build-libmpv-lgpl.sh @@ -0,0 +1,415 @@ +#!/usr/bin/env bash +# Build the pinned LGPL-only Windows libmpv SDK used by Ferrex CI/releases. +# +# Run this inside an MSYS2 UCRT64 shell after installing the packages listed in +# .github/workflows/ci.yml. The output is self-describing and contains the +# runtime DLL closure, headers, GNU import library, exact build profile, hashes, +# and notices. A separate PowerShell step derives mpv.lib for the MSVC Rust +# target from the same DLL exports. + +set -euo pipefail + +PREFIX=${1:-/c/ferrex-libmpv-sdk} +WORK=${FERREX_LIBMPV_BUILD_ROOT:-/c/ferrex-libmpv-build} +JOBS=${NUMBER_OF_PROCESSORS:-4} + +MPV_COMMIT=41f6a645068483470267271e1d09966ca3b9f413 +FFMPEG_COMMIT=38b88335f99e76ed89ff3c93f877fdefce736c13 +LIBASS_COMMIT=bbb3c7f1570a4a021e52683f3fbdf74fe492ae84 +LIBPLACEBO_COMMIT=cee9b076f2c63104ccfd497fa79c39a867293ec4 +LUAJIT_COMMIT=b411bec3ce550ef9968fc83bca094455cf812c1f + +if [[ ${MSYSTEM:-} != UCRT64 ]]; then + echo "error: run this script in an MSYS2 UCRT64 shell" >&2 + exit 1 +fi + +mkdir -p "$PREFIX" "$WORK/src" "$WORK/build" +export PATH="$PREFIX/bin:/ucrt64/bin:$PATH" +export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:/ucrt64/lib/pkgconfig" +export PKG_CONFIG=/ucrt64/bin/pkg-config +export CFLAGS="${CFLAGS:-} -O2" + +checkout_exact() { + local name=$1 + local repository=$2 + local commit=$3 + local directory="$WORK/src/$name" + if [[ ! -d $directory/.git ]]; then + mkdir -p "$directory" + git -C "$directory" init + git -C "$directory" remote add origin "$repository" + fi + git -C "$directory" fetch --depth 1 origin "$commit" + git -C "$directory" checkout --detach --force FETCH_HEAD + [[ $(git -C "$directory" rev-parse HEAD) == "$commit" ]] || { + echo "error: $name did not resolve pinned commit $commit" >&2 + exit 1 + } + git -C "$directory" submodule update --init --recursive --depth 1 +} + +checkout_exact libass https://github.com/libass/libass.git "$LIBASS_COMMIT" +checkout_exact ffmpeg https://github.com/FFmpeg/FFmpeg.git "$FFMPEG_COMMIT" +checkout_exact luajit https://github.com/openresty/luajit2.git "$LUAJIT_COMMIT" +checkout_exact libplacebo https://github.com/haasn/libplacebo.git "$LIBPLACEBO_COMMIT" +checkout_exact mpv https://github.com/mpv-player/mpv.git "$MPV_COMMIT" + +# libass 0.17.4: exact shared build used for text/ASS subtitle rendering. +pushd "$WORK/src/libass" >/dev/null +./autogen.sh \ + --prefix="$PREFIX" \ + --disable-static \ + --enable-shared \ + --enable-asm \ + --enable-harfbuzz \ + --disable-fontconfig +make -j"$JOBS" +make install +popd >/dev/null + +# FFmpeg 8.1.2: keep the closure LGPLv2.1 by explicitly rejecting GPL, +# nonfree, and LGPLv3/version3 options. Windows native TLS and hardware decode +# APIs do not require an external restricted library. +mkdir -p "$WORK/build/ffmpeg" +pushd "$WORK/build/ffmpeg" >/dev/null +"$WORK/src/ffmpeg/configure" \ + --prefix="$PREFIX" \ + --disable-debug \ + --disable-doc \ + --disable-programs \ + --disable-static \ + --disable-autodetect \ + --disable-gpl \ + --disable-nonfree \ + --disable-version3 \ + --enable-shared \ + --enable-libass \ + --enable-schannel \ + --enable-d3d11va \ + --enable-dxva2 +make -j"$JOBS" +make install +if grep -Eq '^CONFIG_(GPL|NONFREE|VERSION3)=yes$' ffbuild/config.mak; then + echo "error: FFmpeg resolved a restricted license option" >&2 + exit 1 +fi +for required in CONFIG_LIBASS CONFIG_SCHANNEL CONFIG_D3D11VA CONFIG_DXVA2; do + if ! grep -q "^${required}=yes$" ffbuild/config.mak; then + echo "error: FFmpeg did not enable required Windows feature $required" >&2 + exit 1 + fi +done +popd >/dev/null + +# LuaJIT supplies mpv's controlled OSC/native-window fallback without reading +# a user's standalone mpv configuration. +pushd "$WORK/src/luajit" >/dev/null +make -j"$JOBS" BUILDMODE=dynamic TARGET_SYS=Windows +make install BUILDMODE=dynamic TARGET_SYS=Windows PREFIX="$PREFIX" +popd >/dev/null + +# libplacebo 7.360.1 provides gpu-next's D3D11 renderer. Vulkan/OpenGL are not +# needed for the Windows release path and are disabled to keep the closure +# narrow; shaderc is retained for the D3D11 shader pipeline. +meson setup "$WORK/build/libplacebo" "$WORK/src/libplacebo" \ + --prefix="$PREFIX" \ + --buildtype=release \ + -Ddefault_library=shared \ + -Dvulkan=disabled \ + -Dopengl=disabled \ + -Dd3d11=enabled \ + -Dglslang=disabled \ + -Dshaderc=enabled \ + -Dlcms=enabled \ + -Ddovi=disabled \ + -Ddemos=false \ + -Dtests=false \ + -Dbench=false \ + -Dunwind=disabled +meson compile -C "$WORK/build/libplacebo" -j "$JOBS" +meson install -C "$WORK/build/libplacebo" + +python - "$WORK/build/libplacebo/meson-info/intro-buildoptions.json" <<'PY' +import json +import sys + +options = {item["name"]: item["value"] for item in json.load(open(sys.argv[1], encoding="utf-8"))} +required = { + "vulkan": "disabled", + "opengl": "disabled", + "d3d11": "enabled", + "glslang": "disabled", + "shaderc": "enabled", + "lcms": "enabled", +} +wrong = {key: (options.get(key), value) for key, value in required.items() if options.get(key) != value} +if wrong: + raise SystemExit(f"unexpected libplacebo Windows build options: {wrong}") +PY + +# mpv 0.41.0 / client API 2.5. The LGPL-compatible gpu-next/D3D11 path, +# WASAPI, D3D11VA/DXVA2, and shared libmpv are mandatory. The legacy +# `direct3d` VO is GPL-gated upstream and therefore explicitly disabled. +meson setup "$WORK/build/mpv" "$WORK/src/mpv" \ + --prefix="$PREFIX" \ + --buildtype=release \ + -Dgpl=false \ + -Dbuild-date=false \ + -Dcplayer=false \ + -Dlibmpv=true \ + -Dtests=false \ + -Dfuzzers=false \ + -Dmanpage-build=disabled \ + -Dhtml-build=disabled \ + -Dpdf-build=disabled \ + -Dcdda=disabled \ + -Ddvbin=disabled \ + -Ddvdnav=disabled \ + -Djavascript=disabled \ + -Dlua=luajit \ + -Drubberband=disabled \ + -Duchardet=disabled \ + -Dvapoursynth=disabled \ + -Dzimg=disabled \ + -Dlibarchive=disabled \ + -Dlibbluray=disabled \ + -Dopenal=disabled \ + -Dsdl2-audio=disabled \ + -Dsdl2-video=disabled \ + -Dwasapi=enabled \ + -Dd3d11=enabled \ + -Ddirect3d=disabled \ + -Dd3d-hwaccel=enabled \ + -Dd3d9-hwaccel=enabled \ + -Dgl=disabled \ + -Dvulkan=disabled \ + -Dwin32-threads=enabled \ + -Dwin32-smtc=disabled +meson compile -C "$WORK/build/mpv" -j "$JOBS" +meson install -C "$WORK/build/mpv" + +python - "$WORK/build/mpv/meson-info/intro-buildoptions.json" <<'PY' +import json +import sys + +options = {item["name"]: item["value"] for item in json.load(open(sys.argv[1], encoding="utf-8"))} +required = { + "gpl": False, + "libmpv": True, + "cplayer": False, + "d3d11": "enabled", + "direct3d": "disabled", + "d3d-hwaccel": "enabled", + "d3d9-hwaccel": "enabled", + "wasapi": "enabled", + "gl": "disabled", + "vulkan": "disabled", + "lua": "luajit", + "win32-threads": "enabled", +} +wrong = {key: (options.get(key), value) for key, value in required.items() if options.get(key) != value} +if wrong: + raise SystemExit(f"unexpected mpv Windows build options: {wrong}") +PY + +MPV_DLL=$(find "$PREFIX/bin" -maxdepth 1 -type f \( -iname 'libmpv-2.dll' -o -iname 'mpv-2.dll' \) -print -quit) +[[ -n $MPV_DLL ]] || { + echo "error: mpv install did not produce libmpv-2.dll" >&2 + exit 1 +} +[[ -f "$PREFIX/lib/libmpv.dll.a" ]] || { + echo "error: mpv install did not produce libmpv.dll.a" >&2 + exit 1 +} +[[ -f "$PREFIX/include/mpv/client.h" ]] || { + echo "error: mpv install did not produce mpv/client.h" >&2 + exit 1 +} + +# Everything present before closure materialization came from the exact source +# builds above. Record that boundary so copied MSYS2 runtime DLLs cannot be +# mislabeled merely because they now live under the isolated prefix. +SOURCE_BUILT_DLLS="$WORK/source-built-dlls.txt" +COPIED_RUNTIME_ORIGINS="$WORK/copied-runtime-origins.tsv" +find "$PREFIX/bin" -maxdepth 1 -type f -iname '*.dll' -printf '%f\n' | + LC_ALL=C sort -u >"$SOURCE_BUILT_DLLS" +: >"$COPIED_RUNTIME_ORIGINS" + +# Materialize the complete non-system DLL closure next to libmpv. ldd returns +# the transitive graph; repeat after copying so dependencies discovered through +# a newly staged DLL are also checked. System32/API-set DLLs are never copied. +copy_runtime_closure() { + local changed=1 + while ((changed)); do + changed=0 + while IFS= read -r binary; do + while IFS= read -r dependency; do + case "$dependency" in + /c/Windows/* | /C/Windows/* | "$PREFIX"/*) continue ;; + esac + if [[ -f $dependency ]]; then + local destination basename + basename=$(basename "$dependency") + destination="$PREFIX/bin/$(basename "$dependency")" + if [[ ! -f $destination ]]; then + cp "$dependency" "$destination" + printf '%s\t%s\n' "$basename" "$dependency" \ + >>"$COPIED_RUNTIME_ORIGINS" + changed=1 + elif ! cmp -s "$dependency" "$destination"; then + echo "error: conflicting runtime DLL basename $basename" >&2 + echo " staged: $destination" >&2 + echo " import: $dependency" >&2 + exit 1 + fi + fi + done < <( + ldd "$binary" | awk ' + /=> \/.*\.dll/ { print $3 } + /^[[:space:]]*\/.*\.dll/ { print $1 } + ' + ) + done < <(find "$PREFIX/bin" -maxdepth 1 -type f -iname '*.dll' -print) + done +} +copy_runtime_closure + +while IFS= read -r binary; do + if ldd "$binary" | grep -q 'not found'; then + echo "error: unresolved runtime dependency for $binary" >&2 + ldd "$binary" >&2 + exit 1 + fi +done < <(find "$PREFIX/bin" -maxdepth 1 -type f -iname '*.dll' -print) + +LICENSE_ROOT="$PREFIX/share/licenses/ferrex-libmpv" +mkdir -p "$LICENSE_ROOT/mpv" "$LICENSE_ROOT/ffmpeg" \ + "$LICENSE_ROOT/libass" "$LICENSE_ROOT/libplacebo" \ + "$LICENSE_ROOT/luajit" "$LICENSE_ROOT/runtime-packages" +cp "$WORK/src/mpv/LICENSE.LGPL" "$LICENSE_ROOT/mpv/" +cp "$WORK/src/ffmpeg/COPYING.LGPLv2.1" "$LICENSE_ROOT/ffmpeg/" +cp "$WORK/src/ffmpeg/LICENSE.md" "$LICENSE_ROOT/ffmpeg/" +cp "$WORK/src/libass/COPYING" "$LICENSE_ROOT/libass/" +cp "$WORK/src/libplacebo/LICENSE" "$LICENSE_ROOT/libplacebo/" +cp "$WORK/src/luajit/COPYRIGHT" "$LICENSE_ROOT/luajit/" + +printf '%s\n' \ + 'mpv=0.41.0' \ + "mpv_commit=$MPV_COMMIT" \ + 'client_api=2.5' \ + 'gpl=false' \ + 'libmpv=true' \ + 'gpu_next=true' \ + 'd3d11=true' \ + 'direct3d=false' \ + 'd3d_hwaccel=true' \ + 'd3d9_hwaccel=true' \ + 'wasapi=true' \ + 'gl=false' \ + 'vulkan=false' \ + 'lua=luajit' \ + 'ffmpeg=8.1.2' \ + "ffmpeg_commit=$FFMPEG_COMMIT" \ + 'ffmpeg_gpl=false' \ + 'ffmpeg_nonfree=false' \ + 'ffmpeg_version3=false' \ + 'libass=0.17.4' \ + "libass_commit=$LIBASS_COMMIT" \ + 'libplacebo=7.360.1' \ + "libplacebo_commit=$LIBPLACEBO_COMMIT" \ + "luajit_commit=$LUAJIT_COMMIT" \ + 'toolchain=msys2-ucrt64' \ + >"$LICENSE_ROOT/BUILD_PROFILE" + +# Preserve an exhaustive provenance row for every hashed DLL. Exact source +# builds are tied to the profile above; copied runtime DLLs are resolved back to +# their original MSYS2-owned path before querying pacman. +runtime_manifest="$LICENSE_ROOT/runtime-packages/MANIFEST" +: >"$runtime_manifest" + +source_component_metadata() { + local filename="$1" + case "$filename" in + libmpv-*.dll | mpv-*.dll) + printf 'mpv\t0.41.0@%s\tLGPL-2.1-or-later\n' "$MPV_COMMIT" + ;; + avcodec-*.dll | libavcodec-*.dll | avdevice-*.dll | libavdevice-*.dll | \ + avfilter-*.dll | libavfilter-*.dll | avformat-*.dll | libavformat-*.dll | \ + avutil-*.dll | libavutil-*.dll | swresample-*.dll | libswresample-*.dll | \ + swscale-*.dll | libswscale-*.dll) + printf 'ffmpeg\t8.1.2@%s\tLGPL-2.1-or-later\n' "$FFMPEG_COMMIT" + ;; + libass-*.dll) + printf 'libass\t0.17.4@%s\tISC\n' "$LIBASS_COMMIT" + ;; + libplacebo-*.dll) + printf 'libplacebo\t7.360.1@%s\tLGPL-2.1-or-later\n' "$LIBPLACEBO_COMMIT" + ;; + lua*.dll | libluajit-*.dll) + printf 'luajit\t%s\tMIT\n' "$LUAJIT_COMMIT" + ;; + *) + return 1 + ;; + esac +} + +while IFS= read -r dll; do + filename=$(basename "$dll") + if grep -Fxq "$filename" "$SOURCE_BUILT_DLLS"; then + metadata=$(source_component_metadata "$filename") || { + echo "error: unclassified source-built runtime DLL: $filename" >&2 + exit 1 + } + IFS=$'\t' read -r component version licenses <<<"$metadata" + printf '%s\tsource\t%s\t%s\t%s\n' \ + "$filename" "$component" "$version" "$licenses" >>"$runtime_manifest" + continue + fi + + origin=$(awk -F '\t' -v filename="$filename" \ + '$1 == filename { print $2; exit }' "$COPIED_RUNTIME_ORIGINS") + [[ -n $origin && -f $origin ]] || { + echo "error: copied runtime DLL has no recorded origin: $filename" >&2 + exit 1 + } + owner=$(pacman -Qqo "$origin" 2>/dev/null || true) + [[ -n $owner ]] || { + echo "error: no MSYS2 package owns runtime DLL origin: $origin" >&2 + exit 1 + } + version=$(pacman -Q "$owner" | awk '{print $2}') + licenses=$(pacman -Qi "$owner" | + sed -n 's/^Licenses[[:space:]]*:[[:space:]]*//p') + [[ -n $version && -n $licenses && $licenses != None ]] || { + echo "error: incomplete package license metadata for $owner" >&2 + exit 1 + } + printf '%s\tmsys2\t%s\t%s\t%s\n' \ + "$filename" "$owner" "$version" "$licenses" >>"$runtime_manifest" + + package_directory="$LICENSE_ROOT/runtime-packages/$owner" + mkdir -p "$package_directory" + pacman -Qi "$owner" >"$package_directory/PACKAGE_INFO" + notice_count=0 + while IFS= read -r notice; do + [[ -f $notice ]] || continue + destination="$package_directory$notice" + mkdir -p "$(dirname "$destination")" + cp "$notice" "$destination" + notice_count=$((notice_count + 1)) + done < <(pacman -Ql "$owner" | + awk '$2 ~ /\/share\/licenses\// { print $2 }') + ((notice_count > 0)) || { + echo "error: package $owner ships no installed license notice" >&2 + exit 1 + } +done < <(find "$PREFIX/bin" -maxdepth 1 -type f -iname '*.dll' -print | LC_ALL=C sort) + +( + cd "$PREFIX/bin" + sha256sum ./*.dll | sed 's# \./# #' >"$LICENSE_ROOT/RUNTIME_DLLS.sha256" +) + +echo "validated Ferrex Windows LGPL libmpv SDK: $PREFIX" diff --git a/utils/build-windows/gstreamer-plugin-roots.txt b/utils/build-windows/gstreamer-plugin-roots.txt new file mode 100644 index 00000000..78a6531c --- /dev/null +++ b/utils/build-windows/gstreamer-plugin-roots.txt @@ -0,0 +1,43 @@ +# Reviewed LGPL-compatible Windows rollback surface. The official Cerbero +# installer contains additional restricted/GPL plugin components even when its +# feature metadata marks them unselected, so the distribution stager resolves +# only these exact plugin roots and their recursive PE imports. H.264 uses the +# BSD OpenH264 plugin (with D3D11 available as a hardware path) and AAC uses +# Windows Media Foundation; gst-libav is intentionally excluded so libmpv's +# pinned FFmpeg remains the only FFmpeg ABI in the process. +gstcoreelements +gstplayback +gstapp +gstvideoconvertscale +gstaudioconvert +gstaudioresample +gstvolume +gsttypefindfunctions +gstpbtypes +gstgio +gstrawparse +gstautodetect +gstaudiotestsrc +gstvideotestsrc +gstaudiofx +gstisomp4 +gstmatroska +gstmpegtsdemux +gstmpegtsmux +gstadaptivedemux2 +gstvideoparsersbad +gstaudioparsers +gstsoup +gstsubparse +gstassrender +gstwasapi2 +gstwasapi +gstdirectsound +gstd3d11 +gstmediafoundation +gstopenh264 +gstogg +gstwavparse +gstflac +gstopus +gstvorbis diff --git a/utils/build-windows/install-gstreamer.ps1 b/utils/build-windows/install-gstreamer.ps1 new file mode 100644 index 00000000..2365407b --- /dev/null +++ b/utils/build-windows/install-gstreamer.ps1 @@ -0,0 +1,60 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Destination +) + +$ErrorActionPreference = 'Stop' + +$version = '1.28.4' +$expectedSha256 = '1a745d67225e43394a4a5db929c97397cb56e74b1c38bb77c6ded4b037d3c040' +$architecture = 'x86_64' +$abi = 'msvc' +$installerName = "gstreamer-1.0-$abi-$architecture-$version.exe" +$downloadRoot = if ($env:RUNNER_TEMP) { + $env:RUNNER_TEMP +} else { + [System.IO.Path]::GetTempPath() +} +$installer = Join-Path $downloadRoot $installerName +$pkgConfig = Join-Path $Destination 'lib\pkgconfig\gstreamer-1.0.pc' + +if (-not (Test-Path $pkgConfig -PathType Leaf)) { + $url = "https://gstreamer.freedesktop.org/pkg/windows/$version/$abi/$installerName" + Invoke-WebRequest -Uri $url -OutFile $installer + + $actualSha256 = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualSha256 -ne $expectedSha256) { + throw "GStreamer installer hash mismatch: expected $expectedSha256, got $actualSha256" + } + + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + $arguments = @( + '/VERYSILENT', + '/SUPPRESSMSGBOXES', + '/NORESTART', + '/CURRENTUSER', + '/TYPE=devel', + "/DIR=$Destination" + ) + $process = Start-Process -FilePath $installer -ArgumentList $arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "GStreamer installer exited with code $($process.ExitCode)" + } +} + +if (-not (Test-Path $pkgConfig -PathType Leaf)) { + throw "GStreamer development package is incomplete: $pkgConfig is missing" +} + +$pkgConfigExe = Join-Path $Destination 'bin\pkg-config.exe' +if (-not (Test-Path $pkgConfigExe -PathType Leaf)) { + throw "GStreamer development package is incomplete: $pkgConfigExe is missing" +} + +$reportedVersion = (& $pkgConfigExe --modversion gstreamer-1.0).Trim() +if ($LASTEXITCODE -ne 0 -or $reportedVersion -ne $version) { + throw "Expected GStreamer $version, found '$reportedVersion'" +} + +Remove-Item $installer -Force -ErrorAction SilentlyContinue +Write-Host "Validated pinned GStreamer $version MSVC SDK at $Destination" diff --git a/utils/build-windows/new-libmpv-import-library.ps1 b/utils/build-windows/new-libmpv-import-library.ps1 new file mode 100644 index 00000000..50c539e3 --- /dev/null +++ b/utils/build-windows/new-libmpv-import-library.ps1 @@ -0,0 +1,39 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SdkRoot +) + +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path $SdkRoot).Path +$dll = Get-ChildItem (Join-Path $root 'bin') -File | + Where-Object { $_.Name -in @('libmpv-2.dll', 'mpv-2.dll', 'mpv.dll') } | + Select-Object -First 1 +if (-not $dll) { throw "libmpv runtime DLL not found under $root\bin" } + +$dumpbin = Get-Command dumpbin.exe -ErrorAction Stop +$lib = Get-Command lib.exe -ErrorAction Stop +$exports = & $dumpbin.Source /nologo /exports $dll.FullName | + ForEach-Object { + if ($_ -match '^\s+\d+\s+[0-9A-Fa-f]+\s+[0-9A-Fa-f]+\s+(\S+)') { + $Matches[1] + } + } | + Sort-Object -Unique +if (-not $exports -or $exports.Count -lt 20) { + throw "Could not read a plausible libmpv export table from $($dll.FullName)" +} +foreach ($required in @('mpv_client_api_version', 'mpv_create', 'mpv_initialize', 'mpv_terminate_destroy')) { + if ($required -notin $exports) { throw "libmpv DLL is missing export $required" } +} + +$libDir = Join-Path $root 'lib' +New-Item -ItemType Directory -Force -Path $libDir | Out-Null +$def = Join-Path $libDir 'mpv.def' +$lines = @("LIBRARY `"$($dll.Name)`"", 'EXPORTS') + ($exports | ForEach-Object { " $_" }) +Set-Content -Path $def -Value $lines -Encoding ascii + +& $lib.Source "/def:$def" '/machine:x64' "/out:$(Join-Path $libDir 'mpv.lib')" /nologo +if ($LASTEXITCODE -ne 0 -or -not (Test-Path (Join-Path $libDir 'mpv.lib'))) { + throw 'MSVC libmpv import-library generation failed' +} +Write-Host "Created $libDir\mpv.lib from $($dll.Name)" diff --git a/utils/build-windows/run-ferrex.bat b/utils/build-windows/run-ferrex.bat index 5367f50a..053f7730 100644 --- a/utils/build-windows/run-ferrex.bat +++ b/utils/build-windows/run-ferrex.bat @@ -1,8 +1,17 @@ @echo off echo Starting Ferrex Player... -REM Set GStreamer plugin path +REM Use only the bundled GStreamer plugins and scanner set GST_PLUGIN_PATH=%~dp0lib\gstreamer-1.0 +set GST_PLUGIN_PATH_1_0=%~dp0lib\gstreamer-1.0 +set GST_PLUGIN_SYSTEM_PATH_1_0=%~dp0lib\gstreamer-1.0 +set GST_PLUGIN_SCANNER=%~dp0libexec\gstreamer-1.0\gst-plugin-scanner.exe +set GST_PLUGIN_SCANNER_1_0=%~dp0libexec\gstreamer-1.0\gst-plugin-scanner.exe +set GIO_EXTRA_MODULES=%~dp0lib\gio\modules +set SSL_CERT_FILE=%~dp0etc\ssl\certs\ca-certificates.crt +if not defined LOCALAPPDATA set LOCALAPPDATA=%TEMP% +if not exist "%LOCALAPPDATA%\Ferrex\gstreamer-1.0" mkdir "%LOCALAPPDATA%\Ferrex\gstreamer-1.0" +set GST_REGISTRY_1_0=%LOCALAPPDATA%\Ferrex\gstreamer-1.0\registry.bin REM Add bin directory to PATH for DLLs set PATH=%~dp0bin;%PATH% diff --git a/utils/build-windows/run-ferrex.ps1 b/utils/build-windows/run-ferrex.ps1 index b779ef0e..8c0199b2 100644 --- a/utils/build-windows/run-ferrex.ps1 +++ b/utils/build-windows/run-ferrex.ps1 @@ -2,8 +2,24 @@ Write-Host "Starting Ferrex Player..." -ForegroundColor Green -# Set GStreamer plugin path -$env:GST_PLUGIN_PATH = "$PSScriptRoot\lib\gstreamer-1.0" +# Use only the bundled GStreamer plugins and out-of-process scanner. +$pluginPath = "$PSScriptRoot\lib\gstreamer-1.0" +$scanner = "$PSScriptRoot\libexec\gstreamer-1.0\gst-plugin-scanner.exe" +$env:GST_PLUGIN_PATH = $pluginPath +$env:GST_PLUGIN_PATH_1_0 = $pluginPath +$env:GST_PLUGIN_SYSTEM_PATH_1_0 = $pluginPath +$env:GST_PLUGIN_SCANNER = $scanner +$env:GST_PLUGIN_SCANNER_1_0 = $scanner +$env:GIO_EXTRA_MODULES = "$PSScriptRoot\lib\gio\modules" +$env:SSL_CERT_FILE = "$PSScriptRoot\etc\ssl\certs\ca-certificates.crt" +$localData = if ($env:LOCALAPPDATA) { + $env:LOCALAPPDATA +} else { + [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) +} +$registryDirectory = Join-Path $localData 'Ferrex\gstreamer-1.0' +New-Item -ItemType Directory -Force -Path $registryDirectory | Out-Null +$env:GST_REGISTRY_1_0 = Join-Path $registryDirectory 'registry.bin' # Add bin directory to PATH $env:PATH = "$PSScriptRoot\bin;$env:PATH" diff --git a/utils/build-windows/stage-gstreamer-runtime.ps1 b/utils/build-windows/stage-gstreamer-runtime.ps1 new file mode 100644 index 00000000..e746626c --- /dev/null +++ b/utils/build-windows/stage-gstreamer-runtime.ps1 @@ -0,0 +1,196 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SourceRoot, + + [Parameter(Mandatory = $true)] + [string]$StageRoot, + + [Parameter(Mandatory = $true)] + [string]$PluginManifest +) + +$ErrorActionPreference = 'Stop' + +$sourceRoot = (Resolve-Path $SourceRoot).Path +$stageRoot = (Resolve-Path $StageRoot).Path +$pluginManifest = (Resolve-Path $PluginManifest).Path +$sourceBin = Join-Path $sourceRoot 'bin' +$sourcePlugins = Join-Path $sourceRoot 'lib\gstreamer-1.0' +$sourceGioModules = Join-Path $sourceRoot 'lib\gio\modules' +$stageBin = Join-Path $stageRoot 'bin' +$stagePlugins = Join-Path $stageRoot 'lib\gstreamer-1.0' +$stageGioModules = Join-Path $stageRoot 'lib\gio\modules' +$stageLibexec = Join-Path $stageRoot 'libexec\gstreamer-1.0' +$stageCerts = Join-Path $stageRoot 'etc\ssl\certs' +$noticeRoot = Join-Path $stageRoot 'share\licenses\gstreamer' +$system32 = Join-Path $env:SystemRoot 'System32' +$dumpbin = (Get-Command dumpbin.exe -ErrorAction Stop).Source + +foreach ($directory in @($sourceBin, $sourcePlugins, $sourceGioModules)) { + if (-not (Test-Path $directory -PathType Container)) { + throw "GStreamer source directory is missing: $directory" + } +} +New-Item -ItemType Directory -Force -Path $stageBin, $stagePlugins, $stageGioModules, $stageLibexec, $stageCerts, $noticeRoot | Out-Null + +$pluginRoots = @( + Get-Content $pluginManifest | + ForEach-Object { ($_ -replace '#.*$', '').Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } +) +if ($pluginRoots.Count -eq 0) { + throw "GStreamer plugin allowlist is empty: $pluginManifest" +} +$pluginRootNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($pluginRoot in $pluginRoots) { + if ([System.IO.Path]::GetFileName($pluginRoot) -ne $pluginRoot -or + $pluginRoot -notmatch '^[A-Za-z0-9_.+\-]+$') { + throw "Invalid GStreamer plugin root entry: $pluginRoot" + } + if (-not $pluginRootNames.Add($pluginRoot)) { + throw "Duplicate GStreamer plugin root entry: $pluginRoot" + } +} + +# Cerbero normally omits the Unix `lib` prefix for PE plugins, but accepting +# exactly one of the two documented forms keeps the policy robust across +# official installer layout changes without using a wildcard allowlist. +$plugins = [System.Collections.Generic.List[string]]::new() +foreach ($pluginRoot in $pluginRoots) { + $matches = @( + @( + (Join-Path $sourcePlugins "$pluginRoot.dll"), + (Join-Path $sourcePlugins "lib$pluginRoot.dll") + ) | Where-Object { Test-Path $_ -PathType Leaf } + ) + if ($matches.Count -ne 1) { + throw "Expected exactly one official plugin for '$pluginRoot'; found $($matches.Count)" + } + $plugins.Add([System.IO.Path]::GetFileName($matches[0])) +} + +$copied = [System.Collections.Generic.List[string]]::new() +function Copy-FromGStreamer([string]$Source, [string]$Destination) { + if (-not (Test-Path $Source -PathType Leaf)) { + throw "Required GStreamer runtime file is missing: $Source" + } + Copy-Item -Force $Source $Destination + $copied.Add((Resolve-Path $Destination).Path) +} + +foreach ($plugin in $plugins) { + Copy-FromGStreamer (Join-Path $sourcePlugins $plugin) (Join-Path $stagePlugins $plugin) +} + +$inspect = Join-Path $sourceBin 'gst-inspect-1.0.exe' +Copy-FromGStreamer $inspect (Join-Path $stageBin 'gst-inspect-1.0.exe') +$launch = Join-Path $sourceBin 'gst-launch-1.0.exe' +Copy-FromGStreamer $launch (Join-Path $stageBin 'gst-launch-1.0.exe') +$scanner = @( + (Join-Path $sourceRoot 'libexec\gstreamer-1.0\gst-plugin-scanner.exe'), + (Join-Path $sourceBin 'gst-plugin-scanner.exe') +) | Where-Object { Test-Path $_ -PathType Leaf } | Select-Object -First 1 +if (-not $scanner) { + throw 'GStreamer plugin scanner is missing' +} +Copy-FromGStreamer $scanner (Join-Path $stageLibexec 'gst-plugin-scanner.exe') + +foreach ($gioModule in @('libgioopenssl.dll', 'libgiolibproxy.dll')) { + Copy-FromGStreamer (Join-Path $sourceGioModules $gioModule) (Join-Path $stageGioModules $gioModule) +} +$gioModuleCache = Join-Path $sourceGioModules 'giomodule.cache' +if (Test-Path $gioModuleCache -PathType Leaf) { + Copy-FromGStreamer $gioModuleCache (Join-Path $stageGioModules 'giomodule.cache') +} +$certificateBundle = Join-Path $sourceRoot 'etc\ssl\certs\ca-certificates.crt' +Copy-FromGStreamer $certificateBundle (Join-Path $stageCerts 'ca-certificates.crt') + +function Get-Imports([string]$Path) { + & $dumpbin /nologo /dependents $Path | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -match '^[A-Za-z0-9_.+\-]+\.dll$' } +} + +$availableByName = @{} +foreach ($file in Get-ChildItem $sourceBin -File -Filter '*.dll') { + if ($availableByName.ContainsKey($file.Name)) { + throw "Duplicate GStreamer runtime DLL basename: $($file.Name)" + } + $availableByName[$file.Name] = $file.FullName +} + +$localByName = @{} +foreach ($directory in @($stageRoot, $stageBin, $stagePlugins, $stageGioModules)) { + foreach ($file in Get-ChildItem $directory -File -Filter '*.dll' -ErrorAction SilentlyContinue) { + if ($localByName.ContainsKey($file.Name) -and + (Get-FileHash $localByName[$file.Name] -Algorithm SHA256).Hash -ne + (Get-FileHash $file.FullName -Algorithm SHA256).Hash) { + throw "Conflicting staged DLLs share the name $($file.Name)" + } + $localByName[$file.Name] = $file.FullName + } +} + +$queue = [System.Collections.Generic.Queue[string]]::new() +foreach ($binary in @( + (Join-Path $stageRoot 'ferrex-player.exe'), + (Join-Path $stageBin 'gst-inspect-1.0.exe'), + (Join-Path $stageBin 'gst-launch-1.0.exe'), + (Join-Path $stageLibexec 'gst-plugin-scanner.exe') +)) { + if (Test-Path $binary -PathType Leaf) { + $queue.Enqueue((Resolve-Path $binary).Path) + } +} +foreach ($directory in @($stageBin, $stagePlugins, $stageGioModules)) { + foreach ($binary in Get-ChildItem $directory -File -Filter '*.dll') { + $queue.Enqueue($binary.FullName) + } +} + +$seen = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +while ($queue.Count -gt 0) { + $binary = $queue.Dequeue() + if (-not $seen.Add($binary)) { continue } + + foreach ($dependency in Get-Imports $binary) { + if ($dependency.StartsWith('api-ms-win-', [System.StringComparison]::OrdinalIgnoreCase) -or + $dependency.StartsWith('ext-ms-', [System.StringComparison]::OrdinalIgnoreCase)) { + continue + } + if ($localByName.ContainsKey($dependency)) { + $queue.Enqueue($localByName[$dependency]) + continue + } + if ($availableByName.ContainsKey($dependency)) { + $destination = Join-Path $stageBin $dependency + Copy-FromGStreamer $availableByName[$dependency] $destination + $localByName[$dependency] = (Resolve-Path $destination).Path + $queue.Enqueue($localByName[$dependency]) + continue + } + if (Test-Path (Join-Path $system32 $dependency) -PathType Leaf) { + continue + } + throw "Unresolved DLL import while staging GStreamer: $dependency required by $binary" + } +} + +Copy-Item -Force $pluginManifest (Join-Path $noticeRoot 'PLUGIN_ROOTS') +$plugins | Set-Content (Join-Path $noticeRoot 'PLUGIN_ALLOWLIST') +$runtimeHashes = [System.Collections.Generic.List[string]]::new() +$runtimeProvenance = [System.Collections.Generic.List[string]]::new() +foreach ($path in @($copied | Sort-Object -Unique)) { + $relative = [System.IO.Path]::GetRelativePath($stageRoot, $path).Replace('\', '/') + $hash = (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() + $runtimeHashes.Add("$hash *$relative") + $runtimeProvenance.Add("$relative`tGStreamer 1.28.4 official MSVC x86_64 installer") +} +$runtimeHashes | Set-Content (Join-Path $noticeRoot 'RUNTIME_FILES.sha256') +$runtimeProvenance | Set-Content (Join-Path $noticeRoot 'RUNTIME_PROVENANCE.tsv') + +Write-Host "Staged reviewed GStreamer runtime closure ($($plugins.Count) plugins, $($copied.Count) files)." diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 new file mode 100644 index 00000000..a063f809 --- /dev/null +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -0,0 +1,465 @@ +param( + [Parameter(Mandatory = $true)] + [string]$StageRoot +) + +$ErrorActionPreference = 'Stop' +$root = (Resolve-Path $StageRoot).Path +$bin = Join-Path $root 'bin' +$pluginDir = Join-Path $root 'lib\gstreamer-1.0' +$gioModuleDir = Join-Path $root 'lib\gio\modules' +$scanner = Join-Path $root 'libexec\gstreamer-1.0\gst-plugin-scanner.exe' +$gstInspect = Join-Path $bin 'gst-inspect-1.0.exe' +$gstLaunch = Join-Path $bin 'gst-launch-1.0.exe' +$certificateBundle = Join-Path $root 'etc\ssl\certs\ca-certificates.crt' +$system32 = Join-Path $env:SystemRoot 'System32' +$dumpbin = (Get-Command dumpbin.exe -ErrorAction Stop).Source + +$presenterModePath = Join-Path $root 'share\ferrex-player\PRESENTER_BUILD_MODE' +if (-not (Test-Path $presenterModePath -PathType Leaf)) { + throw 'Presenter build-mode metadata is missing from the staged artifact' +} +$presenterMode = (Get-Content $presenterModePath -Raw).Trim() +if ($presenterMode -notin @('spike', 'disabled')) { + throw "Invalid presenter build mode in staged artifact: $presenterMode" +} +if ($env:FERREX_MPV_WINDOWS_PRESENTER -and + $presenterMode -ne $env:FERREX_MPV_WINDOWS_PRESENTER) { + throw "Staged presenter mode '$presenterMode' does not match build mode '$env:FERREX_MPV_WINDOWS_PRESENTER'" +} + +if (-not (Test-Path (Join-Path $root 'ferrex-player.exe'))) { + throw "ferrex-player.exe is missing from $root" +} +if (-not (Get-ChildItem $bin -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -in @('libmpv-2.dll', 'mpv-2.dll', 'mpv.dll') })) { + throw "libmpv runtime DLL is missing from $bin" +} +if (-not (Test-Path $pluginDir -PathType Container) -or + -not (Get-ChildItem $pluginDir -File -Filter '*.dll' -ErrorAction SilentlyContinue)) { + throw "Bundled GStreamer plugins are missing from $pluginDir" +} +if (-not (Test-Path $scanner -PathType Leaf)) { + throw "Bundled GStreamer plugin scanner is missing: $scanner" +} +if (-not (Test-Path $gstInspect -PathType Leaf)) { + throw "Bundled GStreamer inspection tool is missing: $gstInspect" +} +if (-not (Test-Path $gstLaunch -PathType Leaf)) { + throw "Bundled GStreamer launch smoke tool is missing: $gstLaunch" +} +foreach ($gioModule in @('libgioopenssl.dll', 'libgiolibproxy.dll')) { + if (-not (Test-Path (Join-Path $gioModuleDir $gioModule) -PathType Leaf)) { + throw "Bundled GIO network module is missing: $gioModule" + } +} +if (-not (Test-Path $certificateBundle -PathType Leaf)) { + throw "Bundled CA certificate bundle is missing: $certificateBundle" +} +$gstProfilePath = Join-Path $root 'share\licenses\gstreamer\FERREX_BUILD_PROFILE' +if (-not (Test-Path $gstProfilePath -PathType Leaf)) { + throw 'Pinned GStreamer build profile/notices are missing from the artifact' +} +$gstPluginRootsPath = Join-Path $root 'share\licenses\gstreamer\PLUGIN_ROOTS' +$gstPluginAllowlistPath = Join-Path $root 'share\licenses\gstreamer\PLUGIN_ALLOWLIST' +$gstRuntimeHashesPath = Join-Path $root 'share\licenses\gstreamer\RUNTIME_FILES.sha256' +$gstRuntimeProvenancePath = Join-Path $root 'share\licenses\gstreamer\RUNTIME_PROVENANCE.tsv' +foreach ($requiredPath in @( + $gstPluginRootsPath, + $gstPluginAllowlistPath, + $gstRuntimeHashesPath, + $gstRuntimeProvenancePath +)) { + if (-not (Test-Path $requiredPath -PathType Leaf)) { + throw "GStreamer staged-closure evidence is missing: $requiredPath" + } +} +$gstPluginRootsHash = (Get-FileHash $gstPluginRootsPath -Algorithm SHA256).Hash.ToLowerInvariant() +$gstProfile = Get-Content $gstProfilePath +foreach ($required in @( + 'gstreamer=1.28.4', + 'abi=msvc-x86_64', + 'installer_sha256=1a745d67225e43394a4a5db929c97397cb56e74b1c38bb77c6ded4b037d3c040', + 'plugin_policy=explicit-recursive-pe-v1', + 'codec_policy=openh264-mediafoundation-v1', + "plugin_roots_sha256=$gstPluginRootsHash" +)) { + if ($required -notin $gstProfile) { + throw "GStreamer build profile is missing required assertion: $required" + } +} + +$allowedPluginNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($name in Get-Content $gstPluginAllowlistPath) { + $name = $name.Trim() + if ([string]::IsNullOrWhiteSpace($name)) { continue } + if ([System.IO.Path]::GetFileName($name) -ne $name -or + $name -notmatch '^[A-Za-z0-9_.+\-]+\.dll$' -or + -not $allowedPluginNames.Add($name)) { + throw "Invalid or duplicate staged GStreamer plugin allowlist entry: $name" + } +} +$actualPluginNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($plugin in Get-ChildItem $pluginDir -File -Filter '*.dll') { + [void]$actualPluginNames.Add($plugin.Name) +} +if (-not $actualPluginNames.SetEquals($allowedPluginNames)) { + $missing = @($allowedPluginNames | Where-Object { -not $actualPluginNames.Contains($_) }) + $extra = @($actualPluginNames | Where-Object { -not $allowedPluginNames.Contains($_) }) + throw "Staged GStreamer plugins differ from the reviewed allowlist (missing=$missing, extra=$extra)" +} + +$forbiddenRuntimePattern = '(?i)(x264|x265|a52dec|dtsdec|libdca|dvdcss|dvdnav|dvdread|faad|fdkaac)' +foreach ($file in Get-ChildItem $root -Recurse -File) { + if ($file.Name -match $forbiddenRuntimePattern) { + throw "Restricted/GPL runtime component was staged: $($file.FullName)" + } +} +$licenseRoot = Join-Path $root 'share\licenses\ferrex-libmpv' +$profilePath = Join-Path $licenseRoot 'BUILD_PROFILE' +if (-not (Test-Path $profilePath)) { + throw 'Ferrex libmpv BUILD_PROFILE is missing from the staged artifact' +} +$profile = Get-Content $profilePath +foreach ($required in @( + 'mpv=0.41.0', + 'mpv_commit=41f6a645068483470267271e1d09966ca3b9f413', + 'client_api=2.5', 'gpl=false', 'libmpv=true', + 'gpu_next=true', 'd3d11=true', 'direct3d=false', + 'd3d_hwaccel=true', 'd3d9_hwaccel=true', 'wasapi=true', + 'gl=false', 'vulkan=false', 'lua=luajit', 'ffmpeg=8.1.2', + 'ffmpeg_commit=38b88335f99e76ed89ff3c93f877fdefce736c13', + 'ffmpeg_gpl=false', 'ffmpeg_nonfree=false', 'ffmpeg_version3=false', + 'libass=0.17.4', + 'libass_commit=bbb3c7f1570a4a021e52683f3fbdf74fe492ae84', + 'libplacebo=7.360.1', + 'libplacebo_commit=cee9b076f2c63104ccfd497fa79c39a867293ec4', + 'luajit_commit=b411bec3ce550ef9968fc83bca094455cf812c1f', + 'toolchain=msys2-ucrt64' +)) { + if ($required -notin $profile) { + throw "Ferrex libmpv BUILD_PROFILE is missing required assertion: $required" + } +} +foreach ($notice in @( + 'mpv\LICENSE.LGPL', 'ffmpeg\COPYING.LGPLv2.1', + 'ffmpeg\LICENSE.md', 'libass\COPYING', 'libplacebo\LICENSE', + 'luajit\COPYRIGHT', 'runtime-packages\MANIFEST' +)) { + if (-not (Test-Path (Join-Path $licenseRoot $notice) -PathType Leaf)) { + throw "Required libmpv closure notice is missing: $notice" + } +} +$manifest = Join-Path $licenseRoot 'RUNTIME_DLLS.sha256' +if (-not (Test-Path $manifest)) { + throw 'Ferrex libmpv runtime-DLL hash manifest is missing from the staged artifact' +} +$manifestedNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($line in Get-Content $manifest) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + if ($line -notmatch '^([0-9A-Fa-f]{64})\s+\*?([^\\/]+\.dll)$') { + throw "Invalid libmpv runtime-DLL manifest entry: $line" + } + $expected = $Matches[1] + $name = $Matches[2] + if (-not $manifestedNames.Add($name)) { + throw "Duplicate libmpv runtime-DLL manifest entry: $name" + } + $staged = Join-Path $bin $name + if (-not (Test-Path $staged -PathType Leaf)) { + throw "Manifested libmpv dependency is missing from the stage: $name" + } + $actual = (Get-FileHash $staged -Algorithm SHA256).Hash + if ($actual -ne $expected) { + throw "Manifested libmpv dependency was replaced or modified: $name" + } +} + +$runtimePackageManifest = Join-Path $licenseRoot 'runtime-packages\MANIFEST' +$runtimeEvidenceNames = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +$sourceAssertions = @{ + mpv = @('0.41.0@41f6a645068483470267271e1d09966ca3b9f413', 'LGPL-2.1-or-later') + ffmpeg = @('8.1.2@38b88335f99e76ed89ff3c93f877fdefce736c13', 'LGPL-2.1-or-later') + libass = @('0.17.4@bbb3c7f1570a4a021e52683f3fbdf74fe492ae84', 'ISC') + libplacebo = @('7.360.1@cee9b076f2c63104ccfd497fa79c39a867293ec4', 'LGPL-2.1-or-later') + luajit = @('b411bec3ce550ef9968fc83bca094455cf812c1f', 'MIT') +} +foreach ($line in Get-Content $runtimePackageManifest) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $fields = @($line -split ([char]9), 5) + if ($fields.Count -ne 5) { + throw "Invalid libmpv runtime provenance row: $line" + } + $name, $originKind, $component, $version, $licenses = $fields + if (-not $manifestedNames.Contains($name) -or + -not $runtimeEvidenceNames.Add($name) -or + [string]::IsNullOrWhiteSpace($version) -or + [string]::IsNullOrWhiteSpace($licenses)) { + throw "Incomplete, duplicate, or unexpected libmpv runtime provenance row: $line" + } + if ($originKind -eq 'source') { + if (-not $sourceAssertions.ContainsKey($component)) { + throw "Unknown source-built runtime component: $component" + } + $assertion = $sourceAssertions[$component] + if ($version -ne $assertion[0] -or $licenses -ne $assertion[1]) { + throw "Source-built runtime evidence disagrees with the pinned profile: $line" + } + } elseif ($originKind -eq 'msys2') { + if ($component -notmatch '^mingw-w64-ucrt-x86_64-' -or + $licenses -eq 'None') { + throw "Runtime package is not an explicitly licensed UCRT64 package: $line" + } + $packageEvidence = Join-Path $licenseRoot "runtime-packages\$component" + if (-not (Test-Path (Join-Path $packageEvidence 'PACKAGE_INFO') -PathType Leaf)) { + throw "Runtime package metadata is missing for $component" + } + $noticeFiles = @( + Get-ChildItem $packageEvidence -Recurse -File | + Where-Object { $_.Name -ne 'PACKAGE_INFO' } + ) + if ($noticeFiles.Count -eq 0) { + throw "Runtime package license notices are missing for $component" + } + } else { + throw "Unknown libmpv runtime provenance kind '$originKind'" + } +} +if (-not $runtimeEvidenceNames.SetEquals($manifestedNames)) { + throw 'Libmpv runtime hash and license/provenance manifests cover different DLLs' +} + +$mpvDll = Get-ChildItem $bin -File | + Where-Object { $_.Name -in @('libmpv-2.dll', 'mpv-2.dll', 'mpv.dll') } | + Select-Object -First 1 +if (-not $mpvDll -or -not $manifestedNames.Contains($mpvDll.Name)) { + throw 'The staged libmpv runtime is not covered by its hash manifest' +} + +$gstRuntimePaths = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($line in Get-Content $gstRuntimeHashesPath) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + if ($line -notmatch '^([0-9A-Fa-f]{64})\s+\*?(.+)$') { + throw "Invalid staged GStreamer runtime hash entry: $line" + } + $expected = $Matches[1] + $relative = $Matches[2].Replace('\', '/') + if ([System.IO.Path]::IsPathRooted($relative) -or + @($relative -split '/').Contains('..') -or + -not $gstRuntimePaths.Add($relative)) { + throw "Unsafe or duplicate staged GStreamer runtime path: $relative" + } + $staged = Join-Path $root $relative + if (-not (Test-Path $staged -PathType Leaf)) { + throw "Manifested GStreamer runtime file is missing: $relative" + } + $actual = (Get-FileHash $staged -Algorithm SHA256).Hash + if ($actual -ne $expected) { + throw "Manifested GStreamer runtime file was replaced or modified: $relative" + } +} + +$gstProvenancePaths = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +foreach ($line in Get-Content $gstRuntimeProvenancePath) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $fields = @($line -split ([char]9), 2) + if ($fields.Count -ne 2 -or + $fields[1] -ne 'GStreamer 1.28.4 official MSVC x86_64 installer' -or + -not $gstProvenancePaths.Add($fields[0])) { + throw "Invalid or duplicate GStreamer runtime provenance entry: $line" + } +} +if (-not $gstProvenancePaths.SetEquals($gstRuntimePaths)) { + throw 'GStreamer hash and provenance manifests cover different files' +} + +foreach ($plugin in Get-ChildItem $pluginDir -File -Filter '*.dll') { + $relative = [System.IO.Path]::GetRelativePath($root, $plugin.FullName).Replace('\', '/') + if (-not $gstRuntimePaths.Contains($relative)) { + throw "Staged GStreamer plugin lacks hash/provenance coverage: $relative" + } +} +foreach ($tool in @($scanner, $gstInspect, $gstLaunch, $certificateBundle)) { + $relative = [System.IO.Path]::GetRelativePath($root, $tool).Replace('\', '/') + if (-not $gstRuntimePaths.Contains($relative)) { + throw "Staged GStreamer tool lacks hash/provenance coverage: $relative" + } +} +foreach ($module in Get-ChildItem $gioModuleDir -File -Filter '*.dll') { + $relative = [System.IO.Path]::GetRelativePath($root, $module.FullName).Replace('\', '/') + if (-not $gstRuntimePaths.Contains($relative)) { + throw "Staged GIO module lacks hash/provenance coverage: $relative" + } +} +foreach ($dll in Get-ChildItem $bin -File -Filter '*.dll') { + if ($manifestedNames.Contains($dll.Name)) { continue } + $relative = [System.IO.Path]::GetRelativePath($root, $dll.FullName).Replace('\', '/') + if (-not $gstRuntimePaths.Contains($relative)) { + throw "Staged DLL has neither libmpv nor GStreamer provenance: $relative" + } +} + +function Get-Imports([string]$Path) { + & $dumpbin /nologo /dependents $Path | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -match '^[A-Za-z0-9_.+\-]+\.dll$' } +} + +$searchDirs = @($root, $bin, $pluginDir, $gioModuleDir) | Where-Object { Test-Path $_ } +$queue = [System.Collections.Generic.Queue[string]]::new() +$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$localByName = @{} +foreach ($file in Get-ChildItem -Path $searchDirs -File -Filter '*.dll') { + if (-not $localByName.ContainsKey($file.Name)) { + $localByName[$file.Name] = $file.FullName + } elseif ((Get-FileHash $localByName[$file.Name] -Algorithm SHA256).Hash -ne + (Get-FileHash $file.FullName -Algorithm SHA256).Hash) { + throw "Conflicting staged DLLs share the name $($file.Name)" + } + $queue.Enqueue($file.FullName) +} +$queue.Enqueue((Join-Path $root 'ferrex-player.exe')) +$queue.Enqueue($scanner) +$queue.Enqueue($gstInspect) +$queue.Enqueue($gstLaunch) + +$resolved = 0 +while ($queue.Count -gt 0) { + $binary = $queue.Dequeue() + if (-not $seen.Add($binary)) { continue } + foreach ($dependency in Get-Imports $binary) { + if ($dependency -match $forbiddenRuntimePattern) { + throw "Restricted/GPL DLL import: $dependency required by $binary" + } + if ($dependency.StartsWith('api-ms-win-', [System.StringComparison]::OrdinalIgnoreCase) -or + $dependency.StartsWith('ext-ms-', [System.StringComparison]::OrdinalIgnoreCase)) { + continue + } + if ($localByName.ContainsKey($dependency)) { + $queue.Enqueue($localByName[$dependency]) + $resolved++ + continue + } + if (Test-Path (Join-Path $system32 $dependency)) { continue } + throw "Unresolved DLL import: $dependency required by $binary" + } +} + +$oldPath = $env:PATH +$oldPluginPath = $env:GST_PLUGIN_PATH +$oldPluginPath10 = $env:GST_PLUGIN_PATH_1_0 +$oldSystemPath10 = $env:GST_PLUGIN_SYSTEM_PATH_1_0 +$oldScanner = $env:GST_PLUGIN_SCANNER +$oldScanner10 = $env:GST_PLUGIN_SCANNER_1_0 +$oldRegistry = $env:GST_REGISTRY_1_0 +$oldGioModules = $env:GIO_EXTRA_MODULES +$oldSslCertFile = $env:SSL_CERT_FILE +$registry = Join-Path $env:TEMP "ferrex-gst-registry-$PID.bin" +$hlsFixtureDirectory = Join-Path $env:TEMP "ferrex-gst-hls-$PID" +try { + # Do not let the installer SDK exported by the build job mask a missing + # staged dependency. Windows' normal system DLL directories remain in the + # loader search order without inheriting the build machine's PATH. + $env:PATH = $bin + $env:GST_PLUGIN_PATH = $pluginDir + $env:GST_PLUGIN_PATH_1_0 = $pluginDir + $env:GST_PLUGIN_SYSTEM_PATH_1_0 = $pluginDir + $env:GST_PLUGIN_SCANNER = $scanner + $env:GST_PLUGIN_SCANNER_1_0 = $scanner + $env:GST_REGISTRY_1_0 = $registry + $env:GIO_EXTRA_MODULES = $gioModuleDir + $env:SSL_CERT_FILE = $certificateBundle + + $versionOutput = (& $gstInspect --version 2>&1) -join "`n" + if ($LASTEXITCODE -ne 0 -or $versionOutput -notmatch 'GStreamer\s+1\.28\.4') { + throw "Bundled gst-inspect version check failed: $versionOutput" + } + foreach ($factory in @( + 'playbin3', 'decodebin3', 'uridecodebin3', 'appsink', + 'videoconvertscale', 'audioconvert', 'audioresample', + 'volume', 'scaletempo', 'qtdemux', 'matroskademux', + 'tsdemux', 'hlsdemux2', 'souphttpsrc', 'aacparse', + 'h264parse', 'mfaacdec', 'openh264dec', 'assrender', + 'wasapi2sink', 'directsoundsink', 'audiotestsrc', + 'videotestsrc', 'mfaacenc', 'openh264enc', 'mpegtsmux' + )) { + $output = (& $gstInspect $factory 2>&1) -join "`n" + if ($LASTEXITCODE -ne 0) { + throw "Bundled GStreamer factory '$factory' is unavailable: $output" + } + } + + # Generate and then play a tiny HLS/AAC fixture using only the stage. This + # exercises adaptive demux, MPEG-TS, the reviewed system/BSD codec path, + # and playbin selection without depending on a mutable public media URL. + New-Item -ItemType Directory -Force -Path $hlsFixtureDirectory | Out-Null + $segment = Join-Path $hlsFixtureDirectory 'segment.ts' + $generateArgs = @( + '-q', 'mpegtsmux', 'name=mux', '!', 'filesink', "location=$segment", + 'audiotestsrc', 'wave=sine', 'num-buffers=96', '!', + 'audioconvert', '!', 'audioresample', '!', + 'audio/x-raw,rate=48000,channels=2', '!', 'mfaacenc', '!', + 'aacparse', '!', 'mux.', + 'videotestsrc', 'num-buffers=48', '!', + 'video/x-raw,width=320,height=180,framerate=24/1', '!', + 'videoconvertscale', '!', 'openh264enc', '!', 'h264parse', '!', 'mux.' + ) + $generateOutput = (& $gstLaunch @generateArgs 2>&1) -join [Environment]::NewLine + if ($LASTEXITCODE -ne 0) { + throw "Bundled GStreamer HLS fixture generation failed: $generateOutput" + } + $playlist = Join-Path $hlsFixtureDirectory 'stream.m3u8' + @( + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:3', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXTINF:2.048,', + 'segment.ts', + '#EXT-X-ENDLIST' + ) | Set-Content -Encoding ascii $playlist + $playlistUri = ([System.Uri]::new($playlist)).AbsoluteUri + $playArgs = @( + '-q', 'playbin3', "uri=$playlistUri", + 'audio-sink=fakesink', 'video-sink=fakesink' + ) + $playOutput = (& $gstLaunch @playArgs 2>&1) -join [Environment]::NewLine + if ($LASTEXITCODE -ne 0) { + throw "Bundled GStreamer HLS playback smoke failed: $playOutput" + } + + # Exercise the dynamically loaded GIO TLS backend and CA bundle from the + # clean stage; factory discovery alone does not prove HTTPS can connect. + $httpsUrl = 'https://gstreamer.freedesktop.org/data/pkg/windows/1.28.4/msvc/gstreamer-1.0-msvc-x86_64-1.28.4.exe.sha256sum' + $httpsArgs = @('-q', 'souphttpsrc', "location=$httpsUrl", '!', 'fakesink') + $httpsOutput = (& $gstLaunch @httpsArgs 2>&1) -join [Environment]::NewLine + if ($LASTEXITCODE -ne 0) { + throw "Bundled GStreamer HTTPS smoke failed: $httpsOutput" + } +} finally { + $env:PATH = $oldPath + $env:GST_PLUGIN_PATH = $oldPluginPath + $env:GST_PLUGIN_PATH_1_0 = $oldPluginPath10 + $env:GST_PLUGIN_SYSTEM_PATH_1_0 = $oldSystemPath10 + $env:GST_PLUGIN_SCANNER = $oldScanner + $env:GST_PLUGIN_SCANNER_1_0 = $oldScanner10 + $env:GST_REGISTRY_1_0 = $oldRegistry + $env:GIO_EXTRA_MODULES = $oldGioModules + $env:SSL_CERT_FILE = $oldSslCertFile + Remove-Item $registry -Force -ErrorAction SilentlyContinue + Remove-Item $hlsFixtureDirectory -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Verified Windows runtime closure, HLS playback, and HTTPS fallback ($($seen.Count) binaries, $resolved local imports)." From 68485f846a47d116545ce2b3d5732c4cdca938d0 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:20:48 -0600 Subject: [PATCH 12/56] feat(player): add the AppKit presenter and app-bundle handoff --- .github/workflows/macos-dist.yml | 251 +++ Cargo.lock | 4 + crates/ferrex-player-playback/Cargo.toml | 16 + crates/ferrex-player-playback/src/lib.rs | 6 + .../src/macos_presenter.rs | 1519 +++++++++++++++++ crates/ferrex-player/src/lib.rs | 3 + .../ferrex-player/src/macos_bundle_runtime.rs | 235 +++ crates/ferrex-player/src/main.rs | 3 + scripts/release/https_test_server.py | 33 + scripts/release/macos-build-libmpv.sh | 502 ++++++ scripts/release/macos-gstreamer-plugins.txt | 30 + scripts/release/macos_bundle.py | 1040 +++++++++++ .../release/macos_gstreamer_bundle_smoke.c | 120 ++ scripts/release/test_macos_build_profile.py | 163 ++ scripts/release/test_macos_bundle.py | 316 ++++ 15 files changed, 4241 insertions(+) create mode 100644 .github/workflows/macos-dist.yml create mode 100644 crates/ferrex-player-playback/src/macos_presenter.rs create mode 100644 crates/ferrex-player/src/macos_bundle_runtime.rs create mode 100644 scripts/release/https_test_server.py create mode 100644 scripts/release/macos-build-libmpv.sh create mode 100644 scripts/release/macos-gstreamer-plugins.txt create mode 100644 scripts/release/macos_bundle.py create mode 100644 scripts/release/macos_gstreamer_bundle_smoke.c create mode 100644 scripts/release/test_macos_build_profile.py create mode 100644 scripts/release/test_macos_bundle.py diff --git a/.github/workflows/macos-dist.yml b/.github/workflows/macos-dist.yml new file mode 100644 index 00000000..57ce8e9d --- /dev/null +++ b/.github/workflows/macos-dist.yml @@ -0,0 +1,251 @@ +name: macOS App Bundle + +on: + workflow_dispatch: + inputs: + presenter_mode: + description: Native AppKit presenter build mode + required: true + type: choice + default: spike + options: + - spike + - disabled + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build-macos-app: + name: Ferrex Player (${{ matrix.architecture }}, ${{ github.event_name == 'workflow_dispatch' && inputs.presenter_mode || 'disabled' }}) + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + target: aarch64-apple-darwin + architecture: arm64 + - runner: macos-15-intel + target: x86_64-apple-darwin + architecture: x86_64 + runs-on: ${{ matrix.runner }} + env: + MACOSX_DEPLOYMENT_TARGET: "15.0" + TARGET: ${{ matrix.target }} + MACOS_SIGNING_CERTIFICATE_P12: ${{ secrets.MACOS_SIGNING_CERTIFICATE_P12 }} + MACOS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_SIGNING_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + PRESENTER_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.presenter_mode || 'disabled' }} + FERREX_MPV_MACOS_PRESENTER: ${{ github.event_name == 'workflow_dispatch' && inputs.presenter_mode || 'disabled' }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install native build dependencies + run: | + brew update + brew install \ + pkg-config meson ninja nasm \ + freetype fribidi harfbuzz \ + molten-vk shaderc vulkan-headers vulkan-loader \ + ca-certificates glib glib-networking gstreamer libsoup openssl@3 + + - name: Build pinned LGPL libmpv + shell: bash + run: | + prefix="${RUNNER_TEMP}/ferrex-libmpv" + export PKG_CONFIG_PATH="$(brew --prefix vulkan-loader)/lib/pkgconfig:$(brew --prefix shaderc)/lib/pkgconfig:${PKG_CONFIG_PATH:-}" + bash scripts/release/macos-build-libmpv.sh "$prefix" + echo "PKG_CONFIG_PATH=$prefix/lib/pkgconfig:${PKG_CONFIG_PATH:-}" >> "$GITHUB_ENV" + echo "LIBRARY_PATH=$prefix/lib:${LIBRARY_PATH:-}" >> "$GITHUB_ENV" + echo "DYLD_FALLBACK_LIBRARY_PATH=$prefix/lib:${DYLD_FALLBACK_LIBRARY_PATH:-}" >> "$GITHUB_ENV" + + - name: Setup Rust + uses: moonrepo/setup-rust@v1 + with: + profile: minimal + targets: ${{ matrix.target }} + + - name: Check backend-disabled rollback build + run: cargo check -p ferrex-player --no-default-features --target "$TARGET" + + - name: Test display-free bundle policy + run: | + python3 -m unittest \ + scripts/release/test_macos_bundle.py \ + scripts/release/test_macos_build_profile.py + + - name: Build player with in-process mpv + run: cargo build -p ferrex-player --features mpv --release --target "$TARGET" + + - name: Import optional release signing identity + if: env.MACOS_SIGNING_CERTIFICATE_P12 != '' + shell: bash + run: | + keychain="${RUNNER_TEMP}/ferrex-signing.keychain-db" + certificate="${RUNNER_TEMP}/ferrex-signing.p12" + printf '%s' "$MACOS_SIGNING_CERTIFICATE_P12" | base64 -D > "$certificate" + security create-keychain -p temporary "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p temporary "$keychain" + security import "$certificate" -k "$keychain" -P "$MACOS_SIGNING_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k temporary "$keychain" + security list-keychains -d user -s "$keychain" login.keychain-db + + - name: Stage, rewrite, sign, and audit app bundle + shell: bash + run: | + version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(package["version"] for package in data["packages"] if package["name"] == "ferrex-player"))')" + identity="${MACOS_SIGNING_IDENTITY:--}" + app="${RUNNER_TEMP}/Ferrex Player.app" + prefix="${RUNNER_TEMP}/ferrex-libmpv" + search_roots=(--search-root "$prefix/lib") + while IFS= read -r formula; do + formula_lib="$(brew --prefix "$formula")/lib" + if [[ -d "$formula_lib" ]]; then + search_roots+=(--search-root "$formula_lib") + fi + done <"$prefix/share/ferrex/native-mpv/homebrew-formulae.txt" + gstreamer_prefix="$(brew --prefix gstreamer)" + molten_vk_prefix="$(brew --prefix molten-vk)" + libsoup_prefix="$(brew --prefix libsoup)" + gio_module="" + for candidate in "$(brew --prefix glib-networking)/lib/gio/modules"/libgiognutls.{so,dylib}; do + if [[ -f "$candidate" ]]; then + gio_module="$candidate" + break + fi + done + if [[ -z "$gio_module" ]]; then + echo "glib-networking TLS module was not installed" >&2 + exit 1 + fi + ca_bundle="$(brew --prefix ca-certificates)/share/ca-certificates/cacert.pem" + if [[ ! -f "$ca_bundle" ]]; then + echo "immutable Homebrew Mozilla CA bundle is missing" >&2 + exit 1 + fi + expected_ca_hash="$(sed -n 's/^ca_certificates_bundle_sha256=//p' "$prefix/share/ferrex/native-mpv/build-profile.txt")" + actual_ca_hash="$(shasum -a 256 "$ca_bundle" | awk '{ print $1 }')" + if [[ -z "$expected_ca_hash" || "$actual_ca_hash" != "$expected_ca_hash" ]]; then + echo "immutable Homebrew Mozilla CA bundle does not match the build profile" >&2 + exit 1 + fi + gstreamer_plugin_args=() + while IFS= read -r plugin; do + if [[ -n "$plugin" && "$plugin" != \#* ]]; then + gstreamer_plugin_args+=(--gstreamer-plugin "$gstreamer_prefix/lib/gstreamer-1.0/$plugin") + fi + done "$archive.sha256" + echo "ARCHIVE=$archive" >> "$GITHUB_ENV" + + - name: Smoke bundled GStreamer fallback factories + shell: bash + run: | + app="${RUNNER_TEMP}/Ferrex Player.app" + plugins="$app/Contents/PlugIns/gstreamer-1.0" + export GST_PLUGIN_SYSTEM_PATH_1_0="$plugins" + export GST_PLUGIN_PATH_1_0="$plugins" + export GST_PLUGIN_SCANNER_1_0="$app/Contents/Helpers/gst-plugin-scanner" + export GST_PLUGIN_SCANNER="$GST_PLUGIN_SCANNER_1_0" + export GST_REGISTRY_1_0="${RUNNER_TEMP}/ferrex-gstreamer-registry.bin" + export GIO_EXTRA_MODULES="$app/Contents/PlugIns/gio/modules" + unset DYLD_LIBRARY_PATH DYLD_FALLBACK_LIBRARY_PATH + fixture_dir="${RUNNER_TEMP}/ferrex-gstreamer-hls" + mkdir -p "$fixture_dir" + "$(brew --prefix ffmpeg)/bin/ffmpeg" \ + -hide_banner -loglevel error -y \ + -f lavfi -i testsrc2=size=320x180:rate=24:duration=2 \ + -f lavfi -i sine=frequency=440:sample_rate=48000:duration=2 \ + -c:v libx264 -pix_fmt yuv420p -c:a aac \ + -f hls -hls_time 1 -hls_list_size 0 "$fixture_dir/stream.m3u8" + cc scripts/release/macos_gstreamer_bundle_smoke.c \ + $(pkg-config --cflags gstreamer-1.0 gio-2.0) \ + -L"$app/Contents/Frameworks" \ + -Wl,-rpath,"$app/Contents/Frameworks" \ + $(pkg-config --libs-only-l gstreamer-1.0 gio-2.0) \ + -o "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" + if otool -L "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" | \ + grep -E '/opt/homebrew/|/usr/local/|/nix/store/' >/dev/null; then + echo "clean-bundle smoke helper links a developer/package-manager path" >&2 + exit 1 + fi + python3 -m http.server 18765 --bind 127.0.0.1 --directory "$fixture_dir" & + http_server_pid=$! + openssl="$(brew --prefix openssl@3)/bin/openssl" + "$openssl" req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj /CN=FerrexTestCA \ + -addext basicConstraints=critical,CA:TRUE \ + -addext keyUsage=critical,keyCertSign,cRLSign \ + -keyout "${RUNNER_TEMP}/ferrex-ca.key" \ + -out "${RUNNER_TEMP}/ferrex-ca.crt" >/dev/null 2>&1 + "$openssl" req -newkey rsa:2048 -nodes \ + -subj /CN=127.0.0.1 \ + -addext subjectAltName=IP:127.0.0.1 \ + -keyout "${RUNNER_TEMP}/ferrex-test.key" \ + -out "${RUNNER_TEMP}/ferrex-test.csr" >/dev/null 2>&1 + "$openssl" x509 -req -days 1 \ + -in "${RUNNER_TEMP}/ferrex-test.csr" \ + -CA "${RUNNER_TEMP}/ferrex-ca.crt" \ + -CAkey "${RUNNER_TEMP}/ferrex-ca.key" \ + -CAcreateserial -copy_extensions copy \ + -out "${RUNNER_TEMP}/ferrex-test.crt" >/dev/null 2>&1 + python3 scripts/release/https_test_server.py \ + --directory "$fixture_dir" \ + --certificate "${RUNNER_TEMP}/ferrex-test.crt" \ + --key "${RUNNER_TEMP}/ferrex-test.key" \ + --port 18766 & + https_server_pid=$! + trap 'kill "$http_server_pid" "$https_server_pid" 2>/dev/null || true' EXIT + curl --fail --retry 20 --retry-connrefused --retry-delay 1 \ + http://127.0.0.1:18765/stream.m3u8 >/dev/null + curl --insecure --fail --retry 20 --retry-connrefused --retry-delay 1 \ + https://127.0.0.1:18766/stream.m3u8 >/dev/null + "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ + http://127.0.0.1:18765/stream.m3u8 \ + "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ + "$app/Contents/Resources/tls/cacert.pem" + if "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ + https://127.0.0.1:18766/stream.m3u8 \ + "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ + "$app/Contents/Resources/tls/cacert.pem"; then + echo "strict HTTPS smoke unexpectedly trusted the unbundled test CA" >&2 + exit 1 + fi + "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ + https://127.0.0.1:18766/stream.m3u8 \ + "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ + "${RUNNER_TEMP}/ferrex-ca.crt" + + - name: Upload app artifact + uses: actions/upload-artifact@v4 + with: + name: ferrex-player-macos-${{ matrix.architecture }}-${{ env.PRESENTER_MODE }} + path: | + ${{ env.ARCHIVE }} + ${{ env.ARCHIVE }}.sha256 diff --git a/Cargo.lock b/Cargo.lock index 67776809..7766d76f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2393,6 +2393,9 @@ dependencies = [ "iced_wgpu", "log", "lucide-icons", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "profiling", "serde", "serde_json", @@ -5321,6 +5324,7 @@ dependencies = [ "bitflags 2.11.0", "block2 0.6.2", "objc2 0.6.4", + "objc2-core-foundation", "objc2-foundation 0.3.2", ] diff --git a/crates/ferrex-player-playback/Cargo.toml b/crates/ferrex-player-playback/Cargo.toml index df68e10b..26d5cf11 100644 --- a/crates/ferrex-player-playback/Cargo.toml +++ b/crates/ferrex-player-playback/Cargo.toml @@ -65,3 +65,19 @@ windows-sys = { version = "0.61.2", features = [ "Win32_UI_HiDpi", "Win32_UI_WindowsAndMessaging", ] } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6.4" +objc2-app-kit = { version = "0.3.2", default-features = false, features = [ + "std", + "NSGraphics", + "NSResponder", + "NSView", + "NSWindow", + "objc2-core-foundation", +] } +objc2-foundation = { version = "0.3.2", default-features = false, features = [ + "std", + "NSGeometry", + "NSObject", +] } diff --git a/crates/ferrex-player-playback/src/lib.rs b/crates/ferrex-player-playback/src/lib.rs index 5499dd96..86ce655f 100644 --- a/crates/ferrex-player-playback/src/lib.rs +++ b/crates/ferrex-player-playback/src/lib.rs @@ -15,6 +15,12 @@ pub mod controls; mod diagnostics; /// External MPV process integration for HDR passthrough. pub mod external_mpv; +/// macOS AppKit native-root presenter capability and fallback gate. +/// +/// Normal builds compile this only on macOS. Unit tests compile its pure +/// evidence model on every host so conservative fallback remains display-free. +#[cfg(any(target_os = "macos", test))] +pub mod macos_presenter; /// Playback message and subscription DTOs. pub mod messages; #[cfg(feature = "mpv")] diff --git a/crates/ferrex-player-playback/src/macos_presenter.rs b/crates/ferrex-player-playback/src/macos_presenter.rs new file mode 100644 index 00000000..402292f9 --- /dev/null +++ b/crates/ferrex-player-playback/src/macos_presenter.rs @@ -0,0 +1,1519 @@ +//! Conservative capability gate for the macOS native-root presenter. +//! +//! mpv's modern macOS backend owns its `NSWindow` and video layer. Ferrex may +//! place a transparent Iced child window above that native root only after the +//! relationship has been proven across AppKit lifetime, fullscreen, Spaces, +//! scale, and teardown transitions. Until then this module deterministically +//! selects mpv's ordinary native window and never advertises `wid` embedding. + +use std::{fmt, num::NonZeroUsize}; + +use crate::{ + contract::{ + FallbackReason, FallbackReasonCode, PlaybackError, PlaybackErrorKind, + PlaybackTarget, + }, + presenter::{ + FullscreenOwner, NativePresenter, PresenterCapabilities, + PresenterIdentity, SurfaceGeometry, + }, +}; + +/// Build-time switch used to compile the developer AppKit presenter path. +pub const MACOS_PRESENTER_BUILD_ENV: &str = "FERREX_MPV_MACOS_PRESENTER"; + +/// Release-safe build mode for the native-root presenter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MacOsPresenterBuildMode { + /// Integrated presentation is unavailable and selection falls back. + #[default] + Disabled, + /// Explicit developer/hardware-validation path; never selected by Auto. + Spike, +} + +impl MacOsPresenterBuildMode { + /// Parse the build environment. Unknown values fail closed. + pub fn parse(value: Option<&str>) -> Result { + match value.map(str::trim) { + None | Some("") | Some("disabled") => Ok(Self::Disabled), + Some("spike") => Ok(Self::Spike), + Some(value) => { + Err(MacOsPresenterError::InvalidBuildMode(value.to_owned())) + } + } + } + + /// Mode compiled into a macOS target. + #[cfg(target_os = "macos")] + pub fn compiled() -> Self { + Self::parse(option_env!("FERREX_MPV_MACOS_PRESENTER")) + .unwrap_or(Self::Disabled) + } +} + +/// Native relationship under evaluation for macOS integration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacOsPresenterStrategy { + /// mpv owns the root `NSWindow`; a transparent Iced child follows it. + NativeRootChildWindow, +} + +/// Stable, non-sensitive reason an integrated presenter is not available. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacOsPresenterBlocker { + AppKitMainThreadUnavailable, + MpvWindowUnavailable, + MpvWindowLifetimeUnverified, + ChildWindowRelationshipUnverified, + ContentLayoutUnverified, + BackingScaleUnverified, + FocusOcclusionUnverified, + FullscreenUnverified, + SpacesUnverified, + HideUnhideUnverified, + TeardownUnverified, +} + +impl MacOsPresenterBlocker { + /// Machine-stable label suitable for diagnostics and fallback logs. + pub const fn as_str(self) -> &'static str { + match self { + Self::AppKitMainThreadUnavailable => "appkit_main_thread", + Self::MpvWindowUnavailable => "mpv_window", + Self::MpvWindowLifetimeUnverified => "mpv_window_lifetime", + Self::ChildWindowRelationshipUnverified => { + "child_window_relationship" + } + Self::ContentLayoutUnverified => "content_layout", + Self::BackingScaleUnverified => "backing_scale", + Self::FocusOcclusionUnverified => "focus_occlusion", + Self::FullscreenUnverified => "native_fullscreen", + Self::SpacesUnverified => "spaces", + Self::HideUnhideUnverified => "app_hide_unhide", + Self::TeardownUnverified => "detach_before_teardown", + } + } +} + +/// Evidence collected by the macOS AppKit spike/integration harness. +/// +/// This deliberately stores no raw `NSWindow`, `NSView`, or `window-id` value; +/// diagnostics can report availability without leaking or retaining pointers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct MacOsPresenterEvidence { + pub appkit_main_thread: bool, + pub mpv_window_available: bool, + pub mpv_window_lifetime_verified: bool, + pub child_window_relationship_verified: bool, + pub content_layout_verified: bool, + pub backing_scale_verified: bool, + pub focus_occlusion_verified: bool, + pub native_fullscreen_verified: bool, + pub spaces_verified: bool, + pub app_hide_unhide_verified: bool, + pub detach_before_teardown_verified: bool, + pub native_hdr_verified: bool, +} + +impl MacOsPresenterEvidence { + /// Evidence fixture representing a completed non-HDR integration spike. + /// Production code must populate equivalent facts from the platform gate; + /// this constructor does not itself enable any backend selector. + #[cfg(test)] + const fn verified() -> Self { + Self { + appkit_main_thread: true, + mpv_window_available: true, + mpv_window_lifetime_verified: true, + child_window_relationship_verified: true, + content_layout_verified: true, + backing_scale_verified: true, + focus_occlusion_verified: true, + native_fullscreen_verified: true, + spaces_verified: true, + app_hide_unhide_verified: true, + detach_before_teardown_verified: true, + native_hdr_verified: false, + } + } + + fn blockers(self) -> Vec { + let checks = [ + ( + self.appkit_main_thread, + MacOsPresenterBlocker::AppKitMainThreadUnavailable, + ), + ( + self.mpv_window_available, + MacOsPresenterBlocker::MpvWindowUnavailable, + ), + ( + self.mpv_window_lifetime_verified, + MacOsPresenterBlocker::MpvWindowLifetimeUnverified, + ), + ( + self.child_window_relationship_verified, + MacOsPresenterBlocker::ChildWindowRelationshipUnverified, + ), + ( + self.content_layout_verified, + MacOsPresenterBlocker::ContentLayoutUnverified, + ), + ( + self.backing_scale_verified, + MacOsPresenterBlocker::BackingScaleUnverified, + ), + ( + self.focus_occlusion_verified, + MacOsPresenterBlocker::FocusOcclusionUnverified, + ), + ( + self.native_fullscreen_verified, + MacOsPresenterBlocker::FullscreenUnverified, + ), + ( + self.spaces_verified, + MacOsPresenterBlocker::SpacesUnverified, + ), + ( + self.app_hide_unhide_verified, + MacOsPresenterBlocker::HideUnhideUnverified, + ), + ( + self.detach_before_teardown_verified, + MacOsPresenterBlocker::TeardownUnverified, + ), + ]; + checks + .into_iter() + .filter_map(|(verified, blocker)| (!verified).then_some(blocker)) + .collect() + } +} + +/// Capability and fallback result for one macOS platform probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MacOsPresenterDecision { + pub strategy: MacOsPresenterStrategy, + pub capabilities: PresenterCapabilities, + pub blockers: Vec, + pub fallback: Option, +} + +impl MacOsPresenterDecision { + /// Evaluate integrated presentation without depending on `wid`. + pub fn evaluate(evidence: MacOsPresenterEvidence) -> Self { + let blockers = evidence.blockers(); + let integrated = blockers.is_empty(); + let capabilities = PresenterCapabilities { + integrated_overlay: integrated, + embedded_surface: false, + native_hdr: integrated && evidence.native_hdr_verified, + fractional_scaling: integrated && evidence.backing_scale_verified, + native_window_fallback: true, + fullscreen_owner: integrated + .then_some(FullscreenOwner::VideoOutput), + compositor_requirement: None, + }; + let fallback = (!integrated).then(|| FallbackReason { + code: FallbackReasonCode::MissingCapability, + from: Some(PlaybackTarget::MPV_INTEGRATED), + to: PlaybackTarget::MPV_NATIVE_WINDOW, + detail: format!( + "macOS native-root presenter unavailable: {}", + blockers + .iter() + .map(|blocker| blocker.as_str()) + .collect::>() + .join(",") + ), + }); + + Self { + strategy: MacOsPresenterStrategy::NativeRootChildWindow, + capabilities, + blockers, + fallback, + } + } + + /// Modern mpv's macOS native-root strategy never requires `wid`. + pub const fn requires_wid(&self) -> bool { + false + } +} + +/// Capabilities of the developer AppKit presenter path. +/// +/// The spike is intentionally explicit-only until the manual display, Spaces, +/// fullscreen, HDR, and teardown matrix has been recorded. In particular, +/// this function does not claim native HDR support. +pub fn macos_presenter_capabilities( + build_mode: MacOsPresenterBuildMode, +) -> PresenterCapabilities { + PresenterCapabilities { + integrated_overlay: matches!( + build_mode, + MacOsPresenterBuildMode::Spike + ), + embedded_surface: false, + native_hdr: false, + fractional_scaling: true, + native_window_fallback: true, + fullscreen_owner: Some(FullscreenOwner::VideoOutput), + compositor_requirement: Some( + "macOS AppKit child-window composition".to_owned(), + ), + } +} + +/// Opaque, non-null AppKit `NSWindow` identity. +/// +/// For mpv v0.41's macOS VO, `VOCTRL_GET_WINDOW_ID` returns the `NSWindow` +/// pointer bit-cast to `i64`. This is an output observation; it is not mpv's +/// unsupported macOS `wid` input option. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacOsWindow(NonZeroUsize); + +impl MacOsWindow { + /// Convert mpv's read-only `window-id` property to an opaque window. + pub fn from_mpv_window_id(value: i64) -> Result { + let pointer_bits = value as u64; + let raw = usize::try_from(pointer_bits) + .ok() + .and_then(NonZeroUsize::new) + .ok_or(MacOsPresenterError::InvalidMpvWindowId)?; + Ok(Self(raw)) + } + + /// Wrap a non-null pointer obtained from an AppKit object lease. + pub const fn from_non_zero(value: NonZeroUsize) -> Self { + Self(value) + } + + pub const fn get(self) -> usize { + self.0.get() + } +} + +impl fmt::Debug for MacOsWindow { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MacOsWindow()") + } +} + +/// Iced overlay window borrowed for one AppKit attach operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MacOsPresenterHost { + pub overlay: MacOsWindow, +} + +#[cfg(all(target_os = "macos", feature = "ui"))] +impl MacOsPresenterHost { + /// Resolve Iced's AppKit `NSView` lease to its owning `NSWindow`. + pub fn from_captured_iced_host( + host: &crate::native_video_slot::CapturedIcedHost, + ) -> Result { + use iced::window::raw_window_handle::{ + HasWindowHandle, RawWindowHandle, + }; + use objc2::rc::Retained; + use objc2_app_kit::NSView; + + let raw = host.window_handle().map_err(|error| { + presenter_error(format!( + "could not borrow Iced AppKit overlay handle: {error}" + )) + })?; + let RawWindowHandle::AppKit(handle) = raw.as_raw() else { + return Err(presenter_error( + "captured Iced host is not an AppKit window", + )); + }; + + // SAFETY: raw-window-handle guarantees that `ns_view` names a live + // NSView for the duration of the host lease. Retaining it keeps the + // object alive while AppKit resolves the owning top-level window. + let view = unsafe { + Retained::::retain(handle.ns_view.as_ptr().cast()) + } + .ok_or_else(|| { + presenter_error("Iced AppKit NSView is no longer live") + })?; + let window = view.window().ok_or_else(|| { + presenter_error("Iced AppKit NSView is not installed in a window") + })?; + let raw = NonZeroUsize::new(Retained::as_ptr(&window) as usize) + .ok_or_else(|| presenter_error("Iced AppKit NSWindow is null"))?; + Ok(Self { + overlay: MacOsWindow::from_non_zero(raw), + }) + } +} + +/// Logical screen rectangle used to align the transparent overlay. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MacOsScreenRect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl MacOsScreenRect { + fn validate(self) -> Result { + if [self.x, self.y, self.width, self.height] + .into_iter() + .all(f64::is_finite) + && self.width >= 0.0 + && self.height >= 0.0 + { + Ok(self) + } else { + Err(MacOsPresenterError::Operation( + "AppKit returned invalid content geometry".to_owned(), + )) + } + } +} + +/// AppKit observations retained by the spike for scale/occlusion diagnostics. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MacOsWindowSnapshot { + pub content_rect: MacOsScreenRect, + pub backing_scale_factor: f64, + pub visible_on_active_space: bool, + pub occluded: bool, + pub miniaturized: bool, +} + +/// AppKit operations isolated behind a display-free fakeable interface. +pub trait MacOsWindowSystem { + /// Retain a trusted AppKit window identity for subsequent operations. + fn retain_window( + &mut self, + window: MacOsWindow, + ) -> Result<(), MacOsPresenterError>; + fn release_window(&mut self, window: MacOsWindow); + fn is_window(&self, window: MacOsWindow) -> bool; + fn parent_window(&self, window: MacOsWindow) -> Option; + fn collection_behavior( + &self, + window: MacOsWindow, + ) -> Result; + fn set_collection_behavior( + &mut self, + window: MacOsWindow, + behavior: u64, + ) -> Result<(), MacOsPresenterError>; + fn ignores_mouse_events( + &self, + window: MacOsWindow, + ) -> Result; + fn set_ignores_mouse_events( + &mut self, + window: MacOsWindow, + ignores: bool, + ) -> Result<(), MacOsPresenterError>; + fn add_child_above( + &mut self, + root: MacOsWindow, + child: MacOsWindow, + ) -> Result<(), MacOsPresenterError>; + fn remove_child(&mut self, root: MacOsWindow, child: MacOsWindow); + fn snapshot( + &self, + root: MacOsWindow, + ) -> Result; + fn position_overlay( + &mut self, + overlay: MacOsWindow, + rect: MacOsScreenRect, + ) -> Result<(), MacOsPresenterError>; + fn set_visible_without_activation( + &mut self, + overlay: MacOsWindow, + visible: bool, + ) -> Result<(), MacOsPresenterError>; + fn activate( + &mut self, + window: MacOsWindow, + ) -> Result<(), MacOsPresenterError>; +} + +const COLLECTION_TRANSIENT: u64 = 1 << 3; +const COLLECTION_FULLSCREEN_AUXILIARY: u64 = 1 << 8; + +#[derive(Debug, Clone, Copy)] +struct MacOsAttachment { + identity: PresenterIdentity, + overlay: MacOsWindow, + original_collection_behavior: u64, + original_ignores_mouse_events: bool, +} + +/// UI-thread-local AppKit native-root/child-overlay presenter. +/// +/// mpv retains fullscreen ownership. The callback serializes a fullscreen +/// request through mpv; the presenter never independently toggles the root. +pub struct MacOsPresenter { + windows: W, + fullscreen: F, + video_root: MacOsWindow, + build_mode: MacOsPresenterBuildMode, + capabilities: PresenterCapabilities, + attachment: Option, + requested_visible: bool, + suspended: bool, + geometry_visible: bool, + applied_visible: Option, + last_snapshot: Option, +} + +impl fmt::Debug for MacOsPresenter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MacOsPresenter") + .field("video_root", &self.video_root) + .field("build_mode", &self.build_mode) + .field("capabilities", &self.capabilities) + .field("attachment", &self.attachment) + .field("requested_visible", &self.requested_visible) + .field("suspended", &self.suspended) + .field("geometry_visible", &self.geometry_visible) + .field("applied_visible", &self.applied_visible) + .field("last_snapshot", &self.last_snapshot) + .finish_non_exhaustive() + } +} + +impl MacOsPresenter +where + W: MacOsWindowSystem, + F: FnMut(bool) -> Result<(), PlaybackError>, +{ + pub fn new( + windows: W, + fullscreen: F, + video_root: MacOsWindow, + build_mode: MacOsPresenterBuildMode, + ) -> Self { + Self { + windows, + fullscreen, + video_root, + build_mode, + capabilities: macos_presenter_capabilities(build_mode), + attachment: None, + requested_visible: false, + suspended: false, + geometry_visible: false, + applied_visible: None, + last_snapshot: None, + } + } + + pub const fn relationship(&self) -> MacOsPresenterStrategy { + MacOsPresenterStrategy::NativeRootChildWindow + } + + pub const fn last_snapshot(&self) -> Option { + self.last_snapshot + } + + /// Explicit focus handoff for keyboard navigation/input testing. + pub fn focus_overlay(&mut self) -> Result<(), PlaybackError> { + let attachment = self.attachment.ok_or_else(|| { + presenter_error("macOS presenter is not attached") + })?; + self.windows + .activate(attachment.overlay) + .map_err(Into::into) + } + + fn ensure_identity( + &self, + identity: PresenterIdentity, + ) -> Result { + match self.attachment { + Some(attachment) if attachment.identity == identity => { + Ok(attachment) + } + Some(_) => Err(presenter_error( + "macOS presenter rejected a stale attachment generation", + )), + None => Err(presenter_error("macOS presenter is not attached")), + } + } + + fn refresh_position_and_visibility( + &mut self, + attachment: MacOsAttachment, + ) -> Result<(), PlaybackError> { + if !self.windows.is_window(self.video_root) + || !self.windows.is_window(attachment.overlay) + { + return Err(presenter_error( + "macOS presenter window was destroyed before synchronization", + )); + } + let snapshot = self.windows.snapshot(self.video_root)?; + let rect = snapshot.content_rect.validate()?; + if !snapshot.backing_scale_factor.is_finite() + || snapshot.backing_scale_factor <= 0.0 + { + return Err(presenter_error( + "AppKit returned an invalid backing scale factor", + )); + } + if self.last_snapshot.map(|snapshot| snapshot.content_rect) + != Some(rect) + { + self.windows.position_overlay(attachment.overlay, rect)?; + } + let visible = self.requested_visible + && !self.suspended + && self.geometry_visible + && snapshot.visible_on_active_space + && !snapshot.miniaturized; + // Do not hide solely from the root's occlusion bit. AppKit may count + // this presenter's own transparent child as occluding the mpv root, + // which would otherwise create a show/hide feedback loop. Parent/child + // ordering already follows other-app occlusion. + if self.applied_visible != Some(visible) { + self.windows + .set_visible_without_activation(attachment.overlay, visible)?; + self.applied_visible = Some(visible); + } + self.last_snapshot = Some(snapshot); + Ok(()) + } + + fn restore_attachment(&mut self, attachment: MacOsAttachment) { + if self.windows.is_window(attachment.overlay) { + let _ = self + .windows + .set_visible_without_activation(attachment.overlay, false); + if self.windows.parent_window(attachment.overlay) + == Some(self.video_root) + { + self.windows + .remove_child(self.video_root, attachment.overlay); + } + let _ = self.windows.set_collection_behavior( + attachment.overlay, + attachment.original_collection_behavior, + ); + let _ = self.windows.set_ignores_mouse_events( + attachment.overlay, + attachment.original_ignores_mouse_events, + ); + } + self.windows.release_window(attachment.overlay); + self.applied_visible = None; + } +} + +impl NativePresenter for MacOsPresenter +where + W: MacOsWindowSystem + 'static, + F: FnMut(bool) -> Result<(), PlaybackError> + 'static, +{ + type Host<'host> + = MacOsPresenterHost + where + Self: 'host; + + fn attach( + &mut self, + identity: PresenterIdentity, + host: Self::Host<'_>, + ) -> Result<(), PlaybackError> { + if !matches!(self.build_mode, MacOsPresenterBuildMode::Spike) { + return Err(presenter_error( + "macOS integrated presenter is disabled in this build", + )); + } + if self.attachment.is_some() { + return Err(presenter_error( + "macOS presenter attach was requested more than once", + )); + } + if host.overlay == self.video_root { + return Err(presenter_error( + "macOS presenter received identical root and overlay windows", + )); + } + self.windows.retain_window(host.overlay)?; + if !self.windows.is_window(host.overlay) + || !self.windows.is_window(self.video_root) + { + self.windows.release_window(host.overlay); + return Err(presenter_error( + "macOS presenter received a stale AppKit window", + )); + } + if self.windows.parent_window(host.overlay).is_some() { + self.windows.release_window(host.overlay); + return Err(presenter_error( + "Iced AppKit overlay already has a parent window", + )); + } + + let original_collection_behavior = + match self.windows.collection_behavior(host.overlay) { + Ok(behavior) => behavior, + Err(error) => { + self.windows.release_window(host.overlay); + return Err(error.into()); + } + }; + let original_ignores_mouse_events = + match self.windows.ignores_mouse_events(host.overlay) { + Ok(ignores) => ignores, + Err(error) => { + self.windows.release_window(host.overlay); + return Err(error.into()); + } + }; + let attachment = MacOsAttachment { + identity, + overlay: host.overlay, + original_collection_behavior, + original_ignores_mouse_events, + }; + + let behavior = original_collection_behavior + | COLLECTION_TRANSIENT + | COLLECTION_FULLSCREEN_AUXILIARY; + let setup = (|| { + self.windows + .set_visible_without_activation(host.overlay, false)?; + self.applied_visible = Some(false); + self.windows.set_ignores_mouse_events(host.overlay, false)?; + self.windows + .set_collection_behavior(host.overlay, behavior)?; + self.windows.add_child_above(self.video_root, host.overlay) + })(); + if let Err(error) = setup { + self.restore_attachment(attachment); + return Err(error.into()); + } + + self.attachment = Some(attachment); + if let Err(error) = self.refresh_position_and_visibility(attachment) { + self.attachment = None; + self.restore_attachment(attachment); + return Err(error); + } + Ok(()) + } + + fn synchronize( + &mut self, + identity: PresenterIdentity, + geometry: SurfaceGeometry, + ) -> Result<(), PlaybackError> { + geometry.validate().map_err(|error| { + presenter_error(format!( + "macOS presenter rejected host geometry: {error}" + )) + })?; + let attachment = self.ensure_identity(identity)?; + self.geometry_visible = geometry.is_visible(); + self.refresh_position_and_visibility(attachment) + } + + fn set_visible( + &mut self, + identity: PresenterIdentity, + visible: bool, + ) -> Result<(), PlaybackError> { + let attachment = self.ensure_identity(identity)?; + self.requested_visible = visible; + self.refresh_position_and_visibility(attachment) + } + + fn set_suspended( + &mut self, + identity: PresenterIdentity, + suspended: bool, + ) -> Result<(), PlaybackError> { + let attachment = self.ensure_identity(identity)?; + self.suspended = suspended; + self.refresh_position_and_visibility(attachment) + } + + fn set_fullscreen( + &mut self, + identity: PresenterIdentity, + owner: FullscreenOwner, + fullscreen: bool, + ) -> Result<(), PlaybackError> { + let _ = self.ensure_identity(identity)?; + if owner != FullscreenOwner::VideoOutput { + return Err(presenter_error( + "macOS native-root mode requires mpv to own fullscreen", + )); + } + (self.fullscreen)(fullscreen) + } + + fn detach(&mut self, identity: PresenterIdentity) { + let Some(attachment) = self.attachment else { + return; + }; + if attachment.identity != identity { + return; + } + self.attachment = None; + self.requested_visible = false; + self.geometry_visible = false; + self.applied_visible = None; + self.last_snapshot = None; + self.restore_attachment(attachment); + } + + fn capabilities(&self) -> &PresenterCapabilities { + &self.capabilities + } +} + +/// Target-native AppKit implementation. The retained objects and +/// `MainThreadMarker` make this value event-loop-local and non-`Send`. +#[cfg(target_os = "macos")] +pub struct AppKitWindowSystem { + _main_thread: objc2::MainThreadMarker, + windows: std::collections::HashMap< + MacOsWindow, + objc2::rc::Retained, + >, +} + +#[cfg(target_os = "macos")] +impl fmt::Debug for AppKitWindowSystem { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AppKitWindowSystem") + .field("retained_window_count", &self.windows.len()) + .finish() + } +} + +#[cfg(target_os = "macos")] +impl AppKitWindowSystem { + /// Whether the caller is the AppKit main thread. + pub fn main_thread_available() -> bool { + objc2::MainThreadMarker::new().is_some() + } + + /// Retain mpv's live `NSWindow` on the AppKit main thread. + /// + /// Call this only after `vo-configured=true` and a non-zero macOS + /// `window-id` observation. mpv owns the source pointer contract. + pub fn new(video_root: MacOsWindow) -> Result { + let main_thread = objc2::MainThreadMarker::new() + .ok_or(MacOsPresenterError::AppKitMainThreadRequired)?; + let root = Self::retain_native(video_root)?; + Ok(Self { + _main_thread: main_thread, + windows: std::collections::HashMap::from([(video_root, root)]), + }) + } + + pub fn is_live(&self, window: MacOsWindow) -> bool { + self.windows.contains_key(&window) + } + + fn retain_native( + window: MacOsWindow, + ) -> Result, MacOsPresenterError> + { + // SAFETY: callers obtain identities only from mpv's macOS + // VOCTRL_GET_WINDOW_ID or NSView.window. Both are NSWindow pointers, + // and construction is restricted to the AppKit main thread. + unsafe { + objc2::rc::Retained::retain( + window.get() as *mut objc2_app_kit::NSWindow + ) + } + .ok_or_else(|| { + MacOsPresenterError::Operation( + "could not retain AppKit NSWindow".to_owned(), + ) + }) + } + + fn window( + &self, + window: MacOsWindow, + ) -> Result<&objc2_app_kit::NSWindow, MacOsPresenterError> { + self.windows.get(&window).map(AsRef::as_ref).ok_or_else(|| { + MacOsPresenterError::Operation( + "AppKit window lease is unavailable".to_owned(), + ) + }) + } + + fn identity(window: &objc2_app_kit::NSWindow) -> MacOsWindow { + let raw = NonZeroUsize::new(window as *const _ as usize) + .expect("Objective-C object references are non-null"); + MacOsWindow::from_non_zero(raw) + } +} + +#[cfg(target_os = "macos")] +impl MacOsWindowSystem for AppKitWindowSystem { + fn retain_window( + &mut self, + window: MacOsWindow, + ) -> Result<(), MacOsPresenterError> { + if !self.windows.contains_key(&window) { + self.windows.insert(window, Self::retain_native(window)?); + } + Ok(()) + } + + fn release_window(&mut self, window: MacOsWindow) { + self.windows.remove(&window); + } + + fn is_window(&self, window: MacOsWindow) -> bool { + self.is_live(window) + } + + fn parent_window(&self, window: MacOsWindow) -> Option { + self.window(window) + .ok() + .and_then(objc2_app_kit::NSWindow::parentWindow) + .as_deref() + .map(Self::identity) + } + + fn collection_behavior( + &self, + window: MacOsWindow, + ) -> Result { + Ok(self.window(window)?.collectionBehavior().0 as u64) + } + + fn set_collection_behavior( + &mut self, + window: MacOsWindow, + behavior: u64, + ) -> Result<(), MacOsPresenterError> { + let behavior = usize::try_from(behavior).map_err(|_| { + MacOsPresenterError::Operation( + "AppKit collection behavior is out of range".to_owned(), + ) + })?; + self.window(window)?.setCollectionBehavior( + objc2_app_kit::NSWindowCollectionBehavior::from_bits_retain( + behavior, + ), + ); + Ok(()) + } + + fn ignores_mouse_events( + &self, + window: MacOsWindow, + ) -> Result { + Ok(self.window(window)?.ignoresMouseEvents()) + } + + fn set_ignores_mouse_events( + &mut self, + window: MacOsWindow, + ignores: bool, + ) -> Result<(), MacOsPresenterError> { + self.window(window)?.setIgnoresMouseEvents(ignores); + Ok(()) + } + + fn add_child_above( + &mut self, + root: MacOsWindow, + child: MacOsWindow, + ) -> Result<(), MacOsPresenterError> { + // SAFETY: both retained objects are NSWindows, have no existing child + // relationship, and this is executed on the AppKit main thread. + unsafe { + self.window(root)?.addChildWindow_ordered( + self.window(child)?, + objc2_app_kit::NSWindowOrderingMode::Above, + ); + } + Ok(()) + } + + fn remove_child(&mut self, root: MacOsWindow, child: MacOsWindow) { + if let (Ok(root), Ok(child)) = (self.window(root), self.window(child)) { + root.removeChildWindow(child); + } + } + + fn snapshot( + &self, + root: MacOsWindow, + ) -> Result { + let root = self.window(root)?; + let content = root.contentView().ok_or_else(|| { + MacOsPresenterError::Operation( + "mpv AppKit window has no content view".to_owned(), + ) + })?; + let window_rect = content.convertRect_toView(content.bounds(), None); + let screen_rect = root.convertRectToScreen(window_rect); + let occluded = !root + .occlusionState() + .contains(objc2_app_kit::NSWindowOcclusionState::Visible); + Ok(MacOsWindowSnapshot { + content_rect: MacOsScreenRect { + x: screen_rect.origin.x, + y: screen_rect.origin.y, + width: screen_rect.size.width, + height: screen_rect.size.height, + }, + backing_scale_factor: root.backingScaleFactor(), + visible_on_active_space: root.isVisible() && root.isOnActiveSpace(), + occluded, + miniaturized: root.isMiniaturized(), + }) + } + + fn position_overlay( + &mut self, + overlay: MacOsWindow, + rect: MacOsScreenRect, + ) -> Result<(), MacOsPresenterError> { + let rect = rect.validate()?; + self.window(overlay)?.setFrame_display( + objc2_foundation::NSRect::new( + objc2_foundation::NSPoint::new(rect.x, rect.y), + objc2_foundation::NSSize::new(rect.width, rect.height), + ), + true, + ); + Ok(()) + } + + fn set_visible_without_activation( + &mut self, + overlay: MacOsWindow, + visible: bool, + ) -> Result<(), MacOsPresenterError> { + let overlay = self.window(overlay)?; + if visible { + overlay.orderFront(None); + } else { + overlay.orderOut(None); + } + Ok(()) + } + + fn activate( + &mut self, + window: MacOsWindow, + ) -> Result<(), MacOsPresenterError> { + self.window(window)?.makeKeyWindow(); + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum MacOsPresenterError { + #[error("invalid {MACOS_PRESENTER_BUILD_ENV} value: {0}")] + InvalidBuildMode(String), + #[error("mpv returned an invalid macOS window-id")] + InvalidMpvWindowId, + #[error("the macOS presenter must run on the AppKit main thread")] + AppKitMainThreadRequired, + #[error("macOS presenter operation failed: {0}")] + Operation(String), +} + +impl From for PlaybackError { + fn from(error: MacOsPresenterError) -> Self { + presenter_error(error.to_string()) + } +} + +fn presenter_error(message: impl Into) -> PlaybackError { + let mut error = PlaybackError::new(PlaybackErrorKind::Presenter, message); + error.backend = Some(crate::contract::BackendKind::Mpv); + error.recoverable = true; + error +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, collections::HashMap, rc::Rc}; + + use crate::contract::{ + BackendCandidate, BackendRequest, FallbackPolicy, GeometryRevision, + LogicalRect, PlaybackRequirements, SessionGeneration, select_backend, + }; + use crate::presenter::{NativePresenter, PresenterGeneration}; + + use super::*; + + fn window(value: usize) -> MacOsWindow { + MacOsWindow::from_non_zero(NonZeroUsize::new(value).unwrap()) + } + + fn identity(value: u64) -> PresenterIdentity { + PresenterIdentity::new( + SessionGeneration::new(value), + PresenterGeneration::new(value), + ) + } + + fn geometry(visible: bool) -> SurfaceGeometry { + SurfaceGeometry::new( + GeometryRevision::INITIAL, + LogicalRect::new(0.0, 0.0, 1280.0, 720.0), + visible.then(|| LogicalRect::new(0.0, 0.0, 1280.0, 720.0)), + 2.0, + ) + } + + #[derive(Debug, Clone)] + struct WindowState { + live: bool, + parent: Option, + behavior: u64, + ignores_mouse: bool, + visible: bool, + frame: Option, + } + + #[derive(Debug, Clone)] + struct FakeAppKit { + state: Rc>>, + snapshot: Rc>, + operations: Rc>>, + } + + impl FakeAppKit { + fn new(video: MacOsWindow, overlay: MacOsWindow) -> Self { + Self { + state: Rc::new(RefCell::new(HashMap::from([ + ( + video, + WindowState { + live: true, + parent: None, + behavior: 0, + ignores_mouse: false, + visible: true, + frame: None, + }, + ), + ( + overlay, + WindowState { + live: true, + parent: None, + behavior: 0x20, + ignores_mouse: true, + visible: false, + frame: None, + }, + ), + ]))), + snapshot: Rc::new(RefCell::new(MacOsWindowSnapshot { + content_rect: MacOsScreenRect { + x: 40.0, + y: 80.0, + width: 1280.0, + height: 720.0, + }, + backing_scale_factor: 2.0, + visible_on_active_space: true, + occluded: false, + miniaturized: false, + })), + operations: Rc::new(RefCell::new(Vec::new())), + } + } + + fn operation(&self, value: impl Into) { + self.operations.borrow_mut().push(value.into()); + } + } + + impl MacOsWindowSystem for FakeAppKit { + fn retain_window( + &mut self, + window: MacOsWindow, + ) -> Result<(), MacOsPresenterError> { + self.operation("retain"); + self.is_window(window) + .then_some(()) + .ok_or_else(|| MacOsPresenterError::Operation("stale".into())) + } + + fn release_window(&mut self, _window: MacOsWindow) { + self.operation("release"); + } + + fn is_window(&self, window: MacOsWindow) -> bool { + self.state + .borrow() + .get(&window) + .is_some_and(|state| state.live) + } + + fn parent_window(&self, window: MacOsWindow) -> Option { + self.state + .borrow() + .get(&window) + .and_then(|state| state.parent) + } + + fn collection_behavior( + &self, + window: MacOsWindow, + ) -> Result { + Ok(self.state.borrow()[&window].behavior) + } + + fn set_collection_behavior( + &mut self, + window: MacOsWindow, + behavior: u64, + ) -> Result<(), MacOsPresenterError> { + self.operation(format!("behavior:{behavior:#x}")); + self.state.borrow_mut().get_mut(&window).unwrap().behavior = + behavior; + Ok(()) + } + + fn ignores_mouse_events( + &self, + window: MacOsWindow, + ) -> Result { + Ok(self.state.borrow()[&window].ignores_mouse) + } + + fn set_ignores_mouse_events( + &mut self, + window: MacOsWindow, + ignores: bool, + ) -> Result<(), MacOsPresenterError> { + self.operation(format!("ignores:{ignores}")); + self.state + .borrow_mut() + .get_mut(&window) + .unwrap() + .ignores_mouse = ignores; + Ok(()) + } + + fn add_child_above( + &mut self, + root: MacOsWindow, + child: MacOsWindow, + ) -> Result<(), MacOsPresenterError> { + self.operation("add_child"); + self.state.borrow_mut().get_mut(&child).unwrap().parent = + Some(root); + Ok(()) + } + + fn remove_child(&mut self, root: MacOsWindow, child: MacOsWindow) { + self.operation("remove_child"); + let mut state = self.state.borrow_mut(); + let child = state.get_mut(&child).unwrap(); + if child.parent == Some(root) { + child.parent = None; + } + } + + fn snapshot( + &self, + _root: MacOsWindow, + ) -> Result { + Ok(*self.snapshot.borrow()) + } + + fn position_overlay( + &mut self, + overlay: MacOsWindow, + rect: MacOsScreenRect, + ) -> Result<(), MacOsPresenterError> { + self.operation("position"); + self.state.borrow_mut().get_mut(&overlay).unwrap().frame = + Some(rect); + Ok(()) + } + + fn set_visible_without_activation( + &mut self, + overlay: MacOsWindow, + visible: bool, + ) -> Result<(), MacOsPresenterError> { + self.operation(format!("visible:{visible}")); + self.state.borrow_mut().get_mut(&overlay).unwrap().visible = + visible; + Ok(()) + } + + fn activate( + &mut self, + _window: MacOsWindow, + ) -> Result<(), MacOsPresenterError> { + self.operation("activate"); + Ok(()) + } + } + + #[test] + fn unverified_appkit_path_falls_back_to_mpv_native_window() { + let decision = + MacOsPresenterDecision::evaluate(MacOsPresenterEvidence::default()); + + assert!(!decision.capabilities.integrated_overlay); + assert!(!decision.capabilities.embedded_surface); + assert!(decision.capabilities.native_window_fallback); + assert!(!decision.requires_wid()); + let fallback = decision.fallback.as_ref().unwrap(); + assert_eq!(fallback.code, FallbackReasonCode::MissingCapability); + assert_eq!(fallback.from, Some(PlaybackTarget::MPV_INTEGRATED)); + assert_eq!(fallback.to, PlaybackTarget::MPV_NATIVE_WINDOW); + assert!(fallback.detail.contains("appkit_main_thread")); + assert!(fallback.detail.contains("spaces")); + } + + #[test] + fn presenter_decision_feeds_the_backend_fallback_policy() { + let presenter = + MacOsPresenterDecision::evaluate(MacOsPresenterEvidence::default()); + let candidates = [ + BackendCandidate::unavailable( + PlaybackTarget::MPV_INTEGRATED, + presenter.fallback.as_ref().unwrap().code, + ), + BackendCandidate::available( + PlaybackTarget::MPV_NATIVE_WINDOW, + true, + ), + ]; + + let selected = select_backend( + BackendRequest::Exact(PlaybackTarget::MPV_INTEGRATED), + PlaybackRequirements::default(), + &FallbackPolicy::migration_default(), + &candidates, + ) + .unwrap(); + + assert_eq!(selected.selected, PlaybackTarget::MPV_NATIVE_WINDOW); + assert_eq!( + selected.fallback.unwrap().code, + FallbackReasonCode::MissingCapability + ); + } + + #[test] + fn every_appkit_gate_is_required_before_integration_is_advertised() { + let verified = MacOsPresenterEvidence::verified(); + let decision = MacOsPresenterDecision::evaluate(verified); + assert!(decision.capabilities.integrated_overlay); + assert!(decision.capabilities.fractional_scaling); + assert_eq!( + decision.capabilities.fullscreen_owner, + Some(FullscreenOwner::VideoOutput) + ); + assert!(decision.fallback.is_none()); + + let missing_spaces = + MacOsPresenterDecision::evaluate(MacOsPresenterEvidence { + spaces_verified: false, + ..verified + }); + assert!(!missing_spaces.capabilities.integrated_overlay); + assert_eq!( + missing_spaces.blockers, + vec![MacOsPresenterBlocker::SpacesUnverified] + ); + } + + #[test] + fn hdr_capability_requires_separate_native_output_evidence() { + let without_hdr = MacOsPresenterDecision::evaluate( + MacOsPresenterEvidence::verified(), + ); + assert!(without_hdr.capabilities.integrated_overlay); + assert!(!without_hdr.capabilities.native_hdr); + + let with_hdr = + MacOsPresenterDecision::evaluate(MacOsPresenterEvidence { + native_hdr_verified: true, + ..MacOsPresenterEvidence::verified() + }); + assert!(with_hdr.capabilities.native_hdr); + } + + #[test] + fn build_mode_and_mpv_window_id_fail_closed() { + assert_eq!( + MacOsPresenterBuildMode::parse(None).unwrap(), + MacOsPresenterBuildMode::Disabled + ); + assert_eq!( + MacOsPresenterBuildMode::parse(Some("spike")).unwrap(), + MacOsPresenterBuildMode::Spike + ); + assert!(MacOsPresenterBuildMode::parse(Some("production")).is_err()); + assert!(MacOsWindow::from_mpv_window_id(0).is_err()); + assert_eq!(MacOsWindow::from_mpv_window_id(42).unwrap().get(), 42); + let high_bit = MacOsWindow::from_mpv_window_id(i64::MIN).unwrap(); + assert_eq!(high_bit.get() as u64, i64::MIN as u64); + assert!(!format!("{:?}", window(42)).contains("42")); + } + + #[test] + fn child_overlay_tracks_geometry_visibility_fullscreen_and_detach() { + let video = window(10); + let overlay = window(20); + let appkit = FakeAppKit::new(video, overlay); + let observed = appkit.clone(); + let fullscreen_values = Rc::new(RefCell::new(Vec::new())); + let fullscreen_values_for_callback = Rc::clone(&fullscreen_values); + let mut presenter = MacOsPresenter::new( + appkit, + move |fullscreen| { + fullscreen_values_for_callback.borrow_mut().push(fullscreen); + Ok(()) + }, + video, + MacOsPresenterBuildMode::Spike, + ); + let id = identity(1); + + presenter + .attach(id, MacOsPresenterHost { overlay }) + .unwrap(); + { + let state = observed.state.borrow(); + assert_eq!(state[&overlay].parent, Some(video)); + assert_eq!( + state[&overlay].behavior, + 0x20 | COLLECTION_TRANSIENT | COLLECTION_FULLSCREEN_AUXILIARY + ); + assert!(!state[&overlay].ignores_mouse); + assert!(!state[&overlay].visible); + } + + presenter.synchronize(id, geometry(true)).unwrap(); + presenter.set_visible(id, true).unwrap(); + assert!(observed.state.borrow()[&overlay].visible); + assert_eq!( + observed.state.borrow()[&overlay].frame, + Some(observed.snapshot.borrow().content_rect) + ); + assert_eq!( + presenter.last_snapshot().unwrap().backing_scale_factor, + 2.0 + ); + + presenter.set_suspended(id, true).unwrap(); + assert!(!observed.state.borrow()[&overlay].visible); + presenter.set_suspended(id, false).unwrap(); + assert!(observed.state.borrow()[&overlay].visible); + presenter + .set_fullscreen(id, FullscreenOwner::VideoOutput, true) + .unwrap(); + assert_eq!(&*fullscreen_values.borrow(), &[true]); + presenter.focus_overlay().unwrap(); + + presenter.detach(id); + let state = observed.state.borrow(); + assert_eq!(state[&overlay].parent, None); + assert_eq!(state[&overlay].behavior, 0x20); + assert!(state[&overlay].ignores_mouse); + assert!(!state[&overlay].visible); + drop(state); + let operations = observed.operations.borrow(); + let hide = operations + .iter() + .position(|operation| operation == "visible:false") + .unwrap(); + let attach = operations + .iter() + .position(|operation| operation == "add_child") + .unwrap(); + assert!(hide < attach); + assert!(operations.iter().any(|operation| operation == "release")); + } + + #[test] + fn occlusion_does_not_oscillate_overlay_and_stale_generations_fail_safe() { + let video = window(30); + let overlay = window(40); + let appkit = FakeAppKit::new(video, overlay); + let observed = appkit.clone(); + let mut presenter = MacOsPresenter::new( + appkit, + |_| Ok(()), + video, + MacOsPresenterBuildMode::Spike, + ); + let id = identity(2); + presenter + .attach(id, MacOsPresenterHost { overlay }) + .unwrap(); + presenter.synchronize(id, geometry(true)).unwrap(); + presenter.set_visible(id, true).unwrap(); + assert!(observed.state.borrow()[&overlay].visible); + + let operation_count = observed.operations.borrow().len(); + observed.snapshot.borrow_mut().occluded = true; + presenter.synchronize(id, geometry(true)).unwrap(); + assert!(observed.state.borrow()[&overlay].visible); + assert_eq!(observed.operations.borrow().len(), operation_count); + + observed.snapshot.borrow_mut().miniaturized = true; + presenter.synchronize(id, geometry(true)).unwrap(); + assert!(!observed.state.borrow()[&overlay].visible); + + let stale = identity(3); + assert!(presenter.synchronize(stale, geometry(true)).is_err()); + presenter.detach(stale); + assert_eq!(observed.state.borrow()[&overlay].parent, Some(video)); + presenter.detach(id); + } + + #[test] + fn disabled_presenter_rejects_attach_without_mutating_appkit() { + let video = window(50); + let overlay = window(60); + let appkit = FakeAppKit::new(video, overlay); + let observed = appkit.clone(); + let mut presenter = MacOsPresenter::new( + appkit, + |_| Ok(()), + video, + MacOsPresenterBuildMode::Disabled, + ); + + assert!( + presenter + .attach(identity(4), MacOsPresenterHost { overlay }) + .is_err() + ); + assert!(observed.operations.borrow().is_empty()); + } + + #[test] + fn diagnostic_decision_never_contains_a_raw_window_value() { + let decision = + MacOsPresenterDecision::evaluate(MacOsPresenterEvidence::default()); + let debug = format!("{decision:?}"); + assert!(!debug.contains("0x")); + assert!(!debug.contains("window-id")); + } +} diff --git a/crates/ferrex-player/src/lib.rs b/crates/ferrex-player/src/lib.rs index c1313569..c2f1cf75 100644 --- a/crates/ferrex-player/src/lib.rs +++ b/crates/ferrex-player/src/lib.rs @@ -5,4 +5,7 @@ //! `ferrex-player` binary and historical `ferrex_player::*` imports working //! during the extraction stack. +#[cfg(any(target_os = "macos", test))] +pub mod macos_bundle_runtime; + pub use ferrex_player_app::*; diff --git a/crates/ferrex-player/src/macos_bundle_runtime.rs b/crates/ferrex-player/src/macos_bundle_runtime.rs new file mode 100644 index 00000000..375e895d --- /dev/null +++ b/crates/ferrex-player/src/macos_bundle_runtime.rs @@ -0,0 +1,235 @@ +//! Early runtime path setup for the self-contained macOS application bundle. + +use std::path::{Path, PathBuf}; + +#[cfg(target_os = "macos")] +use std::ffi::CString; +#[cfg(target_os = "macos")] +use std::os::raw::{c_char, c_int, c_void}; +#[cfg(target_os = "macos")] +use std::os::unix::ffi::OsStrExt; +#[cfg(target_os = "macos")] +use std::sync::OnceLock; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BundleRuntimePaths { + gstreamer_plugins: PathBuf, + gstreamer_scanner: PathBuf, + gio_modules: PathBuf, + libsoup: PathBuf, + ca_bundle: PathBuf, + vulkan_icd: Option, +} + +fn bundle_runtime_paths(executable: &Path) -> Option { + let macos = executable.parent()?; + if macos.file_name()?.to_str()? != "MacOS" { + return None; + } + let contents = macos.parent()?; + if contents.file_name()?.to_str()? != "Contents" { + return None; + } + + let gstreamer_plugins = contents.join("PlugIns/gstreamer-1.0"); + let gstreamer_scanner = contents.join("Helpers/gst-plugin-scanner"); + let gio_modules = contents.join("PlugIns/gio/modules"); + let libsoup = contents.join("Frameworks/libsoup-3.0.0.dylib"); + let ca_bundle = contents.join("Resources/tls/cacert.pem"); + if !gstreamer_plugins.is_dir() + || !gstreamer_scanner.is_file() + || !gio_modules.is_dir() + || !libsoup.is_file() + || !ca_bundle.is_file() + { + return None; + } + let icd = contents.join("Resources/vulkan/icd.d/MoltenVK_icd.json"); + Some(BundleRuntimePaths { + gstreamer_plugins, + gstreamer_scanner, + gio_modules, + libsoup, + ca_bundle, + vulkan_icd: icd.is_file().then_some(icd), + }) +} + +/// Configure dynamic runtime discovery before any worker threads are created. +/// +/// Developer launches are unchanged: variables are written only when the +/// current executable is inside a complete `Contents/MacOS` app layout. +#[cfg(target_os = "macos")] +pub fn configure() { + let Ok(executable) = std::env::current_exe() else { + return; + }; + let Some(paths) = bundle_runtime_paths(&executable) else { + return; + }; + let registry_root = std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| { + home.join("Library/Caches/io.github.lowband21.FerrexPlayer") + }) + .unwrap_or_else(|| { + std::env::temp_dir().join("io.github.lowband21.FerrexPlayer") + }); + if std::fs::create_dir_all(®istry_root).is_err() { + return; + } + let registry = registry_root.join("gstreamer-registry-1.0.bin"); + + // SAFETY: `main` calls this as its first operation, before application, + // logging, GStreamer, or mpv threads exist. No concurrent environment + // access can have been initiated by Ferrex at this point. + unsafe { + std::env::set_var( + "GST_PLUGIN_SYSTEM_PATH_1_0", + &paths.gstreamer_plugins, + ); + std::env::set_var("GST_PLUGIN_PATH_1_0", &paths.gstreamer_plugins); + std::env::set_var("GST_PLUGIN_SCANNER_1_0", &paths.gstreamer_scanner); + std::env::set_var("GST_PLUGIN_SCANNER", &paths.gstreamer_scanner); + std::env::set_var("GST_REGISTRY_1_0", registry); + std::env::set_var("GIO_EXTRA_MODULES", &paths.gio_modules); + if let Some(icd) = paths.vulkan_icd { + std::env::set_var("VK_ICD_FILENAMES", icd); + } + } + configure_bundle_tls(&paths.ca_bundle); + preload_bundled_libsoup(&paths.libsoup); +} + +#[cfg(target_os = "macos")] +fn configure_bundle_tls(path: &Path) { + static DATABASE: OnceLock = OnceLock::new(); + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + return; + }; + // SAFETY: GIO is linked into the macOS player; the path is a live, + // NUL-terminated bundle resource. The database reference is intentionally + // retained for process lifetime after becoming the backend default. + let database = + unsafe { g_tls_file_database_new(path.as_ptr(), std::ptr::null_mut()) }; + let backend = unsafe { g_tls_backend_get_default() }; + if database.is_null() || backend.is_null() { + eprintln!( + "bundled CA database initialization failed; trusted GStreamer HTTPS is unavailable" + ); + return; + } + unsafe { g_tls_backend_set_default_database(backend, database) }; + let _ = DATABASE.set(database as usize); +} + +#[cfg(target_os = "macos")] +fn preload_bundled_libsoup(path: &Path) { + const RTLD_LAZY: c_int = 0x1; + const RTLD_GLOBAL: c_int = 0x8; + static HANDLE: OnceLock = OnceLock::new(); + + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + return; + }; + // SAFETY: the path points at the closure-audited bundled dylib, the C + // string is NUL-terminated, and the returned handle is intentionally kept + // alive for the process so GStreamer's later bare-leaf dlopen coalesces it. + let handle = unsafe { dlopen(path.as_ptr(), RTLD_LAZY | RTLD_GLOBAL) }; + if !handle.is_null() { + let _ = HANDLE.set(handle as usize); + } else { + eprintln!( + "bundled libsoup preload failed; GStreamer network fallback is unavailable" + ); + } +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn dlopen(path: *const c_char, mode: c_int) -> *mut c_void; +} + +#[cfg(target_os = "macos")] +#[link(name = "gio-2.0")] +unsafe extern "C" { + fn g_tls_file_database_new( + anchors: *const c_char, + error: *mut *mut c_void, + ) -> *mut c_void; + fn g_tls_backend_get_default() -> *mut c_void; + fn g_tls_backend_set_default_database( + backend: *mut c_void, + database: *mut c_void, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_temp_dir() -> PathBuf { + let nonce = format!( + "ferrex-macos-runtime-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + ); + std::env::temp_dir().join(nonce) + } + + #[test] + fn recognizes_only_complete_app_bundle_layout() { + let root = unique_temp_dir(); + let contents = root.join("Ferrex Player.app/Contents"); + let executable = contents.join("MacOS/ferrex-player"); + std::fs::create_dir_all(executable.parent().expect("MacOS parent")) + .expect("create MacOS"); + + assert_eq!(bundle_runtime_paths(&executable), None); + + let plugins = contents.join("PlugIns/gstreamer-1.0"); + let scanner = contents.join("Helpers/gst-plugin-scanner"); + let gio_modules = contents.join("PlugIns/gio/modules"); + let libsoup = contents.join("Frameworks/libsoup-3.0.0.dylib"); + let ca_bundle = contents.join("Resources/tls/cacert.pem"); + let icd = contents.join("Resources/vulkan/icd.d/MoltenVK_icd.json"); + std::fs::create_dir_all(&plugins).expect("create plugins"); + std::fs::create_dir_all(scanner.parent().expect("scanner parent")) + .expect("create helpers"); + std::fs::write(&scanner, []).expect("create scanner"); + std::fs::create_dir_all(&gio_modules).expect("create GIO modules"); + std::fs::create_dir_all(libsoup.parent().expect("Frameworks parent")) + .expect("create Frameworks"); + std::fs::write(&libsoup, []).expect("create libsoup"); + std::fs::create_dir_all(ca_bundle.parent().expect("CA parent")) + .expect("create CA directory"); + std::fs::write(&ca_bundle, b"certificate").expect("create CA bundle"); + std::fs::create_dir_all(icd.parent().expect("ICD parent")) + .expect("create ICD dir"); + std::fs::write(&icd, b"{}").expect("create ICD"); + + assert_eq!( + bundle_runtime_paths(&executable), + Some(BundleRuntimePaths { + gstreamer_plugins: plugins, + gstreamer_scanner: scanner, + gio_modules, + libsoup, + ca_bundle, + vulkan_icd: Some(icd), + }) + ); + std::fs::remove_dir_all(root).expect("remove fixture"); + } + + #[test] + fn rejects_developer_executable_layout() { + assert_eq!( + bundle_runtime_paths(Path::new("/tmp/target/debug/ferrex-player")), + None + ); + } +} diff --git a/crates/ferrex-player/src/main.rs b/crates/ferrex-player/src/main.rs index b2bca6ee..f8a77cf4 100644 --- a/crates/ferrex-player/src/main.rs +++ b/crates/ferrex-player/src/main.rs @@ -1,4 +1,7 @@ fn main() -> ferrex_player::Result { + #[cfg(target_os = "macos")] + ferrex_player::macos_bundle_runtime::configure(); + match ferrex_player::screenshot::run_command_from_args(std::env::args_os()) { Ok(ferrex_player::screenshot::CommandOutcome::NotScreenshot) => { diff --git a/scripts/release/https_test_server.py b/scripts/release/https_test_server.py new file mode 100644 index 00000000..ed6e401a --- /dev/null +++ b/scripts/release/https_test_server.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Serve a directory over loopback HTTPS for clean-bundle runtime smoke.""" + +from __future__ import annotations + +import argparse +import functools +import http.server +import ssl +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--directory", type=Path, required=True) + parser.add_argument("--certificate", type=Path, required=True) + parser.add_argument("--key", type=Path, required=True) + parser.add_argument("--port", type=int, required=True) + arguments = parser.parse_args() + + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, + directory=str(arguments.directory), + ) + server = http.server.ThreadingHTTPServer(("127.0.0.1", arguments.port), handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(arguments.certificate, arguments.key) + server.socket = context.wrap_socket(server.socket, server_side=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/release/macos-build-libmpv.sh b/scripts/release/macos-build-libmpv.sh new file mode 100644 index 00000000..4242d9fd --- /dev/null +++ b/scripts/release/macos-build-libmpv.sh @@ -0,0 +1,502 @@ +#!/usr/bin/env bash +# Build the reviewed macOS libmpv profile into an isolated prefix. + +set -euo pipefail + +: "${MACOSX_DEPLOYMENT_TARGET:=15.0}" +export MACOSX_DEPLOYMENT_TARGET +if [[ "$MACOSX_DEPLOYMENT_TARGET" != "15.0" ]]; then + echo "Ferrex handoff packages target macOS 15.0, not $MACOSX_DEPLOYMENT_TARGET" >&2 + exit 2 +fi + +readonly MPV_VERSION="0.41.0" +readonly MPV_CLIENT_API="2.5.0" +readonly MPV_ARCHIVE_SHA256="ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209" +readonly MPV_ARCHIVE_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v${MPV_VERSION}.tar.gz" +readonly FFMPEG_COMMIT="38b88335f99e76ed89ff3c93f877fdefce736c13" +readonly LIBPLACEBO_COMMIT="cee9b076f2c63104ccfd497fa79c39a867293ec4" +readonly LIBASS_COMMIT="bbb3c7f1570a4a021e52683f3fbdf74fe492ae84" +readonly LUA_VERSION="5.2.4" +readonly LUA_ARCHIVE_SHA256="b9e2e4aad6789b3b63a056d442f7b39f0ecfca3ae0f1fc0ae4e9614401b69f4b" +readonly LUA_ARCHIVE_URL="https://www.lua.org/ftp/lua-${LUA_VERSION}.tar.gz" +readonly GSTREAMER_VERSION="1.28.5" +readonly GSTREAMER_SOURCE_SHA256="c53f71b93aefda6864ea09bc845295d89c57afd4d9335f9f8c9a98c969b7693f" +readonly FFMPEG_REPOSITORY="https://github.com/FFmpeg/FFmpeg.git" +readonly LIBPLACEBO_REPOSITORY="https://github.com/haasn/libplacebo.git" +readonly LIBASS_REPOSITORY="https://github.com/libass/libass.git" +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "usage: $0 OUTPUT_PREFIX [WORK_DIRECTORY]" >&2 + exit 2 +fi + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macos-build-libmpv.sh must run on macOS" >&2 + exit 2 +fi + +prefix="$(mkdir -p "$1" && cd "$1" && pwd)" +if find "$prefix" -mindepth 1 -print -quit | grep -q .; then + echo "refusing to install over a non-empty prefix: $prefix" >&2 + exit 2 +fi + +if [[ $# -eq 2 ]]; then + work_directory="$(mkdir -p "$2" && cd "$2" && pwd)" + cleanup_work_directory=false +else + work_directory="$(mktemp -d "${TMPDIR:-/tmp}/ferrex-libmpv.XXXXXX")" + cleanup_work_directory=true +fi + +cleanup() { + if [[ "$cleanup_work_directory" == true ]]; then + rm -rf "$work_directory" + fi +} +trap cleanup EXIT + +archive="$work_directory/mpv-v${MPV_VERSION}.tar.gz" +source_directory="$work_directory/mpv-${MPV_VERSION}" +dependency_source_directory="$work_directory/dependencies" +dependency_build_directory="$work_directory/dependency-build" +build_directory="$work_directory/mpv-build" +jobs="$(sysctl -n hw.logicalcpu 2>/dev/null || printf '4')" + +mkdir -p "$dependency_source_directory" "$dependency_build_directory" + +checkout_exact() { + local name="$1" + local repository="$2" + local commit="$3" + local directory="$dependency_source_directory/$name" + + if [[ ! -d "$directory/.git" ]]; then + mkdir -p "$directory" + git -C "$directory" init --quiet + git -C "$directory" remote add origin "$repository" + fi + git -C "$directory" fetch --quiet --depth 1 origin "$commit" + git -C "$directory" checkout --quiet --detach --force FETCH_HEAD + if [[ "$(git -C "$directory" rev-parse HEAD)" != "$commit" ]]; then + echo "$name did not resolve pinned commit $commit" >&2 + exit 1 + fi + git -C "$directory" submodule update --init --recursive --depth 1 +} + +checkout_exact ffmpeg "$FFMPEG_REPOSITORY" "$FFMPEG_COMMIT" +checkout_exact libplacebo "$LIBPLACEBO_REPOSITORY" "$LIBPLACEBO_COMMIT" +checkout_exact libass "$LIBASS_REPOSITORY" "$LIBASS_COMMIT" + +lua_archive="$work_directory/lua-${LUA_VERSION}.tar.gz" +curl --fail --location --silent --show-error \ + "$LUA_ARCHIVE_URL" --output "$lua_archive" +printf '%s %s\n' "$LUA_ARCHIVE_SHA256" "$lua_archive" | shasum -a 256 --check +tar -xzf "$lua_archive" -C "$dependency_source_directory" + +curl --fail --location --silent --show-error \ + "$MPV_ARCHIVE_URL" --output "$archive" +printf '%s %s\n' "$MPV_ARCHIVE_SHA256" "$archive" | shasum -a 256 --check +tar -xzf "$archive" -C "$work_directory" + +# Do not inherit Homebrew's default FFmpeg/libplacebo feature or license +# choices. Build both reviewed dependencies into the isolated prefix first; +# their pkg-config files then take precedence for the mpv configuration. +export PATH="$prefix/bin:$PATH" +export PKG_CONFIG_PATH="$prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" +export DYLD_FALLBACK_LIBRARY_PATH="$prefix/lib${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" + +for module in freetype2 fribidi harfbuzz vulkan shaderc; do + if ! pkg-config --exists "$module"; then + echo "required version-recorded Homebrew module is missing: $module" >&2 + exit 1 + fi +done +if ! pkg-config --exact-version="$GSTREAMER_VERSION" gstreamer-1.0; then + echo "Ferrex macOS releases require reviewed GStreamer $GSTREAMER_VERSION" >&2 + exit 1 +fi + +ffmpeg_build="$dependency_build_directory/ffmpeg" +mkdir -p "$ffmpeg_build" +( + cd "$ffmpeg_build" + "$dependency_source_directory/ffmpeg/configure" \ + --prefix="$prefix" \ + --disable-debug \ + --disable-doc \ + --disable-programs \ + --disable-static \ + --disable-autodetect \ + --disable-gpl \ + --disable-nonfree \ + --disable-version3 \ + --enable-shared \ + --enable-avfoundation \ + --enable-audiotoolbox \ + --enable-videotoolbox \ + --enable-securetransport \ + --enable-iconv \ + --enable-zlib + make -j"$jobs" + make install +) + +if grep -Eq '^CONFIG_(GPL|NONFREE|VERSION3)=yes$' \ + "$ffmpeg_build/ffbuild/config.mak"; then + echo "the pinned FFmpeg build resolved a restricted license option" >&2 + exit 1 +fi +for required in AVFOUNDATION AUDIOTOOLBOX VIDEOTOOLBOX SECURETRANSPORT; do + if ! grep -q "^CONFIG_${required}=yes$" "$ffmpeg_build/ffbuild/config.mak"; then + echo "pinned FFmpeg did not enable required macOS feature $required" >&2 + exit 1 + fi +done + +libass_build="$dependency_build_directory/libass" +meson setup "$libass_build" "$dependency_source_directory/libass" \ + --wrap-mode=nofallback \ + --prefix="$prefix" \ + --libdir=lib \ + --buildtype=release \ + -Ddefault_library=shared \ + -Dfontconfig=disabled \ + -Dcoretext=enabled \ + -Ddirectwrite=disabled \ + -Dlibunibreak=disabled \ + -Dtest=disabled \ + -Dcompare=disabled \ + -Dprofile=disabled \ + -Dfuzz=disabled \ + -Dcheckasm=disabled +meson compile -C "$libass_build" -j "$jobs" +meson install -C "$libass_build" + +python3 - "$libass_build/meson-info/intro-buildoptions.json" <<'PY' +import json +import sys + +values = {item["name"]: item["value"] for item in json.load(open(sys.argv[1], encoding="utf-8"))} +required = { + "default_library": "shared", + "fontconfig": "disabled", + "coretext": "enabled", + "directwrite": "disabled", + "libunibreak": "disabled", +} +wrong = {name: (values.get(name), expected) for name, expected in required.items() if values.get(name) != expected} +if wrong: + raise SystemExit(f"unexpected pinned libass build options: {wrong}") +PY + +# Build only the static PIC library. Upstream's `macosx` aggregate also links +# the unused `lua` CLI against readline, which is not part of this runtime. +lua_source="$dependency_source_directory/lua-${LUA_VERSION}" +make -C "$lua_source/src" -j"$jobs" liblua.a \ + MYCFLAGS="-fPIC -DLUA_USE_MACOSX" +mkdir -p "$prefix/lib/pkgconfig" "$prefix/include" +cp "$lua_source/src/liblua.a" "$prefix/lib/" +ranlib "$prefix/lib/liblua.a" +cp "$lua_source/src/lua.h" \ + "$lua_source/src/luaconf.h" \ + "$lua_source/src/lualib.h" \ + "$lua_source/src/lauxlib.h" \ + "$lua_source/src/lua.hpp" \ + "$prefix/include/" +{ + printf 'prefix=%s\n' "$prefix" + printf 'exec_prefix=${prefix}\nlibdir=${exec_prefix}/lib\nincludedir=${prefix}/include\n\n' + printf 'Name: Lua\nDescription: Lua interpreter library\nVersion: %s\n' "$LUA_VERSION" + printf 'Libs: -L${libdir} -llua -lm\nCflags: -I${includedir}\n' +} >"$prefix/lib/pkgconfig/lua52.pc" +if [[ "$(pkg-config --modversion lua52)" != "$LUA_VERSION" ]]; then + echo "pinned Lua pkg-config metadata did not resolve version $LUA_VERSION" >&2 + exit 1 +fi + +libplacebo_build="$dependency_build_directory/libplacebo" +meson setup "$libplacebo_build" "$dependency_source_directory/libplacebo" \ + --wrap-mode=nofallback \ + --prefix="$prefix" \ + --libdir=lib \ + --buildtype=release \ + -Ddefault_library=shared \ + -Dvulkan=enabled \ + -Dvk-proc-addr=enabled \ + -Dopengl=enabled \ + -Dd3d11=disabled \ + -Dglslang=disabled \ + -Dshaderc=enabled \ + -Dlcms=disabled \ + -Ddovi=disabled \ + -Ddemos=false \ + -Dtests=false \ + -Dbench=false \ + -Dunwind=disabled +meson compile -C "$libplacebo_build" -j "$jobs" +meson install -C "$libplacebo_build" + +python3 - "$libplacebo_build/meson-info/intro-buildoptions.json" <<'PY' +import json +import sys + +values = {item["name"]: item["value"] for item in json.load(open(sys.argv[1], encoding="utf-8"))} +required = { + "default_library": "shared", + "vulkan": "enabled", + "vk-proc-addr": "enabled", + "opengl": "enabled", + "d3d11": "disabled", + "glslang": "disabled", + "shaderc": "enabled", + "lcms": "disabled", + "dovi": "disabled", +} +wrong = {name: (values.get(name), expected) for name, expected in required.items() if values.get(name) != expected} +if wrong: + raise SystemExit(f"unexpected pinned libplacebo build options: {wrong}") +PY + +# mpv's native macOS VO and VideoToolbox/libplacebo path are required. The +# cplayer is not shipped: Ferrex links the shared client library and owns the +# application bundle. Optional GPL and external-script surfaces stay disabled. +meson setup "$build_directory" "$source_directory" \ + --wrap-mode=nofallback \ + --buildtype=release \ + --prefix="$prefix" \ + --libdir=lib \ + -Dgpl=false \ + -Dcplayer=false \ + -Dlibmpv=true \ + -Dbuild-date=false \ + -Dtests=false \ + -Dcocoa=enabled \ + -Dswift-build=enabled \ + -Dmacos-cocoa-cb=enabled \ + -Dcoreaudio=enabled \ + -Davfoundation=enabled \ + -Dvideotoolbox-pl=enabled \ + -Dvideotoolbox-gl=enabled \ + -Dgl=enabled \ + -Dgl-cocoa=enabled \ + -Dvulkan=enabled \ + -Dshaderc=enabled \ + -Dlcms2=disabled \ + -Dlibarchive=disabled \ + -Dlibbluray=disabled \ + -Djpeg=disabled \ + -Drubberband=disabled \ + -Duchardet=disabled \ + -Dzimg=disabled \ + -Dcplugins=disabled \ + -Djavascript=disabled \ + -Dlua=lua52 \ + -Dmanpage-build=disabled \ + -Dhtml-build=disabled \ + -Dpdf-build=disabled + +meson compile -C "$build_directory" +meson install -C "$build_directory" + +export PKG_CONFIG_PATH="$prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" +actual_client_api="$(pkg-config --modversion mpv)" +if [[ "$actual_client_api" != "$MPV_CLIENT_API" ]]; then + echo "expected libmpv client API $MPV_CLIENT_API, found $actual_client_api" >&2 + exit 1 +fi + +libmpv="$prefix/lib/libmpv.2.dylib" +if [[ ! -f "$libmpv" ]]; then + echo "pinned build did not install $libmpv" >&2 + exit 1 +fi + +build_options="$build_directory/meson-info/intro-buildoptions.json" +python3 -c ' +import json, sys +values = {item["name"]: item["value"] for item in json.load(open(sys.argv[1], encoding="utf-8"))} +required = { + "gpl": False, + "cplayer": False, + "libmpv": True, + "cocoa": "enabled", + "swift-build": "enabled", + "macos-cocoa-cb": "enabled", + "videotoolbox-pl": "enabled", + "videotoolbox-gl": "enabled", + "gl": "enabled", + "gl-cocoa": "enabled", + "vulkan": "enabled", + "shaderc": "enabled", + "lua": "lua52", +} +wrong = {name: (values.get(name), expected) for name, expected in required.items() if values.get(name) != expected} +if wrong: + raise SystemExit(f"unexpected mpv build options: {wrong}") +' "$build_options" + +ffmpeg_configuration="$(sed -n '/^FFMPEG_CONFIGURATION=/p' "$ffmpeg_build/ffbuild/config.mak")" + +profile_directory="$prefix/share/ferrex/native-mpv" +mkdir -p "$profile_directory" +homebrew_direct_formulae=( + freetype fribidi harfbuzz molten-vk shaderc vulkan-headers vulkan-loader + ca-certificates glib glib-networking gstreamer libsoup openssl@3 +) +homebrew_formulae="$profile_directory/homebrew-formulae.txt" +{ + printf '%s\n' "${homebrew_direct_formulae[@]}" + brew deps --union "${homebrew_direct_formulae[@]}" +} | LC_ALL=C sort -u >"$homebrew_formulae" +xargs brew list --versions <"$homebrew_formulae" \ + >"$profile_directory/homebrew-build-inputs.txt" +xargs brew info --json=v2 <"$homebrew_formulae" \ + >"$profile_directory/homebrew-build-inputs.json" +ca_certificates_bundle="$(brew --prefix ca-certificates)/share/ca-certificates/cacert.pem" +if [[ ! -f "$ca_certificates_bundle" ]]; then + echo "immutable Homebrew Mozilla CA bundle is missing: $ca_certificates_bundle" >&2 + exit 1 +fi +ca_certificates_version="$(brew list --versions ca-certificates | awk 'NF >= 2 { print $2; exit }')" +IFS=$'\t' read -r ca_certificates_source ca_certificates_source_sha256 < <( + python3 - "$profile_directory/homebrew-build-inputs.json" <<'PY' +import json +import sys + +metadata = json.load(open(sys.argv[1], encoding="utf-8")) +formula = next( + item for item in metadata["formulae"] if item["name"] == "ca-certificates" +) +stable = formula["urls"]["stable"] +print(stable["url"], stable["checksum"], sep="\t") +PY +) +ca_certificates_bundle_sha256="$(shasum -a 256 "$ca_certificates_bundle" | awk '{ print $1 }')" +if [[ -z "$ca_certificates_version" \ + || -z "$ca_certificates_source" \ + || -z "$ca_certificates_source_sha256" \ + || "$ca_certificates_bundle_sha256" != "$ca_certificates_source_sha256" ]]; then + echo "Homebrew Mozilla CA bundle does not match its recorded formula source" >&2 + exit 1 +fi +{ + printf 'mpv_version=%s\n' "$MPV_VERSION" + printf 'mpv_client_api=%s\n' "$actual_client_api" + printf 'mpv_source=%s\n' "$MPV_ARCHIVE_URL" + printf 'mpv_source_sha256=%s\n' "$MPV_ARCHIVE_SHA256" + printf 'mpv_gpl=false\n' + printf 'mpv_cocoa=enabled\n' + printf 'mpv_swift_build=enabled\n' + printf 'mpv_macos_cocoa_cb=enabled\n' + printf 'mpv_videotoolbox_pl=enabled\n' + printf 'mpv_gl=enabled\n' + printf 'mpv_vulkan=enabled\n' + printf 'mpv_lua=lua52\n' + printf 'ffmpeg_commit=%s\n' "$FFMPEG_COMMIT" + printf 'ffmpeg_version=%s\n' "$(pkg-config --modversion libavcodec)" + printf 'ffmpeg_gpl=false\nffmpeg_nonfree=false\nffmpeg_version3=false\n' + printf 'libplacebo_commit=%s\n' "$LIBPLACEBO_COMMIT" + printf 'libplacebo_version=%s\n' "$(pkg-config --modversion libplacebo)" + printf 'libplacebo_opengl=enabled\nlibplacebo_vulkan=enabled\n' + printf 'libass_commit=%s\n' "$LIBASS_COMMIT" + printf 'libass_version=%s\n' "$(pkg-config --modversion libass)" + printf 'libass_coretext=enabled\n' + printf 'freetype_version=%s\n' "$(pkg-config --modversion freetype2)" + printf 'fribidi_version=%s\n' "$(pkg-config --modversion fribidi)" + printf 'harfbuzz_version=%s\n' "$(pkg-config --modversion harfbuzz)" + printf 'vulkan_loader_version=%s\n' "$(pkg-config --modversion vulkan)" + printf 'shaderc_version=%s\n' "$(pkg-config --modversion shaderc)" + printf 'lua_version=%s\n' "$LUA_VERSION" + printf 'lua_source=%s\n' "$LUA_ARCHIVE_URL" + printf 'lua_source_sha256=%s\n' "$LUA_ARCHIVE_SHA256" + printf 'gstreamer_version=%s\n' "$GSTREAMER_VERSION" + printf 'gstreamer_source_sha256=%s\n' "$GSTREAMER_SOURCE_SHA256" + printf 'gstreamer_profile=homebrew-bottle-curated-lgpl-runtime\n' + printf 'gstreamer_plugins_sha256=%s\n' "$(shasum -a 256 "$script_directory/macos-gstreamer-plugins.txt" | awk '{print $1}')" + printf 'ca_certificates_version=%s\n' "$ca_certificates_version" + printf 'ca_certificates_source=%s\n' "$ca_certificates_source" + printf 'ca_certificates_source_sha256=%s\n' "$ca_certificates_source_sha256" + printf 'ca_certificates_bundle_sha256=%s\n' "$ca_certificates_bundle_sha256" + printf 'macos_deployment_target=%s\n' "$MACOSX_DEPLOYMENT_TARGET" +} >"$profile_directory/build-profile.txt" +cp "$script_directory/macos-gstreamer-plugins.txt" \ + "$profile_directory/gstreamer-plugin-allowlist.txt" +printf '%s\n' "$ffmpeg_configuration" \ + >"$profile_directory/ffmpeg-build-configuration.txt" +cp "$source_directory/Copyright" \ + "$source_directory/LICENSE.LGPL" \ + "$profile_directory/" +mkdir -p "$profile_directory/licenses/ffmpeg" \ + "$profile_directory/licenses/libplacebo" \ + "$profile_directory/licenses/libass" \ + "$profile_directory/licenses/lua" +cp "$dependency_source_directory/ffmpeg/COPYING.LGPLv2.1" \ + "$dependency_source_directory/ffmpeg/LICENSE.md" \ + "$profile_directory/licenses/ffmpeg/" +cp "$dependency_source_directory/libplacebo/LICENSE" \ + "$profile_directory/licenses/libplacebo/" +cp "$dependency_source_directory/libass/COPYING" \ + "$profile_directory/licenses/libass/" +cp "$dependency_source_directory/lua-${LUA_VERSION}/doc/readme.html" \ + "$profile_directory/licenses/lua/" + +# Preserve any installed formula notices in addition to Homebrew's complete +# version/license JSON. Keep relative paths so identically named notices from +# nested packages cannot overwrite one another. +while IFS= read -r formula; do + formula_prefix="$(brew --prefix "$formula")" + formula_notice_directory="$profile_directory/licenses/homebrew/$formula" + python3 - "$formula_prefix" "$formula_notice_directory" <<'PY' +import pathlib +import shutil +import sys + +source = pathlib.Path(sys.argv[1]) +destination = pathlib.Path(sys.argv[2]) +for notice in source.rglob("*"): + name = notice.name.upper() + if notice.is_file() and name.startswith(("LICENSE", "COPYING", "NOTICE")): + target = destination / notice.relative_to(source) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(notice, target) +PY +done <"$homebrew_formulae" + +# The reviewed SDK may use only isolated-prefix dylibs, Apple system libraries, +# and the complete version/license-recorded transitive Homebrew formula set. +homebrew_roots="$profile_directory/homebrew-allowed-roots.txt" +while IFS= read -r formula; do + formula_prefix="$(brew --prefix "$formula")" + (cd "$formula_prefix" && pwd -P) +done <"$homebrew_formulae" | LC_ALL=C sort -u >"$homebrew_roots" +while IFS= read -r binary; do + while IFS= read -r dependency; do + case "$dependency" in + "$prefix"/* | /System/Library/* | /usr/lib/* | @rpath/* | @loader_path/*) + continue + ;; + esac + resolved_dependency="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$dependency")" + declared=false + while IFS= read -r allowed_root; do + case "$resolved_dependency" in + "$allowed_root"/*) + declared=true + break + ;; + esac + done <"$homebrew_roots" + if [[ "$declared" != true ]]; then + echo "undeclared dependency in macOS libmpv closure: $binary -> $dependency" >&2 + exit 1 + fi + done < <(otool -L "$binary" | awk 'NR > 1 { print $1 }') +done < <(find "$prefix/lib" -type f -name '*.dylib' -print) + +otool -D "$libmpv" +otool -L "$libmpv" +echo "built Ferrex macOS libmpv $MPV_VERSION (client API $actual_client_api) at $prefix" diff --git a/scripts/release/macos-gstreamer-plugins.txt b/scripts/release/macos-gstreamer-plugins.txt new file mode 100644 index 00000000..6fc51cf3 --- /dev/null +++ b/scripts/release/macos-gstreamer-plugins.txt @@ -0,0 +1,30 @@ +# Reviewed macOS GStreamer fallback surface. Homebrew's current monolithic +# formula also ships GPL/codec plugins and a libav plugin that conflicts with +# the pinned FFmpeg SONAME closure; release packaging stages only this set. +libgstcoreelements.dylib +libgstplayback.dylib +libgstapp.dylib +libgstvideoconvertscale.dylib +libgstaudioconvert.dylib +libgstaudioresample.dylib +libgstvolume.dylib +libgsttypefindfunctions.dylib +libgstautodetect.dylib +libgstosxaudio.dylib +libgstapplemedia.dylib +libgstisomp4.dylib +libgstmatroska.dylib +libgstmpegtsdemux.dylib +libgstadaptivedemux2.dylib +libgstvideoparsersbad.dylib +libgstaudioparsers.dylib +libgstsoup.dylib +libgstaudiofx.dylib +libgstgio.dylib +libgstrawparse.dylib +libgstflac.dylib +libgstmpg123.dylib +libgstopus.dylib +libgstvorbis.dylib +libgstdav1d.dylib +libgstvpx.dylib diff --git a/scripts/release/macos_bundle.py b/scripts/release/macos_bundle.py new file mode 100644 index 00000000..6749d896 --- /dev/null +++ b/scripts/release/macos_bundle.py @@ -0,0 +1,1040 @@ +#!/usr/bin/env python3 +"""Stage and verify a relocatable Ferrex macOS application bundle. + +The bundler copies the complete non-system Mach-O dependency closure into +``Contents/Frameworks``, rewrites install names to ``@rpath``, removes package +manager/developer rpaths, and signs only after all binary mutation is complete. +It is intentionally independent of Homebrew layout: search roots are build +inputs, never runtime lookup paths. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import plistlib +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Iterable, Sequence + + +APP_RPATH = "@executable_path/../Frameworks" +GSTREAMER_PLUGIN_RPATH = "@loader_path/../../Frameworks" +GIO_MODULE_RPATH = "@loader_path/../../../Frameworks" +PRESENTER_BUILD_MODES = frozenset({"disabled", "spike"}) +FORBIDDEN_RUNTIME_PREFIXES = ( + "/opt/homebrew/", + "/usr/local/", + "/nix/store/", +) +SYSTEM_LIBRARY_PREFIXES = ( + "/System/Library/", + "/usr/lib/", +) +FORBIDDEN_GSTREAMER_PLUGIN_NAMES = { + "libgstassrender.dylib", + "libgstlibav.dylib", + "libgstx264.dylib", + "libgstx265.dylib", +} +FORBIDDEN_GSTREAMER_DEPENDENCY_PREFIXES = ( + "libass.", + "libavcodec.", + "libavdevice.", + "libavfilter.", + "libavformat.", + "libavutil.", + "libfaac.", + "libfdk-aac.", + "libswresample.", + "libswscale.", + "libx264.", + "libx265.", +) + + +class BundleError(RuntimeError): + """A bundle cannot satisfy the relocatable install-name policy.""" + + +@dataclass(frozen=True) +class MachORecord: + path: Path + install_id: str | None + dependencies: tuple[str, ...] + rpaths: tuple[str, ...] + architectures: tuple[str, ...] + minimum_macos: str | None + executable: bool = False + install_id_required: bool = True + + +@dataclass +class StagedMachO: + source: Path + staged: Path + record: MachORecord + dependency_targets: dict[str, str] + + +def run( + arguments: Sequence[str | os.PathLike[str]], + *, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + command = [os.fspath(argument) for argument in arguments] + completed = subprocess.run( + command, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise BundleError(f"command failed ({' '.join(command)}): {detail}") + return completed + + +def parse_otool_libraries(output: str) -> tuple[str, ...]: + dependencies: list[str] = [] + for line in output.splitlines()[1:]: + match = re.match(r"^\s+(.+?)\s+\(compatibility version ", line) + if match: + dependencies.append(match.group(1)) + return tuple(dependencies) + + +def parse_otool_rpaths(output: str) -> tuple[str, ...]: + lines = output.splitlines() + rpaths: list[str] = [] + for index, line in enumerate(lines): + if line.strip() != "cmd LC_RPATH": + continue + for candidate in lines[index + 1 : index + 5]: + match = re.match(r"^\s*path (.+?) \(offset \d+\)$", candidate) + if match: + rpaths.append(match.group(1)) + break + return tuple(rpaths) + + +def parse_architectures(output: str) -> tuple[str, ...]: + return tuple(part for part in output.strip().split() if part) + + +def parse_macos_deployment_target(output: str) -> str | None: + """Return the Mach-O's LC_BUILD_VERSION/LC_VERSION_MIN_MACOSX target.""" + lines = output.splitlines() + targets: list[str] = [] + for index, line in enumerate(lines): + command = line.strip() + if command not in {"cmd LC_BUILD_VERSION", "cmd LC_VERSION_MIN_MACOSX"}: + continue + field = "minos" if command == "cmd LC_BUILD_VERSION" else "version" + for candidate in lines[index + 1 : index + 8]: + match = re.match(rf"^\s*{field}\s+(\d+(?:\.\d+)*)\s*$", candidate) + if match: + targets.append(match.group(1)) + break + if not targets: + return None + # Fat inputs can contain one load-command block per architecture. Audit + # the strictest slice so a lower first slice cannot hide a newer minimum. + return max(targets, key=version_tuple) + + +def version_tuple(value: str) -> tuple[int, ...]: + if not re.fullmatch(r"\d+(?:\.\d+)*", value): + raise BundleError(f"invalid numeric version: {value!r}") + return tuple(int(component) for component in value.split(".")) + + +def apple_bundle_version(version: str) -> str: + """Normalize a Cargo semver to Apple's numeric bundle-version grammar.""" + match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?", version) + if match is None: + raise BundleError(f"cannot derive Apple bundle version from {version!r}") + return ".".join(component for component in match.groups(default="0")) + + +def is_system_library(install_name: str) -> bool: + return install_name.startswith(SYSTEM_LIBRARY_PREFIXES) + + +def has_forbidden_runtime_prefix(value: str) -> bool: + return value.startswith(FORBIDDEN_RUNTIME_PREFIXES) + + +def macho_record( + path: Path, + *, + executable: bool = False, + install_id_required: bool | None = None, +) -> MachORecord: + libraries = parse_otool_libraries(run(["otool", "-L", path]).stdout) + id_result = run(["otool", "-D", path], check=False) + install_id = None + if id_result.returncode == 0: + id_lines = [line.strip() for line in id_result.stdout.splitlines()[1:]] + install_id = next((line for line in id_lines if line), None) + dependencies = tuple( + dependency for dependency in libraries if dependency != install_id + ) + load_commands = run(["otool", "-l", path]).stdout + architectures = parse_architectures(run(["lipo", "-archs", path]).stdout) + return MachORecord( + path=path, + install_id=install_id, + dependencies=dependencies, + rpaths=parse_otool_rpaths(load_commands), + architectures=architectures, + minimum_macos=parse_macos_deployment_target(load_commands), + executable=executable, + install_id_required=(not executable) + if install_id_required is None + else install_id_required, + ) + + +def expand_special_path( + value: str, + *, + loader: Path, + executable: Path, +) -> Path | None: + replacements = { + "@loader_path": loader.parent, + "@executable_path": executable.parent, + } + for marker, root in replacements.items(): + if value == marker: + return root + if value.startswith(f"{marker}/"): + return root / value[len(marker) + 1 :] + if value.startswith("@"): + return None + return Path(value) + + +class DependencyResolver: + def __init__(self, search_roots: Iterable[Path], executable: Path): + self.search_roots = tuple(root.resolve() for root in search_roots) + self.executable = executable.resolve() + self._basename_index: dict[str, list[Path]] | None = None + self._allowed_sources: set[Path] | None = None + self._record_cache: dict[Path, MachORecord] = {} + + def record( + self, + path: Path, + *, + executable: bool = False, + install_id_required: bool | None = None, + ) -> MachORecord: + resolved = path.resolve() + cached = self._record_cache.get(resolved) + if cached is None: + cached = macho_record(resolved) + self._record_cache[resolved] = cached + return replace( + cached, + executable=executable, + install_id_required=(not executable) + if install_id_required is None + else install_id_required, + ) + + def resolve(self, install_name: str, loader: Path) -> Path: + if is_system_library(install_name): + return Path(install_name) + + expanded = expand_special_path( + install_name, + loader=loader, + executable=self.executable, + ) + if expanded is not None and expanded.exists(): + return self._require_declared_source(expanded.resolve(), install_name, loader) + + if install_name.startswith("@rpath/"): + suffix = install_name[len("@rpath/") :] + records = [self.record(loader)] + if loader.resolve() != self.executable: + records.append(self.record(self.executable, executable=True)) + for record in records: + for rpath in record.rpaths: + root = expand_special_path( + rpath, + loader=loader, + executable=self.executable, + ) + if root is not None: + candidate = root / suffix + if candidate.exists(): + return self._require_declared_source( + candidate.resolve(), install_name, loader + ) + + basename = Path(install_name).name + candidates = self._index().get(basename, []) + if not candidates: + raise BundleError( + f"cannot resolve {install_name!r} required by {loader}" + ) + return candidates[0] + + def _require_declared_source( + self, + candidate: Path, + install_name: str, + loader: Path, + ) -> Path: + # An existing absolute install name is not sufficient evidence that a + # dependency belongs in a release. It must also be reachable through a + # caller-declared search root, including through a symlink in that root. + self._index() + assert self._allowed_sources is not None + if candidate not in self._allowed_sources: + raise BundleError( + f"dependency {install_name!r} required by {loader} resolves outside " + f"declared search roots: {candidate}" + ) + return candidate + + def _index(self) -> dict[str, list[Path]]: + if self._basename_index is None: + index: dict[str, list[Path]] = {} + allowed_sources: set[Path] = set() + for root in self.search_roots: + if not root.is_dir(): + raise BundleError(f"library search root is not a directory: {root}") + for candidate in root.rglob("*"): + if candidate.is_file() and ( + candidate.suffix == ".dylib" + or ".framework/" in candidate.as_posix() + ): + resolved = candidate.resolve() + allowed_sources.add(resolved) + bucket = index.setdefault(candidate.name, []) + if resolved not in bucket: + bucket.append(resolved) + self._basename_index = index + self._allowed_sources = allowed_sources + return self._basename_index + + +def copy_dependency_closure( + binary: Path, + staged_binary: Path, + frameworks: Path, + search_roots: Iterable[Path], + additional_seeds: Iterable[tuple[Path, Path, bool, bool]] = (), +) -> list[StagedMachO]: + resolver = DependencyResolver(search_roots, binary) + queue: list[tuple[Path, Path, bool, bool]] = [ + (binary.resolve(), staged_binary, True, False) + ] + staged_by_source: dict[Path, Path] = {binary.resolve(): staged_binary} + source_by_basename: dict[str, Path] = {staged_binary.name: binary.resolve()} + for ( + seed_source, + seed_target, + seed_executable, + seed_install_id_required, + ) in additional_seeds: + resolved_seed = seed_source.resolve() + previous_source = source_by_basename.get(seed_target.name) + if previous_source is not None and previous_source != resolved_seed: + raise BundleError( + "bundle contains two different Mach-O files named " + f"{seed_target.name}: {previous_source} and {resolved_seed}" + ) + previous_target = staged_by_source.get(resolved_seed) + if previous_target is not None and previous_target != seed_target: + raise BundleError( + f"Mach-O seed {resolved_seed} was staged twice as " + f"{previous_target} and {seed_target}" + ) + source_by_basename[seed_target.name] = resolved_seed + staged_by_source[resolved_seed] = seed_target + queue.append( + ( + resolved_seed, + seed_target, + seed_executable, + seed_install_id_required, + ) + ) + staged_records: list[StagedMachO] = [] + + while queue: + source, staged, executable, install_id_required = queue.pop(0) + record = resolver.record( + source, + executable=executable, + install_id_required=install_id_required, + ) + targets: dict[str, str] = {} + for dependency in record.dependencies: + if is_system_library(dependency): + continue + resolved = resolver.resolve(dependency, source) + # Preserve the dependency's install-name basename (its SONAME), + # even when resolving a symlink yields a more-versioned filename. + # Callers and --require-library intentionally refer to this name. + basename = Path(dependency).name or resolved.name + existing_target = staged_by_source.get(resolved) + if existing_target is not None: + basename = existing_target.name + previous_source = source_by_basename.get(basename) + if previous_source is not None and previous_source != resolved: + raise BundleError( + "dependency closure contains two different libraries named " + f"{basename}: {previous_source} and {resolved}" + ) + source_by_basename[basename] = resolved + target = frameworks / basename + targets[dependency] = basename + if existing_target is None: + shutil.copy2(resolved, target) + target.chmod(target.stat().st_mode | 0o200) + staged_by_source[resolved] = target + queue.append((resolved, target, False, True)) + staged_records.append( + StagedMachO( + source=source, + staged=staged, + record=record, + dependency_targets=targets, + ) + ) + + return staged_records + + +def rewrite_install_names(records: Iterable[StagedMachO]) -> None: + for staged in records: + for old_name, basename in staged.dependency_targets.items(): + run( + [ + "install_name_tool", + "-change", + old_name, + f"@rpath/{basename}", + staged.staged, + ] + ) + if staged.record.install_id_required: + run( + [ + "install_name_tool", + "-id", + f"@rpath/{staged.staged.name}", + staged.staged, + ] + ) + for rpath in staged.record.rpaths: + if rpath in {APP_RPATH, GSTREAMER_PLUGIN_RPATH, GIO_MODULE_RPATH} or rpath.startswith( + SYSTEM_LIBRARY_PREFIXES + ): + continue + run(["install_name_tool", "-delete_rpath", rpath, staged.staged]) + required_rpath = None + if staged.record.executable: + required_rpath = APP_RPATH + elif "Contents/PlugIns/gstreamer-1.0" in staged.staged.as_posix(): + # GStreamer's soup loader opens libsoup by leaf name rather than a + # Mach-O import. Give dyld a bundle-local search path for that load. + required_rpath = GSTREAMER_PLUGIN_RPATH + elif "Contents/PlugIns/gio/modules" in staged.staged.as_posix(): + required_rpath = GIO_MODULE_RPATH + if required_rpath is not None: + current = macho_record(staged.staged, executable=staged.record.executable) + if required_rpath not in current.rpaths: + run( + [ + "install_name_tool", + "-add_rpath", + required_rpath, + staged.staged, + ] + ) + + +def write_info_plist( + contents: Path, + *, + executable_name: str, + bundle_name: str, + bundle_identifier: str, + version: str, + minimum_macos: str, +) -> None: + normalized_version = apple_bundle_version(version) + info = { + "CFBundleDevelopmentRegion": "en", + "CFBundleExecutable": executable_name, + "CFBundleIdentifier": bundle_identifier, + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": bundle_name, + "CFBundleDisplayName": bundle_name, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": normalized_version, + "CFBundleVersion": normalized_version, + "LSMinimumSystemVersion": minimum_macos, + "NSHighResolutionCapable": True, + "NSSupportsAutomaticGraphicsSwitching": True, + } + with (contents / "Info.plist").open("wb") as plist: + plistlib.dump(info, plist, sort_keys=True) + + +def copy_resources(resources: Iterable[Path], destination: Path) -> None: + for resource in resources: + if not resource.exists(): + raise BundleError(f"bundle resource does not exist: {resource}") + target = destination / resource.name + if resource.is_dir(): + shutil.copytree(resource, target) + else: + shutil.copy2(resource, target) + + +def copy_macho_seed( + source: Path, + target: Path, + *, + executable: bool, + install_id_required: bool | None = None, +) -> tuple[Path, Path, bool, bool]: + if not source.is_file(): + raise BundleError(f"Mach-O bundle input does not exist: {source}") + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + raise BundleError(f"duplicate Mach-O bundle destination: {target}") + shutil.copy2(source, target) + mode = target.stat().st_mode | 0o200 + if executable: + mode |= 0o111 + target.chmod(mode) + return ( + source, + target, + executable, + (not executable) if install_id_required is None else install_id_required, + ) + + +def stage_gstreamer_runtime( + plugin_directories: Iterable[Path], + plugin_files: Iterable[Path], + scanner: Path | None, + contents: Path, + excluded_plugins: Iterable[str] = (), +) -> list[tuple[Path, Path, bool, bool]]: + directories = tuple(plugin_directories) + files = tuple(plugin_files) + if not directories and not files and scanner is None: + return [] + if (not directories and not files) or scanner is None: + raise BundleError( + "GStreamer packaging requires plugin inputs and --gstreamer-scanner" + ) + + seeds: list[tuple[Path, Path, bool, bool]] = [] + exclusions = set(excluded_plugins) + plugin_target = contents / "PlugIns/gstreamer-1.0" + seen_names: dict[str, Path] = {} + candidates = list(files) + for directory in directories: + if not directory.is_dir(): + raise BundleError(f"GStreamer plugin directory does not exist: {directory}") + candidates.extend(sorted(directory.rglob("*.dylib"))) + for plugin in candidates: + if not plugin.is_file(): + raise BundleError(f"GStreamer plugin does not exist: {plugin}") + if plugin.suffix != ".dylib": + raise BundleError(f"GStreamer plugin is not a .dylib: {plugin}") + if plugin.name in exclusions: + continue + resolved = plugin.resolve() + previous = seen_names.get(plugin.name) + if previous is not None and previous != resolved: + raise BundleError( + f"two GStreamer plugins are named {plugin.name}: " + f"{previous} and {resolved}" + ) + if previous is None: + seen_names[plugin.name] = resolved + seeds.append( + copy_macho_seed( + plugin, + plugin_target / plugin.name, + executable=False, + ) + ) + if not seeds: + raise BundleError("no GStreamer .dylib plugins were found") + manifest = contents / "Resources/gstreamer-plugin-manifest.txt" + manifest.write_text( + "".join(f"{name}\n" for name in sorted(seen_names)), + encoding="utf-8", + ) + seeds.append( + copy_macho_seed( + scanner, + contents / "Helpers/gst-plugin-scanner", + executable=True, + ) + ) + return seeds + + +def stage_vulkan_icd(source: Path, resources: Path, library_name: str) -> Path: + if not source.is_file(): + raise BundleError(f"Vulkan ICD manifest does not exist: {source}") + try: + manifest = json.loads(source.read_text(encoding="utf-8")) + icd = manifest["ICD"] + except (OSError, json.JSONDecodeError, KeyError, TypeError) as error: + raise BundleError(f"invalid Vulkan ICD manifest {source}: {error}") from error + if not isinstance(icd, dict): + raise BundleError(f"invalid Vulkan ICD object in {source}") + icd["library_path"] = f"../../../Frameworks/{library_name}" + target = resources / "vulkan/icd.d/MoltenVK_icd.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return target + + +def stage_ca_bundle(source: Path, resources: Path) -> Path: + if not source.is_file(): + raise BundleError(f"CA bundle does not exist: {source}") + data = source.read_bytes() + if b"-----BEGIN CERTIFICATE-----" not in data: + raise BundleError(f"CA bundle contains no PEM certificates: {source}") + target = resources / "tls/cacert.pem" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + (target.parent / "cacert.pem.sha256").write_text( + hashlib.sha256(data).hexdigest() + " cacert.pem\n", + encoding="utf-8", + ) + return target + + +def stage_presenter_build_mode(mode: str, resources: Path) -> Path: + if mode not in PRESENTER_BUILD_MODES: + raise BundleError(f"invalid macOS presenter build mode: {mode!r}") + target = resources / "presenter-build-mode.txt" + target.write_text(f"{mode}\n", encoding="utf-8") + return target + + +def load_presenter_build_mode(app: Path) -> str: + path = app / "Contents/Resources/presenter-build-mode.txt" + try: + mode = path.read_text(encoding="utf-8").strip() + except OSError as error: + raise BundleError(f"presenter build-mode evidence is missing: {path}") from error + if mode not in PRESENTER_BUILD_MODES: + raise BundleError(f"invalid presenter build-mode evidence: {mode!r}") + return mode + + +def stage_gio_modules( + modules: Iterable[Path], contents: Path +) -> list[tuple[Path, Path, bool, bool]]: + seeds: list[tuple[Path, Path, bool, bool]] = [] + destination = contents / "PlugIns/gio/modules" + for module in modules: + seeds.append( + copy_macho_seed( + module, + destination / module.name, + executable=False, + # GIO modules are Mach-O bundles rather than dylibs and do not + # carry LC_ID_DYLIB. + install_id_required=False, + ) + ) + return seeds + + +def sign_bundle(app: Path, identity: str, nested_machos: Iterable[Path]) -> None: + # Sign every nested Mach-O explicitly after install-name mutation. The app + # signature is last so its sealed-resource envelope includes final bytes. + for library in sorted(set(nested_machos), key=lambda path: (-len(path.parts), str(path))): + arguments = ["codesign", "--force", "--sign", identity] + if identity == "-": + arguments.append("--timestamp=none") + else: + arguments.extend(["--options", "runtime", "--timestamp"]) + run([*arguments, library]) + + arguments = ["codesign", "--force", "--sign", identity] + if identity == "-": + arguments.append("--timestamp=none") + else: + arguments.extend(["--options", "runtime", "--timestamp"]) + run([*arguments, app]) + + +def audit_records( + records: Iterable[MachORecord], + *, + framework_names: set[str], + required_architecture: str | None, + executable_name: str, + maximum_deployment_target: str, +) -> None: + records = tuple(records) + errors: list[str] = [] + maximum_target = version_tuple(maximum_deployment_target) + for record in records: + if required_architecture and required_architecture not in record.architectures: + errors.append( + f"{record.path.name} lacks architecture {required_architecture}: " + f"{','.join(record.architectures)}" + ) + if record.minimum_macos is None: + errors.append(f"{record.path.name} has no macOS deployment-target load command") + elif version_tuple(record.minimum_macos) > maximum_target: + errors.append( + f"{record.path.name} requires macOS {record.minimum_macos}, newer than " + f"bundle minimum {maximum_deployment_target}" + ) + if ( + record.install_id_required + and record.install_id != f"@rpath/{record.path.name}" + ): + errors.append( + f"{record.path.name} has non-relocatable id {record.install_id!r}" + ) + for dependency in record.dependencies: + if is_system_library(dependency): + continue + if has_forbidden_runtime_prefix(dependency): + errors.append( + f"{record.path.name} retains developer dependency {dependency}" + ) + continue + if not dependency.startswith("@rpath/"): + errors.append( + f"{record.path.name} has non-canonical dependency {dependency}" + ) + continue + basename = dependency[len("@rpath/") :] + if basename not in framework_names: + errors.append( + f"{record.path.name} dependency {dependency} is not bundled" + ) + for rpath in record.rpaths: + if has_forbidden_runtime_prefix(rpath): + errors.append(f"{record.path.name} retains developer rpath {rpath}") + if record.executable and APP_RPATH not in record.rpaths: + errors.append(f"{executable_name} lacks bundle rpath {APP_RPATH}") + if errors: + raise BundleError("invalid Mach-O bundle closure:\n- " + "\n- ".join(errors)) + + +def audit_gstreamer_runtime(app: Path, records: Iterable[MachORecord]) -> None: + plugins = app / "Contents/PlugIns/gstreamer-1.0" + if not plugins.is_dir(): + return + actual_names = {path.name for path in plugins.glob("*.dylib") if path.is_file()} + forbidden_names = sorted(actual_names & FORBIDDEN_GSTREAMER_PLUGIN_NAMES) + if forbidden_names: + raise BundleError( + "forbidden GStreamer plugins are bundled: " + ", ".join(forbidden_names) + ) + manifest_path = app / "Contents/Resources/gstreamer-plugin-manifest.txt" + if not manifest_path.is_file(): + raise BundleError("bundled GStreamer plugin manifest is missing") + manifest_names = { + line.strip() + for line in manifest_path.read_text(encoding="utf-8").splitlines() + if line.strip() + } + if manifest_names != actual_names: + raise BundleError("bundled GStreamer plugin manifest does not match staged files") + + errors: list[str] = [] + for record in records: + if plugins not in record.path.parents: + continue + if GSTREAMER_PLUGIN_RPATH not in record.rpaths: + errors.append( + f"{record.path.name} lacks bundle dynamic-loader rpath " + f"{GSTREAMER_PLUGIN_RPATH}" + ) + for dependency in record.dependencies: + basename = Path(dependency).name + if basename.startswith(FORBIDDEN_GSTREAMER_DEPENDENCY_PREFIXES): + errors.append(f"{record.path.name} links forbidden dependency {basename}") + if errors: + raise BundleError( + "invalid GStreamer license/closure surface:\n- " + "\n- ".join(errors) + ) + + +def load_bundle_records(app: Path) -> tuple[list[MachORecord], str, str]: + info_path = app / "Contents/Info.plist" + if not info_path.is_file(): + raise BundleError(f"missing bundle metadata: {info_path}") + with info_path.open("rb") as plist: + info = plistlib.load(plist) + executable_name = info.get("CFBundleExecutable") + if not isinstance(executable_name, str) or not executable_name: + raise BundleError("Info.plist does not define CFBundleExecutable") + executable = app / "Contents/MacOS" / executable_name + if not executable.is_file(): + raise BundleError(f"missing application executable: {executable}") + minimum_macos = info.get("LSMinimumSystemVersion") + if not isinstance(minimum_macos, str): + raise BundleError("Info.plist does not define LSMinimumSystemVersion") + version_tuple(minimum_macos) + frameworks = app / "Contents/Frameworks" + records = [macho_record(executable, executable=True)] + records.extend(macho_record(path) for path in sorted(frameworks.rglob("*")) if path.is_file()) + plugins = app / "Contents/PlugIns/gstreamer-1.0" + if plugins.is_dir(): + records.extend( + macho_record(path) for path in sorted(plugins.rglob("*.dylib")) if path.is_file() + ) + scanner = app / "Contents/Helpers/gst-plugin-scanner" + if scanner.is_file(): + records.append(macho_record(scanner, executable=True)) + gio_modules = app / "Contents/PlugIns/gio/modules" + if gio_modules.is_dir(): + records.extend( + macho_record(path, install_id_required=False) + for path in sorted(gio_modules.iterdir()) + if path.is_file() + ) + return records, executable_name, minimum_macos + + +def verify_bundle( + app: Path, + *, + required_architecture: str | None, + required_libraries: Iterable[str], + verify_signature: bool, + require_gstreamer_runtime: bool = False, + require_vulkan_icd: bool = False, + expected_presenter_mode: str | None = None, +) -> None: + records, executable_name, minimum_macos = load_bundle_records(app) + presenter_mode = load_presenter_build_mode(app) + if expected_presenter_mode is not None and presenter_mode != expected_presenter_mode: + raise BundleError( + f"presenter build mode is {presenter_mode!r}, expected " + f"{expected_presenter_mode!r}" + ) + frameworks = app / "Contents/Frameworks" + framework_names = {path.name for path in frameworks.iterdir() if path.is_file()} + missing = sorted(set(required_libraries) - framework_names) + if missing: + raise BundleError(f"required bundled libraries are missing: {', '.join(missing)}") + if require_gstreamer_runtime: + plugins = app / "Contents/PlugIns/gstreamer-1.0" + scanner = app / "Contents/Helpers/gst-plugin-scanner" + if not plugins.is_dir() or not any(plugins.glob("*.dylib")): + raise BundleError("required bundled GStreamer plugins are missing") + if not scanner.is_file() or not os.access(scanner, os.X_OK): + raise BundleError("required bundled GStreamer plugin scanner is missing") + gio_modules = app / "Contents/PlugIns/gio/modules" + if not gio_modules.is_dir() or not any(gio_modules.iterdir()): + raise BundleError("required bundled GIO TLS modules are missing") + for record in records: + if gio_modules in record.path.parents and GIO_MODULE_RPATH not in record.rpaths: + raise BundleError( + f"{record.path.name} lacks bundle GIO module rpath {GIO_MODULE_RPATH}" + ) + ca_bundle = app / "Contents/Resources/tls/cacert.pem" + ca_hash = app / "Contents/Resources/tls/cacert.pem.sha256" + if not ca_bundle.is_file() or not ca_hash.is_file(): + raise BundleError("required bundled CA trust store is missing") + data = ca_bundle.read_bytes() + expected_hash = ca_hash.read_text(encoding="utf-8").split()[0] + if ( + b"-----BEGIN CERTIFICATE-----" not in data + or hashlib.sha256(data).hexdigest() != expected_hash + ): + raise BundleError("bundled CA trust store hash/content is invalid") + if require_vulkan_icd: + manifest_path = app / "Contents/Resources/vulkan/icd.d/MoltenVK_icd.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + library_path = manifest["ICD"]["library_path"] + except (OSError, json.JSONDecodeError, KeyError, TypeError) as error: + raise BundleError(f"required bundled Vulkan ICD is invalid: {error}") from error + expected = "../../../Frameworks/libMoltenVK.dylib" + if library_path != expected: + raise BundleError( + f"bundled Vulkan ICD uses {library_path!r}, expected {expected!r}" + ) + if not (frameworks / "libMoltenVK.dylib").is_file(): + raise BundleError("bundled Vulkan ICD library is missing") + audit_gstreamer_runtime(app, records) + audit_records( + records, + framework_names=framework_names, + required_architecture=required_architecture, + executable_name=executable_name, + maximum_deployment_target=minimum_macos, + ) + run(["plutil", "-lint", app / "Contents/Info.plist"]) + if verify_signature: + run(["codesign", "--verify", "--deep", "--strict", "--verbose=2", app]) + + +def stage_bundle(arguments: argparse.Namespace) -> None: + binary = arguments.binary.resolve() + app = arguments.app.resolve() + if not binary.is_file(): + raise BundleError(f"application binary does not exist: {binary}") + if app.exists(): + raise BundleError(f"refusing to replace existing application bundle: {app}") + + contents = app / "Contents" + macos = contents / "MacOS" + frameworks = contents / "Frameworks" + resources = contents / "Resources" + macos.mkdir(parents=True) + frameworks.mkdir() + resources.mkdir() + staged_binary = macos / arguments.executable_name + shutil.copy2(binary, staged_binary) + staged_binary.chmod(staged_binary.stat().st_mode | 0o111 | 0o200) + write_info_plist( + contents, + executable_name=arguments.executable_name, + bundle_name=arguments.bundle_name, + bundle_identifier=arguments.bundle_identifier, + version=arguments.version, + minimum_macos=arguments.minimum_macos, + ) + copy_resources(arguments.resource, resources) + additional_seeds = stage_gstreamer_runtime( + arguments.gstreamer_plugin_dir, + arguments.gstreamer_plugin, + arguments.gstreamer_scanner, + contents, + arguments.exclude_gstreamer_plugin, + ) + additional_seeds.extend(stage_gio_modules(arguments.gio_module, contents)) + for library in arguments.extra_library: + additional_seeds.append( + copy_macho_seed( + library, + frameworks / library.name, + executable=False, + ) + ) + if arguments.vulkan_icd: + stage_vulkan_icd(arguments.vulkan_icd, resources, "libMoltenVK.dylib") + if arguments.ca_bundle: + stage_ca_bundle(arguments.ca_bundle, resources) + stage_presenter_build_mode(arguments.presenter_mode, resources) + records = copy_dependency_closure( + binary, + staged_binary, + frameworks, + arguments.search_root, + additional_seeds, + ) + rewrite_install_names(records) + if arguments.sign_identity: + sign_bundle( + app, + arguments.sign_identity, + (record.staged for record in records if record.staged != staged_binary), + ) + verify_bundle( + app, + required_architecture=arguments.architecture, + required_libraries=arguments.require_library, + verify_signature=bool(arguments.sign_identity), + require_gstreamer_runtime=bool( + arguments.gstreamer_plugin_dir or arguments.gstreamer_plugin + ), + require_vulkan_icd=bool(arguments.vulkan_icd), + expected_presenter_mode=arguments.presenter_mode, + ) + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + commands = root.add_subparsers(dest="command", required=True) + + stage = commands.add_parser("stage", help="create and verify an .app bundle") + stage.add_argument("--binary", type=Path, required=True) + stage.add_argument("--app", type=Path, required=True) + stage.add_argument("--search-root", type=Path, action="append", default=[], required=True) + stage.add_argument("--resource", type=Path, action="append", default=[]) + stage.add_argument("--extra-library", type=Path, action="append", default=[]) + stage.add_argument( + "--gstreamer-plugin-dir", type=Path, action="append", default=[] + ) + stage.add_argument("--gstreamer-plugin", type=Path, action="append", default=[]) + stage.add_argument("--gstreamer-scanner", type=Path) + stage.add_argument("--exclude-gstreamer-plugin", action="append", default=[]) + stage.add_argument("--gio-module", type=Path, action="append", default=[]) + stage.add_argument("--ca-bundle", type=Path) + stage.add_argument("--vulkan-icd", type=Path) + stage.add_argument("--require-library", action="append", default=["libmpv.2.dylib"]) + stage.add_argument("--architecture", choices=("arm64", "x86_64"), required=True) + stage.add_argument("--executable-name", default="ferrex-player") + stage.add_argument("--bundle-name", default="Ferrex Player") + stage.add_argument("--bundle-identifier", default="io.github.lowband21.FerrexPlayer") + stage.add_argument("--version", required=True) + stage.add_argument("--minimum-macos", default="15.0") + stage.add_argument( + "--presenter-mode", choices=sorted(PRESENTER_BUILD_MODES), required=True + ) + stage.add_argument("--sign-identity", default="-") + stage.set_defaults(handler=stage_bundle) + + verify = commands.add_parser("verify", help="audit an existing .app bundle") + verify.add_argument("--app", type=Path, required=True) + verify.add_argument("--require-library", action="append", default=["libmpv.2.dylib"]) + verify.add_argument("--architecture", choices=("arm64", "x86_64")) + verify.add_argument("--require-gstreamer-runtime", action="store_true") + verify.add_argument("--require-vulkan-icd", action="store_true") + verify.add_argument("--presenter-mode", choices=sorted(PRESENTER_BUILD_MODES)) + verify.add_argument("--skip-signature", action="store_true") + verify.set_defaults( + handler=lambda arguments: verify_bundle( + arguments.app.resolve(), + required_architecture=arguments.architecture, + required_libraries=arguments.require_library, + verify_signature=not arguments.skip_signature, + require_gstreamer_runtime=arguments.require_gstreamer_runtime, + require_vulkan_icd=arguments.require_vulkan_icd, + expected_presenter_mode=arguments.presenter_mode, + ) + ) + return root + + +def main() -> int: + arguments = parser().parse_args() + try: + arguments.handler(arguments) + except BundleError as error: + print(f"macOS bundle error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release/macos_gstreamer_bundle_smoke.c b/scripts/release/macos_gstreamer_bundle_smoke.c new file mode 100644 index 00000000..58175224 --- /dev/null +++ b/scripts/release/macos_gstreamer_bundle_smoke.c @@ -0,0 +1,120 @@ +/* Exercise the staged GStreamer closure without linking to Homebrew dylibs. */ + +#include +#include +#include +#include + +static int audit_factories(void) { + static const char *required[] = { + "playbin3", + "appsink", + "videoconvertscale", + "scaletempo", + "vtdec", + "atdec", + "qtdemux", + "h264parse", + "souphttpsrc", + "osxaudiosink", + }; + + for (size_t index = 0; index < G_N_ELEMENTS(required); index++) { + GstElementFactory *factory = gst_element_factory_find(required[index]); + if (factory == NULL) { + fprintf(stderr, "required clean-bundle factory is missing: %s\n", required[index]); + return 1; + } + gst_object_unref(factory); + } + + GstElementFactory *forbidden = gst_element_factory_find("avdec_h264"); + if (forbidden != NULL) { + fprintf(stderr, "excluded gst-libav factory leaked into the bundle: avdec_h264\n"); + gst_object_unref(forbidden); + return 1; + } + return 0; +} + +int main(int argc, char **argv) { + GstElement *pipeline; + GstElement *audio_sink; + GstElement *video_sink; + GstBus *bus; + GstMessage *message; + GTlsDatabase *database; + GError *tls_error = NULL; + int result = 1; + + if (argc != 4) { + fprintf(stderr, "usage: macos_gstreamer_bundle_smoke URL LIBSOUP CA_PEM\n"); + return 2; + } + if (dlopen(argv[2], RTLD_LAZY | RTLD_GLOBAL) == NULL) { + fprintf(stderr, "could not preload bundled libsoup: %s\n", dlerror()); + return 1; + } + database = g_tls_file_database_new(argv[3], &tls_error); + if (database == NULL) { + fprintf(stderr, "could not load test CA database: %s\n", tls_error->message); + g_clear_error(&tls_error); + return 1; + } + g_tls_backend_set_default_database(g_tls_backend_get_default(), database); + g_object_unref(database); + gst_init(&argc, &argv); + if (audit_factories() != 0) { + return 1; + } + pipeline = gst_element_factory_make("playbin3", NULL); + audio_sink = gst_element_factory_make("fakesink", NULL); + video_sink = gst_element_factory_make("fakesink", NULL); + if (pipeline == NULL || audio_sink == NULL || video_sink == NULL) { + fprintf(stderr, "required clean-bundle GStreamer factories are missing\n"); + goto cleanup_elements; + } + g_object_set(audio_sink, "sync", FALSE, NULL); + g_object_set(video_sink, "sync", FALSE, NULL); + g_object_set( + pipeline, + "uri", argv[1], + "audio-sink", audio_sink, + "video-sink", video_sink, + NULL + ); + if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { + fprintf(stderr, "clean-bundle HLS pipeline could not start\n"); + goto cleanup_pipeline; + } + + bus = gst_element_get_bus(pipeline); + message = gst_bus_timed_pop_filtered( + bus, + 30 * GST_SECOND, + GST_MESSAGE_ERROR | GST_MESSAGE_EOS + ); + if (message == NULL) { + fprintf(stderr, "clean-bundle HLS pipeline timed out\n"); + } else if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) { + GError *error = NULL; + gchar *debug = NULL; + gst_message_parse_error(message, &error, &debug); + fprintf(stderr, "clean-bundle HLS pipeline failed: %s\n", error->message); + g_clear_error(&error); + g_free(debug); + gst_message_unref(message); + } else { + gst_message_unref(message); + result = 0; + } + gst_object_unref(bus); + +cleanup_pipeline: + gst_element_set_state(pipeline, GST_STATE_NULL); +cleanup_elements: + if (pipeline != NULL) gst_object_unref(pipeline); + if (audio_sink != NULL) gst_object_unref(audio_sink); + if (video_sink != NULL) gst_object_unref(video_sink); + return result; +} diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py new file mode 100644 index 00000000..cda715c0 --- /dev/null +++ b/scripts/release/test_macos_build_profile.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Static guardrails for the reviewed macOS native playback build profile.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +BUILD = (ROOT / "scripts/release/macos-build-libmpv.sh").read_text(encoding="utf-8") +PLUGINS = (ROOT / "scripts/release/macos-gstreamer-plugins.txt").read_text( + encoding="utf-8" +) +SMOKE = (ROOT / "scripts/release/macos_gstreamer_bundle_smoke.c").read_text( + encoding="utf-8" +) +WORKFLOWS = "\n".join( + (ROOT / path).read_text(encoding="utf-8") + for path in (".github/workflows/ci.yml", ".github/workflows/macos-dist.yml") +) + + +class MacOSBuildProfileTests(unittest.TestCase): + def test_sources_and_revisions_are_pinned(self) -> None: + for expected in ( + 'MPV_VERSION="0.41.0"', + 'FFMPEG_COMMIT="38b88335f99e76ed89ff3c93f877fdefce736c13"', + 'LIBPLACEBO_COMMIT="cee9b076f2c63104ccfd497fa79c39a867293ec4"', + 'LIBASS_COMMIT="bbb3c7f1570a4a021e52683f3fbdf74fe492ae84"', + 'LUA_VERSION="5.2.4"', + 'LUA_ARCHIVE_SHA256="b9e2e4aad6789b3b63a056d442f7b39f0ecfca3ae0f1fc0ae4e9614401b69f4b"', + ): + self.assertIn(expected, BUILD) + self.assertGreaterEqual(BUILD.count("--wrap-mode=nofallback"), 3) + + def test_ffmpeg_profile_is_source_built_and_lgpl(self) -> None: + for flag in ( + "--disable-gpl", + "--disable-nonfree", + "--disable-version3", + "--enable-videotoolbox", + ): + self.assertIn(flag, BUILD) + self.assertNotIn("brew install ffmpeg", BUILD) + + def test_mpv_profile_keeps_required_macos_paths(self) -> None: + for flag in ( + "-Dcocoa=enabled", + "-Dswift-build=enabled", + "-Dmacos-cocoa-cb=enabled", + "-Dvideotoolbox-pl=enabled", + "-Dvideotoolbox-gl=enabled", + "-Dgl-cocoa=enabled", + "-Dvulkan=enabled", + "-Dshaderc=enabled", + "-Dlua=lua52", + ): + self.assertIn(flag, BUILD) + self.assertNotIn("-Dlibass=", BUILD) + + def test_lua_is_pic_library_only_without_readline_or_libdl(self) -> None: + self.assertIn('make -C "$lua_source/src"', BUILD) + self.assertIn('MYCFLAGS="-fPIC -DLUA_USE_MACOSX"', BUILD) + self.assertNotIn("macosx MYCFLAGS", BUILD) + self.assertNotIn("-lreadline", BUILD) + self.assertNotIn("-llua -lm -ldl", BUILD) + self.assertIn("pkg-config --modversion lua52", BUILD) + + def test_libass_and_libplacebo_profiles_are_explicit(self) -> None: + self.assertIn("-Dcoretext=enabled", BUILD) + self.assertIn("-Dfontconfig=disabled", BUILD) + self.assertIn("-Dvk-proc-addr=enabled", BUILD) + self.assertIn("-Dglslang=disabled", BUILD) + + def test_homebrew_trust_is_transitive_but_not_prefix_wide(self) -> None: + self.assertIn('brew deps --union "${homebrew_direct_formulae[@]}"', BUILD) + self.assertIn("homebrew-build-inputs.json", BUILD) + self.assertIn("homebrew-allowed-roots.txt", BUILD) + self.assertNotIn('"$brew_prefix"/*', BUILD) + + def test_curated_plugins_avoid_conflicting_or_gpl_codec_surfaces(self) -> None: + for required in ( + "libgstplayback.dylib", + "libgstapp.dylib", + "libgstvideoconvertscale.dylib", + "libgstapplemedia.dylib", + "libgstosxaudio.dylib", + "libgstaudiofx.dylib", + ): + self.assertIn(required, PLUGINS) + for forbidden in ( + "libgstlibav.dylib", + "libgstassrender.dylib", + "libgstx264.dylib", + "libgstx265.dylib", + "libgstscaletempo.dylib", + ): + self.assertNotIn(forbidden, PLUGINS) + + def test_workflows_stage_moltenvk_and_clean_gstreamer_runtime(self) -> None: + for expected in ( + "molten-vk shaderc vulkan-headers vulkan-loader", + "--extra-library", + "libMoltenVK.dylib", + "libsoup-3.0.0.dylib", + "--ca-bundle", + "ca-certificates", + "strict HTTPS smoke unexpectedly trusted", + "MoltenVK_icd.json", + "macos-gstreamer-plugins.txt", + "gst-plugin-scanner", + "GST_REGISTRY_1_0", + "GIO_EXTRA_MODULES", + "libgiognutls", + "https_test_server.py", + ): + self.assertIn(expected, WORKFLOWS) + self.assertNotIn("export DYLD_LIBRARY_PATH", WORKFLOWS) + for expected in ("avdec_h264", "atdec", "g_tls_file_database_new"): + self.assertIn(expected, SMOKE) + + def test_ca_bundle_is_immutable_and_provenance_recorded(self) -> None: + immutable_path = "share/ca-certificates/cacert.pem" + merged_path = "etc/ca-certificates/cert.pem" + self.assertIn(immutable_path, BUILD) + self.assertIn(immutable_path, WORKFLOWS) + self.assertNotIn(merged_path, WORKFLOWS) + for field in ( + "ca_certificates_version", + "ca_certificates_source", + "ca_certificates_source_sha256", + "ca_certificates_bundle_sha256", + ): + self.assertIn(field, BUILD) + + def test_tag_build_is_disabled_and_engineering_artifact_only(self) -> None: + distribution = (ROOT / ".github/workflows/macos-dist.yml").read_text( + encoding="utf-8" + ) + self.assertIn("presenter_mode:", distribution) + self.assertIn("|| 'disabled'", distribution) + self.assertIn("contents: read", distribution) + self.assertIn("actions/upload-artifact", distribution) + self.assertNotIn("ncipollo/release-action", distribution) + self.assertNotIn("Attach app to GitHub release", distribution) + self.assertIn("_${PRESENTER_MODE}.zip", distribution) + self.assertIn('--presenter-mode "$PRESENTER_MODE"', distribution) + self.assertIn( + "presenter-build-mode.txt", + (ROOT / "scripts/release/macos_bundle.py").read_text(encoding="utf-8"), + ) + + def test_gstreamer_version_and_allowlist_are_exact(self) -> None: + self.assertIn('GSTREAMER_VERSION="1.28.5"', BUILD) + self.assertIn("pkg-config --exact-version=", BUILD) + self.assertIn("gstreamer_plugins_sha256", BUILD) + self.assertIn('MACOSX_DEPLOYMENT_TARGET:=15.0', BUILD) + self.assertIn('MACOSX_DEPLOYMENT_TARGET: "15.0"', WORKFLOWS) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/test_macos_bundle.py b/scripts/release/test_macos_bundle.py new file mode 100644 index 00000000..748419b2 --- /dev/null +++ b/scripts/release/test_macos_bundle.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Display-free tests for the macOS install-name policy.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("macos_bundle.py") +SPEC = importlib.util.spec_from_file_location("macos_bundle", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +macos_bundle = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = macos_bundle +SPEC.loader.exec_module(macos_bundle) + + +class MacOSBundlePolicyTests(unittest.TestCase): + def record( + self, + name: str, + *, + install_id: str | None = None, + dependencies: tuple[str, ...] = (), + rpaths: tuple[str, ...] = (), + architectures: tuple[str, ...] = ("arm64",), + minimum_macos: str | None = "15.0", + executable: bool = False, + ) -> macos_bundle.MachORecord: + return macos_bundle.MachORecord( + path=Path(name), + install_id=install_id, + dependencies=dependencies, + rpaths=rpaths, + architectures=architectures, + minimum_macos=minimum_macos, + executable=executable, + install_id_required=not executable, + ) + + def test_parses_otool_dependencies_and_rpaths(self) -> None: + libraries = """/tmp/Ferrex Player: +\t@rpath/libmpv.2.dylib (compatibility version 2.0.0, current version 2.5.0) +\t/System/Library/Frameworks/AppKit.framework/Versions/C/AppKit (compatibility version 45.0.0, current version 2575.0.0) +""" + load_commands = """Load command 12 + cmd LC_RPATH + cmdsize 48 + path @executable_path/../Frameworks (offset 12) +""" + self.assertEqual( + macos_bundle.parse_otool_libraries(libraries), + ( + "@rpath/libmpv.2.dylib", + "/System/Library/Frameworks/AppKit.framework/Versions/C/AppKit", + ), + ) + self.assertEqual( + macos_bundle.parse_otool_rpaths(load_commands), + (macos_bundle.APP_RPATH,), + ) + + build_version = """Load command 10 + cmd LC_BUILD_VERSION + cmdsize 32 + platform MACOS + minos 15.0 + sdk 15.0 +""" + self.assertEqual( + macos_bundle.parse_macos_deployment_target(build_version), "15.0" + ) + self.assertEqual( + macos_bundle.parse_macos_deployment_target( + build_version + build_version.replace("15.0", "16.0") + ), + "16.0", + ) + + def test_relocatable_closure_passes(self) -> None: + records = [ + self.record( + "ferrex-player", + dependencies=( + "@rpath/libmpv.2.dylib", + "/System/Library/Frameworks/AppKit.framework/Versions/C/AppKit", + ), + rpaths=(macos_bundle.APP_RPATH,), + executable=True, + ), + self.record( + "libmpv.2.dylib", + install_id="@rpath/libmpv.2.dylib", + dependencies=("@rpath/libavcodec.62.dylib",), + ), + self.record( + "libavcodec.62.dylib", + install_id="@rpath/libavcodec.62.dylib", + dependencies=("/usr/lib/libSystem.B.dylib",), + ), + ] + macos_bundle.audit_records( + records, + framework_names={"libmpv.2.dylib", "libavcodec.62.dylib"}, + required_architecture="arm64", + executable_name="ferrex-player", + maximum_deployment_target="15.0", + ) + + def test_rejects_homebrew_dependency_and_rpath(self) -> None: + record = self.record( + "ferrex-player", + dependencies=("/opt/homebrew/opt/mpv/lib/libmpv.2.dylib",), + rpaths=("/opt/homebrew/lib",), + executable=True, + ) + with self.assertRaisesRegex( + macos_bundle.BundleError, "developer dependency" + ): + macos_bundle.audit_records( + [record], + framework_names=set(), + required_architecture="arm64", + executable_name="ferrex-player", + maximum_deployment_target="15.0", + ) + + def test_rejects_missing_closure_member_and_noncanonical_id(self) -> None: + records = [ + self.record( + "ferrex-player", + dependencies=("@rpath/libmpv.2.dylib",), + rpaths=(macos_bundle.APP_RPATH,), + executable=True, + ), + self.record( + "libmpv.2.dylib", + install_id="/tmp/build/libmpv.2.dylib", + dependencies=("@rpath/libplacebo.349.dylib",), + ), + ] + with self.assertRaises(macos_bundle.BundleError) as raised: + macos_bundle.audit_records( + records, + framework_names={"libmpv.2.dylib"}, + required_architecture="arm64", + executable_name="ferrex-player", + maximum_deployment_target="15.0", + ) + detail = str(raised.exception) + self.assertIn("non-relocatable id", detail) + self.assertIn("is not bundled", detail) + + def test_rejects_wrong_architecture(self) -> None: + record = self.record( + "ferrex-player", + rpaths=(macos_bundle.APP_RPATH,), + architectures=("x86_64",), + executable=True, + ) + with self.assertRaisesRegex(macos_bundle.BundleError, "lacks architecture"): + macos_bundle.audit_records( + [record], + framework_names=set(), + required_architecture="arm64", + executable_name="ferrex-player", + maximum_deployment_target="15.0", + ) + + def test_rejects_newer_or_missing_deployment_target(self) -> None: + records = [ + self.record( + "ferrex-player", + rpaths=(macos_bundle.APP_RPATH,), + minimum_macos="16.0", + executable=True, + ), + self.record( + "libmpv.2.dylib", + install_id="@rpath/libmpv.2.dylib", + minimum_macos=None, + ), + ] + with self.assertRaises(macos_bundle.BundleError) as raised: + macos_bundle.audit_records( + records, + framework_names={"libmpv.2.dylib"}, + required_architecture="arm64", + executable_name="ferrex-player", + maximum_deployment_target="15.0", + ) + self.assertIn("requires macOS 16.0", str(raised.exception)) + self.assertIn("has no macOS deployment-target", str(raised.exception)) + + def test_existing_dependency_must_be_in_declared_search_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + declared = root / "declared" + declared.mkdir() + allowed = declared / "liballowed.dylib" + outside = root / "liboutside.dylib" + allowed.touch() + outside.touch() + executable = root / "ferrex-player" + executable.touch() + resolver = macos_bundle.DependencyResolver([declared], executable) + self.assertEqual(resolver.resolve(str(allowed), executable), allowed) + with self.assertRaisesRegex( + macos_bundle.BundleError, "outside declared search roots" + ): + resolver.resolve(str(outside), executable) + + def test_normalizes_prerelease_bundle_version(self) -> None: + self.assertEqual(macos_bundle.apple_bundle_version("0.1.2-alpha.1"), "0.1.2") + + def test_rewrites_vulkan_icd_to_bundled_moltenvk(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "MoltenVK_icd.json" + source.write_text( + json.dumps( + { + "file_format_version": "1.0.0", + "ICD": {"library_path": "/opt/homebrew/lib/libMoltenVK.dylib"}, + } + ), + encoding="utf-8", + ) + target = macos_bundle.stage_vulkan_icd( + source, root / "Resources", "libMoltenVK.dylib" + ) + manifest = json.loads(target.read_text(encoding="utf-8")) + self.assertEqual( + manifest["ICD"]["library_path"], + "../../../Frameworks/libMoltenVK.dylib", + ) + + def test_stages_hashed_ca_bundle(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source.pem" + source.write_text( + "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n", + encoding="utf-8", + ) + target = macos_bundle.stage_ca_bundle(source, root / "Resources") + digest = (target.parent / "cacert.pem.sha256").read_text( + encoding="utf-8" + ) + self.assertIn("cacert.pem", digest) + self.assertEqual(target.read_bytes(), source.read_bytes()) + + def test_stages_and_audits_presenter_build_mode(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + app = Path(temporary) / "Ferrex Player.app" + resources = app / "Contents/Resources" + resources.mkdir(parents=True) + target = macos_bundle.stage_presenter_build_mode("spike", resources) + self.assertEqual(target.read_text(encoding="utf-8"), "spike\n") + self.assertEqual(macos_bundle.load_presenter_build_mode(app), "spike") + + target.write_text("unknown\n", encoding="utf-8") + with self.assertRaisesRegex( + macos_bundle.BundleError, "invalid presenter build-mode" + ): + macos_bundle.load_presenter_build_mode(app) + + def test_rejects_invalid_presenter_mode_before_staging(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + with self.assertRaisesRegex( + macos_bundle.BundleError, "invalid macOS presenter build mode" + ): + macos_bundle.stage_presenter_build_mode( + "production", Path(temporary) + ) + + def test_rejects_forbidden_gstreamer_dependency(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + app = Path(temporary) / "Ferrex Player.app" + plugins = app / "Contents/PlugIns/gstreamer-1.0" + resources = app / "Contents/Resources" + plugins.mkdir(parents=True) + resources.mkdir(parents=True) + plugin = plugins / "libgstplayback.dylib" + plugin.touch() + (resources / "gstreamer-plugin-manifest.txt").write_text( + f"{plugin.name}\n", encoding="utf-8" + ) + record = self.record( + str(plugin), + install_id="@rpath/libgstplayback.dylib", + dependencies=("@rpath/libavcodec.62.dylib",), + ) + with self.assertRaisesRegex( + macos_bundle.BundleError, "forbidden dependency" + ): + macos_bundle.audit_gstreamer_runtime(app, [record]) + + def test_system_library_policy_is_narrow(self) -> None: + self.assertTrue(macos_bundle.is_system_library("/usr/lib/libSystem.B.dylib")) + self.assertTrue( + macos_bundle.is_system_library( + "/System/Library/Frameworks/Cocoa.framework/Versions/A/Cocoa" + ) + ) + self.assertFalse( + macos_bundle.is_system_library("/usr/local/lib/libmpv.2.dylib") + ) + + +if __name__ == "__main__": + unittest.main() From a4f77a001c55247422aa279a3a2727781c801af1 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:21:05 -0600 Subject: [PATCH 13/56] ci(player): verify native playback backends --- .github/workflows/ci.yml | 347 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 335 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47f3d9f8..134d9eb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,9 @@ jobs: libglib2.0-dev \ libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ + libmpv-dev \ + ffmpeg \ + fonts-dejavu-core \ libavcodec-dev \ libavformat-dev \ libavutil-dev \ @@ -135,18 +138,135 @@ jobs: if: runner.os == 'macOS' run: | brew update - brew install pkg-config glib gstreamer gst-plugins-base ffmpeg + brew install \ + pkg-config meson ninja nasm \ + freetype fribidi harfbuzz \ + molten-vk shaderc vulkan-headers vulkan-loader \ + ca-certificates glib glib-networking gstreamer libsoup openssl@3 - # On Windows leverage GitHub action to install official MSVC GStreamer runtime + devel packages - - name: Setup GStreamer (Windows) + - name: Build pinned LGPL libmpv (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + prefix="${RUNNER_TEMP}/ferrex-libmpv" + export MACOSX_DEPLOYMENT_TARGET="15.0" + echo "MACOSX_DEPLOYMENT_TARGET=$MACOSX_DEPLOYMENT_TARGET" >> "$GITHUB_ENV" + export PKG_CONFIG_PATH="$(brew --prefix vulkan-loader)/lib/pkgconfig:$(brew --prefix shaderc)/lib/pkgconfig:${PKG_CONFIG_PATH:-}" + bash scripts/release/macos-build-libmpv.sh "$prefix" + echo "PKG_CONFIG_PATH=$prefix/lib/pkgconfig:${PKG_CONFIG_PATH:-}" >> "$GITHUB_ENV" + echo "LIBRARY_PATH=$prefix/lib:${LIBRARY_PATH:-}" >> "$GITHUB_ENV" + echo "DYLD_FALLBACK_LIBRARY_PATH=$prefix/lib:${DYLD_FALLBACK_LIBRARY_PATH:-}" >> "$GITHUB_ENV" + echo "FERREX_MPV_MACOS_PRESENTER=spike" >> "$GITHUB_ENV" + + - name: Test macOS bundle policy parser + if: runner.os == 'macOS' + run: | + python3 -m unittest \ + scripts/release/test_macos_bundle.py \ + scripts/release/test_macos_build_profile.py + + - name: Generate and validate native playback fixtures (Linux) + if: runner.os == 'Linux' + run: | + python3 -m unittest \ + scripts/qa/test_native_playback_fixtures.py \ + scripts/qa/test_native_playback_wayland_trace.py + ./scripts/qa/native_playback_fixtures.py generate \ + --output "${RUNNER_TEMP}/native-playback-fixtures" + ./scripts/qa/native_playback_fixtures.py verify \ + --output "${RUNNER_TEMP}/native-playback-fixtures" + + - name: Enforce native mpv architecture boundaries (Linux) + if: runner.os == 'Linux' + run: ./scripts/check-native-mpv-boundaries.sh + + - name: Cache pinned GStreamer MSVC SDK (Windows) + if: runner.os == 'Windows' + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}\gstreamer-msvc-x86_64 + key: windows-gstreamer-msvc-1.28.4-1a745d67225e4339 + + - name: Install pinned GStreamer MSVC SDK (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $root = Join-Path $env:RUNNER_TEMP 'gstreamer-msvc-x86_64' + & .\utils\build-windows\install-gstreamer.ps1 -Destination $root + "GSTREAMER_1_0_ROOT_MSVC_X86_64=$root" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "PKG_CONFIG=$root\bin\pkg-config.exe" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "PKG_CONFIG_PATH=$root\lib\pkgconfig" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + (Join-Path $root 'bin') | Out-File $env:GITHUB_PATH -Append -Encoding utf8 + + - name: Setup MSYS2 UCRT64 for pinned libmpv (Windows) + if: runner.os == 'Windows' + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + base-devel + autoconf-archive + git + mingw-w64-ucrt-x86_64-toolchain + mingw-w64-ucrt-x86_64-meson + mingw-w64-ucrt-x86_64-ninja + mingw-w64-ucrt-x86_64-pkgconf + mingw-w64-ucrt-x86_64-nasm + mingw-w64-ucrt-x86_64-python + mingw-w64-ucrt-x86_64-python-mako + mingw-w64-ucrt-x86_64-python-jinja + mingw-w64-ucrt-x86_64-python-glad + mingw-w64-ucrt-x86_64-harfbuzz + mingw-w64-ucrt-x86_64-freetype + mingw-w64-ucrt-x86_64-fribidi + mingw-w64-ucrt-x86_64-libpng + mingw-w64-ucrt-x86_64-lcms2 + mingw-w64-ucrt-x86_64-shaderc + mingw-w64-ucrt-x86_64-spirv-cross + mingw-w64-ucrt-x86_64-xxhash + mingw-w64-ucrt-x86_64-zlib + + - name: Cache pinned LGPL libmpv SDK (Windows) + if: runner.os == 'Windows' + uses: actions/cache@v4 + with: + path: C:\ferrex-libmpv-sdk + key: windows-libmpv-lgpl-${{ hashFiles('utils/build-windows/build-libmpv-lgpl.sh') }} + + - name: Build pinned LGPL libmpv SDK (Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + run: | + if [[ ! -f /c/ferrex-libmpv-sdk/share/licenses/ferrex-libmpv/BUILD_PROFILE ]]; then + ./utils/build-windows/build-libmpv-lgpl.sh /c/ferrex-libmpv-sdk + fi + + - name: Enter MSVC developer environment (Windows) if: runner.os == 'Windows' - # v.1.5.3 not v1.5.3. The action installs MSI packages; - # upstream Windows 1.28.x artifacts are EXE-only, so pin the - # newest available MSI release until the action supports EXE installers. - uses: marcpabst/setup-gstreamer@v.1.5.3 + uses: ilammy/msvc-dev-cmd@v1 with: - version: '1.26.11' - arch: x86_64 + arch: x64 + + - name: Generate MSVC import library and export libmpv paths (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $root = 'C:\ferrex-libmpv-sdk' + & .\utils\build-windows\new-libmpv-import-library.ps1 -SdkRoot $root + $dll = Get-ChildItem (Join-Path $root 'bin') -File | + Where-Object { $_.Name -in @('libmpv-2.dll', 'mpv-2.dll', 'mpv.dll') } | + Select-Object -First 1 + if (-not $dll) { throw "libmpv runtime DLL missing from $root\bin" } + "LIBMPV_ROOT=$root" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "LIBMPV_LIB_DIR=$(Join-Path $root 'lib')" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "LIBMPV_INCLUDE_DIR=$(Join-Path $root 'include')" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "LIBMPV_DLL_DIR=$(Join-Path $root 'bin')" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "LIBMPV_DLL=$($dll.FullName)" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + "FERREX_MPV_WINDOWS_PRESENTER=spike" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + (Join-Path $root 'bin') | Out-File $env:GITHUB_PATH -Append -Encoding utf8 - uses: moonrepo/setup-rust@v1 if: runner.os != 'Windows' @@ -194,6 +314,164 @@ jobs: if: runner.os != 'Windows' run: cargo build --workspace --all-features --target ${{ matrix.target }} + - name: Stage and audit relocatable app bundle (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(package["version"] for package in data["packages"] if package["name"] == "ferrex-player"))')" + prefix="${RUNNER_TEMP}/ferrex-libmpv" + search_roots=(--search-root "$prefix/lib") + while IFS= read -r formula; do + formula_lib="$(brew --prefix "$formula")/lib" + if [[ -d "$formula_lib" ]]; then + search_roots+=(--search-root "$formula_lib") + fi + done <"$prefix/share/ferrex/native-mpv/homebrew-formulae.txt" + gstreamer_prefix="$(brew --prefix gstreamer)" + molten_vk_prefix="$(brew --prefix molten-vk)" + libsoup_prefix="$(brew --prefix libsoup)" + gio_module="" + for candidate in "$(brew --prefix glib-networking)/lib/gio/modules"/libgiognutls.{so,dylib}; do + if [[ -f "$candidate" ]]; then + gio_module="$candidate" + break + fi + done + if [[ -z "$gio_module" ]]; then + echo "glib-networking TLS module was not installed" >&2 + exit 1 + fi + ca_bundle="$(brew --prefix ca-certificates)/share/ca-certificates/cacert.pem" + if [[ ! -f "$ca_bundle" ]]; then + echo "immutable Homebrew Mozilla CA bundle is missing" >&2 + exit 1 + fi + expected_ca_hash="$(sed -n 's/^ca_certificates_bundle_sha256=//p' "$prefix/share/ferrex/native-mpv/build-profile.txt")" + actual_ca_hash="$(shasum -a 256 "$ca_bundle" | awk '{ print $1 }')" + if [[ -z "$expected_ca_hash" || "$actual_ca_hash" != "$expected_ca_hash" ]]; then + echo "immutable Homebrew Mozilla CA bundle does not match the build profile" >&2 + exit 1 + fi + gstreamer_plugin_args=() + while IFS= read -r plugin; do + if [[ -n "$plugin" && "$plugin" != \#* ]]; then + gstreamer_plugin_args+=(--gstreamer-plugin "$gstreamer_prefix/lib/gstreamer-1.0/$plugin") + fi + done /dev/null; then + echo "clean-bundle smoke helper links a developer/package-manager path" >&2 + exit 1 + fi + python3 -m http.server 18765 --bind 127.0.0.1 --directory "$fixture_dir" & + http_server_pid=$! + openssl="$(brew --prefix openssl@3)/bin/openssl" + "$openssl" req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj /CN=FerrexTestCA \ + -addext basicConstraints=critical,CA:TRUE \ + -addext keyUsage=critical,keyCertSign,cRLSign \ + -keyout "${RUNNER_TEMP}/ferrex-ca.key" \ + -out "${RUNNER_TEMP}/ferrex-ca.crt" >/dev/null 2>&1 + "$openssl" req -newkey rsa:2048 -nodes \ + -subj /CN=127.0.0.1 \ + -addext subjectAltName=IP:127.0.0.1 \ + -keyout "${RUNNER_TEMP}/ferrex-test.key" \ + -out "${RUNNER_TEMP}/ferrex-test.csr" >/dev/null 2>&1 + "$openssl" x509 -req -days 1 \ + -in "${RUNNER_TEMP}/ferrex-test.csr" \ + -CA "${RUNNER_TEMP}/ferrex-ca.crt" \ + -CAkey "${RUNNER_TEMP}/ferrex-ca.key" \ + -CAcreateserial -copy_extensions copy \ + -out "${RUNNER_TEMP}/ferrex-test.crt" >/dev/null 2>&1 + python3 scripts/release/https_test_server.py \ + --directory "$fixture_dir" \ + --certificate "${RUNNER_TEMP}/ferrex-test.crt" \ + --key "${RUNNER_TEMP}/ferrex-test.key" \ + --port 18766 & + https_server_pid=$! + trap 'kill "$http_server_pid" "$https_server_pid" 2>/dev/null || true' EXIT + curl --fail --retry 20 --retry-connrefused --retry-delay 1 \ + http://127.0.0.1:18765/stream.m3u8 >/dev/null + curl --insecure --fail --retry 20 --retry-connrefused --retry-delay 1 \ + https://127.0.0.1:18766/stream.m3u8 >/dev/null + "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ + http://127.0.0.1:18765/stream.m3u8 \ + "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ + "$app/Contents/Resources/tls/cacert.pem" + if "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ + https://127.0.0.1:18766/stream.m3u8 \ + "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ + "$app/Contents/Resources/tls/cacert.pem"; then + echo "strict HTTPS smoke unexpectedly trusted the unbundled test CA" >&2 + exit 1 + fi + "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ + https://127.0.0.1:18766/stream.m3u8 \ + "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ + "${RUNNER_TEMP}/ferrex-ca.crt" + + - name: libmpv control-plane smoke (non-Windows) + if: runner.os != 'Windows' + run: cargo test -p ferrex-player-mpv --features linked --target ${{ matrix.target }} -- --test-threads=1 + + - name: Playback contract and libmpv adapter tests (Linux) + if: runner.os == 'Linux' + run: cargo test -p ferrex-player-playback --features mpv --target ${{ matrix.target }} --lib -- --test-threads=1 + + - name: Backend-disabled rollback build (Linux) + if: runner.os == 'Linux' + run: cargo check -p ferrex-player --no-default-features --target ${{ matrix.target }} + - name: Doctests (non-Windows) if: runner.os != 'Windows' run: cargo test --doc --workspace --all-features --target ${{ matrix.target }} @@ -215,7 +493,8 @@ jobs: "gst-app: $(pkg-config --modversion gstreamer-app-1.0)" | Write-Host "gst-video: $(pkg-config --modversion gstreamer-video-1.0)" | Write-Host - # Run cargo in Windows shell with MinGW toolchain and official GStreamer + # Run the MSVC Rust target against the pinned MSVC GStreamer SDK and the + # import library generated from the MSYS2-built LGPL libmpv runtime. - name: Fmt (Windows) if: runner.os == 'Windows' shell: pwsh @@ -224,11 +503,55 @@ jobs: - name: Clippy (Windows) if: runner.os == 'Windows' shell: pwsh - run: cargo clippy -p ferrex-player --target ${{ matrix.target }} -- -W warnings + run: cargo clippy -p ferrex-player --features mpv --target ${{ matrix.target }} -- -W warnings - name: Build (Windows) if: runner.os == 'Windows' shell: pwsh - run: cargo build -p ferrex-player --target ${{ matrix.target }} + run: cargo build -p ferrex-player --features mpv --target ${{ matrix.target }} + + - name: Windows libmpv and presenter tests + if: runner.os == 'Windows' + shell: pwsh + run: | + cargo test -p ferrex-player-mpv --features linked --target ${{ matrix.target }} -- --test-threads=1 + cargo test -p ferrex-player-playback --all-features --target ${{ matrix.target }} --lib windows_presenter -- --test-threads=1 + + - name: Audit staged Windows runtime closure + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $stage = Join-Path $env:RUNNER_TEMP 'ferrex-ci-runtime' + $bin = Join-Path $stage 'bin' + $plugins = Join-Path $stage 'lib\gstreamer-1.0' + $libexec = Join-Path $stage 'libexec\gstreamer-1.0' + $notices = Join-Path $stage 'share\licenses\ferrex-libmpv' + New-Item -ItemType Directory -Force -Path $bin, $plugins, $libexec, $notices | Out-Null + Copy-Item -Force "target\${{ matrix.target }}\debug\ferrex-player.exe" $stage + $buildMetadata = Join-Path $stage 'share\ferrex-player' + New-Item -ItemType Directory -Force -Path $buildMetadata | Out-Null + $env:FERREX_MPV_WINDOWS_PRESENTER | Set-Content (Join-Path $buildMetadata 'PRESENTER_BUILD_MODE') + Copy-Item -Force (Join-Path $env:LIBMPV_ROOT 'bin\*.dll') $bin + $pluginRoots = Join-Path (Get-Location).Path 'utils\build-windows\gstreamer-plugin-roots.txt' + & .\utils\build-windows\stage-gstreamer-runtime.ps1 -SourceRoot $env:GSTREAMER_1_0_ROOT_MSVC_X86_64 -StageRoot $stage -PluginManifest $pluginRoots + $gstLicenseSource = @( + (Join-Path $env:GSTREAMER_1_0_ROOT_MSVC_X86_64 'share\licenses'), + (Join-Path $env:GSTREAMER_1_0_ROOT_MSVC_X86_64 'share\gstreamer-1.0\licenses') + ) | Where-Object { Test-Path $_ -PathType Container } | Select-Object -First 1 + if (-not $gstLicenseSource) { throw 'GStreamer license directory is missing' } + $gstNotices = Join-Path $stage 'share\licenses\gstreamer' + New-Item -ItemType Directory -Force -Path $gstNotices | Out-Null + Copy-Item -Force -Recurse (Join-Path $gstLicenseSource '*') $gstNotices + @( + 'gstreamer=1.28.4', + 'abi=msvc-x86_64', + 'installer_sha256=1a745d67225e43394a4a5db929c97397cb56e74b1c38bb77c6ded4b037d3c040', + 'plugin_policy=explicit-recursive-pe-v1', + 'codec_policy=openh264-mediafoundation-v1', + "plugin_roots_sha256=$((Get-FileHash $pluginRoots -Algorithm SHA256).Hash.ToLowerInvariant())" + ) | Set-Content (Join-Path $gstNotices 'FERREX_BUILD_PROFILE') + Copy-Item -Force -Recurse (Join-Path $env:LIBMPV_ROOT 'share\licenses\ferrex-libmpv\*') $notices + & .\utils\build-windows\verify-runtime-closure.ps1 -StageRoot $stage # - name: Build documentation (TODO) # run: cargo doc --workspace --no-deps --all-features -- -D warnings From 658db9d7dedb6b96c6be0ddd3600926640e5ce7d Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Fri, 24 Jul 2026 19:21:26 -0600 Subject: [PATCH 14/56] docs(player): document playback policy diagnostics and rollout --- README.md | 31 +- crates/ferrex-player/README.md | 57 + docs/plans/native-mpv-playback-migration.md | 1484 +++++++++++++++++ .../content/docs/developer/architecture.md | 47 +- .../developer/desktop-playback-backends.md | 187 +++ .../developer/native-mpv-extension-api.md | 172 ++ .../developer/native-playback-fixtures.md | 779 +++++++++ .../content/docs/operator/configuration.md | 16 +- .../reference/qa/playback-auth-regression.md | 49 +- 9 files changed, 2788 insertions(+), 34 deletions(-) create mode 100644 docs/plans/native-mpv-playback-migration.md create mode 100644 docs/src/content/docs/developer/desktop-playback-backends.md create mode 100644 docs/src/content/docs/developer/native-mpv-extension-api.md create mode 100644 docs/src/content/docs/developer/native-playback-fixtures.md diff --git a/README.md b/README.md index 7aa68808..31fe0f04 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,9 @@ A Rust‑native media server and player focused on delivering a smooth and low l - Feels local, because it is: batched rendering of custom UI primitives keeps latency spikes in check as you fling through high‑DPI posters. - Zero‑copy HDR on Wayland: a Wayland‑subsurface path uses current GStreamer stable HDR support to preserve metadata and avoid expensive copies. -- Pragmatic elsewhere: on other platforms, Ferrex can hand off to mpv. +- Pragmatic elsewhere: playback runs behind a backend-neutral contract with + GStreamer rollback, an opt-in in-process libmpv native-window path, and an + explicit process-isolated mpv handoff. Status: pre-alpha (0.1.0-alpha). Expect rapid changes while core surfaces continue to stabilize. @@ -35,7 +37,7 @@ Existing home media tools are flexible but often not fast in the ways that feel ## Who it’s for -Self‑hosters and performance‑minded enthusiasts who value a fluid desktop experience and want to make use of their hardware efficiently—especially on Wayland, where full HDR zero‑copy playback relies on the GStreamer 1.28 stable series for correct HDR metadata passthrough (tested with **GStreamer 1.28.4**). Windows and macOS may utilize mpv hand‑off or the alternate player backend that does not include any HDR passthrough or tone-mapping. +Self‑hosters and performance‑minded enthusiasts who value a fluid desktop experience and want to make use of their hardware efficiently—especially on Wayland, where full HDR zero‑copy playback relies on the GStreamer 1.28 stable series for correct HDR metadata passthrough (tested with **GStreamer 1.28.4**). Windows and macOS have explicit in-process libmpv presenter handoff builds while GStreamer/external-mpv remain the rollback policy; Auto, HDR, and hardware-decoding capability claims stay gated on representative native-output evidence. ## Highlights @@ -43,7 +45,8 @@ Self‑hosters and performance‑minded enthusiasts who value a fluid desktop ex - Animated poster grids that stream in as fast as your GPU can swallow textures. - Keyboard driven and animated UI navigation/scrolling. - Wayland HDR pipeline with a subsurface strategy tailored for native output. -- mpv hand‑off with watch status tracking maintained. +- In-process libmpv native-window playback and an external mpv handoff, both + with backend-neutral watch status and redacted diagnostics. ## Quickstart @@ -132,16 +135,24 @@ See `ferrexctl --help` for all packaging options. - Tested environment: Arch Linux (Hyprland WM). Please report results for GNOME/KDE/wlroots compositors. - Player specifics and platform notes: see [crates/ferrex-player/README.md](crates/ferrex-player/README.md). -- Other platforms: playback via the cross‑platform backend or "Open with MPV" from detail views. +- Other platforms: the cross-platform GStreamer path remains the current Auto + policy. An mpv-enabled developer/release build can explicitly request + in-process native-window playback; the separate external action remains a + crash-isolated compatibility handoff. ### Compatibility -| Platform | Playback path | HDR passthrough | Zero‑copy | Status | -|-------------------|----------------------------|-----------------|-----------|--------------------| -| Linux (Wayland) | GStreamer + subsurface | Yes (1.28.x) | Yes | Primary, supported | -| Linux (Xorg) | Alt backend / mpv hand‑off | No | No | Works, less ideal | -| Windows | Alt backend / mpv hand‑off | No (today) | No | Experimental | -| macOS | Alt backend / mpv hand‑off | No (today) | No | Experimental | +| Platform | Current Auto/integrated path | Explicit mpv path | Evidence-qualified status | +|---|---|---|---| +| Linux (Wayland) | GStreamer 1.28 subsurface | In-process native window or external process | HYBRID. GStreamer HDR/zero-copy and mpv `gpu-next`/hwdec have platform evidence; integrated mpv is deferred. | +| Linux (X11) | Integrated GStreamer | External process only in the reviewed package | HYBRID. mpv 0.41 X11 VO is excluded from the LGPL-only in-process build. | +| Windows | GStreamer rollback | Compile-gated Win32 owned-overlay presenter; native-window/external fallback | Representative-system handoff ready; Auto, HDR, hwdec, taskbar/focus/fullscreen, and stress gates remain open. | +| macOS | GStreamer rollback | Compile-gated AppKit child-window presenter; native-window/external fallback | Representative-system handoff ready; Auto, HDR/EDR, VideoToolbox, Spaces/fullscreen, and stress gates remain open. | + +See [Desktop playback backends](https://ferrexmedia.org/developer/desktop-playback-backends/) +for build selection, deterministic fallback order, diagnostics, platform +limitations, and rollback. The implementation specification and live rollout +checklist are linked from the [architecture page](https://ferrexmedia.org/developer/architecture/). ## Security notes diff --git a/crates/ferrex-player/README.md b/crates/ferrex-player/README.md index 23b0508f..7e86cd92 100644 --- a/crates/ferrex-player/README.md +++ b/crates/ferrex-player/README.md @@ -139,6 +139,56 @@ Ferrex’s Wayland HDR path relies on the GStreamer 1.28 stable series. The Nix and Flatpak packaging pin **GStreamer 1.28.4**; when building outside those environments, use matching GStreamer and plugin development headers. +## Experimental in-process mpv + +Build with `--features mpv` to enable the developer-only in-process backend. +The explicit **Play in MPV** action requests the compile-gated integrated +presenter on Windows/macOS and the native-window backend elsewhere; failed +presenter preflight/attachment falls back to native-window mode with a +structured reason. Auto continues to use the existing backend during +migration. + +The reviewed macOS handoff bundle targets **macOS 15.0 or newer** on both +Apple Silicon and Intel. Its bundle metadata and every staged Mach-O load +command are audited against that floor; this handoff does not claim support +for older macOS releases. + +Ferrex uses a deterministic mpv profile by default: standard user config, +scripts, and external URL resolvers are disabled. Controlled native OSC/input +bindings are enabled for native-window compatibility and disabled when Iced +owns an integrated controls overlay. For trusted local development only, +standard mpv config, `input.conf`, and scripts can be enabled explicitly: + +```bash +FERREX_MPV_CONFIG_POLICY=trusted-user \ + cargo run -p ferrex-player --features mpv +``` + +mpv config and scripts execute inside the Ferrex process. Do not enable this +policy for untrusted configuration. Invalid policy values fail closed to the +deterministic profile. Playback diagnostics report the effective policy, +capability-gated external-subtitle/screenshot/shader/profile support, and only +the active shader count—never config contents, profile names, or local paths. Extension authors +should follow the +[native mpv extension API](https://ferrexmedia.org/developer/native-mpv-extension-api/) +for owner-thread, local-extension, raw-command, observation, and redaction +rules. + +Native messages use a bounded verbose-at-startup then informational policy by +default. Diagnostic runs may select a fixed filter without changing playback +behavior: + +```bash +FERREX_MPV_LOG_LEVEL=trace \ + RUST_LOG=ferrex_player_playback=trace,ferrex_player_mpv=trace \ + cargo run -p ferrex-player --features mpv +``` + +Accepted levels are `none`, `fatal`, `error`, `warn`, `info`, `verbose`, +`debug`, and `trace`. Invalid values fail closed without being echoed. Copied +messages remain credential/source-redacted, but traces can still reveal local +filenames or system topology and must be reviewed before sharing. + ## Windows MPV override If MPV auto-detection fails on Windows, set `FERREX_MPV_PATH` to the full path @@ -152,3 +202,10 @@ When distributed as a Flatpak bundle: flatpak install --user ./ferrex-player*.flatpak flatpak run io.github.lowband21.FerrexPlayer ``` + +The manifest enables the in-process mpv feature and bundles pinned mpv 0.41.0, +FFmpeg 8.1.2, and libplacebo with a build-time-asserted LGPL-only profile. +Wayland native-window mpv includes Vulkan, dmabuf, VA-API, PipeWire, and Pulse +support. mpv 0.41's X11 VO is GPL-only and is deliberately excluded from this +profile; Flatpak X11 sessions retain integrated GStreamer playback and the +separate external-player compatibility action. diff --git a/docs/plans/native-mpv-playback-migration.md b/docs/plans/native-mpv-playback-migration.md new file mode 100644 index 00000000..8fcdae27 --- /dev/null +++ b/docs/plans/native-mpv-playback-migration.md @@ -0,0 +1,1484 @@ +# Native mpv Playback Migration Plan + +- **Status:** In progress +- **Branch:** `feat/mpv-integration` +- **Worktree:** `~/dev/ferrex/mpv` +- **Specification:** [Native mpv Playback Integration](../specs/native-mpv-playback.md) +- **Last updated:** 2026-07-24 + +## 1. How to Use This Plan + +This document tracks implementation and migration. The specification is the +source of truth for architecture and acceptance criteria; this plan owns task +order, status, dependencies, decisions, and rollout checkpoints. + +Update this file in the same change that: + +- completes or materially changes a milestone; +- resolves an open design spike; +- changes fallback or rollout policy; +- adds a newly discovered blocker or risk; or +- changes a platform's default backend. + +### Status legend + +- `[ ]` Not started +- `[~]` In progress (use only one owner/status note beneath the item) +- `[x]` Complete and verified +- `[!]` Blocked; link or describe the blocker +- `[-]` Deliberately deferred or rejected with rationale + +Checklist syntax cannot encode `[~]` or `[!]` as interactive Markdown boxes, so +those markers are textual status labels and must not be treated as completed. + +## 2. Delivery Rules + +1. Every phase must leave the player buildable and keep a working playback + fallback. +2. Refactoring the player contract must land before mpv-specific branches + spread through domain/view code. +3. The current default remains unchanged until the relevant platform gate + passes. +4. Native-window mpv is implemented before integrated presentation; it is the + compatibility and debugging baseline. +5. Release packaging is part of backend completion, not a follow-up. +6. Wayland is a gated feasibility project. Failure results in a documented + hybrid backend, not unsafe framework coupling or silent HDR regression. +7. No mpv-specific Iced change is proposed upstream. +8. Each platform presenter must have a deterministic fallback and teardown + test before it can be selected by Auto. +9. No decoded video frame may enter wgpu in the native-VO milestone. +10. Any private mpv patch requires an explicit specification amendment and a + maintenance/exit strategy before adoption. + +## 3. Milestone Overview + +| ID | Milestone | Depends on | Status | Exit result | +|---|---|---|---|---| +| P0 | Baseline, fixtures, and design records | — | In progress | Reproducible current behavior and test matrix | +| P1 | Backend-neutral player contract | P0 | In progress | Subwave runs through Ferrex-owned commands/events | +| P2 | libmpv FFI and packaging foundation | P0 | In progress | Versioned libmpv loads/builds on target CI | +| P3 | mpv control plane and native-window vertical slice | P1, P2 | In progress | End-to-end playback without render API | +| P4 | Native presenter and Iced surface lifecycle | P1, P3 | In progress | Fake presenter and host lifecycle are stable | +| P5 | Windows presenter and X11 platform decision | P4 | Handoff ready; hardware gate open | Compile-gated owned-overlay spike plus deterministic fallback | +| P6 | macOS integrated presenter | P4 | Handoff ready; hardware gate open | Compile-gated AppKit child-window spike plus deterministic fallback | +| P7 | Wayland protocol bridge | P3, P4 | Complete (HYBRID) | GStreamer integrated; mpv native-window until a safe bridge exists | +| P8 | Playback feature parity | P3, platform presenter | In progress | Current player controls and tracks work through mpv | +| P9 | Hardening, performance, and release packaging | P5–P8 | In progress | Platform acceptance matrix passes | +| P10 | Staged default rollout | P9 | Not started | mpv selected by Auto per approved platform | +| P11 | Legacy cleanup and optional upstream work | P10 | Not started | Obsolete playback code removed after rollback window | + +P2 packaging work and P5/P6 platform work may proceed in parallel after their +listed interfaces are stable. The recorded Wayland HYBRID decision does not +reduce the Windows or macOS scope: both remain active targets for fully +integrated native-VO mpv presentation inside the Ferrex player experience. +D-023 separately records X11 as HYBRID because mpv 0.41 excludes its X11 VO +from the LGPL-only build required by D-005. + +## 4. P0 — Baseline, Fixtures, and Design Records + +**Objective:** make regressions measurable before changing the player model. + +### Documentation + +- [x] Create the target architecture specification. +- [x] Create this migration tracking plan. +- [x] Link the specification, plan, and baseline from the canonical Starlight + architecture page when implementation begins. +- [x] Record the exact current Iced fork revisions, Subwave revision, GStreamer + version, and release packaging inputs in + [`native-playback-baseline.md`](../src/content/docs/developer/native-playback-baseline.md). + +### Current behavior inventory + +- [x] Inventory every direct use of `SubwaveVideo` in the extracted playback + and UI crates. +- [x] Inventory every branch on `external_mpv_active` and + `external_mpv_handle`. +- [x] Map each `PlayerMessage` to current backend calls and resulting state + changes. +- [x] Record current behavior for stop, EOF, error, next episode, previous + episode, and navigation while playing. +- [x] Record current progress heartbeat and final progress persistence behavior. +- [x] Record current track identity/index semantics for audio and subtitles. +- [x] Record current content-fit behavior for contain, cover, and fill. +- [x] Record current fullscreen ownership and window restoration behavior. + +The inventory above is maintained in +[`native-playback-baseline.md`](../src/content/docs/developer/native-playback-baseline.md) +and is anchored to the pre-contract `dev` commit recorded there. + +### Test media and environments + +- [x] Define a redistributable or locally generated media fixture set covering: + - [x] H.264 SDR 8-bit; + - [x] HEVC Main10 SDR; + - [x] HDR10/PQ metadata; + - [x] HLG; + - [x] VP9 and AV1; + - [x] ASS with fonts and animation; + - [x] PGS bitmap subtitles; + - [x] multiple audio/subtitle tracks; + - [x] chapters and attachments; and + - [x] malformed/unsupported input. +- [x] Document commands to generate synthetic fixtures when redistribution is + not permitted. +- [x] Define authenticated HTTP range and transcoded-stream fixtures against a + local Ferrex server. +- [x] Create the initial platform/GPU/compositor test inventory. + +The schema-versioned generator and validator are +[`scripts/qa/native_playback_fixtures.py`](../../scripts/qa/native_playback_fixtures.py); +all generated media and checksummed manifests live under the ignored +`target/native-playback-fixtures/` directory. It creates nine primary files, +including locally constructed PGS packets, plus malformed inputs and segmented +HLS output. The loopback-only +[`native_playback_fixture_server.py`](../../scripts/qa/native_playback_fixture_server.py) +reads its token outside argv and supports bearer/query authentication and real +single-range `206` responses. Generation and validation passed with FFmpeg 8.1 +on 2026-07-12; Linux CI now regenerates and verifies the same matrix. A +transport smoke separately verified `401`, bounded `206`, and +header-authenticated HLS segment reads without retained-token disclosure. On +2026-07-13 a second display-backed acceptance passed through a real +network-bound Ferrex router: it rewrote only the generated HLS fixture's local +segment references to credential-free protected stream routes, required the +same playback-scoped bearer ticket for the manifest and all four MPEG-TS +segments, verified HLS MIME types and unauthenticated rejection, and completed +the native-mpv control/screenshot lifecycle. This proves server/router HLS +transport and header propagation, independently of Ferrex-side transcode +generation. A third display-backed run on 2026-07-13 submitted the real `360p` +profile, waited for bounded FFmpeg generation and atomic cache publication, +verified ticket enforcement on the generated manifest and every segment, +confirmed cached reuse, and completed the same native-mpv +control/shader/screenshot/stop lifecycle. +[The fixture procedure and initial platform/Wayland matrix](../src/content/docs/developer/native-playback-fixtures.md) +also define local Ferrex import/direct/transcode acceptance. The manual player +quality-picker run and the UI episode run remain P8 gates. + +### Baseline measurements + +- [ ] Capture current startup-to-first-frame time. +- [ ] Capture CPU/GPU usage for SDR and HDR reference playback. +- [ ] Capture seek latency and resize/fullscreen behavior. +- [ ] Verify and record current hardware-decoder selection. +- [ ] Capture Wayland protocol traces for the known-good GStreamer HDR path. +- [ ] Run repeated load/stop cycles and record native/GPU memory behavior. + +### Exit criteria + +- [ ] Current player behavior is represented by a written mapping and tests + where practical. +- [ ] Test fixtures and manual HDR procedure are reproducible. +- [ ] Baseline measurements are stored under an appropriate ignored or + documented results location. + +## 5. P1 — Backend-neutral Player Contract + +**Objective:** remove backend objects from domain/view behavior before adding a +second in-process backend. + +### Contract design + +- [x] Keep the initial Ferrex-owned contract in the already extracted + `crates/ferrex-player-playback/src/contract/` boundary; split it again only + when another client needs a dependency-lighter crate. +- [x] Define `PlaybackCommand`. +- [x] Define `PlaybackEvent`. +- [x] Define `PlaybackSnapshot` and `PlaybackState`. +- [x] Define Ferrex-owned `TrackId`, `AudioTrack`, `SubtitleTrack`, chapter, and + video-parameter models. +- [x] Define `PlaybackCapabilities`, `BackendKind`, and presentation capability + models. +- [x] Define structured `PlaybackError` and `FallbackReason` types. +- [x] Define source/authentication data without embedding access tokens in log + output. +- [x] Define controller/event channel ownership and shutdown semantics. + +### Subwave adapter + +- [x] Wrap `SubwaveVideo` behind the new command/event contract. +- [x] Convert Subwave audio/subtitle models into Ferrex-owned models. +- [x] Move direct `SubwaveVideo` polling out of player `view` code. +- [x] Reduce adapter events into one `PlaybackSnapshot`. +- [x] Preserve current progress, seek timeout, controls, and track behavior. +- [x] Preserve current backend toggle only as a Subwave diagnostic during the + migration; do not add it to the generic contract unless it represents a real + user capability. + +### Player domain migration + +- [x] Replace the concrete value in `PlayerDomainState::video_opt` with a + backend-neutral `PlaybackSession` handle and reduced snapshot (the temporary + field name is retained for compatibility during the migration). +- [x] Stop storing Subwave track types in `PlayerDomainState`. +- [x] Move concrete position/duration polling into adapter snapshot event + reduction; legacy `last_valid_*` mirrors remain only for seek-preview and + persistence compatibility until P11 cleanup. +- [x] Consolidate internal and external playback branches where behavior is + backend-independent. +- [x] Keep `external_mpv.rs` operational through an adapter or explicit legacy + path. +- [x] Ensure `view.rs` reads only snapshot and presenter state. +- [x] Ensure player commands do not unwrap `current_media_id` on paths where it + may legitimately be absent. + +The external process remains an explicit legacy path rather than a generic +backend session. It now starts mpv idle, observes state over its private IPC +socket, and submits the media URL with `loadfile` over IPC so playback tickets +do not appear in the child argument vector. Its Unix socket lives under an +RAII-owned `0700` temporary directory. On 2026-07-12 the ignored Linux real-mpv +smoke passed against a generated Matroska file behind a local query-ticket HTTP +range server, verifying IPC load, observed media state, and +`/proc//cmdline` non-disclosure while preserving the existing +process-liveness fallback. The bounded legacy snapshot synchronization now +samples Subwave's owned EOS flag inside the adapter and reduces it to +`Ended(Eof)` instead of allowing the backend's terminal pause to overwrite EOF. +The same generation-scoped terminal handler is used by legacy ticks, the +progress heartbeat, and event-driven mpv wakeups, so EOF/error dispatch and +final progress happen once. Event-driven snapshots now clear seeking only from +mpv's confirmed state, while Subwave retains its one-second timeout; snapshot +projection also preserves pending resume hints and the last available subtitle +selection. Pure regression tests cover EOS-vs-pause ordering, confirmed seek +completion, one-shot terminal dispatch/error progress, resume projection, and +subtitle restoration. The retained external process now has one reduced +`PlaybackSnapshot`; its native handle owns only process/IPC resources. Desktop +and 10-foot views select presentation through that snapshot and obtain the +backend-owned widget through a state presentation boundary, while progress +heartbeats, episode mode, and navigation use one neutral progress projection. +The obsolete `external_mpv_active` flag and all direct view reads of the process +handle/session field are removed. Final IPC values are reduced before the +process handle is dropped, fixing external episode advancement and avoiding the +old duplicate terminal progress/navigation dispatch. State/update regression +tests cover external loading/playing/terminal reduction, invalid observations, +reset cleanup, heartbeat persistence, and terminal episode transition after +handle teardown. + +### Tests + +- [x] Command-to-Subwave adapter tests. +- [x] Snapshot reducer tests for duplicate, out-of-order, and missing values. +- [x] Track identity/selection tests across reload. +- [x] Stop/EOF/error progress persistence tests. +- [x] Episode transition tests independent of backend. +- [x] Fallback-policy unit tests. + +### Exit criteria + +- [ ] Existing GStreamer playback works with no intended UI behavior change. +- [x] `SubwaveVideo` is not referenced by player view or domain policy code. +- [x] Backend-specific track types do not escape the adapter. +- [x] All new contract/reducer tests pass. + +## 6. P2 — libmpv FFI and Packaging Foundation + +**Objective:** select and ship a libmpv foundation capable of exposing the full +control API on every desktop target. + +### FFI decision spike + +Evaluate at least: + +- a thin Ferrex wrapper over maintained raw `libmpv` bindings; +- `libmpv2` plus direct raw access for missing APIs; and +- generated/local bindings with dynamic symbol loading. + +For each option record: + +- [x] supported client API and mpv versions; +- [x] command/property/node/event coverage; +- [x] wakeup callback and async reply support; +- [x] raw escape-hatch feasibility; +- [x] Windows and macOS linking behavior; +- [x] maintenance activity and licensing; +- [x] cross-compilation behavior; and +- [x] ability to test with a fake function table. + +Spike result (crate releases and repository activity checked 2026-07-11): + +| Option | Coverage and version | Build/test characteristics | Decision | +|---|---|---|---| +| `libmpv2` 6.0.0 plus raw access | Maintained; high-level crate declares API 2.2 while `libmpv2-sys` 4.0.1 ships current API 2.5 declarations. High-level events omit node payloads and it exposes no public async command/property, node-command, hook, or log-request methods. | LGPL-2.1; linked through `libmpv2-sys`; public raw context permits escape hatches but couples two ownership layers and is not naturally fakeable. | Rejected as the ownership layer; useful only as prior art. | +| Thin Ferrex wrapper over `libmpv2-sys` 4.0.1 | Complete API 2.5 client/node/event/wakeup/async declarations from mpv 0.40+ headers, including APIs required by P3. | Maintained, LGPL-2.1, pregenerated bindings avoid target bindgen/Clang, and the build script links `mpv`. Ferrex's own function table makes it fakeable and keeps unsafe ownership local. | **Selected.** | +| Generated/local bindings with dynamic symbol loading | Can provide complete coverage and graceful runtime absence. | Adds header/generated-code drift, a library-lifetime loader, per-platform search policy, and a second binding-maintenance surface before packaging is proven. | Deferred; reconsider only if linked development/release layouts cannot meet diagnostics or rollback requirements. | + +`mpv-client-dyn` 0.5.0 was also rejected: it is GPL-3.0, hard-codes +`mpv.exe`, and omits required version, terminate, wakeup, node/async-property, +and log APIs. `mpv-client-cross-sys` 4.0.0 is current but GPL-3.0 and its +dynamic-symbol path is designed for C plugins hosted by mpv, not an embedding +application. + +Decision: + +- [x] Write `D-004` in the decision log with the selected FFI foundation. +- [x] Set the minimum supported client API version to 2.2 (mpv 0.37.0), + which contains every P3 client symbol; release packaging targets mpv 0.41.0 + / API 2.5. +- [x] Decide release bundling versus development dynamic loading: use normal + shared-library linking, keep it behind the `linked` Cargo feature, use an + explicitly LGPL-only mpv/FFmpeg build, and bundle/reference that exact shared + library in release artifacts. Do not add a bespoke runtime `dlopen` layer + unless packaging evidence requires it. + +### Build integration + +- [x] Add the selected Rust dependencies with minimal features in the isolated + `ferrex-player-mpv` crate; its default feature set does not link libmpv. +- [x] Add compile-time platform gating without compiling Wayland dependencies on + Windows/macOS (`linked` enables only the raw client bindings). +- [x] Add libmpv version detection and actionable build errors: Unix builds + require `mpv >= 2.2.0` through pkg-config, Windows names the required + `LIBMPV_LIB_DIR`, and runtime client API compatibility is checked before + allocation. +- [x] Add a runtime compatibility report. +- [x] Ensure the player can still build in a configuration where mpv is + deliberately disabled during the migration. + +### Packaging workstream + +- [x] Nix development shell provides mpv 0.41.0 headers and runtime library + built with `gpl=false`, LGPL-only FFmpeg, and GPL-only optional inputs + disabled. +- [x] Nix package references the selected LGPL libmpv closure and includes its + library directory in the wrapped runtime path; package and license-profile + checks pass. +- [x] Linux CI installs `libmpv-dev` at the API 2.2 compatibility floor and + runs the linked handle smoke test; Nix/release packaging remains pinned to + mpv 0.41.0. +- [x] Flatpak manifest builds/bundles libmpv with required VO/protocol features. +- [~] Windows CI/source builder, MSVC import-library generation, hashed DLL + closure staging, and package audit are implemented; the first target CI and + clean-VM artifact run remain representative-system gates. +- [~] macOS CI/source builder and complete dylib closure rewrite/audit are + implemented for Apple Silicon and Intel; the first target CI and clean-app + launch remain representative-system gates. +- [~] Windows/macOS package audits reject loader dependencies and runtime search + paths into developer Nix/Homebrew prefixes; execute them against the produced + target artifacts before closing this item. These checks do not claim to scan + arbitrary resource strings for unrelated build-host paths. +- [~] The target builders emit exact mpv, FFmpeg, libplacebo, libass, and + target Lua runtime profiles/notices/hashes (LuaJIT on Windows/Flatpak, static + Lua 5.2.4 on macOS); final license review remains open until the target + artifacts are produced. + +The Flatpak manifest now builds the player with its `mpv` feature and pins mpv +0.41.0/API 2.5, FFmpeg 8.1.2, libplacebo 7.360.1, libass, and LuaJIT. Configure +and post-install assertions require `gpl=false`, reject FFmpeg GPL/nonfree/ +version-3 options, require Vulkan/Wayland/dmabuf support, verify the final Rust +binary directly needs `libmpv.so.2`, and install the component license/build +profile files. `gst-libav` is built against the same bundled FFmpeg ABI so the +process does not load both the runtime and bundled FFmpeg versions. On +2026-07-12 a clean Flatpak builder run, 50 MiB bundle export, user installation, +and installed-runtime loader smoke passed; libmpv, FFmpeg, libplacebo, libass, +and LuaJIT all resolved from `/app/lib` without a Nix/store or host package +path. The Flatpak workflow now installs every produced bundle and repeats the +closure/profile assertions before upload. mpv 0.41 gates X11 VO/`wid` sources +on its GPL option, so D-023 keeps them out of the reviewed bundle and selects +GStreamer integration on X11; the required Wayland native-window +`gpu-next`/Vulkan path remains present. + +### Exit criteria + +- [ ] A minimal program creates and destroys a libmpv handle on Linux, Windows, + and macOS CI or documented equivalent builders. +- [x] Version/capability diagnostics are available. +- [x] Release package layout is defined for all targets. +- [ ] The FFI decision is recorded and reviewed. + +## 7. P3 — mpv Control Plane and Native-window Vertical Slice + +**Objective:** deliver end-to-end in-process playback using mpv's ordinary +native window before attempting embedding. + +### Session core + +- [x] Add RAII ownership for `mpv_handle`. +- [x] Set deterministic pre-initialization options. +- [x] Initialize without creating `mpv_render_context`. +- [x] Add the serialized command/property owner. +- [x] Install a wakeup callback that only signals the owner/runtime. +- [x] Drain and copy events safely. +- [x] Correlate async command and property replies. +- [x] Forward mpv logs with level mapping and redaction. +- [x] Implement ordered stop and termination. +- [~] AppKit presenter work is main-thread-token-gated, detaches before + shutdown, and hands the blocking `MpvWorker` drain to a named off-main + reaper; a real macOS load/quit/fullscreen stress run remains required. + +The first P3 control-plane tranche lives entirely in `ferrex-player-mpv`. +`MpvSession` is a thread-affine serialized owner; `MpvWorker` creates it on a +named owner thread, wakes through an atomic/unpark-only callback, forwards only +owned events, and performs a bounded stop/reply/final-event drain before RAII +termination. The local session form remains available for the unresolved macOS +main-loop model. + +### Generic mpv API + +- [x] Set/get string, flag, integer, double, and node properties. +- [x] Observe and unobserve arbitrary properties. +- [x] Submit arbitrary async commands and node commands. +- [x] Expose hook/client-message support needed by scripts and future features. +- [x] Expose API/FFmpeg/libplacebo version diagnostics. +- [x] Add an explicit raw/unsafe extension boundary. + +### Ferrex mapping + +- [x] Load authenticated HTTP media without exposing tokens in logs/process + arguments. +- [x] Observe pause, time, duration, cache, seeking, EOF, and idle state. +- [x] Observe tracks, chapters, editions, video parameters, and hardware decoder. +- [x] Map core events into `PlaybackEvent`. +- [x] Map play/pause, absolute/relative seek, volume, mute, and speed. +- [x] Map audio/subtitle selection. +- [x] Map stop and end reasons. +- [x] Preserve final and heartbeat watch progress. + +The Ferrex mapping now lives in the feature-gated +`ferrex-player-playback::mpv_adapter`. Direct-stream ticket resolution creates a +credential-free URI plus a zeroizing `Authorization` header on +`PlaybackSource`; both Subwave and libmpv receive that source in process. Only +the explicit legacy external-player boundary reconstructs a query-ticket URL. +The adapter submits authenticated sources as in-process node commands with +per-file options, validates header/cookie input, redacts source-specific secrets +from copied logs, and reduces the observed mpv property/event surface into the +existing generation-scoped snapshot. The mpv owner emits a coalesced +backend-neutral readiness signal after copied events +are queued, so Iced drains them without a video-frame or periodic polling +redraw loop. Async load failure falls back to Auto/Subwave from the last +observed position. A schema-versioned, serializable diagnostic snapshot now +reports requested/selected backend, backend and presenter lifecycle, client API +compatibility, mpv/FFmpeg/libplacebo versions, compiled features, VO/GPU +context and adapter, hwdec/interop, input/output color parameters, frame timing +counters, presenter geometry/display scale, the ordered deduplicated fallback +chain, and the last structured fallback/error. Geometry and fallback history +introduced diagnostic schema version 2; additive chapter/edition capabilities +advanced it to version 3; effective mpv config/script trust policy advanced it +to version 4. Capability-gated local extensions and the redacted active shader +count advanced it to version 5. The effective native log policy advances the +current schema to version 6. By default, startup-only verbose logging is +reduced to informational filtering after file initialization; the explicit +`FERREX_MPV_LOG_LEVEL` diagnostic switch can instead retain a fixed native +filter without recording log contents or an invalid environment value. + +### Native-window vertical slice + +- [x] Add an opt-in backend selector for in-process mpv native-window mode. +- [x] Play a local fixture. +- [x] Play an authenticated Ferrex URL. +- [x] Verify server transcoding output. The bounded FFmpeg job provider, + start/status/assets routes, quality-profile request, authenticated rendition + source, atomic cache publication, protected reload, and display-backed + native-mpv run pass. +- [x] Verify next-episode transition through real native-mpv EOF and the + backend-neutral replacement path. +- [x] Verify ordinary mpv fullscreen and close handling. +- [x] Enable a controlled mpv OSC fallback in native-window mode. +- [x] Keep existing external mpv fallback available. + +Evidence: the ignored +`mpv_adapter::tests::linked_native_window_load_control_fullscreen_stop_and_close_smoke` +test loaded a locally generated MPEG-4/AAC Matroska fixture through the real +mpv 0.41.0 `gpu-next` native VO on 2026-07-11, then exercised metadata/track +observation, pause, seek, confirmed fullscreen enter/exit, stop, replacement +load, orderly native-window quit, and teardown. The copied quit event is kept +distinct from EOF, and the domain test verifies that close/core termination +persists final progress and exits rather than auto-advancing an episode. The +transport form of the same smoke path passed again on 2026-07-12 against a +temporary authenticated HTTP range server whose media endpoint required the +current bearer header; it completed load, metadata/track discovery, pause, +fullscreen enter/exit, seek, stop, replacement load, and close. An earlier +variant also covered query-ticket and cookie input. On 2026-07-12 the expanded +smoke also passed against the schema-generated multitrack fixture, confirming +an initial resume offset, observed volume, mute, speed, content-fit, +audio-track selection, subtitle selection/off, chapter selection, edition +catalog/selection, confirmed fullscreen, absolute/relative seek, explicit stop, +natural EOF, post-terminal reload, and native close. +The normal server integration test separately proves that a real Ferrex router +accepts the scoped playback ticket (not a full account session) in the same +`Authorization: Bearer` form and serves a bounded `206` range. The stream +handler now also returns demuxer-appropriate MIME types for protected HLS +manifests, MPEG-TS/AAC segments, and fragmented-MP4 segments. On 2026-07-13 +the feature-gated ignored +`playback_ticket_drives_display_backed_native_mpv_through_ferrex_router` test +then combined both ends against an isolated PostgreSQL database and a real +network-bound Ferrex router: it registered a user, seeded the generated H.264 +fixture with its actual size, issued the normal playback-scoped ticket, and +opened that protected URL through the backend-neutral exact-mpv session. The +real native VO confirmed resume/metadata, pause, an authenticated range seek, +shader application, a non-empty screenshot, redacted diagnostics, and ordered +stop. The normal feature suite remains display-free; the test is opt-in through +the server's `native-mpv-e2e` feature and its command is documented with the +fixture procedure. A second run the same day loaded the generated +`transcoded-hls/index.m3u8` transport fixture through protected real-router +URLs, requiring one header-carried ticket on the manifest and every segment; +it verified unauthenticated rejection, credential-free manifest URLs, +redacted diagnostics, controls, seek, shader, screenshot, and ordered stop. +That closes router/HLS transport propagation independently. A third ignored +acceptance, +`server_generated_transcode_plays_through_display_backed_native_mpv`, passed on +2026-07-13 with the real bounded FFmpeg provider and generated HLS assets. It +submitted `360p`, polled the authenticated job to completion, verified atomic +publication, unauthenticated rejection and ticket access for the manifest and +every segment, confirmed immediate cached reuse, and completed the same real +native-VO resume/control/seek/shader/screenshot/ordered-stop lifecycle. The +manual quality-picker run and end-to-end UI episode transition remain open. +The ignored +`update::tests::linked_native_window_eof_reloads_next_episode_with_same_backend` +smoke also passed on 2026-07-13. It let the first synthetic episode reach real +native-mpv EOF, required one final-progress plus backend-preserving next-episode +request, then drove the normal `SetStreamSource` close/reopen path and confirmed +a newer mpv session generation playing the second episode. This closes the P3 +backend/domain transition; outer repository selection, ticket resolution, and +the visible app-shell transition remain in the P8 manual UI gate. + +### Tests + +- [x] Fake-FFI tests for copied event lifetimes. +- [x] Node conversion tests including nested maps/arrays and null values. +- [x] Async reply correlation and cancellation tests. +- [x] Wakeup storm/coalescing tests. +- [x] Stop during load/seek/EOF tests. +- [x] Repeated session create/destroy test. + +### Exit criteria + +- [ ] mpv plays supported fixtures through its native VO with no render context. +- [x] Current basic controls, tracks, EOF, and progress work through the generic + player contract. +- [x] Hardware-decoder and VO diagnostics are visible. +- [x] Failure returns cleanly to GStreamer/external fallback. + +## 8. P4 — Native Presenter and Iced Surface Lifecycle + +**Objective:** implement platform-neutral host geometry/lifecycle before native +platform attachment code. + +### Presenter state model + +- [x] Define host-ready, VO-ready, attach, hidden, suspended, detach, and failure + transitions. +- [x] Add monotonically increasing session/presenter generations. +- [x] Define presenter commands and events without requiring native resources to + be `Send`. +- [x] Define logical bounds, visible bounds, scale factor, and geometry revision. +- [x] Define fullscreen ownership and actual-state confirmation. +- [x] Define deterministic fallback requests. + +The platform-neutral implementation is in +`ferrex-player-playback::presenter`. `PresenterLifecycle` accepts only +session/presenter-generation-scoped inputs, emits UI-thread-local commands plus +existing playback presenter events, attaches at most once per generation, and +rejects stale generations and geometry revisions. `NativePresenter` uses a +borrowed generic associated host with no `Send` bound, so later Wayland, +AppKit, and window-system resources can remain event-loop-local. Fullscreen +changes reach the playback snapshot only after native confirmation; presenter +failures detach first and request the configured native-window fallback. + +### `NativeVideoSlot` + +- [x] Implement a renderer-generic custom widget outside Iced. +- [x] Store attachment state in `Tree::State`. +- [x] Acquire host raw handles through current generic Iced APIs. +- [x] Synchronize only on geometry revisions during redraw. +- [x] Handle zero size and full clipping as hidden. +- [x] Detach on tree removal and explicit window-close flow. +- [x] Draw loading/failure fallback without drawing decoded video. +- [x] Remove continuous redraw behavior used only for polling. + +`ferrex-player-playback::native_video_slot` now provides a renderer-neutral +layout slot and an explicit `window::run` host-capture task. Raw window/display +handles remain in an event-loop thread-local registry and are exposed only as a +borrow during presenter callbacks, preserving the presenter's non-`Send` +contract. `Tree::State` owns the generation handle, monotonically revisions +changed bounds/clip/scale observations only on redraw, requests host capture at +most once while absent, and performs idempotent detach on replacement, drop, +and close request. Loading/failure plates use only generic renderer quads; the +slot contains no decoded image or wgpu video primitive. Unit tests cover raw +host capture, duplicate suppression, scaling, clipping/zero size, deferred host +capture, and detach-before-drop. Platform callbacks and selection of the slot +remain gated on P5–P7 presenters. Desktop and 10-foot views no longer register +a decoded-frame callback for player-state updates: native backends wake through +the copied-event signal, while the legacy adapter synchronizes only on the +bounded controls timer and the existing low-rate progress heartbeat. + +### Dedicated playback overlay window + +- [x] Add a player/overlay `WindowKind` to the existing daemon window manager. +- [x] Create transparent overlays hidden before native attachment. +- [x] Render only the player UI for the overlay window. +- [x] Keep the library/main window alive but hidden or suspended during dedicated + native-root playback. +- [x] Restore geometry/focus after playback. +- [ ] Ensure one visible player/taskbar identity at a time. + +The daemon window manager now owns a deterministic +`Closed -> Hidden -> Active -> Closing` player-overlay lifecycle. Allocation is +transparent, undecorated, and invisible. Native attachment/positioning occurs +while hidden; an explicit post-attachment task hides the still-live main +window before a follow-up marks the presenter host visible and focuses it. +That follow-up never reapplies stale main-window geometry. The window manager +also retains a separate live overlay viewport for controls/focus/hit testing, +leaving main geometry untouched for restoration. User close detaches every +registered native slot and +releases the event-loop-local raw-host lease before queuing native destruction, +then restores the retained main geometry, fullscreen mode, and focus. A +separate dismiss path preserves playback during presenter fallback. Every +completed exit now funnels through a backend-neutral `PlaybackExited` window +event; the app shell idempotently dismisses an active overlay, detaches its +host, and restores the retained main geometry/focus after stop, EOF, native +close, Back, or Home. Pure manager/settings/theme tests cover map replacement, +lifecycle ordering, hidden allocation, explicit surface alpha, and exit +dismissal without player mutation; the native-slot test covers multi-slot +detach-before-host-release. Platform presenters must still establish native +ownership/z-order and prove the single taskbar/Alt-Tab identity in P5/P6. + +### Transparency + +- [x] Make player background alpha explicit. +- [x] Verify the wgpu surface uses a compositing alpha mode where required. +- [x] Verify Iced does not advertise a full opaque region over video. +- [ ] Verify controls and text remain SDR and readable over HDR output. + +The pinned Iced revision `577abb7f` selects post-multiplied alpha when +available, then pre-multiplied alpha, and configures every wgpu surface with +the selected mode. The dedicated player window is created with +`Settings::transparent = true`; winit 0.30.13 responds on Wayland by issuing +`wl_surface.set_opaque_region(null)` instead of the full-surface opaque region. +Ferrex unit tests independently require the hidden overlay setting and its root +theme background to remain transparent. Actual compositor support and SDR UI +legibility over HDR remain platform acceptance measurements rather than an +assumption from these code paths. + +### Fake presenter tests + +- [x] attach occurs once per generation; +- [x] host-before-VO and VO-before-host ordering; +- [x] duplicate geometry suppression; +- [x] clipping/hide/show transitions; +- [x] scale and window recreation; +- [x] stale event rejection; +- [x] explicit close before drop; and +- [x] presenter error to fallback transition. + +### Exit criteria + +- [x] The presenter/widget contract is stable without mpv or platform-specific + types in Iced-facing public APIs. +- [x] Fake presenter lifecycle tests pass. +- [x] Player UI no longer needs video-frame redraws to update progress. +- [x] No Iced fork change has been added for native presentation. + +## 9. P5 — Windows and X11 Presenters + +Windows may proceed after P4 and remains a target for fully integrated +native-VO mpv presentation; the Wayland HYBRID decision does not defer or +weaken its production presenter gate. X11 is now a separate licensing-gated +HYBRID under D-023: the reviewed LGPL libmpv profile has no X11 VO or `wid` +implementation, so the checklist is retained only as re-entry criteria. + +### Windows + +- [x] Observe/query mpv `window-id` as a full pointer-width `i64`, reject zero + or out-of-range values, and validate with `IsWindow` before attach. +- [x] Choose and record an mpv-root/owned-Iced-overlay relationship. +- [x] Allocate the Iced overlay hidden and reveal it only after attachment. +- [x] Synchronize the mpv client rectangle and per-monitor DPI; the active + spike re-queries the native root independently of Iced layout revisions. +- [x] Hide the retained main window before presenter-driven reveal/focus and + keep live overlay viewport geometry independent from restoration geometry. +- [x] Implement owned-window z-order, minimize/restore/visibility, focus + handoff, task-switcher styles, and idempotent restoration. +- [x] Delegate fullscreen to mpv and update state only from its observed + confirmation. +- [x] Route integrated controls/input through Iced and disable mpv OSC/default + input for the integrated request. +- [x] Detach and restore the overlay before either HWND is destroyed. +- [-] `wid` inline mode is not retained for the full-player experience; the + native-root owned-overlay path preserves mpv's modern VO and the ordinary + native window is the deterministic fallback. +- [~] One taskbar entry and correct Alt-Tab behavior are ready for the + representative Windows matrix; target observation remains open. +- [~] SDR/HDR overlay-visible/hidden behavior is ready for representative + display testing; native HDR capability remains false until recorded. +- [~] D3D11 `gpu-next` and D3D11VA/DXVA2 diagnostics are packaged and exposed; + actual hardware evidence remains open. +- [~] The generic lower-level stress harness exists; the Win32 + owned-overlay-specific 100-cycle run remains open. + +**Windows exit decision:** + +- [~] The compile-gated owned-overlay implementation is ready for + representative-system handoff; production/Auto approval remains open. +- [x] Native-window mode is the explicit structured fallback and render API + integration is not forced. + +### X11 + +**Status:** Deferred under D-023; retained as X11 re-entry criteria. + +- [-] Detect X11 backend and compositing-manager presence. +- [-] Obtain mpv and Iced XIDs and verify display/screen compatibility. +- [-] Create/attach an ARGB overlay above the mpv window. +- [-] Synchronize configure, map/unmap, stack, focus, and scale behavior. +- [-] Define input shape/region behavior. +- [-] Delegate and confirm fullscreen state. +- [-] Detach/destroy in protocol-safe order. +- [-] Implement/test `wid` inline mode. +- [-] Test with and without a compositing manager. +- [-] Verify one taskbar entry and window-manager compatibility. +- [-] Stress 100 window/session cycles. + +**X11 exit decision:** + +- [-] Integrated mpv and `wid` are not built from mpv 0.41's GPL-only X11 + sources under D-005's LGPL release policy. +- [ ] Verify packaged X11 GStreamer fallback and the optional external-process + handoff before rollout; do not advertise in-process mpv native-window mode. + +## 10. P6 — macOS Presenter + +**Objective:** preserve mpv's native modern macOS VO while delivering a fully +integrated Ferrex player and Iced controls where AppKit permits. The Wayland +HYBRID decision does not change this target. + +### AppKit spike + +- [x] Confirm mpv 0.41 returns its live `NSWindow` pointer through the + read-only `window-id` property; no unsupported macOS `wid` input is used. +- [x] Resolve and retain the mpv `NSWindow` and Iced host `NSView`/`NSWindow` + only with an AppKit main-thread marker. +- [x] Implement a transparent Iced child `NSWindow` above mpv's content view. +- [~] Movement, resize, backing-scale, focus, occlusion, close, and app + visibility synchronization are implemented and fake-tested; target + observation remains open. +- [~] Fullscreen ownership/confirmation and auxiliary-window behavior are + implemented; native animation observation remains open. +- [~] Active-Space and hide/unhide visibility refresh is implemented; the + representative Spaces matrix remains open. +- [~] Apple Silicon and Intel build/package jobs are defined; representative + hardware execution remains open. +- [~] Child-window composition is the selected handoff strategy; retain the + native-window fallback until representative testing proves it sufficient. + +### Production presenter + +- [x] Implement the child-window relationship behind the presenter contract. +- [x] Keep all AppKit object access behind the non-`Send` main-thread window + system. +- [x] Synchronize the root content-view screen rectangle rather than the outer + frame. +- [x] Detach the AppKit relationship first, then move blocking libmpv shutdown + to a named reaper so the main run loop remains serviceable. +- [~] VideoToolbox diagnostics are exposed; representative hardware decoding + evidence remains open. +- [~] HDR/EDR overlay-visible/hidden validation remains open on a capable + display, and native HDR capability stays false meanwhile. +- [~] The macOS child-window-specific 100-cycle playback/fullscreen/teardown + run remains open. + +### Exit decision + +- [~] Integrated capability is enabled only in an explicit compile-time spike + for representative-system handoff; Auto/production remains closed. +- [x] Any preflight or attachment failure selects mpv native-window mode and + dismisses the hidden Iced host. +- [-] Do not substitute a deprecated OpenGL render path solely to claim + embedding. + +The exact target build commands, representative fixture matrix, retained +artifact rules, and production-pass boundary for P5/P6 are documented in +[`native-playback-fixtures.md`](../src/content/docs/developer/native-playback-fixtures.md#windows-and-macos-integrated-presenter-handoff). + +## 11. P7 — Wayland Protocol Bridge + +**Objective:** determine whether mpv's normal Wayland VO can be safely +virtualized as an Iced subsurface without copying frames or modifying Iced with +platform hacks. W0 found the connection bootstrap unsafe under the current +boundary, so D-022 records HYBRID and defers bridge implementation. + +This phase has recorded the **HYBRID** outcome in D-022. GStreamer remains the +integrated Wayland backend and mpv remains available through ordinary +native-window presentation. W1–W5 are retained below as re-entry criteria, but +are deliberately deferred until a safer per-session Wayland connection path or +other maintainable architecture exists. Auto defaults are unchanged. + +### W0 — Research fixture and bridge boundary + +- [x] Pin the mpv version used by the spike. +- [x] Inventory every Wayland global/protocol used by mpv for `gpu-next` on the + test environment. +- [x] Evaluate reuse/forking of the `wl-proxy` library used by Jellyfin's + precedent. +- [x] Define a raw protocol trace fixture for basic map, resize, fullscreen, + frame presentation, and teardown. +- [x] Define how the bridge identifies the intended mpv VO connection/surface. +- [!] Define how only mpv is directed to the private socket without racing other + process users of `WAYLAND_DISPLAY`/`WAYLAND_SOCKET`. + +The versioned +[`native_playback_wayland_trace.py`](../../scripts/qa/native_playback_wayland_trace.py) +harness pins mpv 0.41.0, runs ordinary `gpu-next`/Vulkan/`waylandvk`, inserts +operation markers for map, pause/seek, resize, fullscreen, stop, VO reload, and +teardown, and writes only redacted mode-private artifacts below the ignored +results directory. Its display-free parser/redaction tests run in Linux CI. +Three `wl-wlroots-amd` runs against the generated SDR, HDR10/PQ, and HLG +fixtures passed on 2026-07-13 UTC with Vulkan hardware decoding and the expected +input color parameters. They used the same protocol-interface set, issued ten +registry requests across mpv/libplacebo/driver activity, and exposed exactly +one `xdg_surface.get_toplevel` VO candidate per run. The exact globals, +interfaces, per-method inventory, surface-identification rule, and evaluation +of permissively licensed `wl-proxy` 0.1.3/Jellyfin precedent are recorded in +[the W0 spike page](../src/content/docs/developer/native-mpv-wayland-spike.md). + +Stable libmpv provides no per-context Wayland endpoint, and a temporary +process-environment override cannot cover delayed/internal-thread VO and driver +connections safely. The only race-free candidate found so far is a +process-lifetime startup proxy that routes Iced and mpv into one upstream +namespace and virtualizes only the protocol-identified mpv shell candidate. +That is broader than the specification's private mpv-only socket, so it is not +selected. D-022 records HYBRID and D-008 is deferred. Reopening W1 requires a +compliant redirection mechanism or an explicit specification amendment backed +by a maintainable ownership/teardown design. No environment race or symbol +interposition is accepted. + +### W1 — Same-upstream connection proof + +**Status:** Deferred under D-022; retained as Wayland re-entry criteria. + +- [ ] Obtain Iced's borrowed `wl_display` and parent `wl_surface` safely. +- [ ] Build an upstream client/event queue over the borrowed display without + taking ownership of it. +- [ ] Start a private downstream Wayland socket for mpv. +- [ ] Forward registry/global binding and core object traffic. +- [ ] Prove the mpv child and Iced parent are objects on the same upstream + connection. +- [ ] Prove bridge teardown does not disconnect or consume Iced's display. +- [ ] Test concurrent event queues for deadlock/starvation under resize and + playback. + +**Gate W1:** + +- [-] Continue only if same-connection forwarding and ownership are reliable; + deferred because no safe mpv-only connection bootstrap exists. +- [x] Record a hybrid decision: retain GStreamer integrated Wayland and use mpv + native-window mode as the mpv fallback. + +### W2 — Shell-role virtualization + +**Status:** Deferred under D-022; retained as Wayland re-entry criteria. + +- [ ] Capture the mpv video `wl_surface`. +- [ ] Suppress upstream `xdg_wm_base.get_xdg_surface` for that surface. +- [ ] Suppress its toplevel role and virtualize required downstream objects. +- [ ] Assign upstream `wl_subsurface` under Iced's parent. +- [ ] Set desynchronized child commits. +- [ ] Set position from the surface-slot geometry. +- [ ] Apply an empty native input region or otherwise ensure Iced owns input. +- [ ] Synthesize initial and subsequent configure events with valid serials. +- [ ] Consume/validate downstream ack-configure behavior. +- [ ] Handle surface recreation and VO restart generations. + +### W3 — Protocol and WSI preservation + +**Status:** Deferred under D-022; retained as Wayland re-entry criteria. + +- [ ] Vulkan WSI playback through the bridge. +- [ ] EGL/OpenGL fallback where supported. +- [ ] dmabuf file-descriptor forwarding. +- [ ] explicit synchronization and release behavior. +- [ ] viewporter without creating a competing host viewport. +- [ ] fractional-scale events and mixed-DPI display movement. +- [ ] output enter/leave behavior. +- [ ] presentation-time/frame callbacks. +- [ ] tearing-control/content-type where selected by mpv. +- [ ] idle-inhibit behavior. +- [ ] color-management and color-representation objects owned by mpv. +- [ ] gracefully forward or reject unknown/unsupported optional protocols. + +### W4 — Host window semantics + +**Status:** Deferred under D-022; retained as Wayland re-entry criteria. + +- [ ] Translate fullscreen requests to Iced and synthesize resulting state. +- [ ] Translate close requests. +- [ ] Define minimize/maximize behavior. +- [ ] Define interactive move/resize behavior or explicitly leave it to Iced + decorations. +- [ ] Preserve Iced keyboard, pointer, touch, IME, clipboard, and drag/drop. +- [ ] Synchronize slot geometry before the relevant parent commit. +- [ ] Measure whether current redraw ordering is sufficient. +- [ ] If insufficient, document a minimal generic Iced use case before any + upstream discussion; do not restore the persistent `wayland-hack` hook. + +### W5 — HDR and robustness gate + +**Status:** Deferred under D-022; retained as Wayland re-entry criteria. + +- [ ] Verify `gpu-next` and expected hardware decoder. +- [ ] Verify HDR10/PQ and HLG color-description traffic on a capable compositor. +- [ ] Verify SDR Iced controls compose over HDR video without incorrect output + labeling. +- [ ] Test Hyprland/wlroots, KDE, and GNOME where available. +- [ ] Test Intel, AMD, and NVIDIA proprietary drivers where available. +- [ ] Test pause, seek, resize, fractional scaling, fullscreen, minimize, + suspend/resume, monitor removal, VO reload, and stop. +- [ ] Stress 100 load/stop and 100 fullscreen cycles. +- [ ] Verify clean fallback when optional protocols are absent. +- [ ] Verify no CPU frame path is used. + +### Wayland decision + +Recorded outcome: + +- [-] **GO:** not selected; integrated mpv is not eligible for Wayland rollout. +- [x] **HYBRID:** GStreamer remains the integrated Wayland backend; mpv is used + in native-window mode on Wayland and remains the integrated target on Windows + and macOS. +- [-] **STOP:** not selected; the bridge criteria and research fixture are + retained for reconsideration when a better path exists. + +D-022 is a platform-specific release decision, not an abandonment of embedded +mpv elsewhere. Windows P5 and macOS P6 continue toward fully integrated +native-VO presentation. Reopening Wayland GO requires a new decision backed by +a safe connection bootstrap, W1–W5 evidence, and release packaging; a +single-compositor demonstration remains insufficient. + +## 12. P8 — Playback Feature Parity + +**Objective:** make the mpv backend replace current player behavior rather than +merely play a file. + +### Core controls + +- [x] play, pause, and toggle; +- [x] absolute and relative seek; +- [x] seek preview/drag throttling without flooding libmpv; +- [x] volume and mute; +- [x] playback speed; +- [x] contain/cover/fill mapping with documented mpv properties; +- [x] confirmed fullscreen state; +- [x] loading, buffering, seeking, and error UI; and +- [x] controls visibility without frame-driven redraw. + +Desktop and 10-foot playback surfaces now derive static loading, buffering +(with bounded percentage), seeking, stopping, presenter-readiness, and +structured failure plates from `PlaybackSnapshot`/`PresenterState`. The +pre-session loading route and terminal error route remain shell-owned, while an +active backend no longer depends on adapter-specific UI state for those +transitions. Pure projection tests cover transient states, integrated presenter +readiness, native-window non-presentation, and structured-error fallback text; +the plates do not request animation or video-frame redraws. + +The native-window content-fit implementation maps contain/cover/fill to +`keepaspect`, `video-unscaled`, and `panscan`; native-size and scale-down modes +are mapped at the same boundary. The exact table is now normative in the +specification, and pure mapping tests cover every mode. Seek preview dispatch +is limited to one command per 100 ms at the UI boundary. The mpv adapter also +allows only one absolute seek request in flight and replaces its single queued +position with the newest drag target; stop, replacement load, and shutdown +clear that queue so late replies cannot seek a new lifecycle. Deterministic +tests cover the UI interval, latest-value coalescing, and late-reply rejection. +`SeekTo` now submits an immediate absolute command rather than changing only +the drag preview, and keyboard/episode relative seeks remain signed +`PlaybackCommand::SeekRelative` operations after duration clamping. Explicit +pause intent now takes precedence over buffering and is not inferred from other +non-playing states, so toggle behavior remains correct during load/seek/cache +transitions. The real native-VO smoke confirms play, pause, and both seek forms. +Once an initial track catalog exists, backend-driven audio/subtitle selection changes +are now diffed during snapshot projection and use the same short-lived notice +as Iced-issued selections; initial discovery, duplicate confirmations, and +replacement-file loading remain quiet. A pure test covers simultaneous audio +change/subtitle disable, duplicate suppression, and initial-catalog +suppression. + +Chapter and edition catalogs now retain Ferrex-owned stable identities plus the +currently observed selection in `PlaybackSnapshot`. Capability-gated settings +pickers submit backend-neutral `SelectChapter`/`SelectEdition` commands; the mpv +adapter maps those to the standard `chapter` and `edition` properties while +Subwave reports the unsupported capability explicitly. Those serialized +capability flags introduced diagnostic schema version 3; the config trust +policy below advanced it to version 4, local extension capabilities advanced it +to version 5, and effective log policy advances the current schema to version +6. Replacement loads clear old catalogs before +the next demuxer identities arrive. Reducer/parser tests +cover catalog normalization, chronological chapter presentation versus native +indices, selection observations, and mpv's single-default-edition case where +the scalar property is unavailable. On 2026-07-12 the display-backed mpv 0.41 +multitrack smoke selected the second generated chapter and the generated default +edition through this path, in addition to its existing track/control lifecycle +checks. + +### Tracks and media structure + +- [x] stable audio track identities and selection; +- [x] subtitle off/on/selection and previous selection; +- [x] ASS, text, bitmap, and external subtitle coverage; +- [x] chapters; +- [x] editions; +- [ ] attached fonts; and +- [x] track-change notifications. + +On 2026-07-12 the expanded display-backed smoke also passed against the +`ass-animation-fonts.mkv` and `pgs-bitmap.mkv` generated fixtures, including +track discovery/selection and the full shader/screenshot/control lifecycle. A +separate run loaded `sources/english.srt` beside the H.264 fixture through the +capability-gated `AddExternalSubtitle` command and confirmed a newly observed, +selected Ferrex-owned track with `is_external=true`. Together with the embedded +SRT tracks in the multitrack smoke, this closes native-VO ASS/text/PGS/external +load and selection coverage. Visual attached-font substitution correctness +remains open. + +### Ferrex behavior + +- [x] authenticated direct play; +- [x] server transcode URL playback, including protected HLS generation, + publication, route authentication, source projection, and a display-backed + native-mpv load; +- [x] quality-profile switch and credential-preserving stream reload; +- [x] resume position; +- [x] progress heartbeat; +- [x] final progress on all terminal paths; +- [x] next/previous/restart episode behavior; +- [x] navigation back/home while playing; and +- [x] restore main window state after playback. + +The episode reducer preserves Internal, in-process mpv native-window, or +external-process mode across explicit next/previous and natural EOF +transitions, checkpoints progress before replacement, and applies the exact +five-percent Previous boundary (an unknown duration safely restarts). Final +progress, replacement/reset, and navigation messages now use serialized task +chains instead of parallel batches. Back and Home checkpoint and then enter the +common reset path. `ResetAfterStop` emits one backend-neutral host-exit event +after state reset; the UI window +controller's idempotent dismiss path closes an active dedicated overlay and +restores the retained main size, position, fullscreen mode, and focus. Pure +reducer tests cover all three backend modes, the restart boundary, Back/Home +ordering, and the host-exit event; window-controller tests prove exit dismissal +does not mutate player state. + +### mpv compatibility surface + +- [x] raw command UI/debug console or documented developer API; +- [x] arbitrary property observations for future features; +- [x] profile/config loading policy; +- [x] optional user scripts and input bindings; +- [x] user shader/profile passthrough; +- [x] screenshot behavior; and +- [x] capability diagnostics for unavailable options. + +The Starlight +[`Native mpv extension API`](../src/content/docs/developer/native-mpv-extension-api.md) +now documents the public `MpvSession`/`MpvWorker` ownership models, arbitrary +string/node commands, typed and node properties, stable arbitrary observations, +hooks/client messages/events, reply correlation, redaction rules, and the final +unsafe raw-handle boundary. Feature code must keep user-facing cross-backend +behavior in the neutral contract; a user command console is not required for +this gate. Existing fake-ABI tests exercise every documented value format, +observation registration/removal, command form, cancellation, copied event +lifetime, and teardown. + +`ferrex-player-mpv::MpvConfigPolicy` now makes configuration trust explicit. +The default deterministic profile disables standard user config, script +discovery, and external URL resolvers; native-window OSC and controlled input +bindings remain Ferrex-owned options. The developer-only +`FERREX_MPV_CONFIG_POLICY=trusted-user` opt-in enables standard mpv config, +`input.conf`, and scripts as trusted in-process code. Unknown or non-Unicode +values fail closed without being echoed into logs. Diagnostic schema version 4 +introduced the effective policy and high-level switches without config contents +or paths; schema version 5 added extension capability booleans and only the +observed active shader count, while schema version 6 reports only the effective +native message level and whether the bounded startup capture is active. +Fake-ABI and pure parser/diagnostic tests verify both profiles, fail-closed +selection, and continued `ytdl=no`; the player README documents the trust +boundary and invocation. A normal settings control remains rollout UX work and +does not change Auto or fallback selection. + +`PlaybackSession` now exposes capability-gated Ferrex commands for external +sidecar subtitles, named video profiles, an ordered local shader list, and +explicit-path screenshots. The mpv adapter maps them to argument-separated +`sub-add`, `apply-profile`, the `change-list` command for `glsl-shaders`, and +`screenshot-to-file`; named user profiles require the trusted-user policy. +Subwave reports `UnsupportedOperation` instead of a +no-op. Local paths and profile names have redacted `Debug` forms, invalid inputs +fail without echoing values, and diagnostics never include paths. Pure tests +cover every command/mode, policy-dependent capability reporting, unsupported +fallbacks, and redaction. On 2026-07-12 the display-backed mpv 0.41 smoke passed +again after applying and observing a temporary identity shader, writing a +non-empty screenshot, clearing the shader list, and removing both files. + +### External player migration + +- [x] Compare in-process native-window fallback with current external mpv + behavior. +- [x] Decide whether external process mode remains for crash isolation. +- [x] If retained, adapt it to the same playback contract and redaction rules. +- [-] Removal is not selected during the rollback window or while D-023 needs + an explicit X11 handoff. + +D-009 retains external mpv as an explicit, process-isolated compatibility +handoff, never an Auto candidate. In-process native-window mpv provides the +full command/event/track surface and header-authenticated transport but shares +the Ferrex process; external mpv provides crash isolation and user-installed +X11 VO availability, while intentionally advertising only its observed +progress, seek, fullscreen, terminal, and native-window capabilities. D-017 +keeps its credential-bearing URL out of argv and sends it through private IPC; +D-019 reduces copied IPC observations into the neutral snapshot used by +progress and episode policy. The same snapshot now also produces the redacted +backend/presentation/evidence summary used by diagnostics. Unit tests cover +snapshot lifecycle, heartbeat/final progress, episode transition after handle +teardown, and diagnostic projection; the real Linux smoke covers private IPC, +process observation, and argv non-disclosure. Reconsider removal in P11 after +the rollback window and only if X11 has another policy-approved handoff. + +### Exit criteria + +- [ ] Existing player integration tests pass against mpv where backend-neutral. +- [ ] Manual control/track/episode parity checklist passes. +- [ ] Unsupported mpv-native behavior is represented as a capability, not a + hidden no-op. + +## 13. P9 — Hardening, Performance, and Release Packaging + +### Automated verification + +- [x] Linux unit/integration suite with libmpv enabled. +- [~] Windows mpv/presenter build, focused tests, SDK staging, and closure audit + are defined in CI; the first target run and display smoke remain open. +- [~] macOS mpv/AppKit build, focused tests, pinned core source builds, + version/hash-recorded runtime inputs, bundle rewrite/sign/audit, and both + architectures are defined in CI; the first target run and display smoke + remain open. +- [x] Backend-disabled build remains valid during rollback window; Linux CI + and both target distribution workflows check it explicitly. +- [x] Fake presenter and FFI tests run without a display. +- [x] Lifecycle stress test is runnable in CI or a documented compositor job. +- [x] Secret-redaction tests cover URL, cookies, and headers. + +Linux CI now runs both the linked `ferrex-player-mpv` handle suite and the +`ferrex-player-playback --features mpv` contract/adapter suite, including fake +FFI/presenter lifecycle and source/log redaction coverage. It also performs an +explicit `ferrex-player --no-default-features` rollback build; the Windows and +macOS distribution workflows repeat that check for their target triples. +Display-backed +native-VO smoke tests remain explicitly ignored and are covered by the manual +fixture gate rather than silently using a software/headless VO in CI. The +ignored `linked_native_window_load_stop_lifecycle_stress` job now defaults to +100 fresh libmpv/native-window load, VO-ready, ordered-stop, and teardown +cycles; the fixture procedure documents the exact compositor command and +results location. On 2026-07-13 a full 100-cycle Wayland run with mpv 0.41.0 +passed in 25.81 seconds under explicit 64 MiB RSS-growth and four-FD budgets: +process RSS moved from an 80,680 KiB post-first-cycle baseline to 96,192 KiB +with a 103,976 KiB peak, while open FDs remained four. This closes the generic +Linux native-window harness gate; separate GPU/native-resource review and the +Windows/macOS presenter-specific stress gates remain open. + +### Performance + +- [ ] Compare startup-to-first-frame against baseline. +- [ ] Compare seek latency. +- [ ] Compare CPU/GPU usage for SDR/HDR. +- [ ] Verify Iced does not redraw at video frame rate when controls are idle. +- [ ] Inspect frame-drop/timing diagnostics under 60/120/144 Hz UI settings. +- [ ] Verify overlay visibility does not cause an unacceptable HDR or latency + regression. +- [ ] Confirm no readback/upload path in native-VO mode. + +### Release artifacts + +- [x] Nix package smoke test outside the development shell. +- [x] Flatpak bundle smoke test. +- [ ] Windows packaged install smoke test on a clean VM. +- [ ] macOS signed/bundled app smoke test on a clean machine. +- [ ] License and notices reviewed. +- [x] Upgrade/rollback behavior documented. + +On 2026-07-13, `nix build path:.#ferrex-player` produced the wrapped player +outside the development shell. The source filter now explicitly excludes +ignored local Flatpak, target, cache, and direnv roots so working-tree package +smokes cannot ingest unrelated vendored Cargo manifests. A clean-environment +launch with a temporary home completed `ferrex-player screenshot --help`; the +packaged ELF directly requires `libmpv.so.2`, its loader metadata contains no +build/developer path, and no developer home path remains in the binary. Nix +store references are expected and resolve through the pinned LGPL closure. + +The Flatpak release smoke is a real bundle install, not only a build-directory +check. It verifies the executable and pinned libmpv/FFmpeg/libplacebo closure, +the LGPL build-profile records, and then removes the test installation. The +same loader/profile smoke now runs in the Flatpak workflow before artifact +upload; display-backed playback remains part of the separate manual fixture +gate. + +### Documentation + +- [x] Update root README platform table based on measured capabilities. +- [x] Update `ferrex-player/README.md` prerequisites and diagnostics. +- [x] Update `docs/architecture.md` diagram. +- [x] Add mpv configuration and troubleshooting documentation. +- [x] Document backend selector and fallback order. +- [x] Document how to collect mpv and presenter diagnostics safely. + +The canonical Starlight architecture now shows the neutral session/reducer and +three concrete adapter/presentation paths. The new Desktop playback backends +guide documents build-time feature selection, current per-platform Auto +policy, deterministic exact-request fallback, trusted config, fixed native log +filters, evidence-qualified in-player diagnostics, authentication/package +troubleshooting, and the GStreamer/external rollback boundary. The player README +and operator configuration page link the same policy and commands; the legacy +`docs/architecture.md` remains a pointer to the canonical page. + +### Exit criteria + +- [ ] Every platform proposed for rollout passes its specification gate. +- [ ] Release packages work without developer-only paths. +- [ ] Performance does not regress beyond an explicitly accepted budget. + +## 14. P10 — Staged Default Rollout + +### Stage A — Developer-only + +- [ ] Backend available only through an explicit developer setting. +- [ ] Structured diagnostics are collected in issue reports. +- [ ] GStreamer remains default everywhere. + +### Stage B — User opt-in + +- [ ] Document experimental mpv integrated/native-window choices. +- [ ] Add visible fallback reason when integration fails. +- [ ] Collect a minimum soak period and issue inventory. +- [ ] Retain one-click/config rollback to GStreamer. + +### Stage C — Per-platform Auto + +For each platform independently: + +- [ ] platform acceptance gate signed off; +- [ ] release artifact verified; +- [ ] fallback verified; +- [ ] known limitations documented; +- [ ] Auto switched to mpv in one focused change; and +- [ ] release notes identify rollback setting. + +Under D-022, Wayland Auto remains on the integrated GStreamer path and an mpv +selection uses native-window presentation. Under D-023, X11 Auto also remains +integrated GStreamer, while the LGPL-only in-process mpv backend reports X11 +presentation unavailable and the external process remains an explicit handoff. +Windows and macOS retain independent per-platform Auto gates for their fully +integrated mpv presenters. Reopening either Linux HYBRID decision requires its +recorded re-entry evidence and a new decision. + +### Stage D — Primary backend + +- [ ] mpv is Auto on every platform approved by its gate. +- [ ] At least one release cycle retains and exercises GStreamer rollback. +- [ ] Crash/error/fallback reports are reviewed before cleanup. + +## 15. P11 — Legacy Cleanup and Optional Upstream Work + +### Player cleanup + +- [ ] Remove obsolete `video_opt` compatibility fields and duplicated state. +- [ ] Remove obsolete external-mpv messages if external mode is retired. +- [ ] Remove backend-specific UI branches superseded by capabilities. +- [x] Remove filename-based HDR provider selection. +- [x] Remove frame-driven progress polling. + +Provider selection no longer constructs an HDR hint from `2160p`, `UHD`, +`HDR`, or `DV` filename fragments. Player content labeling uses only server or +decoder color/bit-depth metadata, while native HDR output remains a separate +observed diagnostic. A pure metadata test covers PQ, HLG, BT.2020, 10-bit, and +8-bit SDR without a filename input. Snapshot synchronization is driven by the +bounded controls timer for Subwave and the coalesced copied-event signal for +mpv; views register no decoded-frame progress callback. + +### GStreamer/Subwave cleanup + +Only after rollout and rollback criteria: + +- [ ] Confirm no server/media-analysis use depends on playback GStreamer + packages. +- [ ] Remove unused appsink playback path. +- [ ] Remove unused Wayland playback surface code if mpv replaced it. +- [ ] Remove the GStreamer development-version pin from player packaging when + no remaining feature requires it. +- [ ] Preserve Subwave as a separate backend only if it has a documented, + tested capability. + +### Iced fork cleanup + +- [ ] Remove the playback-specific Wayland integration hook when no longer used. +- [ ] Keep batching/performance changes separate from media integration. +- [ ] Re-evaluate whether Ferrex can track upstream Iced more directly. +- [ ] Do not combine Iced cleanup with the mpv default-switch change. + +### Optional upstream proposal + +- [ ] Collect at least two non-media use cases for foreign parent-window + support. +- [ ] Open an Iced Discourse design discussion before writing a PR. +- [ ] Keep the proposed change backend-generic and map to winit semantics. +- [ ] Submit only after maintainer alignment. +- [ ] Do not include mpv, Wayland protocol objects, HDR policy, or persistent + pre-commit callbacks. + +### Exit criteria + +- [ ] No obsolete playback dependency remains in release packages. +- [ ] Architecture and platform docs match the shipped implementation. +- [ ] Rollback history and removed capability decisions are recorded. + +## 16. Cross-cutting Workstreams + +### 16.1 Diagnostics + +- [x] Define a serializable playback diagnostic snapshot. +- [x] Add backend/presenter lifecycle state. +- [x] Add versions and runtime capability list. +- [x] Add VO, GPU context, adapter, hwdec, and color parameters. +- [x] Add geometry and display scale. +- [x] Add fallback chain and reason. +- [x] Add opt-in verbose mpv and Wayland bridge traces. + +`FERREX_MPV_LOG_LEVEL` accepts only the fixed levels `none`, `fatal`, `error`, +`warn`, `info`, `verbose`, `debug`, and `trace`; invalid/non-Unicode values fail +closed to the bounded startup-verbose/steady-info policy and are never echoed. +Copied mpv messages still pass through both wrapper and active-source +redaction, and trace-severity messages remain trace-severity application logs. +Diagnostic schema version 6 reports the effective filter and bounded-startup +flag, never message contents. The deferred Wayland bridge has no runtime to +instrument under D-022; its opt-in W0 protocol harness already writes redacted, +mode-private traces through `native_playback_wayland_trace.py` and remains the +required re-entry diagnostic. + +### 16.2 Secret handling + +- [x] Introduce a redacted playback source debug representation. +- [x] Prefer HTTP headers/cookies over query-token URLs where server API permits. + Direct in-process streams and the typed `StreamingPlaybackSource` used by the + streaming/HLS service now carry a zeroizing bearer header on a + credential-free URI. Invalid/injectable values fail closed and `Debug` + redacts both path and authorization. Only the explicit external-process + compatibility boundary reconstructs a temporary query-ticket URL. +- [x] Ensure mpv logs are filtered before entering normal application logs. + Provider startup logging also uses `PlaybackSource`'s redacted formatter and + never emits the raw path, query, userinfo, headers, or cookies. +- [x] Ensure errors and panic diagnostics do not expose authorization data. +- [x] Remove URL-bearing external process arguments from the retained legacy + mode by submitting its media URL over the private mpv IPC socket. + +### 16.3 Capability UX + +- [x] Show selected backend and presentation mode in diagnostics/settings. +- [x] Explain why integrated presentation is unavailable. +- [x] Distinguish native HDR support from detected HDR content. +- [x] Distinguish expected/observed hardware decoding. +- [x] Never label a path zero-copy without observed evidence. + +The in-player settings panel now projects a redacted, evidence-qualified summary +from `PlaybackDiagnosticSnapshot`: requested and selected backend/presentation, +integrated-presenter status and fallback detail, input HDR metadata separately +from native-output HDR evidence, and configured hardware-decoder policy +separately from the observed decoder. Retained external-process snapshots now +produce the same summary after process exit. Pure projection tests cover the +labels and explicitly reject unobserved zero-copy wording; no URI, header, +cookie, local path, or configuration path enters the summary. + +## 17. Risk Register + +| ID | Risk | Impact | Mitigation / decision trigger | Status | +|---|---|---|---|---| +| R1 | mpv cannot be safely proxied onto Iced's Wayland connection | Blocks integrated mpv on Wayland | D-022 selects GStreamer integration plus mpv native-window rather than a framework/environment hack; reopen only for a maintainable per-session path | Mitigated by HYBRID; research deferred | +| R2 | Proxy misses evolving Wayland protocols used by mpv/driver | Playback/HDR failures by compositor or version | Retain the pinned trace fixture and W1–W5 re-entry matrix; no bridge ships under D-022 | Deferred under HYBRID | +| R3 | Transparent overlay breaks HDR, independent flip, or latency | Quality/performance regression | Measure overlay shown/hidden; native-window fallback; per-platform rollout | Open | +| R4 | macOS child window fails fullscreen/Spaces behavior | No integrated macOS controls | P6 spike; retain native-window mode; do not force OpenGL | Open | +| R5 | libmpv/AppKit/event-loop threading deadlocks | Application hang on load/exit | Serialized owner, callback rules, lifecycle stress, main-loop-aware teardown | Open | +| R6 | Native resource teardown races host window destruction | Crashes/leaks | Explicit generations, close ordering, 100-cycle tests | Open | +| R7 | Packaging differs from developer environment | Backend absent in releases | P2 packaging workstream and clean-machine smoke tests | Open | +| R8 | mpv config/scripts make behavior nondeterministic or unsafe | Support/security problems | Deterministic profile; explicit trusted-user config/scripts opt-in with diagnostics | Mitigated; settings UX pending | +| R9 | Player abstraction refactor changes progress/episode behavior | User-visible regressions | Adapter-first P1 with behavior tests before mpv | Open | +| R10 | Scope expands into a private graphics/render backend | Long-term maintenance burden | Native-VO invariant; private mpv patch requires spec amendment | Open | +| R11 | User input cannot reveal hidden Iced overlay reliably | Broken controls/focus | Explicit per-platform input policy and tests | Open | +| R12 | mpv API/version churn breaks wrapper | Build/runtime incompatibility | Bundle known version, runtime checks, raw API tests, update policy | Open | +| R13 | A distribution links Ferrex to mpv/FFmpeg built with GPL-only code | Release license incompatibility | Require per-platform LGPL build profiles and notices; Nix and Flatpak assert mpv's resolved `gpl=false` option plus FFmpeg's LGPL/no-`--enable-gpl` configuration | Mitigated on Nix/Flatpak; open elsewhere | +| R14 | mpv 0.41's X11 VO and `wid` implementation require its GPL build profile | Blocks bundled in-process mpv on X11 under D-005 | D-023 keeps X11 on integrated GStreamer and permits the external process boundary; reopen only for compatibly licensed upstream code or an explicit distribution-policy amendment | Mitigated by HYBRID; packaged fallback gate open | +| R15 | Server-side FFmpeg jobs can exhaust resources or expose incomplete/stale renditions | Availability or protected-stream integrity regression | Bound concurrency/timeouts/retention, write into per-job staging, validate output, atomically publish, authenticate ownership and every asset, and keep transport-only fixtures independently tested | Mitigated; live generated-rendition load passes, operational soak open | + +## 18. Decision Log + +| ID | Decision | State | Date | Notes | +|---|---|---|---|---| +| D-001 | Target libmpv native VO instead of forcing frames through wgpu | Accepted | 2026-07-11 | Maximizes current mpv VO/hwdec/HDR support | +| D-002 | Keep GStreamer as migration/failure fallback | Accepted | 2026-07-11 | Removal requires per-platform gates and rollback release | +| D-003 | Make the first integration without mpv-specific Iced changes | Accepted | 2026-07-11 | Use raw handles, `window::run`, redraw events, and widget tree state; the pinned Iced revision has no `Shell::window` host accessor. | +| D-004 | Use a thin Ferrex wrapper and fakeable function table over `libmpv2-sys` 4.0.1 | Accepted | 2026-07-11 | Full client API 2.5 coverage without adopting `libmpv2` ownership/event limitations. Isolated in `ferrex-player-mpv`; `linked` is opt-in and propagated by the player-level `mpv` feature; LGPL exception is explicit in `deny.toml`. Fake lifecycle/version tests and the Nix-linked create/initialize/destroy smoke test pass. Fallback impact: backend-disabled builds remain valid. | +| D-005 | Dynamically link a known LGPL-only mpv 0.41.0 shared library; minimum client API 2.2 | Accepted | 2026-07-11 | API 2.2 (mpv 0.37) contains every P3 client symbol; production remains pinned to mpv 0.41/API 2.5. The Nix profile uses `gpl=false`, LGPL FFmpeg, disabled GPL-only inputs, and an install-time feature/license assertion; its player package and real-handle smoke test pass. Flatpak/Windows/macOS must reproduce the LGPL profile and notices, not use default GPL builds. A bespoke runtime loader is deferred, not prohibited. Fallback impact: builds without `mpv`/`linked` retain GStreamer/external mpv. | +| D-006 | Non-Wayland native-root plus transparent Iced overlay | Accepted for handoff | 2026-07-13 | Windows and macOS now implement independently compile-gated native-root presenters behind the neutral lifecycle. Representative hardware approval remains per-platform in D-024/D-025; Auto is unchanged. | +| D-007 | Wayland Iced root plus proxied mpv subsurface | Deferred | 2026-07-12 | D-022 selects HYBRID. Retain this architecture only as re-entry criteria if a safe connection bootstrap becomes available. | +| D-008 | Wayland-only mpv connection redirection mechanism | Deferred | 2026-07-12 | W0 confirms stable libmpv has no per-context Wayland endpoint and delayed helper connections make temporary environment overrides unsafe. A process-lifetime startup proxy is the only race-free candidate found, but routing Iced too conflicts with the private mpv-only socket requirement. D-022 selects HYBRID until a better path exists. | +| D-009 | Retain external mpv as an explicit process-isolated compatibility handoff through the rollback window | Accepted | 2026-07-13 | In-process native-window mpv now has control/track/progress parity for the supported matrix, but the external process still supplies crash isolation and the D-023 X11 handoff without linking GPL-only X11 VO code into Ferrex. D-017 sends its credential-bearing source through private IPC rather than argv; D-019 projects copied state into `PlaybackSnapshot`, and the capability-UX tranche adds the same redacted diagnostic summary. Pure tests cover lifecycle/progress/episode/diagnostic behavior; the ignored real-mpv smoke verifies IPC load, observation, private socket cleanup, and `/proc//cmdline` non-disclosure. Fallback impact: external mpv remains explicit and is never selected by Auto; GStreamer and in-process native-window selection are unchanged. Revisit removal only in P11 after the rollback window and an approved X11 alternative. | +| D-010 | Optional Iced foreign-parent proposal | Deferred | 2026-07-11 | Discuss only after working external implementation | +| D-011 | Keep the neutral contract in `ferrex-player-playback::contract` | Accepted | 2026-07-11 | `dev` already extracted the playback crate; contract/reducer/channel/fallback and adapter tests live there, with no second crate until another client needs it. Evidence: playback and UI unit suites plus workspace all-target check in the initial implementation change | +| D-012 | Serialize libmpv through a thread-affine `MpvSession`, with an optional owner-thread `MpvWorker` | Accepted | 2026-07-11 | The wakeup callback performs only atomic coalescing and `Thread::unpark`; native pointers stay on the owner, and event payloads are bounded/copied before the next wait. Fake ABI tests cover nodes, replies, cancellation, hooks, logs, wake storms, and 50 teardown cycles; linked tests cover a real property reply and ordered stop. macOS can use the local owner until its AppKit model is proven. Fallback impact: none when the `mpv` feature is disabled. | +| D-013 | Route the existing explicit “Play in MPV” action to in-process native-window libmpv when the `mpv` feature is enabled | Accepted | 2026-07-11 | `mpv_adapter.rs`, `video::open_requested_session`, and a coalesced copied-event readiness subscription provide the vertical slice without frame uploads or periodic event polling. Auto remains Subwave; async mpv load failure resumes through Auto/Subwave, the separate external-process handoff remains available, and backend-disabled builds retain the historical external action. Unit tests cover source/log redaction, property/event/track mapping, close-versus-EOF terminal policy, version/VO/GPU/hwdec/frame diagnostic serialization, and load/seek/EOF stop order; the expanded local real-VO smoke test passes with mpv 0.41.0 and verifies public runtime diagnostics, confirmed fullscreen enter/exit, stop/reload, and native close/quit, while the earlier authenticated HTTP range variant separately proves header/cookie/query-ticket transport. | +| D-014 | Keep native presenter resources UI-thread-local behind a generation-scoped lifecycle reducer | Accepted | 2026-07-11 | `ferrex-player-playback::presenter` owns pure readiness, geometry, visibility, suspension, fullscreen-confirmation, teardown, and failure transitions while `NativePresenter` deliberately has no `Send` bound. Accepted/cleared geometry now crosses the neutral presenter-event boundary into diagnostic schema v2, including logical/visible bounds and display scale; fallback requests form an ordered deduplicated chain. Fake lifecycle/reducer tests cover both readiness orders, one attach per generation, clipping/zero size, duplicate geometry, scale/window recreation, stale rejection, explicit detach-before-drop, deterministic fallback, geometry clearing, and fallback history. Fallback impact: presenter failure emits `PresenterFailed` from integrated mpv to native-window mpv after detach; no platform presenter or Auto default is enabled yet. | +| D-015 | Acquire Iced native hosts with a `window::run` handshake and event-loop-local lease | Accepted | 2026-07-11 | The pinned Iced API does not expose a window through widget `Shell`. `native_video_slot.rs` therefore requests capture once, returns only a pointer-free result through `Task`, and keeps copied raw handles in a thread-local borrow registry used by generation-scoped presenter callbacks. `Tree::State` revisions geometry only on redraw and detaches on replacement/drop/close. Unit tests cover host capture, clipping/scale revisions, capture deferral, and teardown. Fallback impact: none; no integrated presenter or Auto selection is enabled. | +| D-016 | Decouple player snapshot updates from decoded-frame callbacks | Accepted | 2026-07-12 | Desktop and 10-foot views construct the backend presentation widget without `on_new_frame`. Native mpv wakes Iced only through its coalesced copied-event signal; legacy Subwave synchronization reuses the bounded controls timer, and progress persistence keeps its existing ten-second heartbeat. The player/UI feature suite and backend-disabled check pass. Fallback impact: Subwave remains fully available, but its UI position refresh is intentionally bounded instead of frame-rate-driven. | +| D-017 | Carry in-process playback tickets in typed source headers | Accepted | 2026-07-13 | `resolve_playback_stream_source`, `PlayerDomainState::current_source`, and `SetStreamSource` keep direct Ferrex stream URIs credential-free and store the playback-scoped token in a redacted, zeroizing `Authorization` header. The formerly string-only streaming/HLS service now returns a typed `StreamingPlaybackSource` with the same constraints and projects it into `PlaybackSource`; embedded query/userinfo credentials and header injection fail closed. Subwave and libmpv pass source headers in process; the legacy external path creates a temporary zeroizing query URL only at its compatibility boundary and sends it through private mpv IPC rather than the child argument vector. Unit tests verify direct and streaming-service header transport, injection rejection, redacted source/state/error diagnostics, fail-closed ticket errors, legacy conversion, and `0700` IPC-directory cleanup. Real native-mpv smokes pass against a bearer-header range server, a real Ferrex router direct stream, and a router-backed HLS manifest whose four segments require the same ticket; the separate external-mpv smoke verifies query-ticket IPC load plus `/proc//cmdline` non-disclosure. Fallback impact: in-process mpv-to-Subwave fallback retains the same authenticated source; only the explicit external compatibility handoff reconstructs a query ticket. | +| D-018 | Allocate the dedicated Iced player overlay hidden and reveal it only after explicit native attachment | Accepted | 2026-07-12 | `WindowKind::PlayerOverlay`, the daemon window controller, player-only root view routing, and the explicit transparent theme implement a generation-independent host window shell without platform objects. Attachment and presenter positioning occur while hidden; an explicit `Activating` state covers the serialized retained-main hide, and `Active` is recorded only after the presenter synchronously reveals the host. Pointer-free transition logs then confirm the delivered overlay-focus event. No stale main resize/move is applied after attachment. A separate live overlay viewport drives controls/focus/hit testing while retained main geometry remains restoration state. Close handling calls `prepare_iced_native_host_close` before `window::close`, detaching all registered slot generations before releasing the raw-host lease; the retained main window is restored after activating/active-overlay teardown. Manager, settings, theme, viewport, and native-slot tests cover hidden/activating/active/closing order and detach-before-release. P5/P6 still own native root relationship, z-order, and taskbar identity validation. Fallback impact: the overlay is dormant until an attachment confirmation explicitly activates it, and presenter fallback can dismiss it without stopping playback; current Subwave, mpv native-window, and external modes are unchanged. | +| D-019 | Project retained external-mpv process observations into the backend-neutral snapshot | Accepted | 2026-07-12 | `PlayerDomainState::external_mpv_snapshot` is reduced from copied private-IPC observations while `ExternalMpvHandle` owns only process resources. Desktop/10-foot views, progress heartbeat, navigation, and episode start-mode policy now consume the same snapshot/progress projection as in-process backends; only process polling and external seek remain explicit compatibility branches. Final observations are captured before handle drop, and tests prove terminal episode advancement and progress persistence without a surviving handle. Fallback impact: external process mode remains available and D-009 remains pending; Subwave and in-process mpv selection are unchanged. | +| D-020 | Keep mpv user config and scripts disabled unless trusted-code policy is explicit | Accepted | 2026-07-12 | `ferrex-player-mpv::MpvConfigPolicy` defaults to Ferrex's deterministic native-window profile. `FERREX_MPV_CONFIG_POLICY=trusted-user` is the developer-only opt-in for standard mpv config, `input.conf`, and scripts; invalid values fail closed and are not logged. Diagnostic schema v4 reports policy and effective high-level switches. Fake session tests verify pre-initialization config/script options and retained external-resolver disablement; playback parser/diagnostic tests and backend-disabled compilation pass. Fallback impact: none—Auto remains Subwave, exact mpv selection is unchanged, and builds without mpv do not read the policy. | +| D-021 | Expose local video extensions as capability-gated Ferrex commands | Accepted | 2026-07-12 | `PlaybackCommand`/`PlaybackSession` model external sidecar subtitle loading, named profile application, ordered local shader replacement, and explicit-path screenshots without exposing the mpv owner. `PlaybackFilePath` and `VideoProfileName` redact debug output; adapter validation and copied-log filtering never echo values. mpv uses argument-separated standard commands, while Subwave returns structured `UnsupportedOperation`; user profiles are available only under D-020's trusted policy. Diagnostic schema v5 adds the four support booleans and only an observed shader count. Pure mapping/redaction/policy tests pass; display-backed mpv 0.41 native-VO smokes passed with a real external SRT track, identity shader, and non-empty screenshot on 2026-07-12. Fallback impact: unsupported backends remain selected and report the unavailable operation rather than changing backend or silently doing nothing. | +| D-022 | Use a HYBRID Wayland backend until a safe integrated mpv connection path exists | Accepted | 2026-07-12 | W0 traces on SDR, HDR10/PQ, and HLG prove ordinary mpv 0.41 `gpu-next`/Vulkan native VO and identify one shell candidate, but stable libmpv cannot direct a session to the private bridge without a process-global race; the only race-free startup proxy candidate would also proxy Iced and violates the current boundary. Wayland therefore keeps GStreamer/Subwave for integrated presentation and offers mpv in native-window mode. Windows P5 and macOS P6 remain fully integrated native-VO targets. Fallback impact: no Wayland Auto change, no CPU/wgpu mpv frame path, and no change to Windows/macOS rollout gates. | +| D-023 | Keep X11 on integrated GStreamer under the LGPL release profile | Accepted | 2026-07-12 | mpv 0.41's Meson graph requires `gpl=true` for the X11 VO and therefore for native-window/overlay/`wid` presentation. The reviewed D-005 profile cannot ship that code. The Flatpak build asserts `gpl=false` and `x11=disabled`, retains Wayland Vulkan/dmabuf/VA-API, and its built/installed bundle resolves the pinned libmpv/FFmpeg/libplacebo closure from `/app`; Nix uses the same mpv license option. Both package builds compile `FERREX_MPV_X11=disabled`; `open_requested_session` preflights an X11-only environment into a structured `UnsupportedPlatform` fallback before creating libmpv, with a pure display/profile matrix test. Re-entry requires compatibly licensed upstream X11 support or an explicit distribution-policy/specification amendment, followed by the retained P5 matrix. Fallback impact: X11 Auto remains integrated GStreamer, in-process mpv is reported unavailable, and the separate external mpv process may remain an explicit handoff; Windows/macOS gates are unchanged. | +| D-024 | Use mpv's Win32 HWND as the video root and an owned, taskbar-suppressed Iced HWND as the controls overlay | Accepted for handoff | 2026-07-13 | The compile-time `FERREX_MPV_WINDOWS_PRESENTER=spike` path observes the full pointer-width `window-id`, validates both HWNDs, synchronizes client geometry/DPI/minimize/visibility at an independent native-root cadence, delegates fullscreen to mpv, and restores owner/style state on detach. Display-free presenter tests, pinned LGPL libmpv SDK/import-library tooling, exhaustive provenance for the staged runtime DLL closure, and a reviewed GStreamer PE/GIO/TLS closure with HLS/HTTPS smoke are defined; floating Rust/MSYS2 build tools remain identified in workflow logs rather than covered by that runtime-closure claim. Fallback impact: any preflight/attach failure dismisses the hidden overlay and selects mpv native-window; Auto, native HDR, and production capability remain closed until the Windows hardware/package/100-cycle matrix passes. | +| D-025 | Use mpv's AppKit NSWindow as the video root and an AppKit child Iced NSWindow as the controls overlay | Accepted for handoff | 2026-07-13 | The compile-time `FERREX_MPV_MACOS_PRESENTER=spike` path treats `window-id` only as mpv's read-only NSWindow observation, retains and manipulates AppKit objects behind a non-Send main-thread marker, follows the content layout/active Space/occlusion/scale state, and detaches before handing blocking libmpv termination to an off-main reaper. Display-free presenter tests, pinned core LGPL sources, version/hash-recorded Homebrew inputs, and a strict macOS 15+ bundle closure/HLS audit are defined; the first target artifact run remains open. Fallback impact: any preflight/attach failure dismisses the hidden overlay and selects mpv native-window; Auto, EDR/HDR, VideoToolbox, and production capability remain closed until Apple Silicon/Intel package/fullscreen/Spaces/100-cycle evidence passes. | + +When resolving a pending/proposed decision, add the implementation reference, +test evidence, and fallback impact to its Notes field. + +## 19. Definition of Done + +The migration is complete when: + +- [ ] Ferrex-owned playback commands/events/snapshots are the only player-domain + backend contract. +- [ ] libmpv native-window mode is complete and release-packaged on all desktop + targets. +- [ ] each platform has either a passing integrated presenter or a documented + native-window fallback. +- [x] Wayland has a recorded HYBRID decision backed by P7 W0 evidence. +- [ ] mpv feature parity covers current controls, tracks, subtitles, progress, + episodes, direct play, and transcoding output. +- [ ] no native-VO path transfers decoded frames through CPU/wgpu. +- [ ] HDR and hardware-decoding claims are backed by diagnostics and manual test + evidence. +- [ ] lifecycle and 100-cycle stress gates pass. +- [ ] Nix, Flatpak, Windows, and macOS release artifacts are verified. +- [ ] fallback and rollback paths are documented and tested. +- [ ] platform Auto defaults match recorded rollout decisions. +- [ ] obsolete GStreamer/external/fork code is removed only after the rollback + window, or retained with a documented capability. +- [ ] README, architecture, configuration, and troubleshooting docs describe the + shipped system. + +## 20. Immediate Next Actions + +1. [x] Complete the P0 direct-use and behavior inventory. +2. [x] Draft the P1 Ferrex-owned playback contract and fake backend tests. +3. [x] Run the P2 FFI comparison and record D-004/D-005. +4. [x] Build the P3 native-window vertical slice before any graphics embedding + work. +5. [x] In parallel, assemble the Wayland protocol/media test matrix needed for + the P7 W1 feasibility gate. +6. [x] Make episode/navigation exit policy and local mpv extensions explicit, + capability-gated, redacted, and covered by display-free tests. +7. [~] Complete the remaining UI quality-picker/episode gate against a live + local Ferrex server. Authenticated direct play, protected fixture HLS, and a + real server-generated `360p` rendition all pass display-backed through the + network-bound router with header-only tickets, including cache reuse, + shader, screenshot, seek, and ordered-stop coverage. The manual quality + selection run and UI episode transition remain open. +8. [x] Record the P7 HYBRID decision without changing Wayland Auto. +9. [x] Implement compile-gated Windows P5 and macOS P6 native presenter handoff + builds independently of the deferred Wayland bridge. +10. [~] Run the documented Windows/macOS representative-system package, + fullscreen/focus/scale/HDR/hwdec, fallback, and 100-cycle matrices before + changing either platform's production or Auto capability. diff --git a/docs/src/content/docs/developer/architecture.md b/docs/src/content/docs/developer/architecture.md index 87d661ac..41c40c15 100644 --- a/docs/src/content/docs/developer/architecture.md +++ b/docs/src/content/docs/developer/architecture.md @@ -30,6 +30,7 @@ Related docs: - Phase 1 backend intelligence foundation: [Phase 1 intelligence foundation](/developer/intelligence-foundation/) - Player specifics and platform notes: [ferrex-player README](https://github.com/Lowband21/ferrex/blob/dev/crates/ferrex-player/README.md) - Player crate dependency boundaries: [Player dependency boundaries](/developer/player-dependency-boundaries/) +- Native mpv migration: [architecture specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md), [delivery plan](https://github.com/Lowband21/ferrex/blob/dev/docs/plans/native-mpv-playback-migration.md), [current playback baseline](/developer/native-playback-baseline/), [fixture/test matrix](/developer/native-playback-fixtures/), and [Wayland spike record](/developer/native-mpv-wayland-spike/) - Demo mode: [Demo mode](/operator/demo-mode/) - UI testing workflow: [UI testing workflow](/developer/ui-testing-workflow/) @@ -44,18 +45,30 @@ nix develop .#ferrex-player --command cargo test -p ferrex-core --lib ## High‑Level Diagram -``` - +------------------------+ HTTP/WS +------------------------+ - | ferrex-player | <-----------------------> | ferrex-server | - | (app shell + Iced UI) | | (Axum + Postgres) | - | | watch stat, metadata, images | | - +-+--------------+-------+ +------+ +------+----------+------+ - | | | | | | - | | | | | | - v v v v v v - Appsink Wayland subsurface GStreamer pipeline Postgres Redis - Player (HDR zero-copy) (decode/metadata) (state) (rate-limiting) - +```text ++-------------------------------- ferrex-player --------------------------------+ +| Iced app shell, layout, controls, input, and dedicated controls overlay | +| | +| PlaybackCommand -> Ferrex-owned session/reducer -> PlaybackSnapshot | +| | ^ | +| v | PlaybackEvent | +| +---------------------------+------------+------------------+ | +| | | | | +| Subwave/GStreamer adapter in-process libmpv adapter external mpv IPC | +| | | | | +| integrated Wayland/X11 or mpv native VO (`gpu-next`) process-isolated | +| embedded-frame fallback (no frame enters Iced/wgpu) compatibility | ++----------+---------------------------+-------------------------------+----------+ + | | | + +---------------------------+-------------------------------+ + | + authenticated HTTP/range/HLS + | + +-------------v-------------+ + | ferrex-server | + | Axum + Postgres + Redis | + | tickets, media, progress | + +---------------------------+ ``` ## Components @@ -78,10 +91,12 @@ nix develop .#ferrex-player --command cargo test -p ferrex-core --lib - App shell: `ferrex-player-app` owns runtime bootstrap/config, state/domain composition, cross-domain routing surfaces, root update/view/subscription wiring, Iced daemon/application construction, presets, and logger/profiling hooks. - UI/presentation: `ferrex-player-ui` owns the Iced views/widgets, Iced task/subscription adapters, design tokens, shader widgets, WGSL assets, and 10-foot surfaces. - Data/API crates: `ferrex-player-auth`, `ferrex-player-repository`, `ferrex-player-library`, `ferrex-player-media`, `ferrex-player-metadata`, `ferrex-player-search`, `ferrex-player-settings`, `ferrex-player-user-admin`, and `ferrex-player-api` own player data-domain behavior and service contracts without pulling in Iced/subwave runtime code; settings only shares `iced_core` color/point DTOs. Async work crosses this boundary through `ferrex-player-foundation` domain tasks or dependency-light streams that the UI crate wraps. -- Playback/video: `ferrex-player-playback` owns Subwave/MPV playback state, controls, subscriptions, and overlay helpers behind explicit ports that `ferrex-player-ui` adapts. -- Video: subwave backend with platform‑optimized paths. - - Wayland/HDR: GStreamer path with subsurfaces enables zero‑copy HDR output. - - Other platforms: cross‑platform backend or mpv hand‑off with watch status tracking. +- Playback/video: `ferrex-player-playback` owns Subwave/MPV playback state, controls, subscriptions, and overlay helpers behind explicit ports that `ferrex-player-ui` adapts. Its `contract` module is the migration boundary for Ferrex-owned backend-neutral commands, events, snapshots, track models, and fallback policy. +- Video: backend-neutral control with platform-optimized presentation. + - Wayland HYBRID (D-022): GStreamer/Subwave remains integrated; mpv uses its native window until a safe per-session bridge path exists. + - Windows and macOS: fully integrated native-VO mpv presenters remain active migration targets with independent rollout gates. + - All platforms retain backend-neutral watch status and deterministic fallback. + - Operational selection, diagnostics, and rollback: [Desktop playback backends](/developer/desktop-playback-backends/). - Focus: smooth, low‑latency poster grids and animated navigation. ### Core (`ferrex-core`) diff --git a/docs/src/content/docs/developer/desktop-playback-backends.md b/docs/src/content/docs/developer/desktop-playback-backends.md new file mode 100644 index 00000000..9526f7c2 --- /dev/null +++ b/docs/src/content/docs/developer/desktop-playback-backends.md @@ -0,0 +1,187 @@ +--- +title: "Desktop playback backends" +description: "Build, select, diagnose, and troubleshoot Ferrex's GStreamer, in-process libmpv, and external mpv desktop playback paths." +sidebar: + order: 7 +--- + +Ferrex is migrating desktop playback behind a backend-neutral contract. The +player domain consumes Ferrex-owned commands, events, snapshots, tracks, and +capabilities; concrete GStreamer and libmpv objects stay inside their adapters. +The [architecture specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md) +defines the target, while the +[migration plan](https://github.com/Lowband21/ferrex/blob/dev/docs/plans/native-mpv-playback-migration.md) +records measured rollout gates. + +## Shipped choices and current defaults + +| Choice | Presentation | Current role | +|---|---|---| +| Auto | Platform release policy | GStreamer remains first during the rollback/soak window. | +| GStreamer | Integrated Subwave surface or embedded fallback | Default migration backend; the integrated HDR path on Wayland and the LGPL-compatible X11 path. | +| mpv integrated | Platform native presenter | Explicit Windows/macOS handoff build; never selected by Auto until that platform's representative-system gate passes. | +| mpv native window | libmpv `gpu-next` native VO | Developer opt-in and compatibility path; no decoded frame enters Iced/wgpu. | +| External mpv | Separate process and private IPC | Explicit crash-isolated/X11 compatibility handoff; never selected by Auto. | + +Wayland is intentionally HYBRID: integrated playback stays on GStreamer and an +explicit mpv request uses mpv's ordinary native window. Stable libmpv cannot +currently direct only its delayed Wayland connections through a private bridge +without a process-global race. X11 also stays on GStreamer in the reviewed +LGPL-only package because mpv 0.41 gates its X11 VO and `wid` code on the GPL +build profile. Neither policy silently selects a CPU-copy mpv path. + +Windows and macOS now have compile-gated Win32/AppKit presenter handoff builds, +with independent package and representative-system gates. Consult the +migration plan rather than assuming that a development machine finding libmpv +proves a release artifact or a production-ready integrated presenter. + +## Build and select the developer backend + +The default feature set remains buildable without linked libmpv. Enable the +in-process backend explicitly: + +```bash +cargo run -p ferrex-player --features mpv +``` + +Use **Play in MPV** in an mpv-enabled build. On Windows and macOS it requests +the compile-gated integrated presenter and falls back to mpv's native window +with a structured reason if preflight or attachment fails. On Wayland it +requests the ordinary native-window path; the reviewed X11 package retains the +external handoff. If the build omits the mpv feature, the historical action +retains its external-process behavior. Auto is deliberately unchanged during +the staged rollout. + +The exact spike build commands and hardware checklist are in the +[native playback fixture handoff](/developer/native-playback-fixtures/#windows-and-macos-integrated-presenter-handoff/). + +Release packages must carry the pinned, LGPL-only libmpv/FFmpeg/libplacebo +closure. A system libmpv found through a developer package manager is not +release evidence. The Flatpak and Nix profiles additionally reject mpv's GPL +option and record their resolved feature/license profiles. + +## Deterministic fallback order + +Every requested and selected target is recorded separately. During migration, +Auto tries integrated then embedded GStreamer. An unavailable exact request can +follow the policy-approved chain through mpv native-window, integrated or +embedded GStreamer, and finally the explicit external target. Duplicate targets +are removed, and the first rejection becomes the machine-readable fallback +reason. + +HDR-required selection rejects candidates that cannot preserve native HDR +signaling. A presenter failure should therefore prefer mpv native-window over +an SDR frame-upload path. Initialization/load failures checkpoint the last +observed position before returning to Auto/GStreamer. + +Common reason codes include `backend_disabled`, `unsupported_platform`, +`requested_unavailable`, `missing_capability`, `presenter_failed`, and +`backend_failure`. A fallback is not inferred from a missing frame timer. + +## Configuration and trusted code + +Ferrex's default libmpv profile disables standard user config, script +discovery, and external URL resolvers. Native-window compatibility enables +controlled OSC/input bindings; integrated presentation disables them because +Iced owns input. Trusted local development can opt into normal mpv config, +`input.conf`, and scripts: + +```bash +FERREX_MPV_CONFIG_POLICY=trusted-user \ + cargo run -p ferrex-player --features mpv +``` + +Those files execute inside the Ferrex process. Invalid or non-Unicode policy +values fail closed to `deterministic` and are not repeated in logs. + +`FERREX_MPV_PATH` applies only to discovery of the separately launched mpv +executable, primarily on Windows. It does not replace or locate the linked +libmpv used by the in-process feature. + +## Safe diagnostics + +Open the in-player settings panel during playback to see the requested and +selected backend, presentation mode, integrated-presenter state, fallback +reason, input HDR evidence, native-output HDR evidence, configured decoder +policy, and observed hardware decoder. These labels deliberately do not infer +HDR, hardware decoding, or zero-copy from the backend name. + +The serializable diagnostic snapshot also includes client/runtime versions, +VO/GPU context and adapter, color parameters, frame timing counters, presenter +geometry and scale, capability flags, and the ordered fallback chain. It never +contains the playback URI, headers, cookies, user config contents, shader +paths, screenshot paths, or copied log messages. + +For a fixed native-message filter, set `FERREX_MPV_LOG_LEVEL` to `none`, +`fatal`, `error`, `warn`, `info`, `verbose`, `debug`, or `trace`, and permit the +same Ferrex target with `RUST_LOG`: + +```bash +FERREX_MPV_LOG_LEVEL=trace \ + RUST_LOG=ferrex_player_playback=trace,ferrex_player_mpv=trace \ + cargo run -p ferrex-player --features mpv +``` + +Without the override, Ferrex captures bounded verbose startup evidence and +returns to informational native messages after file initialization. Invalid +values fail closed without being echoed. Credential and active-source +redaction still applies, but diagnostic logs can reveal local filenames, +hardware names, or private network topology; review them before sharing. + +For deeper, redacted protocol evidence, use the documented +[fixture matrix](/developer/native-playback-fixtures/) and +[Wayland trace harness](/developer/native-mpv-wayland-spike/). Store generated +media and results only below the ignored `target/` locations described there. + +## Troubleshooting + +### The in-process choice falls back immediately + +Check that the player was built with `--features mpv`, then inspect the visible +fallback reason. `backend_disabled` means the feature is absent. +`unsupported_platform` on packaged X11 is expected under the LGPL profile. +`backend_failure` or `backend_initialization` usually indicates an incompatible +or missing runtime library; compare the reported client API with the minimum +API 2.2 requirement. + +### mpv opens but the video is not integrated + +Native-window presentation is the selected compatibility mode, not a hidden +render failure. Check the presenter build gate and structured fallback reason. +An integrated request selects the normal mpv window when preflight or +attachment fails; it must not leave the hidden controls host behind. Passing +one development run still does not approve the lifecycle, input, HDR, stress, +packaging, or Auto gates. + +### Playback works in development but not in a package + +Inspect the package's loader closure and license/build-profile record. It must +not depend on a Nix store or local package manager path. Flatpak libraries must +resolve from `/app`; Windows and macOS packages must carry their reviewed DLL +or dylib closure. Do not work around a missing package closure by silently +switching to a software/headless VO. + +### Authenticated media fails + +In-process backends receive a credential-free URL and playback-scoped bearer +header. Avoid putting a ticket in command arguments or logs. Run the +[playback authentication regression](/reference/qa/playback-auth-regression/) +to distinguish ticket issuance, HTTP range/HLS propagation, and decoder +failures. The external compatibility process receives its temporary query URL +over private IPC rather than argv. + +### Controls or episode transitions behave differently + +Capture the requested/selected targets and fallback chain, then reproduce with +the generated multitrack fixture. Progress, EOF/error handling, next/previous, +Back/Home, track selection, and resume are backend-neutral policy. A difference +between backends is a regression unless diagnostics explicitly report an +unsupported capability. + +## Rollback + +Auto remains the one-step rollback to the current GStreamer policy during the +migration. The external mpv action remains explicit and process-isolated. Do +not remove GStreamer, external mpv, or the pinned Iced/Subwave capability until +the platform gate, release artifact, fallback, soak period, and at least one +rollback release have been verified and recorded. diff --git a/docs/src/content/docs/developer/native-mpv-extension-api.md b/docs/src/content/docs/developer/native-mpv-extension-api.md new file mode 100644 index 00000000..7f7eafe1 --- /dev/null +++ b/docs/src/content/docs/developer/native-mpv-extension-api.md @@ -0,0 +1,172 @@ +--- +title: "Native mpv extension API" +description: "Safe ownership, arbitrary command/property access, and trust boundaries for extending Ferrex's libmpv backend." +sidebar: + order: 11 +--- + +Ferrex keeps ordinary playback behavior behind the backend-neutral contract in +`ferrex-player-playback`. Features that need mpv functionality not yet modeled +there can use the public `ferrex-player-mpv` control-plane crate. This is the +documented developer API for raw commands, node values, properties, +observations, hooks, client messages, and future native events; it is not a +user-facing command console. + +Use a typed `PlaybackCommand`/`PlaybackEvent` when behavior must also work with +Subwave or another backend. Keep an mpv-only operation at this extension +boundary when it is diagnostic, experimental, or inherently mpv-specific. + +## Choose one owner model + +`MpvSession` is a thread-affine, `!Send` owner. It is appropriate when the +platform event loop must create and service libmpv on the current thread. +`MpvWorker` owns the same session on one named thread and serializes requests +through bounded channels. Normal desktop playback uses `MpvWorker`; do not put +an `mpv_handle` in application state or call libmpv concurrently from Iced. + +The wakeup callback only coalesces an atomic signal and unparks the owner. +Drain events on the owner and consume only the owned `MpvEvent` values returned +by Ferrex. Native event pointers are invalidated by the next +`mpv_wait_event` call and must never escape the wrapper. + +## Commands, properties, and observations + +The public owner APIs provide: + +- `command_async` for standard string-vector commands; +- `command_node_async` for arbitrary array/map commands and nested `MpvNode` + values; +- `get_property_async` and `set_property_async` for string, flag, integer, + double, null, byte-array, array, and map values; +- `observe_property` and `unobserve_property` for arbitrary documented or + future properties; +- `add_hook`, `continue_hook`, `set_event_enabled`, and client-message events; +- correlated `MpvRequestId`/`MpvObservationId` values in copied replies; and +- bounded node conversion through `MpvNodeLimits`. + +A minimal owner-thread sketch is: + +```rust +use ferrex_player_mpv::{ + MpvEvent, MpvFormat, MpvFunctionTable, MpvSessionConfig, MpvWorker, + MpvWorkerConfig, +}; + +let mut worker = MpvWorker::spawn( + MpvFunctionTable::linked(), + MpvSessionConfig::native_window(), + MpvWorkerConfig::default(), +)?; +let observation = worker.observe_property("estimated-vf-fps", MpvFormat::Double)?; +let request = worker.command_async(["show-text", "Ferrex diagnostic"])?; + +for event in worker.drain_events() { + match event { + MpvEvent::PropertyChanged(change) if change.id == observation => { + // The value is an owned MpvNode; reduce it into feature-owned state. + } + MpvEvent::AsyncReply(reply) if reply.id == request => { + // Correlate success/failure without relying on event order. + } + _ => {} + } +} + +worker.unobserve_property(observation)?; +worker.shutdown()?; +``` + +Do not submit credentials through commands intended for logs or a process +argument vector. Authenticated media must continue to use the redacted +`PlaybackSource` mapping, which applies headers/cookies as per-file options. +Never log arbitrary command arguments, node values, property values, or copied +client messages without applying the playback redaction policy. + +## Capability-gated playback extensions + +User-facing local extensions use Ferrex-owned commands instead of exposing the +mpv owner through application state: + +- `PlaybackSession::add_external_subtitle` maps a redacted local sidecar path + to `sub-add`, optionally selects it, and lets normal `track-list` reduction + expose the new stable identity and `is_external` flag; +- `PlaybackSession::capture_screenshot` maps to one `screenshot-to-file` + request and requires an explicit destination plus video-only, + video-with-subtitles, or full-window mode; +- `PlaybackSession::set_video_shaders` replaces mpv's ordered `glsl-shaders` + list using argument-separated `change-list` commands; an empty list clears + it; and +- `PlaybackSession::apply_video_profile` maps to `apply-profile` only when the + effective policy enables trusted user configuration. + +Inspect `PlaybackSnapshot::capabilities` before presenting these actions. +Subwave and other unsupported backends return a structured +`UnsupportedOperation` error rather than silently ignoring them. Explicit +shader files and screenshot destinations use `PlaybackFilePath`, whose debug +form is redacted. Diagnostic schema version 6 reports support booleans, the +observed active shader count, and the effective native log filter; it never +reports profile names, local paths, or copied log contents. Command arguments +remain separate libmpv values, and empty, multiline, overlong, or non-Unicode +paths fail before submission. + +On 2026-07-12 the display-backed mpv 0.41 native-VO smokes loaded and selected +an external SRT sidecar, applied a temporary identity shader, confirmed the +observed shader count, wrote a non-empty screenshot, cleared the shader list, +and removed both temporary files. Separate runs covered the generated +multitrack text, animated ASS/attached-font, and PGS fixtures. Normal tests +verify command shapes and redaction without a display. + +## Initialization options and trusted config + +`MpvSessionConfig::with_option` is the pre-initialization option escape hatch. +Options are applied in order before `mpv_initialize`; they are not runtime +properties. Production behavior should start from a Ferrex profile rather than +constructing an unreviewed option list. + +`MpvConfigPolicy::Deterministic` is the default. It disables standard user +config, script discovery, and external URL resolvers. The developer-only +`FERREX_MPV_CONFIG_POLICY=trusted-user` player switch selects +`MpvConfigPolicy::TrustedUser`, which enables standard mpv config, +`input.conf`, and scripts. Those files are trusted code running inside Ferrex. +Invalid policy values fail closed and diagnostics expose only the effective +high-level switches, never config contents or paths. + +## Native logging and protocol traces + +Normal playback briefly requests verbose libmpv messages during file startup +to discover version, VO, GPU, and adapter evidence, then returns to the `info` +filter. For an opt-in diagnostic run, set `FERREX_MPV_LOG_LEVEL` to one of +`none`, `fatal`, `error`, `warn`, `info`, `verbose`, `debug`, or `trace` to keep +that fixed native filter for the session. Use `RUST_LOG` separately to permit +the corresponding Ferrex log target, for example: + +```bash +FERREX_MPV_LOG_LEVEL=trace \ + RUST_LOG=ferrex_player_playback=trace,ferrex_player_mpv=trace \ + cargo run -p ferrex-player --features mpv +``` + +Invalid or non-Unicode values fail closed and are not repeated in logs. +Messages still pass through generic credential redaction and the active +playback-source redactor before reaching the application logger. Review traces +for private filenames or server topology before sharing them. Wayland bridge +protocol capture is currently the redacted opt-in W0 harness documented in the +[Wayland spike](./native-mpv-wayland-spike/); no bridge runtime ships under the +HYBRID decision. + +## Raw native escape hatch + +`MpvSession::with_raw_handle` is the final unsafe boundary for a client API +symbol that the wrapper does not yet represent. Its callback must not retain or +destroy the handle, drain events, replace the wakeup callback, or race the +serialized owner. Prefer extending the fakeable function table and safe wrapper +instead. Any graphics/render-context work also requires an architecture review: +the native-VO migration must not become a private decoded-frame path. + +## Verification expectations + +Add fake-ABI coverage for every new command/property/event shape, including +reply correlation, cancellation, copied lifetimes, and teardown. Tests that +need a real VO belong in the ignored display-backed smoke gate and must use the +schema-generated fixtures. Keep normal unit tests display-free and ensure the +player still compiles without the `mpv` feature. diff --git a/docs/src/content/docs/developer/native-playback-fixtures.md b/docs/src/content/docs/developer/native-playback-fixtures.md new file mode 100644 index 00000000..b22d4b8d --- /dev/null +++ b/docs/src/content/docs/developer/native-playback-fixtures.md @@ -0,0 +1,779 @@ +--- +title: "Native playback fixtures and test matrix" +description: "Reproducible synthetic media, authenticated transport, and environment inventory for desktop playback migration testing." +sidebar: + order: 10 +--- + +This page defines the P0 media and environment inputs for the [native mpv integration specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md). Generated media and run results stay outside the repository; the generator, validator, and transport server are versioned. + +## Generate and verify + +The complete set requires Python 3, `ffmpeg`, `ffprobe`, Fontconfig/DejaVu Sans (or an explicitly supplied redistributable TrueType font), and these FFmpeg encoders: + +- `libx264`; +- `libx265`; +- `libvpx-vp9`; +- `libaom-av1`; +- AAC; and +- ASS. + +From the repository root: + +```bash +./scripts/qa/native_playback_fixtures.py generate +./scripts/qa/native_playback_fixtures.py verify +``` + +The default output is `target/native-playback-fixtures/`, which is already ignored with the rest of `target/`. Regeneration is explicit: + +```bash +./scripts/qa/native_playback_fixtures.py generate --force +``` + +The generator refuses to replace a directory without its Ferrex fixture marker. It writes a schema-versioned `manifest.json` and `SHA256SUMS`; verification checks the hashes, codecs, color signaling, HDR side data, tracks, chapters, attachments, subtitles, HLS structure, and expected malformed-input rejection. + +Use `--font /path/to/font.ttf` when DejaVu Sans is unavailable. The selected font is embedded into generated Matroska files, not copied into the repository. Confirm that a replacement font permits this use. + +## Fixture inventory + +All fixtures are four-second, 640×360 synthetic patterns with a low-level synthetic audio tone unless noted otherwise. + +| Path | Coverage and required observation | +|---|---| +| `h264-sdr-8bit.mkv` | H.264, `yuv420p`, BT.709 SDR baseline. Keyframes are spaced for one-second HLS segmentation. | +| `hevc-main10-sdr.mkv` | HEVC Main10, `yuv420p10le`, BT.709 SDR. This distinguishes bit depth from HDR. | +| `hdr10-pq.mkv` | HEVC Main10 with BT.2020/PQ, mastering-display metadata, MaxCLL 1000, and MaxFALL 400. | +| `hlg.mkv` | HEVC Main10 with BT.2020 and ARIB STD-B67/HLG transfer signaling. | +| `vp9-sdr.mkv` | VP9 SDR decode path. | +| `av1-sdr.mkv` | AV1 SDR decode path. | +| `ass-animation-fonts.mkv` | Two animated/karaoke ASS events and an attached DejaVu Sans font. | +| `pgs-bitmap.mkv` | A locally constructed HDMV PGS bitmap object, palette, show, and clear sequence. No external PGS encoder or copyrighted subtitle is required. | +| `multitrack-structure.mkv` | Two named/language-tagged audio tracks, two text subtitle tracks (default and forced), two chapters, and a font attachment. | +| `transcoded-hls/index.m3u8` | VOD HLS playlist and MPEG-TS segments derived from the H.264 baseline. This is a deterministic transport-format fixture, not evidence of Ferrex transcoding parity. | +| `malformed-truncated.mkv` | Truncated EBML input that `ffprobe` must reject. | +| `unsupported.txt` | Non-media input that `ffprobe` must reject. | +| `sources/` | Generated ASS, SRT, chapter metadata, and raw SUP inputs for inspection and external-subtitle tests. | + +The HDR10 and HLG files test metadata handling and native-output behavior. Their synthetic pattern is **not** a mastering-quality visual reference and cannot establish display accuracy by itself. + +## Authenticated HTTP range and HLS transport + +Generate the fixtures, choose an ephemeral secret, and start the loopback-only server. The token is read from the environment rather than a command-line argument. + +```bash +export FERREX_FIXTURE_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" +./scripts/qa/native_playback_fixture_server.py --port 8000 +``` + +The default is bearer authentication. It supports `HEAD`, `GET`, and a single standards-style byte range, including open-ended and suffix ranges. It does not list directories and refuses to serve an unmarked root. + +Verify authorization and range handling: + +```bash +curl --fail \ + -H "Authorization: Bearer ${FERREX_FIXTURE_TOKEN}" \ + -H 'Range: bytes=0-1023' \ + http://127.0.0.1:8000/h264-sdr-8bit.mkv \ + --output /tmp/ferrex-range.bin +``` + +The server selects an ephemeral port by default. For automation, use a private ready file: + +```bash +ready_dir="$(mktemp -d)" +chmod 700 "$ready_dir" +port_file="$ready_dir/port" +FERREX_FIXTURE_TOKEN="$FERREX_FIXTURE_TOKEN" \ + ./scripts/qa/native_playback_fixture_server.py --port-file "$port_file" & +server_pid=$! +cleanup_fixture_server() { + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + rm -rf "$ready_dir" +} +trap cleanup_fixture_server EXIT +for _ in {1..100}; do + [[ -s "$port_file" ]] && break + if ! kill -0 "$server_pid" 2>/dev/null; then + wait "$server_pid" 2>/dev/null || true + echo "fixture server exited before publishing its port" >&2 + exit 1 + fi + sleep 0.1 +done +[[ -s "$port_file" ]] || { + echo "fixture server did not publish its port within 10 seconds" >&2 + exit 1 +} +port="$(<"$port_file")" +[[ "$port" =~ ^[0-9]+$ ]] || { + echo "fixture server published an invalid port" >&2 + exit 1 +} +``` + +For the real libmpv native-window smoke (requires a working desktop VO): + +```bash +FERREX_MPV_SMOKE_URL="http://127.0.0.1:${port}/h264-sdr-8bit.mkv" \ +FERREX_MPV_SMOKE_AUTHORIZATION="Bearer ${FERREX_FIXTURE_TOKEN}" \ +cargo test -p ferrex-player-playback --features mpv \ + mpv_adapter::tests::linked_native_window_load_control_fullscreen_stop_and_close_smoke \ + -- --ignored --exact --nocapture +``` + +Use `transcoded-hls/index.m3u8` as the URL to exercise bearer propagation +across playlist and segment requests. To exercise the complete local +track/chapter/edition control path, run the same ignored test with +`FERREX_MPV_SMOKE_MEDIA=target/native-playback-fixtures/multitrack-structure.mkv`; +selectors are tested only when the loaded fixture advertises the corresponding +catalog. The smoke also applies an identity native-VO shader, confirms its +redacted observed count, writes/removes a screenshot, and clears the shader. + +Run subtitle formats separately with `ass-animation-fonts.mkv` and +`pgs-bitmap.mkv`. To add and select a local sidecar through the public contract, +use: + +```bash +FERREX_MPV_SMOKE_MEDIA=target/native-playback-fixtures/h264-sdr-8bit.mkv \ +FERREX_MPV_SMOKE_EXTERNAL_SUBTITLE=target/native-playback-fixtures/sources/english.srt \ +cargo test -p ferrex-player-playback --features mpv \ + mpv_adapter::tests::linked_native_window_load_control_fullscreen_stop_and_close_smoke \ + -- --ignored --exact --nocapture +``` + +The test requires the new track to be selected, text-kind, and marked external; +local paths and temporary screenshot/shader paths are filtered from copied mpv +logs. `--auth query` exists only for the retained legacy compatibility path; +query credentials do not automatically propagate into relative HLS segment +URLs. Logs omit query strings and redact the configured token. + +## Local Ferrex server acceptance + +The loopback fixture server isolates client transport behavior. It does **not** +satisfy the Ferrex-server direct-play or transcode acceptance gates. + +For server-backed acceptance: + +1. mount or copy `target/native-playback-fixtures/` into a local test library; +2. scan the library and record the resulting media IDs; +3. request the normal playback ticket/source through the Ferrex API; +4. test direct play with `h264-sdr-8bit.mkv`, `multitrack-structure.mkv`, and + both HDR-signaled files; +5. force the local server's transcode profile and verify its returned manifest + and every segment use the same playback-scoped authorization policy; and +6. run the UI/native-window path through next episode, stop, EOF, and navigation + while retaining redacted client and server diagnostics. + +Feature-gated ignored tests provide reproducible direct-play, +generated-transcode, and protected-HLS transport paths through isolated +PostgreSQL databases and real network-bound Ferrex routers. The direct test +issues the normal account session and playback-scoped ticket, opens the +protected stream through the +backend-neutral native-mpv session, and confirms metadata/resume, pause, +authenticated range seek, shader and screenshot commands, diagnostics +redaction, and ordered stop: + +```bash +./scripts/dev/sqlx-db.sh start +set -a +source .env.sqlx +set +a +DATABASE_URL="$DATABASE_URL_ADMIN" \ + cargo test -p ferrex-server --features native-mpv-e2e \ + --test playback_stream_failures \ + playback_ticket_drives_display_backed_native_mpv_through_ferrex_router \ + -- --ignored --exact --nocapture --test-threads=1 +``` + +The default input is `h264-sdr-8bit.mkv`; set +`FERREX_MPV_SERVER_SMOKE_MEDIA` to another generated file. + +The HLS transport test rewrites only the generated fixture's local segment +references to credential-free protected Ferrex stream routes. It requires one +header-carried playback ticket on the manifest and every segment, checks +unauthenticated rejection and HLS MIME types, and then runs the same real +native-mpv lifecycle: + +```bash +./scripts/dev/sqlx-db.sh start +set -a +source .env.sqlx +set +a +DATABASE_URL="$DATABASE_URL_ADMIN" \ + cargo test -p ferrex-server --features native-mpv-e2e \ + --test playback_stream_failures \ + playback_ticket_propagates_to_every_router_backed_hls_segment \ + -- --ignored --exact --nocapture --test-threads=1 +``` + +Set `FERREX_MPV_SERVER_SMOKE_HLS` to use another local VOD manifest. Relative +media references must remain beneath its directory; remote, absolute, and +traversal references fail closed. + +The generated-transcode test submits the `360p` profile to the real bounded +FFmpeg job provider, waits for atomic publication, checks unauthenticated +rejection and the playback ticket on the manifest and every segment, verifies +cached reuse, and loads the resulting protected playlist through native mpv: + +```bash +./scripts/dev/sqlx-db.sh start +set -a +source .env.sqlx +set +a +DATABASE_URL="$DATABASE_URL_ADMIN" \ + cargo test -p ferrex-server --features native-mpv-e2e \ + --test playback_stream_failures \ + server_generated_transcode_plays_through_display_backed_native_mpv \ + -- --ignored --exact --nocapture --test-threads=1 +``` + +Set `FERREX_MPV_SERVER_TRANSCODE_MEDIA` to transcode another generated local +fixture. The test requires FFmpeg with `libx264` and AAC encoders in addition +to the direct-play test's requirements. + +These tests use the production router, ticket service, transcode manager, +stream handlers, and real HTTP/libmpv boundary. The pre-generated HLS run +isolates protected manifest/segment transport, while the generated run covers +bounded FFmpeg generation, atomic publication, cached reuse, protected assets, +and display-backed loading as one lifecycle. The normal display-free route and +manager tests continue to cover job ownership and failure behavior. A manual +player quality-picker run and UI episode-transition acceptance remain in the +live-server gate above. + +Follow the [playback authentication regression procedure](/reference/qa/playback-auth-regression/) +for ticket lifecycle and retained-artifact checks. Record server revision, +profile, source media ID, returned container/codecs, and whether playback was +direct or transcoded. Never retain the raw ticket, authorization header, cookie, +or unredacted mpv log. + +## Native-window episode replacement + +The ignored playback-domain smoke lets the first synthetic episode reach real +native-mpv EOF, verifies final progress and the backend-preserving next-episode +request, then drives the normal `SetStreamSource` close/reopen path and requires +the second episode to start in a newer mpv session generation: + +```bash +FERREX_MPV_SMOKE_MEDIA="$PWD/target/native-playback-fixtures/h264-sdr-8bit.mkv" \ + cargo test -p ferrex-player-playback --features mpv \ + update::tests::linked_native_window_eof_reloads_next_episode_with_same_backend \ + -- --ignored --exact --nocapture --test-threads=1 +``` + +This combines the real native-VO lifecycle with the backend-neutral episode +reducer. The outer repository selection, ticket-resolution task, and visible +player-shell transition remain part of the manual live-server UI gate. + +## Native-window lifecycle stress + +The ignored linked-libmpv stress test creates a fresh in-process mpv core and +native window for every cycle, waits for playback and native-VO configuration, +issues an ordered stop, confirms the terminal event, and tears the owner down. +It defaults to the 100 cycles required by the native-presentation gate: + +```bash +FERREX_MPV_SMOKE_MEDIA="$PWD/target/native-playback-fixtures/h264-sdr-8bit.mkv" \ +FERREX_MPV_STRESS_CYCLES=100 \ +FERREX_MPV_STRESS_MAX_RSS_GROWTH_MIB=64 \ +FERREX_MPV_STRESS_MAX_FD_GROWTH=4 \ + cargo test -p ferrex-player-playback --features mpv \ + mpv_adapter::tests::linked_native_window_load_stop_lifecycle_stress \ + -- --ignored --exact --nocapture --test-threads=1 +``` + +On Linux the test reports first-cycle baseline, final, and peak resident memory +and open file descriptors. The two optional limit variables make excessive +final growth fail the run; choose and record a reviewed platform budget rather +than silently loosening it. Use a smaller `FERREX_MPV_STRESS_CYCLES` only to +validate the harness. A platform gate still requires one uninterrupted +100-cycle run while separately monitoring native/GPU memory and window-system +resources; save its redacted output and resource samples under the environment's ignored +`target/native-playback-results/` run directory. This generic native-window +job does not replace the Windows HWND or macOS AppKit presenter-specific +100-cycle gates. + +## Windows and macOS integrated-presenter handoff + +The Windows and macOS implementations have an explicit `spike` build gate so +they can be exercised on representative systems without changing the Auto +backend policy. In a spike build, **Play in MPV** requests +`mpv-integrated`: mpv owns the native video window and Iced attaches a hidden, +transparent controls window after both native handles are ready. A failed +preflight or attachment records a structured reason and returns to +`mpv-native-window` while GStreamer remains available for rollback. + +"Handoff ready" here means the target code, deterministic fallback, source +builders, package staging, and display-free tests are present. It does **not** +mean the production/Auto gate has passed. Keep that gate closed until the +hardware observations below and the clean-package smoke have been recorded. + +### Windows test build + +The canonical, provenance-recorded path is the `Windows Dist` workflow. It +builds the pinned LGPL libmpv SDK, generates the MSVC import library, compiles +the selected presenter mode, stages only the reviewed GStreamer 1.28.4 plugin +roots plus their recursive PE/GIO/TLS closure, and audits H.264/AAC HLS and +strict HTTPS from the clean stage before uploading the zip. Runtime DLL owners, +versions, hashes, and notices are recorded; the floating Rust/MSYS2 build-tool +selection remains visible in workflow logs rather than pinned as a bit-for-bit +reproducible toolchain. + +Use the uploaded artifact for the clean-host handoff run. From a PowerShell +shell with GitHub CLI authentication: + +```powershell +$ref = '' +$expectedSha = (git rev-parse $ref).Trim() +gh workflow run windows-dist.yml --ref $ref ` + -f profile=release -f presenter_mode=spike +gh run list --workflow windows-dist.yml --event workflow_dispatch ` + --commit $expectedSha --limit 5 +# Select the spike run dispatched above, not merely the newest repository run. +$run = '' +$actualSha = (gh run view $run --json headSha | ConvertFrom-Json).headSha +if ($actualSha -ne $expectedSha) { throw "Run revision mismatch: $actualSha" } +gh run watch $run --exit-status +$spikeDir = Join-Path 'target\windows-handoff' $run +if (Test-Path -LiteralPath $spikeDir) { + throw "Refusing to reuse handoff directory: $spikeDir" +} +$null = New-Item -ItemType Directory -Path $spikeDir +gh run download $run --name ferrex-player-windows-spike ` + --dir $spikeDir +$zips = @(Get-ChildItem -LiteralPath $spikeDir -File -Filter '*.zip') +if ($zips.Count -ne 1) { + throw "Expected exactly one zip in $spikeDir, found $($zips.Count)" +} +$zip = $zips[0] +Get-FileHash $zip.FullName -Algorithm SHA256 +$appDir = Join-Path $spikeDir 'app' +Expand-Archive -LiteralPath $zip.FullName -DestinationPath $appDir +$localAppData = if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $env:TEMP +} else { + $env:LOCALAPPDATA +} +$registry = Join-Path $localAppData 'Ferrex\gstreamer-1.0\registry.bin' +if (Test-Path -LiteralPath $registry) { + throw "Clean-user prerequisite failed; archive or remove $registry first" +} +cmd /c "$appDir\run-ferrex.bat" "http://your-ferrex-server:3000" +``` + +Run that launcher, rather than the executable or downloaded PowerShell script +directly: the batch launcher is not subject to PowerShell execution policy and +selects only the bundled plugins, GIO TLS modules, registry, CA bundle, and +`gst-plugin-scanner`. Perform the artifact run from a clean Windows user or VM +without another GStreamer or mpv directory on `PATH`, and retain the displayed +zip hash, run ID, and verified `headSha` with the result record. + +For the required disabled-presenter control, dispatch the same revision again +and retain its separately closure-audited artifact: + +```powershell +gh workflow run windows-dist.yml --ref $ref ` + -f profile=release -f presenter_mode=disabled +gh run list --workflow windows-dist.yml --event workflow_dispatch ` + --commit $expectedSha --limit 5 +$disabledRun = '' +$disabledSha = (gh run view $disabledRun --json headSha | ConvertFrom-Json).headSha +if ($disabledSha -ne $expectedSha) { throw "Run revision mismatch: $disabledSha" } +gh run watch $disabledRun --exit-status +$disabledDir = Join-Path 'target\windows-disabled-handoff' $disabledRun +if (Test-Path -LiteralPath $disabledDir) { + throw "Refusing to reuse handoff directory: $disabledDir" +} +$null = New-Item -ItemType Directory -Path $disabledDir +gh run download $disabledRun --name ferrex-player-windows-disabled ` + --dir $disabledDir +$disabledZips = @( + Get-ChildItem -LiteralPath $disabledDir -File -Filter '*.zip' +) +if ($disabledZips.Count -ne 1) { + throw "Expected exactly one zip in $disabledDir, found $($disabledZips.Count)" +} +$disabledZip = $disabledZips[0] +Get-FileHash $disabledZip.FullName -Algorithm SHA256 +$disabledAppDir = Join-Path $disabledDir 'app' +Expand-Archive -LiteralPath $disabledZip.FullName ` + -DestinationPath $disabledAppDir +$modeFile = Join-Path $disabledAppDir ` + 'share\ferrex-player\PRESENTER_BUILD_MODE' +$mode = (Get-Content -LiteralPath $modeFile -Raw).Trim() +if ($mode -ne 'disabled') { throw "Unexpected presenter mode: $mode" } +if (Test-Path -LiteralPath $registry) { + $evidenceDir = Join-Path $disabledDir 'evidence' + $null = New-Item -ItemType Directory -Path $evidenceDir + Move-Item -LiteralPath $registry ` + -Destination (Join-Path $evidenceDir 'registry-after-spike.bin') +} +if (Test-Path -LiteralPath $registry) { + throw "Spike GStreamer registry was not isolated: $registry" +} +cmd /c "$disabledAppDir\run-ferrex.bat" "http://your-ferrex-server:3000" +``` + +Complete the spike cases below and quit the app before running the disabled +block. The commands refuse stale download/extraction directories, require one +archive, and move the spike GStreamer registry out of the fixed per-user path +before the disabled launch. A separate clean user is also acceptable. Do not +reuse either extraction tree or registry evidence. Tag-triggered Windows +artifacts force `disabled`; the unapproved spike cannot become a release +attachment. + +For a local MSVC test, first build the SDK from an MSYS2 UCRT64 shell as +documented in `utils/build-windows/build-libmpv-lgpl.sh`, then run this from a +PowerShell developer shell. The installer helper downloads only the pinned +official GStreamer 1.28.4 SDK and verifies its recorded SHA-256: + +```powershell +$root = 'C:\ferrex-libmpv-sdk' +& .\utils\build-windows\new-libmpv-import-library.ps1 -SdkRoot $root +$dll = Get-ChildItem (Join-Path $root 'bin') -File | + Where-Object { $_.Name -in @('libmpv-2.dll', 'mpv-2.dll', 'mpv.dll') } | + Select-Object -First 1 +if (-not $dll) { throw "libmpv runtime DLL is missing from $root\bin" } + +$env:LIBMPV_ROOT = $root +$env:LIBMPV_LIB_DIR = Join-Path $root 'lib' +$env:LIBMPV_INCLUDE_DIR = Join-Path $root 'include' +$env:LIBMPV_DLL_DIR = Join-Path $root 'bin' +$env:LIBMPV_DLL = $dll.FullName +$gst = Join-Path (Get-Location) 'target\gstreamer-msvc-x86_64' +& .\utils\build-windows\install-gstreamer.ps1 -Destination $gst +$env:GSTREAMER_1_0_ROOT_MSVC_X86_64 = $gst +$env:PKG_CONFIG = Join-Path $gst 'bin\pkg-config.exe' +$env:PKG_CONFIG_PATH = Join-Path $gst 'lib\pkgconfig' +$env:FERREX_MPV_WINDOWS_PRESENTER = 'spike' +$env:PATH = "$(Join-Path $root 'bin');$(Join-Path $gst 'bin');$env:PATH" +cargo run -p ferrex-player --features mpv +``` + +The environment value is consumed at compile time. Rebuild after changing it. +Do not substitute a locally installed default mpv build for release evidence. + +### macOS test build + +The canonical, provenance-recorded path is the `macOS App Bundle` workflow, +which builds both Apple Silicon and Intel artifacts. mpv, FFmpeg, libplacebo, +libass, and Lua 5.2 sources are pinned; Homebrew build/runtime inputs are +version/hash recorded and rejected if their expected GStreamer profile drifts. +The workflow rewrites the complete dylib/GIO/trust closure to bundle-relative +paths, signs nested code, and performs strict clean-bundle HTTP/HTTPS HLS +playback. This engineering handoff artifact explicitly requires macOS 15 or +newer. Use the uploaded app—not a raw Cargo binary—for clean-bundle, Dock, +fullscreen, GStreamer rollback, and runtime-path evidence: + +```bash +ref='' +expected_sha="$(git rev-parse "$ref")" +gh workflow run macos-dist.yml --ref "$ref" -f presenter_mode=spike +gh run list --workflow macos-dist.yml --event workflow_dispatch \ + --commit "$expected_sha" --limit 5 +# Select the spike run dispatched above and use arm64 or x86_64 for this Mac. +run='' +arch="$(uname -m)" +actual_sha="$(gh run view "$run" --json headSha --jq .headSha)" +test "$actual_sha" = "$expected_sha" +gh run watch "$run" --exit-status +spike_dir="target/macos-handoff/$run" +test ! -e "$spike_dir" || { + echo "refusing to reuse handoff directory: $spike_dir" >&2 + exit 1 +} +mkdir -p "$spike_dir" +gh run download "$run" --name "ferrex-player-macos-$arch-spike" \ + --dir "$spike_dir" +(cd "$spike_dir" && shasum -a 256 --check ./*.sha256) +archive_count="$(find "$spike_dir" -maxdepth 1 -type f -name '*.zip' | + wc -l | tr -d '[:space:]')" +test "$archive_count" -eq 1 || { + echo "expected exactly one zip in $spike_dir, found $archive_count" >&2 + exit 1 +} +archive="$(find "$spike_dir" -maxdepth 1 -type f -name '*.zip' -print)" +ditto -x -k "$archive" "$spike_dir/app" +registry="$HOME/Library/Caches/io.github.lowband21.FerrexPlayer/gstreamer-registry-1.0.bin" +test ! -e "$registry" || { + echo "clean-user prerequisite failed; archive or remove $registry first" >&2 + exit 1 +} +open "$spike_dir/app/Ferrex Player.app" +``` + +Use a clean macOS user with Homebrew library paths, `DYLD_*`, `GST_*`, and +`VK_*` overrides unset. At least one representative run must use a host or VM +without Homebrew installed, rather than relying only on a clean account on the +build host. The app must obtain libmpv, MoltenVK, the GStreamer +plugins/scanner, GIO TLS module, CA trust database, and their closure from +`Contents` only. An unsigned workflow run is ad-hoc signed for engineering +handoff rather than notarized for public distribution; preserve the verified +archive hash, run ID, `headSha`, signing output, and closure/HLS audit output. + +Complete the spike cases below and quit the app before continuing. Dispatch +`presenter_mode=disabled` at the same `ref` for the fallback control, verify +that run's `headSha`, and download `ferrex-player-macos-$arch-disabled`. +Tag-triggered artifacts force the disabled mode but remain Actions artifacts; +this engineering workflow never attaches macOS artifacts to a GitHub Release. +Public distribution requires a separate Developer ID signing and notarization +workflow. Verify and launch the disabled artifact from its own directory: + +```bash +gh workflow run macos-dist.yml --ref "$ref" -f presenter_mode=disabled +gh run list --workflow macos-dist.yml --event workflow_dispatch \ + --commit "$expected_sha" --limit 5 +# Select the disabled run dispatched above. +disabled_run='' +disabled_sha="$(gh run view "$disabled_run" --json headSha --jq .headSha)" +test "$disabled_sha" = "$expected_sha" +gh run watch "$disabled_run" --exit-status +disabled_dir="target/macos-disabled-handoff/$disabled_run" +test ! -e "$disabled_dir" || { + echo "refusing to reuse handoff directory: $disabled_dir" >&2 + exit 1 +} +mkdir -p "$disabled_dir" +gh run download "$disabled_run" \ + --name "ferrex-player-macos-$arch-disabled" --dir "$disabled_dir" +(cd "$disabled_dir" && shasum -a 256 --check ./*.sha256) +disabled_archive_count="$(find "$disabled_dir" -maxdepth 1 -type f \ + -name '*.zip' | wc -l | tr -d '[:space:]')" +test "$disabled_archive_count" -eq 1 || { + echo "expected exactly one zip in $disabled_dir, found $disabled_archive_count" >&2 + exit 1 +} +disabled_archive="$(find "$disabled_dir" -maxdepth 1 -type f \ + -name '*.zip' -print)" +ditto -x -k "$disabled_archive" "$disabled_dir/app" +test "$(cat "$disabled_dir/app/Ferrex Player.app/Contents/Resources/presenter-build-mode.txt")" = disabled +if test -e "$registry"; then + mkdir -p "$disabled_dir/evidence" + mv "$registry" "$disabled_dir/evidence/registry-after-spike.bin" +fi +test ! -e "$registry" +open "$disabled_dir/app/Ferrex Player.app" +``` + +Run the same fallback and Auto cases against this copy. The commands refuse +stale directories, require one archive, and archive the spike registry before +the disabled launch. A separate clean user is also acceptable; never reuse the +spike bundle or registry evidence. + +For a local development run, install the native build prerequisites listed in +that workflow and choose a new empty prefix: + +```bash +prefix="$PWD/target/ferrex-libmpv-macos" +export MACOSX_DEPLOYMENT_TARGET=15.0 +bash scripts/release/macos-build-libmpv.sh "$prefix" + +export PKG_CONFIG_PATH="$prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" +export LIBRARY_PATH="$prefix/lib${LIBRARY_PATH:+:$LIBRARY_PATH}" +export DYLD_FALLBACK_LIBRARY_PATH="$prefix/lib${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" +export FERREX_MPV_MACOS_PRESENTER=spike +cargo run -p ferrex-player --features mpv +``` + +The prefix builder refuses to install over a non-empty directory. The +presenter build value is compile-time state, so rebuild after changing it. +Normal AppKit object access stays on the Iced/AppKit main thread; blocking +libmpv teardown is handed to the named background reaper only after the child +window has detached. + +### Representative-system procedure + +Generate and verify the fixtures first. Test a normal direct Ferrex stream and +at least `h264-sdr-8bit.mkv`, `multitrack-structure.mkv`, +`ass-animation-fonts.mkv`, `pgs-bitmap.mkv`, `hdr10-pq.mkv`, and `hlg.mkv`. +Run the SDR cases on every environment and the HDR/EDR cases only on a capable +display with the OS HDR state recorded. + +For every run: + +1. Start Ferrex with the spike compiled in and use **Play in MPV**. Confirm + diagnostics report requested/selected `mpv-integrated`, then + `presenter_state=attached`, with no fallback reason. Retain the pointer-free + `native player overlay handoff:` debug transition log plus a native-window + trace or screen recording proving this order: hidden controls host + allocated; presenter attached and positioned to mpv's content rectangle; + retained main window hide completed; presenter host made visible; overlay + focus confirmed. The serialized diagnostic snapshot supplies presenter + state/geometry; it does not by itself prove Iced hide/focus delivery. Any early + `ShowWindow`/`orderFront`, stale main-window resize/move after attachment, or + visible flash fails the run. +2. Confirm there is one active player identity in the Windows taskbar and + Alt-Tab list, or one Ferrex application identity in the macOS Dock and app + switcher. No second blank or permanently hidden window may remain after + stop. +3. Move and resize continuously, cross monitors, change Windows per-monitor + DPI or macOS backing scale, minimize/restore, hide/unhide the app, and cover + then uncover it. The overlay must follow the video content rectangle without + drift, stale controls, focus theft, or visible startup flash. Include one + independent-viewport case: retain a 1280×720 main-window snapshot while the + native content/overlay becomes a materially different size and aspect (for + example 1024×768) and scale. Control layout, focus geometry, progress-bar + bounds, and pointer hit targets must follow the live overlay viewport rather + than the retained main size. +4. Exercise mouse, keyboard, seek, pause, volume, track/subtitle selection, + settings, next/previous episode, and Back/Home. In integrated mode Iced is + the input owner; mpv OSC/default bindings must not compete with it. +5. Enter and leave native fullscreen repeatedly. mpv owns the transition and + Ferrex must wait for the observed fullscreen property before changing its + snapshot. On macOS, repeat across Spaces and during the native animation. +6. Compare the HDR/EDR fixture with controls continuously visible, controls + hidden, and repeated overlay show/hide. Record the reported input/output + color parameters rather than inferring HDR from the filename or backend. +7. Record `current-vo`, GPU context/API, adapter, `hwdec-current`, frame/drop + counters, and the presenter geometry/scale. VideoToolbox on macOS and + D3D11VA/DXVA2 on Windows are observations, not assumptions. +8. Test normal stop, EOF, native window close, overlay close, app quit, reload, + and an immediate second playback. Native relationships must detach before + either host is destroyed, and shutdown must not hang the AppKit main loop. +9. Validate fallback with a build whose presenter gate is `disabled`, and + capture the structured transition to `mpv-native-window`. Playback must + remain controllable and the hidden Iced overlay must be dismissed. From the + same spike and disabled artifacts, select Auto and actually play the direct + Ferrex SDR stream and `h264-sdr-8bit.mkv` through EOF with seek, pause, + audio, and stop working. Then select a non-original quality profile so the + local Ferrex server generates its protected HLS rendition; wait for the job + to complete and verify Auto reloads the credential-free manifest URI with + its playback-scoped header and plays every protected segment. Repeat that + application path through an HTTPS Ferrex base URL whose hostname and + certificate chain are trusted by the bundled Mozilla CA set (a loopback + self-signed certificate is not this test). Keep the authenticated-HTTP and + strict-HTTPS results separate. Confirm Auto selects GStreamer, and correlate + the run with the same artifact's closure-audit output proving its packaged + H.264/AAC, demux, network/TLS, audio-sink, and appsink factories; runtime + diagnostics do not enumerate factories. Selection or the helper-only HTTPS + smoke alone is not a rollback pass. +10. After exploratory checks pass, complete one uninterrupted 100-cycle + load/attach/fullscreen/stop/close run. Monitor process, native-window, and + GPU resources against the provisional budget below; the lower-level + native-window stress test above does not exercise the attached overlay. + +### Presenter stress budget + +Declare the following provisional budget before the 100-cycle run. Establish +the baseline after cycle 10 and 30 seconds of quiescence. Sample again after +cycles 20–80 in ten-cycle increments, after every cycle from 81–100, and after +a final 30-second quiescence. Do not restart the process between samples. + +- Final working set/RSS, private committed bytes, and virtual address-space + size must each be no more than 64 MiB above the stabilized baseline; the + post-baseline peak must be no more than 128 MiB above it. Windows + `VirtualMemorySize64` and macOS VSZ are address-space measurements, not + committed-memory measurements. +- Windows handle count must finish at no more than baseline +8. macOS open file + descriptors must finish at no more than baseline +4. +- GPU process memory must return to no more than baseline +64 MiB after final + quiescence, with no live decoder, swapchain, or video texture from a stopped + generation. +- Native player/overlay window count and ownership/child relationships must + return exactly to baseline after every stop. Any orphan window, second + taskbar/Dock identity, or stale native relationship fails immediately. +- The final 20 quiescent samples must not be monotonically non-decreasing with + a net increase of at least 1 MiB, one handle/FD, or one native/GPU object. + Any limit breach, crash, hang, fallback, or diagnostics from a stale + generation fails the run. + +On Windows, capture the process counters at each sample with this PowerShell +snippet and save the objects as CSV; use Task Manager, Process Explorer, PIX, +or an equivalent reviewed tool for GPU and HWND relationship evidence: + +```powershell +$p = Get-Process ferrex-player -ErrorAction Stop +[pscustomobject]@{ + Utc = [DateTime]::UtcNow.ToString('o') + WorkingSet64 = $p.WorkingSet64 + PrivateMemorySize64 = $p.PrivateMemorySize64 + VirtualMemorySize64 = $p.VirtualMemorySize64 + HandleCount = $p.HandleCount + MainWindowHandle = $p.MainWindowHandle +} +``` + +On macOS, record RSS/VSZ and open descriptors at the same cadence, and use +Activity Monitor plus Instruments/Quartz Debug (or an equivalent reviewed +tool) for GPU and AppKit child-window evidence: + +```bash +pid="$(pgrep -n ferrex-player)" +date -u +%Y-%m-%dT%H:%M:%SZ +ps -o pid=,rss=,vsz= -p "$pid" +lsof -a -p "$pid" -Ff | awk '/^f[0-9]+$/ { count++ } END { print count+0 }' +``` + +### Result record and pass boundary + +Create one directory per run beneath +`target/native-playback-results///`. Save a short +`summary.md`, redacted Ferrex output, package/fixture hashes, resource samples, +and screenshots or screen recordings only when they contain no private media, +server address, account name, token, header, cookie, or machine-identifying +path. A useful diagnostic launch is: + +```bash +FERREX_MPV_LOG_LEVEL=trace \ +RUST_LOG=ferrex_player_playback=trace,ferrex_player_mpv=trace,ferrex_player_ui=debug \ + cargo run -p ferrex-player --features mpv +``` + +Use the equivalent PowerShell environment variables on Windows. The run passes +the **handoff validation** when all required operations succeed, the package +closure audit passes on the same artifact, no pointer/handle or credential is +present in retained diagnostics, and every predeclared 100-cycle resource +budget above passes. Only then update the P5/P6 production decision; HDR/EDR, +hardware-decoding, and Auto capability flags must reflect the recorded +evidence instead of the build target. + +## Initial platform and protocol inventory + +Use stable environment IDs in results instead of hostnames or user names. The initial inventory requires at least these classes before an Auto rollout decision: + +| Environment ID | Window system/compositor | GPU/driver class | Display gate | Primary purpose | +|---|---|---|---|---| +| `wl-wlroots-amd` | wlroots/Hyprland family | AMD Mesa | SDR + HDR-capable output when available | Wayland bridge, dmabuf, explicit sync, color management | +| `wl-kde-intel` | KDE Wayland | Intel Mesa | SDR and fractional scale | configure/scale/output transitions | +| `wl-gnome-intel` | GNOME Wayland | Intel Mesa | SDR | protocol compatibility and fallback | +| `wl-nvidia` | supported Wayland compositor | NVIDIA proprietary | SDR + HDR when available | interop and explicit-sync behavior | +| `x11-composited` | X11 with compositor | any supported GPU | SDR | transparent overlay presenter | +| `x11-uncomposited` | X11 without compositor | any supported GPU | SDR | deterministic `wid`/native-window fallback | +| `windows-sdr` | supported Windows | Intel/AMD/NVIDIA | SDR | HWND, DPI, focus, taskbar, gpu-next | +| `windows-hdr` | supported Windows | HDR-capable adapter | HDR enabled | overlay-visible/hidden HDR gate | +| `macos-apple` | macOS 15+ | Apple Silicon | SDR + EDR when available | AppKit, Spaces, fullscreen, VideoToolbox | +| `macos-intel` | macOS 15+ | Intel | SDR | fallback and teardown compatibility | + +A run record must include: + +- Ferrex revision and fixture `manifest.json` plus `SHA256SUMS` hashes; +- mpv/client API, FFmpeg, libplacebo, VO, GPU context/API, adapter, and `hwdec-current`; +- OS, kernel/build, window system, compositor, GPU, driver, monitor, refresh rate, scale, and HDR state; +- direct versus transcoded source and selected backend/presenter; +- pass/fail for load, first frame, pause, seek, tracks, subtitles, chapters, resize, hide/show, fullscreen, stop, close, and fallback; and +- links to redacted logs, performance samples, and protocol traces. + +Store local run artifacts under `target/native-playback-results///`. Keep a small redacted summary in review documentation when it supports a rollout decision; do not commit large media, traces, or machine-identifying dumps. + +### Wayland trace matrix + +Capture only on a dedicated test environment and strip sensitive titles/paths. The P7 spike must correlate these fixture operations with protocol traffic: + +| Operation | Required protocol evidence | +|---|---| +| Initial map and first frame | registry bindings, surface creation, shell-role virtualization, configure/ack, buffer attach, frame/presentation callback | +| Resize and fractional scale | parent geometry revision, synthetic configure, viewport/buffer scale, output enter/leave | +| HDR10/PQ and HLG | mpv-selected VO/hwdec plus compositor color-management/color-representation traffic | +| Pause/seek | independent native-VO cadence without Iced frame polling; explicit-sync/dmabuf release remains live | +| Fullscreen | Iced top-level transition and synthesized mpv state/configure, with no second real toplevel | +| Stop/VO reload | child role/object teardown before parent destruction and clean generation replacement | +| Missing optional global | explicit capability/fallback reason; no silent CPU frame path | + +A single successful compositor run is spike evidence only. D-022 currently selects HYBRID, so W1–W5 are deferred; any future P7 GO still requires the complete matrix and release packaging. + +Use the versioned `native_playback_wayland_trace.py` harness to capture the +initial map/control/fullscreen/VO-reload sequence against pinned mpv 0.41.0. +The [native mpv Wayland spike record](/developer/native-mpv-wayland-spike/) +documents the command, redacted artifact schema, initial protocol inventory, +and the current connection-redirection blocker. diff --git a/docs/src/content/docs/operator/configuration.md b/docs/src/content/docs/operator/configuration.md index aa7c3211..ab6af6aa 100644 --- a/docs/src/content/docs/operator/configuration.md +++ b/docs/src/content/docs/operator/configuration.md @@ -27,7 +27,14 @@ These are the most commonly used variables. See `.env.example` for the authorita - `DATABASE_URL` – Postgres connection URL (host/local use) plus `DATABASE_URL_CONTAINER` for in-container commands. - `REDIS_URL` – Redis connection URL (plus `REDIS_URL_CONTAINER` for in-container access). - `RUST_LOG` – Server logging filter, e.g. `sqlx=trace,ferrex=debug`. -- `FERREX_MPV_PATH` – Optional override for mpv path on Windows if auto‑detection fails. +- `FERREX_MPV_PATH` – Optional override for the separate external mpv executable, + primarily on Windows. It does not locate in-process libmpv. +- `FERREX_MPV_CONFIG_POLICY` – In an mpv-enabled developer build, either + `deterministic` (default) or `trusted-user`. Trusted-user config and scripts + execute inside the Ferrex process. +- `FERREX_MPV_LOG_LEVEL` – Optional fixed in-process libmpv message filter: + `none`, `fatal`, `error`, `warn`, `info`, `verbose`, `debug`, or `trace`. + Invalid values fail closed without being echoed. - TLS options – Paths can be provided via env (if you terminate TLS at the app). If you use a reverse proxy, terminate TLS there instead. - Player URL – Run the player against a custom server with `FERREX_SERVER_URL=https://host:port`. @@ -224,6 +231,13 @@ just start --rust-log 'sqlx=trace,ferrex=debug' Alternatively, set `RUST_LOG` directly in `.env`. +Desktop playback has an additional native-message filter. `RUST_LOG` controls +whether Ferrex emits a target, while `FERREX_MPV_LOG_LEVEL` controls which +messages libmpv sends to Ferrex. See +[Desktop playback backends](/developer/desktop-playback-backends/) for the +backend selector, fallback order, safe diagnostic procedure, and rollback +policy. + ## Demo Mode (Optional) Ferrex includes a feature‑gated demo mode that seeds disposable libraries for exploration and testing. See [Demo mode](/operator/demo-mode/) for full details and environment variables. diff --git a/docs/src/content/docs/reference/qa/playback-auth-regression.md b/docs/src/content/docs/reference/qa/playback-auth-regression.md index b4362a91..76f24d79 100644 --- a/docs/src/content/docs/reference/qa/playback-auth-regression.md +++ b/docs/src/content/docs/reference/qa/playback-auth-regression.md @@ -16,10 +16,10 @@ This packet records the playback-auth regression gate for the Ferrex server, des | Area | Disposition | Evidence / gap | | --- | --- | --- | | Rust format/check/core | Pass | `cargo fmt`, workspace `cargo check --all-targets`, and `ferrex-core` lib tests passed. Workspace check still emits existing `ferrexctl` missing-doc warnings. | -| Server playback auth integration | Pass | `crates/ferrex-server/tests/playback_stream_failures.rs` passed against a local Nix Postgres with `pg_uuidv7`: ticket issue, range streaming, missing/invalid token rejection, account API scope rejection, and typed media recovery headers. | +| Server playback auth integration | Pass | `crates/ferrex-server/tests/playback_stream_failures.rs` passed against a local Nix Postgres with `pg_uuidv7`: ticket issue, query and scoped-bearer range streaming, missing/invalid token rejection, account API scope rejection, and typed media recovery headers. | | Desktop GStreamer smoke | Pass | `scripts/qa/playback-auth-smoke.sh` served a local protected WAV fixture, verified bad tickets return 401, then completed `gst-launch-1.0 playbin` with fakesinks using the ticketed URL. | | Desktop MPV smoke | Pass | The same smoke completed `mpv --no-config --ao=null` against the ticketed URL, redacted the MPV log, and verified retained artifacts do not contain the raw ticket. | -| Desktop ticket URL / redaction unit coverage | Pass | `ferrex-player-playback` tests passed for ticketed URL resolution, fail-closed ticket errors, and access-token redaction in playback/MPV log lines. | +| Desktop ticket source / redaction unit coverage | Pass | `ferrex-player-playback` tests cover header-authenticated direct-stream source resolution, fail-closed ticket errors, legacy external-player URL conversion, and credential redaction in playback/mpv diagnostics. | | Android mobile / TV auth-expiry retry unit coverage | Pass | Focused Gradle unit command for `PlaybackFoundationTest.ticketAuthFailuresRetryThenInvalidateSession` returned `BUILD SUCCESSFUL` for mobile and TV tasks. | | Android mobile / TV manual playback | Blocked | `adb devices -l` returned no attached devices. The SDK emulator binary cannot start in this NixOS workspace because of the stub-ld dynamic-loader error. Physical phone/TV or a runnable emulator is still required for manual playback evidence. | | Android full assemble/unit/lint | Not rerun for LOW-415 | No Android source code changed in this packet. The most recent full Android evidence is recorded in [Android / Android TV final QA acceptance packet](/reference/qa/android-final-qa/); rerun the full Gradle gate if Android code changes. | @@ -109,13 +109,46 @@ FERREX_QA_KEEP_ARTIFACTS=1 nix develop .#ferrex-player --command bash scripts/qa Do not attach `mpv.raw.log`; the script deletes it after producing `mpv.redacted.log`. -### Desktop ticket URL and redaction units +### Desktop ticket source and redaction units + +The 2026-07-12 native-playback migration moved direct in-process ticket +transport from the query string to a redacted `PlaybackSource` Authorization +header. The explicit legacy external-player handoff still converts that source +to a temporary query-ticket URL at its compatibility boundary. A focused +2026-07-12 server integration rerun also passed after adding the exact +in-process transport case: a playback-scoped ticket in the bearer header +returned the requested `206` byte range, independently of the existing full +account-session bearer check. ```bash -nix develop .#ferrex-player --command env cargo test -p ferrex-player-playback resolve_playback_stream_url +nix develop .#ferrex-player --command env cargo test -p ferrex-player-playback resolve_playback_stream_source ``` -Result: pass; `2 passed; 0 failed; 4 filtered out`. +Result: pass; both header-transport and fail-closed source-resolution tests +passed. + +The formerly string-only streaming/HLS API source now uses a typed, +credential-free URI plus a zeroizing bearer header. Its focused tests cover +header transport, redacted diagnostics, query/userinfo rejection, header +injection rejection, and fail-closed ticket errors: + +```bash +cargo test -p ferrex-player-api services::streaming::tests +cargo test -p ferrex-player-ui --features unimplemented \ + domains::streaming::update_handlers::transcoding::tests::transcoded_source_reload_keeps_ticket_in_redacted_header_transport \ + -- --exact +``` + +On 2026-07-13 the ignored display-backed server acceptance also passed with the +generated transcoded-HLS fixture routed through protected real Ferrex stream +URLs. One playback-scoped bearer ticket authorized the manifest and all four +segments, unauthenticated segment reads returned `401`, manifest URLs remained +credential-free, and native mpv completed playback controls, seek, shader, +screenshot, diagnostics-redaction, and stop. This isolates router/HLS +transport from the separately tested bounded FFmpeg job provider. Current +start/status/assets route tests cover job ownership, cached publication, and a +playback-scoped ticket on every generated rendition asset; the representative +display-backed generated-HLS run remains a separate gate. ```bash nix develop .#ferrex-player --command env cargo test -p ferrex-player-playback redacts_access_token @@ -166,8 +199,8 @@ Results: Record device model, OS/API level, display mode, server build, media fixture, and redacted logs/screenshots for each manual run. -1. **Desktop UI, GStreamer path:** launch the player against a QA server, start playback from a detail page, verify the resolved stream URL is ticketed, pause/seek/exit, and confirm no raw ticket appears in Ferrex logs. -2. **Desktop UI, MPV hand-off:** use the same media item, launch "Play in MPV", confirm MPV opens the ticketed stream, close MPV, and confirm Ferrex/MPV retained logs are redacted. +1. **Desktop UI, GStreamer path:** launch the player against a QA server, start playback from a detail page, verify the in-process source URI has no `access_token` query value and the protected stream succeeds through its bearer header, pause/seek/exit, and confirm no raw ticket appears in Ferrex logs. +2. **Desktop UI, MPV hand-off:** use the same media item and launch “Play in MPV.” In a build with the `mpv` feature, confirm the in-process libmpv native window opens the ticketed stream, pause/seek/stop work, and closing the native window returns control to Ferrex. In a backend-disabled build, confirm the action retains the external-process fallback. Also exercise the explicit external-player hand-off from the player controls. Confirm all retained Ferrex/mpv logs are redacted. 3. **Short ticket expiry:** configure a short playback-ticket TTL or use an expired ticket fixture. Verify server returns 401/403, clients retry where supported, and recovery remains actionable after retry limits without clearing app data. 4. **Android phone:** install `app-mobile-debug.apk`, sign in, play/resume/start-over, force ticket/session expiry, and verify retry then sign-in/change-server recovery without OS app-data wipe. 5. **Android TV:** install `app-tv-debug.apk`, repeat the phone playback/expiry cases using D-pad/OK/Back, and verify focus remains reachable on playback recovery actions. @@ -183,6 +216,8 @@ Record device model, OS/API level, display mode, server build, media fixture, an - `crates/ferrex-server/tests/playback_stream_failures.rs` - `crates/ferrex-player-playback/src/update.rs` - `crates/ferrex-player-playback/src/diagnostics.rs` +- `crates/ferrex-player-playback/src/external_mpv.rs` +- `crates/ferrex-player-playback/src/mpv_adapter.rs` - `mobile/android/app/src/test/kotlin/com/ferrex/android/core/playback/PlaybackFoundationTest.kt` - [Android / Android TV final QA acceptance packet](/reference/qa/android-final-qa/) - [Android playback QA matrix](/reference/qa/android-playback-matrix/) From 019aff044a4f662b5cec51f0ed0d881e7f15952f Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 10:55:47 -0600 Subject: [PATCH 15/56] fix(macos): support foreign AppKit view hosts in winit --- Cargo.lock | 2 - Cargo.toml | 1 + third-party/winit-0.30.13-ferrex/Cargo.toml | 553 ++++ .../winit-0.30.13-ferrex/Cargo.toml.orig | 378 +++ third-party/winit-0.30.13-ferrex/FEATURES.md | 248 ++ .../winit-0.30.13-ferrex/FERREX-PATCH.md | 63 + third-party/winit-0.30.13-ferrex/LICENSE | 201 ++ third-party/winit-0.30.13-ferrex/README.md | 70 + third-party/winit-0.30.13-ferrex/build.rs | 27 + .../winit-0.30.13-ferrex/src/application.rs | 339 +++ .../winit-0.30.13-ferrex/src/changelog/mod.rs | 77 + .../src/changelog/unreleased.md | 41 + .../src/changelog/v0.10.md | 13 + .../src/changelog/v0.11.md | 27 + .../src/changelog/v0.12.md | 8 + .../src/changelog/v0.13.md | 20 + .../src/changelog/v0.14.md | 21 + .../src/changelog/v0.15.md | 42 + .../src/changelog/v0.16.md | 32 + .../src/changelog/v0.17.md | 23 + .../src/changelog/v0.18.md | 52 + .../src/changelog/v0.19.md | 27 + .../src/changelog/v0.20.md | 200 ++ .../src/changelog/v0.21.md | 16 + .../src/changelog/v0.22.md | 37 + .../src/changelog/v0.23.md | 65 + .../src/changelog/v0.24.md | 28 + .../src/changelog/v0.25.md | 31 + .../src/changelog/v0.26.md | 36 + .../src/changelog/v0.27.md | 107 + .../src/changelog/v0.28.md | 100 + .../src/changelog/v0.29.md | 289 ++ .../src/changelog/v0.30.md | 413 +++ .../src/changelog/v0.8.md | 33 + .../src/changelog/v0.9.md | 22 + .../winit-0.30.13-ferrex/src/cursor.rs | 263 ++ third-party/winit-0.30.13-ferrex/src/error.rs | 131 + third-party/winit-0.30.13-ferrex/src/event.rs | 1183 ++++++++ .../winit-0.30.13-ferrex/src/event_loop.rs | 651 ++++ third-party/winit-0.30.13-ferrex/src/icon.rs | 117 + .../winit-0.30.13-ferrex/src/keyboard.rs | 1804 +++++++++++ third-party/winit-0.30.13-ferrex/src/lib.rs | 217 ++ .../winit-0.30.13-ferrex/src/monitor.rs | 167 + .../src/platform/android.rs | 188 ++ .../winit-0.30.13-ferrex/src/platform/ios.rs | 435 +++ .../src/platform/macos.rs | 531 ++++ .../winit-0.30.13-ferrex/src/platform/mod.rs | 55 + .../src/platform/modifier_supplement.rs | 35 + .../src/platform/orbital.rs | 6 + .../src/platform/pump_events.rs | 143 + .../src/platform/run_on_demand.rs | 111 + .../src/platform/scancode.rs | 50 + .../src/platform/startup_notify.rs | 99 + .../src/platform/wayland.rs | 131 + .../winit-0.30.13-ferrex/src/platform/web.rs | 465 +++ .../src/platform/windows.rs | 767 +++++ .../winit-0.30.13-ferrex/src/platform/x11.rs | 254 ++ .../src/platform_impl/android/keycodes.rs | 608 ++++ .../src/platform_impl/android/mod.rs | 1141 +++++++ .../src/platform_impl/ios/app_state.rs | 925 ++++++ .../src/platform_impl/ios/event_loop.rs | 494 +++ .../src/platform_impl/ios/mod.rs | 52 + .../src/platform_impl/ios/monitor.rs | 277 ++ .../platform_impl/ios/notification_center.rs | 27 + .../src/platform_impl/ios/view.rs | 607 ++++ .../src/platform_impl/ios/view_controller.rs | 176 ++ .../src/platform_impl/ios/window.rs | 750 +++++ .../src/platform_impl/linux/common/mod.rs | 1 + .../platform_impl/linux/common/xkb/compose.rs | 123 + .../platform_impl/linux/common/xkb/keymap.rs | 1044 +++++++ .../src/platform_impl/linux/common/xkb/mod.rs | 416 +++ .../platform_impl/linux/common/xkb/state.rs | 189 ++ .../src/platform_impl/linux/mod.rs | 1044 +++++++ .../linux/wayland/event_loop/mod.rs | 810 +++++ .../linux/wayland/event_loop/proxy.rs | 28 + .../linux/wayland/event_loop/sink.rs | 53 + .../src/platform_impl/linux/wayland/mod.rs | 85 + .../src/platform_impl/linux/wayland/output.rs | 163 + .../linux/wayland/seat/keyboard/mod.rs | 380 +++ .../platform_impl/linux/wayland/seat/mod.rs | 235 ++ .../linux/wayland/seat/pointer/mod.rs | 502 ++++ .../wayland/seat/pointer/relative_pointer.rs | 83 + .../linux/wayland/seat/text_input/mod.rs | 201 ++ .../linux/wayland/seat/touch/mod.rs | 220 ++ .../src/platform_impl/linux/wayland/state.rs | 435 +++ .../linux/wayland/types/cursor.rs | 59 + .../linux/wayland/types/kwin_blur.rs | 68 + .../platform_impl/linux/wayland/types/mod.rs | 7 + .../wayland/types/wp_fractional_scaling.rs | 78 + .../linux/wayland/types/wp_viewporter.rs | 65 + .../linux/wayland/types/xdg_activation.rs | 103 + .../platform_impl/linux/wayland/window/mod.rs | 763 +++++ .../linux/wayland/window/state.rs | 1220 ++++++++ .../src/platform_impl/linux/x11/activation.rs | 194 ++ .../src/platform_impl/linux/x11/atoms.rs | 117 + .../src/platform_impl/linux/x11/dnd.rs | 174 ++ .../linux/x11/event_processor.rs | 1883 ++++++++++++ .../src/platform_impl/linux/x11/ffi.rs | 5 + .../platform_impl/linux/x11/ime/callbacks.rs | 206 ++ .../platform_impl/linux/x11/ime/context.rs | 376 +++ .../src/platform_impl/linux/x11/ime/inner.rs | 74 + .../linux/x11/ime/input_method.rs | 345 +++ .../src/platform_impl/linux/x11/ime/mod.rs | 233 ++ .../src/platform_impl/linux/x11/mod.rs | 1064 +++++++ .../src/platform_impl/linux/x11/monitor.rs | 355 +++ .../linux/x11/tests/xsettings.dat | 1 + .../linux/x11/util/client_msg.rs | 31 + .../platform_impl/linux/x11/util/cookie.rs | 55 + .../platform_impl/linux/x11/util/cursor.rs | 174 ++ .../platform_impl/linux/x11/util/geometry.rs | 265 ++ .../src/platform_impl/linux/x11/util/hint.rs | 169 ++ .../src/platform_impl/linux/x11/util/icon.rs | 36 + .../src/platform_impl/linux/x11/util/input.rs | 106 + .../src/platform_impl/linux/x11/util/keys.rs | 75 + .../platform_impl/linux/x11/util/memory.rs | 42 + .../src/platform_impl/linux/x11/util/mod.rs | 77 + .../platform_impl/linux/x11/util/modifiers.rs | 187 ++ .../src/platform_impl/linux/x11/util/mouse.rs | 49 + .../src/platform_impl/linux/x11/util/randr.rs | 186 ++ .../linux/x11/util/window_property.rs | 196 ++ .../src/platform_impl/linux/x11/util/wm.rs | 137 + .../platform_impl/linux/x11/util/xmodmap.rs | 56 + .../src/platform_impl/linux/x11/window.rs | 1938 ++++++++++++ .../src/platform_impl/linux/x11/xdisplay.rs | 356 +++ .../src/platform_impl/linux/x11/xsettings.rs | 325 ++ .../src/platform_impl/macos/app.rs | 195 ++ .../src/platform_impl/macos/app_state.rs | 446 +++ .../src/platform_impl/macos/cursor.rs | 225 ++ .../src/platform_impl/macos/event.rs | 616 ++++ .../src/platform_impl/macos/event_handler.rs | 139 + .../src/platform_impl/macos/event_loop.rs | 523 ++++ .../src/platform_impl/macos/ffi.rs | 256 ++ .../src/platform_impl/macos/menu.rs | 107 + .../src/platform_impl/macos/mod.rs | 61 + .../src/platform_impl/macos/monitor.rs | 409 +++ .../src/platform_impl/macos/observer.rs | 312 ++ .../src/platform_impl/macos/util.rs | 27 + .../src/platform_impl/macos/view.rs | 1787 +++++++++++ .../src/platform_impl/macos/window.rs | 134 + .../platform_impl/macos/window_delegate.rs | 2187 ++++++++++++++ .../src/platform_impl/mod.rs | 78 + .../src/platform_impl/orbital/event_loop.rs | 829 +++++ .../src/platform_impl/orbital/mod.rs | 255 ++ .../src/platform_impl/orbital/window.rs | 509 ++++ .../src/platform_impl/web/async/abortable.rs | 96 + .../platform_impl/web/async/atomic_waker.rs | 35 + .../src/platform_impl/web/async/channel.rs | 113 + .../web/async/concurrent_queue.rs | 52 + .../src/platform_impl/web/async/dispatcher.rs | 106 + .../src/platform_impl/web/async/mod.rs | 19 + .../src/platform_impl/web/async/notifier.rs | 72 + .../src/platform_impl/web/async/waker.rs | 131 + .../src/platform_impl/web/async/wrapper.rs | 89 + .../src/platform_impl/web/cursor.rs | 730 +++++ .../src/platform_impl/web/device.rs | 8 + .../src/platform_impl/web/error.rs | 10 + .../src/platform_impl/web/event_loop/mod.rs | 119 + .../src/platform_impl/web/event_loop/proxy.rs | 29 + .../platform_impl/web/event_loop/runner.rs | 808 +++++ .../src/platform_impl/web/event_loop/state.rs | 18 + .../web/event_loop/window_target.rs | 685 +++++ .../src/platform_impl/web/keyboard.rs | 522 ++++ .../src/platform_impl/web/main_thread.rs | 96 + .../src/platform_impl/web/mod.rs | 50 + .../src/platform_impl/web/monitor.rs | 53 + .../web/web_sys/animation_frame.rs | 60 + .../src/platform_impl/web/web_sys/canvas.rs | 565 ++++ .../src/platform_impl/web/web_sys/event.rs | 270 ++ .../platform_impl/web/web_sys/event_handle.rs | 38 + .../platform_impl/web/web_sys/fullscreen.rs | 103 + .../web/web_sys/intersection_handle.rs | 33 + .../web/web_sys/media_query_handle.rs | 48 + .../src/platform_impl/web/web_sys/mod.rs | 233 ++ .../src/platform_impl/web/web_sys/pointer.rs | 244 ++ .../web/web_sys/resize_scaling.rs | 302 ++ .../src/platform_impl/web/web_sys/schedule.rs | 342 +++ .../src/platform_impl/web/web_sys/worker.js | 10 + .../platform_impl/web/web_sys/worker.min.js | 1 + .../src/platform_impl/web/window.rs | 477 +++ .../src/platform_impl/windows/dark_mode.rs | 170 ++ .../src/platform_impl/windows/definitions.rs | 148 + .../src/platform_impl/windows/dpi.rs | 112 + .../src/platform_impl/windows/drop_handler.rs | 237 ++ .../src/platform_impl/windows/event_loop.rs | 2676 +++++++++++++++++ .../windows/event_loop/runner.rs | 406 +++ .../src/platform_impl/windows/icon.rs | 265 ++ .../src/platform_impl/windows/ime.rs | 162 + .../src/platform_impl/windows/keyboard.rs | 1243 ++++++++ .../platform_impl/windows/keyboard_layout.rs | 986 ++++++ .../src/platform_impl/windows/minimal_ime.rs | 67 + .../src/platform_impl/windows/mod.rs | 196 ++ .../src/platform_impl/windows/monitor.rs | 256 ++ .../src/platform_impl/windows/raw_input.rs | 301 ++ .../src/platform_impl/windows/util.rs | 280 ++ .../src/platform_impl/windows/window.rs | 1575 ++++++++++ .../src/platform_impl/windows/window_state.rs | 545 ++++ third-party/winit-0.30.13-ferrex/src/utils.rs | 28 + .../winit-0.30.13-ferrex/src/window.rs | 1876 ++++++++++++ 198 files changed, 61553 insertions(+), 2 deletions(-) create mode 100644 third-party/winit-0.30.13-ferrex/Cargo.toml create mode 100644 third-party/winit-0.30.13-ferrex/Cargo.toml.orig create mode 100644 third-party/winit-0.30.13-ferrex/FEATURES.md create mode 100644 third-party/winit-0.30.13-ferrex/FERREX-PATCH.md create mode 100644 third-party/winit-0.30.13-ferrex/LICENSE create mode 100644 third-party/winit-0.30.13-ferrex/README.md create mode 100644 third-party/winit-0.30.13-ferrex/build.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/application.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/unreleased.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.10.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.11.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.12.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.13.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.14.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.15.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.16.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.17.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.18.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.19.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.20.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.21.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.22.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.23.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.24.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.25.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.26.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.27.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.28.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.29.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.30.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.8.md create mode 100644 third-party/winit-0.30.13-ferrex/src/changelog/v0.9.md create mode 100644 third-party/winit-0.30.13-ferrex/src/cursor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/error.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/event.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/event_loop.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/icon.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/keyboard.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/lib.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/monitor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/android.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/ios.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/macos.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/modifier_supplement.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/orbital.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/pump_events.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/run_on_demand.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/scancode.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/startup_notify.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/wayland.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/web.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/windows.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform/x11.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/android/keycodes.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/android/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/ios/app_state.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/ios/event_loop.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/ios/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/ios/monitor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/ios/notification_center.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/ios/view.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/ios/view_controller.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/ios/window.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/compose.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/keymap.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/state.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/proxy.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/sink.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/output.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/keyboard/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/pointer/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/pointer/relative_pointer.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/text_input/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/touch/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/state.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/cursor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/kwin_blur.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/wp_fractional_scaling.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/wp_viewporter.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/xdg_activation.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/window/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/window/state.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/activation.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/atoms.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/dnd.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/event_processor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ffi.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/callbacks.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/context.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/inner.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/input_method.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/monitor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/tests/xsettings.dat create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/client_msg.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/cookie.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/cursor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/geometry.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/hint.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/icon.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/input.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/keys.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/memory.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/modifiers.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/mouse.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/randr.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/window_property.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/wm.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/xmodmap.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/window.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/xdisplay.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/xsettings.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/app.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/app_state.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/cursor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event_handler.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event_loop.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/ffi.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/menu.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/monitor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/observer.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/util.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/view.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/window.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/macos/window_delegate.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/event_loop.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/window.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/abortable.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/atomic_waker.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/channel.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/concurrent_queue.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/dispatcher.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/notifier.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/waker.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/wrapper.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/cursor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/device.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/error.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/proxy.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/runner.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/state.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/window_target.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/keyboard.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/main_thread.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/monitor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/animation_frame.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/canvas.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/event.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/event_handle.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/fullscreen.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/intersection_handle.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/media_query_handle.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/pointer.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/resize_scaling.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/schedule.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/worker.js create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/worker.min.js create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/web/window.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/dark_mode.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/definitions.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/dpi.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/drop_handler.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/event_loop.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/event_loop/runner.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/icon.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/ime.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/keyboard.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/keyboard_layout.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/minimal_ime.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/mod.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/monitor.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/raw_input.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/util.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/window.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/platform_impl/windows/window_state.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/utils.rs create mode 100644 third-party/winit-0.30.13-ferrex/src/window.rs diff --git a/Cargo.lock b/Cargo.lock index 7766d76f..06f36f68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9685,8 +9685,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ "ahash 0.8.12", "android-activity", diff --git a/Cargo.toml b/Cargo.toml index 9fd51580..946b138e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -353,6 +353,7 @@ iced = { git = "https://github.com/Lowband21/iced-ferrex.git", package = "iced" iced_core = { git = "https://github.com/Lowband21/iced-ferrex.git", package = "iced_core" } iced_widget = { git = "https://github.com/Lowband21/iced-ferrex.git", package = "iced_widget" } iced_wgpu = { git = "https://github.com/Lowband21/iced-ferrex.git", package = "iced_wgpu" } +winit = { path = "third-party/winit-0.30.13-ferrex" } [patch."https://github.com/iced-rs/iced.git"] iced = { git = "https://github.com/Lowband21/iced-ferrex.git", package = "iced" } diff --git a/third-party/winit-0.30.13-ferrex/Cargo.toml b/third-party/winit-0.30.13-ferrex/Cargo.toml new file mode 100644 index 00000000..4bfded8a --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/Cargo.toml @@ -0,0 +1,553 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.70.0" +name = "winit" +version = "0.30.13" +authors = [ + "The winit contributors", + "Pierre Krieger ", +] +build = "build.rs" +include = [ + "/build.rs", + "/docs", + "/examples", + "/FEATURES.md", + "/LICENSE", + "/src", + "!/src/platform_impl/web/script", + "/src/platform_impl/web/script/**/*.min.js", + "/tests", +] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Cross-platform window creation library." +documentation = "https://docs.rs/winit" +readme = "README.md" +keywords = ["windowing"] +categories = ["gui"] +license = "Apache-2.0" +repository = "https://github.com/rust-windowing/winit" + +[package.metadata.docs.rs] +features = [ + "rwh_04", + "rwh_05", + "rwh_06", + "serde", + "mint", + "android-native-activity", +] +targets = [ + "i686-pc-windows-msvc", + "x86_64-pc-windows-msvc", + "x86_64-apple-darwin", + "i686-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + "x86_64-apple-ios", + "aarch64-linux-android", + "wasm32-unknown-unknown", +] +rustdoc-args = [ + "--cfg", + "docsrs", +] + +[features] +android-game-activity = ["android-activity/game-activity"] +android-native-activity = ["android-activity/native-activity"] +default = [ + "rwh_06", + "x11", + "wayland", + "wayland-dlopen", + "wayland-csd-adwaita", +] +mint = ["dpi/mint"] +rwh_04 = [ + "dep:rwh_04", + "ndk/rwh_04", +] +rwh_05 = [ + "dep:rwh_05", + "ndk/rwh_05", +] +rwh_06 = [ + "dep:rwh_06", + "ndk/rwh_06", +] +serde = [ + "dep:serde", + "cursor-icon/serde", + "smol_str/serde", + "dpi/serde", +] +wayland = [ + "wayland-client", + "wayland-backend", + "wayland-protocols", + "wayland-protocols-plasma", + "sctk", + "ahash", + "memmap2", +] +wayland-csd-adwaita = [ + "sctk-adwaita", + "sctk-adwaita/ab_glyph", +] +wayland-csd-adwaita-crossfont = [ + "sctk-adwaita", + "sctk-adwaita/crossfont", +] +wayland-csd-adwaita-notitle = ["sctk-adwaita"] +wayland-dlopen = ["wayland-backend/dlopen"] +x11 = [ + "x11-dl", + "bytemuck", + "percent-encoding", + "xkbcommon-dl/x11", + "x11rb", +] + +[lib] +name = "winit" +path = "src/lib.rs" + +[[example]] +name = "child_window" +path = "examples/child_window.rs" + +[[example]] +name = "control_flow" +path = "examples/control_flow.rs" + +[[example]] +name = "pump_events" +path = "examples/pump_events.rs" + +[[example]] +name = "run_on_demand" +path = "examples/run_on_demand.rs" + +[[example]] +name = "window" +path = "examples/window.rs" +doc-scrape-examples = true + +[[example]] +name = "x11_embed" +path = "examples/x11_embed.rs" + +[[test]] +name = "send_objects" +path = "tests/send_objects.rs" + +[[test]] +name = "serde_objects" +path = "tests/serde_objects.rs" + +[[test]] +name = "sync_object" +path = "tests/sync_object.rs" + +[dependencies.bitflags] +version = "2" + +[dependencies.cursor-icon] +version = "1.1.0" + +[dependencies.dpi] +version = "0.1.1" + +[dependencies.rwh_04] +version = "0.4" +optional = true +package = "raw-window-handle" + +[dependencies.rwh_05] +version = "0.5.2" +features = ["std"] +optional = true +package = "raw-window-handle" + +[dependencies.rwh_06] +version = "0.6" +features = ["std"] +optional = true +package = "raw-window-handle" + +[dependencies.serde] +version = "1" +features = ["serde_derive"] +optional = true + +[dependencies.smol_str] +version = "0.2.0" + +[dependencies.tracing] +version = "0.1.40" +default-features = false + +[dev-dependencies.image] +version = "0.25.0" +features = ["png"] +default-features = false + +[dev-dependencies.tracing] +version = "0.1.40" +features = ["log"] +default-features = false + +[dev-dependencies.tracing-subscriber] +version = "0.3.18" +features = ["env-filter"] + +[build-dependencies.cfg_aliases] +version = "0.2.1" + +[target.'cfg(all(target_family = "wasm", target_feature = "atomics"))'.dependencies.atomic-waker] +version = "1" + +[target.'cfg(all(target_family = "wasm", target_feature = "atomics"))'.dependencies.concurrent-queue] +version = "2" +default-features = false + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.ahash] +version = "0.8.7" +features = ["no-rng"] +optional = true + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.bytemuck] +version = "1.13.1" +optional = true +default-features = false + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.calloop] +version = "0.13.0" + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.libc] +version = "0.2.64" + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.memmap2] +version = "0.9.0" +optional = true + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.percent-encoding] +version = "2.0" +optional = true + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.rustix] +version = "0.38.4" +features = [ + "std", + "system", + "thread", + "process", +] +default-features = false + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.sctk] +version = "0.19.2" +features = ["calloop"] +optional = true +default-features = false +package = "smithay-client-toolkit" + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.sctk-adwaita] +version = "0.10.1" +optional = true +default-features = false + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.wayland-backend] +version = "0.3.10" +features = ["client_system"] +optional = true +default-features = false + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.wayland-client] +version = "0.31.10" +optional = true + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.wayland-protocols] +version = "0.32.8" +features = ["staging"] +optional = true + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.wayland-protocols-plasma] +version = "0.3.8" +features = ["client"] +optional = true + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.x11-dl] +version = "2.19.1" +optional = true + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.x11rb] +version = "0.13.0" +features = [ + "allow-unsafe-code", + "dl-libxcb", + "randr", + "resource_manager", + "xinput", + "xkb", +] +optional = true +default-features = false + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies.xkbcommon-dl] +version = "0.4.2" + +[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies.block2] +version = "0.5.1" + +[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies.core-foundation] +version = "0.9.3" + +[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies.objc2] +version = "0.5.2" +features = ["relax-sign-encoding"] + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dev-dependencies.softbuffer] +version = "0.4.0" +features = [ + "x11", + "x11-dlopen", + "wayland", + "wayland-dlopen", +] +default-features = false + +[target.'cfg(target_family = "wasm")'.dependencies.js-sys] +version = "0.3.70" + +[target.'cfg(target_family = "wasm")'.dependencies.pin-project] +version = "1" + +[target.'cfg(target_family = "wasm")'.dependencies.wasm-bindgen] +version = "0.2.93" + +[target.'cfg(target_family = "wasm")'.dependencies.wasm-bindgen-futures] +version = "0.4.43" + +[target.'cfg(target_family = "wasm")'.dependencies.web-time] +version = "1" + +[target.'cfg(target_family = "wasm")'.dependencies.web_sys] +version = "0.3.70" +features = [ + "AbortController", + "AbortSignal", + "Blob", + "BlobPropertyBag", + "console", + "CssStyleDeclaration", + "Document", + "DomException", + "DomRect", + "DomRectReadOnly", + "Element", + "Event", + "EventTarget", + "FocusEvent", + "HtmlCanvasElement", + "HtmlElement", + "HtmlImageElement", + "ImageBitmap", + "ImageBitmapOptions", + "ImageBitmapRenderingContext", + "ImageData", + "IntersectionObserver", + "IntersectionObserverEntry", + "KeyboardEvent", + "MediaQueryList", + "MessageChannel", + "MessagePort", + "Navigator", + "Node", + "OrientationLockType", + "OrientationType", + "PageTransitionEvent", + "Permissions", + "PermissionState", + "PermissionStatus", + "PointerEvent", + "PremultiplyAlpha", + "ResizeObserver", + "ResizeObserverBoxOptions", + "ResizeObserverEntry", + "ResizeObserverOptions", + "ResizeObserverSize", + "Screen", + "ScreenOrientation", + "Url", + "VisibilityState", + "WheelEvent", + "Window", + "Worker", +] +package = "web-sys" + +[target.'cfg(target_family = "wasm")'.dev-dependencies.console_error_panic_hook] +version = "0.1" + +[target.'cfg(target_family = "wasm")'.dev-dependencies.tracing-web] +version = "0.1" + +[target.'cfg(target_os = "android")'.dependencies.android-activity] +version = "0.6.0" + +[target.'cfg(target_os = "android")'.dependencies.ndk] +version = "0.9.0" +default-features = false + +[target.'cfg(target_os = "ios")'.dependencies.objc2-foundation] +version = "0.2.2" +features = [ + "block2", + "dispatch", + "NSArray", + "NSEnumerator", + "NSGeometry", + "NSObjCRuntime", + "NSOperation", + "NSString", + "NSProcessInfo", + "NSThread", + "NSSet", +] + +[target.'cfg(target_os = "ios")'.dependencies.objc2-ui-kit] +version = "0.2.2" +features = [ + "UIApplication", + "UIDevice", + "UIEvent", + "UIGeometry", + "UIGestureRecognizer", + "UITextInput", + "UITextInputTraits", + "UIOrientation", + "UIPanGestureRecognizer", + "UIPinchGestureRecognizer", + "UIResponder", + "UIRotationGestureRecognizer", + "UIScreen", + "UIScreenMode", + "UITapGestureRecognizer", + "UITouch", + "UITraitCollection", + "UIView", + "UIViewController", + "UIWindow", +] + +[target.'cfg(target_os = "macos")'.dependencies.core-graphics] +version = "0.23.1" + +[target.'cfg(target_os = "macos")'.dependencies.objc2-app-kit] +version = "0.2.2" +features = [ + "NSAppearance", + "NSApplication", + "NSBitmapImageRep", + "NSButton", + "NSColor", + "NSControl", + "NSCursor", + "NSDragging", + "NSEvent", + "NSGraphics", + "NSGraphicsContext", + "NSImage", + "NSImageRep", + "NSMenu", + "NSMenuItem", + "NSOpenGLView", + "NSPasteboard", + "NSResponder", + "NSRunningApplication", + "NSScreen", + "NSTextInputClient", + "NSTextInputContext", + "NSView", + "NSWindow", + "NSWindowScripting", + "NSWindowTabGroup", +] + +[target.'cfg(target_os = "macos")'.dependencies.objc2-foundation] +version = "0.2.2" +features = [ + "block2", + "dispatch", + "NSArray", + "NSAttributedString", + "NSData", + "NSDictionary", + "NSDistributedNotificationCenter", + "NSEnumerator", + "NSKeyValueObserving", + "NSNotification", + "NSObjCRuntime", + "NSPathUtilities", + "NSProcessInfo", + "NSRunLoop", + "NSString", + "NSThread", + "NSValue", +] + +[target.'cfg(target_os = "redox")'.dependencies.orbclient] +version = "0.3.47" +default-features = false + +[target.'cfg(target_os = "redox")'.dependencies.redox_syscall] +version = "0.4.1" + +[target.'cfg(target_os = "windows")'.dependencies.unicode-segmentation] +version = "1.7.1" + +[target.'cfg(target_os = "windows")'.dependencies.windows-sys] +version = "0.52.0" +features = [ + "Win32_Devices_HumanInterfaceDevice", + "Win32_Foundation", + "Win32_Globalization", + "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", + "Win32_Media", + "Win32_System_Com_StructuredStorage", + "Win32_System_Com", + "Win32_System_LibraryLoader", + "Win32_System_Ole", + "Win32_Security", + "Win32_System_SystemInformation", + "Win32_System_SystemServices", + "Win32_System_Threading", + "Win32_System_WindowsProgramming", + "Win32_UI_Accessibility", + "Win32_UI_Controls", + "Win32_UI_HiDpi", + "Win32_UI_Input_Ime", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Input_Pointer", + "Win32_UI_Input_Touch", + "Win32_UI_Shell", + "Win32_UI_TextServices", + "Win32_UI_WindowsAndMessaging", +] diff --git a/third-party/winit-0.30.13-ferrex/Cargo.toml.orig b/third-party/winit-0.30.13-ferrex/Cargo.toml.orig new file mode 100644 index 00000000..71f7c581 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/Cargo.toml.orig @@ -0,0 +1,378 @@ +[package] +name = "winit" +version = "0.30.13" +authors = [ + "The winit contributors", + "Pierre Krieger ", +] +description = "Cross-platform window creation library." +keywords = ["windowing"] +readme = "README.md" +documentation = "https://docs.rs/winit" +categories = ["gui"] +rust-version.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +include = [ + "/build.rs", + "/docs", + "/examples", + "/FEATURES.md", + "/LICENSE", + "/src", + "!/src/platform_impl/web/script", + "/src/platform_impl/web/script/**/*.min.js", + "/tests", +] + +[package.metadata.docs.rs] +features = [ + "rwh_04", + "rwh_05", + "rwh_06", + "serde", + "mint", + # Enabled to get docs to compile + "android-native-activity", +] +# These are all tested in CI +targets = [ + # Windows + "i686-pc-windows-msvc", + "x86_64-pc-windows-msvc", + # macOS + "x86_64-apple-darwin", + # Unix (X11 & Wayland) + "i686-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + # iOS + "x86_64-apple-ios", + # Android + "aarch64-linux-android", + # Web + "wasm32-unknown-unknown", +] +rustdoc-args = ["--cfg", "docsrs"] + +# Features are documented in either `lib.rs` or under `winit::platform`. +[features] +default = ["rwh_06", "x11", "wayland", "wayland-dlopen", "wayland-csd-adwaita"] +x11 = ["x11-dl", "bytemuck", "percent-encoding", "xkbcommon-dl/x11", "x11rb"] +wayland = [ + "wayland-client", + "wayland-backend", + "wayland-protocols", + "wayland-protocols-plasma", + "sctk", + "ahash", + "memmap2", +] +wayland-dlopen = ["wayland-backend/dlopen"] +wayland-csd-adwaita = ["sctk-adwaita", "sctk-adwaita/ab_glyph"] +wayland-csd-adwaita-crossfont = ["sctk-adwaita", "sctk-adwaita/crossfont"] +wayland-csd-adwaita-notitle = ["sctk-adwaita"] +android-native-activity = ["android-activity/native-activity"] +android-game-activity = ["android-activity/game-activity"] +serde = ["dep:serde", "cursor-icon/serde", "smol_str/serde", "dpi/serde"] +mint = ["dpi/mint"] +rwh_04 = ["dep:rwh_04", "ndk/rwh_04"] +rwh_05 = ["dep:rwh_05", "ndk/rwh_05"] +rwh_06 = ["dep:rwh_06", "ndk/rwh_06"] + +[build-dependencies] +cfg_aliases = "0.2.1" + +[dependencies] +bitflags = "2" +cursor-icon = "1.1.0" +dpi = { version = "0.1.1", path = "dpi" } +rwh_04 = { package = "raw-window-handle", version = "0.4", optional = true } +rwh_05 = { package = "raw-window-handle", version = "0.5.2", features = [ + "std", +], optional = true } +rwh_06 = { package = "raw-window-handle", version = "0.6", features = [ + "std", +], optional = true } +serde = { workspace = true, optional = true } +smol_str = "0.2.0" +tracing = { version = "0.1.40", default-features = false } + +[dev-dependencies] +image = { version = "0.25.0", default-features = false, features = ["png"] } +tracing = { version = "0.1.40", default-features = false, features = ["log"] } +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +winit = { path = ".", features = ["rwh_05"] } + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dev-dependencies] +softbuffer = { version = "0.4.0", default-features = false, features = [ + "x11", + "x11-dlopen", + "wayland", + "wayland-dlopen", +] } + +[target.'cfg(target_os = "android")'.dependencies] +android-activity = "0.6.0" +ndk = { version = "0.9.0", default-features = false } + +[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] +block2 = "0.5.1" +core-foundation = "0.9.3" +objc2 = { version = "0.5.2", features = ["relax-sign-encoding"] } + +[target.'cfg(target_os = "macos")'.dependencies] +core-graphics = "0.23.1" + +[target.'cfg(target_os = "macos")'.dependencies.objc2-foundation] +version = "0.2.2" +features = [ + "block2", + "dispatch", + "NSArray", + "NSAttributedString", + "NSData", + "NSDictionary", + "NSDistributedNotificationCenter", + "NSEnumerator", + "NSKeyValueObserving", + "NSNotification", + "NSObjCRuntime", + "NSPathUtilities", + "NSProcessInfo", + "NSRunLoop", + "NSString", + "NSThread", + "NSValue", +] + +[target.'cfg(target_os = "macos")'.dependencies.objc2-app-kit] +version = "0.2.2" +features = [ + "NSAppearance", + "NSApplication", + "NSBitmapImageRep", + "NSButton", + "NSColor", + "NSControl", + "NSCursor", + "NSDragging", + "NSEvent", + "NSGraphics", + "NSGraphicsContext", + "NSImage", + "NSImageRep", + "NSMenu", + "NSMenuItem", + "NSOpenGLView", + "NSPasteboard", + "NSResponder", + "NSRunningApplication", + "NSScreen", + "NSTextInputClient", + "NSTextInputContext", + "NSView", + "NSWindow", + "NSWindowScripting", + "NSWindowTabGroup", +] + +[target.'cfg(target_os = "ios")'.dependencies.objc2-foundation] +version = "0.2.2" +features = [ + "block2", + "dispatch", + "NSArray", + "NSEnumerator", + "NSGeometry", + "NSObjCRuntime", + "NSOperation", + "NSString", + "NSProcessInfo", + "NSThread", + "NSSet", +] + +[target.'cfg(target_os = "ios")'.dependencies.objc2-ui-kit] +version = "0.2.2" +features = [ + "UIApplication", + "UIDevice", + "UIEvent", + "UIGeometry", + "UIGestureRecognizer", + "UITextInput", + "UITextInputTraits", + "UIOrientation", + "UIPanGestureRecognizer", + "UIPinchGestureRecognizer", + "UIResponder", + "UIRotationGestureRecognizer", + "UIScreen", + "UIScreenMode", + "UITapGestureRecognizer", + "UITouch", + "UITraitCollection", + "UIView", + "UIViewController", + "UIWindow", +] + +[target.'cfg(target_os = "windows")'.dependencies] +unicode-segmentation = "1.7.1" + +[target.'cfg(target_os = "windows")'.dependencies.windows-sys] +version = "0.52.0" +features = [ + "Win32_Devices_HumanInterfaceDevice", + "Win32_Foundation", + "Win32_Globalization", + "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", + "Win32_Media", + "Win32_System_Com_StructuredStorage", + "Win32_System_Com", + "Win32_System_LibraryLoader", + "Win32_System_Ole", + "Win32_Security", + "Win32_System_SystemInformation", + "Win32_System_SystemServices", + "Win32_System_Threading", + "Win32_System_WindowsProgramming", + "Win32_UI_Accessibility", + "Win32_UI_Controls", + "Win32_UI_HiDpi", + "Win32_UI_Input_Ime", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Input_Pointer", + "Win32_UI_Input_Touch", + "Win32_UI_Shell", + "Win32_UI_TextServices", + "Win32_UI_WindowsAndMessaging", +] + +[target.'cfg(all(unix, not(any(target_os = "redox", target_family = "wasm", target_os = "android", target_os = "ios", target_os = "macos"))))'.dependencies] +ahash = { version = "0.8.7", features = ["no-rng"], optional = true } +bytemuck = { version = "1.13.1", default-features = false, optional = true } +calloop = "0.13.0" +libc = "0.2.64" +memmap2 = { version = "0.9.0", optional = true } +percent-encoding = { version = "2.0", optional = true } +rustix = { version = "0.38.4", default-features = false, features = [ + "std", + "system", + "thread", + "process", +] } +sctk = { package = "smithay-client-toolkit", version = "0.19.2", default-features = false, features = [ + "calloop", +], optional = true } +sctk-adwaita = { version = "0.10.1", default-features = false, optional = true } +wayland-backend = { version = "0.3.10", default-features = false, features = [ + "client_system", +], optional = true } +wayland-client = { version = "0.31.10", optional = true } +wayland-protocols = { version = "0.32.8", features = [ + "staging", +], optional = true } +wayland-protocols-plasma = { version = "0.3.8", features = [ + "client", +], optional = true } +x11-dl = { version = "2.19.1", optional = true } +x11rb = { version = "0.13.0", default-features = false, features = [ + "allow-unsafe-code", + "dl-libxcb", + "randr", + "resource_manager", + "xinput", + "xkb", +], optional = true } +xkbcommon-dl = "0.4.2" + +[target.'cfg(target_os = "redox")'.dependencies] +orbclient = { version = "0.3.47", default-features = false } +redox_syscall = "0.4.1" + +[target.'cfg(target_family = "wasm")'.dependencies] +js-sys = "0.3.70" +pin-project = "1" +wasm-bindgen = "0.2.93" +wasm-bindgen-futures = "0.4.43" +web-time = "1" +web_sys = { package = "web-sys", version = "0.3.70", features = [ + "AbortController", + "AbortSignal", + "Blob", + "BlobPropertyBag", + "console", + "CssStyleDeclaration", + "Document", + "DomException", + "DomRect", + "DomRectReadOnly", + "Element", + "Event", + "EventTarget", + "FocusEvent", + "HtmlCanvasElement", + "HtmlElement", + "HtmlImageElement", + "ImageBitmap", + "ImageBitmapOptions", + "ImageBitmapRenderingContext", + "ImageData", + "IntersectionObserver", + "IntersectionObserverEntry", + "KeyboardEvent", + "MediaQueryList", + "MessageChannel", + "MessagePort", + "Navigator", + "Node", + "OrientationLockType", + "OrientationType", + "PageTransitionEvent", + "Permissions", + "PermissionState", + "PermissionStatus", + "PointerEvent", + "PremultiplyAlpha", + "ResizeObserver", + "ResizeObserverBoxOptions", + "ResizeObserverEntry", + "ResizeObserverOptions", + "ResizeObserverSize", + "Screen", + "ScreenOrientation", + "Url", + "VisibilityState", + "WheelEvent", + "Window", + "Worker", +] } + +[target.'cfg(all(target_family = "wasm", target_feature = "atomics"))'.dependencies] +atomic-waker = "1" +concurrent-queue = { version = "2", default-features = false } + +[target.'cfg(target_family = "wasm")'.dev-dependencies] +console_error_panic_hook = "0.1" +tracing-web = "0.1" + +[[example]] +doc-scrape-examples = true +name = "window" + +[workspace] +resolver = "2" +members = ["dpi"] + +[workspace.package] +rust-version = "1.70.0" +repository = "https://github.com/rust-windowing/winit" +license = "Apache-2.0" +edition = "2021" + +[workspace.dependencies] +serde = { version = "1", features = ["serde_derive"] } +mint = "0.5.6" diff --git a/third-party/winit-0.30.13-ferrex/FEATURES.md b/third-party/winit-0.30.13-ferrex/FEATURES.md new file mode 100644 index 00000000..2ae94770 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/FEATURES.md @@ -0,0 +1,248 @@ +# Winit Scope + +Winit aims to expose an interface that abstracts over window creation and input handling and can +be used to create both games and applications. It supports the following main graphical platforms: +- Desktop + - Windows + - macOS + - Unix + - via X11 + - via Wayland + - Redox OS, via Orbital +- Mobile + - iOS + - Android +- Web + +Most platforms expose capabilities that cannot be meaningfully transposed onto others. Winit does not +aim to support every single feature of every platform, but rather to abstract over the common features +available everywhere. In this context, APIs exposed in winit can be split into different "support tiers": + +- **Core:** Features that are essential to providing a well-formed abstraction over each platform's + windowing and input APIs. +- **Platform:** Platform-specific features that can't be meaningfully exposed through a common API and + cannot be implemented outside of Winit without exposing a significant amount of Winit's internals + or interfering with Winit's abstractions. +- **Usability:** Features that are not strictly essential to Winit's functionality, but provide meaningful + usability improvements and cannot be reasonably implemented in an external crate. These are + generally optional and exposed through Cargo features. + +Core features are taken care of by the core Winit maintainers. Platform features are not. +When a platform feature is submitted, the submitter is considered the expert in the +feature and may be asked to support the feature should it break in the future. + +Winit ***does not*** directly expose functionality for drawing inside windows or creating native +menus, but ***does*** commit to providing APIs that higher-level crates can use to implement that +functionality. + +## `1.0` and stability + +When all core features are implemented to the satisfaction of the Winit maintainers, Winit 1.0 will +be released and the library will enter maintenance mode. For the most part, new core features will not +be added past this point. New platform features may be accepted and exposed through point releases. + +### Tier upgrades +Some platform features could, in theory, be exposed across multiple platforms, but have not gone +through the implementation work necessary to function on all platforms. When one of these features +gets implemented across all platforms, a PR can be opened to upgrade the feature to a core feature. +If that gets accepted, the platform-specific functions get deprecated and become permanently +exposed through the core, cross-platform API. + +# Features + +## Extending this section + +If your PR makes notable changes to Winit's features, please update this section as follows: + +- If your PR adds a new feature, add a brief description to the relevant section. If the feature is a core + feature, add a row to the feature matrix and describe what platforms the feature has been implemented on. + +- If your PR begins a new API rework, add a row to the `Pending API Reworks` table. If the PR implements the + API rework on all relevant platforms, please move it to the `Completed API Reworks` table. + +- If your PR implements an already-existing feature on a new platform, either mark the feature as *completed*, + or mark it as *mostly completed* and link to an issue describing the problems with the implementation. + +## Core + +### Windowing +- **Window initialization**: Winit allows the creation of a window +- **Providing pointer to init OpenGL**: Winit provides the necessary pointers to initialize a working opengl context +- **Providing pointer to init Vulkan**: Same as OpenGL but for Vulkan +- **Window decorations**: The windows created by winit are properly decorated, and the decorations can + be deactivated +- **Window decorations toggle**: Decorations can be turned on or off after window creation +- **Window resizing**: The windows created by winit can be resized and generate the appropriate events + when they are. The application can precisely control its window size if desired. +- **Window resize increments**: When the window gets resized, the application can choose to snap the window's + size to specific values. +- **Window transparency**: Winit allows the creation of windows with a transparent background. +- **Window maximization**: The windows created by winit can be maximized upon creation. +- **Window maximization toggle**: The windows created by winit can be maximized and unmaximized after + creation. +- **Window minimization**: The windows created by winit can be minimized after creation. +- **Fullscreen**: The windows created by winit can be put into fullscreen mode. +- **Fullscreen toggle**: The windows created by winit can be switched to and from fullscreen after + creation. +- **Exclusive fullscreen**: Winit allows changing the video mode of the monitor + for fullscreen windows and, if applicable, captures the monitor for exclusive + use by this application. +- **HiDPI support**: Winit assists developers in appropriately scaling HiDPI content. +- **Popup / modal windows**: Windows can be created relative to the client area of other windows, and parent + windows can be disabled in favor of popup windows. This feature also guarantees that popup windows + get drawn above their owner. + + +### System Information +- **Monitor list**: Retrieve the list of monitors and their metadata, including which one is primary. +- **Video mode query**: Monitors can be queried for their supported fullscreen video modes (consisting of resolution, refresh rate, and bit depth). + +### Input Handling +- **Mouse events**: Generating mouse events associated with pointer motion, click, and scrolling events. +- **Mouse set location**: Forcibly changing the location of the pointer. +- **Cursor locking**: Locking the cursor inside the window so it cannot move. +- **Cursor confining**: Confining the cursor to the window bounds so it cannot leave them. +- **Cursor icon**: Changing the cursor icon or hiding the cursor. +- **Cursor image**: Changing the cursor to your own image. +- **Cursor hittest**: Handle or ignore mouse events for a window. +- **Touch events**: Single-touch events. +- **Touch pressure**: Touch events contain information about the amount of force being applied. +- **Multitouch**: Multi-touch events, including cancellation of a gesture. +- **Keyboard events**: Properly processing keyboard events using the user-specified keymap and + translating keypresses into UTF-8 characters, handling dead keys and IMEs. +- **Drag & Drop**: Dragging content into winit, detecting when content enters, drops, or if the drop is cancelled. +- **Raw Device Events**: Capturing input from input devices without any OS filtering. +- **Gamepad/Joystick events**: Capturing input from gamepads and joysticks. +- **Device movement events**: Capturing input from the device gyroscope and accelerometer. + +## Platform +### Windows +* Setting the name of the internal window class +* Setting the taskbar icon +* Setting the parent window +* Setting a menu bar +* `WS_EX_NOREDIRECTIONBITMAP` support +* Theme the title bar according to Windows 10 Dark Mode setting or set a preferred theme +* Changing a system-drawn backdrop +* Setting the window border color +* Setting the title bar background color +* Setting the title color +* Setting the corner rounding preference + +### macOS +* Window activation policy +* Window movable by background +* Transparent titlebar +* Hidden titlebar +* Hidden titlebar buttons +* Full-size content view +* Accepts first mouse +* Set a preferred theme and get current theme. + +### Unix +* Window urgency +* X11 Window Class +* X11 Override Redirect Flag +* GTK Theme Variant +* Base window size +* Setting the X11 parent window + +### iOS +* Get the `UIScreen` object pointer +* Setting the `UIView` hidpi factor +* Valid orientations +* Home indicator visibility +* Status bar visibility and style +* Deferring system gestures +* Getting the device idiom +* Getting the preferred video mode + +### Web +* Get if the systems preferred color scheme is "dark" + +## Compatibility Matrix + +Legend: + +- ✔️: Works as intended +- ▢: Mostly works, but some bugs are known +- ❌: Missing feature or large bugs making it unusable +- **N/A**: Not applicable for this platform +- ❓: Unknown status + +### Windowing +|Feature |Windows|MacOS |Linux x11 |Linux Wayland |Android|iOS |Web |Redox OS| +|-------------------------------- | ----- | ---- | ------- | ----------- | ----- | ----- | -------- | ------ | +|Window initialization |✔️ |✔️ |▢[#5] |✔️ |▢[#33]|▢[#33] |✔️ |✔️ | +|Providing pointer to init OpenGL |✔️ |✔️ |✔️ |✔️ |✔️ |✔️ |**N/A**|✔️ | +|Providing pointer to init Vulkan |✔️ |✔️ |✔️ |✔️ |✔️ |❓ |**N/A**|**N/A** | +|Window decorations |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|**N/A**|✔️ | +|Window decorations toggle |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|**N/A**|**N/A** | +|Window resizing |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|✔️ |✔️ | +|Window resize increments |✔️ |✔️ |✔️ |❌ |**N/A**|**N/A**|**N/A**|**N/A** | +|Window transparency |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|N/A |✔️ | +|Window blur |❌ |❌ |❌ |✔️ |**N/A**|**N/A**|N/A |❌ | +|Window maximization |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|**N/A**|**N/A** | +|Window maximization toggle |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|**N/A**|**N/A** | +|Window minimization |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|**N/A**|**N/A** | +|Fullscreen |✔️ |✔️ |✔️ |✔️ |**N/A**|✔️ |✔️ |**N/A** | +|Fullscreen toggle |✔️ |✔️ |✔️ |✔️ |**N/A**|✔️ |✔️ |**N/A** | +|Exclusive fullscreen |✔️ |✔️ |✔️ |**N/A** |❌ |✔️ |**N/A**|**N/A** | +|HiDPI support |✔️ |✔️ |✔️ |✔️ |✔️ |✔️ |✔️ |❌ | +|Popup windows |❌ |❌ |❌ |❌ |❌ |❌ |**N/A**|**N/A** | + +### System information +|Feature |Windows|MacOS |Linux x11|Linux Wayland|Android|iOS |Web |Redox OS| +|---------------- | ----- | ---- | ------- | ----------- | ----- | ------- | -------- | ------ | +|Monitor list |✔️ |✔️ |✔️ |✔️ |✔️ |✔️ |**N/A**|❌ | +|Video mode query |✔️ |✔️ |✔️ |✔️ |✔️ |✔️ |**N/A**|❌ | + +### Input handling +|Feature |Windows |MacOS |Linux x11|Linux Wayland|Android|iOS |Web |Redox OS| +|----------------------- | ----- | ---- | ------- | ----------- | ----- | ----- | -------- | ------ | +|Mouse events |✔️ |▢[#63] |✔️ |✔️ |**N/A**|**N/A**|✔️ |✔️ | +|Mouse set location |✔️ |✔️ |✔️ |✔️(when locked) |**N/A**|**N/A**|**N/A**|**N/A** | +|Cursor locking |❌ |✔️ |❌ |✔️ |**N/A**|**N/A**|✔️ |❌ | +|Cursor confining |✔️ |❌ |✔️ |✔️ |**N/A**|**N/A**|❌ |❌ | +|Cursor icon |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|✔️ |**N/A** | +|Cursor image |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|✔️ |**N/A** | +|Cursor hittest |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|❌ |❌ | +|Touch events |✔️ |❌ |✔️ |✔️ |✔️ |✔️ |✔️ |**N/A** | +|Touch pressure |✔️ |❌ |❌ |❌ |❌ |✔️ |✔️ |**N/A** | +|Multitouch |✔️ |❌ |✔️ |✔️ |✔️ |✔️ |❌ |**N/A** | +|Keyboard events |✔️ |✔️ |✔️ |✔️ |✔️ |❌ |✔️ |✔️ | +|Drag & Drop |▢[#720] |▢[#720] |▢[#720] |▢[#720] |**N/A**|**N/A**|❓ |**N/A** | +|Raw Device Events |▢[#750] |▢[#750] |▢[#750] |❌ |❌ |❌ |❓ |**N/A** | +|Gamepad/Joystick events |❌[#804] |❌ |❌ |❌ |❌ |❌ |❓ |**N/A** | +|Device movement events |❓ |❓ |❓ |❓ |❌ |❌ |❓ |**N/A** | +|Drag window with cursor |✔️ |✔️ |✔️ |✔️ |**N/A**|**N/A**|**N/A** |**N/A** | +|Resize with cursor |✔️ |❌ |✔️ |✔️ |**N/A**|**N/A**|**N/A** |**N/A** | + +### Pending API Reworks +Changes in the API that have been agreed upon but aren't implemented across all platforms. + +|Feature |Windows|MacOS |Linux x11|Linux Wayland|Android|iOS |Web |Redox OS| +|------------------------------ | ----- | ---- | ------- | ----------- | ----- | ----- | -------- | ------ | +|New API for HiDPI ([#315] [#319]) |✔️ |✔️ |✔️ |✔️ |✔️ |✔️ |❓ |❓ | +|Event Loop 2.0 ([#459]) |✔️ |✔️ |✔️ |✔️ |✔️ |✔️ |❓ |✔️ | +|Keyboard Input 2.0 ([#753]) |✔️ |✔️ |✔️ |✔️ |✔️ |❌ |✔️ |✔️ | + +### Completed API Reworks +|Feature |Windows|MacOS |Linux x11|Linux Wayland|Android|iOS |Web |Redox OS| +|------------------------------ | ----- | ---- | ------- | ----------- | ----- | ----- | -------- | ------ | + +[#165]: https://github.com/rust-windowing/winit/issues/165 +[#219]: https://github.com/rust-windowing/winit/issues/219 +[#242]: https://github.com/rust-windowing/winit/issues/242 +[#306]: https://github.com/rust-windowing/winit/issues/306 +[#315]: https://github.com/rust-windowing/winit/issues/315 +[#319]: https://github.com/rust-windowing/winit/issues/319 +[#33]: https://github.com/rust-windowing/winit/issues/33 +[#459]: https://github.com/rust-windowing/winit/issues/459 +[#5]: https://github.com/rust-windowing/winit/issues/5 +[#63]: https://github.com/rust-windowing/winit/issues/63 +[#720]: https://github.com/rust-windowing/winit/issues/720 +[#721]: https://github.com/rust-windowing/winit/issues/721 +[#750]: https://github.com/rust-windowing/winit/issues/750 +[#753]: https://github.com/rust-windowing/winit/issues/753 +[#804]: https://github.com/rust-windowing/winit/issues/804 diff --git a/third-party/winit-0.30.13-ferrex/FERREX-PATCH.md b/third-party/winit-0.30.13-ferrex/FERREX-PATCH.md new file mode 100644 index 00000000..2a6a9c8b --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/FERREX-PATCH.md @@ -0,0 +1,63 @@ +# Ferrex macOS foreign-view patch + +This directory is a source copy of crates.io `winit` 0.30.13 +(`a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d`). +The upstream license is retained in `LICENSE`. + +Ferrex carries a narrow AppKit patch because its macOS native-mpv presenter +keeps winit's window identity in an unordered staging `NSWindow` while moving +the associated `WinitView` into mpv's externally owned root `NSWindow`. +Upstream 0.30.13 assumes that `WinitView` always remains the staging window's +content view. That assumption makes raw-handle recreation unsafe and leaves +move, scale, focus, cursor, IME, and resize behavior tied to the wrong window +after reparenting. + +The Ferrex delta is intentionally limited to macOS implementation files. It +must: + +- retain the `WinitView` directly instead of recovering it through an unsafe + cast of the staging window's current content view; +- preserve the staging `WindowId` for event routing; +- use the view's actual host `NSWindow` for host-sensitive position, geometry, + scale, focus, cursor, IME, and drag behavior; +- defer host-transition scale/resize delivery to the next main-run-loop turn + so AppKit reparent callbacks cannot re-enter winit's borrowed event handler; +- snapshot foreign-root movement in physical coordinates at notification time + and deliver the newest position exactly once even if detach completes before + the deferred callback, before any donor scale-factor transition can change + how Iced converts that physical position; +- retain the captured foreign scale and view size so a coalesced backing-scale + change is replayed before the final move, keep its size writer valid without + applying that request to the donor, and defer `Resized` until authoritative + donor reconciliation; +- mirror relevant host-window notifications while the view is foreign-hosted; +- remove those observations automatically when the view returns to staging; +- leave staging-window visibility, destruction, and application identity under + normal winit ownership. + +Changed source files: + +- `src/platform_impl/macos/view.rs` splits stable event identity from the + effective AppKit host, binds external-root notifications, preserves a final + host move across notification-to-detach races, reports view-local metrics, + and owns observer/IME/focus cleanup; +- `src/platform_impl/macos/window_delegate.rs` retains the exact view, returns + effective-host queries, and prevents window/lifecycle setters from mutating + either mpv's root or the hidden donor while foreign-hosted; +- `src/platform_impl/macos/window.rs` detaches a foreign-hosted view before the + donor closes. + +Notification registration is object-scoped to the current external root. +Cleanup is deliberately name-scoped with `object: nil`: this removes a stale +registration even when the previous root has already deallocated, while +leaving the independent view-frame notification untouched. + +The donor remains a winit-owned, unordered staging object so Iced keeps its +normal logical window and renderer lifecycle. It is not a presented overlay; +all externally visible window operations remain owned by mpv's root. Ferrex +must route player fullscreen/lifecycle commands through libmpv and native +background drag through the retained root, never through generic donor-window +actions. + +Apple Silicon and Intel acceptance evidence is required before this patch can +be treated as production-qualified. diff --git a/third-party/winit-0.30.13-ferrex/LICENSE b/third-party/winit-0.30.13-ferrex/LICENSE new file mode 100644 index 00000000..ad410e11 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/third-party/winit-0.30.13-ferrex/README.md b/third-party/winit-0.30.13-ferrex/README.md new file mode 100644 index 00000000..ab958874 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/README.md @@ -0,0 +1,70 @@ +# winit - Cross-platform window creation and management in Rust + +[![Crates.io](https://img.shields.io/crates/v/winit.svg)](https://crates.io/crates/winit) +[![Docs.rs](https://docs.rs/winit/badge.svg)](https://docs.rs/winit) +[![Master Docs](https://img.shields.io/github/actions/workflow/status/rust-windowing/winit/docs.yml?branch=master&label=master%20docs +)](https://rust-windowing.github.io/winit/winit/index.html) +[![CI Status](https://github.com/rust-windowing/winit/workflows/CI/badge.svg)](https://github.com/rust-windowing/winit/actions) + +```toml +[dependencies] +winit = "0.30.13" +``` + +## [Documentation](https://docs.rs/winit) + +For features _within_ the scope of winit, see [FEATURES.md](FEATURES.md). + +For features _outside_ the scope of winit, see [Are we GUI Yet?](https://areweguiyet.com/) and [Are we game yet?](https://arewegameyet.rs/), depending on what kind of project you're looking to do. + +## Contact Us + +Join us in our [![Matrix](https://img.shields.io/badge/Matrix-%23rust--windowing%3Amatrix.org-blueviolet.svg)](https://matrix.to/#/#rust-windowing:matrix.org) room. + +The maintainers have a meeting every friday at UTC 15. The meeting notes can be found [here](https://hackmd.io/@winit-meetings). + +## Usage + +Winit is a window creation and management library. It can create windows and lets you handle +events (for example: the window being resized, a key being pressed, a mouse movement, etc.) +produced by the window. + +Winit is designed to be a low-level brick in a hierarchy of libraries. Consequently, in order to +show something on the window you need to use the platform-specific getters provided by winit, or +another library. + +## CONTRIBUTING + +For contributing guidelines see [CONTRIBUTING.md](./CONTRIBUTING.md). + +## MSRV Policy + +This crate's Minimum Supported Rust Version (MSRV) is **1.70**. Changes to +the MSRV will be accompanied by a minor version bump. + +As a **tentative** policy, the upper bound of the MSRV is given by the following +formula: + +``` +min(sid, stable - 3) +``` + +Where `sid` is the current version of `rustc` provided by [Debian Sid], and +`stable` is the latest stable version of Rust. This bound may be broken in case of a major ecosystem shift or a security vulnerability. + +[Debian Sid]: https://packages.debian.org/sid/rustc + +The exception is for the Android platform, where a higher Rust version +must be used for certain Android features. In this case, the MSRV will be +capped at the latest stable version of Rust minus three. This inconsistency is +not reflected in Cargo metadata, as it is not powerful enough to expose this +restriction. + +All crates in the [`rust-windowing`] organizations have the +same MSRV policy. + +[`rust-windowing`]: https://github.com/rust-windowing + +### Platform-specific usage + +Check out the [`winit::platform`](https://rust-windowing.github.io/winit/winit/platform/index.html) module for platform-specific usage. diff --git a/third-party/winit-0.30.13-ferrex/build.rs b/third-party/winit-0.30.13-ferrex/build.rs new file mode 100644 index 00000000..6a4528b3 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/build.rs @@ -0,0 +1,27 @@ +use cfg_aliases::cfg_aliases; + +fn main() { + // The script doesn't depend on our code. + println!("cargo:rerun-if-changed=build.rs"); + + // Setup cfg aliases. + cfg_aliases! { + // Systems. + android_platform: { target_os = "android" }, + web_platform: { all(target_family = "wasm", target_os = "unknown") }, + macos_platform: { target_os = "macos" }, + ios_platform: { target_os = "ios" }, + windows_platform: { target_os = "windows" }, + apple: { any(target_os = "ios", target_os = "macos") }, + free_unix: { all(unix, not(apple), not(android_platform), not(target_os = "emscripten")) }, + redox: { target_os = "redox" }, + + // Native displays. + x11_platform: { all(feature = "x11", free_unix, not(redox)) }, + wayland_platform: { all(feature = "wayland", free_unix, not(redox)) }, + orbital_platform: { redox }, + } + + // Winit defined cfgs. + println!("cargo:rustc-check-cfg=cfg(unreleased_changelogs)"); +} diff --git a/third-party/winit-0.30.13-ferrex/src/application.rs b/third-party/winit-0.30.13-ferrex/src/application.rs new file mode 100644 index 00000000..977a7c79 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/application.rs @@ -0,0 +1,339 @@ +//! End user application handling. + +use crate::event::{DeviceEvent, DeviceId, StartCause, WindowEvent}; +use crate::event_loop::ActiveEventLoop; +use crate::window::WindowId; + +/// The handler of the application events. +pub trait ApplicationHandler { + /// Emitted when new events arrive from the OS to be processed. + /// + /// This is a useful place to put code that should be done before you start processing + /// events, such as updating frame timing information for benchmarking or checking the + /// [`StartCause`] to see if a timer set by + /// [`ControlFlow::WaitUntil`][crate::event_loop::ControlFlow::WaitUntil] has elapsed. + fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) { + let _ = (event_loop, cause); + } + + /// Emitted when the application has been resumed. + /// + /// For consistency, all platforms emit a `Resumed` event even if they don't themselves have a + /// formal suspend/resume lifecycle. For systems without a formal suspend/resume lifecycle + /// the `Resumed` event is always emitted after the + /// [`NewEvents(StartCause::Init)`][StartCause::Init] event. + /// + /// # Portability + /// + /// It's recommended that applications should only initialize their graphics context and create + /// a window after they have received their first `Resumed` event. Some systems + /// (specifically Android) won't allow applications to create a render surface until they are + /// resumed. + /// + /// Considering that the implementation of [`Suspended`] and `Resumed` events may be internally + /// driven by multiple platform-specific events, and that there may be subtle differences across + /// platforms with how these internal events are delivered, it's recommended that applications + /// be able to gracefully handle redundant (i.e. back-to-back) [`Suspended`] or `Resumed` + /// events. + /// + /// Also see [`Suspended`] notes. + /// + /// ## Android + /// + /// On Android, the `Resumed` event is sent when a new [`SurfaceView`] has been created. This is + /// expected to closely correlate with the [`onResume`] lifecycle event but there may + /// technically be a discrepancy. + /// + /// [`onResume`]: https://developer.android.com/reference/android/app/Activity#onResume() + /// + /// Applications that need to run on Android must wait until they have been `Resumed` + /// before they will be able to create a render surface (such as an `EGLSurface`, + /// [`VkSurfaceKHR`] or [`wgpu::Surface`]) which depend on having a + /// [`SurfaceView`]. Applications must also assume that if they are [`Suspended`], then their + /// render surfaces are invalid and should be dropped. + /// + /// Also see [`Suspended`] notes. + /// + /// [`SurfaceView`]: https://developer.android.com/reference/android/view/SurfaceView + /// [Activity lifecycle]: https://developer.android.com/guide/components/activities/activity-lifecycle + /// [`VkSurfaceKHR`]: https://www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VkSurfaceKHR.html + /// [`wgpu::Surface`]: https://docs.rs/wgpu/latest/wgpu/struct.Surface.html + /// + /// ## iOS + /// + /// On iOS, the `Resumed` event is emitted in response to an [`applicationDidBecomeActive`] + /// callback which means the application is "active" (according to the + /// [iOS application lifecycle]). + /// + /// [`applicationDidBecomeActive`]: https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622956-applicationdidbecomeactive + /// [iOS application lifecycle]: https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle + /// + /// ## Web + /// + /// On Web, the `Resumed` event is emitted in response to a [`pageshow`] event + /// with the property [`persisted`] being true, which means that the page is being + /// restored from the [`bfcache`] (back/forward cache) - an in-memory cache that + /// stores a complete snapshot of a page (including the JavaScript heap) as the + /// user is navigating away. + /// + /// [`pageshow`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/pageshow_event + /// [`persisted`]: https://developer.mozilla.org/en-US/docs/Web/API/PageTransitionEvent/persisted + /// [`bfcache`]: https://web.dev/bfcache/ + /// [`Suspended`]: Self::suspended + fn resumed(&mut self, event_loop: &ActiveEventLoop); + + /// Emitted when an event is sent from [`EventLoopProxy::send_event`]. + /// + /// [`EventLoopProxy::send_event`]: crate::event_loop::EventLoopProxy::send_event + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: T) { + let _ = (event_loop, event); + } + + /// Emitted when the OS sends an event to a winit window. + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ); + + /// Emitted when the OS sends an event to a device. + fn device_event( + &mut self, + event_loop: &ActiveEventLoop, + device_id: DeviceId, + event: DeviceEvent, + ) { + let _ = (event_loop, device_id, event); + } + + /// Emitted when the event loop is about to block and wait for new events. + /// + /// Most applications shouldn't need to hook into this event since there is no real relationship + /// between how often the event loop needs to wake up and the dispatching of any specific + /// events. + /// + /// High frequency event sources, such as input devices could potentially lead to lots of wake + /// ups and also lots of corresponding `AboutToWait` events. + /// + /// This is not an ideal event to drive application rendering from and instead applications + /// should render in response to [`WindowEvent::RedrawRequested`] events. + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + let _ = event_loop; + } + + /// Emitted when the application has been suspended. + /// + /// # Portability + /// + /// Not all platforms support the notion of suspending applications, and there may be no + /// technical way to guarantee being able to emit a `Suspended` event if the OS has + /// no formal application lifecycle (currently only Android, iOS, and Web do). For this reason, + /// Winit does not currently try to emit pseudo `Suspended` events before the application + /// quits on platforms without an application lifecycle. + /// + /// Considering that the implementation of `Suspended` and [`Resumed`] events may be internally + /// driven by multiple platform-specific events, and that there may be subtle differences across + /// platforms with how these internal events are delivered, it's recommended that applications + /// be able to gracefully handle redundant (i.e. back-to-back) `Suspended` or [`Resumed`] + /// events. + /// + /// Also see [`Resumed`] notes. + /// + /// ## Android + /// + /// On Android, the `Suspended` event is only sent when the application's associated + /// [`SurfaceView`] is destroyed. This is expected to closely correlate with the [`onPause`] + /// lifecycle event but there may technically be a discrepancy. + /// + /// [`onPause`]: https://developer.android.com/reference/android/app/Activity#onPause() + /// + /// Applications that need to run on Android should assume their [`SurfaceView`] has been + /// destroyed, which indirectly invalidates any existing render surfaces that may have been + /// created outside of Winit (such as an `EGLSurface`, [`VkSurfaceKHR`] or [`wgpu::Surface`]). + /// + /// After being `Suspended` on Android applications must drop all render surfaces before + /// the event callback completes, which may be re-created when the application is next + /// [`Resumed`]. + /// + /// [`SurfaceView`]: https://developer.android.com/reference/android/view/SurfaceView + /// [Activity lifecycle]: https://developer.android.com/guide/components/activities/activity-lifecycle + /// [`VkSurfaceKHR`]: https://www.khronos.org/registry/vulkan/specs/1.3-extensions/man/html/VkSurfaceKHR.html + /// [`wgpu::Surface`]: https://docs.rs/wgpu/latest/wgpu/struct.Surface.html + /// + /// ## iOS + /// + /// On iOS, the `Suspended` event is currently emitted in response to an + /// [`applicationWillResignActive`] callback which means that the application is + /// about to transition from the active to inactive state (according to the + /// [iOS application lifecycle]). + /// + /// [`applicationWillResignActive`]: https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622950-applicationwillresignactive + /// [iOS application lifecycle]: https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle + /// + /// ## Web + /// + /// On Web, the `Suspended` event is emitted in response to a [`pagehide`] event + /// with the property [`persisted`] being true, which means that the page is being + /// put in the [`bfcache`] (back/forward cache) - an in-memory cache that stores a + /// complete snapshot of a page (including the JavaScript heap) as the user is + /// navigating away. + /// + /// [`pagehide`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event + /// [`persisted`]: https://developer.mozilla.org/en-US/docs/Web/API/PageTransitionEvent/persisted + /// [`bfcache`]: https://web.dev/bfcache/ + /// [`Resumed`]: Self::resumed + fn suspended(&mut self, event_loop: &ActiveEventLoop) { + let _ = event_loop; + } + + /// Emitted when the event loop is being shut down. + /// + /// This is irreversible - if this method is called, it is guaranteed that the event loop + /// will exit right after. + fn exiting(&mut self, event_loop: &ActiveEventLoop) { + let _ = event_loop; + } + + /// Emitted when the application has received a memory warning. + /// + /// ## Platform-specific + /// + /// ### Android + /// + /// On Android, the `MemoryWarning` event is sent when [`onLowMemory`] was called. The + /// application must [release memory] or risk being killed. + /// + /// [`onLowMemory`]: https://developer.android.com/reference/android/app/Application.html#onLowMemory() + /// [release memory]: https://developer.android.com/topic/performance/memory#release + /// + /// ### iOS + /// + /// On iOS, the `MemoryWarning` event is emitted in response to an + /// [`applicationDidReceiveMemoryWarning`] callback. The application must free as much + /// memory as possible or risk being terminated, see [how to respond to memory warnings]. + /// + /// [`applicationDidReceiveMemoryWarning`]: https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623063-applicationdidreceivememorywarni + /// [how to respond to memory warnings]: https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle/responding_to_memory_warnings + /// + /// ### Others + /// + /// - **macOS / Orbital / Wayland / Web / Windows:** Unsupported. + fn memory_warning(&mut self, event_loop: &ActiveEventLoop) { + let _ = event_loop; + } +} + +impl, T: 'static> ApplicationHandler for &mut A { + #[inline] + fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) { + (**self).new_events(event_loop, cause); + } + + #[inline] + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + (**self).resumed(event_loop); + } + + #[inline] + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: T) { + (**self).user_event(event_loop, event); + } + + #[inline] + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + (**self).window_event(event_loop, window_id, event); + } + + #[inline] + fn device_event( + &mut self, + event_loop: &ActiveEventLoop, + device_id: DeviceId, + event: DeviceEvent, + ) { + (**self).device_event(event_loop, device_id, event); + } + + #[inline] + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + (**self).about_to_wait(event_loop); + } + + #[inline] + fn suspended(&mut self, event_loop: &ActiveEventLoop) { + (**self).suspended(event_loop); + } + + #[inline] + fn exiting(&mut self, event_loop: &ActiveEventLoop) { + (**self).exiting(event_loop); + } + + #[inline] + fn memory_warning(&mut self, event_loop: &ActiveEventLoop) { + (**self).memory_warning(event_loop); + } +} + +impl, T: 'static> ApplicationHandler for Box { + #[inline] + fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) { + (**self).new_events(event_loop, cause); + } + + #[inline] + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + (**self).resumed(event_loop); + } + + #[inline] + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: T) { + (**self).user_event(event_loop, event); + } + + #[inline] + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + (**self).window_event(event_loop, window_id, event); + } + + #[inline] + fn device_event( + &mut self, + event_loop: &ActiveEventLoop, + device_id: DeviceId, + event: DeviceEvent, + ) { + (**self).device_event(event_loop, device_id, event); + } + + #[inline] + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + (**self).about_to_wait(event_loop); + } + + #[inline] + fn suspended(&mut self, event_loop: &ActiveEventLoop) { + (**self).suspended(event_loop); + } + + #[inline] + fn exiting(&mut self, event_loop: &ActiveEventLoop) { + (**self).exiting(event_loop); + } + + #[inline] + fn memory_warning(&mut self, event_loop: &ActiveEventLoop) { + (**self).memory_warning(event_loop); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/mod.rs b/third-party/winit-0.30.13-ferrex/src/changelog/mod.rs new file mode 100644 index 00000000..a05ea9cc --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/mod.rs @@ -0,0 +1,77 @@ +//! # Changelog and migrations +//! +//! All notable changes to this project will be documented in this module, +//! along with migration instructions for larger changes. +// Put the current entry at the top of this page, for discoverability. +// See `.cargo/config.toml` for details about `unreleased_changelogs`. +#![cfg_attr(unreleased_changelogs, doc = include_str!("unreleased.md"))] +#![cfg_attr(not(unreleased_changelogs), doc = include_str!("v0.30.md"))] + +#[doc = include_str!("v0.30.md")] +pub mod v0_30 {} + +#[doc = include_str!("v0.29.md")] +pub mod v0_29 {} + +#[doc = include_str!("v0.28.md")] +pub mod v0_28 {} + +#[doc = include_str!("v0.27.md")] +pub mod v0_27 {} + +#[doc = include_str!("v0.26.md")] +pub mod v0_26 {} + +#[doc = include_str!("v0.25.md")] +pub mod v0_25 {} + +#[doc = include_str!("v0.24.md")] +pub mod v0_24 {} + +#[doc = include_str!("v0.23.md")] +pub mod v0_23 {} + +#[doc = include_str!("v0.22.md")] +pub mod v0_22 {} + +#[doc = include_str!("v0.21.md")] +pub mod v0_21 {} + +#[doc = include_str!("v0.20.md")] +pub mod v0_20 {} + +#[doc = include_str!("v0.19.md")] +pub mod v0_19 {} + +#[doc = include_str!("v0.18.md")] +pub mod v0_18 {} + +#[doc = include_str!("v0.17.md")] +pub mod v0_17 {} + +#[doc = include_str!("v0.16.md")] +pub mod v0_16 {} + +#[doc = include_str!("v0.15.md")] +pub mod v0_15 {} + +#[doc = include_str!("v0.14.md")] +pub mod v0_14 {} + +#[doc = include_str!("v0.13.md")] +pub mod v0_13 {} + +#[doc = include_str!("v0.12.md")] +pub mod v0_12 {} + +#[doc = include_str!("v0.11.md")] +pub mod v0_11 {} + +#[doc = include_str!("v0.10.md")] +pub mod v0_10 {} + +#[doc = include_str!("v0.9.md")] +pub mod v0_9 {} + +#[doc = include_str!("v0.8.md")] +pub mod v0_8 {} diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/unreleased.md b/third-party/winit-0.30.13-ferrex/src/changelog/unreleased.md new file mode 100644 index 00000000..f3a0f6d2 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/unreleased.md @@ -0,0 +1,41 @@ +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +The sections should follow the order `Added`, `Changed`, `Deprecated`, +`Removed`, and `Fixed`. + +Platform specific changed should be added to the end of the section and grouped +by platform name. Common API additions should have `, implemented` at the end +for platforms where the API was initially implemented. See the following example +on how to add them: + +```md +### Added + +- Add `Window::turbo()`, implemented on X11, Wayland, and Web. +- On X11, add `Window::some_rare_api`. +- On X11, add `Window::even_more_rare_api`. +- On Wayland, add `Window::common_api`. +- On Windows, add `Window::some_rare_api`. +``` + +When the change requires non-trivial amount of work for users to comply +with it, the migration guide should be added below the entry, like: + +```md +- Deprecate `Window` creation outside of `EventLoop::run` + + This was done to simply migration in the future. Consider the + following code: + + // Code snippet. + + To migrate it we should do X, Y, and then Z, for example: + + // Code snippet. + +``` + +The migration guide could reference other migration examples in the current +changelog entry. + +## Unreleased diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.10.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.10.md new file mode 100644 index 00000000..9e47c0b1 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.10.md @@ -0,0 +1,13 @@ +## 0.10.1 + +_Yanked_ + +## 0.10.0 + +- Add support for `Touch` for emscripten backend. +- Added support for `DroppedFile`, `HoveredFile`, and `HoveredFileCancelled` to X11 backend. +- **Breaking:** `unix::WindowExt` no longer returns pointers for things that aren't actually pointers; `get_xlib_window` now returns `Option` and `get_xlib_screen_id` returns `Option`. Additionally, methods that previously returned `libc::c_void` have been changed to return `std::os::raw::c_void`, which are not interchangeable types, so users wanting the former will need to explicitly cast. +- Added `set_decorations` method to `Window` to allow decorations to be toggled after the window is built. Presently only implemented on X11. +- Raised the minimum supported version of Rust to 1.20 on MacOS due to usage of associated constants in new versions of cocoa and core-graphics. +- Added `modifiers` field to `MouseInput`, `MouseWheel`, and `CursorMoved` events to track the modifiers state (`ModifiersState`). +- Fixed the emscripten backend to return the size of the canvas instead of the size of the window. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.11.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.11.md new file mode 100644 index 00000000..31fb1d3b --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.11.md @@ -0,0 +1,27 @@ +## 0.11.3 + +- Added `set_min_dimensions` and `set_max_dimensions` methods to `Window`, and implemented on Windows, X11, Wayland, and OSX. +- On X11, dropping a `Window` actually closes it now, and clicking the window's × button (or otherwise having the WM signal to close it) will result in the window closing. +- Added `WindowBuilderExt` methods for macos: `with_titlebar_transparent`, + `with_title_hidden`, `with_titlebar_buttons_hidden`, + `with_fullsize_content_view`. +- Mapped X11 numpad keycodes (arrows, Home, End, PageUp, PageDown, Insert and Delete) to corresponding virtual keycodes + +## 0.11.2 + +- Impl `Hash`, `PartialEq`, and `Eq` for `events::ModifiersState`. +- Implement `MonitorId::get_hidpi_factor` for MacOS. +- Added method `os::macos::MonitorIdExt::get_nsscreen() -> *mut c_void` that gets a `NSScreen` object matching the monitor ID. +- Send `Awakened` event on Android when event loop is woken up. + +## 0.11.1 + +- Fixed windows not receiving mouse events when click-dragging the mouse outside the client area of a window, on Windows platforms. +- Added method `os::android::EventsLoopExt:set_suspend_callback(Option ()>>)` that allows glutin to register a callback when a suspend event happens + +## 0.11.0 + +- Implement `MonitorId::get_dimensions` for Android. +- Added method `os::macos::WindowBuilderExt::with_movable_by_window_background(bool)` that allows to move a window without a titlebar - `with_decorations(false)` +- Implement `Window::set_fullscreen`, `Window::set_maximized` and `Window::set_decorations` for Wayland. +- Added `Caret` as VirtualKeyCode and support OSX ^-Key with german input. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.12.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.12.md new file mode 100644 index 00000000..3f9c83d8 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.12.md @@ -0,0 +1,8 @@ +## 0.12.0 + +- Added subclass to macos windows so they can be made resizable even with no decorations. +- Dead keys now work properly on X11, no longer resulting in a panic. +- On X11, input method creation first tries to use the value from the user's `XMODIFIERS` environment variable, so application developers should no longer need to manually call `XSetLocaleModifiers`. If that fails, fallbacks are tried, which should prevent input method initialization from ever outright failing. +- Fixed thread safety issues with input methods on X11. +- Add support for `Touch` for win32 backend. +- Fixed `Window::get_inner_size` and friends to return the size in pixels instead of points when using HIDPI displays on OSX. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.13.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.13.md new file mode 100644 index 00000000..fd45665f --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.13.md @@ -0,0 +1,20 @@ +## 0.13.1 + +- Ensure necessary `x11-dl` version is used. + +## 0.13.0 + +- Implement `WindowBuilder::with_maximized`, `Window::set_fullscreen`, `Window::set_maximized` and `Window::set_decorations` for MacOS. +- Implement `WindowBuilder::with_maximized`, `Window::set_fullscreen`, `Window::set_maximized` and `Window::set_decorations` for Windows. +- On Windows, `WindowBuilder::with_fullscreen` no longer changing monitor display resolution. +- Overhauled X11 window geometry calculations. `get_position` and `set_position` are more universally accurate across different window managers, and `get_outer_size` actually works now. +- Fixed SIGSEGV/SIGILL crashes on macOS caused by stabilization of the `!` (never) type. +- Implement `WindowEvent::HiDPIFactorChanged` for macOS +- On X11, input methods now work completely out of the box, no longer requiring application developers to manually call `setlocale`. Additionally, when input methods are started, stopped, or restarted on the server end, it's correctly handled. +- Implemented `Refresh` event on Windows. +- Properly calculate the minimum and maximum window size on Windows, including window decorations. +- Map more `MouseCursor` variants to cursor icons on Windows. +- Corrected `get_position` on macOS to return outer frame position, not content area position. +- Corrected `set_position` on macOS to set outer frame position, not content area position. +- Added `get_inner_position` method to `Window`, which gets the position of the window's client area. This is implemented on all applicable platforms (all desktop platforms other than Wayland, where this isn't possible). +- **Breaking:** the `Closed` event has been replaced by `CloseRequested` and `Destroyed`. To migrate, you typically just need to replace all usages of `Closed` with `CloseRequested`; see example programs for more info. The exception is iOS, where `Closed` must be replaced by `Destroyed`. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.14.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.14.md new file mode 100644 index 00000000..822ae209 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.14.md @@ -0,0 +1,21 @@ +## 0.14.0 + +- Created the `Copy`, `Paste` and `Cut` `VirtualKeyCode`s and added support for them on X11 and Wayland +- Fix `.with_decorations(false)` in macOS +- On Mac, `NSWindow` and supporting objects might be alive long after they were `closed` which resulted in apps consuming more heap then needed. Mainly it was affecting multi window applications. Not expecting any user visible change of behaviour after the fix. +- Fix regression of Window platform extensions for macOS where `NSFullSizeContentViewWindowMask` was not being correctly applied to `.fullsize_content_view`. +- Corrected `get_position` on Windows to be relative to the screen rather than to the taskbar. +- Corrected `Moved` event on Windows to use position values equivalent to those returned by `get_position`. It previously supplied client area positions instead of window positions, and would additionally interpret negative values as being very large (around `u16::MAX`). +- Implemented `Moved` event on macOS. +- On X11, the `Moved` event correctly use window positions rather than client area positions. Additionally, a stray `Moved` that unconditionally accompanied `Resized` with the client area position relative to the parent has been eliminated; `Moved` is still received alongside `Resized`, but now only once and always correctly. +- On Windows, implemented all variants of `DeviceEvent` other than `Text`. Mouse `DeviceEvent`s are now received even if the window isn't in the foreground. +- `DeviceId` on Windows is no longer a unit struct, and now contains a `u32`. For `WindowEvent`s, this will always be 0, but on `DeviceEvent`s it will be the handle to that device. `DeviceIdExt::get_persistent_identifier` can be used to acquire a unique identifier for that device that persists across replugs/reboots/etc. +- Corrected `run_forever` on X11 to stop discarding `Awakened` events. +- Various safety and correctness improvements to the X11 backend internals. +- Fixed memory leak on X11 every time the mouse entered the window. +- On X11, drag and drop now works reliably in release mode. +- Added `WindowBuilderExt::with_resize_increments` and `WindowBuilderExt::with_base_size` to X11, allowing for more optional hints to be set. +- Rework of the wayland backend, migrating it to use [Smithay's Client Toolkit](https://github.com/Smithay/client-toolkit). +- Added `WindowBuilder::with_window_icon` and `Window::set_window_icon`, finally making it possible to set the window icon on Windows and X11. The `icon_loading` feature can be enabled to allow for icons to be easily loaded; see example program `window_icon.rs` for usage. +- Windows additionally has `WindowBuilderExt::with_taskbar_icon` and `WindowExt::set_taskbar_icon`. +- On Windows, fix panic when trying to call `set_fullscreen(None)` on a window that has not been fullscreened prior. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.15.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.15.md new file mode 100644 index 00000000..f5fc4853 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.15.md @@ -0,0 +1,42 @@ +## 0.15.1 + +- On X11, the `Moved` event is no longer sent when the window is resized without changing position. +- `MouseCursor` and `CursorState` now implement `Default`. +- `WindowBuilder::with_resizable` implemented for Windows, X11, Wayland, and macOS. +- `Window::set_resizable` implemented for Windows, X11, Wayland, and macOS. +- On X11, if the monitor's width or height in millimeters is reported as 0, the DPI is now 1.0 instead of +inf. +- On X11, the environment variable `WINIT_HIDPI_FACTOR` has been added for overriding DPI factor. +- On X11, enabling transparency no longer causes the window contents to flicker when resizing. +- On X11, `with_override_redirect` now actually enables override redirect. +- macOS now generates `VirtualKeyCode::LAlt` and `VirtualKeyCode::RAlt` instead of `None` for both. +- On macOS, `VirtualKeyCode::RWin` and `VirtualKeyCode::LWin` are no longer switched. +- On macOS, windows without decorations can once again be resized. +- Fixed race conditions when creating an `EventsLoop` on X11, most commonly manifesting as `"[xcb] Unknown sequence number while processing queue"`. +- On macOS, `CursorMoved` and `MouseInput` events are only generated if they occurs within the window's client area. +- On macOS, resizing the window no longer generates a spurious `MouseInput` event. + +## 0.15.0 + +- `Icon::to_cardinals` is no longer public, since it was never supposed to be. +- Wayland: improve diagnostics if initialization fails +- Fix some system event key doesn't work when focused, do not block keyevent forward to system on macOS +- On X11, the scroll wheel position is now correctly reset on i3 and other WMs that have the same quirk. +- On X11, `Window::get_current_monitor` now reliably returns the correct monitor. +- On X11, `Window::hidpi_factor` returns values from XRandR rather than the inaccurate values previously queried from the core protocol. +- On X11, the primary monitor is detected correctly even when using versions of XRandR less than 1.5. +- `MonitorId` now implements `Debug`. +- Fixed bug on macOS where using `with_decorations(false)` would cause `set_decorations(true)` to produce a transparent titlebar with no title. +- Implemented `MonitorId::get_position` on macOS. +- On macOS, `Window::get_current_monitor` now returns accurate values. +- Added `WindowBuilderExt::with_resize_increments` to macOS. +- **Breaking:** On X11, `WindowBuilderExt::with_resize_increments` and `WindowBuilderExt::with_base_size` now take `u32` values rather than `i32`. +- macOS keyboard handling has been overhauled, allowing for the use of dead keys, IME, etc. Right modifier keys are also no longer reported as being left. +- Added the `Window::set_ime_spot(x: i32, y: i32)` method, which is implemented on X11 and macOS. +- **Breaking**: `os::unix::WindowExt::send_xim_spot(x: i16, y: i16)` no longer exists. Switch to the new `Window::set_ime_spot(x: i32, y: i32)`, which has equivalent functionality. +- Fixed detection of `Pause` and `Scroll` keys on Windows. +- On Windows, alt-tabbing while the cursor is grabbed no longer makes it impossible to re-grab the cursor. +- On Windows, using `CursorState::Hide` when the cursor is grabbed now ungrabs the cursor first. +- Implemented `MouseCursor::NoneCursor` on Windows. +- Added `WindowBuilder::with_always_on_top` and `Window::set_always_on_top`. Implemented on Windows, macOS, and X11. +- On X11, `WindowBuilderExt` now has `with_class`, `with_override_redirect`, and `with_x11_window_type` to allow for more control over window creation. `WindowExt` additionally has `set_urgent`. +- More hints are set by default on X11, including `_NET_WM_PID` and `WM_CLIENT_MACHINE`. Note that prior to this, the `WM_CLASS` hint was automatically set to whatever value was passed to `with_title`. It's now set to the executable name to better conform to expectations and the specification; if this is undesirable, you must explicitly use `WindowBuilderExt::with_class`. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.16.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.16.md new file mode 100644 index 00000000..126ad9f7 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.16.md @@ -0,0 +1,32 @@ +## 0.16.2 + +- On Windows, non-resizable windows now have the maximization button disabled. This is consistent with behavior on macOS and popular X11 WMs. +- Corrected incorrect `unreachable!` usage when guessing the DPI factor with no detected monitors. + +## 0.16.1 + +- Added logging through `log`. Logging will become more extensive over time. +- On X11 and Windows, the window's DPI factor is guessed before creating the window. This _greatly_ cuts back on unsightly auto-resizing that would occur immediately after window creation. +- Fixed X11 backend compilation for environments where `c_char` is unsigned. + +## 0.16.0 + +- Windows additionally has `WindowBuilderExt::with_no_redirection_bitmap`. +- **Breaking:** Removed `VirtualKeyCode::LMenu` and `VirtualKeyCode::RMenu`; Windows now generates `VirtualKeyCode::LAlt` and `VirtualKeyCode::RAlt` instead. +- On X11, exiting fullscreen no longer leaves the window in the monitor's top left corner. +- **Breaking:** `Window::hidpi_factor` has been renamed to `Window::get_hidpi_factor` for better consistency. `WindowEvent::HiDPIFactorChanged` has been renamed to `WindowEvent::HiDpiFactorChanged`. DPI factors are always represented as `f64` instead of `f32` now. +- The Windows backend is now DPI aware. `WindowEvent::HiDpiFactorChanged` is implemented, and `MonitorId::get_hidpi_factor` and `Window::hidpi_factor` return accurate values. +- Implemented `WindowEvent::HiDpiFactorChanged` on X11. +- On macOS, `Window::set_cursor_position` is now relative to the client area. +- On macOS, setting the maximum and minimum dimensions now applies to the client area dimensions rather than to the window dimensions. +- On iOS, `MonitorId::get_dimensions` has been implemented and both `MonitorId::get_hidpi_factor` and `Window::get_hidpi_factor` return accurate values. +- On Emscripten, `MonitorId::get_hidpi_factor` now returns the same value as `Window::get_hidpi_factor` (it previously would always return 1.0). +- **Breaking:** The entire API for sizes, positions, etc. has changed. In the majority of cases, winit produces and consumes positions and sizes as `LogicalPosition` and `LogicalSize`, respectively. The notable exception is `MonitorId` methods, which deal in `PhysicalPosition` and `PhysicalSize`. See the documentation for specifics and explanations of the types. Additionally, winit automatically conserves logical size when the DPI factor changes. +- **Breaking:** All deprecated methods have been removed. For `Window::platform_display` and `Window::platform_window`, switch to the appropriate platform-specific `WindowExt` methods. For `Window::get_inner_size_points` and `Window::get_inner_size_pixels`, use the `LogicalSize` returned by `Window::get_inner_size` and convert as needed. +- HiDPI support for Wayland. +- `EventsLoop::get_available_monitors` and `EventsLoop::get_primary_monitor` now have identical counterparts on `Window`, so this information can be acquired without an `EventsLoop` borrow. +- `AvailableMonitorsIter` now implements `Debug`. +- Fixed quirk on macOS where certain keys would generate characters at twice the normal rate when held down. +- On X11, all event loops now share the same `XConnection`. +- **Breaking:** `Window::set_cursor_state` and `CursorState` enum removed in favor of the more composable `Window::grab_cursor` and `Window::hide_cursor`. As a result, grabbing the cursor no longer automatically hides it; you must call both methods to retain the old behavior on Windows and macOS. `Cursor::NoneCursor` has been removed, as it's no longer useful. +- **Breaking:** `Window::set_cursor_position` now returns `Result<(), String>`, thus allowing for `Box` conversion via `?`. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.17.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.17.md new file mode 100644 index 00000000..0a1abc8b --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.17.md @@ -0,0 +1,23 @@ +## 0.17.2 + +- On macOS, fix `` so applications receive the event. +- On macOS, fix `` so applications receive the event. +- On Wayland, key press events will now be repeated. + +## 0.17.1 + +- On X11, prevent a compilation failure in release mode for versions of Rust greater than or equal to 1.30. +- Fixed deadlock that broke fullscreen mode on Windows. + +## 0.17.0 + +- Cocoa and core-graphics updates. +- Fixed thread-safety issues in several `Window` functions on Windows. +- On MacOS, the key state for modifiers key events is now properly set. +- On iOS, the view is now set correctly. This makes it possible to render things (instead of being stuck on a black screen), and touch events work again. +- Added NetBSD support. +- **Breaking:** On iOS, `UIView` is now the default root view. `WindowBuilderExt::with_root_view_class` can be used to set the root view objective-c class to `GLKView` (OpenGLES) or `MTKView` (Metal/MoltenVK). +- On iOS, the `UIApplication` is not started until `Window::new` is called. +- Fixed thread unsafety with cursor hiding on macOS. +- On iOS, fixed the size of the `JmpBuf` type used for `setjmp`/`longjmp` calls. Previously this was a buffer overflow on most architectures. +- On Windows, use cached window DPI instead of repeatedly querying the system. This fixes sporadic crashes on Windows 7. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.18.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.18.md new file mode 100644 index 00000000..e98df5bc --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.18.md @@ -0,0 +1,52 @@ +## 0.18.1 + +- On macOS, fix `Yen` (JIS) so applications receive the event. +- On X11 with a tiling WM, fixed high CPU usage when moving windows across monitors. +- On X11, fixed panic caused by dropping the window before running the event loop. +- on macOS, added `WindowExt::set_simple_fullscreen` which does not require a separate space +- Introduce `WindowBuilderExt::with_app_id` to allow setting the application ID on Wayland. +- On Windows, catch panics in event loop child thread and forward them to the parent thread. This prevents an invocation of undefined behavior due to unwinding into foreign code. +- On Windows, fix issue where resizing or moving window combined with grabbing the cursor would freeze program. +- On Windows, fix issue where resizing or moving window would eat `Awakened` events. +- On Windows, exiting fullscreen after entering fullscreen with disabled decorations no longer shrinks window. +- On X11, fixed a segfault when using virtual monitors with XRandR. +- Derive `Ord` and `PartialOrd` for `VirtualKeyCode` enum. +- On Windows, fix issue where hovering or dropping a non file item would create a panic. +- On Wayland, fix resizing and DPI calculation when a `wl_output` is removed without sending a `leave` event to the `wl_surface`, such as disconnecting a monitor from a laptop. +- On Wayland, DPI calculation is handled by smithay-client-toolkit. +- On X11, `WindowBuilder::with_min_dimensions` and `WindowBuilder::with_max_dimensions` now correctly account for DPI. +- Added support for generating dummy `DeviceId`s and `WindowId`s to better support unit testing. +- On macOS, fixed unsoundness in drag-and-drop that could result in drops being rejected. +- On macOS, implemented `WindowEvent::Refresh`. +- On macOS, all `MouseCursor` variants are now implemented and the cursor will no longer reset after unfocusing. +- Removed minimum supported Rust version guarantee. + +## 0.18.0 + +- **Breaking:** `image` crate upgraded to 0.20. This is exposed as part of the `icon_loading` API. +- On Wayland, pointer events will now provide the current modifiers state. +- On Wayland, titles will now be displayed in the window header decoration. +- On Wayland, key repetition is now ended when keyboard loses focus. +- On Wayland, windows will now use more stylish and modern client side decorations. +- On Wayland, windows will use server-side decorations when available. +- **Breaking:** Added support for F16-F24 keys (variants were added to the `VirtualKeyCode` enum). +- Fixed graphical glitches when resizing on Wayland. +- On Windows, fix freezes when performing certain actions after a window resize has been triggered. Reintroduces some visual artifacts when resizing. +- Updated window manager hints under X11 to v1.5 of [Extended Window Manager Hints](https://specifications.freedesktop.org/wm-spec/wm-spec-1.5.html#idm140200472629520). +- Added `WindowBuilderExt::with_gtk_theme_variant` to X11-specific `WindowBuilder` functions. +- Fixed UTF8 handling bug in X11 `set_title` function. +- On Windows, `Window::set_cursor` now applies immediately instead of requiring specific events to occur first. +- On Windows, the `HoveredFile` and `HoveredFileCancelled` events are now implemented. +- On Windows, fix `Window::set_maximized`. +- On Windows 10, fix transparency (#260). +- On macOS, fix modifiers during key repeat. +- Implemented the `Debug` trait for `Window`, `EventsLoop`, `EventsLoopProxy` and `WindowBuilder`. +- On X11, now a `Resized` event will always be generated after a DPI change to ensure the window's logical size is consistent with the new DPI. +- Added further clarifications to the DPI docs. +- On Linux, if neither X11 nor Wayland manage to initialize, the corresponding panic now consists of a single line only. +- Add optional `serde` feature with implementations of `Serialize`/`Deserialize` for DPI types and various event types. +- Add `PartialEq`, `Eq`, and `Hash` implementations on public types that could have them but were missing them. +- On X11, drag-and-drop receiving an unsupported drop type can no longer cause the WM to freeze. +- Fix issue whereby the OpenGL context would not appear at startup on macOS Mojave (#1069). +- **Breaking:** Removed `From` impl from `ActivationPolicy` on macOS. +- On macOS, the application can request the user's attention with `WindowExt::request_user_attention`. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.19.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.19.md new file mode 100644 index 00000000..65aefef8 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.19.md @@ -0,0 +1,27 @@ +## 0.19.1 + +- On Wayland, added a `get_wayland_display` function to `EventsLoopExt`. +- On Windows, fix `CursorMoved(0, 0)` getting dispatched on window focus. +- On macOS, fix command key event left and right reverse. +- On FreeBSD, NetBSD, and OpenBSD, fix build of X11 backend. +- On Linux, the numpad's add, subtract and divide keys are now mapped to the `Add`, `Subtract` and `Divide` virtual key codes +- On macOS, the numpad's subtract key has been added to the `Subtract` mapping +- On Wayland, the numpad's home, end, page up and page down keys are now mapped to the `Home`, `End`, `PageUp` and `PageDown` virtual key codes +- On Windows, fix icon not showing up in corner of window. +- On X11, change DPI scaling factor behavior. First, winit tries to read it from "Xft.dpi" XResource, and uses DPI calculation from xrandr dimensions as fallback behavior. + +## 0.19.0 + +- On X11, we will use the faster `XRRGetScreenResourcesCurrent` function instead of `XRRGetScreenResources` when available. +- On macOS, fix keycodes being incorrect when using a non-US keyboard layout. +- On Wayland, fix `with_title()` not setting the windows title +- On Wayland, add `set_wayland_theme()` to control client decoration color theme +- Added serde serialization to `os::unix::XWindowType`. +- **Breaking:** Remove the `icon_loading` feature and the associated `image` dependency. +- On X11, make event loop thread safe by replacing XNextEvent with select(2) and XCheckIfEvent +- On Windows, fix malformed function pointer typecast that could invoke undefined behavior. +- Refactored Windows state/flag-setting code. +- On Windows, hiding the cursor no longer hides the cursor for all Winit windows - just the one `hide_cursor` was called on. +- On Windows, cursor grabs used to get perpetually canceled when the grabbing window lost focus. Now, cursor grabs automatically get re-initialized when the window regains focus and the mouse moves over the client area. +- On Windows, only vertical mouse wheel events were handled. Now, horizontal mouse wheel events are also handled. +- On Windows, ignore the AltGr key when populating the `ModifiersState` type. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.20.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.20.md new file mode 100644 index 00000000..eb4ba6a1 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.20.md @@ -0,0 +1,200 @@ +## 0.20.0 + +- On X11, fix `ModifiersChanged` emitting incorrect modifier change events +- **Breaking**: Overhaul how Winit handles DPI: + - Window functions and events now return `PhysicalSize` instead of `LogicalSize`. + - Functions that take `Size` or `Position` types can now take either `Logical` or `Physical` types. + - `hidpi_factor` has been renamed to `scale_factor`. + - `HiDpiFactorChanged` has been renamed to `ScaleFactorChanged`, and lets you control how the OS + resizes the window in response to the change. + - On X11, deprecate `WINIT_HIDPI_FACTOR` environment variable in favor of `WINIT_X11_SCALE_FACTOR`. + - `Size` and `Position` types are now generic over their exact pixel type. + +## 0.20.0-alpha6 + +- On macOS, fix `set_cursor_visible` hides cursor outside of window. +- On macOS, fix `CursorEntered` and `CursorLeft` events fired at old window size. +- On macOS, fix error when `set_fullscreen` is called during fullscreen transition. +- On all platforms except mobile and WASM, implement `Window::set_minimized`. +- On X11, fix `CursorEntered` event being generated for non-winit windows. +- On macOS, fix crash when starting maximized without decorations. +- On macOS, fix application not terminating on `run_return`. +- On Wayland, fix cursor icon updates on window borders when using CSD. +- On Wayland, under mutter(GNOME Wayland), fix CSD being behind the status bar, when starting window in maximized mode. +- On Windows, theme the title bar according to whether the system theme is "Light" or "Dark". +- Added `WindowEvent::ThemeChanged` variant to handle changes to the system theme. Currently only implemented on Windows. +- **Breaking**: Changes to the `RedrawRequested` event (#1041): + - `RedrawRequested` has been moved from `WindowEvent` to `Event`. + - `EventsCleared` has been renamed to `MainEventsCleared`. + - `RedrawRequested` is now issued only after `MainEventsCleared`. + - `RedrawEventsCleared` is issued after each set of `RedrawRequested` events. +- Implement synthetic window focus key events on Windows. +- **Breaking**: Change `ModifiersState` to a `bitflags` struct. +- On Windows, implement `VirtualKeyCode` translation for `LWin` and `RWin`. +- On Windows, fix closing the last opened window causing `DeviceEvent`s to stop getting emitted. +- On Windows, fix `Window::set_visible` not setting internal flags correctly. This resulted in some weird behavior. +- Add `DeviceEvent::ModifiersChanged`. + - Deprecate `modifiers` fields in other events in favor of `ModifiersChanged`. +- On X11, `WINIT_HIDPI_FACTOR` now dominates `Xft.dpi` when picking DPI factor for output. +- On X11, add special value `randr` for `WINIT_HIDPI_FACTOR` to make winit use self computed DPI factor instead of the one from `Xft.dpi`. + +## 0.20.0-alpha5 + +- On macOS, fix application termination on `ControlFlow::Exit` +- On Windows, fix missing `ReceivedCharacter` events when Alt is held. +- On macOS, stop emitting private corporate characters in `ReceivedCharacter` events. +- On X11, fix misreporting DPI factor at startup. +- On X11, fix events not being reported when using `run_return`. +- On X11, fix key modifiers being incorrectly reported. +- On X11, fix window creation hanging when another window is fullscreen. +- On Windows, fix focusing unfocused windows when switching from fullscreen to windowed. +- On X11, fix reporting incorrect DPI factor when waking from suspend. +- Change `EventLoopClosed` to contain the original event. +- **Breaking**: Add `is_synthetic` field to `WindowEvent` variant `KeyboardInput`, + indicating that the event is generated by winit. +- On X11, generate synthetic key events for keys held when a window gains or loses focus. +- On X11, issue a `CursorMoved` event when a `Touch` event occurs, + as X11 implicitly moves the cursor for such events. + +## 0.20.0-alpha4 + +- Add web support via the 'stdweb' or 'web-sys' features +- On Windows, implemented function to get HINSTANCE +- On macOS, implement `run_return`. +- On iOS, fix inverted parameter in `set_prefers_home_indicator_hidden`. +- On X11, performance is improved when rapidly calling `Window::set_cursor_icon`. +- On iOS, fix improper `msg_send` usage that was UB and/or would break if `!` is stabilized. +- On Windows, unset `maximized` when manually changing the window's position or size. +- On Windows, add touch pressure information for touch events. +- On macOS, differentiate between `CursorIcon::Grab` and `CursorIcon::Grabbing`. +- On Wayland, fix event processing sometimes stalling when using OpenGL with vsync. +- Officially remove the Emscripten backend. +- On Windows, fix handling of surrogate pairs when dispatching `ReceivedCharacter`. +- On macOS 10.15, fix freeze upon exiting exclusive fullscreen mode. +- On iOS, fix panic upon closing the app. +- On X11, allow setting multiple `XWindowType`s. +- On iOS, fix null window on initial `HiDpiFactorChanged` event. +- On Windows, fix fullscreen window shrinking upon getting restored to a normal window. +- On macOS, fix events not being emitted during modal loops, such as when windows are being resized + by the user. +- On Windows, fix hovering the mouse over the active window creating an endless stream of CursorMoved events. +- Always dispatch a `RedrawRequested` event after creating a new window. +- On X11, return dummy monitor data to avoid panicking when no monitors exist. +- On X11, prevent stealing input focus when creating a new window. + Only steal input focus when entering fullscreen mode. +- On Wayland, fixed DeviceEvents for relative mouse movement is not always produced +- On Wayland, add support for set_cursor_visible and set_cursor_grab. +- On Wayland, fixed DeviceEvents for relative mouse movement is not always produced. +- Removed `derivative` crate dependency. +- On Wayland, add support for set_cursor_icon. +- Use `impl Iterator` instead of `AvailableMonitorsIter` consistently. +- On macOS, fix fullscreen state being updated after entering fullscreen instead of before, + resulting in `Window::fullscreen` returning the old state in `Resized` events instead of + reflecting the new fullscreen state +- On X11, fix use-after-free during window creation +- On Windows, disable monitor change keyboard shortcut while in exclusive fullscreen. +- On Windows, ensure that changing a borderless fullscreen window's monitor via keyboard shortcuts keeps the window fullscreen on the new monitor. +- Prevent `EventLoop::new` and `EventLoop::with_user_event` from getting called outside the main thread. + - This is because some platforms cannot run the event loop outside the main thread. Preventing this + reduces the potential for cross-platform compatibility gotchyas. +- On Windows and Linux X11/Wayland, add platform-specific functions for creating an `EventLoop` outside the main thread. +- On Wayland, drop resize events identical to the current window size. +- On Windows, fix window rectangle not getting set correctly on high-DPI systems. + +## 0.20.0-alpha3 + +- On macOS, drop the run closure on exit. +- On Windows, location of `WindowEvent::Touch` are window client coordinates instead of screen coordinates. +- On X11, fix delayed events after window redraw. +- On macOS, add `WindowBuilderExt::with_disallow_hidpi` to have the option to turn off best resolution openGL surface. +- On Windows, screen saver won't start if the window is in fullscreen mode. +- Change all occurrences of the `new_user_event` method to `with_user_event`. +- On macOS, the dock and the menu bar are now hidden in fullscreen mode. +- `Window::set_fullscreen` now takes `Option` where `Fullscreen` + consists of `Fullscreen::Exclusive(VideoMode)` and + `Fullscreen::Borderless(MonitorHandle)` variants. + - Adds support for exclusive fullscreen mode. +- On iOS, add support for hiding the home indicator. +- On iOS, add support for deferring system gestures. +- On iOS, fix a crash that occurred while acquiring a monitor's name. +- On iOS, fix armv7-apple-ios compile target. +- Removed the `T: Clone` requirement from the `Clone` impl of `EventLoopProxy`. +- On iOS, disable overscan compensation for external displays (removes black + bars surrounding the image). +- On Linux, the functions `is_wayland`, `is_x11`, `xlib_xconnection` and `wayland_display` have been moved to a new `EventLoopWindowTargetExtUnix` trait. +- On iOS, add `set_prefers_status_bar_hidden` extension function instead of + hijacking `set_decorations` for this purpose. +- On macOS and iOS, corrected the auto trait impls of `EventLoopProxy`. +- On iOS, add touch pressure information for touch events. +- Implement `raw_window_handle::HasRawWindowHandle` for `Window` type on all supported platforms. +- On macOS, fix the signature of `-[NSView drawRect:]`. +- On iOS, fix the behavior of `ControlFlow::Poll`. It wasn't polling if that was the only mode ever used by the application. +- On iOS, fix DPI sent out by views on creation was `0.0` - now it gives a reasonable number. +- On iOS, RedrawRequested now works for gl/metal backed views. +- On iOS, RedrawRequested is generally ordered after EventsCleared. + +## 0.20.0-alpha2 + +- On X11, non-resizable windows now have maximize explicitly disabled. +- On Windows, support paths longer than MAX_PATH (260 characters) in `WindowEvent::DroppedFile` + and `WindowEvent::HoveredFile`. +- On Mac, implement `DeviceEvent::Button`. +- Change `Event::Suspended(true / false)` to `Event::Suspended` and `Event::Resumed`. +- On X11, fix sanity check which checks that a monitor's reported width and height (in millimeters) are non-zero when calculating the DPI factor. +- Revert the use of invisible surfaces in Wayland, which introduced graphical glitches with OpenGL (#835) +- On X11, implement `_NET_WM_PING` to allow desktop environment to kill unresponsive programs. +- On Windows, when a window is initially invisible, it won't take focus from the existing visible windows. +- On Windows, fix multiple calls to `request_redraw` during `EventsCleared` sending multiple `RedrawRequested events.` +- On Windows, fix edge case where `RedrawRequested` could be dispatched before input events in event loop iteration. +- On Windows, fix timing issue that could cause events to be improperly dispatched after `RedrawRequested` but before `EventsCleared`. +- On macOS, drop unused Metal dependency. +- On Windows, fix the trail effect happening on transparent decorated windows. Borderless (or un-decorated) windows were not affected. +- On Windows, fix `with_maximized` not properly setting window size to entire window. +- On macOS, change `WindowExtMacOS::request_user_attention()` to take an `enum` instead of a `bool`. + +## 0.20.0-alpha1 + +- Changes below are considered **breaking**. +- Change all occurrences of `EventsLoop` to `EventLoop`. +- Previously flat API is now exposed through `event`, `event_loop`, `monitor`, and `window` modules. +- `os` module changes: + - Renamed to `platform`. + - All traits now have platform-specific suffixes. + - Exposes new `desktop` module on Windows, Mac, and Linux. +- Changes to event loop types: + - `EventLoopProxy::wakeup` has been removed in favor of `send_event`. + - **Major:** New `run` method drives winit event loop. + - Returns `!` to ensure API behaves identically across all supported platforms. + - This allows `emscripten` implementation to work without lying about the API. + - `ControlFlow`'s variants have been replaced with `Wait`, `WaitUntil(Instant)`, `Poll`, and `Exit`. + - Is read after `EventsCleared` is processed. + - `Wait` waits until new events are available. + - `WaitUntil` waits until either new events are available or the provided time has been reached. + - `Poll` instantly resumes the event loop. + - `Exit` aborts the event loop. + - Takes a closure that implements `'static + FnMut(Event, &EventLoop, &mut ControlFlow)`. + - `&EventLoop` is provided to allow new `Window`s to be created. + - **Major:** `platform::desktop` module exposes `EventLoopExtDesktop` trait with `run_return` method. + - Behaves identically to `run`, but returns control flow to the calling context and can take non-`'static` closures. + - `EventLoop`'s `poll_events` and `run_forever` methods have been removed in favor of `run` and `run_return`. +- Changes to events: + - Remove `Event::Awakened` in favor of `Event::UserEvent(T)`. + - Can be sent with `EventLoopProxy::send_event`. + - Rename `WindowEvent::Refresh` to `WindowEvent::RedrawRequested`. + - `RedrawRequested` can be sent by the user with the `Window::request_redraw` method. + - `EventLoop`, `EventLoopProxy`, and `Event` are now generic over `T`, for use in `UserEvent`. + - **Major:** Add `NewEvents(StartCause)`, `EventsCleared`, and `LoopDestroyed` variants to `Event`. + - `NewEvents` is emitted when new events are ready to be processed by event loop. + - `StartCause` describes why new events are available, with `ResumeTimeReached`, `Poll`, `WaitCancelled`, and `Init` (sent once at start of loop). + - `EventsCleared` is emitted when all available events have been processed. + - Can be used to perform logic that depends on all events being processed (e.g. an iteration of a game loop). + - `LoopDestroyed` is emitted when the `run` or `run_return` method is about to exit. +- Rename `MonitorId` to `MonitorHandle`. +- Removed `serde` implementations from `ControlFlow`. +- Rename several functions to improve both internal consistency and compliance with Rust API guidelines. +- Remove `WindowBuilder::multitouch` field, since it was only implemented on a few platforms. Multitouch is always enabled now. +- **Breaking:** On macOS, change `ns` identifiers to use snake_case for consistency with iOS's `ui` identifiers. +- Add `MonitorHandle::video_modes` method for retrieving supported video modes for the given monitor. +- On Wayland, the window now exists even if nothing has been drawn. +- On Windows, fix initial dimensions of a fullscreen window. +- On Windows, Fix transparent borderless windows rendering wrong. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.21.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.21.md new file mode 100644 index 00000000..48f3d354 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.21.md @@ -0,0 +1,16 @@ +## 0.21.0 + +- On Windows, fixed "error: linking with `link.exe` failed: exit code: 1120" error on older versions of windows. +- On macOS, fix set_minimized(true) works only with decorations. +- On macOS, add `hide_application` to `EventLoopWindowTarget` via a new `EventLoopWindowTargetExtMacOS` trait. `hide_application` will hide the entire application by calling `-[NSApplication hide: nil]`. +- On macOS, fix not sending ReceivedCharacter event for specific keys combinations. +- On macOS, fix `CursorMoved` event reporting the cursor position using logical coordinates. +- On macOS, fix issue where unbundled applications would sometimes open without being focused. +- On macOS, fix `run_return` does not return unless it receives a message. +- On Windows, fix bug where `RedrawRequested` would only get emitted every other iteration of the event loop. +- On X11, fix deadlock on window state when handling certain window events. +- `WindowBuilder` now implements `Default`. +- **Breaking:** `WindowEvent::CursorMoved` changed to `f64` units, preserving high-precision data supplied by most backends +- On Wayland, fix coordinates in mouse events when scale factor isn't 1 +- On Web, add the ability to provide a custom canvas +- **Breaking:** On Wayland, the `WaylandTheme` struct has been replaced with a `Theme` trait, allowing for extra configuration diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.22.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.22.md new file mode 100644 index 00000000..07bc1ef2 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.22.md @@ -0,0 +1,37 @@ +## 0.22.2 + +- Added Clone implementation for 'static events. +- On Windows, fix window intermittently hanging when `ControlFlow` was set to `Poll`. +- On Windows, fix `WindowBuilder::with_maximized` being ignored. +- On Android, minimal platform support. +- On iOS, touch positions are now properly converted to physical pixels. +- On macOS, updated core-* dependencies and cocoa + +## 0.22.1 + +- On X11, fix `ResumeTimeReached` being fired too early. +- On Web, replaced zero timeout for `ControlFlow::Poll` with `requestAnimationFrame` +- On Web, fix a possible panic during event handling +- On macOS, fix `EventLoopProxy` leaking memory for every instance. + +## 0.22.0 + +- On Windows, fix minor timing issue in wait_until_time_or_msg +- On Windows, rework handling of request_redraw() to address panics. +- On macOS, fix `set_simple_screen` to remember frame excluding title bar. +- On Wayland, fix coordinates in touch events when scale factor isn't 1. +- On Wayland, fix color from `close_button_icon_color` not applying. +- Ignore locale if unsupported by X11 backend +- On Wayland, Add HiDPI cursor support +- On Web, add the ability to query "Light" or "Dark" system theme send `ThemeChanged` on change. +- Fix `Event::to_static` returning `None` for user events. +- On Wayland, Hide CSD for fullscreen windows. +- On Windows, ignore spurious mouse move messages. +- **Breaking:** Move `ModifiersChanged` variant from `DeviceEvent` to `WindowEvent`. +- On Windows, add `IconExtWindows` trait which exposes creating an `Icon` from an external file or embedded resource +- Add `BadIcon::OsError` variant for when OS icon functionality fails +- On Windows, fix crash at startup on systems that do not properly support Windows' Dark Mode +- Revert On macOS, fix not sending ReceivedCharacter event for specific keys combinations. +- on macOS, fix incorrect ReceivedCharacter events for some key combinations. +- **Breaking:** Use `i32` instead of `u32` for position type in `WindowEvent::Moved`. +- On macOS, a mouse motion event is now generated before every mouse click. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.23.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.23.md new file mode 100644 index 00000000..33ea2abd --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.23.md @@ -0,0 +1,65 @@ +## 0.23.0 + +- On iOS, fixed support for the "Debug View Hierarchy" feature in Xcode. +- On all platforms, `available_monitors` and `primary_monitor` are now on `EventLoopWindowTarget` rather than `EventLoop` to list monitors event in the event loop. +- On Unix, X11 and Wayland are now optional features (enabled by default) +- On X11, fix deadlock when calling `set_fullscreen_inner`. +- On Web, prevent the webpage from scrolling when the user is focused on a winit canvas +- On Web, calling `window.set_cursor_icon` no longer breaks HiDPI scaling +- On Windows, drag and drop is now optional (enabled by default) and can be disabled with `WindowBuilderExtWindows::with_drag_and_drop(false)`. +- On Wayland, fix deadlock when calling to `set_inner_size` from a callback. +- On macOS, add `hide__other_applications` to `EventLoopWindowTarget` via existing `EventLoopWindowTargetExtMacOS` trait. `hide_other_applications` will hide other applications by calling `-[NSApplication hideOtherApplications: nil]`. +- On android added support for `run_return`. +- On MacOS, Fixed fullscreen and dialog support for `run_return`. +- On Windows, fix bug where we'd try to emit `MainEventsCleared` events during nested win32 event loops. +- On Web, use mouse events if pointer events aren't supported. This affects Safari. +- On Windows, `set_ime_position` is now a no-op instead of a runtime crash. +- On Android, `set_fullscreen` is now a no-op instead of a runtime crash. +- On iOS and Android, `set_inner_size` is now a no-op instead of a runtime crash. +- On Android, fix `ControlFlow::Poll` not polling the Android event queue. +- On macOS, add `NSWindow.hasShadow` support. +- On Web, fix vertical mouse wheel scrolling being inverted. +- On Web, implement mouse capturing for click-dragging out of the canvas. +- On Web, fix `ControlFlow::Exit` not properly handled. +- On Web (web-sys only), send `WindowEvent::ScaleFactorChanged` event when `window.devicePixelRatio` is changed. +- **Breaking:** On Web, `set_cursor_position` and `set_cursor_grab` will now always return an error. +- **Breaking:** `PixelDelta` scroll events now return a `PhysicalPosition`. +- On NetBSD, fixed crash due to incorrect detection of the main thread. +- **Breaking:** On X11, `-` key is mapped to the `Minus` virtual key code, instead of `Subtract`. +- On macOS, fix inverted horizontal scroll. +- **Breaking:** `current_monitor` now returns `Option`. +- **Breaking:** `primary_monitor` now returns `Option`. +- On macOS, updated core-* dependencies and cocoa. +- Bump `parking_lot` to 0.11 +- On Android, bump `ndk`, `ndk-sys` and `ndk-glue` to 0.2. Checkout the new ndk-glue main proc attribute. +- On iOS, fixed starting the app in landscape where the view still had portrait dimensions. +- Deprecate the stdweb backend, to be removed in a future release +- **Breaking:** Prefixed virtual key codes `Add`, `Multiply`, `Divide`, `Decimal`, and `Subtract` with `Numpad`. +- Added `Asterisk` and `Plus` virtual key codes. +- On Web (web-sys only), the `Event::LoopDestroyed` event is correctly emitted when leaving the page. +- On Web, the `WindowEvent::Destroyed` event now gets emitted when a `Window` is dropped. +- On Web (web-sys only), the event listeners are now removed when a `Window` is dropped or when the event loop is destroyed. +- On Web, the event handler closure passed to `EventLoop::run` now gets dropped after the event loop is destroyed. +- **Breaking:** On Web, the canvas element associated to a `Window` is no longer removed from the DOM when the `Window` is dropped. +- On Web, `WindowEvent::Resized` is now emitted when `Window::set_inner_size` is called. +- **Breaking:** `Fullscreen` enum now uses `Borderless(Option)` instead of `Borderless(MonitorHandle)` to allow picking the current monitor. +- On MacOS, fix `WindowEvent::Moved` ignoring the scale factor. +- On Wayland, add missing virtual keycodes. +- On Wayland, implement proper `set_cursor_grab`. +- On Wayland, the cursor will use similar icons if the requested one isn't available. +- On Wayland, right clicking on client side decorations will request application menu. +- On Wayland, fix tracking of window size after state changes. +- On Wayland, fix client side decorations not being hidden properly in fullscreen. +- On Wayland, fix incorrect size event when entering fullscreen with client side decorations. +- On Wayland, fix `resizable` attribute not being applied properly on startup. +- On Wayland, fix disabled repeat rate not being handled. +- On Wayland, fix decoration buttons not working after tty switch. +- On Wayland, fix scaling not being applied on output re-enable. +- On Wayland, fix crash when `XCURSOR_SIZE` is `0`. +- On Wayland, fix pointer getting created in some cases without pointer capability. +- On Wayland, on kwin, fix space between window and decorations on startup. +- **Breaking:** On Wayland, `Theme` trait was reworked. +- On Wayland, disable maximize button for non-resizable window. +- On Wayland, added support for `set_ime_position`. +- On Wayland, fix crash on startup since GNOME 3.37.90. +- On X11, fix incorrect modifiers state on startup. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.24.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.24.md new file mode 100644 index 00000000..fa0a27b2 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.24.md @@ -0,0 +1,28 @@ +## 0.24.0 + +- On Windows, fix applications not exiting gracefully due to thread_event_target_callback accessing corrupted memory. +- On Windows, implement `Window::set_ime_position`. +- **Breaking:** On Windows, Renamed `WindowBuilderExtWindows`'s `is_dark_mode` to `theme`. +- **Breaking:** On Windows, renamed `WindowBuilderExtWindows::is_dark_mode` to `theme`. +- On Windows, add `WindowBuilderExtWindows::with_theme` to set a preferred theme. +- On Windows, fix bug causing message boxes to appear delayed. +- On Android, calling `WindowEvent::Focused` now works properly instead of always returning false. +- On Windows, fix Alt-Tab behaviour by removing borderless fullscreen "always on top" flag. +- On Windows, fix bug preventing windows with transparency enabled from having fully-opaque regions. +- **Breaking:** On Windows, include prefix byte in scancodes. +- On Wayland, fix window not being resizeable when using `WindowBuilder::with_min_inner_size`. +- On Unix, fix cross-compiling to wasm32 without enabling X11 or Wayland. +- On Windows, fix use-after-free crash during window destruction. +- On Web, fix `WindowEvent::ReceivedCharacter` never being sent on key input. +- On macOS, fix compilation when targeting aarch64. +- On X11, fix `Window::request_redraw` not waking the event loop. +- On Wayland, the keypad arrow keys are now recognized. +- **Breaking** Rename `desktop::EventLoopExtDesktop` to `run_return::EventLoopExtRunReturn`. +- Added `request_user_attention` method to `Window`. +- **Breaking:** On macOS, removed `WindowExt::request_user_attention`, use `Window::request_user_attention`. +- **Breaking:** On X11, removed `WindowExt::set_urgent`, use `Window::request_user_attention`. +- On Wayland, default font size in CSD increased from 11 to 17. +- On Windows, fix bug causing message boxes to appear delayed. +- On Android, support multi-touch. +- On Wayland, extra mouse buttons are not dropped anymore. +- **Breaking**: `MouseButton::Other` now uses `u16`. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.25.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.25.md new file mode 100644 index 00000000..00451c08 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.25.md @@ -0,0 +1,31 @@ +## 0.25.0 + +- **Breaking:** On macOS, replace `WindowBuilderExtMacOS::with_activation_policy` with `EventLoopExtMacOS::set_activation_policy` +- On macOS, wait with activating the application until the application has initialized. +- On macOS, fix creating new windows when the application has a main menu. +- On Windows, fix fractional deltas for mouse wheel device events. +- On macOS, fix segmentation fault after dropping the main window. +- On Android, `InputEvent::KeyEvent` is partially implemented providing the key scancode. +- Added `is_maximized` method to `Window`. +- On Windows, fix bug where clicking the decoration bar would make the cursor blink. +- On Windows, fix bug causing newly created windows to erroneously display the "wait" (spinning) cursor. +- On macOS, wake up the event loop immediately when a redraw is requested. +- On Windows, change the default window size (1024x768) to match the default on other desktop platforms (800x600). +- On Windows, fix bug causing mouse capture to not be released. +- On Windows, fix fullscreen not preserving minimized/maximized state. +- On Android, unimplemented events are marked as unhandled on the native event loop. +- On Windows, added `WindowBuilderExtWindows::with_menu` to set a custom menu at window creation time. +- On Android, bump `ndk` and `ndk-glue` to 0.3: use predefined constants for event `ident`. +- On macOS, fix objects captured by the event loop closure not being dropped on panic. +- On Windows, fixed `WindowEvent::ThemeChanged` not properly firing and fixed `Window::theme` returning the wrong theme. +- On Web, added support for `DeviceEvent::MouseMotion` to listen for relative mouse movements. +- Added `WindowBuilder::with_position` to allow setting the position of a `Window` on creation. Supported on Windows, macOS and X11. +- Added `Window::drag_window`. Implemented on Windows, macOS, X11 and Wayland. +- On X11, bump `mio` to 0.7. +- On Windows, added `WindowBuilderExtWindows::with_owner_window` to allow creating popup windows. +- On Windows, added `WindowExtWindows::set_enable` to allow creating modal popup windows. +- On macOS, emit `RedrawRequested` events immediately while the window is being resized. +- Implement `Default`, `Hash`, and `Eq` for `LogicalPosition`, `PhysicalPosition`, `LogicalSize`, and `PhysicalSize`. +- On macOS, initialize the Menu Bar with minimal defaults. (Can be prevented using `enable_default_menu_creation`) +- On macOS, change the default behavior for first click when the window was unfocused. Now the window becomes focused and then emits a `MouseInput` event on a "first mouse click". +- Implement mint (math interoperability standard types) conversions (under feature flag `mint`). diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.26.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.26.md new file mode 100644 index 00000000..d33d4a3b --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.26.md @@ -0,0 +1,36 @@ +## 0.26.1 + +- Fix linking to the `ColorSync` framework on macOS 10.7, and in newer Rust versions. +- On Web, implement cursor grabbing through the pointer lock API. +- On X11, add mappings for numpad comma, numpad enter, numlock and pause. +- On macOS, fix Pinyin IME input by reverting a change that intended to improve IME. +- On Windows, fix a crash with transparent windows on Windows 11. + +## 0.26.0 + +- Update `raw-window-handle` to `v0.4`. This is _not_ a breaking change, we still implement `HasRawWindowHandle` from `v0.3`, see [rust-windowing/raw-window-handle#74](https://github.com/rust-windowing/raw-window-handle/pull/74). Note that you might have to run `cargo update -p raw-window-handle` after upgrading. +- On X11, bump `mio` to 0.8. +- On Android, fixed `WindowExtAndroid::config` initially returning an empty `Configuration`. +- On Android, fixed `Window::scale_factor` and `MonitorHandle::scale_factor` initially always returning 1.0. +- On X11, select an appropriate visual for transparency if is requested +- On Wayland and X11, fix diagonal window resize cursor orientation. +- On macOS, drop the event callback before exiting. +- On Android, implement `Window::request_redraw` +- **Breaking:** On Web, remove the `stdweb` backend. +- Added `Window::focus_window`to bring the window to the front and set input focus. +- On Wayland and X11, implement `is_maximized` method on `Window`. +- On Windows, prevent ghost window from showing up in the taskbar after either several hours of use or restarting `explorer.exe`. +- On macOS, fix issue where `ReceivedCharacter` was not being emitted during some key repeat events. +- On Wayland, load cursor icons `hand2` and `hand1` for `CursorIcon::Hand`. +- **Breaking:** On Wayland, Theme trait and its support types are dropped. +- On Wayland, bump `smithay-client-toolkit` to 0.15.1. +- On Wayland, implement `request_user_attention` with `xdg_activation_v1`. +- On X11, emit missing `WindowEvent::ScaleFactorChanged` when the only monitor gets reconnected. +- On X11, if RANDR based scale factor is higher than 20 reset it to 1 +- On Wayland, add an enabled-by-default feature called `wayland-dlopen` so users can opt out of using `dlopen` to load system libraries. +- **Breaking:** On Android, bump `ndk` and `ndk-glue` to 0.5. +- On Windows, increase wait timer resolution for more accurate timing when using `WaitUntil`. +- On macOS, fix native file dialogs hanging the event loop. +- On Wayland, implement a workaround for wrong configure size when using `xdg_decoration` in `kwin_wayland` +- On macOS, fix an issue that prevented the menu bar from showing in borderless fullscreen mode. +- On X11, EINTR while polling for events no longer causes a panic. Instead it will be treated as a spurious wakeup. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.27.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.27.md new file mode 100644 index 00000000..5f067f59 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.27.md @@ -0,0 +1,107 @@ +## 0.27.5 + +- On Wayland, fix byte offset in `Ime::Preedit` pointing to invalid bytes. + +## 0.27.4 + +- On Windows, emit `ReceivedCharacter` events on system keybindings. +- On Windows, fixed focus event emission on minimize. +- On X11, fixed IME crashing during reload. + +## 0.27.3 + +- On Windows, added `WindowExtWindows::set_undecorated_shadow` and `WindowBuilderExtWindows::with_undecorated_shadow` to draw the drop shadow behind a borderless window. +- On Windows, fixed default window features (ie snap, animations, shake, etc.) when decorations are disabled. +- On Windows, fixed ALT+Space shortcut to open window menu. +- On Wayland, fixed `Ime::Preedit` not being sent on IME reset. +- Fixed unbound version specified for `raw-window-handle` leading to compilation failures. +- Empty `Ime::Preedit` event will be sent before `Ime::Commit` to help clearing preedit. +- On X11, fixed IME context picking by querying for supported styles beforehand. + +## 0.27.2 + +- On macOS, fixed touch phase reporting when scrolling. +- On X11, fix min, max and resize increment hints not persisting for resizable windows (e.g. on DPI change). +- On Windows, respect min/max inner sizes when creating the window. +- For backwards compatibility, `Window` now (additionally) implements the old version (`0.4`) of the `HasRawWindowHandle` trait +- On Windows, added support for `EventLoopWindowTarget::set_device_event_filter`. +- On Wayland, fix user requested `WindowEvent::RedrawRequested` being delayed by a frame. + +## 0.27.1 + +- The minimum supported Rust version was lowered to `1.57.0` and now explicitly tested. +- On X11, fix crash on start due to inability to create an IME context without any preedit. + +## 0.27.0 + +- On Windows, fix hiding a maximized window. +- On Android, `ndk-glue`'s `NativeWindow` lock is now held between `Event::Resumed` and `Event::Suspended`. +- On Web, added `EventLoopExtWebSys` with a `spawn` method to start the event loop without throwing an exception. +- Added `WindowEvent::Occluded(bool)`, currently implemented on macOS and X11. +- On X11, fix events for caps lock key not being sent +- Build docs on `docs.rs` for iOS and Android as well. +- **Breaking:** Removed the `WindowAttributes` struct, since all its functionality is accessible from `WindowBuilder`. +- Added `WindowBuilder::transparent` getter to check if the user set `transparent` attribute. +- On macOS, Fix emitting `Event::LoopDestroyed` on CMD+Q. +- On macOS, fixed an issue where having multiple windows would prevent run_return from ever returning. +- On Wayland, fix bug where the cursor wouldn't hide in GNOME. +- On macOS, Windows, and Wayland, add `set_cursor_hittest` to let the window ignore mouse events. +- On Windows, added `WindowExtWindows::set_skip_taskbar` and `WindowBuilderExtWindows::with_skip_taskbar`. +- On Windows, added `EventLoopBuilderExtWindows::with_msg_hook`. +- On Windows, remove internally unique DC per window. +- On macOS, remove the need to call `set_ime_position` after moving the window. +- Added `Window::is_visible`. +- Added `Window::is_resizable`. +- Added `Window::is_decorated`. +- On X11, fix for repeated event loop iteration when `ControlFlow` was `Wait` +- On X11, fix scale factor calculation when the only monitor is reconnected +- On Wayland, report unaccelerated mouse deltas in `DeviceEvent::MouseMotion`. +- On Web, a focused event is manually generated when a click occurs to emulate behaviour of other backends. +- **Breaking:** Bump `ndk` version to 0.6, ndk-sys to `v0.3`, `ndk-glue` to `0.6`. +- Remove no longer needed `WINIT_LINK_COLORSYNC` environment variable. +- **Breaking:** Rename the `Exit` variant of `ControlFlow` to `ExitWithCode`, which holds a value to control the exit code after running. Add an `Exit` constant which aliases to `ExitWithCode(0)` instead to avoid major breakage. This shouldn't affect most existing programs. +- Add `EventLoopBuilder`, which allows you to create and tweak the settings of an event loop before creating it. +- Deprecated `EventLoop::with_user_event`; use `EventLoopBuilder::with_user_event` instead. +- **Breaking:** Replaced `EventLoopExtMacOS` with `EventLoopBuilderExtMacOS` (which also has renamed methods). +- **Breaking:** Replaced `EventLoopExtWindows` with `EventLoopBuilderExtWindows` (which also has renamed methods). +- **Breaking:** Replaced `EventLoopExtUnix` with `EventLoopBuilderExtUnix` (which also has renamed methods). +- **Breaking:** The platform specific extensions for Windows `winit::platform::windows` have changed. All `HANDLE`-like types e.g. `HWND` and `HMENU` were converted from winapi types or `*mut c_void` to `isize`. This was done to be consistent with the type definitions in windows-sys and to not expose internal dependencies. +- The internal bindings to the [Windows API](https://docs.microsoft.com/en-us/windows/) were changed from the unofficial [winapi](https://github.com/retep998/winapi-rs) bindings to the official Microsoft [windows-sys](https://github.com/microsoft/windows-rs) bindings. +- On Wayland, fix polling during consecutive `EventLoop::run_return` invocations. +- On Windows, fix race issue creating fullscreen windows with `WindowBuilder::with_fullscreen` +- On Android, `virtual_keycode` for `KeyboardInput` events is now filled in where a suitable match is found. +- Added helper methods on `ControlFlow` to set its value. +- On Wayland, fix `TouchPhase::Ended` always reporting the location of the first touch down, unless the compositor + sent a cancel or frame event. +- On iOS, send `RedrawEventsCleared` even if there are no redraw events, consistent with other platforms. +- **Breaking:** Replaced `Window::with_app_id` and `Window::with_class` with `Window::with_name` on `WindowBuilderExtUnix`. +- On Wayland, fallback CSD was replaced with proper one: + - `WindowBuilderExtUnix::with_wayland_csd_theme` to set color theme in builder. + - `WindowExtUnix::wayland_set_csd_theme` to set color theme when creating a window. + - `WINIT_WAYLAND_CSD_THEME` env variable was added, it can be used to set "dark"/"light" theme in apps that don't expose theme setting. + - `wayland-csd-adwaita` feature that enables proper CSD with title rendering using FreeType system library. + - `wayland-csd-adwaita-notitle` feature that enables CSD but without title rendering. +- On Wayland and X11, fix window not resizing with `Window::set_inner_size` after calling `Window:set_resizable(false)`. +- On Windows, fix wrong fullscreen monitors being recognized when handling WM_WINDOWPOSCHANGING messages +- **Breaking:** Added new `WindowEvent::Ime` supported on desktop platforms. +- Added `Window::set_ime_allowed` supported on desktop platforms. +- **Breaking:** IME input on desktop platforms won't be received unless it's explicitly allowed via `Window::set_ime_allowed` and new `WindowEvent::Ime` events are handled. +- On macOS, `WindowEvent::Resized` is now emitted in `frameDidChange` instead of `windowDidResize`. +- **Breaking:** On X11, device events are now ignored for unfocused windows by default, use `EventLoopWindowTarget::set_device_event_filter` to set the filter level. +- Implemented `Default` on `EventLoop<()>`. +- Implemented `Eq` for `Fullscreen`, `Theme`, and `UserAttentionType`. +- **Breaking:** `Window::set_cursor_grab` now accepts `CursorGrabMode` to control grabbing behavior. +- On Wayland, add support for `Window::set_cursor_position`. +- Fix on macOS `WindowBuilder::with_disallow_hidpi`, setting true or false by the user no matter the SO default value. +- `EventLoopBuilder::build` will now panic when the `EventLoop` is being created more than once. +- Added `From` for `WindowId` and `From` for `u64`. +- Added `MonitorHandle::refresh_rate_millihertz` to get monitor's refresh rate. +- **Breaking**, Replaced `VideoMode::refresh_rate` with `VideoMode::refresh_rate_millihertz` providing better precision. +- On Web, add `with_prevent_default` and `with_focusable` to `WindowBuilderExtWebSys` to control whether events should be propagated. +- On Windows, fix focus events being sent to inactive windows. +- **Breaking**, update `raw-window-handle` to `v0.5` and implement `HasRawDisplayHandle` for `Window` and `EventLoopWindowTarget`. +- On X11, add function `register_xlib_error_hook` into `winit::platform::unix` to subscribe for errors coming from Xlib. +- On Android, upgrade `ndk` and `ndk-glue` dependencies to the recently released `0.7.0`. +- All platforms can now be relied on to emit a `Resumed` event. Applications are recommended to lazily initialize graphics state and windows on first resume for portability. +- **Breaking:**: Reverse horizontal scrolling sign in `MouseScrollDelta` to match the direction of vertical scrolling. A positive X value now means moving the content to the right. The meaning of vertical scrolling stays the same: a positive Y value means moving the content down. +- On MacOS, fix deadlock when calling `set_maximized` from event loop. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.28.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.28.md new file mode 100644 index 00000000..a8b3262f --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.28.md @@ -0,0 +1,100 @@ +## 0.28.7 + +- Fix window size sometimes being invalid when resizing on macOS 14 Sonoma. + +## 0.28.6 + +- On macOS, fixed memory leak when getting monitor handle. +- On macOS, fix `Backspace` being emitted when clearing preedit with it. + +## 0.28.5 + +- On macOS, fix `key_up` being ignored when `Ime` is disabled. + +## 0.28.4 + +- On macOS, fix empty marked text blocking regular input. +- On macOS, fix potential panic when getting refresh rate. +- On macOS, fix crash when calling `Window::set_ime_position` from another thread. + +## 0.28.3 + +- Fix macOS memory leaks. + +## 0.28.2 + +- Implement `HasRawDisplayHandle` for `EventLoop`. +- On macOS, set resize increments only for live resizes. +- On Wayland, fix rare crash on DPI change +- Web: Added support for `Window::theme`. +- On Wayland, fix rounding issues when doing resize. +- On macOS, fix wrong focused state on startup. +- On Windows, fix crash on setting taskbar when using Visual Studio debugger. +- On macOS, resize simple fullscreen windows on windowDidChangeScreen events. + +## 0.28.1 + +- On Wayland, fix crash when dropping a window in multi-window setup. + +## 0.28.0 + +- On macOS, fixed `Ime::Commit` persisting for all input after interacting with `Ime`. +- On macOS, added `WindowExtMacOS::option_as_alt` and `WindowExtMacOS::set_option_as_alt`. +- On Windows, fix window size for maximized, undecorated windows. +- On Windows and macOS, add `WindowBuilder::with_active`. +- Add `Window::is_minimized`. +- On X11, fix errors handled during `register_xlib_error_hook` invocation bleeding into winit. +- Add `Window::has_focus`. +- On Windows, fix `Window::set_minimized(false)` not working for windows minimized by `Win + D` hotkey. +- **Breaking:** On Web, touch input no longer fires `WindowEvent::Cursor*`, `WindowEvent::MouseInput`, or `DeviceEvent::MouseMotion` like other platforms, but instead it fires `WindowEvent::Touch`. +- **Breaking:** Removed platform specific `WindowBuilder::with_parent` API in favor of `WindowBuilder::with_parent_window`. +- On Windows, retain `WS_MAXIMIZE` window style when un-minimizing a maximized window. +- On Windows, fix left mouse button release event not being sent after `Window::drag_window`. +- On macOS, run most actions on the main thread, which is strictly more correct, but might make multithreaded applications block slightly more. +- On macOS, fix panic when getting current monitor without any monitor attached. +- On Windows and MacOS, add API to enable/disable window buttons (close, minimize, ...etc). +- On Windows, macOS, X11 and Wayland, add `Window::set_theme`. +- **Breaking:** Remove `WindowExtWayland::wayland_set_csd_theme` and `WindowBuilderExtX11::with_gtk_theme_variant`. +- On Windows, revert window background to an empty brush to avoid white flashes when changing scaling. +- **Breaking:** Removed `Window::set_always_on_top` and related APIs in favor of `Window::set_window_level`. +- On Windows, MacOS and X11, add always on bottom APIs. +- On Windows, fix the value in `MouseButton::Other`. +- On macOS, add `WindowExtMacOS::is_document_edited` and `WindowExtMacOS::set_document_edited` APIs. +- **Breaking:** Removed `WindowBuilderExtIOS::with_root_view_class`; instead, you should use `[[view layer] addSublayer: ...]` to add an instance of the desired layer class (e.g. `CAEAGLLayer` or `CAMetalLayer`). See `vulkano-win` or `wgpu` for examples of this. +- On MacOS and Windows, add `Window::set_content_protected`. +- On MacOS, add `EventLoopBuilderExtMacOS::with_activate_ignoring_other_apps`. +- On Windows, fix icons specified on `WindowBuilder` not taking effect for windows created after the first one. +- On Windows and macOS, add `Window::title` to query the current window title. +- On Windows, fix focusing menubar when pressing `Alt`. +- On MacOS, made `accepts_first_mouse` configurable. +- Migrated `WindowBuilderExtUnix::with_resize_increments` to `WindowBuilder`. +- Added `Window::resize_increments`/`Window::set_resize_increments` to update resize increments at runtime for X11/macOS. +- macOS/iOS: Use `objc2` instead of `objc` internally. +- **Breaking:** Bump MSRV from `1.57` to `1.60`. +- **Breaking:** Split the `platform::unix` module into `platform::x11` and `platform::wayland`. The extension types are similarly renamed. +- **Breaking:**: Removed deprecated method `platform::unix::WindowExtUnix::is_ready`. +- Removed `parking_lot` dependency. +- **Breaking:** On macOS, add support for two-finger touchpad magnification and rotation gestures with new events `WindowEvent::TouchpadMagnify` and `WindowEvent::TouchpadRotate`. Also add support for touchpad smart-magnification gesture with a new event `WindowEvent::SmartMagnify`. +- **Breaking:** On web, the `WindowBuilderExtWebSys::with_prevent_default` setting (enabled by default), now additionally prevents scrolling of the webpage in mobile browsers, previously it only disabled scrolling on desktop. +- On Wayland, `wayland-csd-adwaita` now uses `ab_glyph` instead of `crossfont` to render the title for decorations. +- On Wayland, a new `wayland-csd-adwaita-crossfont` feature was added to use `crossfont` instead of `ab_glyph` for decorations. +- On Wayland, if not otherwise specified use upstream automatic CSD theme selection. +- On X11, added `WindowExtX11::with_parent` to create child windows. +- Added support for `WindowBuilder::with_theme` and `Window::theme` to support per-window dark/light/system theme configuration on macos, windows and wayland. +- On macOS, added support for `WindowEvent::ThemeChanged`. +- **Breaking:** Removed `WindowBuilderExtWindows::with_theme` and `WindowBuilderExtWayland::with_wayland_csd_theme` in favour of `WindowBuilder::with_theme`. +- **Breaking:** Removed `WindowExtWindows::theme` in favour of `Window::theme`. +- Enabled `doc_auto_cfg` when generating docs on docs.rs for feature labels. +- **Breaking:** On Android, switched to using [`android-activity`](https://github.com/rib/android-activity) crate as a glue layer instead of [`ndk-glue`](https://github.com/rust-windowing/android-ndk-rs/tree/master/ndk-glue). See [README.md#Android](https://github.com/rust-windowing/winit#Android) for more details. ([#2444](https://github.com/rust-windowing/winit/pull/2444)) +- **Breaking:** Removed support for `raw-window-handle` version `0.4` +- On Wayland, `RedrawRequested` not emitted during resize. +- Add a `set_wait_timeout` function to `ControlFlow` to allow waiting for a `Duration`. +- **Breaking:** Remove the unstable `xlib_xconnection()` function from the private interface. +- Added Orbital support for Redox OS +- On X11, added `drag_resize_window` method. +- Added `Window::set_transparent` to provide a hint about transparency of the window on Wayland and macOS. +- On macOS, fix the mouse buttons other than left/right/middle being reported as middle. +- On Wayland, support fractional scaling via the wp-fractional-scale protocol. +- On web, fix removal of mouse event listeners from the global object upon window destruction. +- Add WindowAttributes getter to WindowBuilder to allow introspection of default values. +- Added `Window::set_ime_purpose` for setting the IME purpose, currently implemented on Wayland only. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.29.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.29.md new file mode 100644 index 00000000..fc17f170 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.29.md @@ -0,0 +1,289 @@ +## 0.29.15 + +- On X11, fix crash due to xsettings query on systems with incomplete xsettings. + +## 0.29.14 + +- On X11/Wayland, fix `text` and `text_with_all_modifiers` not being `None` during compose. +- On Wayland, don't reapply cursor grab when unchanged. +- On X11, fix a bug where some mouse events would be unexpectedly filtered out. + +## 0.29.13 + +- On Web, fix possible crash with `ControlFlow::Wait` and `ControlFlow::WaitUntil`. + +## 0.29.12 + +- On X11, fix use after free during xinput2 handling. +- On X11, filter close to zero values in mouse device events + +## 0.29.11 + +- Fix compatibility with 32-bit platforms without 64-bit atomics. +- On macOS, fix incorrect IME cursor rect origin. +- On Windows, fixed a race condition when sending an event through the loop proxy. +- On X11, fix swapped instance and general class names. +- On X11, don't require XIM to run. +- On X11, fix xkb state not being updated correctly sometimes leading to wrong input. +- On X11, reload dpi on `_XSETTINGS_SETTINGS` update. +- On X11, fix deadlock when adjusting DPI and resizing at the same time. +- On Wayland, disable `Occluded` event handling. +- On Wayland, fix DeviceEvent::Motion not being sent +- On Wayland, fix `Focused(false)` being send when other seats still have window focused. +- On Wayland, fix `Window::set_{min,max}_inner_size` not always applied. +- On Wayland, fix title in CSD not updated from `AboutToWait`. +- On Windows, fix inconsistent resizing behavior with multi-monitor setups when repositioning outside the event loop. +- On Wayland, fix `WAYLAND_SOCKET` not used when detecting platform. +- On Orbital, fix `logical_key` and `text` not reported in `KeyEvent`. +- On Orbital, implement `KeyEventExtModifierSupplement`. +- On Orbital, map keys to `NamedKey` when possible. +- On Orbital, implement `set_cursor_grab`. +- On Orbital, implement `set_cursor_visible`. +- On Orbital, implement `drag_window`. +- On Orbital, implement `drag_resize_window`. +- On Orbital, implement `set_transparent`. +- On Orbital, implement `set_visible`. +- On Orbital, implement `is_visible`. +- On Orbital, implement `set_resizable`. +- On Orbital, implement `is_resizable`. +- On Orbital, implement `set_maximized`. +- On Orbital, implement `is_maximized`. +- On Orbital, implement `set_decorations`. +- On Orbital, implement `is_decorated`. +- On Orbital, implement `set_window_level`. +- On Orbital, emit `DeviceEvent::MouseMotion`. + +## 0.29.10 + +- On Web, account for canvas being focused already before event loop starts. +- On Web, increase cursor position accuracy. + +## 0.29.9 + +- On X11, fix `NotSupported` error not propagated when creating event loop. +- On Wayland, fix resize not issued when scale changes +- On X11 and Wayland, fix arrow up on keypad reported as `ArrowLeft`. +- On macOS, report correct logical key when Ctrl or Cmd is pressed. + +## 0.29.8 + +- On X11, fix IME input lagging behind. +- On X11, fix `ModifiersChanged` not sent from xdotool-like input +- On X11, fix keymap not updated from xmodmap. +- On X11, reduce the amount of time spent fetching screen resources. +- On Wayland, fix `Window::request_inner_size` being overwritten by resize. +- On Wayland, fix `Window::inner_size` not using the correct rounding. + +## 0.29.7 + +- On X11, fix `Xft.dpi` reload during runtime. +- On X11, fix window minimize. + +## 0.29.6 + +- On Web, fix context menu not being disabled by `with_prevent_default(true)`. +- On Wayland, fix `WindowEvent::Destroyed` not being delivered after destroying window. +- Fix `EventLoopExtRunOnDemand::run_on_demand` not working for consequent invocation + +## 0.29.5 + +- On macOS, remove spurious error logging when handling `Fn`. +- On X11, fix an issue where floating point data from the server is + misinterpreted during a drag and drop operation. +- On X11, fix a bug where focusing the window would panic. +- On macOS, fix `refresh_rate_millihertz`. +- On Wayland, disable Client Side Decorations when `wl_subcompositor` is not supported. +- On X11, fix `Xft.dpi` detection from Xresources. +- On Windows, fix consecutive calls to `window.set_fullscreen(Some(Fullscreen::Borderless(None)))` resulting in losing previous window state when eventually exiting fullscreen using `window.set_fullscreen(None)`. +- On Wayland, fix resize being sent on focus change. +- On Windows, fix `set_ime_cursor_area`. + +## 0.29.4 + +- Fix crash when running iOS app on macOS. +- On X11, check common alternative cursor names when loading cursor. +- On X11, reload the DPI after a property change event. +- On Windows, fix so `drag_window` and `drag_resize_window` can be called from another thread. +- On Windows, fix `set_control_flow` in `AboutToWait` not being taken in account. +- On macOS, send a `Resized` event after each `ScaleFactorChanged` event. +- On Wayland, fix `wl_surface` being destroyed before associated objects. +- On macOS, fix assertion when pressing `Fn` key. +- On Windows, add `WindowBuilderExtWindows::with_clip_children` to control `WS_CLIPCHILDREN` style. + +## 0.29.3 + +- On Wayland, apply correct scale to `PhysicalSize` passed in `WindowBuilder::with_inner_size` when possible. +- On Wayland, fix `RedrawRequested` being always sent without decorations and `sctk-adwaita` feature. +- On Wayland, ignore resize requests when the window is fully tiled. +- On Wayland, use `configure_bounds` to constrain `with_inner_size` when compositor wants users to pick size. +- On Windows, fix deadlock when accessing the state during `Cursor{Enter,Leave}`. +- On Windows, add support for `Window::set_transparent`. +- On macOS, fix deadlock when entering a nested event loop from an event handler. +- On macOS, add support for `Window::set_blur`. + +## 0.29.2 + +- **Breaking:** Bump MSRV from `1.60` to `1.65`. +- **Breaking:** Add `Event::MemoryWarning`; implemented on iOS/Android. +- **Breaking:** Bump `ndk` version to `0.8.0`, ndk-sys to `0.5.0`, `android-activity` to `0.5.0`. +- **Breaking:** Change default `ControlFlow` from `Poll` to `Wait`. +- **Breaking:** Move `Event::RedrawRequested` to `WindowEvent::RedrawRequested`. +- **Breaking:** Moved `ControlFlow::Exit` to `EventLoopWindowTarget::exit()` and `EventLoopWindowTarget::exiting()` and removed `ControlFlow::ExitWithCode(_)` entirely. +- **Breaking:** Moved `ControlFlow` to `EventLoopWindowTarget::set_control_flow()` and `EventLoopWindowTarget::control_flow()`. +- **Breaking:** `EventLoop::new` and `EventLoopBuilder::build` now return `Result` +- **Breaking:** `WINIT_UNIX_BACKEND` was removed in favor of standard `WAYLAND_DISPLAY` and `DISPLAY` variables. +- **Breaking:** on Wayland, dispatching user created Wayland queue won't wake up the loop unless winit has event to send back. +- **Breaking:** remove `DeviceEvent::Text`. +- **Breaking:** Remove lifetime parameter from `Event` and `WindowEvent`. +- **Breaking:** Rename `Window::set_inner_size` to `Window::request_inner_size` and indicate if the size was applied immediately. +- **Breaking:** `ActivationTokenDone` event which could be requested with the new `startup_notify` module, see its docs for more. +- **Breaking:** `ScaleFactorChanged` now contains a writer instead of a reference to update inner size. +- **Breaking** `run() -> !` has been replaced by `run() -> Result<(), EventLoopError>` for returning errors without calling `std::process::exit()` ([#2767](https://github.com/rust-windowing/winit/pull/2767)) +- **Breaking** Removed `EventLoopExtRunReturn` / `run_return` in favor of `EventLoopExtPumpEvents` / `pump_events` and `EventLoopExtRunOnDemand` / `run_on_demand` ([#2767](https://github.com/rust-windowing/winit/pull/2767)) +- `RedrawRequested` is no longer guaranteed to be emitted after `MainEventsCleared`, it is now platform-specific when the event is emitted after being requested via `redraw_request()`. + - On Windows, `RedrawRequested` is now driven by `WM_PAINT` messages which are requested via `redraw_request()` +- **Breaking** `LoopDestroyed` renamed to `LoopExiting` ([#2900](https://github.com/rust-windowing/winit/issues/2900)) +- **Breaking** `RedrawEventsCleared` removed ([#2900](https://github.com/rust-windowing/winit/issues/2900)) +- **Breaking** `MainEventsCleared` removed ([#2900](https://github.com/rust-windowing/winit/issues/2900)) +- **Breaking:** Remove all deprecated `modifiers` fields. +- **Breaking:** Rename `DeviceEventFilter` to `DeviceEvents` reversing the behavior of variants. +- **Breaking** Add `AboutToWait` event which is emitted when the event loop is about to block and wait for new events ([#2900](https://github.com/rust-windowing/winit/issues/2900)) +- **Breaking:** Rename `EventLoopWindowTarget::set_device_event_filter` to `listen_device_events`. +- **Breaking:** Rename `Window::set_ime_position` to `Window::set_ime_cursor_area` adding a way to set exclusive zone. +- **Breaking:** `with_x11_visual` now takes the visual ID instead of the bare pointer. +- **Breaking** `MouseButton` now supports `Back` and `Forward` variants, emitted from mouse events on Wayland, X11, Windows, macOS and Web. +- **Breaking:** On Web, `instant` is now replaced by `web_time`. +- **Breaking:** On Web, dropped support for Safari versions below 13.1. +- **Breaking:** On Web, the canvas output bitmap size is no longer adjusted. +- **Breaking:** On Web, the canvas size is not controlled by Winit anymore and external changes to the canvas size will be reported through `WindowEvent::Resized`. +- **Breaking:** Updated `bitflags` crate version to `2`, which changes the API on exposed types. +- **Breaking:** `CursorIcon::Arrow` was removed. +- **Breaking:** `CursorIcon::Hand` is now named `CursorIcon::Pointer`. +- **Breaking:** `CursorIcon` is now used from the `cursor-icon` crate. +- **Breaking:** `WindowExtWebSys::canvas()` now returns an `Option`. +- **Breaking:** Overhaul keyboard input handling. + - Replace `KeyboardInput` with `KeyEvent` and `RawKeyEvent`. + - Change `WindowEvent::KeyboardInput` to contain a `KeyEvent`. + - Change `Event::Key` to contain a `RawKeyEvent`. + - Remove `Event::ReceivedCharacter`. In its place, you should use + `KeyEvent.text` in combination with `WindowEvent::Ime`. + - Replace `VirtualKeyCode` with the `Key` enum. + - Replace `ScanCode` with the `KeyCode` enum. + - Rename `ModifiersState::LOGO` to `SUPER` and `ModifiersState::CTRL` to `CONTROL`. + - Add `PhysicalKey` wrapping `KeyCode` and `NativeKeyCode`. + - Add `KeyCode` to refer to keys (roughly) by their physical location. + - Add `NativeKeyCode` to represent raw `KeyCode`s which Winit doesn't + understand. + - Add `Key` to represent the keys after they've been interpreted by the + active (software) keyboard layout. + - Add `NamedKey` to represent the categorized keys. + - Add `NativeKey` to represent raw `Key`s which Winit doesn't understand. + - Add `KeyLocation` to tell apart `Key`s which usually "mean" the same thing, + but can appear simultaneously in different spots on the same keyboard + layout. + - Add `Window::reset_dead_keys` to enable application-controlled cancellation + of dead key sequences. + - Add `KeyEventExtModifierSupplement` to expose additional (and less + portable) interpretations of a given key-press. + - Add `PhysicalKeyExtScancode`, which lets you convert between scancodes and + `PhysicalKey`. + - `ModifiersChanged` now uses dedicated `Modifiers` struct. +- Removed platform-specific extensions that should be retrieved through `raw-window-handle` trait implementations instead: + - `platform::windows::HINSTANCE`. + - `WindowExtWindows::hinstance`. + - `WindowExtWindows::hwnd`. + - `WindowExtIOS::ui_window`. + - `WindowExtIOS::ui_view_controller`. + - `WindowExtIOS::ui_view`. + - `WindowExtMacOS::ns_window`. + - `WindowExtMacOS::ns_view`. + - `EventLoopWindowTargetExtWayland::wayland_display`. + - `WindowExtWayland::wayland_surface`. + - `WindowExtWayland::wayland_display`. + - `WindowExtX11::xlib_window`. + - `WindowExtX11::xlib_display`. + - `WindowExtX11::xlib_screen_id`. + - `WindowExtX11::xcb_connection`. +- Reexport `raw-window-handle` in `window` module. +- Add `ElementState::is_pressed`. +- Add `Window::pre_present_notify` to notify winit before presenting to the windowing system. +- Add `Window::set_blur` to request a blur behind the window; implemented on Wayland for now. +- Add `Window::show_window_menu` to request a titlebar/system menu; implemented on Wayland/Windows for now. +- Implement `AsFd`/`AsRawFd` for `EventLoop` on X11 and Wayland. +- Implement `PartialOrd` and `Ord` for `MouseButton`. +- Implement `PartialOrd` and `Ord` on types in the `dpi` module. +- Make `WindowBuilder` `Send + Sync`. +- Make iOS `MonitorHandle` and `VideoMode` usable from other threads. +- Make iOS windows usable from other threads. +- On Android, add force data to touch events. +- On Android, added `EventLoopBuilderExtAndroid::handle_volume_keys` to indicate that the application will handle the volume keys manually. +- On Android, fix `DeviceId` to contain device id's. +- On Orbital, fix `ModifiersChanged` not being sent. +- On Wayland, `Window::outer_size` now accounts for **client side** decorations. +- On Wayland, add `Window::drag_resize_window` method. +- On Wayland, remove `WINIT_WAYLAND_CSD_THEME` variable. +- On Wayland, fix `TouchPhase::Canceled` being sent for moved events. +- On Wayland, fix forward compatibility issues. +- On Wayland, fix initial window size not restored for maximized/fullscreened on startup window. +- On Wayland, fix maximized startup not taking full size on GNOME. +- On Wayland, fix maximized window creation and window geometry handling. +- On Wayland, fix window not checking that it actually got initial configure event. +- On Wayland, make double clicking and moving the CSD frame more reliable. +- On Wayland, support `Occluded` event with xdg-shell v6 +- On Wayland, use frame callbacks to throttle `RedrawRequested` events so redraws will align with compositor. +- On Web, `ControlFlow::WaitUntil` now uses the Prioritized Task Scheduling API. `setTimeout()`, with a trick to circumvent throttling to 4ms, is used as a fallback. +- On Web, `EventLoopProxy` now implements `Send`. +- On Web, `Window` now implements `Send` and `Sync`. +- On Web, account for CSS `padding`, `border`, and `margin` when getting or setting the canvas position. +- On Web, add Fullscreen API compatibility for Safari. +- On Web, add `DeviceEvent::Motion`, `DeviceEvent::MouseWheel`, `DeviceEvent::Button` and `DeviceEvent::Key` support. +- On Web, add `EventLoopWindowTargetExtWebSys` and `PollStrategy`, which allows to set different strategies for `ControlFlow::Poll`. By default the Prioritized Task Scheduling API is used, but an option to use `Window.requestIdleCallback` is available as well. Both use `setTimeout()`, with a trick to circumvent throttling to 4ms, as a fallback. +- On Web, add `WindowBuilderExtWebSys::with_append()` to append the canvas element to the web page on creation. +- On Web, allow event loops to be recreated with `spawn`. +- On Web, enable event propagation. +- On Web, fix `ControlFlow::WaitUntil` to never wake up **before** the given time. +- On Web, fix `DeviceEvent::MouseMotion` only being emitted for each canvas instead of the whole window. +- On Web, fix `Window:::set_fullscreen` doing nothing when called outside the event loop but during transient activation. +- On Web, fix pen treated as mouse input. +- On Web, fix pointer button events not being processed when a buttons is already pressed. +- On Web, fix scale factor resize suggestion always overwriting the canvas size. +- On Web, fix some `WindowBuilder` methods doing nothing. +- On Web, fix some `Window` methods using incorrect HTML attributes instead of CSS properties. +- On Web, fix the bfcache by not using the `beforeunload` event and map bfcache loading/unloading to `Suspended`/`Resumed` events. +- On Web, fix touch input not gaining or losing focus. +- On Web, fix touch location to be as accurate as mouse position. +- On Web, handle coalesced pointer events, which increases the resolution of pointer inputs. +- On Web, implement `Window::focus_window()`. +- On Web, implement `Window::set_(min|max)_inner_size()`. +- On Web, implement `WindowEvent::Occluded`. +- On Web, never return a `MonitorHandle`. +- On Web, prevent clicks on the canvas to select text. +- On Web, remove any fullscreen requests from the queue when an external fullscreen activation was detected. +- On Web, remove unnecessary `Window::is_dark_mode()`, which was replaced with `Window::theme()`. +- On Web, respect `EventLoopWindowTarget::listen_device_events()` settings. +- On Web, scale factor and dark mode detection are now more robust. +- On Web, send mouse position on button release as well. +- On Web, take all transient activations on the canvas and window into account to queue a fullscreen request. +- On Web, use `Window.requestAnimationFrame()` to throttle `RedrawRequested` events. +- On Web, use the correct canvas size when calculating the new size during scale factor change, instead of using the output bitmap size. +- On Web: fix `Window::request_redraw` not waking the event loop when called from outside the loop. +- On Web: fix position of touch events to be relative to the canvas. +- On Windows, add `drag_resize_window` method support. +- On Windows, add horizontal MouseWheel `DeviceEvent`. +- On Windows, added `WindowBuilderExtWindows::with_class_name` to customize the internal class name. +- On Windows, fix IME APIs not working when from non event loop thread. +- On Windows, fix `CursorEnter/Left` not being sent when grabbing the mouse. +- On Windows, fix `RedrawRequested` not being delivered when calling `Window::request_redraw` from `RedrawRequested`. +- On Windows, port to `windows-sys` version 0.48.0. +- On X11, add a `with_embedded_parent_window` function to the window builder to allow embedding a window into another window. +- On X11, fix event loop not waking up on `ControlFlow::Poll` and `ControlFlow::WaitUntil`. +- On X11, fix false positive flagging of key repeats when pressing different keys with no release between presses. +- On X11, set `visual_id` in returned `raw-window-handle`. +- On iOS, add ability to change the status bar style. +- On iOS, add force data to touch events when using the Apple Pencil. +- On iOS, always wake the event loop when transitioning from `ControlFlow::Poll` to `ControlFlow::Poll`. +- On iOS, send events `WindowEvent::Occluded(false)`, `WindowEvent::Occluded(true)` when application enters/leaves foreground. +- On macOS, add tabbing APIs on `WindowExtMacOS` and `EventLoopWindowTargetExtMacOS`. +- On macOS, fix assertion when pressing `Globe` key. +- On macOS, fix crash in `window.set_minimized(false)`. +- On macOS, fix crash when dropping `Window`. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.30.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.30.md new file mode 100644 index 00000000..6b0b47dd --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.30.md @@ -0,0 +1,413 @@ +## 0.30.13 + +### Added + +- On Wayland, add `Window::set_resize_increments`. + +### Fixed + +- On macOS, fixed crash when dragging non-file content onto window. +- On X11, fix `set_hittest` not working on some window managers. +- On X11, fix debug mode overflow panic in `set_timestamp`. +- On macOS, fix crash in `set_marked_text` when native Pinyin IME sends out-of-bounds `selected_range`. +- On Windows, fix `WM_IME_SETCONTEXT` IME UI flag masking on `lParam`. +- On Android, populate `KeyEvent::text` and `KeyEvent::text_with_all_modifiers` via `Key::to_text()`. + +## 0.30.12 + +### Fixed + +- On macOS, fix crash on macOS 26 by using objc2's `relax-sign-encoding` feature. + +## 0.30.11 + +### Fixed + +- On Windows, fixed crash in should_apps_use_dark_mode() for Windows versions < 17763. +- On Wayland, fixed `pump_events` driven loop deadlocking when loop was not drained before exit. + +## 0.30.10 + +### Added + +- On Windows, add `IconExtWindows::from_resource_name`. +- On Windows, add `CursorGrabMode::Locked`. +- On Wayland, add `WindowExtWayland::xdg_toplevel`. + +### Changed + +- On macOS, no longer need control of the main `NSApplication` class (which means you can now override it yourself). +- On iOS, remove custom application delegates. You are now allowed to override the + application delegate yourself. +- On iOS, no longer act as-if the application successfully open all URLs. Override + `application:didFinishLaunchingWithOptions:` and provide the desired behaviour yourself. + +### Fixed + +- On Windows, fixed ~500 ms pause when clicking the title bar during continuous redraw. +- On macOS, `WindowExtMacOS::set_simple_fullscreen` now honors `WindowExtMacOS::set_borderless_game` +- On X11 and Wayland, fixed pump_events with `Some(Duration::Zero)` blocking with `Wait` polling mode +- On Wayland, fixed a crash when consequently calling `set_cursor_grab` without pointer focus. +- On Wayland, ensure that external event loop is woken-up when using pump_events and integrating via `FD`. +- On Wayland, apply fractional scaling to custom cursors. +- On macOS, fixed `run_app_on_demand` returning without closing open windows. +- On macOS, fixed `VideoMode::refresh_rate_millihertz` for fractional refresh rates. +- On macOS, store monitor handle to avoid panics after going in/out of sleep. +- On macOS, allow certain invalid monitor handles and return `None` instead of panicking. +- On Windows, fixed `Ime::Preedit` cursor offset calculation. + +## 0.30.9 + +### Changed + +- On Wayland, no longer send an explicit clearing `Ime::Preedit` just prior to a new `Ime::Preedit`. + +### Fixed + +- On X11, fix crash with uim. +- On X11, fix modifiers for keys that were sent by the same X11 request. +- On iOS, fix high CPU usage even when using `ControlFlow::Wait`. + +## 0.30.8 + +### Added + +- `ActivationToken::from_raw` and `ActivationToken::into_raw`. +- On X11, add a workaround for disabling IME on GNOME. + +### Fixed + +- On Windows, fixed the event loop not waking on accessibility requests. +- On X11, fixed cursor grab mode state tracking on error. + +## 0.30.7 + +### Fixed + +- On X11, fixed KeyboardInput delivered twice when IME enabled. + +## 0.30.6 + +### Added + +- On macOS, add `WindowExtMacOS::set_borderless_game` and `WindowAttributesExtMacOS::with_borderless_game` + to fully disable the menu bar and dock in Borderless Fullscreen as commonly done in games. +- On X11, the `window` example now understands the `X11_VISUAL_ID` and `X11_SCREEN_ID` env + variables to test the respective modifiers of window creation. +- On Android, the soft keyboard can now be shown using `Window::set_ime_allowed`. +- Add basic iOS IME support. The soft keyboard can now be shown using `Window::set_ime_allowed`. + +### Fixed + +- On macOS, fix `WindowEvent::Moved` sometimes being triggered unnecessarily on resize. +- On macOS, package manifest definitions of `LSUIElement` will no longer be overridden with the + default activation policy, unless explicitly provided during initialization. +- On macOS, fix crash when calling `drag_window()` without a left click present. +- On X11, key events forward to IME anyway, even when it's disabled. +- On Windows, make `ControlFlow::WaitUntil` work more precisely using `CREATE_WAITABLE_TIMER_HIGH_RESOLUTION`. +- On X11, creating windows on screen that is not the first one (e.g. `DISPLAY=:0.1`) works again. +- On X11, creating windows while passing `with_x11_screen(non_default_screen)` works again. +- On X11, fix XInput handling that prevented a new window from getting the focus in some cases. +- On macOS, fix crash when pressing Caps Lock in certain configurations. +- On iOS, fixed `MonitorHandle`'s `PartialEq` and `Hash` implementations. +- On macOS, fixed undocumented cursors (e.g. zoom, resize, help) always appearing to be invalid and falling back to the default cursor. + +## 0.30.5 + +### Added + +- Add `ActiveEventLoop::system_theme()`, returning the current system theme. +- On Web, implement `Error` for `platform::web::CustomCursorError`. +- On Android, add `{Active,}EventLoopExtAndroid::android_app()` to access the app used to create the loop. + +### Fixed + +- On MacOS, fix building with `feature = "rwh_04"`. +- On Web, pen events are now routed through to `WindowEvent::Cursor*`. +- On macOS, fix panic when releasing not available monitor. +- On MacOS, return the system theme in `Window::theme()` if no theme override is set. + +## 0.30.4 + +### Changed + +- `DeviceId::dummy()` and `WindowId::dummy()` are no longer marked `unsafe`. + +### Fixed + +- On Wayland, avoid crashing when compositor is misbehaving. +- On Web, fix `WindowEvent::Resized` not using `requestAnimationFrame` when sending + `WindowEvent::RedrawRequested` and also potentially causing `WindowEvent::RedrawRequested` + to not be de-duplicated. +- Account for different browser engine implementations of pointer movement coordinate space. + +## 0.30.3 + +### Added + +- On Web, add `EventLoopExtWebSys::(set_)poll_strategy()` to allow setting + control flow strategies before starting the event loop. +- On Web, add `WaitUntilStrategy`, which allows to set different strategies for + `ControlFlow::WaitUntil`. By default the Prioritized Task Scheduling API is + used, with a fallback to `setTimeout()` with a trick to circumvent throttling + to 4ms. But an option to use a Web worker to schedule the timer is available + as well, which commonly prevents any throttling when the window is not focused. + +### Changed + +- On macOS, set the window theme on the `NSWindow` instead of application-wide. + +### Fixed + +- On X11, build on arm platforms. +- On macOS, fixed `WindowBuilder::with_theme` not having any effect on the window. + +## 0.30.2 + +### Fixed + +- On Web, fix `EventLoopProxy::send_event()` triggering event loop immediately + when not called from inside the event loop. Now queues a microtask instead. +- On Web, stop overwriting default cursor with `CursorIcon::Default`. +- On Web, prevent crash when using `InnerSizeWriter::request_inner_size()`. +- On macOS, fix not working opacity for entire window. + +## 0.30.1 + +### Added + +- Reexport `raw-window-handle` versions 0.4 and 0.5 as `raw_window_handle_04` and `raw_window_handle_05`. +- Implement `ApplicationHandler` for `&mut` references and heap allocations to something that implements `ApplicationHandler`. + +### Fixed + +- On macOS, fix panic on exit when dropping windows outside the event loop. +- On macOS, fix window dragging glitches when dragging across a monitor boundary with different scale factor. +- On macOS, fix the range in `Ime::Preedit`. +- On macOS, use the system's internal mechanisms for queuing events. +- On macOS, handle events directly instead of queuing when possible. + +## 0.30.0 + +### Added + +- Add `OwnedDisplayHandle` type for allowing safe display handle usage outside of + trivial cases. +- Add `ApplicationHandler` trait which mimics `Event`. +- Add `WindowBuilder::with_cursor` and `Window::set_cursor` which takes a + `CursorIcon` or `CustomCursor`. +- Add `Sync` implementation for `EventLoopProxy`. +- Add `Window::default_attributes` to get default `WindowAttributes`. +- Add `EventLoop::builder` to get `EventLoopBuilder` without export. +- Add `CustomCursor::from_rgba` to allow creating cursor images from RGBA data. +- Add `CustomCursorExtWebSys::from_url` to allow loading cursor images from URLs. +- Add `CustomCursorExtWebSys::from_animation` to allow creating animated + cursors from other `CustomCursor`s. +- Add `{Active,}EventLoop::create_custom_cursor` to load custom cursor image sources. +- Add `ActiveEventLoop::create_window` and `EventLoop::create_window`. +- Add `CustomCursor` which could be set via `Window::set_cursor`, implemented on + Windows, macOS, X11, Wayland, and Web. +- On Web, add to toggle calling `Event.preventDefault()` on `Window`. +- On iOS, add `PinchGesture`, `DoubleTapGesture`, `PanGesture` and `RotationGesture`. +- on iOS, use `UIGestureRecognizerDelegate` for fine grained control of gesture recognizers. +- On macOS, add services menu. +- On Windows, add `with_title_text_color`, and `with_corner_preference` on + `WindowAttributesExtWindows`. +- On Windows, implement resize increments. +- On Windows, add `AnyThread` API to access window handle off the main thread. + +### Changed + +- Bump MSRV from `1.65` to `1.70`. +- On Wayland, bump `sctk-adwaita` to `0.9.0`, which changed system library + crates. This change is a **cascading breaking change**, you must do breaking + change as well, even if you don't expose winit. +- Rename `TouchpadMagnify` to `PinchGesture`. +- Rename `SmartMagnify` to `DoubleTapGesture`. +- Rename `TouchpadRotate` to `RotationGesture`. +- Rename `EventLoopWindowTarget` to `ActiveEventLoop`. +- Rename `platform::x11::XWindowType` to `platform::x11::WindowType`. +- Rename `VideoMode` to `VideoModeHandle` to represent that it doesn't hold + static data. +- Make `Debug` formatting of `WindowId` more concise. +- Move `dpi` types to its own crate, and re-export it from the root crate. +- Replace `log` with `tracing`, use `log` feature on `tracing` to restore old + behavior. +- `EventLoop::with_user_event` now returns `EventLoopBuilder`. +- On Web, return `HandleError::Unavailable` when a window handle is not available. +- On Web, return `RawWindowHandle::WebCanvas` instead of `RawWindowHandle::Web`. +- On Web, remove queuing fullscreen request in absence of transient activation. +- On iOS, return `HandleError::Unavailable` when a window handle is not available. +- On macOS, return `HandleError::Unavailable` when a window handle is not available. +- On Windows, remove `WS_CAPTION`, `WS_BORDER`, and `WS_EX_WINDOWEDGE` styles + for child windows without decorations. +- On Android, bump `ndk` to `0.9.0` and `android-activity` to `0.6.0`, + and remove unused direct dependency on `ndk-sys`. + +### Deprecated + +- Deprecate `EventLoop::run`, use `EventLoop::run_app`. +- Deprecate `EventLoopExtRunOnDemand::run_on_demand`, use `EventLoop::run_app_on_demand`. +- Deprecate `EventLoopExtPumpEvents::pump_events`, use `EventLoopExtPumpEvents::pump_app_events`. + + The new `app` APIs accept a newly added `ApplicationHandler` instead of + `Fn`. The semantics are mostly the same, given that the capture list of the + closure is your new `State`. Consider the following code: + + ```rust,no_run + use winit::event::Event; + use winit::event_loop::EventLoop; + use winit::window::Window; + + struct MyUserEvent; + + let event_loop = EventLoop::::with_user_event().build().unwrap(); + let window = event_loop.create_window(Window::default_attributes()).unwrap(); + let mut counter = 0; + + let _ = event_loop.run(move |event, event_loop| { + match event { + Event::AboutToWait => { + window.request_redraw(); + counter += 1; + } + Event::WindowEvent { window_id, event } => { + // Handle window event. + } + Event::UserEvent(event) => { + // Handle user event. + } + Event::DeviceEvent { device_id, event } => { + // Handle device event. + } + _ => (), + } + }); + ``` + + To migrate this code, you should move all the captured values into some + newtype `State` and implement `ApplicationHandler` for this type. Finally, + we move particular `match event` arms into methods on `ApplicationHandler`, + for example: + + ```rust,no_run + use winit::application::ApplicationHandler; + use winit::event::{Event, WindowEvent, DeviceEvent, DeviceId}; + use winit::event_loop::{EventLoop, ActiveEventLoop}; + use winit::window::{Window, WindowId}; + + struct MyUserEvent; + + struct State { + window: Window, + counter: i32, + } + + impl ApplicationHandler for State { + fn user_event(&mut self, event_loop: &ActiveEventLoop, user_event: MyUserEvent) { + // Handle user event. + } + + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + // Your application got resumed. + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) { + // Handle window event. + } + + fn device_event(&mut self, event_loop: &ActiveEventLoop, device_id: DeviceId, event: DeviceEvent) { + // Handle device event. + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + self.window.request_redraw(); + self.counter += 1; + } + } + + let event_loop = EventLoop::::with_user_event().build().unwrap(); + #[allow(deprecated)] + let window = event_loop.create_window(Window::default_attributes()).unwrap(); + let mut state = State { window, counter: 0 }; + + let _ = event_loop.run_app(&mut state); + ``` + + Please submit your feedback after migrating in [this issue](https://github.com/rust-windowing/winit/issues/3626). + +- Deprecate `Window::set_cursor_icon`, use `Window::set_cursor`. + +### Removed + +- Remove `Window::new`, use `ActiveEventLoop::create_window` instead. + + You now have to create your windows inside the actively running event loop + (usually the `new_events(cause: StartCause::Init)` or `resumed()` events), + and can no longer do it before the application has properly launched. + This change is done to fix many long-standing issues on iOS and macOS, and + will improve things on Wayland once fully implemented. + + To ease migration, we provide the deprecated `EventLoop::create_window` that + will allow you to bypass this restriction in this release. + + Using the migration example from above, you can change your code as follows: + + ```rust,no_run + use winit::application::ApplicationHandler; + use winit::event::{Event, WindowEvent, DeviceEvent, DeviceId}; + use winit::event_loop::{EventLoop, ActiveEventLoop}; + use winit::window::{Window, WindowId}; + + #[derive(Default)] + struct State { + // Use an `Option` to allow the window to not be available until the + // application is properly running. + window: Option, + counter: i32, + } + + impl ApplicationHandler for State { + // This is a common indicator that you can create a window. + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + self.window = Some(event_loop.create_window(Window::default_attributes()).unwrap()); + } + fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) { + // `unwrap` is fine, the window will always be available when + // receiving a window event. + let window = self.window.as_ref().unwrap(); + // Handle window event. + } + fn device_event(&mut self, event_loop: &ActiveEventLoop, device_id: DeviceId, event: DeviceEvent) { + // Handle window event. + } + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if let Some(window) = self.window.as_ref() { + window.request_redraw(); + self.counter += 1; + } + } + } + + let event_loop = EventLoop::new().unwrap(); + let mut state = State::default(); + let _ = event_loop.run_app(&mut state); + ``` + +- Remove `Deref` implementation for `EventLoop` that gave `EventLoopWindowTarget`. +- Remove `WindowBuilder` in favor of `WindowAttributes`. +- Remove Generic parameter `T` from `ActiveEventLoop`. +- Remove `EventLoopBuilder::with_user_event`, use `EventLoop::with_user_event`. +- Remove Redundant `EventLoopError::AlreadyRunning`. +- Remove `WindowAttributes::fullscreen` and expose as field directly. +- On X11, remove `platform::x11::XNotSupported` export. + +### Fixed + +- On Web, fix setting cursor icon overriding cursor visibility. +- On Windows, fix cursor not confined to center of window when grabbed and hidden. +- On macOS, fix sequence of mouse events being out of order when dragging on the trackpad. +- On Wayland, fix decoration glitch on close with some compositors. +- On Android, fix a regression introduced in #2748 to allow volume key events to be received again. +- On Windows, don't return a valid window handle outside of the GUI thread. +- On macOS, don't set the background color when initializing a window with transparency. diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.8.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.8.md new file mode 100644 index 00000000..1963429c --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.8.md @@ -0,0 +1,33 @@ +## 0.8.3 + +- Fixed issue of calls to `set_inner_size` blocking on Windows. +- Mapped `ISO_Left_Tab` to `VirtualKeyCode::Tab` to make the key work with modifiers +- Fixed the X11 backed on 32bit targets + +## 0.8.2 + +- Uniformize keyboard scancode values across Wayland and X11 (#297). +- Internal rework of the wayland event loop +- Added method `os::linux::WindowExt::is_ready` + +## 0.8.1 + +- Added various methods to `os::linux::EventsLoopExt`, plus some hidden items necessary to make + glutin work. + +## 0.8.0 + +- Added `Window::set_maximized`, `WindowAttributes::maximized` and `WindowBuilder::with_maximized`. +- Added `Window::set_fullscreen`. +- Changed `with_fullscreen` to take a `Option` instead of a `MonitorId`. +- Removed `MonitorId::get_native_identifier()` in favor of platform-specific traits in the `os` + module. +- Changed `get_available_monitors()` and `get_primary_monitor()` to be methods of `EventsLoop` + instead of stand-alone methods. +- Changed `EventsLoop` to be tied to a specific X11 or Wayland connection. +- Added a `os::linux::EventsLoopExt` trait that makes it possible to configure the connection. +- Fixed the emscripten code, which now compiles. +- Changed the X11 fullscreen code to use `xrandr` instead of `xxf86vm`. +- Fixed the Wayland backend to produce `Refresh` event after window creation. +- Changed the `Suspended` event to be outside of `WindowEvent`. +- Fixed the X11 backend sometimes reporting the wrong virtual key (#273). diff --git a/third-party/winit-0.30.13-ferrex/src/changelog/v0.9.md b/third-party/winit-0.30.13-ferrex/src/changelog/v0.9.md new file mode 100644 index 00000000..2a9e8cc3 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/changelog/v0.9.md @@ -0,0 +1,22 @@ +## 0.9.0 + +- Added event `WindowEvent::HiDPIFactorChanged`. +- Added method `MonitorId::get_hidpi_factor`. +- Deprecated `get_inner_size_pixels` and `get_inner_size_points` methods of `Window` in favor of + `get_inner_size`. +- **Breaking:** `EventsLoop` is `!Send` and `!Sync` because of platform-dependant constraints, + but `Window`, `WindowId`, `DeviceId` and `MonitorId` guaranteed to be `Send`. +- `MonitorId::get_position` now returns `(i32, i32)` instead of `(u32, u32)`. +- Rewrite of the wayland backend to use wayland-client-0.11 +- Support for dead keys on wayland for keyboard utf8 input +- Monitor enumeration on Windows is now implemented using `EnumDisplayMonitors` instead of + `EnumDisplayDevices`. This changes the value returned by `MonitorId::get_name()`. +- On Windows added `MonitorIdExt::hmonitor` method +- Impl `Clone` for `EventsLoopProxy` +- `EventsLoop::get_primary_monitor()` on X11 will fallback to any available monitor if no primary is found +- Support for touch event on wayland +- `WindowEvent`s `MouseMoved`, `MouseEntered`, and `MouseLeft` have been renamed to + `CursorMoved`, `CursorEntered`, and `CursorLeft`. +- New `DeviceEvent`s added, `MouseMotion` and `MouseWheel`. +- Send `CursorMoved` event after `CursorEntered` and `Focused` events. +- Add support for `ModifiersState`, `MouseMove`, `MouseInput`, `MouseMotion` for emscripten backend. diff --git a/third-party/winit-0.30.13-ferrex/src/cursor.rs b/third-party/winit-0.30.13-ferrex/src/cursor.rs new file mode 100644 index 00000000..7bcac54d --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/cursor.rs @@ -0,0 +1,263 @@ +use core::fmt; +use std::error::Error; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use cursor_icon::CursorIcon; + +use crate::platform_impl::{PlatformCustomCursor, PlatformCustomCursorSource}; + +/// The maximum width and height for a cursor when using [`CustomCursor::from_rgba`]. +pub const MAX_CURSOR_SIZE: u16 = 2048; + +const PIXEL_SIZE: usize = 4; + +/// See [`Window::set_cursor()`][crate::window::Window::set_cursor] for more details. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum Cursor { + Icon(CursorIcon), + Custom(CustomCursor), +} + +impl Default for Cursor { + fn default() -> Self { + Self::Icon(CursorIcon::default()) + } +} + +impl From for Cursor { + fn from(icon: CursorIcon) -> Self { + Self::Icon(icon) + } +} + +impl From for Cursor { + fn from(custom: CustomCursor) -> Self { + Self::Custom(custom) + } +} + +/// Use a custom image as a cursor (mouse pointer). +/// +/// Is guaranteed to be cheap to clone. +/// +/// ## Platform-specific +/// +/// **Web**: Some browsers have limits on cursor sizes usually at 128x128. +/// +/// # Example +/// +/// ```no_run +/// # use winit::event_loop::ActiveEventLoop; +/// # use winit::window::Window; +/// # fn scope(event_loop: &ActiveEventLoop, window: &Window) { +/// use winit::window::CustomCursor; +/// +/// let w = 10; +/// let h = 10; +/// let rgba = vec![255; (w * h * 4) as usize]; +/// +/// #[cfg(not(target_family = "wasm"))] +/// let source = CustomCursor::from_rgba(rgba, w, h, w / 2, h / 2).unwrap(); +/// +/// #[cfg(target_family = "wasm")] +/// let source = { +/// use winit::platform::web::CustomCursorExtWebSys; +/// CustomCursor::from_url(String::from("http://localhost:3000/cursor.png"), 0, 0) +/// }; +/// +/// let custom_cursor = event_loop.create_custom_cursor(source); +/// +/// window.set_cursor(custom_cursor.clone()); +/// # } +/// ``` +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct CustomCursor { + /// Platforms should make sure this is cheap to clone. + pub(crate) inner: PlatformCustomCursor, +} + +impl CustomCursor { + /// Creates a new cursor from an rgba buffer. + /// + /// The alpha channel is assumed to be **not** premultiplied. + pub fn from_rgba( + rgba: impl Into>, + width: u16, + height: u16, + hotspot_x: u16, + hotspot_y: u16, + ) -> Result { + let _span = + tracing::debug_span!("winit::Cursor::from_rgba", width, height, hotspot_x, hotspot_y) + .entered(); + + Ok(CustomCursorSource { + inner: PlatformCustomCursorSource::from_rgba( + rgba.into(), + width, + height, + hotspot_x, + hotspot_y, + )?, + }) + } +} + +/// Source for [`CustomCursor`]. +/// +/// See [`CustomCursor`] for more details. +#[derive(Debug)] +pub struct CustomCursorSource { + pub(crate) inner: PlatformCustomCursorSource, +} + +/// An error produced when using [`CustomCursor::from_rgba`] with invalid arguments. +#[derive(Debug, Clone)] +pub enum BadImage { + /// Produced when the image dimensions are larger than [`MAX_CURSOR_SIZE`]. This doesn't + /// guarantee that the cursor will work, but should avoid many platform and device specific + /// limits. + TooLarge { width: u16, height: u16 }, + /// Produced when the length of the `rgba` argument isn't divisible by 4, thus `rgba` can't be + /// safely interpreted as 32bpp RGBA pixels. + ByteCountNotDivisibleBy4 { byte_count: usize }, + /// Produced when the number of pixels (`rgba.len() / 4`) isn't equal to `width * height`. + /// At least one of your arguments is incorrect. + DimensionsVsPixelCount { width: u16, height: u16, width_x_height: u64, pixel_count: u64 }, + /// Produced when the hotspot is outside the image bounds + HotspotOutOfBounds { width: u16, height: u16, hotspot_x: u16, hotspot_y: u16 }, +} + +impl fmt::Display for BadImage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BadImage::TooLarge { width, height } => write!( + f, + "The specified dimensions ({width:?}x{height:?}) are too large. The maximum is \ + {MAX_CURSOR_SIZE:?}x{MAX_CURSOR_SIZE:?}.", + ), + BadImage::ByteCountNotDivisibleBy4 { byte_count } => write!( + f, + "The length of the `rgba` argument ({byte_count:?}) isn't divisible by 4, making \ + it impossible to interpret as 32bpp RGBA pixels.", + ), + BadImage::DimensionsVsPixelCount { width, height, width_x_height, pixel_count } => { + write!( + f, + "The specified dimensions ({width:?}x{height:?}) don't match the number of \ + pixels supplied by the `rgba` argument ({pixel_count:?}). For those \ + dimensions, the expected pixel count is {width_x_height:?}.", + ) + }, + BadImage::HotspotOutOfBounds { width, height, hotspot_x, hotspot_y } => write!( + f, + "The specified hotspot ({hotspot_x:?}, {hotspot_y:?}) is outside the image bounds \ + ({width:?}x{height:?}).", + ), + } + } +} + +impl Error for BadImage {} + +/// Platforms export this directly as `PlatformCustomCursorSource` if they need to only work with +/// images. +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) struct OnlyCursorImageSource(pub(crate) CursorImage); + +#[allow(dead_code)] +impl OnlyCursorImageSource { + pub(crate) fn from_rgba( + rgba: Vec, + width: u16, + height: u16, + hotspot_x: u16, + hotspot_y: u16, + ) -> Result { + CursorImage::from_rgba(rgba, width, height, hotspot_x, hotspot_y).map(Self) + } +} + +/// Platforms export this directly as `PlatformCustomCursor` if they don't implement caching. +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub(crate) struct OnlyCursorImage(pub(crate) Arc); + +impl Hash for OnlyCursorImage { + fn hash(&self, state: &mut H) { + Arc::as_ptr(&self.0).hash(state); + } +} + +impl PartialEq for OnlyCursorImage { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for OnlyCursorImage {} + +#[derive(Debug)] +#[allow(dead_code)] +pub(crate) struct CursorImage { + pub(crate) rgba: Vec, + pub(crate) width: u16, + pub(crate) height: u16, + pub(crate) hotspot_x: u16, + pub(crate) hotspot_y: u16, +} + +impl CursorImage { + pub(crate) fn from_rgba( + rgba: Vec, + width: u16, + height: u16, + hotspot_x: u16, + hotspot_y: u16, + ) -> Result { + if width > MAX_CURSOR_SIZE || height > MAX_CURSOR_SIZE { + return Err(BadImage::TooLarge { width, height }); + } + + if rgba.len() % PIXEL_SIZE != 0 { + return Err(BadImage::ByteCountNotDivisibleBy4 { byte_count: rgba.len() }); + } + + let pixel_count = (rgba.len() / PIXEL_SIZE) as u64; + let width_x_height = width as u64 * height as u64; + if pixel_count != width_x_height { + return Err(BadImage::DimensionsVsPixelCount { + width, + height, + width_x_height, + pixel_count, + }); + } + + if hotspot_x >= width || hotspot_y >= height { + return Err(BadImage::HotspotOutOfBounds { width, height, hotspot_x, hotspot_y }); + } + + Ok(CursorImage { rgba, width, height, hotspot_x, hotspot_y }) + } +} + +// Platforms that don't support cursors will export this as `PlatformCustomCursor`. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct NoCustomCursor; + +#[allow(dead_code)] +impl NoCustomCursor { + pub(crate) fn from_rgba( + rgba: Vec, + width: u16, + height: u16, + hotspot_x: u16, + hotspot_y: u16, + ) -> Result { + CursorImage::from_rgba(rgba, width, height, hotspot_x, hotspot_y)?; + Ok(Self) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/error.rs b/third-party/winit-0.30.13-ferrex/src/error.rs new file mode 100644 index 00000000..d15bb9e7 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/error.rs @@ -0,0 +1,131 @@ +use std::{error, fmt}; + +use crate::platform_impl; + +// TODO: Rename +/// An error that may be generated when requesting Winit state +#[derive(Debug)] +pub enum ExternalError { + /// The operation is not supported by the backend. + NotSupported(NotSupportedError), + /// The operation was ignored. + Ignored, + /// The OS cannot perform the operation. + Os(OsError), +} + +/// The error type for when the requested operation is not supported by the backend. +#[derive(Clone)] +pub struct NotSupportedError { + _marker: (), +} + +/// The error type for when the OS cannot perform the requested operation. +#[derive(Debug)] +pub struct OsError { + line: u32, + file: &'static str, + error: platform_impl::OsError, +} + +/// A general error that may occur while running the Winit event loop +#[derive(Debug)] +pub enum EventLoopError { + /// The operation is not supported by the backend. + NotSupported(NotSupportedError), + /// The OS cannot perform the operation. + Os(OsError), + /// The event loop can't be re-created. + RecreationAttempt, + /// Application has exit with an error status. + ExitFailure(i32), +} + +impl From for EventLoopError { + fn from(value: OsError) -> Self { + Self::Os(value) + } +} + +impl NotSupportedError { + #[inline] + #[allow(dead_code)] + pub(crate) fn new() -> NotSupportedError { + NotSupportedError { _marker: () } + } +} + +impl OsError { + #[allow(dead_code)] + pub(crate) fn new(line: u32, file: &'static str, error: platform_impl::OsError) -> OsError { + OsError { line, file, error } + } +} + +#[allow(unused_macros)] +macro_rules! os_error { + ($error:expr) => {{ + crate::error::OsError::new(line!(), file!(), $error) + }}; +} + +impl fmt::Display for OsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + f.pad(&format!("os error at {}:{}: {}", self.file, self.line, self.error)) + } +} + +impl fmt::Display for ExternalError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + match self { + ExternalError::NotSupported(e) => e.fmt(f), + ExternalError::Ignored => write!(f, "Operation was ignored"), + ExternalError::Os(e) => e.fmt(f), + } + } +} + +impl fmt::Debug for NotSupportedError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + f.debug_struct("NotSupportedError").finish() + } +} + +impl fmt::Display for NotSupportedError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + f.pad("the requested operation is not supported by Winit") + } +} + +impl fmt::Display for EventLoopError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + match self { + EventLoopError::RecreationAttempt => write!(f, "EventLoop can't be recreated"), + EventLoopError::NotSupported(e) => e.fmt(f), + EventLoopError::Os(e) => e.fmt(f), + EventLoopError::ExitFailure(status) => write!(f, "Exit Failure: {status}"), + } + } +} + +impl error::Error for OsError {} +impl error::Error for ExternalError {} +impl error::Error for NotSupportedError {} +impl error::Error for EventLoopError {} + +#[cfg(test)] +#[allow(clippy::redundant_clone)] +mod tests { + use super::*; + + // Eat attributes for testing + #[test] + fn ensure_fmt_does_not_panic() { + let _ = format!("{:?}, {}", NotSupportedError::new(), NotSupportedError::new().clone()); + let _ = format!( + "{:?}, {}", + ExternalError::NotSupported(NotSupportedError::new()), + ExternalError::NotSupported(NotSupportedError::new()) + ); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/event.rs b/third-party/winit-0.30.13-ferrex/src/event.rs new file mode 100644 index 00000000..4e01420a --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/event.rs @@ -0,0 +1,1183 @@ +//! The [`Event`] enum and assorted supporting types. +//! +//! These are sent to the closure given to [`EventLoop::run_app(...)`], where they get +//! processed and used to modify the program state. For more details, see the root-level +//! documentation. +//! +//! Some of these events represent different "parts" of a traditional event-handling loop. You could +//! approximate the basic ordering loop of [`EventLoop::run_app(...)`] like this: +//! +//! ```rust,ignore +//! let mut start_cause = StartCause::Init; +//! +//! while !elwt.exiting() { +//! app.new_events(event_loop, start_cause); +//! +//! for event in (window events, user events, device events) { +//! // This will pick the right method on the application based on the event. +//! app.handle_event(event_loop, event); +//! } +//! +//! for window_id in (redraw windows) { +//! app.window_event(event_loop, window_id, RedrawRequested); +//! } +//! +//! app.about_to_wait(event_loop); +//! start_cause = wait_if_necessary(); +//! } +//! +//! app.exiting(event_loop); +//! ``` +//! +//! This leaves out timing details like [`ControlFlow::WaitUntil`] but hopefully +//! describes what happens in what order. +//! +//! [`EventLoop::run_app(...)`]: crate::event_loop::EventLoop::run_app +//! [`ControlFlow::WaitUntil`]: crate::event_loop::ControlFlow::WaitUntil +use std::path::PathBuf; +use std::sync::{Mutex, Weak}; +#[cfg(not(web_platform))] +use std::time::Instant; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; +#[cfg(web_platform)] +use web_time::Instant; + +use crate::dpi::{PhysicalPosition, PhysicalSize}; +use crate::error::ExternalError; +use crate::event_loop::AsyncRequestSerial; +use crate::keyboard::{self, ModifiersKeyState, ModifiersKeys, ModifiersState}; +use crate::platform_impl; +#[cfg(doc)] +use crate::window::Window; +use crate::window::{ActivationToken, Theme, WindowId}; + +/// Describes a generic event. +/// +/// See the module-level docs for more information on the event loop manages each event. +#[derive(Debug, Clone, PartialEq)] +pub enum Event { + /// See [`ApplicationHandler::new_events`] for details. + /// + /// [`ApplicationHandler::new_events`]: crate::application::ApplicationHandler::new_events + NewEvents(StartCause), + + /// See [`ApplicationHandler::window_event`] for details. + /// + /// [`ApplicationHandler::window_event`]: crate::application::ApplicationHandler::window_event + WindowEvent { window_id: WindowId, event: WindowEvent }, + + /// See [`ApplicationHandler::device_event`] for details. + /// + /// [`ApplicationHandler::device_event`]: crate::application::ApplicationHandler::device_event + DeviceEvent { device_id: DeviceId, event: DeviceEvent }, + + /// See [`ApplicationHandler::user_event`] for details. + /// + /// [`ApplicationHandler::user_event`]: crate::application::ApplicationHandler::user_event + UserEvent(T), + + /// See [`ApplicationHandler::suspended`] for details. + /// + /// [`ApplicationHandler::suspended`]: crate::application::ApplicationHandler::suspended + Suspended, + + /// See [`ApplicationHandler::resumed`] for details. + /// + /// [`ApplicationHandler::resumed`]: crate::application::ApplicationHandler::resumed + Resumed, + + /// See [`ApplicationHandler::about_to_wait`] for details. + /// + /// [`ApplicationHandler::about_to_wait`]: crate::application::ApplicationHandler::about_to_wait + AboutToWait, + + /// See [`ApplicationHandler::exiting`] for details. + /// + /// [`ApplicationHandler::exiting`]: crate::application::ApplicationHandler::exiting + LoopExiting, + + /// See [`ApplicationHandler::memory_warning`] for details. + /// + /// [`ApplicationHandler::memory_warning`]: crate::application::ApplicationHandler::memory_warning + MemoryWarning, +} + +impl Event { + #[allow(clippy::result_large_err)] + pub fn map_nonuser_event(self) -> Result, Event> { + use self::Event::*; + match self { + UserEvent(_) => Err(self), + WindowEvent { window_id, event } => Ok(WindowEvent { window_id, event }), + DeviceEvent { device_id, event } => Ok(DeviceEvent { device_id, event }), + NewEvents(cause) => Ok(NewEvents(cause)), + AboutToWait => Ok(AboutToWait), + LoopExiting => Ok(LoopExiting), + Suspended => Ok(Suspended), + Resumed => Ok(Resumed), + MemoryWarning => Ok(MemoryWarning), + } + } +} + +/// Describes the reason the event loop is resuming. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartCause { + /// Sent if the time specified by [`ControlFlow::WaitUntil`] has been reached. Contains the + /// moment the timeout was requested and the requested resume time. The actual resume time is + /// guaranteed to be equal to or after the requested resume time. + /// + /// [`ControlFlow::WaitUntil`]: crate::event_loop::ControlFlow::WaitUntil + ResumeTimeReached { start: Instant, requested_resume: Instant }, + + /// Sent if the OS has new events to send to the window, after a wait was requested. Contains + /// the moment the wait was requested and the resume time, if requested. + WaitCancelled { start: Instant, requested_resume: Option }, + + /// Sent if the event loop is being resumed after the loop's control flow was set to + /// [`ControlFlow::Poll`]. + /// + /// [`ControlFlow::Poll`]: crate::event_loop::ControlFlow::Poll + Poll, + + /// Sent once, immediately after `run` is called. Indicates that the loop was just initialized. + Init, +} + +/// Describes an event from a [`Window`]. +#[derive(Debug, Clone, PartialEq)] +pub enum WindowEvent { + /// The activation token was delivered back and now could be used. + #[cfg_attr(not(any(x11_platform, wayland_platform)), allow(rustdoc::broken_intra_doc_links))] + /// Delivered in response to [`request_activation_token`]. + /// + /// [`request_activation_token`]: crate::platform::startup_notify::WindowExtStartupNotify::request_activation_token + ActivationTokenDone { serial: AsyncRequestSerial, token: ActivationToken }, + + /// The size of the window has changed. Contains the client area's new dimensions. + Resized(PhysicalSize), + + /// The position of the window has changed. Contains the window's new position. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web / Wayland:** Unsupported. + Moved(PhysicalPosition), + + /// The window has been requested to close. + CloseRequested, + + /// The window has been destroyed. + Destroyed, + + /// A file has been dropped into the window. + /// + /// When the user drops multiple files at once, this event will be emitted for each file + /// separately. + DroppedFile(PathBuf), + + /// A file is being hovered over the window. + /// + /// When the user hovers multiple files at once, this event will be emitted for each file + /// separately. + HoveredFile(PathBuf), + + /// A file was hovered, but has exited the window. + /// + /// There will be a single `HoveredFileCancelled` event triggered even if multiple files were + /// hovered. + HoveredFileCancelled, + + /// The window gained or lost focus. + /// + /// The parameter is true if the window has gained focus, and false if it has lost focus. + Focused(bool), + + /// An event from the keyboard has been received. + /// + /// ## Platform-specific + /// - **Windows:** The shift key overrides NumLock. In other words, while shift is held down, + /// numpad keys act as if NumLock wasn't active. When this is used, the OS sends fake key + /// events which are not marked as `is_synthetic`. + KeyboardInput { + device_id: DeviceId, + event: KeyEvent, + + /// If `true`, the event was generated synthetically by winit + /// in one of the following circumstances: + /// + /// * Synthetic key press events are generated for all keys pressed when a window gains + /// focus. Likewise, synthetic key release events are generated for all keys pressed when + /// a window goes out of focus. ***Currently, this is only functional on X11 and + /// Windows*** + /// + /// Otherwise, this value is always `false`. + is_synthetic: bool, + }, + + /// The keyboard modifiers have changed. + ModifiersChanged(Modifiers), + + /// An event from an input method. + /// + /// **Note:** You have to explicitly enable this event using [`Window::set_ime_allowed`]. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web / Orbital:** Unsupported. + Ime(Ime), + + /// The cursor has moved on the window. + /// + /// ## Platform-specific + /// + /// - **Web:** Doesn't take into account CSS [`border`], [`padding`], or [`transform`]. + /// + /// [`border`]: https://developer.mozilla.org/en-US/docs/Web/CSS/border + /// [`padding`]: https://developer.mozilla.org/en-US/docs/Web/CSS/padding + /// [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform + CursorMoved { + device_id: DeviceId, + + /// (x,y) coords in pixels relative to the top-left corner of the window. Because the range + /// of this data is limited by the display area and it may have been transformed by + /// the OS to implement effects such as cursor acceleration, it should not be used + /// to implement non-cursor-like interactions such as 3D camera control. + position: PhysicalPosition, + }, + + /// The cursor has entered the window. + /// + /// ## Platform-specific + /// + /// - **Web:** Doesn't take into account CSS [`border`], [`padding`], or [`transform`]. + /// + /// [`border`]: https://developer.mozilla.org/en-US/docs/Web/CSS/border + /// [`padding`]: https://developer.mozilla.org/en-US/docs/Web/CSS/padding + /// [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform + CursorEntered { device_id: DeviceId }, + + /// The cursor has left the window. + /// + /// ## Platform-specific + /// + /// - **Web:** Doesn't take into account CSS [`border`], [`padding`], or [`transform`]. + /// + /// [`border`]: https://developer.mozilla.org/en-US/docs/Web/CSS/border + /// [`padding`]: https://developer.mozilla.org/en-US/docs/Web/CSS/padding + /// [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform + CursorLeft { device_id: DeviceId }, + + /// A mouse wheel movement or touchpad scroll occurred. + MouseWheel { device_id: DeviceId, delta: MouseScrollDelta, phase: TouchPhase }, + + /// An mouse button press has been received. + MouseInput { device_id: DeviceId, state: ElementState, button: MouseButton }, + + /// Two-finger pinch gesture, often used for magnification. + /// + /// ## Platform-specific + /// + /// - Only available on **macOS** and **iOS**. + /// - On iOS, not recognized by default. It must be enabled when needed. + PinchGesture { + device_id: DeviceId, + /// Positive values indicate magnification (zooming in) and negative + /// values indicate shrinking (zooming out). + /// + /// This value may be NaN. + delta: f64, + phase: TouchPhase, + }, + + /// N-finger pan gesture + /// + /// ## Platform-specific + /// + /// - Only available on **iOS**. + /// - On iOS, not recognized by default. It must be enabled when needed. + PanGesture { + device_id: DeviceId, + /// Change in pixels of pan gesture from last update. + delta: PhysicalPosition, + phase: TouchPhase, + }, + + /// Double tap gesture. + /// + /// On a Mac, smart magnification is triggered by a double tap with two fingers + /// on the trackpad and is commonly used to zoom on a certain object + /// (e.g. a paragraph of a PDF) or (sort of like a toggle) to reset any zoom. + /// The gesture is also supported in Safari, Pages, etc. + /// + /// The event is general enough that its generating gesture is allowed to vary + /// across platforms. It could also be generated by another device. + /// + /// Unfortunately, neither [Windows](https://support.microsoft.com/en-us/windows/touch-gestures-for-windows-a9d28305-4818-a5df-4e2b-e5590f850741) + /// nor [Wayland](https://wayland.freedesktop.org/libinput/doc/latest/gestures.html) + /// support this gesture or any other gesture with the same effect. + /// + /// ## Platform-specific + /// + /// - Only available on **macOS 10.8** and later, and **iOS**. + /// - On iOS, not recognized by default. It must be enabled when needed. + DoubleTapGesture { device_id: DeviceId }, + + /// Two-finger rotation gesture. + /// + /// Positive delta values indicate rotation counterclockwise and + /// negative delta values indicate rotation clockwise. + /// + /// ## Platform-specific + /// + /// - Only available on **macOS** and **iOS**. + /// - On iOS, not recognized by default. It must be enabled when needed. + RotationGesture { + device_id: DeviceId, + /// change in rotation in degrees + delta: f32, + phase: TouchPhase, + }, + + /// Touchpad pressure event. + /// + /// At the moment, only supported on Apple forcetouch-capable macbooks. + /// The parameters are: pressure level (value between 0 and 1 representing how hard the + /// touchpad is being pressed) and stage (integer representing the click level). + TouchpadPressure { device_id: DeviceId, pressure: f32, stage: i64 }, + + /// Motion on some analog axis. May report data redundant to other, more specific events. + AxisMotion { device_id: DeviceId, axis: AxisId, value: f64 }, + + /// Touch event has been received + /// + /// ## Platform-specific + /// + /// - **Web:** Doesn't take into account CSS [`border`], [`padding`], or [`transform`]. + /// - **macOS:** Unsupported. + /// + /// [`border`]: https://developer.mozilla.org/en-US/docs/Web/CSS/border + /// [`padding`]: https://developer.mozilla.org/en-US/docs/Web/CSS/padding + /// [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform + Touch(Touch), + + /// The window's scale factor has changed. + /// + /// The following user actions can cause DPI changes: + /// + /// * Changing the display's resolution. + /// * Changing the display's scale factor (e.g. in Control Panel on Windows). + /// * Moving the window to a display with a different scale factor. + /// + /// To update the window size, use the provided [`InnerSizeWriter`] handle. By default, the + /// window is resized to the value suggested by the OS, but it can be changed to any value. + /// + /// For more information about DPI in general, see the [`dpi`] crate. + ScaleFactorChanged { + scale_factor: f64, + /// Handle to update inner size during scale changes. + /// + /// See [`InnerSizeWriter`] docs for more details. + inner_size_writer: InnerSizeWriter, + }, + + /// The system window theme has changed. + /// + /// Applications might wish to react to this to change the theme of the content of the window + /// when the system changes the window theme. + /// + /// This only reports a change if the window theme was not overridden by [`Window::set_theme`]. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / X11 / Wayland / Orbital:** Unsupported. + ThemeChanged(Theme), + + /// The window has been occluded (completely hidden from view). + /// + /// This is different to window visibility as it depends on whether the window is closed, + /// minimised, set invisible, or fully occluded by another window. + /// + /// ## Platform-specific + /// + /// ### iOS + /// + /// On iOS, the `Occluded(false)` event is emitted in response to an + /// [`applicationWillEnterForeground`] callback which means the application should start + /// preparing its data. The `Occluded(true)` event is emitted in response to an + /// [`applicationDidEnterBackground`] callback which means the application should free + /// resources (according to the [iOS application lifecycle]). + /// + /// [`applicationWillEnterForeground`]: https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623076-applicationwillenterforeground + /// [`applicationDidEnterBackground`]: https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622997-applicationdidenterbackground + /// [iOS application lifecycle]: https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle + /// + /// ### Others + /// + /// - **Web:** Doesn't take into account CSS [`border`], [`padding`], or [`transform`]. + /// - **Android / Wayland / Windows / Orbital:** Unsupported. + /// + /// [`border`]: https://developer.mozilla.org/en-US/docs/Web/CSS/border + /// [`padding`]: https://developer.mozilla.org/en-US/docs/Web/CSS/padding + /// [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform + Occluded(bool), + + /// Emitted when a window should be redrawn. + /// + /// This gets triggered in two scenarios: + /// - The OS has performed an operation that's invalidated the window's contents (such as + /// resizing the window). + /// - The application has explicitly requested a redraw via [`Window::request_redraw`]. + /// + /// Winit will aggregate duplicate redraw requests into a single event, to + /// help avoid duplicating rendering work. + RedrawRequested, +} + +/// Identifier of an input device. +/// +/// Whenever you receive an event arising from a particular input device, this event contains a +/// `DeviceId` which identifies its origin. Note that devices may be virtual (representing an +/// on-screen cursor and keyboard focus) or physical. Virtual devices typically aggregate inputs +/// from multiple physical devices. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeviceId(pub(crate) platform_impl::DeviceId); + +impl DeviceId { + /// Returns a dummy id, useful for unit testing. + /// + /// # Notes + /// + /// The only guarantee made about the return value of this function is that + /// it will always be equal to itself and to future values returned by this function. + /// No other guarantees are made. This may be equal to a real `DeviceId`. + pub const fn dummy() -> Self { + DeviceId(platform_impl::DeviceId::dummy()) + } +} + +/// Represents raw hardware events that are not associated with any particular window. +/// +/// Useful for interactions that diverge significantly from a conventional 2D GUI, such as 3D camera +/// or first-person game controls. Many physical actions, such as mouse movement, can produce both +/// device and window events. Because window events typically arise from virtual devices +/// (corresponding to GUI cursors and keyboard focus) the device IDs may not match. +/// +/// Note that these events are delivered regardless of input focus. +#[derive(Clone, Debug, PartialEq)] +pub enum DeviceEvent { + Added, + Removed, + + /// Change in physical position of a pointing device. + /// + /// This represents raw, unfiltered physical motion. Not to be confused with + /// [`WindowEvent::CursorMoved`]. + MouseMotion { + /// (x, y) change in position in unspecified units. + /// + /// Different devices may use different units. + delta: (f64, f64), + }, + + /// Physical scroll event + MouseWheel { + delta: MouseScrollDelta, + }, + + /// Motion on some analog axis. This event will be reported for all arbitrary input devices + /// that winit supports on this platform, including mouse devices. If the device is a mouse + /// device then this will be reported alongside the MouseMotion event. + Motion { + axis: AxisId, + value: f64, + }, + + Button { + button: ButtonId, + state: ElementState, + }, + + Key(RawKeyEvent), +} + +/// Describes a keyboard input as a raw device event. +/// +/// Note that holding down a key may produce repeated `RawKeyEvent`s. The +/// operating system doesn't provide information whether such an event is a +/// repeat or the initial keypress. An application may emulate this by, for +/// example keeping a Map/Set of pressed keys and determining whether a keypress +/// corresponds to an already pressed key. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct RawKeyEvent { + pub physical_key: keyboard::PhysicalKey, + pub state: ElementState, +} + +/// Describes a keyboard input targeting a window. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct KeyEvent { + /// Represents the position of a key independent of the currently active layout. + /// + /// It also uniquely identifies the physical key (i.e. it's mostly synonymous with a scancode). + /// The most prevalent use case for this is games. For example the default keys for the player + /// to move around might be the W, A, S, and D keys on a US layout. The position of these keys + /// is more important than their label, so they should map to Z, Q, S, and D on an "AZERTY" + /// layout. (This value is `KeyCode::KeyW` for the Z key on an AZERTY layout.) + /// + /// ## Caveats + /// + /// - Certain niche hardware will shuffle around physical key positions, e.g. a keyboard that + /// implements DVORAK in hardware (or firmware) + /// - Your application will likely have to handle keyboards which are missing keys that your + /// own keyboard has. + /// - Certain `KeyCode`s will move between a couple of different positions depending on what + /// layout the keyboard was manufactured to support. + /// + /// **Because of these caveats, it is important that you provide users with a way to configure + /// most (if not all) keybinds in your application.** + /// + /// ## `Fn` and `FnLock` + /// + /// `Fn` and `FnLock` key events are *exceedingly unlikely* to be emitted by Winit. These keys + /// are usually handled at the hardware or OS level, and aren't surfaced to applications. If + /// you somehow see this in the wild, we'd like to know :) + pub physical_key: keyboard::PhysicalKey, + + // Allowing `broken_intra_doc_links` for `logical_key`, because + // `key_without_modifiers` is not available on all platforms + #[cfg_attr( + not(any(windows_platform, macos_platform, x11_platform, wayland_platform)), + allow(rustdoc::broken_intra_doc_links) + )] + /// This value is affected by all modifiers except Ctrl. + /// + /// This has two use cases: + /// - Allows querying whether the current input is a Dead key. + /// - Allows handling key-bindings on platforms which don't support [`key_without_modifiers`]. + /// + /// If you use this field (or [`key_without_modifiers`] for that matter) for keyboard + /// shortcuts, **it is important that you provide users with a way to configure your + /// application's shortcuts so you don't render your application unusable for users with an + /// incompatible keyboard layout.** + /// + /// ## Platform-specific + /// - **Web:** Dead keys might be reported as the real key instead of `Dead` depending on the + /// browser/OS. + /// + /// [`key_without_modifiers`]: crate::platform::modifier_supplement::KeyEventExtModifierSupplement::key_without_modifiers + pub logical_key: keyboard::Key, + + /// Contains the text produced by this keypress. + /// + /// In most cases this is identical to the content + /// of the `Character` variant of `logical_key`. + /// However, on Windows when a dead key was pressed earlier + /// but cannot be combined with the character from this + /// keypress, the produced text will consist of two characters: + /// the dead-key-character followed by the character resulting + /// from this keypress. + /// + /// An additional difference from `logical_key` is that + /// this field stores the text representation of any key + /// that has such a representation. For example when + /// `logical_key` is `Key::Named(NamedKey::Enter)`, this field is `Some("\r")`. + /// + /// This is `None` if the current keypress cannot + /// be interpreted as text. + /// + /// See also: `text_with_all_modifiers()` + pub text: Option, + + /// Contains the location of this key on the keyboard. + /// + /// Certain keys on the keyboard may appear in more than once place. For example, the "Shift" + /// key appears on the left side of the QWERTY keyboard as well as the right side. However, + /// both keys have the same symbolic value. Another example of this phenomenon is the "1" + /// key, which appears both above the "Q" key and as the "Keypad 1" key. + /// + /// This field allows the user to differentiate between keys like this that have the same + /// symbolic value but different locations on the keyboard. + /// + /// See the [`KeyLocation`] type for more details. + /// + /// [`KeyLocation`]: crate::keyboard::KeyLocation + pub location: keyboard::KeyLocation, + + /// Whether the key is being pressed or released. + /// + /// See the [`ElementState`] type for more details. + pub state: ElementState, + + /// Whether or not this key is a key repeat event. + /// + /// On some systems, holding down a key for some period of time causes that key to be repeated + /// as though it were being pressed and released repeatedly. This field is `true` if and only + /// if this event is the result of one of those repeats. + /// + /// # Example + /// + /// In games, you often want to ignore repeated key events - this can be + /// done by ignoring events where this property is set. + /// + /// ``` + /// use winit::event::{ElementState, KeyEvent, WindowEvent}; + /// use winit::keyboard::{KeyCode, PhysicalKey}; + /// # let window_event = WindowEvent::RedrawRequested; // To make the example compile + /// match window_event { + /// WindowEvent::KeyboardInput { + /// event: + /// KeyEvent { + /// physical_key: PhysicalKey::Code(KeyCode::KeyW), + /// state: ElementState::Pressed, + /// repeat: false, + /// .. + /// }, + /// .. + /// } => { + /// // The physical key `W` was pressed, and it was not a repeat + /// }, + /// _ => {}, // Handle other events + /// } + /// ``` + pub repeat: bool, + + /// Platform-specific key event information. + /// + /// On Windows, Linux and macOS, this type contains the key without modifiers and the text with + /// all modifiers applied. + /// + /// On Android, iOS, Redox and Web, this type is a no-op. + pub(crate) platform_specific: platform_impl::KeyEventExtra, +} + +/// Describes keyboard modifiers event. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Modifiers { + pub(crate) state: ModifiersState, + + // NOTE: Currently pressed modifiers keys. + // + // The field providing a metadata, it shouldn't be used as a source of truth. + pub(crate) pressed_mods: ModifiersKeys, +} + +impl Modifiers { + /// The state of the modifiers. + pub fn state(&self) -> ModifiersState { + self.state + } + + /// The state of the left shift key. + pub fn lshift_state(&self) -> ModifiersKeyState { + self.mod_state(ModifiersKeys::LSHIFT) + } + + /// The state of the right shift key. + pub fn rshift_state(&self) -> ModifiersKeyState { + self.mod_state(ModifiersKeys::RSHIFT) + } + + /// The state of the left alt key. + pub fn lalt_state(&self) -> ModifiersKeyState { + self.mod_state(ModifiersKeys::LALT) + } + + /// The state of the right alt key. + pub fn ralt_state(&self) -> ModifiersKeyState { + self.mod_state(ModifiersKeys::RALT) + } + + /// The state of the left control key. + pub fn lcontrol_state(&self) -> ModifiersKeyState { + self.mod_state(ModifiersKeys::LCONTROL) + } + + /// The state of the right control key. + pub fn rcontrol_state(&self) -> ModifiersKeyState { + self.mod_state(ModifiersKeys::RCONTROL) + } + + /// The state of the left super key. + pub fn lsuper_state(&self) -> ModifiersKeyState { + self.mod_state(ModifiersKeys::LSUPER) + } + + /// The state of the right super key. + pub fn rsuper_state(&self) -> ModifiersKeyState { + self.mod_state(ModifiersKeys::RSUPER) + } + + fn mod_state(&self, modifier: ModifiersKeys) -> ModifiersKeyState { + if self.pressed_mods.contains(modifier) { + ModifiersKeyState::Pressed + } else { + ModifiersKeyState::Unknown + } + } +} + +impl From for Modifiers { + fn from(value: ModifiersState) -> Self { + Self { state: value, pressed_mods: Default::default() } + } +} + +/// Describes [input method](https://en.wikipedia.org/wiki/Input_method) events. +/// +/// This is also called a "composition event". +/// +/// Most keypresses using a latin-like keyboard layout simply generate a +/// [`WindowEvent::KeyboardInput`]. However, one couldn't possibly have a key for every single +/// unicode character that the user might want to type +/// - so the solution operating systems employ is to allow the user to type these using _a sequence +/// of keypresses_ instead. +/// +/// A prominent example of this is accents - many keyboard layouts allow you to first click the +/// "accent key", and then the character you want to apply the accent to. In this case, some +/// platforms will generate the following event sequence: +/// +/// ```ignore +/// // Press "`" key +/// Ime::Preedit("`", Some((0, 0))) +/// // Press "E" key +/// Ime::Preedit("", None) // Synthetic event generated by winit to clear preedit. +/// Ime::Commit("é") +/// ``` +/// +/// Additionally, certain input devices are configured to display a candidate box that allow the +/// user to select the desired character interactively. (To properly position this box, you must use +/// [`Window::set_ime_cursor_area`].) +/// +/// An example of a keyboard layout which uses candidate boxes is pinyin. On a latin keyboard the +/// following event sequence could be obtained: +/// +/// ```ignore +/// // Press "A" key +/// Ime::Preedit("a", Some((1, 1))) +/// // Press "B" key +/// Ime::Preedit("a b", Some((3, 3))) +/// // Press left arrow key +/// Ime::Preedit("a b", Some((1, 1))) +/// // Press space key +/// Ime::Preedit("啊b", Some((3, 3))) +/// // Press space key +/// Ime::Preedit("", None) // Synthetic event generated by winit to clear preedit. +/// Ime::Commit("啊不") +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Ime { + /// Notifies when the IME was enabled. + /// + /// After getting this event you could receive [`Preedit`][Self::Preedit] and + /// [`Commit`][Self::Commit] events. You should also start performing IME related requests + /// like [`Window::set_ime_cursor_area`]. + Enabled, + + /// Notifies when a new composing text should be set at the cursor position. + /// + /// The value represents a pair of the preedit string and the cursor begin position and end + /// position. When it's `None`, the cursor should be hidden. When `String` is an empty string + /// this indicates that preedit was cleared. + /// + /// The cursor position is byte-wise indexed. + Preedit(String, Option<(usize, usize)>), + + /// Notifies when text should be inserted into the editor widget. + /// + /// Right before this event winit will send empty [`Self::Preedit`] event. + Commit(String), + + /// Notifies when the IME was disabled. + /// + /// After receiving this event you won't get any more [`Preedit`][Self::Preedit] or + /// [`Commit`][Self::Commit] events until the next [`Enabled`][Self::Enabled] event. You should + /// also stop issuing IME related requests like [`Window::set_ime_cursor_area`] and clear + /// pending preedit text. + Disabled, +} + +/// Describes touch-screen input state. +#[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum TouchPhase { + Started, + Moved, + Ended, + Cancelled, +} + +/// Represents a touch event +/// +/// Every time the user touches the screen, a new [`TouchPhase::Started`] event with an unique +/// identifier for the finger is generated. When the finger is lifted, an [`TouchPhase::Ended`] +/// event is generated with the same finger id. +/// +/// After a `Started` event has been emitted, there may be zero or more `Move` +/// events when the finger is moved or the touch pressure changes. +/// +/// The finger id may be reused by the system after an `Ended` event. The user +/// should assume that a new `Started` event received with the same id has nothing +/// to do with the old finger and is a new finger. +/// +/// A [`TouchPhase::Cancelled`] event is emitted when the system has canceled tracking this +/// touch, such as when the window loses focus, or on iOS if the user moves the +/// device against their face. +/// +/// ## Platform-specific +/// +/// - **Web:** Doesn't take into account CSS [`border`], [`padding`], or [`transform`]. +/// - **macOS:** Unsupported. +/// +/// [`border`]: https://developer.mozilla.org/en-US/docs/Web/CSS/border +/// [`padding`]: https://developer.mozilla.org/en-US/docs/Web/CSS/padding +/// [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Touch { + pub device_id: DeviceId, + pub phase: TouchPhase, + pub location: PhysicalPosition, + /// Describes how hard the screen was pressed. May be `None` if the platform + /// does not support pressure sensitivity. + /// + /// ## Platform-specific + /// + /// - Only available on **iOS** 9.0+, **Windows** 8+, **Web**, and **Android**. + /// - **Android**: This will never be [None]. If the device doesn't support pressure + /// sensitivity, force will either be 0.0 or 1.0. Also see the + /// [android documentation](https://developer.android.com/reference/android/view/MotionEvent#AXIS_PRESSURE). + pub force: Option, + /// Unique identifier of a finger. + pub id: u64, +} + +/// Describes the force of a touch event +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Force { + /// On iOS, the force is calibrated so that the same number corresponds to + /// roughly the same amount of pressure on the screen regardless of the + /// device. + Calibrated { + /// The force of the touch, where a value of 1.0 represents the force of + /// an average touch (predetermined by the system, not user-specific). + /// + /// The force reported by Apple Pencil is measured along the axis of the + /// pencil. If you want a force perpendicular to the device, you need to + /// calculate this value using the `altitude_angle` value. + force: f64, + /// The maximum possible force for a touch. + /// + /// The value of this field is sufficiently high to provide a wide + /// dynamic range for values of the `force` field. + max_possible_force: f64, + /// The altitude (in radians) of the stylus. + /// + /// A value of 0 radians indicates that the stylus is parallel to the + /// surface. The value of this property is Pi/2 when the stylus is + /// perpendicular to the surface. + altitude_angle: Option, + }, + /// If the platform reports the force as normalized, we have no way of + /// knowing how much pressure 1.0 corresponds to – we know it's the maximum + /// amount of force, but as to how much force, you might either have to + /// press really really hard, or not hard at all, depending on the device. + Normalized(f64), +} + +impl Force { + /// Returns the force normalized to the range between 0.0 and 1.0 inclusive. + /// + /// Instead of normalizing the force, you should prefer to handle + /// [`Force::Calibrated`] so that the amount of force the user has to apply is + /// consistent across devices. + pub fn normalized(&self) -> f64 { + match self { + Force::Calibrated { force, max_possible_force, altitude_angle } => { + let force = match altitude_angle { + Some(altitude_angle) => force / altitude_angle.sin(), + None => *force, + }; + force / max_possible_force + }, + Force::Normalized(force) => *force, + } + } +} + +/// Identifier for a specific analog axis on some device. +pub type AxisId = u32; + +/// Identifier for a specific button on some device. +pub type ButtonId = u32; + +/// Describes the input state of a key. +#[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum ElementState { + Pressed, + Released, +} + +impl ElementState { + /// True if `self == Pressed`. + pub fn is_pressed(self) -> bool { + self == ElementState::Pressed + } +} + +/// Describes a button of a mouse controller. +/// +/// ## Platform-specific +/// +/// **macOS:** `Back` and `Forward` might not work with all hardware. +/// **Orbital:** `Back` and `Forward` are unsupported due to orbital not supporting them. +#[derive(Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum MouseButton { + Left, + Right, + Middle, + Back, + Forward, + Other(u16), +} + +/// Describes a difference in the mouse scroll wheel state. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum MouseScrollDelta { + /// Amount in lines or rows to scroll in the horizontal + /// and vertical directions. + /// + /// Positive values indicate that the content that is being scrolled should move + /// right and down (revealing more content left and up). + LineDelta(f32, f32), + + /// Amount in pixels to scroll in the horizontal and + /// vertical direction. + /// + /// Scroll events are expressed as a `PixelDelta` if + /// supported by the device (eg. a touchpad) and + /// platform. + /// + /// Positive values indicate that the content being scrolled should + /// move right/down. + /// + /// For a 'natural scrolling' touch pad (that acts like a touch screen) + /// this means moving your fingers right and down should give positive values, + /// and move the content right and down (to reveal more things left and up). + PixelDelta(PhysicalPosition), +} + +/// Handle to synchronously change the size of the window from the +/// [`WindowEvent`]. +#[derive(Debug, Clone)] +pub struct InnerSizeWriter { + pub(crate) new_inner_size: Weak>>, +} + +impl InnerSizeWriter { + #[cfg(not(orbital_platform))] + pub(crate) fn new(new_inner_size: Weak>>) -> Self { + Self { new_inner_size } + } + + /// Try to request inner size which will be set synchronously on the window. + pub fn request_inner_size( + &mut self, + new_inner_size: PhysicalSize, + ) -> Result<(), ExternalError> { + if let Some(inner) = self.new_inner_size.upgrade() { + *inner.lock().unwrap() = new_inner_size; + Ok(()) + } else { + Err(ExternalError::Ignored) + } + } +} + +impl PartialEq for InnerSizeWriter { + fn eq(&self, other: &Self) -> bool { + self.new_inner_size.as_ptr() == other.new_inner_size.as_ptr() + } +} + +#[cfg(test)] +mod tests { + use crate::dpi::PhysicalPosition; + use crate::event; + use std::collections::{BTreeSet, HashSet}; + + macro_rules! foreach_event { + ($closure:expr) => {{ + #[allow(unused_mut)] + let mut x = $closure; + let did = event::DeviceId::dummy(); + + #[allow(deprecated)] + { + use crate::event::Event::*; + use crate::event::Ime::Enabled; + use crate::event::WindowEvent::*; + use crate::window::WindowId; + + // Mainline events. + let wid = WindowId::dummy(); + x(UserEvent(())); + x(NewEvents(event::StartCause::Init)); + x(AboutToWait); + x(LoopExiting); + x(Suspended); + x(Resumed); + + // Window events. + let with_window_event = |wev| x(WindowEvent { window_id: wid, event: wev }); + + with_window_event(CloseRequested); + with_window_event(Destroyed); + with_window_event(Focused(true)); + with_window_event(Moved((0, 0).into())); + with_window_event(Resized((0, 0).into())); + with_window_event(DroppedFile("x.txt".into())); + with_window_event(HoveredFile("x.txt".into())); + with_window_event(HoveredFileCancelled); + with_window_event(Ime(Enabled)); + with_window_event(CursorMoved { device_id: did, position: (0, 0).into() }); + with_window_event(ModifiersChanged(event::Modifiers::default())); + with_window_event(CursorEntered { device_id: did }); + with_window_event(CursorLeft { device_id: did }); + with_window_event(MouseWheel { + device_id: did, + delta: event::MouseScrollDelta::LineDelta(0.0, 0.0), + phase: event::TouchPhase::Started, + }); + with_window_event(MouseInput { + device_id: did, + state: event::ElementState::Pressed, + button: event::MouseButton::Other(0), + }); + with_window_event(PinchGesture { + device_id: did, + delta: 0.0, + phase: event::TouchPhase::Started, + }); + with_window_event(DoubleTapGesture { device_id: did }); + with_window_event(RotationGesture { + device_id: did, + delta: 0.0, + phase: event::TouchPhase::Started, + }); + with_window_event(PanGesture { + device_id: did, + delta: PhysicalPosition::::new(0.0, 0.0), + phase: event::TouchPhase::Started, + }); + with_window_event(TouchpadPressure { device_id: did, pressure: 0.0, stage: 0 }); + with_window_event(AxisMotion { device_id: did, axis: 0, value: 0.0 }); + with_window_event(Touch(event::Touch { + device_id: did, + phase: event::TouchPhase::Started, + location: (0.0, 0.0).into(), + id: 0, + force: Some(event::Force::Normalized(0.0)), + })); + with_window_event(ThemeChanged(crate::window::Theme::Light)); + with_window_event(Occluded(true)); + } + + #[allow(deprecated)] + { + use event::DeviceEvent::*; + + let with_device_event = + |dev_ev| x(event::Event::DeviceEvent { device_id: did, event: dev_ev }); + + with_device_event(Added); + with_device_event(Removed); + with_device_event(MouseMotion { delta: (0.0, 0.0).into() }); + with_device_event(MouseWheel { + delta: event::MouseScrollDelta::LineDelta(0.0, 0.0), + }); + with_device_event(Motion { axis: 0, value: 0.0 }); + with_device_event(Button { button: 0, state: event::ElementState::Pressed }); + } + }}; + } + + #[allow(clippy::redundant_clone)] + #[test] + fn test_event_clone() { + foreach_event!(|event: event::Event<()>| { + let event2 = event.clone(); + assert_eq!(event, event2); + }) + } + + #[test] + fn test_map_nonuser_event() { + foreach_event!(|event: event::Event<()>| { + let is_user = matches!(event, event::Event::UserEvent(())); + let event2 = event.map_nonuser_event::<()>(); + if is_user { + assert_eq!(event2, Err(event::Event::UserEvent(()))); + } else { + assert!(event2.is_ok()); + } + }) + } + + #[test] + fn test_force_normalize() { + let force = event::Force::Normalized(0.0); + assert_eq!(force.normalized(), 0.0); + + let force2 = + event::Force::Calibrated { force: 5.0, max_possible_force: 2.5, altitude_angle: None }; + assert_eq!(force2.normalized(), 2.0); + + let force3 = event::Force::Calibrated { + force: 5.0, + max_possible_force: 2.5, + altitude_angle: Some(std::f64::consts::PI / 2.0), + }; + assert_eq!(force3.normalized(), 2.0); + } + + #[allow(clippy::clone_on_copy)] + #[test] + fn ensure_attrs_do_not_panic() { + foreach_event!(|event: event::Event<()>| { + let _ = format!("{event:?}"); + }); + let _ = event::StartCause::Init.clone(); + + let did = crate::event::DeviceId::dummy().clone(); + HashSet::new().insert(did); + let mut set = [did, did, did]; + set.sort_unstable(); + let mut set2 = BTreeSet::new(); + set2.insert(did); + set2.insert(did); + + HashSet::new().insert(event::TouchPhase::Started.clone()); + HashSet::new().insert(event::MouseButton::Left.clone()); + HashSet::new().insert(event::Ime::Enabled); + + let _ = event::Touch { + device_id: did, + phase: event::TouchPhase::Started, + location: (0.0, 0.0).into(), + id: 0, + force: Some(event::Force::Normalized(0.0)), + } + .clone(); + let _ = + event::Force::Calibrated { force: 0.0, max_possible_force: 0.0, altitude_angle: None } + .clone(); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/event_loop.rs b/third-party/winit-0.30.13-ferrex/src/event_loop.rs new file mode 100644 index 00000000..233374be --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/event_loop.rs @@ -0,0 +1,651 @@ +//! The [`EventLoop`] struct and assorted supporting types, including +//! [`ControlFlow`]. +//! +//! If you want to send custom events to the event loop, use +//! [`EventLoop::create_proxy`] to acquire an [`EventLoopProxy`] and call its +//! [`send_event`][EventLoopProxy::send_event] method. +//! +//! See the root-level documentation for information on how to create and use an event loop to +//! handle events. +use std::marker::PhantomData; +#[cfg(any(x11_platform, wayland_platform))] +use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, RawFd}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::{error, fmt}; + +#[cfg(not(web_platform))] +use std::time::{Duration, Instant}; +#[cfg(web_platform)] +use web_time::{Duration, Instant}; + +use crate::application::ApplicationHandler; +use crate::error::{EventLoopError, OsError}; +use crate::event::Event; +use crate::monitor::MonitorHandle; +use crate::platform_impl; +use crate::window::{CustomCursor, CustomCursorSource, Theme, Window, WindowAttributes}; + +/// Provides a way to retrieve events from the system and from the windows that were registered to +/// the events loop. +/// +/// An `EventLoop` can be seen more or less as a "context". Calling [`EventLoop::new`] +/// initializes everything that will be required to create windows. For example on Linux creating +/// an event loop opens a connection to the X or Wayland server. +/// +/// To wake up an `EventLoop` from a another thread, see the [`EventLoopProxy`] docs. +/// +/// Note that this cannot be shared across threads (due to platform-dependant logic +/// forbidding it), as such it is neither [`Send`] nor [`Sync`]. If you need cross-thread access, +/// the [`Window`] created from this _can_ be sent to an other thread, and the +/// [`EventLoopProxy`] allows you to wake up an `EventLoop` from another thread. +/// +/// [`Window`]: crate::window::Window +pub struct EventLoop { + pub(crate) event_loop: platform_impl::EventLoop, + pub(crate) _marker: PhantomData<*mut ()>, // Not Send nor Sync +} + +/// Target that associates windows with an [`EventLoop`]. +/// +/// This type exists to allow you to create new windows while Winit executes +/// your callback. +pub struct ActiveEventLoop { + pub(crate) p: platform_impl::ActiveEventLoop, + pub(crate) _marker: PhantomData<*mut ()>, // Not Send nor Sync +} + +/// Object that allows building the event loop. +/// +/// This is used to make specifying options that affect the whole application +/// easier. But note that constructing multiple event loops is not supported. +/// +/// This can be created using [`EventLoop::new`] or [`EventLoop::with_user_event`]. +#[derive(Default)] +pub struct EventLoopBuilder { + pub(crate) platform_specific: platform_impl::PlatformSpecificEventLoopAttributes, + _p: PhantomData, +} + +static EVENT_LOOP_CREATED: AtomicBool = AtomicBool::new(false); + +impl EventLoopBuilder<()> { + /// Start building a new event loop. + #[inline] + #[deprecated = "use `EventLoop::builder` instead"] + pub fn new() -> Self { + EventLoop::builder() + } +} + +impl EventLoopBuilder { + /// Builds a new event loop. + /// + /// ***For cross-platform compatibility, the [`EventLoop`] must be created on the main thread, + /// and only once per application.*** + /// + /// Calling this function will result in display backend initialisation. + /// + /// ## Panics + /// + /// Attempting to create the event loop off the main thread will panic. This + /// restriction isn't strictly necessary on all platforms, but is imposed to + /// eliminate any nasty surprises when porting to platforms that require it. + /// `EventLoopBuilderExt::any_thread` functions are exposed in the relevant + /// [`platform`] module if the target platform supports creating an event + /// loop on any thread. + /// + /// ## Platform-specific + /// + /// - **Wayland/X11:** to prevent running under `Wayland` or `X11` unset `WAYLAND_DISPLAY` or + /// `DISPLAY` respectively when building the event loop. + /// - **Android:** must be configured with an `AndroidApp` from `android_main()` by calling + /// [`.with_android_app(app)`] before calling `.build()`, otherwise it'll panic. + /// + /// [`platform`]: crate::platform + #[cfg_attr( + android_platform, + doc = "[`.with_android_app(app)`]: \ + crate::platform::android::EventLoopBuilderExtAndroid::with_android_app" + )] + #[cfg_attr( + not(android_platform), + doc = "[`.with_android_app(app)`]: #only-available-on-android" + )] + #[inline] + pub fn build(&mut self) -> Result, EventLoopError> { + let _span = tracing::debug_span!("winit::EventLoopBuilder::build").entered(); + + if EVENT_LOOP_CREATED.swap(true, Ordering::Relaxed) { + return Err(EventLoopError::RecreationAttempt); + } + + // Certain platforms accept a mutable reference in their API. + #[allow(clippy::unnecessary_mut_passed)] + Ok(EventLoop { + event_loop: platform_impl::EventLoop::new(&mut self.platform_specific)?, + _marker: PhantomData, + }) + } + + #[cfg(web_platform)] + pub(crate) fn allow_event_loop_recreation() { + EVENT_LOOP_CREATED.store(false, Ordering::Relaxed); + } +} + +impl fmt::Debug for EventLoop { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad("EventLoop { .. }") + } +} + +impl fmt::Debug for ActiveEventLoop { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad("ActiveEventLoop { .. }") + } +} + +/// Set through [`ActiveEventLoop::set_control_flow()`]. +/// +/// Indicates the desired behavior of the event loop after [`Event::AboutToWait`] is emitted. +/// +/// Defaults to [`Wait`]. +/// +/// [`Wait`]: Self::Wait +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum ControlFlow { + /// When the current loop iteration finishes, immediately begin a new iteration regardless of + /// whether or not new events are available to process. + Poll, + + /// When the current loop iteration finishes, suspend the thread until another event arrives. + #[default] + Wait, + + /// When the current loop iteration finishes, suspend the thread until either another event + /// arrives or the given time is reached. + /// + /// Useful for implementing efficient timers. Applications which want to render at the + /// display's native refresh rate should instead use [`Poll`] and the VSync functionality + /// of a graphics API to reduce odds of missed frames. + /// + /// [`Poll`]: Self::Poll + WaitUntil(Instant), +} + +impl ControlFlow { + /// Creates a [`ControlFlow`] that waits until a timeout has expired. + /// + /// In most cases, this is set to [`WaitUntil`]. However, if the timeout overflows, it is + /// instead set to [`Wait`]. + /// + /// [`WaitUntil`]: Self::WaitUntil + /// [`Wait`]: Self::Wait + pub fn wait_duration(timeout: Duration) -> Self { + match Instant::now().checked_add(timeout) { + Some(instant) => Self::WaitUntil(instant), + None => Self::Wait, + } + } +} + +impl EventLoop<()> { + /// Create the event loop. + /// + /// This is an alias of `EventLoop::builder().build()`. + #[inline] + pub fn new() -> Result, EventLoopError> { + Self::builder().build() + } + + /// Start building a new event loop. + /// + /// This returns an [`EventLoopBuilder`], to allow configuring the event loop before creation. + /// + /// To get the actual event loop, call [`build`][EventLoopBuilder::build] on that. + #[inline] + pub fn builder() -> EventLoopBuilder<()> { + Self::with_user_event() + } +} + +impl EventLoop { + /// Start building a new event loop, with the given type as the user event + /// type. + pub fn with_user_event() -> EventLoopBuilder { + EventLoopBuilder { platform_specific: Default::default(), _p: PhantomData } + } + + /// See [`run_app`]. + /// + /// [`run_app`]: Self::run_app + #[inline] + #[deprecated = "use `EventLoop::run_app` instead"] + #[cfg(not(all(web_platform, target_feature = "exception-handling")))] + pub fn run(self, event_handler: F) -> Result<(), EventLoopError> + where + F: FnMut(Event, &ActiveEventLoop), + { + let _span = tracing::debug_span!("winit::EventLoop::run").entered(); + + self.event_loop.run(event_handler) + } + + /// Run the application with the event loop on the calling thread. + /// + /// See the [`set_control_flow()`] docs on how to change the event loop's behavior. + /// + /// ## Platform-specific + /// + /// - **iOS:** Will never return to the caller and so values not passed to this function will + /// *not* be dropped before the process exits. + /// - **Web:** Will _act_ as if it never returns to the caller by throwing a Javascript + /// exception (that Rust doesn't see) that will also mean that the rest of the function is + /// never executed and any values not passed to this function will *not* be dropped. + /// + /// Web applications are recommended to use + #[cfg_attr( + web_platform, + doc = "[`EventLoopExtWebSys::spawn_app()`][crate::platform::web::EventLoopExtWebSys::spawn_app()]" + )] + #[cfg_attr(not(web_platform), doc = "`EventLoopExtWebSys::spawn()`")] + /// [^1] instead of [`run_app()`] to avoid the need + /// for the Javascript exception trick, and to make it clearer that the event loop runs + /// asynchronously (via the browser's own, internal, event loop) and doesn't block the + /// current thread of execution like it does on other platforms. + /// + /// This function won't be available with `target_feature = "exception-handling"`. + /// + /// [`set_control_flow()`]: ActiveEventLoop::set_control_flow() + /// [`run_app()`]: Self::run_app() + /// [^1]: `EventLoopExtWebSys::spawn_app()` is only available on Web. + #[inline] + #[cfg(not(all(web_platform, target_feature = "exception-handling")))] + pub fn run_app>(self, app: &mut A) -> Result<(), EventLoopError> { + self.event_loop.run(|event, event_loop| dispatch_event_for_app(app, event_loop, event)) + } + + /// Creates an [`EventLoopProxy`] that can be used to dispatch user events + /// to the main event loop, possibly from another thread. + pub fn create_proxy(&self) -> EventLoopProxy { + EventLoopProxy { event_loop_proxy: self.event_loop.create_proxy() } + } + + /// Gets a persistent reference to the underlying platform display. + /// + /// See the [`OwnedDisplayHandle`] type for more information. + pub fn owned_display_handle(&self) -> OwnedDisplayHandle { + OwnedDisplayHandle { platform: self.event_loop.window_target().p.owned_display_handle() } + } + + /// Change if or when [`DeviceEvent`]s are captured. + /// + /// See [`ActiveEventLoop::listen_device_events`] for details. + /// + /// [`DeviceEvent`]: crate::event::DeviceEvent + pub fn listen_device_events(&self, allowed: DeviceEvents) { + let _span = tracing::debug_span!( + "winit::EventLoop::listen_device_events", + allowed = ?allowed + ) + .entered(); + + self.event_loop.window_target().p.listen_device_events(allowed); + } + + /// Sets the [`ControlFlow`]. + pub fn set_control_flow(&self, control_flow: ControlFlow) { + self.event_loop.window_target().p.set_control_flow(control_flow) + } + + /// Create a window. + /// + /// Creating window without event loop running often leads to improper window creation; + /// use [`ActiveEventLoop::create_window`] instead. + #[deprecated = "use `ActiveEventLoop::create_window` instead"] + #[inline] + pub fn create_window(&self, window_attributes: WindowAttributes) -> Result { + let _span = tracing::debug_span!( + "winit::EventLoop::create_window", + window_attributes = ?window_attributes + ) + .entered(); + + let window = + platform_impl::Window::new(&self.event_loop.window_target().p, window_attributes)?; + Ok(Window { window }) + } + + /// Create custom cursor. + pub fn create_custom_cursor(&self, custom_cursor: CustomCursorSource) -> CustomCursor { + self.event_loop.window_target().p.create_custom_cursor(custom_cursor) + } +} + +#[cfg(feature = "rwh_06")] +impl rwh_06::HasDisplayHandle for EventLoop { + fn display_handle(&self) -> Result, rwh_06::HandleError> { + rwh_06::HasDisplayHandle::display_handle(self.event_loop.window_target()) + } +} + +#[cfg(feature = "rwh_05")] +unsafe impl rwh_05::HasRawDisplayHandle for EventLoop { + /// Returns a [`rwh_05::RawDisplayHandle`] for the event loop. + fn raw_display_handle(&self) -> rwh_05::RawDisplayHandle { + rwh_05::HasRawDisplayHandle::raw_display_handle(self.event_loop.window_target()) + } +} + +#[cfg(any(x11_platform, wayland_platform))] +impl AsFd for EventLoop { + /// Get the underlying [EventLoop]'s `fd` which you can register + /// into other event loop, like [`calloop`] or [`mio`]. When doing so, the + /// loop must be polled with the [`pump_app_events`] API. + /// + /// [`calloop`]: https://crates.io/crates/calloop + /// [`mio`]: https://crates.io/crates/mio + /// [`pump_app_events`]: crate::platform::pump_events::EventLoopExtPumpEvents::pump_app_events + fn as_fd(&self) -> BorrowedFd<'_> { + self.event_loop.as_fd() + } +} + +#[cfg(any(x11_platform, wayland_platform))] +impl AsRawFd for EventLoop { + /// Get the underlying [EventLoop]'s raw `fd` which you can register + /// into other event loop, like [`calloop`] or [`mio`]. When doing so, the + /// loop must be polled with the [`pump_app_events`] API. + /// + /// [`calloop`]: https://crates.io/crates/calloop + /// [`mio`]: https://crates.io/crates/mio + /// [`pump_app_events`]: crate::platform::pump_events::EventLoopExtPumpEvents::pump_app_events + fn as_raw_fd(&self) -> RawFd { + self.event_loop.as_raw_fd() + } +} + +impl ActiveEventLoop { + /// Create the window. + /// + /// Possible causes of error include denied permission, incompatible system, and lack of memory. + /// + /// ## Platform-specific + /// + /// - **Web:** The window is created but not inserted into the web page automatically. Please + /// see the web platform module for more information. + #[inline] + pub fn create_window(&self, window_attributes: WindowAttributes) -> Result { + let _span = tracing::debug_span!( + "winit::ActiveEventLoop::create_window", + window_attributes = ?window_attributes + ) + .entered(); + + let window = platform_impl::Window::new(&self.p, window_attributes)?; + Ok(Window { window }) + } + + /// Create custom cursor. + pub fn create_custom_cursor(&self, custom_cursor: CustomCursorSource) -> CustomCursor { + let _span = tracing::debug_span!("winit::ActiveEventLoop::create_custom_cursor",).entered(); + + self.p.create_custom_cursor(custom_cursor) + } + + /// Returns the list of all the monitors available on the system. + #[inline] + pub fn available_monitors(&self) -> impl Iterator { + let _span = tracing::debug_span!("winit::ActiveEventLoop::available_monitors",).entered(); + + #[allow(clippy::useless_conversion)] // false positive on some platforms + self.p.available_monitors().into_iter().map(|inner| MonitorHandle { inner }) + } + + /// Returns the primary monitor of the system. + /// + /// Returns `None` if it can't identify any monitor as a primary one. + /// + /// ## Platform-specific + /// + /// **Wayland / Web:** Always returns `None`. + #[inline] + pub fn primary_monitor(&self) -> Option { + let _span = tracing::debug_span!("winit::ActiveEventLoop::primary_monitor",).entered(); + + self.p.primary_monitor().map(|inner| MonitorHandle { inner }) + } + + /// Change if or when [`DeviceEvent`]s are captured. + /// + /// Since the [`DeviceEvent`] capture can lead to high CPU usage for unfocused windows, winit + /// will ignore them by default for unfocused windows on Linux/BSD. This method allows changing + /// this at runtime to explicitly capture them again. + /// + /// ## Platform-specific + /// + /// - **Wayland / macOS / iOS / Android / Orbital:** Unsupported. + /// + /// [`DeviceEvent`]: crate::event::DeviceEvent + pub fn listen_device_events(&self, allowed: DeviceEvents) { + let _span = tracing::debug_span!( + "winit::ActiveEventLoop::listen_device_events", + allowed = ?allowed + ) + .entered(); + + self.p.listen_device_events(allowed); + } + + /// Returns the current system theme. + /// + /// Returns `None` if it cannot be determined on the current platform. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Wayland / x11 / Orbital:** Unsupported. + pub fn system_theme(&self) -> Option { + self.p.system_theme() + } + + /// Sets the [`ControlFlow`]. + pub fn set_control_flow(&self, control_flow: ControlFlow) { + self.p.set_control_flow(control_flow) + } + + /// Gets the current [`ControlFlow`]. + pub fn control_flow(&self) -> ControlFlow { + self.p.control_flow() + } + + /// This exits the event loop. + /// + /// See [`LoopExiting`][Event::LoopExiting]. + pub fn exit(&self) { + let _span = tracing::debug_span!("winit::ActiveEventLoop::exit",).entered(); + + self.p.exit() + } + + /// Returns if the [`EventLoop`] is about to stop. + /// + /// See [`exit()`][Self::exit]. + pub fn exiting(&self) -> bool { + self.p.exiting() + } + + /// Gets a persistent reference to the underlying platform display. + /// + /// See the [`OwnedDisplayHandle`] type for more information. + pub fn owned_display_handle(&self) -> OwnedDisplayHandle { + OwnedDisplayHandle { platform: self.p.owned_display_handle() } + } +} + +#[cfg(feature = "rwh_06")] +impl rwh_06::HasDisplayHandle for ActiveEventLoop { + fn display_handle(&self) -> Result, rwh_06::HandleError> { + let raw = self.p.raw_display_handle_rwh_06()?; + // SAFETY: The display will never be deallocated while the event loop is alive. + Ok(unsafe { rwh_06::DisplayHandle::borrow_raw(raw) }) + } +} + +#[cfg(feature = "rwh_05")] +unsafe impl rwh_05::HasRawDisplayHandle for ActiveEventLoop { + /// Returns a [`rwh_05::RawDisplayHandle`] for the event loop. + fn raw_display_handle(&self) -> rwh_05::RawDisplayHandle { + self.p.raw_display_handle_rwh_05() + } +} + +/// A proxy for the underlying display handle. +/// +/// The purpose of this type is to provide a cheaply cloneable handle to the underlying +/// display handle. This is often used by graphics APIs to connect to the underlying APIs. +/// It is difficult to keep a handle to the [`EventLoop`] type or the [`ActiveEventLoop`] +/// type. In contrast, this type involves no lifetimes and can be persisted for as long as +/// needed. +/// +/// For all platforms, this is one of the following: +/// +/// - A zero-sized type that is likely optimized out. +/// - A reference-counted pointer to the underlying type. +#[derive(Clone)] +pub struct OwnedDisplayHandle { + #[cfg_attr(not(any(feature = "rwh_05", feature = "rwh_06")), allow(dead_code))] + platform: platform_impl::OwnedDisplayHandle, +} + +impl fmt::Debug for OwnedDisplayHandle { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OwnedDisplayHandle").finish_non_exhaustive() + } +} + +#[cfg(feature = "rwh_06")] +impl rwh_06::HasDisplayHandle for OwnedDisplayHandle { + #[inline] + fn display_handle(&self) -> Result, rwh_06::HandleError> { + let raw = self.platform.raw_display_handle_rwh_06()?; + + // SAFETY: The underlying display handle should be safe. + let handle = unsafe { rwh_06::DisplayHandle::borrow_raw(raw) }; + + Ok(handle) + } +} + +#[cfg(feature = "rwh_05")] +unsafe impl rwh_05::HasRawDisplayHandle for OwnedDisplayHandle { + #[inline] + fn raw_display_handle(&self) -> rwh_05::RawDisplayHandle { + self.platform.raw_display_handle_rwh_05() + } +} + +/// Used to send custom events to [`EventLoop`]. +pub struct EventLoopProxy { + event_loop_proxy: platform_impl::EventLoopProxy, +} + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + Self { event_loop_proxy: self.event_loop_proxy.clone() } + } +} + +impl EventLoopProxy { + /// Send an event to the [`EventLoop`] from which this proxy was created. This emits a + /// `UserEvent(event)` event in the event loop, where `event` is the value passed to this + /// function. + /// + /// Returns an `Err` if the associated [`EventLoop`] no longer exists. + /// + /// [`UserEvent(event)`]: Event::UserEvent + pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed> { + let _span = tracing::debug_span!("winit::EventLoopProxy::send_event",).entered(); + + self.event_loop_proxy.send_event(event) + } +} + +impl fmt::Debug for EventLoopProxy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad("EventLoopProxy { .. }") + } +} + +/// The error that is returned when an [`EventLoopProxy`] attempts to wake up an [`EventLoop`] that +/// no longer exists. +/// +/// Contains the original event given to [`EventLoopProxy::send_event`]. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct EventLoopClosed(pub T); + +impl fmt::Display for EventLoopClosed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Tried to wake up a closed `EventLoop`") + } +} + +impl error::Error for EventLoopClosed {} + +/// Control when device events are captured. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)] +pub enum DeviceEvents { + /// Report device events regardless of window focus. + Always, + /// Only capture device events while the window is focused. + #[default] + WhenFocused, + /// Never capture device events. + Never, +} + +/// A unique identifier of the winit's async request. +/// +/// This could be used to identify the async request once it's done +/// and a specific action must be taken. +/// +/// One of the handling scenarios could be to maintain a working list +/// containing [`AsyncRequestSerial`] and some closure associated with it. +/// Then once event is arriving the working list is being traversed and a job +/// executed and removed from the list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AsyncRequestSerial { + serial: usize, +} + +impl AsyncRequestSerial { + // TODO(kchibisov): Remove `cfg` when the clipboard will be added. + #[allow(dead_code)] + pub(crate) fn get() -> Self { + static CURRENT_SERIAL: AtomicUsize = AtomicUsize::new(0); + // NOTE: We rely on wrap around here, while the user may just request + // in the loop usize::MAX times that's issue is considered on them. + let serial = CURRENT_SERIAL.fetch_add(1, Ordering::Relaxed); + Self { serial } + } +} + +/// Shim for various run APIs. +#[inline(always)] +pub(crate) fn dispatch_event_for_app>( + app: &mut A, + event_loop: &ActiveEventLoop, + event: Event, +) { + match event { + Event::NewEvents(cause) => app.new_events(event_loop, cause), + Event::WindowEvent { window_id, event } => app.window_event(event_loop, window_id, event), + Event::DeviceEvent { device_id, event } => app.device_event(event_loop, device_id, event), + Event::UserEvent(event) => app.user_event(event_loop, event), + Event::Suspended => app.suspended(event_loop), + Event::Resumed => app.resumed(event_loop), + Event::AboutToWait => app.about_to_wait(event_loop), + Event::LoopExiting => app.exiting(event_loop), + Event::MemoryWarning => app.memory_warning(event_loop), + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/icon.rs b/third-party/winit-0.30.13-ferrex/src/icon.rs new file mode 100644 index 00000000..b013d2f0 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/icon.rs @@ -0,0 +1,117 @@ +use crate::platform_impl::PlatformIcon; +use std::error::Error; +use std::{fmt, io, mem}; + +#[repr(C)] +#[derive(Debug)] +pub(crate) struct Pixel { + pub(crate) r: u8, + pub(crate) g: u8, + pub(crate) b: u8, + pub(crate) a: u8, +} + +pub(crate) const PIXEL_SIZE: usize = mem::size_of::(); + +#[derive(Debug)] +/// An error produced when using [`Icon::from_rgba`] with invalid arguments. +pub enum BadIcon { + /// Produced when the length of the `rgba` argument isn't divisible by 4, thus `rgba` can't be + /// safely interpreted as 32bpp RGBA pixels. + ByteCountNotDivisibleBy4 { byte_count: usize }, + /// Produced when the number of pixels (`rgba.len() / 4`) isn't equal to `width * height`. + /// At least one of your arguments is incorrect. + DimensionsVsPixelCount { width: u32, height: u32, width_x_height: usize, pixel_count: usize }, + /// Produced when underlying OS functionality failed to create the icon + OsError(io::Error), +} + +impl fmt::Display for BadIcon { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BadIcon::ByteCountNotDivisibleBy4 { byte_count } => write!( + f, + "The length of the `rgba` argument ({byte_count:?}) isn't divisible by 4, making \ + it impossible to interpret as 32bpp RGBA pixels.", + ), + BadIcon::DimensionsVsPixelCount { width, height, width_x_height, pixel_count } => { + write!( + f, + "The specified dimensions ({width:?}x{height:?}) don't match the number of \ + pixels supplied by the `rgba` argument ({pixel_count:?}). For those \ + dimensions, the expected pixel count is {width_x_height:?}.", + ) + }, + BadIcon::OsError(e) => write!(f, "OS error when instantiating the icon: {e:?}"), + } + } +} + +impl Error for BadIcon {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RgbaIcon { + pub(crate) rgba: Vec, + pub(crate) width: u32, + pub(crate) height: u32, +} + +/// For platforms which don't have window icons (e.g. web) +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NoIcon; + +#[allow(dead_code)] // These are not used on every platform +mod constructors { + use super::*; + + impl RgbaIcon { + pub fn from_rgba(rgba: Vec, width: u32, height: u32) -> Result { + if rgba.len() % PIXEL_SIZE != 0 { + return Err(BadIcon::ByteCountNotDivisibleBy4 { byte_count: rgba.len() }); + } + let pixel_count = rgba.len() / PIXEL_SIZE; + if pixel_count != (width * height) as usize { + Err(BadIcon::DimensionsVsPixelCount { + width, + height, + width_x_height: (width * height) as usize, + pixel_count, + }) + } else { + Ok(RgbaIcon { rgba, width, height }) + } + } + } + + impl NoIcon { + pub fn from_rgba(rgba: Vec, width: u32, height: u32) -> Result { + // Create the rgba icon anyway to validate the input + let _ = RgbaIcon::from_rgba(rgba, width, height)?; + Ok(NoIcon) + } + } +} + +/// An icon used for the window titlebar, taskbar, etc. +#[derive(Clone)] +pub struct Icon { + pub(crate) inner: PlatformIcon, +} + +impl fmt::Debug for Icon { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + fmt::Debug::fmt(&self.inner, formatter) + } +} + +impl Icon { + /// Creates an icon from 32bpp RGBA data. + /// + /// The length of `rgba` must be divisible by 4, and `width * height` must equal + /// `rgba.len() / 4`. Otherwise, this will return a `BadIcon` error. + pub fn from_rgba(rgba: Vec, width: u32, height: u32) -> Result { + let _span = tracing::debug_span!("winit::Icon::from_rgba", width, height).entered(); + + Ok(Icon { inner: PlatformIcon::from_rgba(rgba, width, height)? }) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/keyboard.rs b/third-party/winit-0.30.13-ferrex/src/keyboard.rs new file mode 100644 index 00000000..7b406f08 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/keyboard.rs @@ -0,0 +1,1804 @@ +//! Types related to the keyboard. + +// This file contains a substantial portion of the UI Events Specification by the W3C. In +// particular, the variant names within `Key` and `KeyCode` and their documentation are modified +// versions of contents of the aforementioned specification. +// +// The original documents are: +// +// ### For `Key` +// UI Events KeyboardEvent key Values +// https://www.w3.org/TR/2017/CR-uievents-key-20170601/ +// Copyright © 2017 W3C® (MIT, ERCIM, Keio, Beihang). +// +// ### For `KeyCode` +// UI Events KeyboardEvent code Values +// https://www.w3.org/TR/2017/CR-uievents-code-20170601/ +// Copyright © 2017 W3C® (MIT, ERCIM, Keio, Beihang). +// +// These documents were used under the terms of the following license. This W3C license as well as +// the W3C short notice apply to the `Key` and `KeyCode` enums and their variants and the +// documentation attached to their variants. + +// --------- BEGINNING OF W3C LICENSE -------------------------------------------------------------- +// +// License +// +// By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, +// and will comply with the following terms and conditions. +// +// Permission to copy, modify, and distribute this work, with or without modification, for any +// purpose and without fee or royalty is hereby granted, provided that you include the following on +// ALL copies of the work or portions thereof, including modifications: +// +// - The full text of this NOTICE in a location viewable to users of the redistributed or derivative +// work. +// - Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none +// exist, the W3C Software and Document Short Notice should be included. +// - Notice of any changes or modifications, through a copyright statement on the new code or +// document such as "This software or document includes material copied from or derived from +// [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)." +// +// Disclaimers +// +// THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR +// ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD +// PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. +// +// COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES +// ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT. +// +// The name and trademarks of copyright holders may NOT be used in advertising or publicity +// pertaining to the work without specific, written prior permission. Title to copyright in this +// work will at all times remain with copyright holders. +// +// --------- END OF W3C LICENSE -------------------------------------------------------------------- + +// --------- BEGINNING OF W3C SHORT NOTICE --------------------------------------------------------- +// +// winit: https://github.com/rust-windowing/winit +// +// Copyright © 2021 World Wide Web Consortium, (Massachusetts Institute of Technology, European +// Research Consortium for Informatics and Mathematics, Keio University, Beihang). All Rights +// Reserved. This work is distributed under the W3C® Software License [1] in the hope that it will +// be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// [1] http://www.w3.org/Consortium/Legal/copyright-software +// +// --------- END OF W3C SHORT NOTICE --------------------------------------------------------------- + +use bitflags::bitflags; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +pub use smol_str::SmolStr; + +/// Contains the platform-native physical key identifier +/// +/// The exact values vary from platform to platform (which is part of why this is a per-platform +/// enum), but the values are primarily tied to the key's physical location on the keyboard. +/// +/// This enum is primarily used to store raw keycodes when Winit doesn't map a given native +/// physical key identifier to a meaningful [`KeyCode`] variant. In the presence of identifiers we +/// haven't mapped for you yet, this lets you use use [`KeyCode`] to: +/// +/// - Correctly match key press and release events. +/// - On non-web platforms, support assigning keybinds to virtually any key through a UI. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum NativeKeyCode { + Unidentified, + /// An Android "scancode". + Android(u32), + /// A macOS "scancode". + MacOS(u16), + /// A Windows "scancode". + Windows(u16), + /// An XKB "keycode". + Xkb(u32), +} + +impl std::fmt::Debug for NativeKeyCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use NativeKeyCode::{Android, MacOS, Unidentified, Windows, Xkb}; + let mut debug_tuple; + match self { + Unidentified => { + debug_tuple = f.debug_tuple("Unidentified"); + }, + Android(code) => { + debug_tuple = f.debug_tuple("Android"); + debug_tuple.field(&format_args!("0x{code:04X}")); + }, + MacOS(code) => { + debug_tuple = f.debug_tuple("MacOS"); + debug_tuple.field(&format_args!("0x{code:04X}")); + }, + Windows(code) => { + debug_tuple = f.debug_tuple("Windows"); + debug_tuple.field(&format_args!("0x{code:04X}")); + }, + Xkb(code) => { + debug_tuple = f.debug_tuple("Xkb"); + debug_tuple.field(&format_args!("0x{code:04X}")); + }, + } + debug_tuple.finish() + } +} + +/// Contains the platform-native logical key identifier +/// +/// Exactly what that means differs from platform to platform, but the values are to some degree +/// tied to the currently active keyboard layout. The same key on the same keyboard may also report +/// different values on different platforms, which is one of the reasons this is a per-platform +/// enum. +/// +/// This enum is primarily used to store raw keysym when Winit doesn't map a given native logical +/// key identifier to a meaningful [`Key`] variant. This lets you use [`Key`], and let the user +/// define keybinds which work in the presence of identifiers we haven't mapped for you yet. +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum NativeKey { + Unidentified, + /// An Android "keycode", which is similar to a "virtual-key code" on Windows. + Android(u32), + /// A macOS "scancode". There does not appear to be any direct analogue to either keysyms or + /// "virtual-key" codes in macOS, so we report the scancode instead. + MacOS(u16), + /// A Windows "virtual-key code". + Windows(u16), + /// An XKB "keysym". + Xkb(u32), + /// A "key value string". + Web(SmolStr), +} + +impl std::fmt::Debug for NativeKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use NativeKey::{Android, MacOS, Unidentified, Web, Windows, Xkb}; + let mut debug_tuple; + match self { + Unidentified => { + debug_tuple = f.debug_tuple("Unidentified"); + }, + Android(code) => { + debug_tuple = f.debug_tuple("Android"); + debug_tuple.field(&format_args!("0x{code:04X}")); + }, + MacOS(code) => { + debug_tuple = f.debug_tuple("MacOS"); + debug_tuple.field(&format_args!("0x{code:04X}")); + }, + Windows(code) => { + debug_tuple = f.debug_tuple("Windows"); + debug_tuple.field(&format_args!("0x{code:04X}")); + }, + Xkb(code) => { + debug_tuple = f.debug_tuple("Xkb"); + debug_tuple.field(&format_args!("0x{code:04X}")); + }, + Web(code) => { + debug_tuple = f.debug_tuple("Web"); + debug_tuple.field(code); + }, + } + debug_tuple.finish() + } +} + +impl From for NativeKey { + #[inline] + fn from(code: NativeKeyCode) -> Self { + match code { + NativeKeyCode::Unidentified => NativeKey::Unidentified, + NativeKeyCode::Android(x) => NativeKey::Android(x), + NativeKeyCode::MacOS(x) => NativeKey::MacOS(x), + NativeKeyCode::Windows(x) => NativeKey::Windows(x), + NativeKeyCode::Xkb(x) => NativeKey::Xkb(x), + } + } +} + +impl PartialEq for NativeKeyCode { + #[allow(clippy::cmp_owned)] // uses less code than direct match; target is stack allocated + #[inline] + fn eq(&self, rhs: &NativeKey) -> bool { + NativeKey::from(*self) == *rhs + } +} + +impl PartialEq for NativeKey { + #[inline] + fn eq(&self, rhs: &NativeKeyCode) -> bool { + rhs == self + } +} + +/// Represents the location of a physical key. +/// +/// This type is a superset of [`KeyCode`], including an [`Unidentified`][Self::Unidentified] +/// variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum PhysicalKey { + /// A known key code + Code(KeyCode), + /// This variant is used when the key cannot be translated to a [`KeyCode`] + /// + /// The native keycode is provided (if available) so you're able to more reliably match + /// key-press and key-release events by hashing the [`PhysicalKey`]. It is also possible to use + /// this for keybinds for non-standard keys, but such keybinds are tied to a given platform. + Unidentified(NativeKeyCode), +} + +impl From for PhysicalKey { + #[inline] + fn from(code: KeyCode) -> Self { + PhysicalKey::Code(code) + } +} + +impl From for PhysicalKey { + #[inline] + fn from(code: NativeKeyCode) -> Self { + PhysicalKey::Unidentified(code) + } +} + +impl PartialEq for PhysicalKey { + #[inline] + fn eq(&self, rhs: &KeyCode) -> bool { + match self { + PhysicalKey::Code(ref code) => code == rhs, + _ => false, + } + } +} + +impl PartialEq for KeyCode { + #[inline] + fn eq(&self, rhs: &PhysicalKey) -> bool { + rhs == self + } +} + +impl PartialEq for PhysicalKey { + #[inline] + fn eq(&self, rhs: &NativeKeyCode) -> bool { + match self { + PhysicalKey::Unidentified(ref code) => code == rhs, + _ => false, + } + } +} + +impl PartialEq for NativeKeyCode { + #[inline] + fn eq(&self, rhs: &PhysicalKey) -> bool { + rhs == self + } +} + +/// Code representing the location of a physical key +/// +/// This mostly conforms to the UI Events Specification's [`KeyboardEvent.code`] with a few +/// exceptions: +/// - The keys that the specification calls "MetaLeft" and "MetaRight" are named "SuperLeft" and +/// "SuperRight" here. +/// - The key that the specification calls "Super" is reported as `Unidentified` here. +/// +/// [`KeyboardEvent.code`]: https://w3c.github.io/uievents-code/#code-value-tables +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum KeyCode { + /// ` on a US keyboard. This is also called a backtick or grave. + /// This is the 半角/全角/漢字 + /// (hankaku/zenkaku/kanji) key on Japanese keyboards + Backquote, + /// Used for both the US \\ (on the 101-key layout) and also for the key + /// located between the " and Enter keys on row C of the 102-, + /// 104- and 106-key layouts. + /// Labeled # on a UK (102) keyboard. + Backslash, + /// [ on a US keyboard. + BracketLeft, + /// ] on a US keyboard. + BracketRight, + /// , on a US keyboard. + Comma, + /// 0 on a US keyboard. + Digit0, + /// 1 on a US keyboard. + Digit1, + /// 2 on a US keyboard. + Digit2, + /// 3 on a US keyboard. + Digit3, + /// 4 on a US keyboard. + Digit4, + /// 5 on a US keyboard. + Digit5, + /// 6 on a US keyboard. + Digit6, + /// 7 on a US keyboard. + Digit7, + /// 8 on a US keyboard. + Digit8, + /// 9 on a US keyboard. + Digit9, + /// = on a US keyboard. + Equal, + /// Located between the left Shift and Z keys. + /// Labeled \\ on a UK keyboard. + IntlBackslash, + /// Located between the / and right Shift keys. + /// Labeled \\ (ro) on a Japanese keyboard. + IntlRo, + /// Located between the = and Backspace keys. + /// Labeled ¥ (yen) on a Japanese keyboard. \\ on a + /// Russian keyboard. + IntlYen, + /// a on a US keyboard. + /// Labeled q on an AZERTY (e.g., French) keyboard. + KeyA, + /// b on a US keyboard. + KeyB, + /// c on a US keyboard. + KeyC, + /// d on a US keyboard. + KeyD, + /// e on a US keyboard. + KeyE, + /// f on a US keyboard. + KeyF, + /// g on a US keyboard. + KeyG, + /// h on a US keyboard. + KeyH, + /// i on a US keyboard. + KeyI, + /// j on a US keyboard. + KeyJ, + /// k on a US keyboard. + KeyK, + /// l on a US keyboard. + KeyL, + /// m on a US keyboard. + KeyM, + /// n on a US keyboard. + KeyN, + /// o on a US keyboard. + KeyO, + /// p on a US keyboard. + KeyP, + /// q on a US keyboard. + /// Labeled a on an AZERTY (e.g., French) keyboard. + KeyQ, + /// r on a US keyboard. + KeyR, + /// s on a US keyboard. + KeyS, + /// t on a US keyboard. + KeyT, + /// u on a US keyboard. + KeyU, + /// v on a US keyboard. + KeyV, + /// w on a US keyboard. + /// Labeled z on an AZERTY (e.g., French) keyboard. + KeyW, + /// x on a US keyboard. + KeyX, + /// y on a US keyboard. + /// Labeled z on a QWERTZ (e.g., German) keyboard. + KeyY, + /// z on a US keyboard. + /// Labeled w on an AZERTY (e.g., French) keyboard, and y on a + /// QWERTZ (e.g., German) keyboard. + KeyZ, + /// - on a US keyboard. + Minus, + /// . on a US keyboard. + Period, + /// ' on a US keyboard. + Quote, + /// ; on a US keyboard. + Semicolon, + /// / on a US keyboard. + Slash, + /// Alt, Option, or . + AltLeft, + /// Alt, Option, or . + /// This is labeled AltGr on many keyboard layouts. + AltRight, + /// Backspace or . + /// Labeled Delete on Apple keyboards. + Backspace, + /// CapsLock or + CapsLock, + /// The application context menu key, which is typically found between the right + /// Super key and the right Control key. + ContextMenu, + /// Control or + ControlLeft, + /// Control or + ControlRight, + /// Enter or . Labeled Return on Apple keyboards. + Enter, + /// The Windows, , Command, or other OS symbol key. + SuperLeft, + /// The Windows, , Command, or other OS symbol key. + SuperRight, + /// Shift or + ShiftLeft, + /// Shift or + ShiftRight, + ///   (space) + Space, + /// Tab or + Tab, + /// Japanese: (henkan) + Convert, + /// Japanese: カタカナ/ひらがな/ローマ字 + /// (katakana/hiragana/romaji) + KanaMode, + /// Korean: HangulMode 한/영 (han/yeong) + /// + /// Japanese (Mac keyboard): (kana) + Lang1, + /// Korean: Hanja (hanja) + /// + /// Japanese (Mac keyboard): (eisu) + Lang2, + /// Japanese (word-processing keyboard): Katakana + Lang3, + /// Japanese (word-processing keyboard): Hiragana + Lang4, + /// Japanese (word-processing keyboard): Zenkaku/Hankaku + Lang5, + /// Japanese: 無変換 (muhenkan) + NonConvert, + /// . The forward delete key. + /// Note that on Apple keyboards, the key labelled Delete on the main part of + /// the keyboard is encoded as [`Backspace`]. + /// + /// [`Backspace`]: Self::Backspace + Delete, + /// Page Down, End, or + End, + /// Help. Not present on standard PC keyboards. + Help, + /// Home or + Home, + /// Insert or Ins. Not present on Apple keyboards. + Insert, + /// Page Down, PgDn, or + PageDown, + /// Page Up, PgUp, or + PageUp, + /// + ArrowDown, + /// + ArrowLeft, + /// + ArrowRight, + /// + ArrowUp, + /// On the Mac, this is used for the numpad Clear key. + NumLock, + /// 0 Ins on a keyboard. 0 on a phone or remote control + Numpad0, + /// 1 End on a keyboard. 1 or 1 QZ on a phone or remote + /// control + Numpad1, + /// 2 ↓ on a keyboard. 2 ABC on a phone or remote control + Numpad2, + /// 3 PgDn on a keyboard. 3 DEF on a phone or remote control + Numpad3, + /// 4 ← on a keyboard. 4 GHI on a phone or remote control + Numpad4, + /// 5 on a keyboard. 5 JKL on a phone or remote control + Numpad5, + /// 6 → on a keyboard. 6 MNO on a phone or remote control + Numpad6, + /// 7 Home on a keyboard. 7 PQRS or 7 PRS on a phone + /// or remote control + Numpad7, + /// 8 ↑ on a keyboard. 8 TUV on a phone or remote control + Numpad8, + /// 9 PgUp on a keyboard. 9 WXYZ or 9 WXY on a phone + /// or remote control + Numpad9, + /// + + NumpadAdd, + /// Found on the Microsoft Natural Keyboard. + NumpadBackspace, + /// C or A (All Clear). Also for use with numpads that have a + /// Clear key that is separate from the NumLock key. On the Mac, the + /// numpad Clear key is encoded as [`NumLock`]. + /// + /// [`NumLock`]: Self::NumLock + NumpadClear, + /// C (Clear Entry) + NumpadClearEntry, + /// , (thousands separator). For locales where the thousands separator + /// is a "." (e.g., Brazil), this key may generate a .. + NumpadComma, + /// . Del. For locales where the decimal separator is "," (e.g., + /// Brazil), this key may generate a ,. + NumpadDecimal, + /// / + NumpadDivide, + NumpadEnter, + /// = + NumpadEqual, + /// # on a phone or remote control device. This key is typically found + /// below the 9 key and to the right of the 0 key. + NumpadHash, + /// M Add current entry to the value stored in memory. + NumpadMemoryAdd, + /// M Clear the value stored in memory. + NumpadMemoryClear, + /// M Replace the current entry with the value stored in memory. + NumpadMemoryRecall, + /// M Replace the value stored in memory with the current entry. + NumpadMemoryStore, + /// M Subtract current entry from the value stored in memory. + NumpadMemorySubtract, + /// * on a keyboard. For use with numpads that provide mathematical + /// operations (+, - * and /). + /// + /// Use `NumpadStar` for the * key on phones and remote controls. + NumpadMultiply, + /// ( Found on the Microsoft Natural Keyboard. + NumpadParenLeft, + /// ) Found on the Microsoft Natural Keyboard. + NumpadParenRight, + /// * on a phone or remote control device. + /// + /// This key is typically found below the 7 key and to the left of + /// the 0 key. + /// + /// Use "NumpadMultiply" for the * key on + /// numeric keypads. + NumpadStar, + /// - + NumpadSubtract, + /// Esc or + Escape, + /// Fn This is typically a hardware key that does not generate a separate code. + Fn, + /// FLock or FnLock. Function Lock key. Found on the Microsoft + /// Natural Keyboard. + FnLock, + /// PrtScr SysRq or Print Screen + PrintScreen, + /// Scroll Lock + ScrollLock, + /// Pause Break + Pause, + /// Some laptops place this key to the left of the key. + /// + /// This also the "back" button (triangle) on Android. + BrowserBack, + BrowserFavorites, + /// Some laptops place this key to the right of the key. + BrowserForward, + /// The "home" button on Android. + BrowserHome, + BrowserRefresh, + BrowserSearch, + BrowserStop, + /// Eject or . This key is placed in the function section on some Apple + /// keyboards. + Eject, + /// Sometimes labelled My Computer on the keyboard + LaunchApp1, + /// Sometimes labelled Calculator on the keyboard + LaunchApp2, + LaunchMail, + MediaPlayPause, + MediaSelect, + MediaStop, + MediaTrackNext, + MediaTrackPrevious, + /// This key is placed in the function section on some Apple keyboards, replacing the + /// Eject key. + Power, + Sleep, + AudioVolumeDown, + AudioVolumeMute, + AudioVolumeUp, + WakeUp, + // Legacy modifier key. Also called "Super" in certain places. + Meta, + // Legacy modifier key. + Hyper, + Turbo, + Abort, + Resume, + Suspend, + /// Found on Sun’s USB keyboard. + Again, + /// Found on Sun’s USB keyboard. + Copy, + /// Found on Sun’s USB keyboard. + Cut, + /// Found on Sun’s USB keyboard. + Find, + /// Found on Sun’s USB keyboard. + Open, + /// Found on Sun’s USB keyboard. + Paste, + /// Found on Sun’s USB keyboard. + Props, + /// Found on Sun’s USB keyboard. + Select, + /// Found on Sun’s USB keyboard. + Undo, + /// Use for dedicated ひらがな key found on some Japanese word processing keyboards. + Hiragana, + /// Use for dedicated カタカナ key found on some Japanese word processing keyboards. + Katakana, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F1, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F2, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F3, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F4, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F5, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F6, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F7, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F8, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F9, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F10, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F11, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F12, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F13, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F14, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F15, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F16, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F17, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F18, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F19, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F20, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F21, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F22, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F23, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F24, + /// General-purpose function key. + F25, + /// General-purpose function key. + F26, + /// General-purpose function key. + F27, + /// General-purpose function key. + F28, + /// General-purpose function key. + F29, + /// General-purpose function key. + F30, + /// General-purpose function key. + F31, + /// General-purpose function key. + F32, + /// General-purpose function key. + F33, + /// General-purpose function key. + F34, + /// General-purpose function key. + F35, +} + +/// A [`Key::Named`] value +/// +/// This mostly conforms to the UI Events Specification's [`KeyboardEvent.key`] with a few +/// exceptions: +/// - The `Super` variant here, is named `Meta` in the aforementioned specification. (There's +/// another key which the specification calls `Super`. That does not exist here.) +/// - The `Space` variant here, can be identified by the character it generates in the +/// specification. +/// +/// [`KeyboardEvent.key`]: https://w3c.github.io/uievents-key/ +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum NamedKey { + /// The `Alt` (Alternative) key. + /// + /// This key enables the alternate modifier function for interpreting concurrent or subsequent + /// keyboard input. This key value is also used for the Apple Option key. + Alt, + /// The Alternate Graphics (AltGr or AltGraph) key. + /// + /// This key is used enable the ISO Level 3 shift modifier (the standard `Shift` key is the + /// level 2 modifier). + AltGraph, + /// The `Caps Lock` (Capital) key. + /// + /// Toggle capital character lock function for interpreting subsequent keyboard input event. + CapsLock, + /// The `Control` or `Ctrl` key. + /// + /// Used to enable control modifier function for interpreting concurrent or subsequent keyboard + /// input. + Control, + /// The Function switch `Fn` key. Activating this key simultaneously with another key changes + /// that key’s value to an alternate character or function. This key is often handled directly + /// in the keyboard hardware and does not usually generate key events. + Fn, + /// The Function-Lock (`FnLock` or `F-Lock`) key. Activating this key switches the mode of the + /// keyboard to changes some keys' values to an alternate character or function. This key is + /// often handled directly in the keyboard hardware and does not usually generate key events. + FnLock, + /// The `NumLock` or Number Lock key. Used to toggle numpad mode function for interpreting + /// subsequent keyboard input. + NumLock, + /// Toggle between scrolling and cursor movement modes. + ScrollLock, + /// Used to enable shift modifier function for interpreting concurrent or subsequent keyboard + /// input. + Shift, + /// The Symbol modifier key (used on some virtual keyboards). + Symbol, + SymbolLock, + // Legacy modifier key. Also called "Super" in certain places. + Meta, + // Legacy modifier key. + Hyper, + /// Used to enable "super" modifier function for interpreting concurrent or subsequent keyboard + /// input. This key value is used for the "Windows Logo" key and the Apple `Command` or `⌘` + /// key. + /// + /// Note: In some contexts (e.g. the Web) this is referred to as the "Meta" key. + Super, + /// The `Enter` or `↵` key. Used to activate current selection or accept current input. This + /// key value is also used for the `Return` (Macintosh numpad) key. This key value is also + /// used for the Android `KEYCODE_DPAD_CENTER`. + Enter, + /// The Horizontal Tabulation `Tab` key. + Tab, + /// Used in text to insert a space between words. Usually located below the character keys. + Space, + /// Navigate or traverse downward. (`KEYCODE_DPAD_DOWN`) + ArrowDown, + /// Navigate or traverse leftward. (`KEYCODE_DPAD_LEFT`) + ArrowLeft, + /// Navigate or traverse rightward. (`KEYCODE_DPAD_RIGHT`) + ArrowRight, + /// Navigate or traverse upward. (`KEYCODE_DPAD_UP`) + ArrowUp, + /// The End key, used with keyboard entry to go to the end of content (`KEYCODE_MOVE_END`). + End, + /// The Home key, used with keyboard entry, to go to start of content (`KEYCODE_MOVE_HOME`). + /// For the mobile phone `Home` key (which goes to the phone’s main screen), use [`GoHome`]. + /// + /// [`GoHome`]: Self::GoHome + Home, + /// Scroll down or display next page of content. + PageDown, + /// Scroll up or display previous page of content. + PageUp, + /// Used to remove the character to the left of the cursor. This key value is also used for + /// the key labeled `Delete` on MacOS keyboards. + Backspace, + /// Remove the currently selected input. + Clear, + /// Copy the current selection. (`APPCOMMAND_COPY`) + Copy, + /// The Cursor Select key. + CrSel, + /// Cut the current selection. (`APPCOMMAND_CUT`) + Cut, + /// Used to delete the character to the right of the cursor. This key value is also used for + /// the key labeled `Delete` on MacOS keyboards when `Fn` is active. + Delete, + /// The Erase to End of Field key. This key deletes all characters from the current cursor + /// position to the end of the current field. + EraseEof, + /// The Extend Selection (Exsel) key. + ExSel, + /// Toggle between text modes for insertion or overtyping. + /// (`KEYCODE_INSERT`) + Insert, + /// The Paste key. (`APPCOMMAND_PASTE`) + Paste, + /// Redo the last action. (`APPCOMMAND_REDO`) + Redo, + /// Undo the last action. (`APPCOMMAND_UNDO`) + Undo, + /// The Accept (Commit, OK) key. Accept current option or input method sequence conversion. + Accept, + /// Redo or repeat an action. + Again, + /// The Attention (Attn) key. + Attn, + Cancel, + /// Show the application’s context menu. + /// This key is commonly found between the right `Super` key and the right `Control` key. + ContextMenu, + /// The `Esc` key. This key was originally used to initiate an escape sequence, but is + /// now more generally used to exit or "escape" the current context, such as closing a dialog + /// or exiting full screen mode. + Escape, + Execute, + /// Open the Find dialog. (`APPCOMMAND_FIND`) + Find, + /// Open a help dialog or toggle display of help information. (`APPCOMMAND_HELP`, + /// `KEYCODE_HELP`) + Help, + /// Pause the current state or application (as appropriate). + /// + /// Note: Do not use this value for the `Pause` button on media controllers. Use `"MediaPause"` + /// instead. + Pause, + /// Play or resume the current state or application (as appropriate). + /// + /// Note: Do not use this value for the `Play` button on media controllers. Use `"MediaPlay"` + /// instead. + Play, + /// The properties (Props) key. + Props, + Select, + /// The ZoomIn key. (`KEYCODE_ZOOM_IN`) + ZoomIn, + /// The ZoomOut key. (`KEYCODE_ZOOM_OUT`) + ZoomOut, + /// The Brightness Down key. Typically controls the display brightness. + /// (`KEYCODE_BRIGHTNESS_DOWN`) + BrightnessDown, + /// The Brightness Up key. Typically controls the display brightness. (`KEYCODE_BRIGHTNESS_UP`) + BrightnessUp, + /// Toggle removable media to eject (open) and insert (close) state. (`KEYCODE_MEDIA_EJECT`) + Eject, + LogOff, + /// Toggle power state. (`KEYCODE_POWER`) + /// Note: Note: Some devices might not expose this key to the operating environment. + Power, + /// The `PowerOff` key. Sometime called `PowerDown`. + PowerOff, + /// Initiate print-screen function. + PrintScreen, + /// The Hibernate key. This key saves the current state of the computer to disk so that it can + /// be restored. The computer will then shutdown. + Hibernate, + /// The Standby key. This key turns off the display and places the computer into a low-power + /// mode without completely shutting down. It is sometimes labelled `Suspend` or `Sleep` key. + /// (`KEYCODE_SLEEP`) + Standby, + /// The WakeUp key. (`KEYCODE_WAKEUP`) + WakeUp, + /// Initiate the multi-candidate mode. + AllCandidates, + Alphanumeric, + /// Initiate the Code Input mode to allow characters to be entered by + /// their code points. + CodeInput, + /// The Compose key, also known as "Multi_key" on the X Window System. This key acts in a + /// manner similar to a dead key, triggering a mode where subsequent key presses are combined + /// to produce a different character. + Compose, + /// Convert the current input method sequence. + Convert, + /// The Final Mode `Final` key used on some Asian keyboards, to enable the final mode for IMEs. + FinalMode, + /// Switch to the first character group. (ISO/IEC 9995) + GroupFirst, + /// Switch to the last character group. (ISO/IEC 9995) + GroupLast, + /// Switch to the next character group. (ISO/IEC 9995) + GroupNext, + /// Switch to the previous character group. (ISO/IEC 9995) + GroupPrevious, + /// Toggle between or cycle through input modes of IMEs. + ModeChange, + NextCandidate, + /// Accept current input method sequence without + /// conversion in IMEs. + NonConvert, + PreviousCandidate, + Process, + SingleCandidate, + /// Toggle between Hangul and English modes. + HangulMode, + HanjaMode, + JunjaMode, + /// The Eisu key. This key may close the IME, but its purpose is defined by the current IME. + /// (`KEYCODE_EISU`) + Eisu, + /// The (Half-Width) Characters key. + Hankaku, + /// The Hiragana (Japanese Kana characters) key. + Hiragana, + /// The Hiragana/Katakana toggle key. (`KEYCODE_KATAKANA_HIRAGANA`) + HiraganaKatakana, + /// The Kana Mode (Kana Lock) key. This key is used to enter hiragana mode (typically from + /// romaji mode). + KanaMode, + /// The Kanji (Japanese name for ideographic characters of Chinese origin) Mode key. This key + /// is typically used to switch to a hiragana keyboard for the purpose of converting input + /// into kanji. (`KEYCODE_KANA`) + KanjiMode, + /// The Katakana (Japanese Kana characters) key. + Katakana, + /// The Roman characters function key. + Romaji, + /// The Zenkaku (Full-Width) Characters key. + Zenkaku, + /// The Zenkaku/Hankaku (full-width/half-width) toggle key. (`KEYCODE_ZENKAKU_HANKAKU`) + ZenkakuHankaku, + /// General purpose virtual function key, as index 1. + Soft1, + /// General purpose virtual function key, as index 2. + Soft2, + /// General purpose virtual function key, as index 3. + Soft3, + /// General purpose virtual function key, as index 4. + Soft4, + /// Select next (numerically or logically) lower channel. (`APPCOMMAND_MEDIA_CHANNEL_DOWN`, + /// `KEYCODE_CHANNEL_DOWN`) + ChannelDown, + /// Select next (numerically or logically) higher channel. (`APPCOMMAND_MEDIA_CHANNEL_UP`, + /// `KEYCODE_CHANNEL_UP`) + ChannelUp, + /// Close the current document or message (Note: This doesn’t close the application). + /// (`APPCOMMAND_CLOSE`) + Close, + /// Open an editor to forward the current message. (`APPCOMMAND_FORWARD_MAIL`) + MailForward, + /// Open an editor to reply to the current message. (`APPCOMMAND_REPLY_TO_MAIL`) + MailReply, + /// Send the current message. (`APPCOMMAND_SEND_MAIL`) + MailSend, + /// Close the current media, for example to close a CD or DVD tray. (`KEYCODE_MEDIA_CLOSE`) + MediaClose, + /// Initiate or continue forward playback at faster than normal speed, or increase speed if + /// already fast forwarding. (`APPCOMMAND_MEDIA_FAST_FORWARD`, `KEYCODE_MEDIA_FAST_FORWARD`) + MediaFastForward, + /// Pause the currently playing media. (`APPCOMMAND_MEDIA_PAUSE`, `KEYCODE_MEDIA_PAUSE`) + /// + /// Note: Media controller devices should use this value rather than `"Pause"` for their pause + /// keys. + MediaPause, + /// Initiate or continue media playback at normal speed, if not currently playing at normal + /// speed. (`APPCOMMAND_MEDIA_PLAY`, `KEYCODE_MEDIA_PLAY`) + MediaPlay, + /// Toggle media between play and pause states. (`APPCOMMAND_MEDIA_PLAY_PAUSE`, + /// `KEYCODE_MEDIA_PLAY_PAUSE`) + MediaPlayPause, + /// Initiate or resume recording of currently selected media. (`APPCOMMAND_MEDIA_RECORD`, + /// `KEYCODE_MEDIA_RECORD`) + MediaRecord, + /// Initiate or continue reverse playback at faster than normal speed, or increase speed if + /// already rewinding. (`APPCOMMAND_MEDIA_REWIND`, `KEYCODE_MEDIA_REWIND`) + MediaRewind, + /// Stop media playing, pausing, forwarding, rewinding, or recording, if not already stopped. + /// (`APPCOMMAND_MEDIA_STOP`, `KEYCODE_MEDIA_STOP`) + MediaStop, + /// Seek to next media or program track. (`APPCOMMAND_MEDIA_NEXTTRACK`, `KEYCODE_MEDIA_NEXT`) + MediaTrackNext, + /// Seek to previous media or program track. (`APPCOMMAND_MEDIA_PREVIOUSTRACK`, + /// `KEYCODE_MEDIA_PREVIOUS`) + MediaTrackPrevious, + /// Open a new document or message. (`APPCOMMAND_NEW`) + New, + /// Open an existing document or message. (`APPCOMMAND_OPEN`) + Open, + /// Print the current document or message. (`APPCOMMAND_PRINT`) + Print, + /// Save the current document or message. (`APPCOMMAND_SAVE`) + Save, + /// Spellcheck the current document or selection. (`APPCOMMAND_SPELL_CHECK`) + SpellCheck, + /// The `11` key found on media numpads that + /// have buttons from `1` ... `12`. + Key11, + /// The `12` key found on media numpads that + /// have buttons from `1` ... `12`. + Key12, + /// Adjust audio balance leftward. (`VK_AUDIO_BALANCE_LEFT`) + AudioBalanceLeft, + /// Adjust audio balance rightward. (`VK_AUDIO_BALANCE_RIGHT`) + AudioBalanceRight, + /// Decrease audio bass boost or cycle down through bass boost states. (`APPCOMMAND_BASS_DOWN`, + /// `VK_BASS_BOOST_DOWN`) + AudioBassBoostDown, + /// Toggle bass boost on/off. (`APPCOMMAND_BASS_BOOST`) + AudioBassBoostToggle, + /// Increase audio bass boost or cycle up through bass boost states. (`APPCOMMAND_BASS_UP`, + /// `VK_BASS_BOOST_UP`) + AudioBassBoostUp, + /// Adjust audio fader towards front. (`VK_FADER_FRONT`) + AudioFaderFront, + /// Adjust audio fader towards rear. (`VK_FADER_REAR`) + AudioFaderRear, + /// Advance surround audio mode to next available mode. (`VK_SURROUND_MODE_NEXT`) + AudioSurroundModeNext, + /// Decrease treble. (`APPCOMMAND_TREBLE_DOWN`) + AudioTrebleDown, + /// Increase treble. (`APPCOMMAND_TREBLE_UP`) + AudioTrebleUp, + /// Decrease audio volume. (`APPCOMMAND_VOLUME_DOWN`, `KEYCODE_VOLUME_DOWN`) + AudioVolumeDown, + /// Increase audio volume. (`APPCOMMAND_VOLUME_UP`, `KEYCODE_VOLUME_UP`) + AudioVolumeUp, + /// Toggle between muted state and prior volume level. (`APPCOMMAND_VOLUME_MUTE`, + /// `KEYCODE_VOLUME_MUTE`) + AudioVolumeMute, + /// Toggle the microphone on/off. (`APPCOMMAND_MIC_ON_OFF_TOGGLE`) + MicrophoneToggle, + /// Decrease microphone volume. (`APPCOMMAND_MICROPHONE_VOLUME_DOWN`) + MicrophoneVolumeDown, + /// Increase microphone volume. (`APPCOMMAND_MICROPHONE_VOLUME_UP`) + MicrophoneVolumeUp, + /// Mute the microphone. (`APPCOMMAND_MICROPHONE_VOLUME_MUTE`, `KEYCODE_MUTE`) + MicrophoneVolumeMute, + /// Show correction list when a word is incorrectly identified. (`APPCOMMAND_CORRECTION_LIST`) + SpeechCorrectionList, + /// Toggle between dictation mode and command/control mode. + /// (`APPCOMMAND_DICTATE_OR_COMMAND_CONTROL_TOGGLE`) + SpeechInputToggle, + /// The first generic "LaunchApplication" key. This is commonly associated with launching "My + /// Computer", and may have a computer symbol on the key. (`APPCOMMAND_LAUNCH_APP1`) + LaunchApplication1, + /// The second generic "LaunchApplication" key. This is commonly associated with launching + /// "Calculator", and may have a calculator symbol on the key. (`APPCOMMAND_LAUNCH_APP2`, + /// `KEYCODE_CALCULATOR`) + LaunchApplication2, + /// The "Calendar" key. (`KEYCODE_CALENDAR`) + LaunchCalendar, + /// The "Contacts" key. (`KEYCODE_CONTACTS`) + LaunchContacts, + /// The "Mail" key. (`APPCOMMAND_LAUNCH_MAIL`) + LaunchMail, + /// The "Media Player" key. (`APPCOMMAND_LAUNCH_MEDIA_SELECT`) + LaunchMediaPlayer, + LaunchMusicPlayer, + LaunchPhone, + LaunchScreenSaver, + LaunchSpreadsheet, + LaunchWebBrowser, + LaunchWebCam, + LaunchWordProcessor, + /// Navigate to previous content or page in current history. (`APPCOMMAND_BROWSER_BACKWARD`) + BrowserBack, + /// Open the list of browser favorites. (`APPCOMMAND_BROWSER_FAVORITES`) + BrowserFavorites, + /// Navigate to next content or page in current history. (`APPCOMMAND_BROWSER_FORWARD`) + BrowserForward, + /// Go to the user’s preferred home page. (`APPCOMMAND_BROWSER_HOME`) + BrowserHome, + /// Refresh the current page or content. (`APPCOMMAND_BROWSER_REFRESH`) + BrowserRefresh, + /// Call up the user’s preferred search page. (`APPCOMMAND_BROWSER_SEARCH`) + BrowserSearch, + /// Stop loading the current page or content. (`APPCOMMAND_BROWSER_STOP`) + BrowserStop, + /// The Application switch key, which provides a list of recent apps to switch between. + /// (`KEYCODE_APP_SWITCH`) + AppSwitch, + /// The Call key. (`KEYCODE_CALL`) + Call, + /// The Camera key. (`KEYCODE_CAMERA`) + Camera, + /// The Camera focus key. (`KEYCODE_FOCUS`) + CameraFocus, + /// The End Call key. (`KEYCODE_ENDCALL`) + EndCall, + /// The Back key. (`KEYCODE_BACK`) + GoBack, + /// The Home key, which goes to the phone’s main screen. (`KEYCODE_HOME`) + GoHome, + /// The Headset Hook key. (`KEYCODE_HEADSETHOOK`) + HeadsetHook, + LastNumberRedial, + /// The Notification key. (`KEYCODE_NOTIFICATION`) + Notification, + /// Toggle between manner mode state: silent, vibrate, ring, ... (`KEYCODE_MANNER_MODE`) + MannerMode, + VoiceDial, + /// Switch to viewing TV. (`KEYCODE_TV`) + TV, + /// TV 3D Mode. (`KEYCODE_3D_MODE`) + TV3DMode, + /// Toggle between antenna and cable input. (`KEYCODE_TV_ANTENNA_CABLE`) + TVAntennaCable, + /// Audio description. (`KEYCODE_TV_AUDIO_DESCRIPTION`) + TVAudioDescription, + /// Audio description mixing volume down. (`KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN`) + TVAudioDescriptionMixDown, + /// Audio description mixing volume up. (`KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP`) + TVAudioDescriptionMixUp, + /// Contents menu. (`KEYCODE_TV_CONTENTS_MENU`) + TVContentsMenu, + /// Contents menu. (`KEYCODE_TV_DATA_SERVICE`) + TVDataService, + /// Switch the input mode on an external TV. (`KEYCODE_TV_INPUT`) + TVInput, + /// Switch to component input #1. (`KEYCODE_TV_INPUT_COMPONENT_1`) + TVInputComponent1, + /// Switch to component input #2. (`KEYCODE_TV_INPUT_COMPONENT_2`) + TVInputComponent2, + /// Switch to composite input #1. (`KEYCODE_TV_INPUT_COMPOSITE_1`) + TVInputComposite1, + /// Switch to composite input #2. (`KEYCODE_TV_INPUT_COMPOSITE_2`) + TVInputComposite2, + /// Switch to HDMI input #1. (`KEYCODE_TV_INPUT_HDMI_1`) + TVInputHDMI1, + /// Switch to HDMI input #2. (`KEYCODE_TV_INPUT_HDMI_2`) + TVInputHDMI2, + /// Switch to HDMI input #3. (`KEYCODE_TV_INPUT_HDMI_3`) + TVInputHDMI3, + /// Switch to HDMI input #4. (`KEYCODE_TV_INPUT_HDMI_4`) + TVInputHDMI4, + /// Switch to VGA input #1. (`KEYCODE_TV_INPUT_VGA_1`) + TVInputVGA1, + /// Media context menu. (`KEYCODE_TV_MEDIA_CONTEXT_MENU`) + TVMediaContext, + /// Toggle network. (`KEYCODE_TV_NETWORK`) + TVNetwork, + /// Number entry. (`KEYCODE_TV_NUMBER_ENTRY`) + TVNumberEntry, + /// Toggle the power on an external TV. (`KEYCODE_TV_POWER`) + TVPower, + /// Radio. (`KEYCODE_TV_RADIO_SERVICE`) + TVRadioService, + /// Satellite. (`KEYCODE_TV_SATELLITE`) + TVSatellite, + /// Broadcast Satellite. (`KEYCODE_TV_SATELLITE_BS`) + TVSatelliteBS, + /// Communication Satellite. (`KEYCODE_TV_SATELLITE_CS`) + TVSatelliteCS, + /// Toggle between available satellites. (`KEYCODE_TV_SATELLITE_SERVICE`) + TVSatelliteToggle, + /// Analog Terrestrial. (`KEYCODE_TV_TERRESTRIAL_ANALOG`) + TVTerrestrialAnalog, + /// Digital Terrestrial. (`KEYCODE_TV_TERRESTRIAL_DIGITAL`) + TVTerrestrialDigital, + /// Timer programming. (`KEYCODE_TV_TIMER_PROGRAMMING`) + TVTimer, + /// Switch the input mode on an external AVR (audio/video receiver). (`KEYCODE_AVR_INPUT`) + AVRInput, + /// Toggle the power on an external AVR (audio/video receiver). (`KEYCODE_AVR_POWER`) + AVRPower, + /// General purpose color-coded media function key, as index 0 (red). (`VK_COLORED_KEY_0`, + /// `KEYCODE_PROG_RED`) + ColorF0Red, + /// General purpose color-coded media function key, as index 1 (green). (`VK_COLORED_KEY_1`, + /// `KEYCODE_PROG_GREEN`) + ColorF1Green, + /// General purpose color-coded media function key, as index 2 (yellow). (`VK_COLORED_KEY_2`, + /// `KEYCODE_PROG_YELLOW`) + ColorF2Yellow, + /// General purpose color-coded media function key, as index 3 (blue). (`VK_COLORED_KEY_3`, + /// `KEYCODE_PROG_BLUE`) + ColorF3Blue, + /// General purpose color-coded media function key, as index 4 (grey). (`VK_COLORED_KEY_4`) + ColorF4Grey, + /// General purpose color-coded media function key, as index 5 (brown). (`VK_COLORED_KEY_5`) + ColorF5Brown, + /// Toggle the display of Closed Captions. (`VK_CC`, `KEYCODE_CAPTIONS`) + ClosedCaptionToggle, + /// Adjust brightness of device, by toggling between or cycling through states. (`VK_DIMMER`) + Dimmer, + /// Swap video sources. (`VK_DISPLAY_SWAP`) + DisplaySwap, + /// Select Digital Video Recorder. (`KEYCODE_DVR`) + DVR, + /// Exit the current application. (`VK_EXIT`) + Exit, + /// Clear program or content stored as favorite 0. (`VK_CLEAR_FAVORITE_0`) + FavoriteClear0, + /// Clear program or content stored as favorite 1. (`VK_CLEAR_FAVORITE_1`) + FavoriteClear1, + /// Clear program or content stored as favorite 2. (`VK_CLEAR_FAVORITE_2`) + FavoriteClear2, + /// Clear program or content stored as favorite 3. (`VK_CLEAR_FAVORITE_3`) + FavoriteClear3, + /// Select (recall) program or content stored as favorite 0. (`VK_RECALL_FAVORITE_0`) + FavoriteRecall0, + /// Select (recall) program or content stored as favorite 1. (`VK_RECALL_FAVORITE_1`) + FavoriteRecall1, + /// Select (recall) program or content stored as favorite 2. (`VK_RECALL_FAVORITE_2`) + FavoriteRecall2, + /// Select (recall) program or content stored as favorite 3. (`VK_RECALL_FAVORITE_3`) + FavoriteRecall3, + /// Store current program or content as favorite 0. (`VK_STORE_FAVORITE_0`) + FavoriteStore0, + /// Store current program or content as favorite 1. (`VK_STORE_FAVORITE_1`) + FavoriteStore1, + /// Store current program or content as favorite 2. (`VK_STORE_FAVORITE_2`) + FavoriteStore2, + /// Store current program or content as favorite 3. (`VK_STORE_FAVORITE_3`) + FavoriteStore3, + /// Toggle display of program or content guide. (`VK_GUIDE`, `KEYCODE_GUIDE`) + Guide, + /// If guide is active and displayed, then display next day’s content. (`VK_NEXT_DAY`) + GuideNextDay, + /// If guide is active and displayed, then display previous day’s content. (`VK_PREV_DAY`) + GuidePreviousDay, + /// Toggle display of information about currently selected context or media. (`VK_INFO`, + /// `KEYCODE_INFO`) + Info, + /// Toggle instant replay. (`VK_INSTANT_REPLAY`) + InstantReplay, + /// Launch linked content, if available and appropriate. (`VK_LINK`) + Link, + /// List the current program. (`VK_LIST`) + ListProgram, + /// Toggle display listing of currently available live content or programs. (`VK_LIVE`) + LiveContent, + /// Lock or unlock current content or program. (`VK_LOCK`) + Lock, + /// Show a list of media applications: audio/video players and image viewers. (`VK_APPS`) + /// + /// Note: Do not confuse this key value with the Windows' `VK_APPS` / `VK_CONTEXT_MENU` key, + /// which is encoded as `"ContextMenu"`. + MediaApps, + /// Audio track key. (`KEYCODE_MEDIA_AUDIO_TRACK`) + MediaAudioTrack, + /// Select previously selected channel or media. (`VK_LAST`, `KEYCODE_LAST_CHANNEL`) + MediaLast, + /// Skip backward to next content or program. (`KEYCODE_MEDIA_SKIP_BACKWARD`) + MediaSkipBackward, + /// Skip forward to next content or program. (`VK_SKIP`, `KEYCODE_MEDIA_SKIP_FORWARD`) + MediaSkipForward, + /// Step backward to next content or program. (`KEYCODE_MEDIA_STEP_BACKWARD`) + MediaStepBackward, + /// Step forward to next content or program. (`KEYCODE_MEDIA_STEP_FORWARD`) + MediaStepForward, + /// Media top menu. (`KEYCODE_MEDIA_TOP_MENU`) + MediaTopMenu, + /// Navigate in. (`KEYCODE_NAVIGATE_IN`) + NavigateIn, + /// Navigate to next key. (`KEYCODE_NAVIGATE_NEXT`) + NavigateNext, + /// Navigate out. (`KEYCODE_NAVIGATE_OUT`) + NavigateOut, + /// Navigate to previous key. (`KEYCODE_NAVIGATE_PREVIOUS`) + NavigatePrevious, + /// Cycle to next favorite channel (in favorites list). (`VK_NEXT_FAVORITE_CHANNEL`) + NextFavoriteChannel, + /// Cycle to next user profile (if there are multiple user profiles). (`VK_USER`) + NextUserProfile, + /// Access on-demand content or programs. (`VK_ON_DEMAND`) + OnDemand, + /// Pairing key to pair devices. (`KEYCODE_PAIRING`) + Pairing, + /// Move picture-in-picture window down. (`VK_PINP_DOWN`) + PinPDown, + /// Move picture-in-picture window. (`VK_PINP_MOVE`) + PinPMove, + /// Toggle display of picture-in-picture window. (`VK_PINP_TOGGLE`) + PinPToggle, + /// Move picture-in-picture window up. (`VK_PINP_UP`) + PinPUp, + /// Decrease media playback speed. (`VK_PLAY_SPEED_DOWN`) + PlaySpeedDown, + /// Reset playback to normal speed. (`VK_PLAY_SPEED_RESET`) + PlaySpeedReset, + /// Increase media playback speed. (`VK_PLAY_SPEED_UP`) + PlaySpeedUp, + /// Toggle random media or content shuffle mode. (`VK_RANDOM_TOGGLE`) + RandomToggle, + /// Not a physical key, but this key code is sent when the remote control battery is low. + /// (`VK_RC_LOW_BATTERY`) + RcLowBattery, + /// Toggle or cycle between media recording speeds. (`VK_RECORD_SPEED_NEXT`) + RecordSpeedNext, + /// Toggle RF (radio frequency) input bypass mode (pass RF input directly to the RF output). + /// (`VK_RF_BYPASS`) + RfBypass, + /// Toggle scan channels mode. (`VK_SCAN_CHANNELS_TOGGLE`) + ScanChannelsToggle, + /// Advance display screen mode to next available mode. (`VK_SCREEN_MODE_NEXT`) + ScreenModeNext, + /// Toggle display of device settings screen. (`VK_SETTINGS`, `KEYCODE_SETTINGS`) + Settings, + /// Toggle split screen mode. (`VK_SPLIT_SCREEN_TOGGLE`) + SplitScreenToggle, + /// Switch the input mode on an external STB (set top box). (`KEYCODE_STB_INPUT`) + STBInput, + /// Toggle the power on an external STB (set top box). (`KEYCODE_STB_POWER`) + STBPower, + /// Toggle display of subtitles, if available. (`VK_SUBTITLE`) + Subtitle, + /// Toggle display of teletext, if available (`VK_TELETEXT`, `KEYCODE_TV_TELETEXT`). + Teletext, + /// Advance video mode to next available mode. (`VK_VIDEO_MODE_NEXT`) + VideoModeNext, + /// Cause device to identify itself in some manner, e.g., audibly or visibly. (`VK_WINK`) + Wink, + /// Toggle between full-screen and scaled content, or alter magnification level. (`VK_ZOOM`, + /// `KEYCODE_TV_ZOOM_MODE`) + ZoomToggle, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F1, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F2, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F3, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F4, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F5, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F6, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F7, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F8, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F9, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F10, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F11, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F12, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F13, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F14, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F15, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F16, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F17, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F18, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F19, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F20, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F21, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F22, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F23, + /// General-purpose function key. + /// Usually found at the top of the keyboard. + F24, + /// General-purpose function key. + F25, + /// General-purpose function key. + F26, + /// General-purpose function key. + F27, + /// General-purpose function key. + F28, + /// General-purpose function key. + F29, + /// General-purpose function key. + F30, + /// General-purpose function key. + F31, + /// General-purpose function key. + F32, + /// General-purpose function key. + F33, + /// General-purpose function key. + F34, + /// General-purpose function key. + F35, +} + +/// Key represents the meaning of a keypress. +/// +/// This is a superset of the UI Events Specification's [`KeyboardEvent.key`] with +/// additions: +/// - All simple variants are wrapped under the `Named` variant +/// - The `Unidentified` variant here, can still identify a key through it's `NativeKeyCode`. +/// - The `Dead` variant here, can specify the character which is inserted when pressing the +/// dead-key twice. +/// +/// [`KeyboardEvent.key`]: https://w3c.github.io/uievents-key/ +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Key { + /// A simple (unparameterised) action + Named(NamedKey), + + /// A key string that corresponds to the character typed by the user, taking into account the + /// user’s current locale setting, and any system-level keyboard mapping overrides that are in + /// effect. + Character(Str), + + /// This variant is used when the key cannot be translated to any other variant. + /// + /// The native key is provided (if available) in order to allow the user to specify keybindings + /// for keys which are not defined by this API, mainly through some sort of UI. + Unidentified(NativeKey), + + /// Contains the text representation of the dead-key when available. + /// + /// ## Platform-specific + /// - **Web:** Always contains `None` + Dead(Option), +} + +impl From for Key { + #[inline] + fn from(action: NamedKey) -> Self { + Key::Named(action) + } +} + +impl From for Key { + #[inline] + fn from(code: NativeKey) -> Self { + Key::Unidentified(code) + } +} + +impl PartialEq for Key { + #[inline] + fn eq(&self, rhs: &NamedKey) -> bool { + match self { + Key::Named(ref a) => a == rhs, + _ => false, + } + } +} + +impl> PartialEq for Key { + #[inline] + fn eq(&self, rhs: &str) -> bool { + match self { + Key::Character(ref s) => s == rhs, + _ => false, + } + } +} + +impl> PartialEq<&str> for Key { + #[inline] + fn eq(&self, rhs: &&str) -> bool { + self == *rhs + } +} + +impl PartialEq for Key { + #[inline] + fn eq(&self, rhs: &NativeKey) -> bool { + match self { + Key::Unidentified(ref code) => code == rhs, + _ => false, + } + } +} + +impl PartialEq> for NativeKey { + #[inline] + fn eq(&self, rhs: &Key) -> bool { + rhs == self + } +} + +impl Key { + /// Convert `Key::Character(SmolStr)` to `Key::Character(&str)` so you can more easily match on + /// `Key`. All other variants remain unchanged. + pub fn as_ref(&self) -> Key<&str> { + match self { + Key::Named(a) => Key::Named(*a), + Key::Character(ch) => Key::Character(ch.as_str()), + Key::Dead(d) => Key::Dead(*d), + Key::Unidentified(u) => Key::Unidentified(u.clone()), + } + } +} + +impl NamedKey { + /// Convert an action to its approximate textual equivalent. + /// + /// # Examples + /// + /// ``` + /// use winit::keyboard::NamedKey; + /// + /// assert_eq!(NamedKey::Enter.to_text(), Some("\r")); + /// assert_eq!(NamedKey::F20.to_text(), None); + /// ``` + pub fn to_text(&self) -> Option<&str> { + match self { + NamedKey::Enter => Some("\r"), + NamedKey::Backspace => Some("\x08"), + NamedKey::Tab => Some("\t"), + NamedKey::Space => Some(" "), + NamedKey::Escape => Some("\x1b"), + _ => None, + } + } +} + +impl Key { + /// Convert a key to its approximate textual equivalent. + /// + /// # Examples + /// + /// ``` + /// use winit::keyboard::{Key, NamedKey}; + /// + /// assert_eq!(Key::Character("a".into()).to_text(), Some("a")); + /// assert_eq!(Key::Named(NamedKey::Enter).to_text(), Some("\r")); + /// assert_eq!(Key::Named(NamedKey::F20).to_text(), None); + /// ``` + pub fn to_text(&self) -> Option<&str> { + match self { + Key::Named(action) => action.to_text(), + Key::Character(ch) => Some(ch.as_str()), + _ => None, + } + } +} + +/// The location of the key on the keyboard. +/// +/// Certain physical keys on the keyboard can have the same value, but are in different locations. +/// For instance, the Shift key can be on the left or right side of the keyboard, or the number +/// keys can be above the letters or on the numpad. This enum allows the user to differentiate +/// them. +/// +/// See the documentation for the [`location`] field on the [`KeyEvent`] struct for more +/// information. +/// +/// [`location`]: ../event/struct.KeyEvent.html#structfield.location +/// [`KeyEvent`]: crate::event::KeyEvent +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum KeyLocation { + /// The key is in its "normal" location on the keyboard. + /// + /// For instance, the "1" key above the "Q" key on a QWERTY keyboard will use this location. + /// This invariant is also returned when the location of the key cannot be identified. + /// + /// ![Standard 1 key](https://raw.githubusercontent.com/rust-windowing/winit/master/docs/res/keyboard_standard_1_key.svg) + /// + /// + /// For image attribution, see the + /// + /// ATTRIBUTION.md + /// + /// file. + /// + Standard, + + /// The key is on the left side of the keyboard. + /// + /// For instance, the left Shift key below the Caps Lock key on a QWERTY keyboard will use this + /// location. + /// + /// ![Left Shift key](https://raw.githubusercontent.com/rust-windowing/winit/master/docs/res/keyboard_left_shift_key.svg) + /// + /// + /// For image attribution, see the + /// + /// ATTRIBUTION.md + /// + /// file. + /// + Left, + + /// The key is on the right side of the keyboard. + /// + /// For instance, the right Shift key below the Enter key on a QWERTY keyboard will use this + /// location. + /// + /// ![Right Shift key](https://raw.githubusercontent.com/rust-windowing/winit/master/docs/res/keyboard_right_shift_key.svg) + /// + /// + /// For image attribution, see the + /// + /// ATTRIBUTION.md + /// + /// file. + /// + Right, + + /// The key is on the numpad. + /// + /// For instance, the "1" key on the numpad will use this location. + /// + /// ![Numpad 1 key](https://raw.githubusercontent.com/rust-windowing/winit/master/docs/res/keyboard_numpad_1_key.svg) + /// + /// + /// For image attribution, see the + /// + /// ATTRIBUTION.md + /// + /// file. + /// + Numpad, +} + +bitflags! { + /// Represents the current state of the keyboard modifiers + /// + /// Each flag represents a modifier and is set if this modifier is active. + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct ModifiersState: u32 { + /// The "shift" key. + const SHIFT = 0b100; + /// The "control" key. + const CONTROL = 0b100 << 3; + /// The "alt" key. + const ALT = 0b100 << 6; + /// This is the "windows" key on PC and "command" key on Mac. + const SUPER = 0b100 << 9; + } +} + +impl ModifiersState { + /// Returns `true` if the shift key is pressed. + pub fn shift_key(&self) -> bool { + self.intersects(Self::SHIFT) + } + + /// Returns `true` if the control key is pressed. + pub fn control_key(&self) -> bool { + self.intersects(Self::CONTROL) + } + + /// Returns `true` if the alt key is pressed. + pub fn alt_key(&self) -> bool { + self.intersects(Self::ALT) + } + + /// Returns `true` if the super key is pressed. + pub fn super_key(&self) -> bool { + self.intersects(Self::SUPER) + } +} + +/// The state of the particular modifiers key. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModifiersKeyState { + /// The particular key is pressed. + Pressed, + /// The state of the key is unknown. + #[default] + Unknown, +} + +// NOTE: the exact modifier key is not used to represent modifiers state in the +// first place due to a fact that modifiers state could be changed without any +// key being pressed and on some platforms like Wayland/X11 which key resulted +// in modifiers change is hidden, also, not that it really matters. +// +// The reason this API is even exposed is mostly to provide a way for users +// to treat modifiers differently based on their position, which is required +// on macOS due to their AltGr/Option situation. +bitflags! { + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub(crate) struct ModifiersKeys: u8 { + const LSHIFT = 0b0000_0001; + const RSHIFT = 0b0000_0010; + const LCONTROL = 0b0000_0100; + const RCONTROL = 0b0000_1000; + const LALT = 0b0001_0000; + const RALT = 0b0010_0000; + const LSUPER = 0b0100_0000; + const RSUPER = 0b1000_0000; + } +} + +#[cfg(feature = "serde")] +mod modifiers_serde { + use super::ModifiersState; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + #[derive(Default, Serialize, Deserialize)] + #[serde(default)] + #[serde(rename = "ModifiersState")] + pub struct ModifiersStateSerialize { + pub shift_key: bool, + pub control_key: bool, + pub alt_key: bool, + pub super_key: bool, + } + + impl Serialize for ModifiersState { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let s = ModifiersStateSerialize { + shift_key: self.shift_key(), + control_key: self.control_key(), + alt_key: self.alt_key(), + super_key: self.super_key(), + }; + s.serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for ModifiersState { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let ModifiersStateSerialize { shift_key, control_key, alt_key, super_key } = + ModifiersStateSerialize::deserialize(deserializer)?; + let mut m = ModifiersState::empty(); + m.set(ModifiersState::SHIFT, shift_key); + m.set(ModifiersState::CONTROL, control_key); + m.set(ModifiersState::ALT, alt_key); + m.set(ModifiersState::SUPER, super_key); + Ok(m) + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/lib.rs b/third-party/winit-0.30.13-ferrex/src/lib.rs new file mode 100644 index 00000000..41a55b23 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/lib.rs @@ -0,0 +1,217 @@ +//! Winit is a cross-platform window creation and event loop management library. +//! +//! # Building windows +//! +//! Before you can create a [`Window`], you first need to build an [`EventLoop`]. This is done with +//! the [`EventLoop::new()`] function. +//! +//! ```no_run +//! use winit::event_loop::EventLoop; +//! +//! # // Intentionally use `fn main` for clarity +//! fn main() { +//! let event_loop = EventLoop::new().unwrap(); +//! // ... +//! } +//! ``` +//! +//! Then you create a [`Window`] with [`create_window`]. +//! +//! # Event handling +//! +//! Once a [`Window`] has been created, it will generate different *events*. A [`Window`] object can +//! generate [`WindowEvent`]s when certain input events occur, such as a cursor moving over the +//! window or a key getting pressed while the window is focused. Devices can generate +//! [`DeviceEvent`]s, which contain unfiltered event data that isn't specific to a certain window. +//! Some user activity, like mouse movement, can generate both a [`WindowEvent`] *and* a +//! [`DeviceEvent`]. You can also create and handle your own custom [`Event::UserEvent`]s, if +//! desired. +//! +//! You can retrieve events by calling [`EventLoop::run_app()`]. This function will +//! dispatch events for every [`Window`] that was created with that particular [`EventLoop`], and +//! will run until [`exit()`] is used, at which point [`Event::LoopExiting`]. +//! +//! Winit no longer uses a `EventLoop::poll_events() -> impl Iterator`-based event loop +//! model, since that can't be implemented properly on some platforms (e.g web, iOS) and works +//! poorly on most other platforms. However, this model can be re-implemented to an extent with +#![cfg_attr( + any(windows_platform, macos_platform, android_platform, x11_platform, wayland_platform), + doc = "[`EventLoopExtPumpEvents::pump_app_events()`][platform::pump_events::EventLoopExtPumpEvents::pump_app_events()]" +)] +#![cfg_attr( + not(any(windows_platform, macos_platform, android_platform, x11_platform, wayland_platform)), + doc = "`EventLoopExtPumpEvents::pump_app_events()`" +)] +//! [^1]. See that method's documentation for more reasons about why +//! it's discouraged beyond compatibility reasons. +//! +//! +//! ```no_run +//! use winit::application::ApplicationHandler; +//! use winit::event::WindowEvent; +//! use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +//! use winit::window::{Window, WindowId}; +//! +//! #[derive(Default)] +//! struct App { +//! window: Option, +//! } +//! +//! impl ApplicationHandler for App { +//! fn resumed(&mut self, event_loop: &ActiveEventLoop) { +//! self.window = Some(event_loop.create_window(Window::default_attributes()).unwrap()); +//! } +//! +//! fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) { +//! match event { +//! WindowEvent::CloseRequested => { +//! println!("The close button was pressed; stopping"); +//! event_loop.exit(); +//! }, +//! WindowEvent::RedrawRequested => { +//! // Redraw the application. +//! // +//! // It's preferable for applications that do not render continuously to render in +//! // this event rather than in AboutToWait, since rendering in here allows +//! // the program to gracefully handle redraws requested by the OS. +//! +//! // Draw. +//! +//! // Queue a RedrawRequested event. +//! // +//! // You only need to call this if you've determined that you need to redraw in +//! // applications which do not always need to. Applications that redraw continuously +//! // can render here instead. +//! self.window.as_ref().unwrap().request_redraw(); +//! } +//! _ => (), +//! } +//! } +//! } +//! +//! # // Intentionally use `fn main` for clarity +//! fn main() { +//! let event_loop = EventLoop::new().unwrap(); +//! +//! // ControlFlow::Poll continuously runs the event loop, even if the OS hasn't +//! // dispatched any events. This is ideal for games and similar applications. +//! event_loop.set_control_flow(ControlFlow::Poll); +//! +//! // ControlFlow::Wait pauses the event loop if no events are available to process. +//! // This is ideal for non-game applications that only update in response to user +//! // input, and uses significantly less power/CPU time than ControlFlow::Poll. +//! event_loop.set_control_flow(ControlFlow::Wait); +//! +//! let mut app = App::default(); +//! event_loop.run_app(&mut app); +//! } +//! ``` +//! +//! [`WindowEvent`] has a [`WindowId`] member. In multi-window environments, it should be +//! compared to the value returned by [`Window::id()`] to determine which [`Window`] +//! dispatched the event. +//! +//! # Drawing on the window +//! +//! Winit doesn't directly provide any methods for drawing on a [`Window`]. However, it allows you +//! to retrieve the raw handle of the window and display (see the [`platform`] module and/or the +//! [`raw_window_handle`] and [`raw_display_handle`] methods), which in turn allows +//! you to create an OpenGL/Vulkan/DirectX/Metal/etc. context that can be used to render graphics. +//! +//! Note that many platforms will display garbage data in the window's client area if the +//! application doesn't render anything to the window by the time the desktop compositor is ready to +//! display the window to the user. If you notice this happening, you should create the window with +//! [`visible` set to `false`][crate::window::WindowAttributes::with_visible] and explicitly make +//! the window visible only once you're ready to render into it. +//! +//! # UI scaling +//! +//! UI scaling is important, go read the docs for the [`dpi`] crate for an +//! introduction. +//! +//! All of Winit's functions return physical types, but can take either logical or physical +//! coordinates as input, allowing you to use the most convenient coordinate system for your +//! particular application. +//! +//! Winit will dispatch a [`ScaleFactorChanged`] event whenever a window's scale factor has changed. +//! This can happen if the user drags their window from a standard-resolution monitor to a high-DPI +//! monitor or if the user changes their DPI settings. This allows you to rescale your application's +//! UI elements and adjust how the platform changes the window's size to reflect the new scale +//! factor. If a window hasn't received a [`ScaleFactorChanged`] event, its scale factor +//! can be found by calling [`window.scale_factor()`]. +//! +//! [`ScaleFactorChanged`]: event::WindowEvent::ScaleFactorChanged +//! [`window.scale_factor()`]: window::Window::scale_factor +//! +//! # Cargo Features +//! +//! Winit provides the following Cargo features: +//! +//! * `x11` (enabled by default): On Unix platforms, enables the X11 backend. +//! * `wayland` (enabled by default): On Unix platforms, enables the Wayland backend. +//! * `rwh_04`: Implement `raw-window-handle v0.4` traits. +//! * `rwh_05`: Implement `raw-window-handle v0.5` traits. +//! * `rwh_06`: Implement `raw-window-handle v0.6` traits. +//! * `serde`: Enables serialization/deserialization of certain types with [Serde](https://crates.io/crates/serde). +//! * `mint`: Enables mint (math interoperability standard types) conversions. +//! +//! See the [`platform`] module for documentation on platform-specific cargo +//! features. +//! +//! [`EventLoop`]: event_loop::EventLoop +//! [`EventLoop::new()`]: event_loop::EventLoop::new +//! [`EventLoop::run_app()`]: event_loop::EventLoop::run_app +//! [`exit()`]: event_loop::ActiveEventLoop::exit +//! [`Window`]: window::Window +//! [`WindowId`]: window::WindowId +//! [`WindowAttributes`]: window::WindowAttributes +//! [`create_window`]: event_loop::ActiveEventLoop::create_window +//! [`Window::id()`]: window::Window::id +//! [`WindowEvent`]: event::WindowEvent +//! [`DeviceEvent`]: event::DeviceEvent +//! [`Event::UserEvent`]: event::Event::UserEvent +//! [`Event::LoopExiting`]: event::Event::LoopExiting +//! [`raw_window_handle`]: ./window/struct.Window.html#method.raw_window_handle +//! [`raw_display_handle`]: ./window/struct.Window.html#method.raw_display_handle +//! [^1]: `EventLoopExtPumpEvents::pump_app_events()` is only available on Windows, macOS, Android, X11 and Wayland. + +#![deny(rust_2018_idioms)] +#![deny(rustdoc::broken_intra_doc_links)] +#![deny(clippy::all)] +#![deny(unsafe_op_in_unsafe_fn)] +#![cfg_attr(clippy, deny(warnings))] +// Doc feature labels can be tested locally by running RUSTDOCFLAGS="--cfg=docsrs" cargo +nightly +// doc +#![cfg_attr(docsrs, feature(doc_cfg), doc(auto_cfg(hide(doc, docsrs))))] +#![allow(clippy::missing_safety_doc)] +#![warn(clippy::uninlined_format_args)] +// TODO: wasm-binding needs to be updated for that to be resolved, for now just silence it. +#![cfg_attr(web_platform, allow(unknown_lints, renamed_and_removed_lints, wasm_c_abi))] + +#[cfg(feature = "rwh_04")] +pub use rwh_04 as raw_window_handle_04; +#[cfg(feature = "rwh_05")] +pub use rwh_05 as raw_window_handle_05; +#[cfg(feature = "rwh_06")] +pub use rwh_06 as raw_window_handle; + +// Re-export DPI types so that users don't have to put it in Cargo.toml. +#[doc(inline)] +pub use dpi; + +pub mod application; +#[cfg(any(doc, doctest, test))] +pub mod changelog; +#[macro_use] +pub mod error; +mod cursor; +pub mod event; +pub mod event_loop; +mod icon; +pub mod keyboard; +pub mod monitor; +mod platform_impl; +mod utils; +pub mod window; + +pub mod platform; diff --git a/third-party/winit-0.30.13-ferrex/src/monitor.rs b/third-party/winit-0.30.13-ferrex/src/monitor.rs new file mode 100644 index 00000000..ed987e25 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/monitor.rs @@ -0,0 +1,167 @@ +//! Types useful for interacting with a user's monitors. +//! +//! If you want to get basic information about a monitor, you can use the +//! [`MonitorHandle`] type. This is retrieved from one of the following +//! methods, which return an iterator of [`MonitorHandle`]: +//! - [`ActiveEventLoop::available_monitors`][crate::event_loop::ActiveEventLoop::available_monitors]. +//! - [`Window::available_monitors`][crate::window::Window::available_monitors]. +use crate::dpi::{PhysicalPosition, PhysicalSize}; +use crate::platform_impl; + +/// Deprecated! Use `VideoModeHandle` instead. +#[deprecated = "Renamed to `VideoModeHandle`"] +pub type VideoMode = VideoModeHandle; + +/// Describes a fullscreen video mode of a monitor. +/// +/// Can be acquired with [`MonitorHandle::video_modes`]. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct VideoModeHandle { + pub(crate) video_mode: platform_impl::VideoModeHandle, +} + +impl std::fmt::Debug for VideoModeHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.video_mode.fmt(f) + } +} + +impl PartialOrd for VideoModeHandle { + fn partial_cmp(&self, other: &VideoModeHandle) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for VideoModeHandle { + fn cmp(&self, other: &VideoModeHandle) -> std::cmp::Ordering { + self.monitor().cmp(&other.monitor()).then( + self.size() + .cmp(&other.size()) + .then( + self.refresh_rate_millihertz() + .cmp(&other.refresh_rate_millihertz()) + .then(self.bit_depth().cmp(&other.bit_depth())), + ) + .reverse(), + ) + } +} + +impl VideoModeHandle { + /// Returns the resolution of this video mode. + #[inline] + pub fn size(&self) -> PhysicalSize { + self.video_mode.size() + } + + /// Returns the bit depth of this video mode, as in how many bits you have + /// available per color. This is generally 24 bits or 32 bits on modern + /// systems, depending on whether the alpha channel is counted or not. + /// + /// ## Platform-specific + /// + /// - **Wayland / Orbital:** Always returns 32. + /// - **iOS:** Always returns 32. + #[inline] + pub fn bit_depth(&self) -> u16 { + self.video_mode.bit_depth() + } + + /// Returns the refresh rate of this video mode in mHz. + #[inline] + pub fn refresh_rate_millihertz(&self) -> u32 { + self.video_mode.refresh_rate_millihertz() + } + + /// Returns the monitor that this video mode is valid for. Each monitor has + /// a separate set of valid video modes. + #[inline] + pub fn monitor(&self) -> MonitorHandle { + MonitorHandle { inner: self.video_mode.monitor() } + } +} + +impl std::fmt::Display for VideoModeHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}x{} @ {} mHz ({} bpp)", + self.size().width, + self.size().height, + self.refresh_rate_millihertz(), + self.bit_depth() + ) + } +} + +/// Handle to a monitor. +/// +/// Allows you to retrieve information about a given monitor and can be used in [`Window`] creation. +/// +/// [`Window`]: crate::window::Window +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct MonitorHandle { + pub(crate) inner: platform_impl::MonitorHandle, +} + +impl MonitorHandle { + /// Returns a human-readable name of the monitor. + /// + /// Returns `None` if the monitor doesn't exist anymore. + #[inline] + pub fn name(&self) -> Option { + self.inner.name() + } + + /// Returns the monitor's resolution. + #[inline] + pub fn size(&self) -> PhysicalSize { + self.inner.size() + } + + /// Returns the top-left corner position of the monitor relative to the larger full + /// screen area. + #[inline] + pub fn position(&self) -> PhysicalPosition { + self.inner.position() + } + + /// The monitor refresh rate used by the system. + /// + /// Return `Some` if succeed, or `None` if failed, which usually happens when the monitor + /// the window is on is removed. + /// + /// When using exclusive fullscreen, the refresh rate of the [`VideoModeHandle`] that was + /// used to enter fullscreen should be used instead. + #[inline] + pub fn refresh_rate_millihertz(&self) -> Option { + self.inner.refresh_rate_millihertz() + } + + /// Returns the scale factor of the underlying monitor. To map logical pixels to physical + /// pixels and vice versa, use [`Window::scale_factor`]. + /// + /// See the [`dpi`] module for more information. + /// + /// ## Platform-specific + /// + /// - **X11:** Can be overridden using the `WINIT_X11_SCALE_FACTOR` environment variable. + /// - **Wayland:** May differ from [`Window::scale_factor`]. + /// - **Android:** Always returns 1.0. + /// + /// [`Window::scale_factor`]: crate::window::Window::scale_factor + #[inline] + pub fn scale_factor(&self) -> f64 { + self.inner.scale_factor() + } + + /// Returns all fullscreen video modes supported by this monitor. + /// + /// ## Platform-specific + /// + /// - **Web:** Always returns an empty iterator + #[inline] + pub fn video_modes(&self) -> impl Iterator { + self.inner.video_modes().map(|video_mode| VideoModeHandle { video_mode }) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/android.rs b/third-party/winit-0.30.13-ferrex/src/platform/android.rs new file mode 100644 index 00000000..b76159c0 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/android.rs @@ -0,0 +1,188 @@ +//! # Android +//! +//! The Android backend builds on (and exposes types from) the [`ndk`](https://docs.rs/ndk/) crate. +//! +//! Native Android applications need some form of "glue" crate that is responsible +//! for defining the main entry point for your Rust application as well as tracking +//! various life-cycle events and synchronizing with the main JVM thread. +//! +//! Winit uses the [android-activity](https://docs.rs/android-activity/) as a +//! glue crate (prior to `0.28` it used +//! [ndk-glue](https://github.com/rust-windowing/android-ndk-rs/tree/master/ndk-glue)). +//! +//! The version of the glue crate that your application depends on _must_ match the +//! version that Winit depends on because the glue crate is responsible for your +//! application's main entry point. If Cargo resolves multiple versions, they will +//! clash. +//! +//! `winit` glue compatibility table: +//! +//! | winit | ndk-glue | +//! | :---: | :--------------------------: | +//! | 0.30 | `android-activity = "0.6"` | +//! | 0.29 | `android-activity = "0.5"` | +//! | 0.28 | `android-activity = "0.4"` | +//! | 0.27 | `ndk-glue = "0.7"` | +//! | 0.26 | `ndk-glue = "0.5"` | +//! | 0.25 | `ndk-glue = "0.3"` | +//! | 0.24 | `ndk-glue = "0.2"` | +//! +//! The recommended way to avoid a conflict with the glue version is to avoid explicitly +//! depending on the `android-activity` crate, and instead consume the API that +//! is re-exported by Winit under `winit::platform::android::activity::*` +//! +//! Running on an Android device needs a dynamic system library. Add this to Cargo.toml: +//! +//! ```toml +//! [lib] +//! name = "main" +//! crate-type = ["cdylib"] +//! ``` +//! +//! All Android applications are based on an `Activity` subclass, and the +//! `android-activity` crate is designed to support different choices for this base +//! class. Your application _must_ specify the base class it needs via a feature flag: +//! +//! | Base Class | Feature Flag | Notes | +//! | :--------------: | :---------------: | :-----: | +//! | `NativeActivity` | `android-native-activity` | Built-in to Android - it is possible to use without compiling any Java or Kotlin code. Java or Kotlin code may be needed to subclass `NativeActivity` to access some platform features. It does not derive from the [`AndroidAppCompat`] base class.| +//! | [`GameActivity`] | `android-game-activity` | Derives from [`AndroidAppCompat`], a defacto standard `Activity` base class that helps support a wider range of Android versions. Requires a build system that can compile Java or Kotlin and fetch Android dependencies from a [Maven repository][agdk_jetpack] (or link with an embedded [release][agdk_releases] of [`GameActivity`]) | +//! +//! [`GameActivity`]: https://developer.android.com/games/agdk/game-activity +//! [`GameTextInput`]: https://developer.android.com/games/agdk/add-support-for-text-input +//! [`AndroidAppCompat`]: https://developer.android.com/reference/androidx/appcompat/app/AppCompatActivity +//! [agdk_jetpack]: https://developer.android.com/jetpack/androidx/releases/games +//! [agdk_releases]: https://developer.android.com/games/agdk/download#agdk-libraries +//! [Gradle]: https://developer.android.com/studio/build +//! +//! For more details, refer to these `android-activity` [example applications](https://github.com/rust-mobile/android-activity/tree/main/examples). +//! +//! ## Converting from `ndk-glue` to `android-activity` +//! +//! If your application is currently based on `NativeActivity` via the `ndk-glue` crate and building +//! with `cargo apk`, then the minimal changes would be: +//! 1. Remove `ndk-glue` from your `Cargo.toml` +//! 2. Enable the `"android-native-activity"` feature for Winit: `winit = { version = "0.30.13", +//! features = [ "android-native-activity" ] }` +//! 3. Add an `android_main` entrypoint (as above), instead of using the '`[ndk_glue::main]` proc +//! macro from `ndk-macros` (optionally add a dependency on `android_logger` and initialize +//! logging as above). +//! 4. Pass a clone of the `AndroidApp` that your application receives to Winit when building your +//! event loop (as shown above). + +use crate::event_loop::{ActiveEventLoop, EventLoop, EventLoopBuilder}; +use crate::window::{Window, WindowAttributes}; + +use self::activity::{AndroidApp, ConfigurationRef, Rect}; + +/// Additional methods on [`EventLoop`] that are specific to Android. +pub trait EventLoopExtAndroid { + /// Get the [`AndroidApp`] which was used to create this event loop. + fn android_app(&self) -> &AndroidApp; +} + +impl EventLoopExtAndroid for EventLoop { + fn android_app(&self) -> &AndroidApp { + &self.event_loop.android_app + } +} + +/// Additional methods on [`ActiveEventLoop`] that are specific to Android. +pub trait ActiveEventLoopExtAndroid { + /// Get the [`AndroidApp`] which was used to create this event loop. + fn android_app(&self) -> &AndroidApp; +} + +/// Additional methods on [`Window`] that are specific to Android. +pub trait WindowExtAndroid { + fn content_rect(&self) -> Rect; + + fn config(&self) -> ConfigurationRef; +} + +impl WindowExtAndroid for Window { + fn content_rect(&self) -> Rect { + self.window.content_rect() + } + + fn config(&self) -> ConfigurationRef { + self.window.config() + } +} + +impl ActiveEventLoopExtAndroid for ActiveEventLoop { + fn android_app(&self) -> &AndroidApp { + &self.p.app + } +} + +/// Additional methods on [`WindowAttributes`] that are specific to Android. +pub trait WindowAttributesExtAndroid {} + +impl WindowAttributesExtAndroid for WindowAttributes {} + +pub trait EventLoopBuilderExtAndroid { + /// Associates the [`AndroidApp`] that was passed to `android_main()` with the event loop + /// + /// This must be called on Android since the [`AndroidApp`] is not global state. + fn with_android_app(&mut self, app: AndroidApp) -> &mut Self; + + /// Calling this will mark the volume keys to be manually handled by the application + /// + /// Default is to let the operating system handle the volume keys + fn handle_volume_keys(&mut self) -> &mut Self; +} + +impl EventLoopBuilderExtAndroid for EventLoopBuilder { + fn with_android_app(&mut self, app: AndroidApp) -> &mut Self { + self.platform_specific.android_app = Some(app); + self + } + + fn handle_volume_keys(&mut self) -> &mut Self { + self.platform_specific.ignore_volume_keys = false; + self + } +} + +/// Re-export of the `android_activity` API +/// +/// Winit re-exports the `android_activity` API for convenience so that most +/// applications can rely on the Winit crate to resolve the required version of +/// `android_activity` and avoid any chance of a conflict between Winit and the +/// application crate. +/// +/// Unlike most libraries there can only be a single implementation +/// of the `android_activity` glue crate linked with an application because +/// it is responsible for the application's `android_main()` entry point. +/// +/// Since Winit depends on a specific version of `android_activity` the simplest +/// way to avoid creating a conflict is for applications to avoid explicitly +/// depending on the `android_activity` crate, and instead consume the API that +/// is re-exported by Winit. +/// +/// For compatibility applications should then import the [`AndroidApp`] type for +/// their `android_main(app: AndroidApp)` function like: +/// ```rust +/// #[cfg(target_os = "android")] +/// use winit::platform::android::activity::AndroidApp; +/// ``` +pub mod activity { + // We enable the `"native-activity"` feature just so that we can build the + // docs, but it'll be very confusing for users to see the docs with that + // feature enabled, so we avoid inlining it so that they're forced to view + // it on the crate's own docs.rs page. + #[doc(no_inline)] + #[cfg(android_platform)] + pub use android_activity::*; + + #[cfg(not(android_platform))] + #[doc(hidden)] + pub struct Rect; + #[cfg(not(android_platform))] + #[doc(hidden)] + pub struct ConfigurationRef; + #[cfg(not(android_platform))] + #[doc(hidden)] + pub struct AndroidApp; +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/ios.rs b/third-party/winit-0.30.13-ferrex/src/platform/ios.rs new file mode 100644 index 00000000..6e628768 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/ios.rs @@ -0,0 +1,435 @@ +//! # iOS / UIKit +//! +//! Winit has an OS requirement of iOS 8 or higher, and is regularly tested on +//! iOS 9.3. +//! +//! ## Window initialization +//! +//! iOS's main `UIApplicationMain` does some init work that's required by all +//! UI-related code (see issue [#1705]). It is best to create your windows +//! inside [`ApplicationHandler::resumed`]. +//! +//! [#1705]: https://github.com/rust-windowing/winit/issues/1705 +//! [`ApplicationHandler::resumed`]: crate::application::ApplicationHandler::resumed +//! +//! ## Building app +//! +//! To build ios app you will need rustc built for this targets: +//! +//! - armv7-apple-ios +//! - armv7s-apple-ios +//! - i386-apple-ios +//! - aarch64-apple-ios +//! - x86_64-apple-ios +//! +//! Then +//! +//! ``` +//! cargo build --target=... +//! ``` +//! The simplest way to integrate your app into xcode environment is to build it +//! as a static library. Wrap your main function and export it. +//! +//! ```rust, ignore +//! #[no_mangle] +//! pub extern fn start_winit_app() { +//! start_inner() +//! } +//! +//! fn start_inner() { +//! ... +//! } +//! ``` +//! +//! Compile project and then drag resulting .a into Xcode project. Add winit.h to xcode. +//! +//! ```ignore +//! void start_winit_app(); +//! ``` +//! +//! Use start_winit_app inside your xcode's main function. +//! +//! +//! ## App lifecycle and events +//! +//! iOS environment is very different from other platforms and you must be very +//! careful with it's events. Familiarize yourself with +//! [app lifecycle](https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIApplicationDelegate_Protocol/). +//! +//! This is how those event are represented in winit: +//! +//! - applicationDidBecomeActive is Resumed +//! - applicationWillResignActive is Suspended +//! - applicationWillTerminate is LoopExiting +//! +//! Keep in mind that after LoopExiting event is received every attempt to draw with +//! opengl will result in segfault. +//! +//! Also note that app may not receive the LoopExiting event if suspended; it might be SIGKILL'ed. +//! +//! ## Custom `UIApplicationDelegate` +//! +//! Winit usually handles everything related to the lifecycle events of the application. Sometimes, +//! though, you might want to access some of the more niche stuff that [the application +//! delegate][app-delegate] provides. This functionality is not exposed directly in Winit, since it +//! would increase the API surface by quite a lot. Instead, Winit guarantees that it will not +//! register an application delegate, so you can set up a custom one in a nib file instead. +//! +//! [app-delegate]: https://developer.apple.com/documentation/uikit/uiapplicationdelegate?language=objc + +use std::os::raw::c_void; + +use crate::event_loop::EventLoop; +use crate::monitor::{MonitorHandle, VideoModeHandle}; +use crate::window::{Window, WindowAttributes}; + +/// Additional methods on [`EventLoop`] that are specific to iOS. +pub trait EventLoopExtIOS { + /// Returns the [`Idiom`] (phone/tablet/tv/etc) for the current device. + fn idiom(&self) -> Idiom; +} + +impl EventLoopExtIOS for EventLoop { + fn idiom(&self) -> Idiom { + self.event_loop.idiom() + } +} + +/// Additional methods on [`Window`] that are specific to iOS. +pub trait WindowExtIOS { + /// Sets the [`contentScaleFactor`] of the underlying [`UIWindow`] to `scale_factor`. + /// + /// The default value is device dependent, and it's recommended GLES or Metal applications set + /// this to [`MonitorHandle::scale_factor()`]. + /// + /// [`UIWindow`]: https://developer.apple.com/documentation/uikit/uiwindow?language=objc + /// [`contentScaleFactor`]: https://developer.apple.com/documentation/uikit/uiview/1622657-contentscalefactor?language=objc + fn set_scale_factor(&self, scale_factor: f64); + + /// Sets the valid orientations for the [`Window`]. + /// + /// The default value is [`ValidOrientations::LandscapeAndPortrait`]. + /// + /// This changes the value returned by + /// [`-[UIViewController supportedInterfaceOrientations]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621435-supportedinterfaceorientations?language=objc), + /// and then calls + /// [`-[UIViewController attemptRotationToDeviceOrientation]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621400-attemptrotationtodeviceorientati?language=objc). + fn set_valid_orientations(&self, valid_orientations: ValidOrientations); + + /// Sets whether the [`Window`] prefers the home indicator hidden. + /// + /// The default is to prefer showing the home indicator. + /// + /// This changes the value returned by + /// [`-[UIViewController prefersHomeIndicatorAutoHidden]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887510-prefershomeindicatorautohidden?language=objc), + /// and then calls + /// [`-[UIViewController setNeedsUpdateOfHomeIndicatorAutoHidden]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887509-setneedsupdateofhomeindicatoraut?language=objc). + /// + /// This only has an effect on iOS 11.0+. + fn set_prefers_home_indicator_hidden(&self, hidden: bool); + + /// Sets the screen edges for which the system gestures will take a lower priority than the + /// application's touch handling. + /// + /// This changes the value returned by + /// [`-[UIViewController preferredScreenEdgesDeferringSystemGestures]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887512-preferredscreenedgesdeferringsys?language=objc), + /// and then calls + /// [`-[UIViewController setNeedsUpdateOfScreenEdgesDeferringSystemGestures]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887507-setneedsupdateofscreenedgesdefer?language=objc). + /// + /// This only has an effect on iOS 11.0+. + fn set_preferred_screen_edges_deferring_system_gestures(&self, edges: ScreenEdge); + + /// Sets whether the [`Window`] prefers the status bar hidden. + /// + /// The default is to prefer showing the status bar. + /// + /// This sets the value of the + /// [`prefersStatusBarHidden`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621440-prefersstatusbarhidden?language=objc) + /// property. + /// + /// [`setNeedsStatusBarAppearanceUpdate()`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621354-setneedsstatusbarappearanceupdat?language=objc) + /// is also called for you. + fn set_prefers_status_bar_hidden(&self, hidden: bool); + + /// Sets the preferred status bar style for the [`Window`]. + /// + /// The default is system-defined. + /// + /// This sets the value of the + /// [`preferredStatusBarStyle`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621416-preferredstatusbarstyle?language=objc) + /// property. + /// + /// [`setNeedsStatusBarAppearanceUpdate()`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621354-setneedsstatusbarappearanceupdat?language=objc) + /// is also called for you. + fn set_preferred_status_bar_style(&self, status_bar_style: StatusBarStyle); + + /// Sets whether the [`Window`] should recognize pinch gestures. + /// + /// The default is to not recognize gestures. + fn recognize_pinch_gesture(&self, should_recognize: bool); + + /// Sets whether the [`Window`] should recognize pan gestures. + /// + /// The default is to not recognize gestures. + /// Installs [`UIPanGestureRecognizer`](https://developer.apple.com/documentation/uikit/uipangesturerecognizer) onto view + /// + /// Set the minimum number of touches required: [`minimumNumberOfTouches`](https://developer.apple.com/documentation/uikit/uipangesturerecognizer/1621208-minimumnumberoftouches) + /// + /// Set the maximum number of touches recognized: [`maximumNumberOfTouches`](https://developer.apple.com/documentation/uikit/uipangesturerecognizer/1621208-maximumnumberoftouches) + fn recognize_pan_gesture( + &self, + should_recognize: bool, + minimum_number_of_touches: u8, + maximum_number_of_touches: u8, + ); + + /// Sets whether the [`Window`] should recognize double tap gestures. + /// + /// The default is to not recognize gestures. + fn recognize_doubletap_gesture(&self, should_recognize: bool); + + /// Sets whether the [`Window`] should recognize rotation gestures. + /// + /// The default is to not recognize gestures. + fn recognize_rotation_gesture(&self, should_recognize: bool); +} + +impl WindowExtIOS for Window { + #[inline] + fn set_scale_factor(&self, scale_factor: f64) { + self.window.maybe_queue_on_main(move |w| w.set_scale_factor(scale_factor)) + } + + #[inline] + fn set_valid_orientations(&self, valid_orientations: ValidOrientations) { + self.window.maybe_queue_on_main(move |w| w.set_valid_orientations(valid_orientations)) + } + + #[inline] + fn set_prefers_home_indicator_hidden(&self, hidden: bool) { + self.window.maybe_queue_on_main(move |w| w.set_prefers_home_indicator_hidden(hidden)) + } + + #[inline] + fn set_preferred_screen_edges_deferring_system_gestures(&self, edges: ScreenEdge) { + self.window.maybe_queue_on_main(move |w| { + w.set_preferred_screen_edges_deferring_system_gestures(edges) + }) + } + + #[inline] + fn set_prefers_status_bar_hidden(&self, hidden: bool) { + self.window.maybe_queue_on_main(move |w| w.set_prefers_status_bar_hidden(hidden)) + } + + #[inline] + fn set_preferred_status_bar_style(&self, status_bar_style: StatusBarStyle) { + self.window.maybe_queue_on_main(move |w| w.set_preferred_status_bar_style(status_bar_style)) + } + + #[inline] + fn recognize_pinch_gesture(&self, should_recognize: bool) { + self.window.maybe_queue_on_main(move |w| w.recognize_pinch_gesture(should_recognize)); + } + + #[inline] + fn recognize_pan_gesture( + &self, + should_recognize: bool, + minimum_number_of_touches: u8, + maximum_number_of_touches: u8, + ) { + self.window.maybe_queue_on_main(move |w| { + w.recognize_pan_gesture( + should_recognize, + minimum_number_of_touches, + maximum_number_of_touches, + ) + }); + } + + #[inline] + fn recognize_doubletap_gesture(&self, should_recognize: bool) { + self.window.maybe_queue_on_main(move |w| w.recognize_doubletap_gesture(should_recognize)); + } + + #[inline] + fn recognize_rotation_gesture(&self, should_recognize: bool) { + self.window.maybe_queue_on_main(move |w| w.recognize_rotation_gesture(should_recognize)); + } +} + +/// Additional methods on [`WindowAttributes`] that are specific to iOS. +pub trait WindowAttributesExtIOS { + /// Sets the [`contentScaleFactor`] of the underlying [`UIWindow`] to `scale_factor`. + /// + /// The default value is device dependent, and it's recommended GLES or Metal applications set + /// this to [`MonitorHandle::scale_factor()`]. + /// + /// [`UIWindow`]: https://developer.apple.com/documentation/uikit/uiwindow?language=objc + /// [`contentScaleFactor`]: https://developer.apple.com/documentation/uikit/uiview/1622657-contentscalefactor?language=objc + fn with_scale_factor(self, scale_factor: f64) -> Self; + + /// Sets the valid orientations for the [`Window`]. + /// + /// The default value is [`ValidOrientations::LandscapeAndPortrait`]. + /// + /// This sets the initial value returned by + /// [`-[UIViewController supportedInterfaceOrientations]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621435-supportedinterfaceorientations?language=objc). + fn with_valid_orientations(self, valid_orientations: ValidOrientations) -> Self; + + /// Sets whether the [`Window`] prefers the home indicator hidden. + /// + /// The default is to prefer showing the home indicator. + /// + /// This sets the initial value returned by + /// [`-[UIViewController prefersHomeIndicatorAutoHidden]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887510-prefershomeindicatorautohidden?language=objc). + /// + /// This only has an effect on iOS 11.0+. + fn with_prefers_home_indicator_hidden(self, hidden: bool) -> Self; + + /// Sets the screen edges for which the system gestures will take a lower priority than the + /// application's touch handling. + /// + /// This sets the initial value returned by + /// [`-[UIViewController preferredScreenEdgesDeferringSystemGestures]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/2887512-preferredscreenedgesdeferringsys?language=objc). + /// + /// This only has an effect on iOS 11.0+. + fn with_preferred_screen_edges_deferring_system_gestures(self, edges: ScreenEdge) -> Self; + + /// Sets whether the [`Window`] prefers the status bar hidden. + /// + /// The default is to prefer showing the status bar. + /// + /// This sets the initial value returned by + /// [`-[UIViewController prefersStatusBarHidden]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621440-prefersstatusbarhidden?language=objc). + fn with_prefers_status_bar_hidden(self, hidden: bool) -> Self; + + /// Sets the style of the [`Window`]'s status bar. + /// + /// The default is system-defined. + /// + /// This sets the initial value returned by + /// [`-[UIViewController preferredStatusBarStyle]`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621416-preferredstatusbarstyle?language=objc), + fn with_preferred_status_bar_style(self, status_bar_style: StatusBarStyle) -> Self; +} + +impl WindowAttributesExtIOS for WindowAttributes { + #[inline] + fn with_scale_factor(mut self, scale_factor: f64) -> Self { + self.platform_specific.scale_factor = Some(scale_factor); + self + } + + #[inline] + fn with_valid_orientations(mut self, valid_orientations: ValidOrientations) -> Self { + self.platform_specific.valid_orientations = valid_orientations; + self + } + + #[inline] + fn with_prefers_home_indicator_hidden(mut self, hidden: bool) -> Self { + self.platform_specific.prefers_home_indicator_hidden = hidden; + self + } + + #[inline] + fn with_preferred_screen_edges_deferring_system_gestures(mut self, edges: ScreenEdge) -> Self { + self.platform_specific.preferred_screen_edges_deferring_system_gestures = edges; + self + } + + #[inline] + fn with_prefers_status_bar_hidden(mut self, hidden: bool) -> Self { + self.platform_specific.prefers_status_bar_hidden = hidden; + self + } + + #[inline] + fn with_preferred_status_bar_style(mut self, status_bar_style: StatusBarStyle) -> Self { + self.platform_specific.preferred_status_bar_style = status_bar_style; + self + } +} + +/// Additional methods on [`MonitorHandle`] that are specific to iOS. +pub trait MonitorHandleExtIOS { + /// Returns a pointer to the [`UIScreen`] that is used by this monitor. + /// + /// [`UIScreen`]: https://developer.apple.com/documentation/uikit/uiscreen?language=objc + fn ui_screen(&self) -> *mut c_void; + + /// Returns the preferred [`VideoModeHandle`] for this monitor. + /// + /// This translates to a call to [`-[UIScreen preferredMode]`](https://developer.apple.com/documentation/uikit/uiscreen/1617823-preferredmode?language=objc). + fn preferred_video_mode(&self) -> VideoModeHandle; +} + +impl MonitorHandleExtIOS for MonitorHandle { + #[inline] + fn ui_screen(&self) -> *mut c_void { + // SAFETY: The marker is only used to get the pointer of the screen + let mtm = unsafe { objc2_foundation::MainThreadMarker::new_unchecked() }; + objc2::rc::Retained::as_ptr(self.inner.ui_screen(mtm)) as *mut c_void + } + + #[inline] + fn preferred_video_mode(&self) -> VideoModeHandle { + VideoModeHandle { video_mode: self.inner.preferred_video_mode() } + } +} + +/// Valid orientations for a particular [`Window`]. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ValidOrientations { + /// Excludes `PortraitUpsideDown` on iphone + #[default] + LandscapeAndPortrait, + + Landscape, + + /// Excludes `PortraitUpsideDown` on iphone + Portrait, +} + +/// The device [idiom]. +/// +/// [idiom]: https://developer.apple.com/documentation/uikit/uidevice/1620037-userinterfaceidiom?language=objc +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Idiom { + Unspecified, + + /// iPhone and iPod touch. + Phone, + + /// iPad. + Pad, + + /// tvOS and Apple TV. + TV, + CarPlay, +} + +bitflags::bitflags! { + /// The [edges] of a screen. + /// + /// [edges]: https://developer.apple.com/documentation/uikit/uirectedge?language=objc + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct ScreenEdge: u8 { + const NONE = 0; + const TOP = 1 << 0; + const LEFT = 1 << 1; + const BOTTOM = 1 << 2; + const RIGHT = 1 << 3; + const ALL = ScreenEdge::TOP.bits() | ScreenEdge::LEFT.bits() + | ScreenEdge::BOTTOM.bits() | ScreenEdge::RIGHT.bits(); + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum StatusBarStyle { + #[default] + Default, + LightContent, + DarkContent, +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/macos.rs b/third-party/winit-0.30.13-ferrex/src/platform/macos.rs new file mode 100644 index 00000000..30623b01 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/macos.rs @@ -0,0 +1,531 @@ +//! # macOS / AppKit +//! +//! Winit has an OS requirement of macOS 10.11 or higher (same as Rust +//! itself), and is regularly tested on macOS 10.14. +//! +//! ## Window initialization +//! +//! A lot of functionality expects the application to be ready before you +//! start doing anything; this includes creating windows, fetching monitors, +//! drawing, and so on, see issues [#2238], [#2051] and [#2087]. +//! +//! If you encounter problems, you should try doing your initialization inside +//! [`ApplicationHandler::resumed`]. +//! +//! [#2238]: https://github.com/rust-windowing/winit/issues/2238 +//! [#2051]: https://github.com/rust-windowing/winit/issues/2051 +//! [#2087]: https://github.com/rust-windowing/winit/issues/2087 +//! [`ApplicationHandler::resumed`]: crate::application::ApplicationHandler::resumed +//! +//! ## Custom `NSApplicationDelegate` +//! +//! Winit usually handles everything related to the lifecycle events of the application. Sometimes, +//! though, you might want to do more niche stuff, such as [handle when the user re-activates the +//! application][reopen]. Such functionality is not exposed directly in Winit, since it would +//! increase the API surface by quite a lot. +//! +//! [reopen]: https://developer.apple.com/documentation/appkit/nsapplicationdelegate/1428638-applicationshouldhandlereopen?language=objc +//! +//! Instead, Winit guarantees that it will not register an application delegate, so the solution is +//! to register your own application delegate, as outlined in the following example (see +//! `objc2-app-kit` for more detailed information). +#![cfg_attr(target_os = "macos", doc = "```")] +#![cfg_attr(not(target_os = "macos"), doc = "```ignore")] +//! use objc2::rc::Retained; +//! use objc2::runtime::ProtocolObject; +//! use objc2::{declare_class, msg_send_id, mutability, ClassType, DeclaredClass}; +//! use objc2_app_kit::{NSApplication, NSApplicationDelegate}; +//! use objc2_foundation::{NSArray, NSURL, MainThreadMarker, NSObject, NSObjectProtocol}; +//! use winit::event_loop::EventLoop; +//! +//! declare_class!( +//! struct AppDelegate; +//! +//! unsafe impl ClassType for AppDelegate { +//! type Super = NSObject; +//! type Mutability = mutability::MainThreadOnly; +//! const NAME: &'static str = "MyAppDelegate"; +//! } +//! +//! impl DeclaredClass for AppDelegate {} +//! +//! unsafe impl NSObjectProtocol for AppDelegate {} +//! +//! unsafe impl NSApplicationDelegate for AppDelegate { +//! #[method(application:openURLs:)] +//! fn application_openURLs(&self, application: &NSApplication, urls: &NSArray) { +//! // Note: To specifically get `application:openURLs:` to work, you _might_ +//! // have to bundle your application. This is not done in this example. +//! println!("open urls: {application:?}, {urls:?}"); +//! } +//! } +//! ); +//! +//! impl AppDelegate { +//! fn new(mtm: MainThreadMarker) -> Retained { +//! unsafe { msg_send_id![super(mtm.alloc().set_ivars(())), init] } +//! } +//! } +//! +//! fn main() -> Result<(), Box> { +//! let event_loop = EventLoop::new()?; +//! +//! let mtm = MainThreadMarker::new().unwrap(); +//! let delegate = AppDelegate::new(mtm); +//! // Important: Call `sharedApplication` after `EventLoop::new`, +//! // doing it before is not yet supported. +//! let app = NSApplication::sharedApplication(mtm); +//! app.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); +//! +//! // event_loop.run_app(&mut my_app); +//! Ok(()) +//! } +//! ``` + +use std::os::raw::c_void; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::event_loop::{ActiveEventLoop, EventLoopBuilder}; +use crate::monitor::MonitorHandle; +use crate::window::{Window, WindowAttributes}; + +/// Additional methods on [`Window`] that are specific to MacOS. +pub trait WindowExtMacOS { + /// Returns whether or not the window is in simple fullscreen mode. + fn simple_fullscreen(&self) -> bool; + + /// Toggles a fullscreen mode that doesn't require a new macOS space. + /// Returns a boolean indicating whether the transition was successful (this + /// won't work if the window was already in the native fullscreen). + /// + /// This is how fullscreen used to work on macOS in versions before Lion. + /// And allows the user to have a fullscreen window without using another + /// space or taking control over the entire monitor. + fn set_simple_fullscreen(&self, fullscreen: bool) -> bool; + + /// Returns whether or not the window has shadow. + fn has_shadow(&self) -> bool; + + /// Sets whether or not the window has shadow. + fn set_has_shadow(&self, has_shadow: bool); + + /// Group windows together by using the same tabbing identifier. + /// + /// + fn set_tabbing_identifier(&self, identifier: &str); + + /// Returns the window's tabbing identifier. + fn tabbing_identifier(&self) -> String; + + /// Select next tab. + fn select_next_tab(&self); + + /// Select previous tab. + fn select_previous_tab(&self); + + /// Select the tab with the given index. + /// + /// Will no-op when the index is out of bounds. + fn select_tab_at_index(&self, index: usize); + + /// Get the number of tabs in the window tab group. + fn num_tabs(&self) -> usize; + + /// Get the window's edit state. + /// + /// # Examples + /// + /// ```ignore + /// WindowEvent::CloseRequested => { + /// if window.is_document_edited() { + /// // Show the user a save pop-up or similar + /// } else { + /// // Close the window + /// drop(window); + /// } + /// } + /// ``` + fn is_document_edited(&self) -> bool; + + /// Put the window in a state which indicates a file save is required. + fn set_document_edited(&self, edited: bool); + + /// Set option as alt behavior as described in [`OptionAsAlt`]. + /// + /// This will ignore diacritical marks and accent characters from + /// being processed as received characters. Instead, the input + /// device's raw character will be placed in event queues with the + /// Alt modifier set. + fn set_option_as_alt(&self, option_as_alt: OptionAsAlt); + + /// Getter for the [`WindowExtMacOS::set_option_as_alt`]. + fn option_as_alt(&self) -> OptionAsAlt; + + /// Disable the Menu Bar and Dock in Simple or Borderless Fullscreen mode. Useful for games. + /// The effect is applied when [`WindowExtMacOS::set_simple_fullscreen`] or + /// [`Window::set_fullscreen`] is called. + fn set_borderless_game(&self, borderless_game: bool); + + /// Getter for the [`WindowExtMacOS::set_borderless_game`]. + fn is_borderless_game(&self) -> bool; +} + +impl WindowExtMacOS for Window { + #[inline] + fn simple_fullscreen(&self) -> bool { + self.window.maybe_wait_on_main(|w| w.simple_fullscreen()) + } + + #[inline] + fn set_simple_fullscreen(&self, fullscreen: bool) -> bool { + self.window.maybe_wait_on_main(move |w| w.set_simple_fullscreen(fullscreen)) + } + + #[inline] + fn has_shadow(&self) -> bool { + self.window.maybe_wait_on_main(|w| w.has_shadow()) + } + + #[inline] + fn set_has_shadow(&self, has_shadow: bool) { + self.window.maybe_queue_on_main(move |w| w.set_has_shadow(has_shadow)) + } + + #[inline] + fn set_tabbing_identifier(&self, identifier: &str) { + self.window.maybe_wait_on_main(|w| w.set_tabbing_identifier(identifier)) + } + + #[inline] + fn tabbing_identifier(&self) -> String { + self.window.maybe_wait_on_main(|w| w.tabbing_identifier()) + } + + #[inline] + fn select_next_tab(&self) { + self.window.maybe_queue_on_main(|w| w.select_next_tab()) + } + + #[inline] + fn select_previous_tab(&self) { + self.window.maybe_queue_on_main(|w| w.select_previous_tab()) + } + + #[inline] + fn select_tab_at_index(&self, index: usize) { + self.window.maybe_queue_on_main(move |w| w.select_tab_at_index(index)) + } + + #[inline] + fn num_tabs(&self) -> usize { + self.window.maybe_wait_on_main(|w| w.num_tabs()) + } + + #[inline] + fn is_document_edited(&self) -> bool { + self.window.maybe_wait_on_main(|w| w.is_document_edited()) + } + + #[inline] + fn set_document_edited(&self, edited: bool) { + self.window.maybe_queue_on_main(move |w| w.set_document_edited(edited)) + } + + #[inline] + fn set_option_as_alt(&self, option_as_alt: OptionAsAlt) { + self.window.maybe_queue_on_main(move |w| w.set_option_as_alt(option_as_alt)) + } + + #[inline] + fn option_as_alt(&self) -> OptionAsAlt { + self.window.maybe_wait_on_main(|w| w.option_as_alt()) + } + + #[inline] + fn set_borderless_game(&self, borderless_game: bool) { + self.window.maybe_wait_on_main(|w| w.set_borderless_game(borderless_game)) + } + + #[inline] + fn is_borderless_game(&self) -> bool { + self.window.maybe_wait_on_main(|w| w.is_borderless_game()) + } +} + +/// Corresponds to `NSApplicationActivationPolicy`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ActivationPolicy { + /// Corresponds to `NSApplicationActivationPolicyRegular`. + #[default] + Regular, + + /// Corresponds to `NSApplicationActivationPolicyAccessory`. + Accessory, + + /// Corresponds to `NSApplicationActivationPolicyProhibited`. + Prohibited, +} + +/// Additional methods on [`WindowAttributes`] that are specific to MacOS. +/// +/// **Note:** Properties dealing with the titlebar will be overwritten by the +/// [`WindowAttributes::with_decorations`] method: +/// - `with_titlebar_transparent` +/// - `with_title_hidden` +/// - `with_titlebar_hidden` +/// - `with_titlebar_buttons_hidden` +/// - `with_fullsize_content_view` +pub trait WindowAttributesExtMacOS { + /// Enables click-and-drag behavior for the entire window, not just the titlebar. + fn with_movable_by_window_background(self, movable_by_window_background: bool) -> Self; + /// Makes the titlebar transparent and allows the content to appear behind it. + fn with_titlebar_transparent(self, titlebar_transparent: bool) -> Self; + /// Hides the window title. + fn with_title_hidden(self, title_hidden: bool) -> Self; + /// Hides the window titlebar. + fn with_titlebar_hidden(self, titlebar_hidden: bool) -> Self; + /// Hides the window titlebar buttons. + fn with_titlebar_buttons_hidden(self, titlebar_buttons_hidden: bool) -> Self; + /// Makes the window content appear behind the titlebar. + fn with_fullsize_content_view(self, fullsize_content_view: bool) -> Self; + fn with_disallow_hidpi(self, disallow_hidpi: bool) -> Self; + fn with_has_shadow(self, has_shadow: bool) -> Self; + /// Window accepts click-through mouse events. + fn with_accepts_first_mouse(self, accepts_first_mouse: bool) -> Self; + /// Defines the window tabbing identifier. + /// + /// + fn with_tabbing_identifier(self, identifier: &str) -> Self; + /// Set how the Option keys are interpreted. + /// + /// See [`WindowExtMacOS::set_option_as_alt`] for details on what this means if set. + fn with_option_as_alt(self, option_as_alt: OptionAsAlt) -> Self; + /// See [`WindowExtMacOS::set_borderless_game`] for details on what this means if set. + fn with_borderless_game(self, borderless_game: bool) -> Self; +} + +impl WindowAttributesExtMacOS for WindowAttributes { + #[inline] + fn with_movable_by_window_background(mut self, movable_by_window_background: bool) -> Self { + self.platform_specific.movable_by_window_background = movable_by_window_background; + self + } + + #[inline] + fn with_titlebar_transparent(mut self, titlebar_transparent: bool) -> Self { + self.platform_specific.titlebar_transparent = titlebar_transparent; + self + } + + #[inline] + fn with_titlebar_hidden(mut self, titlebar_hidden: bool) -> Self { + self.platform_specific.titlebar_hidden = titlebar_hidden; + self + } + + #[inline] + fn with_titlebar_buttons_hidden(mut self, titlebar_buttons_hidden: bool) -> Self { + self.platform_specific.titlebar_buttons_hidden = titlebar_buttons_hidden; + self + } + + #[inline] + fn with_title_hidden(mut self, title_hidden: bool) -> Self { + self.platform_specific.title_hidden = title_hidden; + self + } + + #[inline] + fn with_fullsize_content_view(mut self, fullsize_content_view: bool) -> Self { + self.platform_specific.fullsize_content_view = fullsize_content_view; + self + } + + #[inline] + fn with_disallow_hidpi(mut self, disallow_hidpi: bool) -> Self { + self.platform_specific.disallow_hidpi = disallow_hidpi; + self + } + + #[inline] + fn with_has_shadow(mut self, has_shadow: bool) -> Self { + self.platform_specific.has_shadow = has_shadow; + self + } + + #[inline] + fn with_accepts_first_mouse(mut self, accepts_first_mouse: bool) -> Self { + self.platform_specific.accepts_first_mouse = accepts_first_mouse; + self + } + + #[inline] + fn with_tabbing_identifier(mut self, tabbing_identifier: &str) -> Self { + self.platform_specific.tabbing_identifier.replace(tabbing_identifier.to_string()); + self + } + + #[inline] + fn with_option_as_alt(mut self, option_as_alt: OptionAsAlt) -> Self { + self.platform_specific.option_as_alt = option_as_alt; + self + } + + #[inline] + fn with_borderless_game(mut self, borderless_game: bool) -> Self { + self.platform_specific.borderless_game = borderless_game; + self + } +} + +pub trait EventLoopBuilderExtMacOS { + /// Sets the activation policy for the application. If used, this will override + /// any relevant settings provided in the package manifest. + /// For instance, `with_activation_policy(ActivationPolicy::Regular)` will prevent + /// the application from running as an "agent", even if LSUIElement is set to true. + /// + /// If unused, the Winit will honor the package manifest. + /// + /// # Example + /// + /// Set the activation policy to "accessory". + /// + /// ``` + /// use winit::event_loop::EventLoopBuilder; + /// #[cfg(target_os = "macos")] + /// use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; + /// + /// let mut builder = EventLoopBuilder::new(); + /// #[cfg(target_os = "macos")] + /// builder.with_activation_policy(ActivationPolicy::Accessory); + /// # if false { // We can't test this part + /// let event_loop = builder.build(); + /// # } + /// ``` + fn with_activation_policy(&mut self, activation_policy: ActivationPolicy) -> &mut Self; + + /// Used to control whether a default menubar menu is created. + /// + /// Menu creation is enabled by default. + /// + /// # Example + /// + /// Disable creating a default menubar. + /// + /// ``` + /// use winit::event_loop::EventLoopBuilder; + /// #[cfg(target_os = "macos")] + /// use winit::platform::macos::EventLoopBuilderExtMacOS; + /// + /// let mut builder = EventLoopBuilder::new(); + /// #[cfg(target_os = "macos")] + /// builder.with_default_menu(false); + /// # if false { // We can't test this part + /// let event_loop = builder.build(); + /// # } + /// ``` + fn with_default_menu(&mut self, enable: bool) -> &mut Self; + + /// Used to prevent the application from automatically activating when launched if + /// another application is already active. + /// + /// The default behavior is to ignore other applications and activate when launched. + fn with_activate_ignoring_other_apps(&mut self, ignore: bool) -> &mut Self; +} + +impl EventLoopBuilderExtMacOS for EventLoopBuilder { + #[inline] + fn with_activation_policy(&mut self, activation_policy: ActivationPolicy) -> &mut Self { + self.platform_specific.activation_policy = Some(activation_policy); + self + } + + #[inline] + fn with_default_menu(&mut self, enable: bool) -> &mut Self { + self.platform_specific.default_menu = enable; + self + } + + #[inline] + fn with_activate_ignoring_other_apps(&mut self, ignore: bool) -> &mut Self { + self.platform_specific.activate_ignoring_other_apps = ignore; + self + } +} + +/// Additional methods on [`MonitorHandle`] that are specific to MacOS. +pub trait MonitorHandleExtMacOS { + /// Returns the identifier of the monitor for Cocoa. + fn native_id(&self) -> u32; + /// Returns a pointer to the NSScreen representing this monitor. + fn ns_screen(&self) -> Option<*mut c_void>; +} + +impl MonitorHandleExtMacOS for MonitorHandle { + #[inline] + fn native_id(&self) -> u32 { + self.inner.native_identifier() + } + + fn ns_screen(&self) -> Option<*mut c_void> { + // SAFETY: We only use the marker to get a pointer + let mtm = unsafe { objc2_foundation::MainThreadMarker::new_unchecked() }; + self.inner.ns_screen(mtm).map(|s| objc2::rc::Retained::as_ptr(&s) as _) + } +} + +/// Additional methods on [`ActiveEventLoop`] that are specific to macOS. +pub trait ActiveEventLoopExtMacOS { + /// Hide the entire application. In most applications this is typically triggered with + /// Command-H. + fn hide_application(&self); + /// Hide the other applications. In most applications this is typically triggered with + /// Command+Option-H. + fn hide_other_applications(&self); + /// Set whether the system can automatically organize windows into tabs. + /// + /// + fn set_allows_automatic_window_tabbing(&self, enabled: bool); + /// Returns whether the system can automatically organize windows into tabs. + fn allows_automatic_window_tabbing(&self) -> bool; +} + +impl ActiveEventLoopExtMacOS for ActiveEventLoop { + fn hide_application(&self) { + self.p.hide_application() + } + + fn hide_other_applications(&self) { + self.p.hide_other_applications() + } + + fn set_allows_automatic_window_tabbing(&self, enabled: bool) { + self.p.set_allows_automatic_window_tabbing(enabled); + } + + fn allows_automatic_window_tabbing(&self) -> bool { + self.p.allows_automatic_window_tabbing() + } +} + +/// Option as alt behavior. +/// +/// The default is `None`. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum OptionAsAlt { + /// The left `Option` key is treated as `Alt`. + OnlyLeft, + + /// The right `Option` key is treated as `Alt`. + OnlyRight, + + /// Both `Option` keys are treated as `Alt`. + Both, + + /// No special handling is applied for `Option` key. + #[default] + None, +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform/mod.rs new file mode 100644 index 00000000..4f59303e --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/mod.rs @@ -0,0 +1,55 @@ +//! Contains traits with platform-specific methods in them. +//! +//! Only the modules corresponding to the platform you're compiling to will be available. + +#[cfg(any(android_platform, docsrs))] +pub mod android; +#[cfg(any(ios_platform, docsrs))] +pub mod ios; +#[cfg(any(macos_platform, docsrs))] +pub mod macos; +#[cfg(any(orbital_platform, docsrs))] +pub mod orbital; +#[cfg(any(x11_platform, wayland_platform, docsrs))] +pub mod startup_notify; +#[cfg(any(wayland_platform, docsrs))] +pub mod wayland; +#[cfg(any(web_platform, docsrs))] +pub mod web; +#[cfg(any(windows_platform, docsrs))] +pub mod windows; +#[cfg(any(x11_platform, docsrs))] +pub mod x11; + +#[cfg(any( + windows_platform, + macos_platform, + android_platform, + x11_platform, + wayland_platform, + docsrs, +))] +pub mod run_on_demand; + +#[cfg(any( + windows_platform, + macos_platform, + android_platform, + x11_platform, + wayland_platform, + docsrs, +))] +pub mod pump_events; + +#[cfg(any( + windows_platform, + macos_platform, + x11_platform, + wayland_platform, + orbital_platform, + docsrs +))] +pub mod modifier_supplement; + +#[cfg(any(windows_platform, macos_platform, x11_platform, wayland_platform, docsrs))] +pub mod scancode; diff --git a/third-party/winit-0.30.13-ferrex/src/platform/modifier_supplement.rs b/third-party/winit-0.30.13-ferrex/src/platform/modifier_supplement.rs new file mode 100644 index 00000000..b1db7345 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/modifier_supplement.rs @@ -0,0 +1,35 @@ +use crate::event::KeyEvent; +use crate::keyboard::Key; + +/// Additional methods for the `KeyEvent` which cannot be implemented on all +/// platforms. +pub trait KeyEventExtModifierSupplement { + /// Identical to `KeyEvent::text` but this is affected by Ctrl. + /// + /// For example, pressing Ctrl+a produces `Some("\x01")`. + fn text_with_all_modifiers(&self) -> Option<&str>; + + /// This value ignores all modifiers including, + /// but not limited to Shift, Caps Lock, + /// and Ctrl. In most cases this means that the + /// unicode character in the resulting string is lowercase. + /// + /// This is useful for key-bindings / shortcut key combinations. + /// + /// In case `logical_key` reports `Dead`, this will still report the + /// key as `Character` according to the current keyboard layout. This value + /// cannot be `Dead`. + fn key_without_modifiers(&self) -> Key; +} + +impl KeyEventExtModifierSupplement for KeyEvent { + #[inline] + fn text_with_all_modifiers(&self) -> Option<&str> { + self.platform_specific.text_with_all_modifiers.as_ref().map(|s| s.as_str()) + } + + #[inline] + fn key_without_modifiers(&self) -> Key { + self.platform_specific.key_without_modifiers.clone() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/orbital.rs b/third-party/winit-0.30.13-ferrex/src/platform/orbital.rs new file mode 100644 index 00000000..d8093503 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/orbital.rs @@ -0,0 +1,6 @@ +//! # Orbital / Redox OS +//! +//! Redox OS has some functionality not yet present that will be implemented +//! when its orbital display server provides it. + +// There are no Orbital specific traits yet. diff --git a/third-party/winit-0.30.13-ferrex/src/platform/pump_events.rs b/third-party/winit-0.30.13-ferrex/src/platform/pump_events.rs new file mode 100644 index 00000000..44766353 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/pump_events.rs @@ -0,0 +1,143 @@ +use std::time::Duration; + +use crate::application::ApplicationHandler; +use crate::event::Event; +use crate::event_loop::{self, ActiveEventLoop, EventLoop}; + +/// Additional methods on [`EventLoop`] for pumping events within an external event loop +pub trait EventLoopExtPumpEvents { + /// A type provided by the user that can be passed through [`Event::UserEvent`]. + type UserEvent: 'static; + + /// Pump the `EventLoop` to check for and dispatch pending events. + /// + /// This API is designed to enable applications to integrate Winit into an + /// external event loop, for platforms that can support this. + /// + /// The given `timeout` limits how long it may block waiting for new events. + /// + /// Passing a `timeout` of `Some(Duration::ZERO)` would ensure your external + /// event loop is never blocked but you would likely need to consider how + /// to throttle your own external loop. + /// + /// Passing a `timeout` of `None` means that it may wait indefinitely for new + /// events before returning control back to the external loop. + /// + /// **Note:** This is not a portable API, and its usage involves a number of + /// caveats and trade offs that should be considered before using this API! + /// + /// You almost certainly shouldn't use this API, unless you absolutely know it's + /// the only practical option you have. + /// + /// ## Synchronous events + /// + /// Some events _must_ only be handled synchronously via the closure that + /// is passed to Winit so that the handler will also be synchronized with + /// the window system and operating system. + /// + /// This is because some events are driven by a window system callback + /// where the window systems expects the application to have handled the + /// event before returning. + /// + /// **These events can not be buffered and handled outside of the closure + /// passed to Winit.** + /// + /// As a general rule it is not recommended to ever buffer events to handle + /// them outside of the closure passed to Winit since it's difficult to + /// provide guarantees about which events are safe to buffer across all + /// operating systems. + /// + /// Notable events that will certainly create portability problems if + /// buffered and handled outside of Winit include: + /// - `RedrawRequested` events, used to schedule rendering. + /// + /// macOS for example uses a `drawRect` callback to drive rendering + /// within applications and expects rendering to be finished before + /// the `drawRect` callback returns. + /// + /// For portability it's strongly recommended that applications should + /// keep their rendering inside the closure provided to Winit. + /// - Any lifecycle events, such as `Suspended` / `Resumed`. + /// + /// The handling of these events needs to be synchronized with the + /// operating system and it would never be appropriate to buffer a + /// notification that your application has been suspended or resumed and + /// then handled that later since there would always be a chance that + /// other lifecycle events occur while the event is buffered. + /// + /// ## Supported Platforms + /// + /// - Windows + /// - Linux + /// - MacOS + /// - Android + /// + /// ## Unsupported Platforms + /// + /// - **Web:** This API is fundamentally incompatible with the event-based way in which Web + /// browsers work because it's not possible to have a long-running external loop that would + /// block the browser and there is nothing that can be polled to ask for new new events. + /// Events are delivered via callbacks based on an event loop that is internal to the browser + /// itself. + /// - **iOS:** It's not possible to stop and start an `NSApplication` repeatedly on iOS so + /// there's no way to support the same approach to polling as on MacOS. + /// + /// ## Platform-specific + /// + /// - **Windows**: The implementation will use `PeekMessage` when checking for window messages + /// to avoid blocking your external event loop. + /// + /// - **MacOS**: The implementation works in terms of stopping the global application whenever + /// the application `RunLoop` indicates that it is preparing to block and wait for new events. + /// + /// This is very different to the polling APIs that are available on other + /// platforms (the lower level polling primitives on MacOS are private + /// implementation details for `NSApplication` which aren't accessible to + /// application developers) + /// + /// It's likely this will be less efficient than polling on other OSs and + /// it also means the `NSApplication` is stopped while outside of the Winit + /// event loop - and that's observable (for example to crates like `rfd`) + /// because the `NSApplication` is global state. + /// + /// If you render outside of Winit you are likely to see window resizing artifacts + /// since MacOS expects applications to render synchronously during any `drawRect` + /// callback. + fn pump_app_events>( + &mut self, + timeout: Option, + app: &mut A, + ) -> PumpStatus { + #[allow(deprecated)] + self.pump_events(timeout, |event, event_loop| { + event_loop::dispatch_event_for_app(app, event_loop, event) + }) + } + + /// See [`pump_app_events`]. + /// + /// [`pump_app_events`]: Self::pump_app_events + #[deprecated = "use EventLoopExtPumpEvents::pump_app_events"] + fn pump_events(&mut self, timeout: Option, event_handler: F) -> PumpStatus + where + F: FnMut(Event, &ActiveEventLoop); +} + +impl EventLoopExtPumpEvents for EventLoop { + type UserEvent = T; + + fn pump_events(&mut self, timeout: Option, event_handler: F) -> PumpStatus + where + F: FnMut(Event, &ActiveEventLoop), + { + self.event_loop.pump_events(timeout, event_handler) + } +} + +/// The return status for `pump_events` +pub enum PumpStatus { + /// Continue running external loop. + Continue, + /// Exit external loop. + Exit(i32), +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/run_on_demand.rs b/third-party/winit-0.30.13-ferrex/src/platform/run_on_demand.rs new file mode 100644 index 00000000..0bb96e92 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/run_on_demand.rs @@ -0,0 +1,111 @@ +use crate::application::ApplicationHandler; +use crate::error::EventLoopError; +use crate::event::Event; +use crate::event_loop::{self, ActiveEventLoop, EventLoop}; + +#[cfg(doc)] +use crate::{platform::pump_events::EventLoopExtPumpEvents, window::Window}; + +/// Additional methods on [`EventLoop`] to return control flow to the caller. +pub trait EventLoopExtRunOnDemand { + /// A type provided by the user that can be passed through [`Event::UserEvent`]. + type UserEvent: 'static; + + /// See [`run_app_on_demand`]. + /// + /// [`run_app_on_demand`]: Self::run_app_on_demand + #[deprecated = "use EventLoopExtRunOnDemand::run_app_on_demand"] + fn run_on_demand(&mut self, event_handler: F) -> Result<(), EventLoopError> + where + F: FnMut(Event, &ActiveEventLoop); + + /// Run the application with the event loop on the calling thread. + /// + /// Unlike [`EventLoop::run_app`], this function accepts non-`'static` (i.e. non-`move`) + /// closures and it is possible to return control back to the caller without + /// consuming the `EventLoop` (by using [`exit()`]) and + /// so the event loop can be re-run after it has exit. + /// + /// It's expected that each run of the loop will be for orthogonal instantiations of your + /// Winit application, but internally each instantiation may re-use some common window + /// system resources, such as a display server connection. + /// + /// This API is not designed to run an event loop in bursts that you can exit from and return + /// to while maintaining the full state of your application. (If you need something like this + /// you can look at the [`EventLoopExtPumpEvents::pump_app_events()`] API) + /// + /// Each time `run_app_on_demand` is called the startup sequence of `init`, followed by + /// `resume` is being preserved. + /// + /// See the [`set_control_flow()`] docs on how to change the event loop's behavior. + /// + /// # Caveats + /// - This extension isn't available on all platforms, since it's not always possible to return + /// to the caller (specifically this is impossible on iOS and Web - though with the Web + /// backend it is possible to use `EventLoopExtWebSys::spawn()` + #[cfg_attr(not(web_platform), doc = "[^1]")] + /// more than once instead). + /// - No [`Window`] state can be carried between separate runs of the event loop. + /// + /// You are strongly encouraged to use [`EventLoop::run_app()`] for portability, unless you + /// specifically need the ability to re-run a single event loop more than once + /// + /// # Supported Platforms + /// - Windows + /// - Linux + /// - macOS + /// - Android + /// + /// # Unsupported Platforms + /// - **Web:** This API is fundamentally incompatible with the event-based way in which Web + /// browsers work because it's not possible to have a long-running external loop that would + /// block the browser and there is nothing that can be polled to ask for new events. Events + /// are delivered via callbacks based on an event loop that is internal to the browser itself. + /// - **iOS:** It's not possible to stop and start an `UIApplication` repeatedly on iOS. + #[cfg_attr(not(web_platform), doc = "[^1]: `spawn()` is only available on `wasm` platforms.")] + /// + #[rustfmt::skip] + /// [`exit()`]: ActiveEventLoop::exit() + /// [`set_control_flow()`]: ActiveEventLoop::set_control_flow() + fn run_app_on_demand>( + &mut self, + app: &mut A, + ) -> Result<(), EventLoopError> { + #[allow(deprecated)] + self.run_on_demand(|event, event_loop| { + event_loop::dispatch_event_for_app(app, event_loop, event) + }) + } +} + +impl EventLoopExtRunOnDemand for EventLoop { + type UserEvent = T; + + fn run_on_demand(&mut self, event_handler: F) -> Result<(), EventLoopError> + where + F: FnMut(Event, &ActiveEventLoop), + { + self.event_loop.window_target().clear_exit(); + self.event_loop.run_on_demand(event_handler) + } +} + +impl ActiveEventLoop { + /// Clear exit status. + pub(crate) fn clear_exit(&self) { + self.p.clear_exit() + } +} + +/// ```compile_fail +/// use winit::event_loop::EventLoop; +/// use winit::platform::run_on_demand::EventLoopExtRunOnDemand; +/// +/// let mut event_loop = EventLoop::new().unwrap(); +/// event_loop.run_on_demand(|_, _| { +/// // Attempt to run the event loop re-entrantly; this must fail. +/// event_loop.run_on_demand(|_, _| {}); +/// }); +/// ``` +#[allow(dead_code)] +fn test_run_on_demand_cannot_access_event_loop() {} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/scancode.rs b/third-party/winit-0.30.13-ferrex/src/platform/scancode.rs new file mode 100644 index 00000000..0d783135 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/scancode.rs @@ -0,0 +1,50 @@ +use crate::keyboard::{KeyCode, PhysicalKey}; + +// TODO: Describe what this value contains for each platform + +/// Additional methods for the [`PhysicalKey`] type that allow the user to access the +/// platform-specific scancode. +/// +/// [`PhysicalKey`]: crate::keyboard::PhysicalKey +pub trait PhysicalKeyExtScancode { + /// The raw value of the platform-specific physical key identifier. + /// + /// Returns `Some(key_id)` if the conversion was successful; returns `None` otherwise. + /// + /// ## Platform-specific + /// - **Windows:** A 16bit extended scancode + /// - **Wayland/X11**: A 32-bit linux scancode, which is X11/Wayland keycode subtracted by 8. + fn to_scancode(self) -> Option; + + /// Constructs a `PhysicalKey` from a platform-specific physical key identifier. + /// + /// Note that this conversion may be lossy, i.e. converting the returned `PhysicalKey` back + /// using `to_scancode` might not yield the original value. + /// + /// ## Platform-specific + /// - **Wayland/X11**: A 32-bit linux scancode. When building from X11/Wayland keycode subtract + /// `8` to get the value you wanted. + fn from_scancode(scancode: u32) -> PhysicalKey; +} + +impl PhysicalKeyExtScancode for PhysicalKey { + fn to_scancode(self) -> Option { + crate::platform_impl::physicalkey_to_scancode(self) + } + + fn from_scancode(scancode: u32) -> PhysicalKey { + crate::platform_impl::scancode_to_physicalkey(scancode) + } +} + +impl PhysicalKeyExtScancode for KeyCode { + #[inline] + fn to_scancode(self) -> Option { + ::to_scancode(PhysicalKey::Code(self)) + } + + #[inline] + fn from_scancode(scancode: u32) -> PhysicalKey { + ::from_scancode(scancode) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/startup_notify.rs b/third-party/winit-0.30.13-ferrex/src/platform/startup_notify.rs new file mode 100644 index 00000000..6fab284e --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/startup_notify.rs @@ -0,0 +1,99 @@ +//! Window startup notification to handle window raising. +//! +//! The [`ActivationToken`] is essential to ensure that your newly +//! created window will obtain the focus, otherwise the user could +//! be requered to click on the window. +//! +//! Such token is usually delivered via the environment variable and +//! could be read from it with the [`EventLoopExtStartupNotify::read_token_from_env`]. +//! +//! Such token must also be reset after reading it from your environment with +//! [`reset_activation_token_env`] otherwise child processes could inherit it. +//! +//! When starting a new child process with a newly obtained [`ActivationToken`] from +//! [`WindowExtStartupNotify::request_activation_token`] the [`set_activation_token_env`] +//! must be used to propagate it to the child +//! +//! To ensure the delivery of such token by other processes to you, the user should +//! set `StartupNotify=true` inside the `.desktop` file of their application. +//! +//! The specification could be found [`here`]. +//! +//! [`here`]: https://specifications.freedesktop.org/startup-notification-spec/startup-notification-latest.txt + +use std::env; + +use crate::error::NotSupportedError; +use crate::event_loop::{ActiveEventLoop, AsyncRequestSerial}; +use crate::window::{ActivationToken, Window, WindowAttributes}; + +/// The variable which is used mostly on X11. +const X11_VAR: &str = "DESKTOP_STARTUP_ID"; + +/// The variable which is used mostly on Wayland. +const WAYLAND_VAR: &str = "XDG_ACTIVATION_TOKEN"; + +pub trait EventLoopExtStartupNotify { + /// Read the token from the environment. + /// + /// It's recommended **to unset** this environment variable for child processes. + fn read_token_from_env(&self) -> Option; +} + +pub trait WindowExtStartupNotify { + /// Request a new activation token. + /// + /// The token will be delivered inside + fn request_activation_token(&self) -> Result; +} + +pub trait WindowAttributesExtStartupNotify { + /// Use this [`ActivationToken`] during window creation. + /// + /// Not using such a token upon a window could make your window not gaining + /// focus until the user clicks on the window. + fn with_activation_token(self, token: ActivationToken) -> Self; +} + +impl EventLoopExtStartupNotify for ActiveEventLoop { + fn read_token_from_env(&self) -> Option { + match self.p { + #[cfg(wayland_platform)] + crate::platform_impl::ActiveEventLoop::Wayland(_) => env::var(WAYLAND_VAR), + #[cfg(x11_platform)] + crate::platform_impl::ActiveEventLoop::X(_) => env::var(X11_VAR), + } + .ok() + .map(ActivationToken::from_raw) + } +} + +impl WindowExtStartupNotify for Window { + fn request_activation_token(&self) -> Result { + self.window.request_activation_token() + } +} + +impl WindowAttributesExtStartupNotify for WindowAttributes { + fn with_activation_token(mut self, token: ActivationToken) -> Self { + self.platform_specific.activation_token = Some(token); + self + } +} + +/// Remove the activation environment variables from the current process. +/// +/// This is wise to do before running child processes, +/// which may not to support the activation token. +pub fn reset_activation_token_env() { + env::remove_var(X11_VAR); + env::remove_var(WAYLAND_VAR); +} + +/// Set environment variables responsible for activation token. +/// +/// This could be used before running daemon processes. +pub fn set_activation_token_env(token: ActivationToken) { + env::set_var(X11_VAR, &token.token); + env::set_var(WAYLAND_VAR, token.token); +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/wayland.rs b/third-party/winit-0.30.13-ferrex/src/platform/wayland.rs new file mode 100644 index 00000000..9c1a8e43 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/wayland.rs @@ -0,0 +1,131 @@ +//! # Wayland +//! +//! **Note:** Windows don't appear on Wayland until you draw/present to them. +//! +//! By default, Winit loads system libraries using `dlopen`. This can be +//! disabled by disabling the `"wayland-dlopen"` cargo feature. +//! +//! ## Client-side decorations +//! +//! Winit provides client-side decorations by default, but the behaviour can +//! be controlled with the following feature flags: +//! +//! * `wayland-csd-adwaita` (default). +//! * `wayland-csd-adwaita-crossfont`. +//! * `wayland-csd-adwaita-notitle`. + +use std::ffi::c_void; +use std::ptr::NonNull; + +use crate::event_loop::{ActiveEventLoop, EventLoop, EventLoopBuilder}; +use crate::monitor::MonitorHandle; +use crate::window::{Window, WindowAttributes}; + +pub use crate::window::Theme; + +/// Additional methods on [`ActiveEventLoop`] that are specific to Wayland. +pub trait ActiveEventLoopExtWayland { + /// True if the [`ActiveEventLoop`] uses Wayland. + fn is_wayland(&self) -> bool; +} + +impl ActiveEventLoopExtWayland for ActiveEventLoop { + #[inline] + fn is_wayland(&self) -> bool { + self.p.is_wayland() + } +} + +/// Additional methods on [`EventLoop`] that are specific to Wayland. +pub trait EventLoopExtWayland { + /// True if the [`EventLoop`] uses Wayland. + fn is_wayland(&self) -> bool; +} + +impl EventLoopExtWayland for EventLoop { + #[inline] + fn is_wayland(&self) -> bool { + self.event_loop.is_wayland() + } +} + +/// Additional methods on [`EventLoopBuilder`] that are specific to Wayland. +pub trait EventLoopBuilderExtWayland { + /// Force using Wayland. + fn with_wayland(&mut self) -> &mut Self; + + /// Whether to allow the event loop to be created off of the main thread. + /// + /// By default, the window is only allowed to be created on the main + /// thread, to make platform compatibility easier. + fn with_any_thread(&mut self, any_thread: bool) -> &mut Self; +} + +impl EventLoopBuilderExtWayland for EventLoopBuilder { + #[inline] + fn with_wayland(&mut self) -> &mut Self { + self.platform_specific.forced_backend = Some(crate::platform_impl::Backend::Wayland); + self + } + + #[inline] + fn with_any_thread(&mut self, any_thread: bool) -> &mut Self { + self.platform_specific.any_thread = any_thread; + self + } +} + +/// Additional methods on [`Window`] that are specific to Wayland. +/// +/// [`Window`]: crate::window::Window +pub trait WindowExtWayland { + /// Returns `xdg_toplevel` of the window or [`None`] if the window is X11 window. + fn xdg_toplevel(&self) -> Option>; +} + +impl WindowExtWayland for Window { + #[inline] + fn xdg_toplevel(&self) -> Option> { + #[allow(clippy::single_match)] + match &self.window { + #[cfg(x11_platform)] + crate::platform_impl::Window::X(_) => None, + #[cfg(wayland_platform)] + crate::platform_impl::Window::Wayland(window) => window.xdg_toplevel(), + } + } +} + +/// Additional methods on [`WindowAttributes`] that are specific to Wayland. +pub trait WindowAttributesExtWayland { + /// Build window with the given name. + /// + /// The `general` name sets an application ID, which should match the `.desktop` + /// file distributed with your program. The `instance` is a `no-op`. + /// + /// For details about application ID conventions, see the + /// [Desktop Entry Spec](https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#desktop-file-id) + fn with_name(self, general: impl Into, instance: impl Into) -> Self; +} + +impl WindowAttributesExtWayland for WindowAttributes { + #[inline] + fn with_name(mut self, general: impl Into, instance: impl Into) -> Self { + self.platform_specific.name = + Some(crate::platform_impl::ApplicationName::new(general.into(), instance.into())); + self + } +} + +/// Additional methods on `MonitorHandle` that are specific to Wayland. +pub trait MonitorHandleExtWayland { + /// Returns the inner identifier of the monitor. + fn native_id(&self) -> u32; +} + +impl MonitorHandleExtWayland for MonitorHandle { + #[inline] + fn native_id(&self) -> u32 { + self.inner.native_identifier() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/web.rs b/third-party/winit-0.30.13-ferrex/src/platform/web.rs new file mode 100644 index 00000000..f257ca41 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/web.rs @@ -0,0 +1,465 @@ +//! # Web +//! +//! The officially supported browsers are Chrome, Firefox and Safari 13.1+, +//! though forks of these should work fine. +//! +//! Winit supports compiling to the `wasm32-unknown-unknown` target with +//! `web-sys`. +//! +//! On the web platform, a Winit window is backed by a `` element. You +//! can either [provide Winit with a `` element][with_canvas], or +//! [let Winit create a `` element which you can then retrieve][get] +//! and insert it into the DOM yourself. +//! +//! Currently, there is no example code using Winit on Web, see [#3473]. For +//! information on using Rust on WebAssembly, check out the [Rust and +//! WebAssembly book]. +//! +//! [with_canvas]: WindowAttributesExtWebSys::with_canvas +//! [get]: WindowExtWebSys::canvas +//! [#3473]: https://github.com/rust-windowing/winit/issues/3473 +//! [Rust and WebAssembly book]: https://rustwasm.github.io/book/ +//! +//! ## CSS properties +//! +//! It is recommended **not** to apply certain CSS properties to the canvas: +//! - [`transform`](https://developer.mozilla.org/en-US/docs/Web/CSS/transform) +//! - [`border`](https://developer.mozilla.org/en-US/docs/Web/CSS/border) +//! - [`padding`](https://developer.mozilla.org/en-US/docs/Web/CSS/padding) +//! +//! The following APIs can't take them into account and will therefore provide inaccurate results: +//! - [`WindowEvent::Resized`] and [`Window::(set_)inner_size()`] +//! - [`WindowEvent::Occluded`] +//! - [`WindowEvent::CursorMoved`], [`WindowEvent::CursorEntered`], [`WindowEvent::CursorLeft`], and +//! [`WindowEvent::Touch`]. +//! - [`Window::set_outer_position()`] +//! +//! [`WindowEvent::Resized`]: crate::event::WindowEvent::Resized +//! [`Window::(set_)inner_size()`]: crate::window::Window::inner_size +//! [`WindowEvent::Occluded`]: crate::event::WindowEvent::Occluded +//! [`WindowEvent::CursorMoved`]: crate::event::WindowEvent::CursorMoved +//! [`WindowEvent::CursorEntered`]: crate::event::WindowEvent::CursorEntered +//! [`WindowEvent::CursorLeft`]: crate::event::WindowEvent::CursorLeft +//! [`WindowEvent::Touch`]: crate::event::WindowEvent::Touch +//! [`Window::set_outer_position()`]: crate::window::Window::set_outer_position + +use std::error::Error; +use std::fmt::{self, Display, Formatter}; +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +#[cfg(web_platform)] +use web_sys::HtmlCanvasElement; + +use crate::application::ApplicationHandler; +use crate::cursor::CustomCursorSource; +use crate::event::Event; +use crate::event_loop::{self, ActiveEventLoop, EventLoop}; +#[cfg(web_platform)] +use crate::platform_impl::CustomCursorFuture as PlatformCustomCursorFuture; +use crate::platform_impl::PlatformCustomCursorSource; +use crate::window::{CustomCursor, Window, WindowAttributes}; + +#[cfg(not(web_platform))] +#[doc(hidden)] +pub struct HtmlCanvasElement; + +pub trait WindowExtWebSys { + /// Only returns the canvas if called from inside the window context (the + /// main thread). + fn canvas(&self) -> Option; + + /// Returns [`true`] if calling `event.preventDefault()` is enabled. + /// + /// See [`Window::set_prevent_default()`] for more details. + fn prevent_default(&self) -> bool; + + /// Sets whether `event.preventDefault()` should be called on events on the + /// canvas that have side effects. + /// + /// For example, by default using the mouse wheel would cause the page to scroll, enabling this + /// would prevent that. + /// + /// Some events are impossible to prevent. E.g. Firefox allows to access the native browser + /// context menu with Shift+Rightclick. + fn set_prevent_default(&self, prevent_default: bool); +} + +impl WindowExtWebSys for Window { + #[inline] + fn canvas(&self) -> Option { + self.window.canvas() + } + + fn prevent_default(&self) -> bool { + self.window.prevent_default() + } + + fn set_prevent_default(&self, prevent_default: bool) { + self.window.set_prevent_default(prevent_default) + } +} + +pub trait WindowAttributesExtWebSys { + /// Pass an [`HtmlCanvasElement`] to be used for this [`Window`]. If [`None`], + /// [`WindowAttributes::default()`] will create one. + /// + /// In any case, the canvas won't be automatically inserted into the web page. + /// + /// [`None`] by default. + #[cfg_attr(not(web_platform), doc = "", doc = "[`HtmlCanvasElement`]: #only-available-on-wasm")] + fn with_canvas(self, canvas: Option) -> Self; + + /// Sets whether `event.preventDefault()` should be called on events on the + /// canvas that have side effects. + /// + /// See [`Window::set_prevent_default()`] for more details. + /// + /// Enabled by default. + fn with_prevent_default(self, prevent_default: bool) -> Self; + + /// Whether the canvas should be focusable using the tab key. This is necessary to capture + /// canvas keyboard events. + /// + /// Enabled by default. + fn with_focusable(self, focusable: bool) -> Self; + + /// On window creation, append the canvas element to the web page if it isn't already. + /// + /// Disabled by default. + fn with_append(self, append: bool) -> Self; +} + +impl WindowAttributesExtWebSys for WindowAttributes { + fn with_canvas(mut self, canvas: Option) -> Self { + self.platform_specific.set_canvas(canvas); + self + } + + fn with_prevent_default(mut self, prevent_default: bool) -> Self { + self.platform_specific.prevent_default = prevent_default; + self + } + + fn with_focusable(mut self, focusable: bool) -> Self { + self.platform_specific.focusable = focusable; + self + } + + fn with_append(mut self, append: bool) -> Self { + self.platform_specific.append = append; + self + } +} + +/// Additional methods on `EventLoop` that are specific to the web. +pub trait EventLoopExtWebSys { + /// A type provided by the user that can be passed through `Event::UserEvent`. + type UserEvent: 'static; + + /// Initializes the winit event loop. + /// + /// Unlike + #[cfg_attr(all(web_platform, target_feature = "exception-handling"), doc = "`run_app()`")] + #[cfg_attr( + not(all(web_platform, target_feature = "exception-handling")), + doc = "[`run_app()`]" + )] + /// [^1], this returns immediately, and doesn't throw an exception in order to + /// satisfy its [`!`] return type. + /// + /// Once the event loop has been destroyed, it's possible to reinitialize another event loop + /// by calling this function again. This can be useful if you want to recreate the event loop + /// while the WebAssembly module is still loaded. For example, this can be used to recreate the + /// event loop when switching between tabs on a single page application. + #[rustfmt::skip] + /// + #[cfg_attr( + not(all(web_platform, target_feature = "exception-handling")), + doc = "[`run_app()`]: EventLoop::run_app()" + )] + /// [^1]: `run_app()` is _not_ available on WASM when the target supports `exception-handling`. + fn spawn_app + 'static>(self, app: A); + + /// See [`spawn_app`]. + /// + /// [`spawn_app`]: Self::spawn_app + #[deprecated = "use EventLoopExtWebSys::spawn_app"] + fn spawn(self, event_handler: F) + where + F: 'static + FnMut(Event, &ActiveEventLoop); + + /// Sets the strategy for [`ControlFlow::Poll`]. + /// + /// See [`PollStrategy`]. + /// + /// [`ControlFlow::Poll`]: crate::event_loop::ControlFlow::Poll + fn set_poll_strategy(&self, strategy: PollStrategy); + + /// Gets the strategy for [`ControlFlow::Poll`]. + /// + /// See [`PollStrategy`]. + /// + /// [`ControlFlow::Poll`]: crate::event_loop::ControlFlow::Poll + fn poll_strategy(&self) -> PollStrategy; + + /// Sets the strategy for [`ControlFlow::WaitUntil`]. + /// + /// See [`WaitUntilStrategy`]. + /// + /// [`ControlFlow::WaitUntil`]: crate::event_loop::ControlFlow::WaitUntil + fn set_wait_until_strategy(&self, strategy: WaitUntilStrategy); + + /// Gets the strategy for [`ControlFlow::WaitUntil`]. + /// + /// See [`WaitUntilStrategy`]. + /// + /// [`ControlFlow::WaitUntil`]: crate::event_loop::ControlFlow::WaitUntil + fn wait_until_strategy(&self) -> WaitUntilStrategy; +} + +impl EventLoopExtWebSys for EventLoop { + type UserEvent = T; + + fn spawn_app + 'static>(self, mut app: A) { + self.event_loop.spawn(move |event, event_loop| { + event_loop::dispatch_event_for_app(&mut app, event_loop, event) + }); + } + + fn spawn(self, event_handler: F) + where + F: 'static + FnMut(Event, &ActiveEventLoop), + { + self.event_loop.spawn(event_handler) + } + + fn set_poll_strategy(&self, strategy: PollStrategy) { + self.event_loop.set_poll_strategy(strategy); + } + + fn poll_strategy(&self) -> PollStrategy { + self.event_loop.poll_strategy() + } + + fn set_wait_until_strategy(&self, strategy: WaitUntilStrategy) { + self.event_loop.set_wait_until_strategy(strategy); + } + + fn wait_until_strategy(&self) -> WaitUntilStrategy { + self.event_loop.wait_until_strategy() + } +} + +pub trait ActiveEventLoopExtWebSys { + /// Sets the strategy for [`ControlFlow::Poll`]. + /// + /// See [`PollStrategy`]. + /// + /// [`ControlFlow::Poll`]: crate::event_loop::ControlFlow::Poll + fn set_poll_strategy(&self, strategy: PollStrategy); + + /// Gets the strategy for [`ControlFlow::Poll`]. + /// + /// See [`PollStrategy`]. + /// + /// [`ControlFlow::Poll`]: crate::event_loop::ControlFlow::Poll + fn poll_strategy(&self) -> PollStrategy; + + /// Sets the strategy for [`ControlFlow::WaitUntil`]. + /// + /// See [`WaitUntilStrategy`]. + /// + /// [`ControlFlow::WaitUntil`]: crate::event_loop::ControlFlow::WaitUntil + fn set_wait_until_strategy(&self, strategy: WaitUntilStrategy); + + /// Gets the strategy for [`ControlFlow::WaitUntil`]. + /// + /// See [`WaitUntilStrategy`]. + /// + /// [`ControlFlow::WaitUntil`]: crate::event_loop::ControlFlow::WaitUntil + fn wait_until_strategy(&self) -> WaitUntilStrategy; + + /// Async version of [`ActiveEventLoop::create_custom_cursor()`] which waits until the + /// cursor has completely finished loading. + fn create_custom_cursor_async(&self, source: CustomCursorSource) -> CustomCursorFuture; +} + +impl ActiveEventLoopExtWebSys for ActiveEventLoop { + #[inline] + fn create_custom_cursor_async(&self, source: CustomCursorSource) -> CustomCursorFuture { + self.p.create_custom_cursor_async(source) + } + + #[inline] + fn set_poll_strategy(&self, strategy: PollStrategy) { + self.p.set_poll_strategy(strategy); + } + + #[inline] + fn poll_strategy(&self) -> PollStrategy { + self.p.poll_strategy() + } + + #[inline] + fn set_wait_until_strategy(&self, strategy: WaitUntilStrategy) { + self.p.set_wait_until_strategy(strategy); + } + + #[inline] + fn wait_until_strategy(&self) -> WaitUntilStrategy { + self.p.wait_until_strategy() + } +} + +/// Strategy used for [`ControlFlow::Poll`][crate::event_loop::ControlFlow::Poll]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PollStrategy { + /// Uses [`Window.requestIdleCallback()`] to queue the next event loop. If not available + /// this will fallback to [`setTimeout()`]. + /// + /// This strategy will wait for the browser to enter an idle period before running and might + /// be affected by browser throttling. + /// + /// [`Window.requestIdleCallback()`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback + /// [`setTimeout()`]: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout + IdleCallback, + /// Uses the [Prioritized Task Scheduling API] to queue the next event loop. If not available + /// this will fallback to [`setTimeout()`]. + /// + /// This strategy will run as fast as possible without disturbing users from interacting with + /// the page and is not affected by browser throttling. + /// + /// This is the default strategy. + /// + /// [Prioritized Task Scheduling API]: https://developer.mozilla.org/en-US/docs/Web/API/Prioritized_Task_Scheduling_API + /// [`setTimeout()`]: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout + #[default] + Scheduler, +} + +/// Strategy used for [`ControlFlow::WaitUntil`][crate::event_loop::ControlFlow::WaitUntil]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WaitUntilStrategy { + /// Uses the [Prioritized Task Scheduling API] to queue the next event loop. If not available + /// this will fallback to [`setTimeout()`]. + /// + /// This strategy is commonly not affected by browser throttling unless the window is not + /// focused. + /// + /// This is the default strategy. + /// + /// [Prioritized Task Scheduling API]: https://developer.mozilla.org/en-US/docs/Web/API/Prioritized_Task_Scheduling_API + /// [`setTimeout()`]: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout + #[default] + Scheduler, + /// Equal to [`Scheduler`][Self::Scheduler] but wakes up the event loop from a [worker]. + /// + /// This strategy is commonly not affected by browser throttling regardless of window focus. + /// + /// [worker]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API + Worker, +} + +pub trait CustomCursorExtWebSys { + /// Returns if this cursor is an animation. + fn is_animation(&self) -> bool; + + /// Creates a new cursor from a URL pointing to an image. + /// It uses the [url css function](https://developer.mozilla.org/en-US/docs/Web/CSS/url), + /// but browser support for image formats is inconsistent. Using [PNG] is recommended. + /// + /// [PNG]: https://en.wikipedia.org/wiki/PNG + fn from_url(url: String, hotspot_x: u16, hotspot_y: u16) -> CustomCursorSource; + + /// Crates a new animated cursor from multiple [`CustomCursor`]s. + /// Supplied `cursors` can't be empty or other animations. + fn from_animation( + duration: Duration, + cursors: Vec, + ) -> Result; +} + +impl CustomCursorExtWebSys for CustomCursor { + fn is_animation(&self) -> bool { + self.inner.animation + } + + fn from_url(url: String, hotspot_x: u16, hotspot_y: u16) -> CustomCursorSource { + CustomCursorSource { inner: PlatformCustomCursorSource::Url { url, hotspot_x, hotspot_y } } + } + + fn from_animation( + duration: Duration, + cursors: Vec, + ) -> Result { + if cursors.is_empty() { + return Err(BadAnimation::Empty); + } + + if cursors.iter().any(CustomCursor::is_animation) { + return Err(BadAnimation::Animation); + } + + Ok(CustomCursorSource { + inner: PlatformCustomCursorSource::Animation { duration, cursors }, + }) + } +} + +/// An error produced when using [`CustomCursor::from_animation`] with invalid arguments. +#[derive(Debug, Clone)] +pub enum BadAnimation { + /// Produced when no cursors were supplied. + Empty, + /// Produced when a supplied cursor is an animation. + Animation, +} + +impl fmt::Display for BadAnimation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "No cursors supplied"), + Self::Animation => write!(f, "A supplied cursor is an animation"), + } + } +} + +impl Error for BadAnimation {} + +#[cfg(not(web_platform))] +struct PlatformCustomCursorFuture; + +#[derive(Debug)] +pub struct CustomCursorFuture(pub(crate) PlatformCustomCursorFuture); + +impl Future for CustomCursorFuture { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + Pin::new(&mut self.0).poll(cx).map_ok(|cursor| CustomCursor { inner: cursor }) + } +} + +#[derive(Clone, Debug)] +pub enum CustomCursorError { + Blob, + Decode(String), + Animation, +} + +impl Display for CustomCursorError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Blob => write!(f, "failed to create `Blob`"), + Self::Decode(error) => write!(f, "failed to decode image: {error}"), + Self::Animation => { + write!(f, "found `CustomCursor` that is an animation when building an animation") + }, + } + } +} + +impl Error for CustomCursorError {} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/windows.rs b/third-party/winit-0.30.13-ferrex/src/platform/windows.rs new file mode 100644 index 00000000..e4593be1 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/windows.rs @@ -0,0 +1,767 @@ +//! # Windows +//! +//! The supported OS version is Windows 7 or higher, though Windows 10 is +//! tested regularly. +use std::borrow::Borrow; +use std::ffi::c_void; +use std::path::Path; + +use crate::dpi::PhysicalSize; +use crate::event::DeviceId; +use crate::event_loop::EventLoopBuilder; +use crate::monitor::MonitorHandle; +use crate::window::{BadIcon, Icon, Window, WindowAttributes}; + +/// Window Handle type used by Win32 API +pub type HWND = isize; +/// Menu Handle type used by Win32 API +pub type HMENU = isize; +/// Monitor Handle type used by Win32 API +pub type HMONITOR = isize; + +/// Describes a system-drawn backdrop material of a window. +/// +/// For a detailed explanation, see [`DWM_SYSTEMBACKDROP_TYPE docs`]. +/// +/// [`DWM_SYSTEMBACKDROP_TYPE docs`]: https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwm_systembackdrop_type +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BackdropType { + /// Corresponds to `DWMSBT_AUTO`. + /// + /// Usually draws a default backdrop effect on the title bar. + #[default] + Auto = 0, + + /// Corresponds to `DWMSBT_NONE`. + None = 1, + + /// Corresponds to `DWMSBT_MAINWINDOW`. + /// + /// Draws the Mica backdrop material. + MainWindow = 2, + + /// Corresponds to `DWMSBT_TRANSIENTWINDOW`. + /// + /// Draws the Background Acrylic backdrop material. + TransientWindow = 3, + + /// Corresponds to `DWMSBT_TABBEDWINDOW`. + /// + /// Draws the Alt Mica backdrop material. + TabbedWindow = 4, +} + +/// Describes a color used by Windows +#[repr(transparent)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Color(u32); + +impl Color { + // Special constant only valid for the window border and therefore modeled using Option + // for user facing code + const NONE: Color = Color(0xfffffffe); + /// Use the system's default color + pub const SYSTEM_DEFAULT: Color = Color(0xffffffff); + + /// Create a new color from the given RGB values + pub const fn from_rgb(r: u8, g: u8, b: u8) -> Self { + Self((r as u32) | ((g as u32) << 8) | ((b as u32) << 16)) + } +} + +impl Default for Color { + fn default() -> Self { + Self::SYSTEM_DEFAULT + } +} + +/// Describes how the corners of a window should look like. +/// +/// For a detailed explanation, see [`DWM_WINDOW_CORNER_PREFERENCE docs`]. +/// +/// [`DWM_WINDOW_CORNER_PREFERENCE docs`]: https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwm_window_corner_preference +#[repr(i32)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CornerPreference { + /// Corresponds to `DWMWCP_DEFAULT`. + /// + /// Let the system decide when to round window corners. + #[default] + Default = 0, + + /// Corresponds to `DWMWCP_DONOTROUND`. + /// + /// Never round window corners. + DoNotRound = 1, + + /// Corresponds to `DWMWCP_ROUND`. + /// + /// Round the corners, if appropriate. + Round = 2, + + /// Corresponds to `DWMWCP_ROUNDSMALL`. + /// + /// Round the corners if appropriate, with a small radius. + RoundSmall = 3, +} + +/// A wrapper around a [`Window`] that ignores thread-specific window handle limitations. +/// +/// See [`WindowBorrowExtWindows::any_thread`] for more information. +#[derive(Debug)] +pub struct AnyThread(W); + +impl> AnyThread { + /// Get a reference to the inner window. + #[inline] + pub fn get_ref(&self) -> &Window { + self.0.borrow() + } + + /// Get a reference to the inner object. + #[inline] + pub fn inner(&self) -> &W { + &self.0 + } + + /// Unwrap and get the inner window. + #[inline] + pub fn into_inner(self) -> W { + self.0 + } +} + +impl> AsRef for AnyThread { + fn as_ref(&self) -> &Window { + self.get_ref() + } +} + +impl> Borrow for AnyThread { + fn borrow(&self) -> &Window { + self.get_ref() + } +} + +impl> std::ops::Deref for AnyThread { + type Target = Window; + + fn deref(&self) -> &Self::Target { + self.get_ref() + } +} + +#[cfg(feature = "rwh_06")] +impl> rwh_06::HasWindowHandle for AnyThread { + fn window_handle(&self) -> Result, rwh_06::HandleError> { + // SAFETY: The top level user has asserted this is only used safely. + unsafe { self.get_ref().window_handle_any_thread() } + } +} + +/// Additional methods on `EventLoop` that are specific to Windows. +pub trait EventLoopBuilderExtWindows { + /// Whether to allow the event loop to be created off of the main thread. + /// + /// By default, the window is only allowed to be created on the main + /// thread, to make platform compatibility easier. + /// + /// # `Window` caveats + /// + /// Note that any `Window` created on the new thread will be destroyed when the thread + /// terminates. Attempting to use a `Window` after its parent thread terminates has + /// unspecified, although explicitly not undefined, behavior. + fn with_any_thread(&mut self, any_thread: bool) -> &mut Self; + + /// Whether to enable process-wide DPI awareness. + /// + /// By default, `winit` will attempt to enable process-wide DPI awareness. If + /// that's undesirable, you can disable it with this function. + /// + /// # Example + /// + /// Disable process-wide DPI awareness. + /// + /// ``` + /// use winit::event_loop::EventLoopBuilder; + /// #[cfg(target_os = "windows")] + /// use winit::platform::windows::EventLoopBuilderExtWindows; + /// + /// let mut builder = EventLoopBuilder::new(); + /// #[cfg(target_os = "windows")] + /// builder.with_dpi_aware(false); + /// # if false { // We can't test this part + /// let event_loop = builder.build(); + /// # } + /// ``` + fn with_dpi_aware(&mut self, dpi_aware: bool) -> &mut Self; + + /// A callback to be executed before dispatching a win32 message to the window procedure. + /// Return true to disable winit's internal message dispatching. + /// + /// # Example + /// + /// ``` + /// # use windows_sys::Win32::UI::WindowsAndMessaging::{ACCEL, CreateAcceleratorTableW, TranslateAcceleratorW, DispatchMessageW, TranslateMessage, MSG}; + /// use winit::event_loop::EventLoopBuilder; + /// #[cfg(target_os = "windows")] + /// use winit::platform::windows::EventLoopBuilderExtWindows; + /// + /// let mut builder = EventLoopBuilder::new(); + /// #[cfg(target_os = "windows")] + /// builder.with_msg_hook(|msg|{ + /// let msg = msg as *const MSG; + /// # let accels: Vec = Vec::new(); + /// let translated = unsafe { + /// TranslateAcceleratorW( + /// (*msg).hwnd, + /// CreateAcceleratorTableW(accels.as_ptr() as _, 1), + /// msg, + /// ) == 1 + /// }; + /// translated + /// }); + /// ``` + fn with_msg_hook(&mut self, callback: F) -> &mut Self + where + F: FnMut(*const c_void) -> bool + 'static; +} + +impl EventLoopBuilderExtWindows for EventLoopBuilder { + #[inline] + fn with_any_thread(&mut self, any_thread: bool) -> &mut Self { + self.platform_specific.any_thread = any_thread; + self + } + + #[inline] + fn with_dpi_aware(&mut self, dpi_aware: bool) -> &mut Self { + self.platform_specific.dpi_aware = dpi_aware; + self + } + + #[inline] + fn with_msg_hook(&mut self, callback: F) -> &mut Self + where + F: FnMut(*const c_void) -> bool + 'static, + { + self.platform_specific.msg_hook = Some(Box::new(callback)); + self + } +} + +/// Additional methods on `Window` that are specific to Windows. +pub trait WindowExtWindows { + /// Enables or disables mouse and keyboard input to the specified window. + /// + /// A window must be enabled before it can be activated. + /// If an application has create a modal dialog box by disabling its owner window + /// (as described in [`WindowAttributesExtWindows::with_owner_window`]), the application must + /// enable the owner window before destroying the dialog box. + /// Otherwise, another window will receive the keyboard focus and be activated. + /// + /// If a child window is disabled, it is ignored when the system tries to determine which + /// window should receive mouse messages. + /// + /// For more information, see + /// and + fn set_enable(&self, enabled: bool); + + /// This sets `ICON_BIG`. A good ceiling here is 256x256. + fn set_taskbar_icon(&self, taskbar_icon: Option); + + /// Whether to show or hide the window icon in the taskbar. + fn set_skip_taskbar(&self, skip: bool); + + /// Shows or hides the background drop shadow for undecorated windows. + /// + /// Enabling the shadow causes a thin 1px line to appear on the top of the window. + fn set_undecorated_shadow(&self, shadow: bool); + + /// Sets system-drawn backdrop type. + /// + /// Requires Windows 11 build 22523+. + fn set_system_backdrop(&self, backdrop_type: BackdropType); + + /// Sets the color of the window border. + /// + /// Supported starting with Windows 11 Build 22000. + fn set_border_color(&self, color: Option); + + /// Sets the background color of the title bar. + /// + /// Supported starting with Windows 11 Build 22000. + fn set_title_background_color(&self, color: Option); + + /// Sets the color of the window title. + /// + /// Supported starting with Windows 11 Build 22000. + fn set_title_text_color(&self, color: Color); + + /// Sets the preferred style of the window corners. + /// + /// Supported starting with Windows 11 Build 22000. + fn set_corner_preference(&self, preference: CornerPreference); + + /// Get the raw window handle for this [`Window`] without checking for thread affinity. + /// + /// Window handles in Win32 have a property called "thread affinity" that ties them to their + /// origin thread. Some operations can only happen on the window's origin thread, while others + /// can be called from any thread. For example, [`SetWindowSubclass`] is not thread safe while + /// [`GetDC`] is thread safe. + /// + /// In Rust terms, the window handle is `Send` sometimes but `!Send` other times. + /// + /// Therefore, in order to avoid confusing threading errors, [`Window`] only returns the + /// window handle when the [`window_handle`] function is called from the thread that created + /// the window. In other cases, it returns an [`Unavailable`] error. + /// + /// However in some cases you may already know that you are using the window handle for + /// operations that are guaranteed to be thread-safe. In which case this function aims + /// to provide an escape hatch so these functions are still accessible from other threads. + /// + /// # Safety + /// + /// It is the responsibility of the user to only pass the window handle into thread-safe + /// Win32 APIs. + /// + /// [`SetWindowSubclass`]: https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-setwindowsubclass + /// [`GetDC`]: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getdc + /// [`Window`]: crate::window::Window + /// [`window_handle`]: https://docs.rs/raw-window-handle/latest/raw_window_handle/trait.HasWindowHandle.html#tymethod.window_handle + /// [`Unavailable`]: https://docs.rs/raw-window-handle/latest/raw_window_handle/enum.HandleError.html#variant.Unavailable + /// + /// ## Example + /// + /// ```no_run + /// # use winit::window::Window; + /// # fn scope(window: Window) { + /// use std::thread; + /// use winit::platform::windows::WindowExtWindows; + /// use winit::raw_window_handle::HasWindowHandle; + /// + /// // We can get the window handle on the current thread. + /// let handle = window.window_handle().unwrap(); + /// + /// // However, on another thread, we can't! + /// thread::spawn(move || { + /// assert!(window.window_handle().is_err()); + /// + /// // We can use this function as an escape hatch. + /// let handle = unsafe { window.window_handle_any_thread().unwrap() }; + /// }); + /// # } + /// ``` + #[cfg(feature = "rwh_06")] + unsafe fn window_handle_any_thread( + &self, + ) -> Result, rwh_06::HandleError>; +} + +impl WindowExtWindows for Window { + #[inline] + fn set_enable(&self, enabled: bool) { + self.window.set_enable(enabled) + } + + #[inline] + fn set_taskbar_icon(&self, taskbar_icon: Option) { + self.window.set_taskbar_icon(taskbar_icon) + } + + #[inline] + fn set_skip_taskbar(&self, skip: bool) { + self.window.set_skip_taskbar(skip) + } + + #[inline] + fn set_undecorated_shadow(&self, shadow: bool) { + self.window.set_undecorated_shadow(shadow) + } + + #[inline] + fn set_system_backdrop(&self, backdrop_type: BackdropType) { + self.window.set_system_backdrop(backdrop_type) + } + + #[inline] + fn set_border_color(&self, color: Option) { + self.window.set_border_color(color.unwrap_or(Color::NONE)) + } + + #[inline] + fn set_title_background_color(&self, color: Option) { + // The windows docs don't mention NONE as a valid options but it works in practice and is + // useful to circumvent the Windows option "Show accent color on title bars and + // window borders" + self.window.set_title_background_color(color.unwrap_or(Color::NONE)) + } + + #[inline] + fn set_title_text_color(&self, color: Color) { + self.window.set_title_text_color(color) + } + + #[inline] + fn set_corner_preference(&self, preference: CornerPreference) { + self.window.set_corner_preference(preference) + } + + #[cfg(feature = "rwh_06")] + unsafe fn window_handle_any_thread( + &self, + ) -> Result, rwh_06::HandleError> { + unsafe { + let handle = self.window.rwh_06_no_thread_check()?; + + // SAFETY: The handle is valid in this context. + Ok(rwh_06::WindowHandle::borrow_raw(handle)) + } + } +} + +/// Additional methods for anything that dereference to [`Window`]. +/// +/// [`Window`]: crate::window::Window +pub trait WindowBorrowExtWindows: Borrow + Sized { + /// Create an object that allows accessing the inner window handle in a thread-unsafe way. + /// + /// It is possible to call [`window_handle_any_thread`] to get around Windows's thread + /// affinity limitations. However, it may be desired to pass the [`Window`] into something + /// that requires the [`HasWindowHandle`] trait, while ignoring thread affinity limitations. + /// + /// This function wraps anything that implements `Borrow` into a structure that + /// uses the inner window handle as a mean of implementing [`HasWindowHandle`]. It wraps + /// `Window`, `&Window`, `Arc`, and other reference types. + /// + /// # Safety + /// + /// It is the responsibility of the user to only pass the window handle into thread-safe + /// Win32 APIs. + /// + /// [`window_handle_any_thread`]: WindowExtWindows::window_handle_any_thread + /// [`Window`]: crate::window::Window + /// [`HasWindowHandle`]: rwh_06::HasWindowHandle + unsafe fn any_thread(self) -> AnyThread { + AnyThread(self) + } +} + +impl + Sized> WindowBorrowExtWindows for W {} + +/// Additional methods on `WindowAttributes` that are specific to Windows. +#[allow(rustdoc::broken_intra_doc_links)] +pub trait WindowAttributesExtWindows { + /// Set an owner to the window to be created. Can be used to create a dialog box, for example. + /// This only works when [`WindowAttributes::with_parent_window`] isn't called or set to `None`. + /// Can be used in combination with + /// [`WindowExtWindows::set_enable(false)`][WindowExtWindows::set_enable] on the owner + /// window to create a modal dialog box. + /// + /// From MSDN: + /// - An owned window is always above its owner in the z-order. + /// - The system automatically destroys an owned window when its owner is destroyed. + /// - An owned window is hidden when its owner is minimized. + /// + /// For more information, see + fn with_owner_window(self, parent: HWND) -> Self; + + /// Sets a menu on the window to be created. + /// + /// Parent and menu are mutually exclusive; a child window cannot have a menu! + /// + /// The menu must have been manually created beforehand with [`CreateMenu`] or similar. + /// + /// Note: Dark mode cannot be supported for win32 menus, it's simply not possible to change how + /// the menus look. If you use this, it is recommended that you combine it with + /// `with_theme(Some(Theme::Light))` to avoid a jarring effect. + #[cfg_attr( + windows_platform, + doc = "[`CreateMenu`]: windows_sys::Win32::UI::WindowsAndMessaging::CreateMenu" + )] + #[cfg_attr(not(windows_platform), doc = "[`CreateMenu`]: #only-available-on-windows")] + fn with_menu(self, menu: HMENU) -> Self; + + /// This sets `ICON_BIG`. A good ceiling here is 256x256. + fn with_taskbar_icon(self, taskbar_icon: Option) -> Self; + + /// This sets `WS_EX_NOREDIRECTIONBITMAP`. + fn with_no_redirection_bitmap(self, flag: bool) -> Self; + + /// Enables or disables drag and drop support (enabled by default). Will interfere with other + /// crates that use multi-threaded COM API (`CoInitializeEx` with `COINIT_MULTITHREADED` + /// instead of `COINIT_APARTMENTTHREADED`) on the same thread. Note that winit may still + /// attempt to initialize COM API regardless of this option. Currently only fullscreen mode + /// does that, but there may be more in the future. If you need COM API with + /// `COINIT_MULTITHREADED` you must initialize it before calling any winit functions. See for more information. + fn with_drag_and_drop(self, flag: bool) -> Self; + + /// Whether show or hide the window icon in the taskbar. + fn with_skip_taskbar(self, skip: bool) -> Self; + + /// Customize the window class name. + fn with_class_name>(self, class_name: S) -> Self; + + /// Shows or hides the background drop shadow for undecorated windows. + /// + /// The shadow is hidden by default. + /// Enabling the shadow causes a thin 1px line to appear on the top of the window. + fn with_undecorated_shadow(self, shadow: bool) -> Self; + + /// Sets system-drawn backdrop type. + /// + /// Requires Windows 11 build 22523+. + fn with_system_backdrop(self, backdrop_type: BackdropType) -> Self; + + /// This sets or removes `WS_CLIPCHILDREN` style. + fn with_clip_children(self, flag: bool) -> Self; + + /// Sets the color of the window border. + /// + /// Supported starting with Windows 11 Build 22000. + fn with_border_color(self, color: Option) -> Self; + + /// Sets the background color of the title bar. + /// + /// Supported starting with Windows 11 Build 22000. + fn with_title_background_color(self, color: Option) -> Self; + + /// Sets the color of the window title. + /// + /// Supported starting with Windows 11 Build 22000. + fn with_title_text_color(self, color: Color) -> Self; + + /// Sets the preferred style of the window corners. + /// + /// Supported starting with Windows 11 Build 22000. + fn with_corner_preference(self, corners: CornerPreference) -> Self; +} + +impl WindowAttributesExtWindows for WindowAttributes { + #[inline] + fn with_owner_window(mut self, parent: HWND) -> Self { + self.platform_specific.owner = Some(parent); + self + } + + #[inline] + fn with_menu(mut self, menu: HMENU) -> Self { + self.platform_specific.menu = Some(menu); + self + } + + #[inline] + fn with_taskbar_icon(mut self, taskbar_icon: Option) -> Self { + self.platform_specific.taskbar_icon = taskbar_icon; + self + } + + #[inline] + fn with_no_redirection_bitmap(mut self, flag: bool) -> Self { + self.platform_specific.no_redirection_bitmap = flag; + self + } + + #[inline] + fn with_drag_and_drop(mut self, flag: bool) -> Self { + self.platform_specific.drag_and_drop = flag; + self + } + + #[inline] + fn with_skip_taskbar(mut self, skip: bool) -> Self { + self.platform_specific.skip_taskbar = skip; + self + } + + #[inline] + fn with_class_name>(mut self, class_name: S) -> Self { + self.platform_specific.class_name = class_name.into(); + self + } + + #[inline] + fn with_undecorated_shadow(mut self, shadow: bool) -> Self { + self.platform_specific.decoration_shadow = shadow; + self + } + + #[inline] + fn with_system_backdrop(mut self, backdrop_type: BackdropType) -> Self { + self.platform_specific.backdrop_type = backdrop_type; + self + } + + #[inline] + fn with_clip_children(mut self, flag: bool) -> Self { + self.platform_specific.clip_children = flag; + self + } + + #[inline] + fn with_border_color(mut self, color: Option) -> Self { + self.platform_specific.border_color = Some(color.unwrap_or(Color::NONE)); + self + } + + #[inline] + fn with_title_background_color(mut self, color: Option) -> Self { + self.platform_specific.title_background_color = Some(color.unwrap_or(Color::NONE)); + self + } + + #[inline] + fn with_title_text_color(mut self, color: Color) -> Self { + self.platform_specific.title_text_color = Some(color); + self + } + + #[inline] + fn with_corner_preference(mut self, corners: CornerPreference) -> Self { + self.platform_specific.corner_preference = Some(corners); + self + } +} + +/// Additional methods on `MonitorHandle` that are specific to Windows. +pub trait MonitorHandleExtWindows { + /// Returns the name of the monitor adapter specific to the Win32 API. + fn native_id(&self) -> String; + + /// Returns the handle of the monitor - `HMONITOR`. + fn hmonitor(&self) -> HMONITOR; +} + +impl MonitorHandleExtWindows for MonitorHandle { + #[inline] + fn native_id(&self) -> String { + self.inner.native_identifier() + } + + #[inline] + fn hmonitor(&self) -> HMONITOR { + self.inner.hmonitor() + } +} + +/// Additional methods on `DeviceId` that are specific to Windows. +pub trait DeviceIdExtWindows { + /// Returns an identifier that persistently refers to this specific device. + /// + /// Will return `None` if the device is no longer available. + fn persistent_identifier(&self) -> Option; +} + +impl DeviceIdExtWindows for DeviceId { + #[inline] + fn persistent_identifier(&self) -> Option { + self.0.persistent_identifier() + } +} + +/// Additional methods on `Icon` that are specific to Windows. +/// +/// Windows icons can be created from files, or from the [`embedded resources`](https://learn.microsoft.com/en-us/windows/win32/menurc/about-resource-files). +/// +/// The `ICON` resource definition statement use the following syntax: +/// ```rc +/// nameID ICON filename +/// ``` +/// `nameID` is a unique name or a 16-bit unsigned integer value identifying the resource, +/// `filename` is the name of the file that contains the resource. +/// +/// More information about the `ICON` resource can be found at [`Microsoft Learn`](https://learn.microsoft.com/en-us/windows/win32/menurc/icon-resource) portal. +pub trait IconExtWindows: Sized { + /// Create an icon from a file path. + /// + /// Specify `size` to load a specific icon size from the file, or `None` to load the default + /// icon size from the file. + /// + /// In cases where the specified size does not exist in the file, Windows may perform scaling + /// to get an icon of the desired size. + fn from_path>(path: P, size: Option>) + -> Result; + + /// Create an icon from a resource embedded in this executable or library by its ordinal id. + /// + /// The valid `ordinal` values range from 1 to [`u16::MAX`] (inclusive). The value `0` is an + /// invalid ordinal id, but it can be used with [`from_resource_name`] as `"0"`. + /// + /// [`from_resource_name`]: IconExtWindows::from_resource_name + /// + /// Specify `size` to load a specific icon size from the file, or `None` to load the default + /// icon size from the file. + /// + /// In cases where the specified size does not exist in the file, Windows may perform scaling + /// to get an icon of the desired size. + fn from_resource(ordinal: u16, size: Option>) -> Result; + + /// Create an icon from a resource embedded in this executable or library by its name. + /// + /// Specify `size` to load a specific icon size from the file, or `None` to load the default + /// icon size from the file. + /// + /// In cases where the specified size does not exist in the file, Windows may perform scaling + /// to get an icon of the desired size. + /// + /// # Notes + /// + /// Consider the following resource definition statements: + /// ```rc + /// app ICON "app.ico" + /// 1 ICON "a.ico" + /// 0027 ICON "custom.ico" + /// 0 ICON "alt.ico" + /// ``` + /// + /// Due to some internal implementation details of the resource embedding/loading process on + /// Windows platform, strings that can be interpreted as 16-bit unsigned integers (`"1"`, + /// `"002"`, etc.) cannot be used as valid resource names, and instead should be passed into + /// [`from_resource`]: + /// + /// [`from_resource`]: IconExtWindows::from_resource + /// + /// ```rust,no_run + /// use winit::platform::windows::IconExtWindows; + /// use winit::window::Icon; + /// + /// assert!(Icon::from_resource_name("app", None).is_ok()); + /// assert!(Icon::from_resource(1, None).is_ok()); + /// assert!(Icon::from_resource(27, None).is_ok()); + /// assert!(Icon::from_resource_name("27", None).is_err()); + /// assert!(Icon::from_resource_name("0027", None).is_err()); + /// ``` + /// + /// While `0` cannot be used as an ordinal id (see [`from_resource`]), it can be used as a + /// name: + /// + /// [`from_resource`]: IconExtWindows::from_resource + /// + /// ```rust,no_run + /// # use winit::platform::windows::IconExtWindows; + /// # use winit::window::Icon; + /// assert!(Icon::from_resource_name("0", None).is_ok()); + /// assert!(Icon::from_resource(0, None).is_err()); + /// ``` + fn from_resource_name(name: &str, size: Option>) -> Result; +} + +impl IconExtWindows for Icon { + fn from_path>( + path: P, + size: Option>, + ) -> Result { + let win_icon = crate::platform_impl::WinIcon::from_path(path, size)?; + Ok(Icon { inner: win_icon }) + } + + fn from_resource(ordinal: u16, size: Option>) -> Result { + let win_icon = crate::platform_impl::WinIcon::from_resource(ordinal, size)?; + Ok(Icon { inner: win_icon }) + } + + fn from_resource_name(name: &str, size: Option>) -> Result { + let win_icon = crate::platform_impl::WinIcon::from_resource_name(name, size)?; + Ok(Icon { inner: win_icon }) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform/x11.rs b/third-party/winit-0.30.13-ferrex/src/platform/x11.rs new file mode 100644 index 00000000..4ab900c9 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform/x11.rs @@ -0,0 +1,254 @@ +//! # X11 +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::event_loop::{ActiveEventLoop, EventLoop, EventLoopBuilder}; +use crate::monitor::MonitorHandle; +use crate::window::{Window, WindowAttributes}; + +use crate::dpi::Size; + +/// X window type. Maps directly to +/// [`_NET_WM_WINDOW_TYPE`](https://specifications.freedesktop.org/wm-spec/wm-spec-1.5.html). +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum WindowType { + /// A desktop feature. This can include a single window containing desktop icons with the same + /// dimensions as the screen, allowing the desktop environment to have full control of the + /// desktop, without the need for proxying root window clicks. + Desktop, + /// A dock or panel feature. Typically a Window Manager would keep such windows on top of all + /// other windows. + Dock, + /// Toolbar windows. "Torn off" from the main application. + Toolbar, + /// Pinnable menu windows. "Torn off" from the main application. + Menu, + /// A small persistent utility window, such as a palette or toolbox. + Utility, + /// The window is a splash screen displayed as an application is starting up. + Splash, + /// This is a dialog window. + Dialog, + /// A dropdown menu that usually appears when the user clicks on an item in a menu bar. + /// This property is typically used on override-redirect windows. + DropdownMenu, + /// A popup menu that usually appears when the user right clicks on an object. + /// This property is typically used on override-redirect windows. + PopupMenu, + /// A tooltip window. Usually used to show additional information when hovering over an object + /// with the cursor. This property is typically used on override-redirect windows. + Tooltip, + /// The window is a notification. + /// This property is typically used on override-redirect windows. + Notification, + /// This should be used on the windows that are popped up by combo boxes. + /// This property is typically used on override-redirect windows. + Combo, + /// This indicates the window is being dragged. + /// This property is typically used on override-redirect windows. + Dnd, + /// This is a normal, top-level window. + #[default] + Normal, +} + +/// The first argument in the provided hook will be the pointer to `XDisplay` +/// and the second one the pointer to [`XErrorEvent`]. The returned `bool` is an +/// indicator whether the error was handled by the callback. +/// +/// [`XErrorEvent`]: https://linux.die.net/man/3/xerrorevent +pub type XlibErrorHook = + Box bool + Send + Sync>; + +/// A unique identifier for an X11 visual. +pub type XVisualID = u32; + +/// A unique identifier for an X11 window. +pub type XWindow = u32; + +/// Hook to winit's xlib error handling callback. +/// +/// This method is provided as a safe way to handle the errors coming from X11 +/// when using xlib in external crates, like glutin for GLX access. Trying to +/// handle errors by speculating with `XSetErrorHandler` is [`unsafe`]. +/// +/// **Be aware that your hook is always invoked and returning `true` from it will +/// prevent `winit` from getting the error itself. It's wise to always return +/// `false` if you're not initiated the `Sync`.** +/// +/// [`unsafe`]: https://www.remlab.net/op/xlib.shtml +#[inline] +pub fn register_xlib_error_hook(hook: XlibErrorHook) { + // Append new hook. + crate::platform_impl::XLIB_ERROR_HOOKS.lock().unwrap().push(hook); +} + +/// Additional methods on [`ActiveEventLoop`] that are specific to X11. +pub trait ActiveEventLoopExtX11 { + /// True if the [`ActiveEventLoop`] uses X11. + fn is_x11(&self) -> bool; +} + +impl ActiveEventLoopExtX11 for ActiveEventLoop { + #[inline] + fn is_x11(&self) -> bool { + !self.p.is_wayland() + } +} + +/// Additional methods on [`EventLoop`] that are specific to X11. +pub trait EventLoopExtX11 { + /// True if the [`EventLoop`] uses X11. + fn is_x11(&self) -> bool; +} + +impl EventLoopExtX11 for EventLoop { + #[inline] + fn is_x11(&self) -> bool { + !self.event_loop.is_wayland() + } +} + +/// Additional methods on [`EventLoopBuilder`] that are specific to X11. +pub trait EventLoopBuilderExtX11 { + /// Force using X11. + fn with_x11(&mut self) -> &mut Self; + + /// Whether to allow the event loop to be created off of the main thread. + /// + /// By default, the window is only allowed to be created on the main + /// thread, to make platform compatibility easier. + fn with_any_thread(&mut self, any_thread: bool) -> &mut Self; +} + +impl EventLoopBuilderExtX11 for EventLoopBuilder { + #[inline] + fn with_x11(&mut self) -> &mut Self { + self.platform_specific.forced_backend = Some(crate::platform_impl::Backend::X); + self + } + + #[inline] + fn with_any_thread(&mut self, any_thread: bool) -> &mut Self { + self.platform_specific.any_thread = any_thread; + self + } +} + +/// Additional methods on [`Window`] that are specific to X11. +pub trait WindowExtX11 {} + +impl WindowExtX11 for Window {} + +/// Additional methods on [`WindowAttributes`] that are specific to X11. +pub trait WindowAttributesExtX11 { + /// Create this window with a specific X11 visual. + fn with_x11_visual(self, visual_id: XVisualID) -> Self; + + fn with_x11_screen(self, screen_id: i32) -> Self; + + /// Build window with the given `general` and `instance` names. + /// + /// The `general` sets general class of `WM_CLASS(STRING)`, while `instance` set the + /// instance part of it. The resulted property looks like `WM_CLASS(STRING) = "instance", + /// "general"`. + /// + /// For details about application ID conventions, see the + /// [Desktop Entry Spec](https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#desktop-file-id) + fn with_name(self, general: impl Into, instance: impl Into) -> Self; + + /// Build window with override-redirect flag; defaults to false. + fn with_override_redirect(self, override_redirect: bool) -> Self; + + /// Build window with `_NET_WM_WINDOW_TYPE` hints; defaults to `Normal`. + fn with_x11_window_type(self, x11_window_type: Vec) -> Self; + + /// Build window with base size hint. + /// + /// ``` + /// # use winit::dpi::{LogicalSize, PhysicalSize}; + /// # use winit::window::Window; + /// # use winit::platform::x11::WindowAttributesExtX11; + /// // Specify the size in logical dimensions like this: + /// Window::default_attributes().with_base_size(LogicalSize::new(400.0, 200.0)); + /// + /// // Or specify the size in physical dimensions like this: + /// Window::default_attributes().with_base_size(PhysicalSize::new(400, 200)); + /// ``` + fn with_base_size>(self, base_size: S) -> Self; + + /// Embed this window into another parent window. + /// + /// # Example + /// + /// ```no_run + /// use winit::window::Window; + /// use winit::event_loop::ActiveEventLoop; + /// use winit::platform::x11::{XWindow, WindowAttributesExtX11}; + /// # fn create_window(event_loop: &ActiveEventLoop) -> Result<(), Box> { + /// let parent_window_id = std::env::args().nth(1).unwrap().parse::()?; + /// let window_attributes = Window::default_attributes().with_embed_parent_window(parent_window_id); + /// let window = event_loop.create_window(window_attributes)?; + /// # Ok(()) } + /// ``` + fn with_embed_parent_window(self, parent_window_id: XWindow) -> Self; +} + +impl WindowAttributesExtX11 for WindowAttributes { + #[inline] + fn with_x11_visual(mut self, visual_id: XVisualID) -> Self { + self.platform_specific.x11.visual_id = Some(visual_id); + self + } + + #[inline] + fn with_x11_screen(mut self, screen_id: i32) -> Self { + self.platform_specific.x11.screen_id = Some(screen_id); + self + } + + #[inline] + fn with_name(mut self, general: impl Into, instance: impl Into) -> Self { + self.platform_specific.name = + Some(crate::platform_impl::ApplicationName::new(general.into(), instance.into())); + self + } + + #[inline] + fn with_override_redirect(mut self, override_redirect: bool) -> Self { + self.platform_specific.x11.override_redirect = override_redirect; + self + } + + #[inline] + fn with_x11_window_type(mut self, x11_window_types: Vec) -> Self { + self.platform_specific.x11.x11_window_types = x11_window_types; + self + } + + #[inline] + fn with_base_size>(mut self, base_size: S) -> Self { + self.platform_specific.x11.base_size = Some(base_size.into()); + self + } + + #[inline] + fn with_embed_parent_window(mut self, parent_window_id: XWindow) -> Self { + self.platform_specific.x11.embed_window = Some(parent_window_id); + self + } +} + +/// Additional methods on `MonitorHandle` that are specific to X11. +pub trait MonitorHandleExtX11 { + /// Returns the inner identifier of the monitor. + fn native_id(&self) -> u32; +} + +impl MonitorHandleExtX11 for MonitorHandle { + #[inline] + fn native_id(&self) -> u32 { + self.inner.native_identifier() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/android/keycodes.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/android/keycodes.rs new file mode 100644 index 00000000..207d549f --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/android/keycodes.rs @@ -0,0 +1,608 @@ +use android_activity::input::{KeyAction, KeyEvent, KeyMapChar, Keycode}; +use android_activity::AndroidApp; + +use crate::keyboard::{Key, KeyCode, KeyLocation, NamedKey, NativeKey, NativeKeyCode, PhysicalKey}; + +pub fn to_physical_key(keycode: Keycode) -> PhysicalKey { + PhysicalKey::Code(match keycode { + Keycode::A => KeyCode::KeyA, + Keycode::B => KeyCode::KeyB, + Keycode::C => KeyCode::KeyC, + Keycode::D => KeyCode::KeyD, + Keycode::E => KeyCode::KeyE, + Keycode::F => KeyCode::KeyF, + Keycode::G => KeyCode::KeyG, + Keycode::H => KeyCode::KeyH, + Keycode::I => KeyCode::KeyI, + Keycode::J => KeyCode::KeyJ, + Keycode::K => KeyCode::KeyK, + Keycode::L => KeyCode::KeyL, + Keycode::M => KeyCode::KeyM, + Keycode::N => KeyCode::KeyN, + Keycode::O => KeyCode::KeyO, + Keycode::P => KeyCode::KeyP, + Keycode::Q => KeyCode::KeyQ, + Keycode::R => KeyCode::KeyR, + Keycode::S => KeyCode::KeyS, + Keycode::T => KeyCode::KeyT, + Keycode::U => KeyCode::KeyU, + Keycode::V => KeyCode::KeyV, + Keycode::W => KeyCode::KeyW, + Keycode::X => KeyCode::KeyX, + Keycode::Y => KeyCode::KeyY, + Keycode::Z => KeyCode::KeyZ, + + Keycode::Keycode0 => KeyCode::Digit0, + Keycode::Keycode1 => KeyCode::Digit1, + Keycode::Keycode2 => KeyCode::Digit2, + Keycode::Keycode3 => KeyCode::Digit3, + Keycode::Keycode4 => KeyCode::Digit4, + Keycode::Keycode5 => KeyCode::Digit5, + Keycode::Keycode6 => KeyCode::Digit6, + Keycode::Keycode7 => KeyCode::Digit7, + Keycode::Keycode8 => KeyCode::Digit8, + Keycode::Keycode9 => KeyCode::Digit9, + + Keycode::Numpad0 => KeyCode::Numpad0, + Keycode::Numpad1 => KeyCode::Numpad1, + Keycode::Numpad2 => KeyCode::Numpad2, + Keycode::Numpad3 => KeyCode::Numpad3, + Keycode::Numpad4 => KeyCode::Numpad4, + Keycode::Numpad5 => KeyCode::Numpad5, + Keycode::Numpad6 => KeyCode::Numpad6, + Keycode::Numpad7 => KeyCode::Numpad7, + Keycode::Numpad8 => KeyCode::Numpad8, + Keycode::Numpad9 => KeyCode::Numpad9, + + Keycode::NumpadAdd => KeyCode::NumpadAdd, + Keycode::NumpadSubtract => KeyCode::NumpadSubtract, + Keycode::NumpadMultiply => KeyCode::NumpadMultiply, + Keycode::NumpadDivide => KeyCode::NumpadDivide, + Keycode::NumpadEnter => KeyCode::NumpadEnter, + Keycode::NumpadEquals => KeyCode::NumpadEqual, + Keycode::NumpadComma => KeyCode::NumpadComma, + Keycode::NumpadDot => KeyCode::NumpadDecimal, + Keycode::NumLock => KeyCode::NumLock, + + Keycode::DpadLeft => KeyCode::ArrowLeft, + Keycode::DpadRight => KeyCode::ArrowRight, + Keycode::DpadUp => KeyCode::ArrowUp, + Keycode::DpadDown => KeyCode::ArrowDown, + + Keycode::F1 => KeyCode::F1, + Keycode::F2 => KeyCode::F2, + Keycode::F3 => KeyCode::F3, + Keycode::F4 => KeyCode::F4, + Keycode::F5 => KeyCode::F5, + Keycode::F6 => KeyCode::F6, + Keycode::F7 => KeyCode::F7, + Keycode::F8 => KeyCode::F8, + Keycode::F9 => KeyCode::F9, + Keycode::F10 => KeyCode::F10, + Keycode::F11 => KeyCode::F11, + Keycode::F12 => KeyCode::F12, + + Keycode::Space => KeyCode::Space, + Keycode::Escape => KeyCode::Escape, + Keycode::Enter => KeyCode::Enter, // not on the Numpad + Keycode::Tab => KeyCode::Tab, + + Keycode::PageUp => KeyCode::PageUp, + Keycode::PageDown => KeyCode::PageDown, + Keycode::MoveHome => KeyCode::Home, + Keycode::MoveEnd => KeyCode::End, + Keycode::Insert => KeyCode::Insert, + + Keycode::Del => KeyCode::Backspace, // Backspace (above Enter) + Keycode::ForwardDel => KeyCode::Delete, // Delete (below Insert) + + Keycode::Copy => KeyCode::Copy, + Keycode::Paste => KeyCode::Paste, + Keycode::Cut => KeyCode::Cut, + + Keycode::VolumeUp => KeyCode::AudioVolumeUp, + Keycode::VolumeDown => KeyCode::AudioVolumeDown, + Keycode::VolumeMute => KeyCode::AudioVolumeMute, + // Keycode::Mute => None, // Microphone mute + Keycode::MediaPlayPause => KeyCode::MediaPlayPause, + Keycode::MediaStop => KeyCode::MediaStop, + Keycode::MediaNext => KeyCode::MediaTrackNext, + Keycode::MediaPrevious => KeyCode::MediaTrackPrevious, + + Keycode::Plus => KeyCode::Equal, + Keycode::Minus => KeyCode::Minus, + // Winit doesn't differentiate both '+' and '=', considering they are usually + // on the same physical key + Keycode::Equals => KeyCode::Equal, + Keycode::Semicolon => KeyCode::Semicolon, + Keycode::Slash => KeyCode::Slash, + Keycode::Backslash => KeyCode::Backslash, + Keycode::Comma => KeyCode::Comma, + Keycode::Period => KeyCode::Period, + Keycode::Apostrophe => KeyCode::Quote, + Keycode::Grave => KeyCode::Backquote, + + // Winit doesn't expose a SysRq code, so map to PrintScreen since it's + // usually the same physical key + Keycode::Sysrq => KeyCode::PrintScreen, + // These are usually the same (Pause/Break) + Keycode::Break => KeyCode::Pause, + // These are exactly the same + Keycode::ScrollLock => KeyCode::ScrollLock, + + Keycode::Yen => KeyCode::IntlYen, + Keycode::Kana => KeyCode::Lang1, + Keycode::KatakanaHiragana => KeyCode::KanaMode, + + Keycode::CtrlLeft => KeyCode::ControlLeft, + Keycode::CtrlRight => KeyCode::ControlRight, + + Keycode::ShiftLeft => KeyCode::ShiftLeft, + Keycode::ShiftRight => KeyCode::ShiftRight, + + Keycode::AltLeft => KeyCode::AltLeft, + Keycode::AltRight => KeyCode::AltRight, + + Keycode::MetaLeft => KeyCode::SuperLeft, + Keycode::MetaRight => KeyCode::SuperRight, + + Keycode::LeftBracket => KeyCode::BracketLeft, + Keycode::RightBracket => KeyCode::BracketRight, + + Keycode::Power => KeyCode::Power, + Keycode::Sleep => KeyCode::Sleep, // what about SoftSleep? + Keycode::Wakeup => KeyCode::WakeUp, + + keycode => return PhysicalKey::Unidentified(NativeKeyCode::Android(keycode.into())), + }) +} + +/// Tries to map the `key_event` to a `KeyMapChar` containing a unicode character or dead key accent +/// +/// This takes a `KeyEvent` and looks up its corresponding `KeyCharacterMap` and +/// uses that to try and map the `key_code` + `meta_state` to a unicode +/// character or a dead key that can be combined with the next key press. +pub fn character_map_and_combine_key( + app: &AndroidApp, + key_event: &KeyEvent<'_>, + combining_accent: &mut Option, +) -> Option { + let device_id = key_event.device_id(); + + let key_map = match app.device_key_character_map(device_id) { + Ok(key_map) => key_map, + Err(err) => { + tracing::warn!("Failed to look up `KeyCharacterMap` for device {device_id}: {err:?}"); + return None; + }, + }; + + match key_map.get(key_event.key_code(), key_event.meta_state()) { + Ok(KeyMapChar::Unicode(unicode)) => { + // Only do dead key combining on key down + if key_event.action() == KeyAction::Down { + let combined_unicode = if let Some(accent) = combining_accent { + match key_map.get_dead_char(*accent, unicode) { + Ok(Some(key)) => Some(key), + Ok(None) => None, + Err(err) => { + tracing::warn!( + "KeyEvent: Failed to combine 'dead key' accent '{accent}' with \ + '{unicode}': {err:?}" + ); + None + }, + } + } else { + Some(unicode) + }; + *combining_accent = None; + combined_unicode.map(KeyMapChar::Unicode) + } else { + Some(KeyMapChar::Unicode(unicode)) + } + }, + Ok(KeyMapChar::CombiningAccent(accent)) => { + if key_event.action() == KeyAction::Down { + *combining_accent = Some(accent); + } + Some(KeyMapChar::CombiningAccent(accent)) + }, + Ok(KeyMapChar::None) => { + // Leave any combining_accent state in tact (seems to match how other + // Android apps work) + None + }, + Err(err) => { + tracing::warn!("KeyEvent: Failed to get key map character: {err:?}"); + *combining_accent = None; + None + }, + } +} + +pub fn to_logical(key_char: Option, keycode: Keycode) -> Key { + use android_activity::input::Keycode::*; + + let native = NativeKey::Android(keycode.into()); + + match key_char { + Some(KeyMapChar::Unicode(c)) => Key::Character(smol_str::SmolStr::from_iter([c])), + Some(KeyMapChar::CombiningAccent(c)) => Key::Dead(Some(c)), + None | Some(KeyMapChar::None) => match keycode { + // Using `BrowserHome` instead of `GoHome` according to + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values + Home => Key::Named(NamedKey::BrowserHome), + Back => Key::Named(NamedKey::BrowserBack), + Call => Key::Named(NamedKey::Call), + Endcall => Key::Named(NamedKey::EndCall), + + //------------------------------------------------------------------------------- + // These should be redundant because they should have already been matched + // as `KeyMapChar::Unicode`, but also matched here as a fallback + Keycode0 => Key::Character("0".into()), + Keycode1 => Key::Character("1".into()), + Keycode2 => Key::Character("2".into()), + Keycode3 => Key::Character("3".into()), + Keycode4 => Key::Character("4".into()), + Keycode5 => Key::Character("5".into()), + Keycode6 => Key::Character("6".into()), + Keycode7 => Key::Character("7".into()), + Keycode8 => Key::Character("8".into()), + Keycode9 => Key::Character("9".into()), + Star => Key::Character("*".into()), + Pound => Key::Character("#".into()), + A => Key::Character("a".into()), + B => Key::Character("b".into()), + C => Key::Character("c".into()), + D => Key::Character("d".into()), + E => Key::Character("e".into()), + F => Key::Character("f".into()), + G => Key::Character("g".into()), + H => Key::Character("h".into()), + I => Key::Character("i".into()), + J => Key::Character("j".into()), + K => Key::Character("k".into()), + L => Key::Character("l".into()), + M => Key::Character("m".into()), + N => Key::Character("n".into()), + O => Key::Character("o".into()), + P => Key::Character("p".into()), + Q => Key::Character("q".into()), + R => Key::Character("r".into()), + S => Key::Character("s".into()), + T => Key::Character("t".into()), + U => Key::Character("u".into()), + V => Key::Character("v".into()), + W => Key::Character("w".into()), + X => Key::Character("x".into()), + Y => Key::Character("y".into()), + Z => Key::Character("z".into()), + Comma => Key::Character(",".into()), + Period => Key::Character(".".into()), + Grave => Key::Character("`".into()), + Minus => Key::Character("-".into()), + Equals => Key::Character("=".into()), + LeftBracket => Key::Character("[".into()), + RightBracket => Key::Character("]".into()), + Backslash => Key::Character("\\".into()), + Semicolon => Key::Character(";".into()), + Apostrophe => Key::Character("'".into()), + Slash => Key::Character("/".into()), + At => Key::Character("@".into()), + Plus => Key::Character("+".into()), + //------------------------------------------------------------------------------- + DpadUp => Key::Named(NamedKey::ArrowUp), + DpadDown => Key::Named(NamedKey::ArrowDown), + DpadLeft => Key::Named(NamedKey::ArrowLeft), + DpadRight => Key::Named(NamedKey::ArrowRight), + DpadCenter => Key::Named(NamedKey::Enter), + + VolumeUp => Key::Named(NamedKey::AudioVolumeUp), + VolumeDown => Key::Named(NamedKey::AudioVolumeDown), + Power => Key::Named(NamedKey::Power), + Camera => Key::Named(NamedKey::Camera), + Clear => Key::Named(NamedKey::Clear), + + AltLeft => Key::Named(NamedKey::Alt), + AltRight => Key::Named(NamedKey::Alt), + ShiftLeft => Key::Named(NamedKey::Shift), + ShiftRight => Key::Named(NamedKey::Shift), + Tab => Key::Named(NamedKey::Tab), + Space => Key::Named(NamedKey::Space), + Sym => Key::Named(NamedKey::Symbol), + Explorer => Key::Named(NamedKey::LaunchWebBrowser), + Envelope => Key::Named(NamedKey::LaunchMail), + Enter => Key::Named(NamedKey::Enter), + Del => Key::Named(NamedKey::Backspace), + + // According to https://developer.android.com/reference/android/view/KeyEvent#KEYCODE_NUM + Num => Key::Named(NamedKey::Alt), + + Headsethook => Key::Named(NamedKey::HeadsetHook), + Focus => Key::Named(NamedKey::CameraFocus), + + Notification => Key::Named(NamedKey::Notification), + Search => Key::Named(NamedKey::BrowserSearch), + MediaPlayPause => Key::Named(NamedKey::MediaPlayPause), + MediaStop => Key::Named(NamedKey::MediaStop), + MediaNext => Key::Named(NamedKey::MediaTrackNext), + MediaPrevious => Key::Named(NamedKey::MediaTrackPrevious), + MediaRewind => Key::Named(NamedKey::MediaRewind), + MediaFastForward => Key::Named(NamedKey::MediaFastForward), + Mute => Key::Named(NamedKey::MicrophoneVolumeMute), + PageUp => Key::Named(NamedKey::PageUp), + PageDown => Key::Named(NamedKey::PageDown), + + Escape => Key::Named(NamedKey::Escape), + ForwardDel => Key::Named(NamedKey::Delete), + CtrlLeft => Key::Named(NamedKey::Control), + CtrlRight => Key::Named(NamedKey::Control), + CapsLock => Key::Named(NamedKey::CapsLock), + ScrollLock => Key::Named(NamedKey::ScrollLock), + MetaLeft => Key::Named(NamedKey::Super), + MetaRight => Key::Named(NamedKey::Super), + Function => Key::Named(NamedKey::Fn), + Sysrq => Key::Named(NamedKey::PrintScreen), + Break => Key::Named(NamedKey::Pause), + MoveHome => Key::Named(NamedKey::Home), + MoveEnd => Key::Named(NamedKey::End), + Insert => Key::Named(NamedKey::Insert), + Forward => Key::Named(NamedKey::BrowserForward), + MediaPlay => Key::Named(NamedKey::MediaPlay), + MediaPause => Key::Named(NamedKey::MediaPause), + MediaClose => Key::Named(NamedKey::MediaClose), + MediaEject => Key::Named(NamedKey::Eject), + MediaRecord => Key::Named(NamedKey::MediaRecord), + F1 => Key::Named(NamedKey::F1), + F2 => Key::Named(NamedKey::F2), + F3 => Key::Named(NamedKey::F3), + F4 => Key::Named(NamedKey::F4), + F5 => Key::Named(NamedKey::F5), + F6 => Key::Named(NamedKey::F6), + F7 => Key::Named(NamedKey::F7), + F8 => Key::Named(NamedKey::F8), + F9 => Key::Named(NamedKey::F9), + F10 => Key::Named(NamedKey::F10), + F11 => Key::Named(NamedKey::F11), + F12 => Key::Named(NamedKey::F12), + NumLock => Key::Named(NamedKey::NumLock), + Numpad0 => Key::Character("0".into()), + Numpad1 => Key::Character("1".into()), + Numpad2 => Key::Character("2".into()), + Numpad3 => Key::Character("3".into()), + Numpad4 => Key::Character("4".into()), + Numpad5 => Key::Character("5".into()), + Numpad6 => Key::Character("6".into()), + Numpad7 => Key::Character("7".into()), + Numpad8 => Key::Character("8".into()), + Numpad9 => Key::Character("9".into()), + NumpadDivide => Key::Character("/".into()), + NumpadMultiply => Key::Character("*".into()), + NumpadSubtract => Key::Character("-".into()), + NumpadAdd => Key::Character("+".into()), + NumpadDot => Key::Character(".".into()), + NumpadComma => Key::Character(",".into()), + NumpadEnter => Key::Named(NamedKey::Enter), + NumpadEquals => Key::Character("=".into()), + NumpadLeftParen => Key::Character("(".into()), + NumpadRightParen => Key::Character(")".into()), + + VolumeMute => Key::Named(NamedKey::AudioVolumeMute), + Info => Key::Named(NamedKey::Info), + ChannelUp => Key::Named(NamedKey::ChannelUp), + ChannelDown => Key::Named(NamedKey::ChannelDown), + ZoomIn => Key::Named(NamedKey::ZoomIn), + ZoomOut => Key::Named(NamedKey::ZoomOut), + Tv => Key::Named(NamedKey::TV), + Guide => Key::Named(NamedKey::Guide), + Dvr => Key::Named(NamedKey::DVR), + Bookmark => Key::Named(NamedKey::BrowserFavorites), + Captions => Key::Named(NamedKey::ClosedCaptionToggle), + Settings => Key::Named(NamedKey::Settings), + TvPower => Key::Named(NamedKey::TVPower), + TvInput => Key::Named(NamedKey::TVInput), + StbPower => Key::Named(NamedKey::STBPower), + StbInput => Key::Named(NamedKey::STBInput), + AvrPower => Key::Named(NamedKey::AVRPower), + AvrInput => Key::Named(NamedKey::AVRInput), + ProgRed => Key::Named(NamedKey::ColorF0Red), + ProgGreen => Key::Named(NamedKey::ColorF1Green), + ProgYellow => Key::Named(NamedKey::ColorF2Yellow), + ProgBlue => Key::Named(NamedKey::ColorF3Blue), + AppSwitch => Key::Named(NamedKey::AppSwitch), + LanguageSwitch => Key::Named(NamedKey::GroupNext), + MannerMode => Key::Named(NamedKey::MannerMode), + Keycode3dMode => Key::Named(NamedKey::TV3DMode), + Contacts => Key::Named(NamedKey::LaunchContacts), + Calendar => Key::Named(NamedKey::LaunchCalendar), + Music => Key::Named(NamedKey::LaunchMusicPlayer), + Calculator => Key::Named(NamedKey::LaunchApplication2), + ZenkakuHankaku => Key::Named(NamedKey::ZenkakuHankaku), + Eisu => Key::Named(NamedKey::Eisu), + Muhenkan => Key::Named(NamedKey::NonConvert), + Henkan => Key::Named(NamedKey::Convert), + KatakanaHiragana => Key::Named(NamedKey::HiraganaKatakana), + Kana => Key::Named(NamedKey::KanjiMode), + BrightnessDown => Key::Named(NamedKey::BrightnessDown), + BrightnessUp => Key::Named(NamedKey::BrightnessUp), + MediaAudioTrack => Key::Named(NamedKey::MediaAudioTrack), + Sleep => Key::Named(NamedKey::Standby), + Wakeup => Key::Named(NamedKey::WakeUp), + Pairing => Key::Named(NamedKey::Pairing), + MediaTopMenu => Key::Named(NamedKey::MediaTopMenu), + LastChannel => Key::Named(NamedKey::MediaLast), + TvDataService => Key::Named(NamedKey::TVDataService), + VoiceAssist => Key::Named(NamedKey::VoiceDial), + TvRadioService => Key::Named(NamedKey::TVRadioService), + TvTeletext => Key::Named(NamedKey::Teletext), + TvNumberEntry => Key::Named(NamedKey::TVNumberEntry), + TvTerrestrialAnalog => Key::Named(NamedKey::TVTerrestrialAnalog), + TvTerrestrialDigital => Key::Named(NamedKey::TVTerrestrialDigital), + TvSatellite => Key::Named(NamedKey::TVSatellite), + TvSatelliteBs => Key::Named(NamedKey::TVSatelliteBS), + TvSatelliteCs => Key::Named(NamedKey::TVSatelliteCS), + TvSatelliteService => Key::Named(NamedKey::TVSatelliteToggle), + TvNetwork => Key::Named(NamedKey::TVNetwork), + TvAntennaCable => Key::Named(NamedKey::TVAntennaCable), + TvInputHdmi1 => Key::Named(NamedKey::TVInputHDMI1), + TvInputHdmi2 => Key::Named(NamedKey::TVInputHDMI2), + TvInputHdmi3 => Key::Named(NamedKey::TVInputHDMI3), + TvInputHdmi4 => Key::Named(NamedKey::TVInputHDMI4), + TvInputComposite1 => Key::Named(NamedKey::TVInputComposite1), + TvInputComposite2 => Key::Named(NamedKey::TVInputComposite2), + TvInputComponent1 => Key::Named(NamedKey::TVInputComponent1), + TvInputComponent2 => Key::Named(NamedKey::TVInputComponent2), + TvInputVga1 => Key::Named(NamedKey::TVInputVGA1), + TvAudioDescription => Key::Named(NamedKey::TVAudioDescription), + TvAudioDescriptionMixUp => Key::Named(NamedKey::TVAudioDescriptionMixUp), + TvAudioDescriptionMixDown => Key::Named(NamedKey::TVAudioDescriptionMixDown), + TvZoomMode => Key::Named(NamedKey::ZoomToggle), + TvContentsMenu => Key::Named(NamedKey::TVContentsMenu), + TvMediaContextMenu => Key::Named(NamedKey::TVMediaContext), + TvTimerProgramming => Key::Named(NamedKey::TVTimer), + Help => Key::Named(NamedKey::Help), + NavigatePrevious => Key::Named(NamedKey::NavigatePrevious), + NavigateNext => Key::Named(NamedKey::NavigateNext), + NavigateIn => Key::Named(NamedKey::NavigateIn), + NavigateOut => Key::Named(NamedKey::NavigateOut), + MediaSkipForward => Key::Named(NamedKey::MediaSkipForward), + MediaSkipBackward => Key::Named(NamedKey::MediaSkipBackward), + MediaStepForward => Key::Named(NamedKey::MediaStepForward), + MediaStepBackward => Key::Named(NamedKey::MediaStepBackward), + Cut => Key::Named(NamedKey::Cut), + Copy => Key::Named(NamedKey::Copy), + Paste => Key::Named(NamedKey::Paste), + Refresh => Key::Named(NamedKey::BrowserRefresh), + + // ----------------------------------------------------------------- + // Keycodes that don't have a logical Key mapping + // ----------------------------------------------------------------- + Unknown => Key::Unidentified(native), + + // Can be added on demand + SoftLeft => Key::Unidentified(native), + SoftRight => Key::Unidentified(native), + + Menu => Key::Unidentified(native), + + Pictsymbols => Key::Unidentified(native), + SwitchCharset => Key::Unidentified(native), + + // ----------------------------------------------------------------- + // Gamepad events should be exposed through a separate API, not + // keyboard events + ButtonA => Key::Unidentified(native), + ButtonB => Key::Unidentified(native), + ButtonC => Key::Unidentified(native), + ButtonX => Key::Unidentified(native), + ButtonY => Key::Unidentified(native), + ButtonZ => Key::Unidentified(native), + ButtonL1 => Key::Unidentified(native), + ButtonR1 => Key::Unidentified(native), + ButtonL2 => Key::Unidentified(native), + ButtonR2 => Key::Unidentified(native), + ButtonThumbl => Key::Unidentified(native), + ButtonThumbr => Key::Unidentified(native), + ButtonStart => Key::Unidentified(native), + ButtonSelect => Key::Unidentified(native), + ButtonMode => Key::Unidentified(native), + // ----------------------------------------------------------------- + Window => Key::Unidentified(native), + + Button1 => Key::Unidentified(native), + Button2 => Key::Unidentified(native), + Button3 => Key::Unidentified(native), + Button4 => Key::Unidentified(native), + Button5 => Key::Unidentified(native), + Button6 => Key::Unidentified(native), + Button7 => Key::Unidentified(native), + Button8 => Key::Unidentified(native), + Button9 => Key::Unidentified(native), + Button10 => Key::Unidentified(native), + Button11 => Key::Unidentified(native), + Button12 => Key::Unidentified(native), + Button13 => Key::Unidentified(native), + Button14 => Key::Unidentified(native), + Button15 => Key::Unidentified(native), + Button16 => Key::Unidentified(native), + + Yen => Key::Unidentified(native), + Ro => Key::Unidentified(native), + + Assist => Key::Unidentified(native), + + Keycode11 => Key::Unidentified(native), + Keycode12 => Key::Unidentified(native), + + StemPrimary => Key::Unidentified(native), + Stem1 => Key::Unidentified(native), + Stem2 => Key::Unidentified(native), + Stem3 => Key::Unidentified(native), + + DpadUpLeft => Key::Unidentified(native), + DpadDownLeft => Key::Unidentified(native), + DpadUpRight => Key::Unidentified(native), + DpadDownRight => Key::Unidentified(native), + + SoftSleep => Key::Unidentified(native), + + SystemNavigationUp => Key::Unidentified(native), + SystemNavigationDown => Key::Unidentified(native), + SystemNavigationLeft => Key::Unidentified(native), + SystemNavigationRight => Key::Unidentified(native), + + AllApps => Key::Unidentified(native), + ThumbsUp => Key::Unidentified(native), + ThumbsDown => Key::Unidentified(native), + ProfileSwitch => Key::Unidentified(native), + + // It's always possible that new versions of Android could introduce + // key codes we can't know about at compile time. + _ => Key::Unidentified(native), + }, + } +} + +pub fn to_location(keycode: Keycode) -> KeyLocation { + use android_activity::input::Keycode::*; + + match keycode { + AltLeft => KeyLocation::Left, + AltRight => KeyLocation::Right, + ShiftLeft => KeyLocation::Left, + ShiftRight => KeyLocation::Right, + + // According to https://developer.android.com/reference/android/view/KeyEvent#KEYCODE_NUM + Num => KeyLocation::Left, + + CtrlLeft => KeyLocation::Left, + CtrlRight => KeyLocation::Right, + MetaLeft => KeyLocation::Left, + MetaRight => KeyLocation::Right, + + NumLock => KeyLocation::Numpad, + Numpad0 => KeyLocation::Numpad, + Numpad1 => KeyLocation::Numpad, + Numpad2 => KeyLocation::Numpad, + Numpad3 => KeyLocation::Numpad, + Numpad4 => KeyLocation::Numpad, + Numpad5 => KeyLocation::Numpad, + Numpad6 => KeyLocation::Numpad, + Numpad7 => KeyLocation::Numpad, + Numpad8 => KeyLocation::Numpad, + Numpad9 => KeyLocation::Numpad, + NumpadDivide => KeyLocation::Numpad, + NumpadMultiply => KeyLocation::Numpad, + NumpadSubtract => KeyLocation::Numpad, + NumpadAdd => KeyLocation::Numpad, + NumpadDot => KeyLocation::Numpad, + NumpadComma => KeyLocation::Numpad, + NumpadEnter => KeyLocation::Numpad, + NumpadEquals => KeyLocation::Numpad, + NumpadLeftParen => KeyLocation::Numpad, + NumpadRightParen => KeyLocation::Numpad, + + _ => KeyLocation::Standard, + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/android/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/android/mod.rs new file mode 100644 index 00000000..c6a7416f --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/android/mod.rs @@ -0,0 +1,1141 @@ +use std::cell::Cell; +use std::collections::VecDeque; +use std::hash::Hash; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +use android_activity::input::{InputEvent, KeyAction, Keycode, MotionAction}; +use android_activity::{ + AndroidApp, AndroidAppWaker, ConfigurationRef, InputStatus, MainEvent, Rect, +}; +use tracing::{debug, trace, warn}; + +use crate::cursor::Cursor; +use crate::dpi::{PhysicalPosition, PhysicalSize, Position, Size}; +use crate::error; +use crate::error::EventLoopError; +use crate::event::{self, Force, InnerSizeWriter, StartCause}; +use crate::event_loop::{self, ActiveEventLoop as RootAEL, ControlFlow, DeviceEvents}; +use crate::platform::pump_events::PumpStatus; +use crate::platform_impl::Fullscreen; +use crate::window::{ + self, CursorGrabMode, CustomCursor, CustomCursorSource, ImePurpose, ResizeDirection, Theme, + WindowButtons, WindowLevel, +}; + +mod keycodes; + +pub(crate) use crate::cursor::{ + NoCustomCursor as PlatformCustomCursor, NoCustomCursor as PlatformCustomCursorSource, +}; +pub(crate) use crate::icon::NoIcon as PlatformIcon; + +static HAS_FOCUS: AtomicBool = AtomicBool::new(true); + +/// Returns the minimum `Option`, taking into account that `None` +/// equates to an infinite timeout, not a zero timeout (so can't just use +/// `Option::min`) +fn min_timeout(a: Option, b: Option) -> Option { + a.map_or(b, |a_timeout| b.map_or(Some(a_timeout), |b_timeout| Some(a_timeout.min(b_timeout)))) +} + +struct PeekableReceiver { + recv: mpsc::Receiver, + first: Option, +} + +impl PeekableReceiver { + pub fn from_recv(recv: mpsc::Receiver) -> Self { + Self { recv, first: None } + } + + pub fn has_incoming(&mut self) -> bool { + if self.first.is_some() { + return true; + } + match self.recv.try_recv() { + Ok(v) => { + self.first = Some(v); + true + }, + Err(mpsc::TryRecvError::Empty) => false, + Err(mpsc::TryRecvError::Disconnected) => { + warn!("Channel was disconnected when checking incoming"); + false + }, + } + } + + pub fn try_recv(&mut self) -> Result { + if let Some(first) = self.first.take() { + return Ok(first); + } + self.recv.try_recv() + } +} + +#[derive(Clone)] +struct SharedFlagSetter { + flag: Arc, +} +impl SharedFlagSetter { + pub fn set(&self) -> bool { + self.flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed).is_ok() + } +} + +struct SharedFlag { + flag: Arc, +} + +// Used for queuing redraws from arbitrary threads. We don't care how many +// times a redraw is requested (so don't actually need to queue any data, +// we just need to know at the start of a main loop iteration if a redraw +// was queued and be able to read and clear the state atomically) +impl SharedFlag { + pub fn new() -> Self { + Self { flag: Arc::new(AtomicBool::new(false)) } + } + + pub fn setter(&self) -> SharedFlagSetter { + SharedFlagSetter { flag: self.flag.clone() } + } + + pub fn get_and_reset(&self) -> bool { + self.flag.swap(false, std::sync::atomic::Ordering::AcqRel) + } +} + +#[derive(Clone)] +pub struct RedrawRequester { + flag: SharedFlagSetter, + waker: AndroidAppWaker, +} + +impl RedrawRequester { + fn new(flag: &SharedFlag, waker: AndroidAppWaker) -> Self { + RedrawRequester { flag: flag.setter(), waker } + } + + pub fn request_redraw(&self) { + if self.flag.set() { + // Only explicitly try to wake up the main loop when the flag + // value changes + self.waker.wake(); + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct KeyEventExtra {} + +pub struct EventLoop { + pub(crate) android_app: AndroidApp, + window_target: event_loop::ActiveEventLoop, + redraw_flag: SharedFlag, + user_events_sender: mpsc::Sender, + user_events_receiver: PeekableReceiver, // must wake looper whenever something gets sent + loop_running: bool, // Dispatched `NewEvents` + running: bool, + pending_redraw: bool, + cause: StartCause, + ignore_volume_keys: bool, + combining_accent: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PlatformSpecificEventLoopAttributes { + pub(crate) android_app: Option, + pub(crate) ignore_volume_keys: bool, +} + +impl Default for PlatformSpecificEventLoopAttributes { + fn default() -> Self { + Self { android_app: Default::default(), ignore_volume_keys: true } + } +} + +impl EventLoop { + pub(crate) fn new( + attributes: &PlatformSpecificEventLoopAttributes, + ) -> Result { + let (user_events_sender, user_events_receiver) = mpsc::channel(); + + let android_app = attributes.android_app.as_ref().expect( + "An `AndroidApp` as passed to android_main() is required to create an `EventLoop` on \ + Android", + ); + let redraw_flag = SharedFlag::new(); + + Ok(Self { + android_app: android_app.clone(), + window_target: event_loop::ActiveEventLoop { + p: ActiveEventLoop { + app: android_app.clone(), + control_flow: Cell::new(ControlFlow::default()), + exit: Cell::new(false), + redraw_requester: RedrawRequester::new( + &redraw_flag, + android_app.create_waker(), + ), + }, + _marker: PhantomData, + }, + redraw_flag, + user_events_sender, + user_events_receiver: PeekableReceiver::from_recv(user_events_receiver), + loop_running: false, + running: false, + pending_redraw: false, + cause: StartCause::Init, + ignore_volume_keys: attributes.ignore_volume_keys, + combining_accent: None, + }) + } + + fn single_iteration(&mut self, main_event: Option>, callback: &mut F) + where + F: FnMut(event::Event, &RootAEL), + { + trace!("Mainloop iteration"); + + let cause = self.cause; + let mut pending_redraw = self.pending_redraw; + let mut resized = false; + + callback(event::Event::NewEvents(cause), self.window_target()); + + if let Some(event) = main_event { + trace!("Handling main event {:?}", event); + + match event { + MainEvent::InitWindow { .. } => { + callback(event::Event::Resumed, self.window_target()); + }, + MainEvent::TerminateWindow { .. } => { + callback(event::Event::Suspended, self.window_target()); + }, + MainEvent::WindowResized { .. } => resized = true, + MainEvent::RedrawNeeded { .. } => pending_redraw = true, + MainEvent::ContentRectChanged { .. } => { + warn!("TODO: find a way to notify application of content rect change"); + }, + MainEvent::GainedFocus => { + HAS_FOCUS.store(true, Ordering::Relaxed); + callback( + event::Event::WindowEvent { + window_id: window::WindowId(WindowId), + event: event::WindowEvent::Focused(true), + }, + self.window_target(), + ); + }, + MainEvent::LostFocus => { + HAS_FOCUS.store(false, Ordering::Relaxed); + callback( + event::Event::WindowEvent { + window_id: window::WindowId(WindowId), + event: event::WindowEvent::Focused(false), + }, + self.window_target(), + ); + }, + MainEvent::ConfigChanged { .. } => { + let monitor = MonitorHandle::new(self.android_app.clone()); + let old_scale_factor = monitor.scale_factor(); + let scale_factor = monitor.scale_factor(); + if (scale_factor - old_scale_factor).abs() < f64::EPSILON { + let new_inner_size = Arc::new(Mutex::new( + MonitorHandle::new(self.android_app.clone()).size(), + )); + let event = event::Event::WindowEvent { + window_id: window::WindowId(WindowId), + event: event::WindowEvent::ScaleFactorChanged { + inner_size_writer: InnerSizeWriter::new(Arc::downgrade( + &new_inner_size, + )), + scale_factor, + }, + }; + callback(event, self.window_target()); + } + }, + MainEvent::LowMemory => { + callback(event::Event::MemoryWarning, self.window_target()); + }, + MainEvent::Start => { + // XXX: how to forward this state to applications? + warn!("TODO: forward onStart notification to application"); + }, + MainEvent::Resume { .. } => { + debug!("App Resumed - is running"); + self.running = true; + }, + MainEvent::SaveState { .. } => { + // XXX: how to forward this state to applications? + // XXX: also how do we expose state restoration to apps? + warn!("TODO: forward saveState notification to application"); + }, + MainEvent::Pause => { + debug!("App Paused - stopped running"); + self.running = false; + }, + MainEvent::Stop => { + // XXX: how to forward this state to applications? + warn!("TODO: forward onStop notification to application"); + }, + MainEvent::Destroy => { + // XXX: maybe exit mainloop to drop things before being + // killed by the OS? + warn!("TODO: forward onDestroy notification to application"); + }, + MainEvent::InsetsChanged { .. } => { + // XXX: how to forward this state to applications? + warn!("TODO: handle Android InsetsChanged notification"); + }, + unknown => { + trace!("Unknown MainEvent {unknown:?} (ignored)"); + }, + } + } else { + trace!("No main event to handle"); + } + + // temporarily decouple `android_app` from `self` so we aren't holding + // a borrow of `self` while iterating + let android_app = self.android_app.clone(); + + // Process input events + match android_app.input_events_iter() { + Ok(mut input_iter) => loop { + let read_event = + input_iter.next(|event| self.handle_input_event(&android_app, event, callback)); + + if !read_event { + break; + } + }, + Err(err) => { + tracing::warn!("Failed to get input events iterator: {err:?}"); + }, + } + + // Empty the user event buffer + { + while let Ok(event) = self.user_events_receiver.try_recv() { + callback(crate::event::Event::UserEvent(event), self.window_target()); + } + } + + if self.running { + if resized { + let size = if let Some(native_window) = self.android_app.native_window().as_ref() { + let width = native_window.width() as _; + let height = native_window.height() as _; + PhysicalSize::new(width, height) + } else { + PhysicalSize::new(0, 0) + }; + let event = event::Event::WindowEvent { + window_id: window::WindowId(WindowId), + event: event::WindowEvent::Resized(size), + }; + callback(event, self.window_target()); + } + + pending_redraw |= self.redraw_flag.get_and_reset(); + if pending_redraw { + pending_redraw = false; + let event = event::Event::WindowEvent { + window_id: window::WindowId(WindowId), + event: event::WindowEvent::RedrawRequested, + }; + callback(event, self.window_target()); + } + } + + // This is always the last event we dispatch before poll again + callback(event::Event::AboutToWait, self.window_target()); + + self.pending_redraw = pending_redraw; + } + + fn handle_input_event( + &mut self, + android_app: &AndroidApp, + event: &InputEvent<'_>, + callback: &mut F, + ) -> InputStatus + where + F: FnMut(event::Event, &RootAEL), + { + let mut input_status = InputStatus::Handled; + match event { + InputEvent::MotionEvent(motion_event) => { + let window_id = window::WindowId(WindowId); + let device_id = event::DeviceId(DeviceId(motion_event.device_id())); + + let phase = match motion_event.action() { + MotionAction::Down | MotionAction::PointerDown => { + Some(event::TouchPhase::Started) + }, + MotionAction::Up | MotionAction::PointerUp => Some(event::TouchPhase::Ended), + MotionAction::Move => Some(event::TouchPhase::Moved), + MotionAction::Cancel => Some(event::TouchPhase::Cancelled), + _ => { + None // TODO mouse events + }, + }; + if let Some(phase) = phase { + let pointers: Box>> = + match phase { + event::TouchPhase::Started | event::TouchPhase::Ended => { + Box::new(std::iter::once( + motion_event.pointer_at_index(motion_event.pointer_index()), + )) + }, + event::TouchPhase::Moved | event::TouchPhase::Cancelled => { + Box::new(motion_event.pointers()) + }, + }; + + for pointer in pointers { + let location = + PhysicalPosition { x: pointer.x() as _, y: pointer.y() as _ }; + trace!( + "Input event {device_id:?}, {phase:?}, loc={location:?}, \ + pointer={pointer:?}" + ); + let event = event::Event::WindowEvent { + window_id, + event: event::WindowEvent::Touch(event::Touch { + device_id, + phase, + location, + id: pointer.pointer_id() as u64, + force: Some(Force::Normalized(pointer.pressure() as f64)), + }), + }; + callback(event, self.window_target()); + } + } + }, + InputEvent::KeyEvent(key) => { + match key.key_code() { + // Flag keys related to volume as unhandled. While winit does not have a way for + // applications to configure what keys to flag as handled, + // this appears to be a good default until winit + // can be configured. + Keycode::VolumeUp | Keycode::VolumeDown | Keycode::VolumeMute + if self.ignore_volume_keys => + { + input_status = InputStatus::Unhandled + }, + keycode => { + let state = match key.action() { + KeyAction::Down => event::ElementState::Pressed, + KeyAction::Up => event::ElementState::Released, + _ => event::ElementState::Released, + }; + + let key_char = keycodes::character_map_and_combine_key( + android_app, + key, + &mut self.combining_accent, + ); + + let logical_key = keycodes::to_logical(key_char, keycode); + let text = if state == event::ElementState::Pressed { + logical_key.to_text().map(smol_str::SmolStr::new) + } else { + None + }; + + let event = event::Event::WindowEvent { + window_id: window::WindowId(WindowId), + event: event::WindowEvent::KeyboardInput { + device_id: event::DeviceId(DeviceId(key.device_id())), + event: event::KeyEvent { + state, + physical_key: keycodes::to_physical_key(keycode), + logical_key, + location: keycodes::to_location(keycode), + repeat: key.repeat_count() > 0, + text, + platform_specific: KeyEventExtra {}, + }, + is_synthetic: false, + }, + }; + callback(event, self.window_target()); + }, + } + }, + _ => { + warn!("Unknown android_activity input event {event:?}") + }, + } + + input_status + } + + pub fn run(mut self, event_handler: F) -> Result<(), EventLoopError> + where + F: FnMut(event::Event, &event_loop::ActiveEventLoop), + { + self.run_on_demand(event_handler) + } + + pub fn run_on_demand(&mut self, mut event_handler: F) -> Result<(), EventLoopError> + where + F: FnMut(event::Event, &event_loop::ActiveEventLoop), + { + loop { + match self.pump_events(None, &mut event_handler) { + PumpStatus::Exit(0) => { + break Ok(()); + }, + PumpStatus::Exit(code) => { + break Err(EventLoopError::ExitFailure(code)); + }, + _ => { + continue; + }, + } + } + } + + pub fn pump_events(&mut self, timeout: Option, mut callback: F) -> PumpStatus + where + F: FnMut(event::Event, &RootAEL), + { + if !self.loop_running { + self.loop_running = true; + + // Reset the internal state for the loop as we start running to + // ensure consistent behaviour in case the loop runs and exits more + // than once + self.pending_redraw = false; + self.cause = StartCause::Init; + + // run the initial loop iteration + self.single_iteration(None, &mut callback); + } + + // Consider the possibility that the `StartCause::Init` iteration could + // request to Exit + if !self.exiting() { + self.poll_events_with_timeout(timeout, &mut callback); + } + if self.exiting() { + self.loop_running = false; + + callback(event::Event::LoopExiting, self.window_target()); + + PumpStatus::Exit(0) + } else { + PumpStatus::Continue + } + } + + fn poll_events_with_timeout(&mut self, mut timeout: Option, mut callback: F) + where + F: FnMut(event::Event, &RootAEL), + { + let start = Instant::now(); + + self.pending_redraw |= self.redraw_flag.get_and_reset(); + + timeout = + if self.running && (self.pending_redraw || self.user_events_receiver.has_incoming()) { + // If we already have work to do then we don't want to block on the next poll + Some(Duration::ZERO) + } else { + let control_flow_timeout = match self.control_flow() { + ControlFlow::Wait => None, + ControlFlow::Poll => Some(Duration::ZERO), + ControlFlow::WaitUntil(wait_deadline) => { + Some(wait_deadline.saturating_duration_since(start)) + }, + }; + + min_timeout(control_flow_timeout, timeout) + }; + + let app = self.android_app.clone(); // Don't borrow self as part of poll expression + app.poll_events(timeout, |poll_event| { + let mut main_event = None; + + match poll_event { + android_activity::PollEvent::Wake => { + // In the X11 backend it's noted that too many false-positive wake ups + // would cause the event loop to run continuously. They handle this by + // re-checking for pending events (assuming they cover all + // valid reasons for a wake up). + // + // For now, user_events and redraw_requests are the only reasons to expect + // a wake up here so we can ignore the wake up if there are no events/requests. + // We also ignore wake ups while suspended. + self.pending_redraw |= self.redraw_flag.get_and_reset(); + if !self.running + || (!self.pending_redraw && !self.user_events_receiver.has_incoming()) + { + return; + } + }, + android_activity::PollEvent::Timeout => {}, + android_activity::PollEvent::Main(event) => { + main_event = Some(event); + }, + unknown_event => { + warn!("Unknown poll event {unknown_event:?} (ignored)"); + }, + } + + self.cause = match self.control_flow() { + ControlFlow::Poll => StartCause::Poll, + ControlFlow::Wait => StartCause::WaitCancelled { start, requested_resume: None }, + ControlFlow::WaitUntil(deadline) => { + if Instant::now() < deadline { + StartCause::WaitCancelled { start, requested_resume: Some(deadline) } + } else { + StartCause::ResumeTimeReached { start, requested_resume: deadline } + } + }, + }; + + self.single_iteration(main_event, &mut callback); + }); + } + + pub fn window_target(&self) -> &event_loop::ActiveEventLoop { + &self.window_target + } + + pub fn create_proxy(&self) -> EventLoopProxy { + EventLoopProxy { + user_events_sender: self.user_events_sender.clone(), + waker: self.android_app.create_waker(), + } + } + + fn control_flow(&self) -> ControlFlow { + self.window_target.p.control_flow() + } + + fn exiting(&self) -> bool { + self.window_target.p.exiting() + } +} + +pub struct EventLoopProxy { + user_events_sender: mpsc::Sender, + waker: AndroidAppWaker, +} + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + EventLoopProxy { + user_events_sender: self.user_events_sender.clone(), + waker: self.waker.clone(), + } + } +} + +impl EventLoopProxy { + pub fn send_event(&self, event: T) -> Result<(), event_loop::EventLoopClosed> { + self.user_events_sender.send(event).map_err(|err| event_loop::EventLoopClosed(err.0))?; + self.waker.wake(); + Ok(()) + } +} + +pub struct ActiveEventLoop { + pub(crate) app: AndroidApp, + control_flow: Cell, + exit: Cell, + redraw_requester: RedrawRequester, +} + +impl ActiveEventLoop { + pub fn primary_monitor(&self) -> Option { + Some(MonitorHandle::new(self.app.clone())) + } + + pub fn create_custom_cursor(&self, source: CustomCursorSource) -> CustomCursor { + let _ = source.inner; + CustomCursor { inner: PlatformCustomCursor } + } + + pub fn available_monitors(&self) -> VecDeque { + let mut v = VecDeque::with_capacity(1); + v.push_back(MonitorHandle::new(self.app.clone())); + v + } + + #[inline] + pub fn listen_device_events(&self, _allowed: DeviceEvents) {} + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::Android(rwh_05::AndroidDisplayHandle::empty()) + } + + #[inline] + pub fn system_theme(&self) -> Option { + None + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::Android(rwh_06::AndroidDisplayHandle::new())) + } + + pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) { + self.control_flow.set(control_flow) + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + self.control_flow.get() + } + + pub(crate) fn exit(&self) { + self.exit.set(true) + } + + pub(crate) fn clear_exit(&self) { + self.exit.set(false) + } + + pub(crate) fn exiting(&self) -> bool { + self.exit.get() + } + + pub(crate) fn owned_display_handle(&self) -> OwnedDisplayHandle { + OwnedDisplayHandle + } +} + +#[derive(Clone)] +pub(crate) struct OwnedDisplayHandle; + +impl OwnedDisplayHandle { + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::AndroidDisplayHandle::empty().into() + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::AndroidDisplayHandle::new().into()) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct WindowId; + +impl WindowId { + pub const fn dummy() -> Self { + WindowId + } +} + +impl From for u64 { + fn from(_: WindowId) -> Self { + 0 + } +} + +impl From for WindowId { + fn from(_: u64) -> Self { + Self + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct DeviceId(i32); + +impl DeviceId { + pub const fn dummy() -> Self { + DeviceId(0) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PlatformSpecificWindowAttributes; + +pub(crate) struct Window { + app: AndroidApp, + redraw_requester: RedrawRequester, +} + +impl Window { + pub(crate) fn new( + el: &ActiveEventLoop, + _window_attrs: window::WindowAttributes, + ) -> Result { + // FIXME this ignores requested window attributes + + Ok(Self { app: el.app.clone(), redraw_requester: el.redraw_requester.clone() }) + } + + pub(crate) fn maybe_queue_on_main(&self, f: impl FnOnce(&Self) + Send + 'static) { + f(self) + } + + pub(crate) fn maybe_wait_on_main(&self, f: impl FnOnce(&Self) -> R + Send) -> R { + f(self) + } + + pub fn id(&self) -> WindowId { + WindowId + } + + pub fn primary_monitor(&self) -> Option { + Some(MonitorHandle::new(self.app.clone())) + } + + pub fn available_monitors(&self) -> VecDeque { + let mut v = VecDeque::with_capacity(1); + v.push_back(MonitorHandle::new(self.app.clone())); + v + } + + pub fn current_monitor(&self) -> Option { + Some(MonitorHandle::new(self.app.clone())) + } + + pub fn scale_factor(&self) -> f64 { + MonitorHandle::new(self.app.clone()).scale_factor() + } + + pub fn request_redraw(&self) { + self.redraw_requester.request_redraw() + } + + pub fn pre_present_notify(&self) {} + + pub fn inner_position(&self) -> Result, error::NotSupportedError> { + Err(error::NotSupportedError::new()) + } + + pub fn outer_position(&self) -> Result, error::NotSupportedError> { + Err(error::NotSupportedError::new()) + } + + pub fn set_outer_position(&self, _position: Position) { + // no effect + } + + pub fn inner_size(&self) -> PhysicalSize { + self.outer_size() + } + + pub fn request_inner_size(&self, _size: Size) -> Option> { + Some(self.inner_size()) + } + + pub fn outer_size(&self) -> PhysicalSize { + MonitorHandle::new(self.app.clone()).size() + } + + pub fn set_min_inner_size(&self, _: Option) {} + + pub fn set_max_inner_size(&self, _: Option) {} + + pub fn resize_increments(&self) -> Option> { + None + } + + pub fn set_resize_increments(&self, _increments: Option) {} + + pub fn set_title(&self, _title: &str) {} + + pub fn set_transparent(&self, _transparent: bool) {} + + pub fn set_blur(&self, _blur: bool) {} + + pub fn set_visible(&self, _visibility: bool) {} + + pub fn is_visible(&self) -> Option { + None + } + + pub fn set_resizable(&self, _resizeable: bool) {} + + pub fn is_resizable(&self) -> bool { + false + } + + pub fn set_enabled_buttons(&self, _buttons: WindowButtons) {} + + pub fn enabled_buttons(&self) -> WindowButtons { + WindowButtons::all() + } + + pub fn set_minimized(&self, _minimized: bool) {} + + pub fn is_minimized(&self) -> Option { + None + } + + pub fn set_maximized(&self, _maximized: bool) {} + + pub fn is_maximized(&self) -> bool { + false + } + + pub fn set_fullscreen(&self, _monitor: Option) { + warn!("Cannot set fullscreen on Android"); + } + + pub fn fullscreen(&self) -> Option { + None + } + + pub fn set_decorations(&self, _decorations: bool) {} + + pub fn is_decorated(&self) -> bool { + true + } + + pub fn set_window_level(&self, _level: WindowLevel) {} + + pub fn set_window_icon(&self, _window_icon: Option) {} + + pub fn set_ime_cursor_area(&self, _position: Position, _size: Size) {} + + pub fn set_ime_allowed(&self, allowed: bool) { + if allowed { + self.app.show_soft_input(true); + } else { + self.app.hide_soft_input(true); + } + } + + pub fn set_ime_purpose(&self, _purpose: ImePurpose) {} + + pub fn focus_window(&self) {} + + pub fn request_user_attention(&self, _request_type: Option) {} + + pub fn set_cursor(&self, _: Cursor) {} + + pub fn set_cursor_position(&self, _: Position) -> Result<(), error::ExternalError> { + Err(error::ExternalError::NotSupported(error::NotSupportedError::new())) + } + + pub fn set_cursor_grab(&self, _: CursorGrabMode) -> Result<(), error::ExternalError> { + Err(error::ExternalError::NotSupported(error::NotSupportedError::new())) + } + + pub fn set_cursor_visible(&self, _: bool) {} + + pub fn drag_window(&self) -> Result<(), error::ExternalError> { + Err(error::ExternalError::NotSupported(error::NotSupportedError::new())) + } + + pub fn drag_resize_window( + &self, + _direction: ResizeDirection, + ) -> Result<(), error::ExternalError> { + Err(error::ExternalError::NotSupported(error::NotSupportedError::new())) + } + + #[inline] + pub fn show_window_menu(&self, _position: Position) {} + + pub fn set_cursor_hittest(&self, _hittest: bool) -> Result<(), error::ExternalError> { + Err(error::ExternalError::NotSupported(error::NotSupportedError::new())) + } + + #[cfg(feature = "rwh_04")] + pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle { + use rwh_04::HasRawWindowHandle; + + if let Some(native_window) = self.app.native_window().as_ref() { + native_window.raw_window_handle() + } else { + panic!( + "Cannot get the native window, it's null and will always be null before \ + Event::Resumed and after Event::Suspended. Make sure you only call this function \ + between those events." + ); + } + } + + #[cfg(feature = "rwh_05")] + pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle { + use rwh_05::HasRawWindowHandle; + + if let Some(native_window) = self.app.native_window().as_ref() { + native_window.raw_window_handle() + } else { + panic!( + "Cannot get the native window, it's null and will always be null before \ + Event::Resumed and after Event::Suspended. Make sure you only call this function \ + between those events." + ); + } + } + + #[cfg(feature = "rwh_05")] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::Android(rwh_05::AndroidDisplayHandle::empty()) + } + + #[cfg(feature = "rwh_06")] + // Allow the usage of HasRawWindowHandle inside this function + #[allow(deprecated)] + pub fn raw_window_handle_rwh_06(&self) -> Result { + use rwh_06::HasRawWindowHandle; + + if let Some(native_window) = self.app.native_window().as_ref() { + native_window.raw_window_handle() + } else { + tracing::error!( + "Cannot get the native window, it's null and will always be null before \ + Event::Resumed and after Event::Suspended. Make sure you only call this function \ + between those events." + ); + Err(rwh_06::HandleError::Unavailable) + } + } + + #[cfg(feature = "rwh_06")] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::Android(rwh_06::AndroidDisplayHandle::new())) + } + + pub fn config(&self) -> ConfigurationRef { + self.app.config() + } + + pub fn content_rect(&self) -> Rect { + self.app.content_rect() + } + + pub fn set_theme(&self, _theme: Option) {} + + pub fn theme(&self) -> Option { + None + } + + pub fn set_content_protected(&self, _protected: bool) {} + + pub fn has_focus(&self) -> bool { + HAS_FOCUS.load(Ordering::Relaxed) + } + + pub fn title(&self) -> String { + String::new() + } + + pub fn reset_dead_keys(&self) {} +} + +#[derive(Default, Clone, Debug)] +pub struct OsError; + +use std::fmt::{self, Display, Formatter}; +impl Display for OsError { + fn fmt(&self, fmt: &mut Formatter<'_>) -> Result<(), fmt::Error> { + write!(fmt, "Android OS Error") + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct MonitorHandle { + app: AndroidApp, +} +impl PartialOrd for MonitorHandle { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for MonitorHandle { + fn cmp(&self, _other: &Self) -> std::cmp::Ordering { + std::cmp::Ordering::Equal + } +} + +impl MonitorHandle { + pub(crate) fn new(app: AndroidApp) -> Self { + Self { app } + } + + pub fn name(&self) -> Option { + Some("Android Device".to_owned()) + } + + pub fn size(&self) -> PhysicalSize { + if let Some(native_window) = self.app.native_window() { + PhysicalSize::new(native_window.width() as _, native_window.height() as _) + } else { + PhysicalSize::new(0, 0) + } + } + + pub fn position(&self) -> PhysicalPosition { + (0, 0).into() + } + + pub fn scale_factor(&self) -> f64 { + self.app.config().density().map(|dpi| dpi as f64 / 160.0).unwrap_or(1.0) + } + + pub fn refresh_rate_millihertz(&self) -> Option { + // FIXME no way to get real refresh rate for now. + None + } + + pub fn video_modes(&self) -> impl Iterator { + let size = self.size().into(); + // FIXME this is not the real refresh rate + // (it is guaranteed to support 32 bit color though) + std::iter::once(VideoModeHandle { + size, + bit_depth: 32, + refresh_rate_millihertz: 60000, + monitor: self.clone(), + }) + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct VideoModeHandle { + size: (u32, u32), + bit_depth: u16, + refresh_rate_millihertz: u32, + monitor: MonitorHandle, +} + +impl VideoModeHandle { + pub fn size(&self) -> PhysicalSize { + self.size.into() + } + + pub fn bit_depth(&self) -> u16 { + self.bit_depth + } + + pub fn refresh_rate_millihertz(&self) -> u32 { + self.refresh_rate_millihertz + } + + pub fn monitor(&self) -> MonitorHandle { + self.monitor.clone() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/app_state.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/app_state.rs new file mode 100644 index 00000000..e34bf43c --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/app_state.rs @@ -0,0 +1,925 @@ +#![deny(unused_results)] + +use std::cell::{RefCell, RefMut}; +use std::collections::HashSet; +use std::os::raw::c_void; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; +use std::{fmt, mem, ptr}; + +use core_foundation::base::CFRelease; +use core_foundation::date::CFAbsoluteTimeGetCurrent; +use core_foundation::runloop::{ + kCFRunLoopCommonModes, CFRunLoopAddTimer, CFRunLoopGetMain, CFRunLoopRef, CFRunLoopTimerCreate, + CFRunLoopTimerInvalidate, CFRunLoopTimerRef, CFRunLoopTimerSetNextFireDate, +}; +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2::{msg_send, sel}; +use objc2_foundation::{ + CGRect, CGSize, MainThreadMarker, NSInteger, NSObjectProtocol, NSOperatingSystemVersion, + NSProcessInfo, +}; +use objc2_ui_kit::{UIApplication, UICoordinateSpace, UIView, UIWindow}; + +use super::window::WinitUIWindow; +use crate::dpi::PhysicalSize; +use crate::event::{Event, InnerSizeWriter, StartCause, WindowEvent}; +use crate::event_loop::{ActiveEventLoop as RootActiveEventLoop, ControlFlow}; +use crate::window::WindowId as RootWindowId; + +macro_rules! bug { + ($($msg:tt)*) => { + panic!("winit iOS bug, file an issue: {}", format!($($msg)*)) + }; +} + +macro_rules! bug_assert { + ($test:expr, $($msg:tt)*) => { + assert!($test, "winit iOS bug, file an issue: {}", format!($($msg)*)) + }; +} + +#[derive(Debug)] +pub(crate) struct HandlePendingUserEvents; + +pub(crate) struct EventLoopHandler { + #[allow(clippy::type_complexity)] + pub(crate) handler: Box, &RootActiveEventLoop)>, + pub(crate) event_loop: RootActiveEventLoop, +} + +impl fmt::Debug for EventLoopHandler { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EventLoopHandler") + .field("handler", &"...") + .field("event_loop", &self.event_loop) + .finish() + } +} + +impl EventLoopHandler { + fn handle_event(&mut self, event: Event) { + (self.handler)(event, &self.event_loop) + } +} + +#[derive(Debug)] +pub(crate) enum EventWrapper { + StaticEvent(Event), + ScaleFactorChanged(ScaleFactorChanged), +} + +#[derive(Debug)] +pub struct ScaleFactorChanged { + pub(super) window: Retained, + pub(super) suggested_size: PhysicalSize, + pub(super) scale_factor: f64, +} + +enum UserCallbackTransitionResult<'a> { + Success { + handler: EventLoopHandler, + active_control_flow: ControlFlow, + processing_redraws: bool, + }, + ReentrancyPrevented { + queued_events: &'a mut Vec, + }, +} + +impl Event { + fn is_redraw(&self) -> bool { + matches!(self, Event::WindowEvent { event: WindowEvent::RedrawRequested, .. }) + } +} + +// this is the state machine for the app lifecycle +#[derive(Debug)] +#[must_use = "dropping `AppStateImpl` without inspecting it is probably a bug"] +enum AppStateImpl { + NotLaunched { + queued_windows: Vec>, + queued_events: Vec, + queued_gpu_redraws: HashSet>, + }, + Launching { + queued_windows: Vec>, + queued_events: Vec, + queued_handler: EventLoopHandler, + queued_gpu_redraws: HashSet>, + }, + ProcessingEvents { + handler: EventLoopHandler, + queued_gpu_redraws: HashSet>, + active_control_flow: ControlFlow, + }, + // special state to deal with reentrancy and prevent mutable aliasing. + InUserCallback { + queued_events: Vec, + queued_gpu_redraws: HashSet>, + }, + ProcessingRedraws { + handler: EventLoopHandler, + active_control_flow: ControlFlow, + }, + Waiting { + waiting_handler: EventLoopHandler, + start: Instant, + }, + PollFinished { + waiting_handler: EventLoopHandler, + }, + Terminated, +} + +pub(crate) struct AppState { + // This should never be `None`, except for briefly during a state transition. + app_state: Option, + control_flow: ControlFlow, + waker: EventLoopWaker, +} + +impl AppState { + pub(crate) fn get_mut(_mtm: MainThreadMarker) -> RefMut<'static, AppState> { + // basically everything in UIKit requires the main thread, so it's pointless to use the + // std::sync APIs. + // must be mut because plain `static` requires `Sync` + static mut APP_STATE: RefCell> = RefCell::new(None); + + #[allow(unknown_lints)] // New lint below + #[allow(static_mut_refs)] // TODO: Use `MainThreadBound` instead. + let mut guard = unsafe { APP_STATE.borrow_mut() }; + if guard.is_none() { + #[inline(never)] + #[cold] + fn init_guard(guard: &mut RefMut<'static, Option>) { + let waker = EventLoopWaker::new(unsafe { CFRunLoopGetMain() }); + **guard = Some(AppState { + app_state: Some(AppStateImpl::NotLaunched { + queued_windows: Vec::new(), + queued_events: Vec::new(), + queued_gpu_redraws: HashSet::new(), + }), + control_flow: ControlFlow::default(), + waker, + }); + } + init_guard(&mut guard); + } + RefMut::map(guard, |state| state.as_mut().unwrap()) + } + + fn state(&self) -> &AppStateImpl { + match &self.app_state { + Some(ref state) => state, + None => bug!("`AppState` previously failed a state transition"), + } + } + + fn state_mut(&mut self) -> &mut AppStateImpl { + match &mut self.app_state { + Some(ref mut state) => state, + None => bug!("`AppState` previously failed a state transition"), + } + } + + fn take_state(&mut self) -> AppStateImpl { + match self.app_state.take() { + Some(state) => state, + None => bug!("`AppState` previously failed a state transition"), + } + } + + fn set_state(&mut self, new_state: AppStateImpl) { + bug_assert!( + self.app_state.is_none(), + "attempted to set an `AppState` without calling `take_state` first {:?}", + self.app_state + ); + self.app_state = Some(new_state) + } + + fn replace_state(&mut self, new_state: AppStateImpl) -> AppStateImpl { + match &mut self.app_state { + Some(ref mut state) => mem::replace(state, new_state), + None => bug!("`AppState` previously failed a state transition"), + } + } + + fn has_launched(&self) -> bool { + !matches!(self.state(), AppStateImpl::NotLaunched { .. } | AppStateImpl::Launching { .. }) + } + + fn has_terminated(&self) -> bool { + matches!(self.state(), AppStateImpl::Terminated) + } + + fn will_launch_transition(&mut self, queued_handler: EventLoopHandler) { + let (queued_windows, queued_events, queued_gpu_redraws) = match self.take_state() { + AppStateImpl::NotLaunched { queued_windows, queued_events, queued_gpu_redraws } => { + (queued_windows, queued_events, queued_gpu_redraws) + }, + s => bug!("unexpected state {:?}", s), + }; + self.set_state(AppStateImpl::Launching { + queued_windows, + queued_events, + queued_handler, + queued_gpu_redraws, + }); + } + + fn did_finish_launching_transition( + &mut self, + ) -> (Vec>, Vec) { + let (windows, events, handler, queued_gpu_redraws) = match self.take_state() { + AppStateImpl::Launching { + queued_windows, + queued_events, + queued_handler, + queued_gpu_redraws, + } => (queued_windows, queued_events, queued_handler, queued_gpu_redraws), + s => bug!("unexpected state {:?}", s), + }; + self.set_state(AppStateImpl::ProcessingEvents { + handler, + active_control_flow: self.control_flow, + queued_gpu_redraws, + }); + (windows, events) + } + + fn wakeup_transition(&mut self) -> Option { + // before `AppState::did_finish_launching` is called, pretend there is no running + // event loop. + if !self.has_launched() || self.has_terminated() { + return None; + } + + let (handler, event) = match (self.control_flow, self.take_state()) { + (ControlFlow::Poll, AppStateImpl::PollFinished { waiting_handler }) => { + (waiting_handler, EventWrapper::StaticEvent(Event::NewEvents(StartCause::Poll))) + }, + (ControlFlow::Wait, AppStateImpl::Waiting { waiting_handler, start }) => ( + waiting_handler, + EventWrapper::StaticEvent(Event::NewEvents(StartCause::WaitCancelled { + start, + requested_resume: None, + })), + ), + ( + ControlFlow::WaitUntil(requested_resume), + AppStateImpl::Waiting { waiting_handler, start }, + ) => { + let event = if Instant::now() >= requested_resume { + EventWrapper::StaticEvent(Event::NewEvents(StartCause::ResumeTimeReached { + start, + requested_resume, + })) + } else { + EventWrapper::StaticEvent(Event::NewEvents(StartCause::WaitCancelled { + start, + requested_resume: Some(requested_resume), + })) + }; + (waiting_handler, event) + }, + s => bug!("`EventHandler` unexpectedly woke up {:?}", s), + }; + + self.set_state(AppStateImpl::ProcessingEvents { + handler, + queued_gpu_redraws: Default::default(), + active_control_flow: self.control_flow, + }); + Some(event) + } + + fn try_user_callback_transition(&mut self) -> UserCallbackTransitionResult<'_> { + // If we're not able to process an event due to recursion or `Init` not having been sent out + // yet, then queue the events up. + match self.state_mut() { + &mut AppStateImpl::Launching { ref mut queued_events, .. } + | &mut AppStateImpl::NotLaunched { ref mut queued_events, .. } + | &mut AppStateImpl::InUserCallback { ref mut queued_events, .. } => { + // A lifetime cast: early returns are not currently handled well with NLL, but + // polonius handles them well. This transmute is a safe workaround. + return unsafe { + mem::transmute::< + UserCallbackTransitionResult<'_>, + UserCallbackTransitionResult<'_>, + >(UserCallbackTransitionResult::ReentrancyPrevented { + queued_events, + }) + }; + }, + + &mut AppStateImpl::ProcessingEvents { .. } + | &mut AppStateImpl::ProcessingRedraws { .. } => {}, + + s @ &mut AppStateImpl::PollFinished { .. } + | s @ &mut AppStateImpl::Waiting { .. } + | s @ &mut AppStateImpl::Terminated => { + bug!("unexpected attempted to process an event {:?}", s) + }, + } + + let (handler, queued_gpu_redraws, active_control_flow, processing_redraws) = match self + .take_state() + { + AppStateImpl::Launching { .. } + | AppStateImpl::NotLaunched { .. } + | AppStateImpl::InUserCallback { .. } => unreachable!(), + AppStateImpl::ProcessingEvents { handler, queued_gpu_redraws, active_control_flow } => { + (handler, queued_gpu_redraws, active_control_flow, false) + }, + AppStateImpl::ProcessingRedraws { handler, active_control_flow } => { + (handler, Default::default(), active_control_flow, true) + }, + AppStateImpl::PollFinished { .. } + | AppStateImpl::Waiting { .. } + | AppStateImpl::Terminated => unreachable!(), + }; + self.set_state(AppStateImpl::InUserCallback { + queued_events: Vec::new(), + queued_gpu_redraws, + }); + UserCallbackTransitionResult::Success { handler, active_control_flow, processing_redraws } + } + + fn main_events_cleared_transition(&mut self) -> HashSet> { + let (handler, queued_gpu_redraws, active_control_flow) = match self.take_state() { + AppStateImpl::ProcessingEvents { handler, queued_gpu_redraws, active_control_flow } => { + (handler, queued_gpu_redraws, active_control_flow) + }, + s => bug!("unexpected state {:?}", s), + }; + self.set_state(AppStateImpl::ProcessingRedraws { handler, active_control_flow }); + queued_gpu_redraws + } + + fn events_cleared_transition(&mut self) { + if !self.has_launched() || self.has_terminated() { + return; + } + let (waiting_handler, old) = match self.take_state() { + AppStateImpl::ProcessingRedraws { handler, active_control_flow } => { + (handler, active_control_flow) + }, + s => bug!("unexpected state {:?}", s), + }; + + let new = self.control_flow; + match (old, new) { + (ControlFlow::Wait, ControlFlow::Wait) => { + let start = Instant::now(); + self.set_state(AppStateImpl::Waiting { waiting_handler, start }); + self.waker.stop() + }, + (ControlFlow::WaitUntil(old_instant), ControlFlow::WaitUntil(new_instant)) + if old_instant == new_instant => + { + let start = Instant::now(); + self.set_state(AppStateImpl::Waiting { waiting_handler, start }); + }, + (_, ControlFlow::Wait) => { + let start = Instant::now(); + self.set_state(AppStateImpl::Waiting { waiting_handler, start }); + self.waker.stop() + }, + (_, ControlFlow::WaitUntil(new_instant)) => { + let start = Instant::now(); + self.set_state(AppStateImpl::Waiting { waiting_handler, start }); + self.waker.start_at(new_instant) + }, + // Unlike on macOS, handle Poll to Poll transition here to call the waker + (_, ControlFlow::Poll) => { + self.set_state(AppStateImpl::PollFinished { waiting_handler }); + self.waker.start() + }, + } + } + + fn terminated_transition(&mut self) -> EventLoopHandler { + match self.replace_state(AppStateImpl::Terminated) { + AppStateImpl::ProcessingEvents { handler, .. } => handler, + s => bug!("`LoopExiting` happened while not processing events {:?}", s), + } + } + + pub(crate) fn set_control_flow(&mut self, control_flow: ControlFlow) { + self.control_flow = control_flow; + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + self.control_flow + } +} + +pub(crate) fn set_key_window(mtm: MainThreadMarker, window: &Retained) { + let mut this = AppState::get_mut(mtm); + match this.state_mut() { + &mut AppStateImpl::NotLaunched { ref mut queued_windows, .. } => { + return queued_windows.push(window.clone()) + }, + &mut AppStateImpl::ProcessingEvents { .. } + | &mut AppStateImpl::InUserCallback { .. } + | &mut AppStateImpl::ProcessingRedraws { .. } => {}, + s @ &mut AppStateImpl::Launching { .. } + | s @ &mut AppStateImpl::Waiting { .. } + | s @ &mut AppStateImpl::PollFinished { .. } => bug!("unexpected state {:?}", s), + &mut AppStateImpl::Terminated => { + panic!("Attempt to create a `Window` after the app has terminated") + }, + } + drop(this); + window.makeKeyAndVisible(); +} + +pub(crate) fn queue_gl_or_metal_redraw(mtm: MainThreadMarker, window: Retained) { + let mut this = AppState::get_mut(mtm); + match this.state_mut() { + &mut AppStateImpl::NotLaunched { ref mut queued_gpu_redraws, .. } + | &mut AppStateImpl::Launching { ref mut queued_gpu_redraws, .. } + | &mut AppStateImpl::ProcessingEvents { ref mut queued_gpu_redraws, .. } + | &mut AppStateImpl::InUserCallback { ref mut queued_gpu_redraws, .. } => { + let _ = queued_gpu_redraws.insert(window); + }, + s @ &mut AppStateImpl::ProcessingRedraws { .. } + | s @ &mut AppStateImpl::Waiting { .. } + | s @ &mut AppStateImpl::PollFinished { .. } => bug!("unexpected state {:?}", s), + &mut AppStateImpl::Terminated => { + panic!("Attempt to create a `Window` after the app has terminated") + }, + } +} + +pub(crate) fn will_launch(mtm: MainThreadMarker, queued_handler: EventLoopHandler) { + AppState::get_mut(mtm).will_launch_transition(queued_handler) +} + +pub fn did_finish_launching(mtm: MainThreadMarker) { + let mut this = AppState::get_mut(mtm); + let windows = match this.state_mut() { + AppStateImpl::Launching { queued_windows, .. } => mem::take(queued_windows), + s => bug!("unexpected state {:?}", s), + }; + + this.waker.start(); + + // have to drop RefMut because the window setup code below can trigger new events + drop(this); + + for window in windows { + // Do a little screen dance here to account for windows being created before + // `UIApplicationMain` is called. This fixes visual issues such as being + // offcenter and sized incorrectly. Additionally, to fix orientation issues, we + // gotta reset the `rootViewController`. + // + // relevant iOS log: + // ``` + // [ApplicationLifecycle] Windows were created before application initialization + // completed. This may result in incorrect visual appearance. + // ``` + let screen = window.screen(); + let _: () = unsafe { msg_send![&window, setScreen: ptr::null::()] }; + window.setScreen(&screen); + + let controller = window.rootViewController(); + window.setRootViewController(None); + window.setRootViewController(controller.as_deref()); + + window.makeKeyAndVisible(); + } + + let (windows, events) = AppState::get_mut(mtm).did_finish_launching_transition(); + + let events = std::iter::once(EventWrapper::StaticEvent(Event::NewEvents(StartCause::Init))) + .chain(events); + handle_nonuser_events(mtm, events); + + // the above window dance hack, could possibly trigger new windows to be created. + // we can just set those windows up normally, as they were created after didFinishLaunching + for window in windows { + window.makeKeyAndVisible(); + } +} + +// AppState::did_finish_launching handles the special transition `Init` +pub fn handle_wakeup_transition(mtm: MainThreadMarker) { + let mut this = AppState::get_mut(mtm); + let wakeup_event = match this.wakeup_transition() { + None => return, + Some(wakeup_event) => wakeup_event, + }; + drop(this); + + handle_nonuser_event(mtm, wakeup_event) +} + +pub(crate) fn handle_nonuser_event(mtm: MainThreadMarker, event: EventWrapper) { + handle_nonuser_events(mtm, std::iter::once(event)) +} + +pub(crate) fn handle_nonuser_events>( + mtm: MainThreadMarker, + events: I, +) { + let mut this = AppState::get_mut(mtm); + if this.has_terminated() { + return; + } + + let (mut handler, active_control_flow, processing_redraws) = + match this.try_user_callback_transition() { + UserCallbackTransitionResult::ReentrancyPrevented { queued_events } => { + queued_events.extend(events); + return; + }, + UserCallbackTransitionResult::Success { + handler, + active_control_flow, + processing_redraws, + } => (handler, active_control_flow, processing_redraws), + }; + drop(this); + + for wrapper in events { + match wrapper { + EventWrapper::StaticEvent(event) => { + if !processing_redraws && event.is_redraw() { + tracing::info!("processing `RedrawRequested` during the main event loop"); + } else if processing_redraws && !event.is_redraw() { + tracing::warn!( + "processing non `RedrawRequested` event after the main event loop: {:#?}", + event + ); + } + handler.handle_event(event) + }, + EventWrapper::ScaleFactorChanged(event) => handle_hidpi_proxy(&mut handler, event), + } + } + + loop { + let mut this = AppState::get_mut(mtm); + let queued_events = match this.state_mut() { + &mut AppStateImpl::InUserCallback { ref mut queued_events, queued_gpu_redraws: _ } => { + mem::take(queued_events) + }, + s => bug!("unexpected state {:?}", s), + }; + if queued_events.is_empty() { + let queued_gpu_redraws = match this.take_state() { + AppStateImpl::InUserCallback { queued_events: _, queued_gpu_redraws } => { + queued_gpu_redraws + }, + _ => unreachable!(), + }; + this.app_state = Some(if processing_redraws { + bug_assert!( + queued_gpu_redraws.is_empty(), + "redraw queued while processing redraws" + ); + AppStateImpl::ProcessingRedraws { handler, active_control_flow } + } else { + AppStateImpl::ProcessingEvents { handler, queued_gpu_redraws, active_control_flow } + }); + break; + } + drop(this); + + for wrapper in queued_events { + match wrapper { + EventWrapper::StaticEvent(event) => { + if !processing_redraws && event.is_redraw() { + tracing::info!("processing `RedrawRequested` during the main event loop"); + } else if processing_redraws && !event.is_redraw() { + tracing::warn!( + "processing non-`RedrawRequested` event after the main event loop: \ + {:#?}", + event + ); + } + handler.handle_event(event) + }, + EventWrapper::ScaleFactorChanged(event) => handle_hidpi_proxy(&mut handler, event), + } + } + } +} + +fn handle_user_events(mtm: MainThreadMarker) { + let mut this = AppState::get_mut(mtm); + let (mut handler, active_control_flow, processing_redraws) = + match this.try_user_callback_transition() { + UserCallbackTransitionResult::ReentrancyPrevented { .. } => { + bug!("unexpected attempted to process an event") + }, + UserCallbackTransitionResult::Success { + handler, + active_control_flow, + processing_redraws, + } => (handler, active_control_flow, processing_redraws), + }; + if processing_redraws { + bug!("user events attempted to be sent out while `ProcessingRedraws`"); + } + drop(this); + + handler.handle_event(Event::UserEvent(HandlePendingUserEvents)); + + loop { + let mut this = AppState::get_mut(mtm); + let queued_events = match this.state_mut() { + &mut AppStateImpl::InUserCallback { ref mut queued_events, queued_gpu_redraws: _ } => { + mem::take(queued_events) + }, + s => bug!("unexpected state {:?}", s), + }; + if queued_events.is_empty() { + let queued_gpu_redraws = match this.take_state() { + AppStateImpl::InUserCallback { queued_events: _, queued_gpu_redraws } => { + queued_gpu_redraws + }, + _ => unreachable!(), + }; + this.app_state = Some(AppStateImpl::ProcessingEvents { + handler, + queued_gpu_redraws, + active_control_flow, + }); + break; + } + drop(this); + + for wrapper in queued_events { + match wrapper { + EventWrapper::StaticEvent(event) => handler.handle_event(event), + EventWrapper::ScaleFactorChanged(event) => handle_hidpi_proxy(&mut handler, event), + } + } + + handler.handle_event(Event::UserEvent(HandlePendingUserEvents)); + } +} + +pub(crate) fn send_occluded_event_for_all_windows(application: &UIApplication, occluded: bool) { + let mtm = MainThreadMarker::from(application); + + let mut events = Vec::new(); + #[allow(deprecated)] + for window in application.windows().iter() { + if window.is_kind_of::() { + // SAFETY: We just checked that the window is a `winit` window + let window = unsafe { + let ptr: *const UIWindow = window; + let ptr: *const WinitUIWindow = ptr.cast(); + &*ptr + }; + events.push(EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::Occluded(occluded), + })); + } + } + handle_nonuser_events(mtm, events); +} + +pub fn handle_main_events_cleared(mtm: MainThreadMarker) { + let mut this = AppState::get_mut(mtm); + if !this.has_launched() || this.has_terminated() { + return; + } + match this.state_mut() { + AppStateImpl::ProcessingEvents { .. } => {}, + _ => bug!("`ProcessingRedraws` happened unexpectedly"), + }; + drop(this); + + handle_user_events(mtm); + + let mut this = AppState::get_mut(mtm); + let redraw_events: Vec = this + .main_events_cleared_transition() + .into_iter() + .map(|window| { + EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::RedrawRequested, + }) + }) + .collect(); + drop(this); + + handle_nonuser_events(mtm, redraw_events); + handle_nonuser_event(mtm, EventWrapper::StaticEvent(Event::AboutToWait)); +} + +pub fn handle_events_cleared(mtm: MainThreadMarker) { + AppState::get_mut(mtm).events_cleared_transition(); +} + +pub(crate) fn terminated(application: &UIApplication) { + let mtm = MainThreadMarker::from(application); + + let mut events = Vec::new(); + #[allow(deprecated)] + for window in application.windows().iter() { + if window.is_kind_of::() { + // SAFETY: We just checked that the window is a `winit` window + let window = unsafe { + let ptr: *const UIWindow = window; + let ptr: *const WinitUIWindow = ptr.cast(); + &*ptr + }; + events.push(EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::Destroyed, + })); + } + } + handle_nonuser_events(mtm, events); + + let mut this = AppState::get_mut(mtm); + let mut handler = this.terminated_transition(); + drop(this); + + handler.handle_event(Event::LoopExiting) +} + +fn handle_hidpi_proxy(handler: &mut EventLoopHandler, event: ScaleFactorChanged) { + let ScaleFactorChanged { suggested_size, scale_factor, window } = event; + let new_inner_size = Arc::new(Mutex::new(suggested_size)); + let event = Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::ScaleFactorChanged { + scale_factor, + inner_size_writer: InnerSizeWriter::new(Arc::downgrade(&new_inner_size)), + }, + }; + handler.handle_event(event); + let (view, screen_frame) = get_view_and_screen_frame(&window); + let physical_size = *new_inner_size.lock().unwrap(); + drop(new_inner_size); + let logical_size = physical_size.to_logical(scale_factor); + let size = CGSize::new(logical_size.width, logical_size.height); + let new_frame: CGRect = CGRect::new(screen_frame.origin, size); + view.setFrame(new_frame); +} + +fn get_view_and_screen_frame(window: &WinitUIWindow) -> (Retained, CGRect) { + let view_controller = window.rootViewController().unwrap(); + let view = view_controller.view().unwrap(); + let bounds = window.bounds(); + let screen = window.screen(); + let screen_space = screen.coordinateSpace(); + let screen_frame = window.convertRect_toCoordinateSpace(bounds, &screen_space); + (view, screen_frame) +} + +struct EventLoopWaker { + timer: CFRunLoopTimerRef, +} + +impl Drop for EventLoopWaker { + fn drop(&mut self) { + unsafe { + CFRunLoopTimerInvalidate(self.timer); + CFRelease(self.timer as _); + } + } +} + +impl EventLoopWaker { + fn new(rl: CFRunLoopRef) -> EventLoopWaker { + extern "C" fn wakeup_main_loop(_timer: CFRunLoopTimerRef, _info: *mut c_void) {} + unsafe { + // Create a timer with a 0.1µs interval (1ns does not work) to mimic polling. + // It is initially setup with a first fire time really far into the + // future, but that gets changed to fire immediately in did_finish_launching + let timer = CFRunLoopTimerCreate( + ptr::null_mut(), + f64::MAX, + 0.000_000_1, + 0, + 0, + wakeup_main_loop, + ptr::null_mut(), + ); + CFRunLoopAddTimer(rl, timer, kCFRunLoopCommonModes); + + EventLoopWaker { timer } + } + } + + fn stop(&mut self) { + unsafe { CFRunLoopTimerSetNextFireDate(self.timer, f64::MAX) } + } + + fn start(&mut self) { + unsafe { CFRunLoopTimerSetNextFireDate(self.timer, f64::MIN) } + } + + fn start_at(&mut self, instant: Instant) { + let now = Instant::now(); + if now >= instant { + self.start(); + } else { + unsafe { + let current = CFAbsoluteTimeGetCurrent(); + let duration = instant - now; + let fsecs = + duration.subsec_nanos() as f64 / 1_000_000_000.0 + duration.as_secs() as f64; + CFRunLoopTimerSetNextFireDate(self.timer, current + fsecs) + } + } + } +} + +macro_rules! os_capabilities { + ( + $( + $(#[$attr:meta])* + $error_name:ident: $objc_call:literal, + $name:ident: $major:literal-$minor:literal + ),* + $(,)* + ) => { + #[derive(Clone, Debug)] + pub struct OSCapabilities { + $( + pub $name: bool, + )* + + os_version: NSOperatingSystemVersion, + } + + impl OSCapabilities { + fn from_os_version(os_version: NSOperatingSystemVersion) -> Self { + $(let $name = meets_requirements(os_version, $major, $minor);)* + Self { $($name,)* os_version, } + } + } + + impl OSCapabilities {$( + $(#[$attr])* + pub fn $error_name(&self, extra_msg: &str) { + tracing::warn!( + concat!("`", $objc_call, "` requires iOS {}.{}+. This device is running iOS {}.{}.{}. {}"), + $major, $minor, self.os_version.majorVersion, self.os_version.minorVersion, self.os_version.patchVersion, + extra_msg + ) + } + )*} + }; +} + +os_capabilities! { + /// + #[allow(unused)] // error message unused + safe_area_err_msg: "-[UIView safeAreaInsets]", + safe_area: 11-0, + /// + home_indicator_hidden_err_msg: "-[UIViewController setNeedsUpdateOfHomeIndicatorAutoHidden]", + home_indicator_hidden: 11-0, + /// + defer_system_gestures_err_msg: "-[UIViewController setNeedsUpdateOfScreenEdgesDeferringSystem]", + defer_system_gestures: 11-0, + /// + maximum_frames_per_second_err_msg: "-[UIScreen maximumFramesPerSecond]", + maximum_frames_per_second: 10-3, + /// + #[allow(unused)] // error message unused + force_touch_err_msg: "-[UITouch force]", + force_touch: 9-0, +} + +fn meets_requirements( + version: NSOperatingSystemVersion, + required_major: NSInteger, + required_minor: NSInteger, +) -> bool { + (version.majorVersion, version.minorVersion) >= (required_major, required_minor) +} + +fn get_version() -> NSOperatingSystemVersion { + let process_info = NSProcessInfo::processInfo(); + let atleast_ios_8 = process_info.respondsToSelector(sel!(operatingSystemVersion)); + // Winit requires atleast iOS 8 because no one has put the time into supporting earlier os + // versions. Older iOS versions are increasingly difficult to test. For example, Xcode 11 does + // not support debugging on devices with an iOS version of less than 8. Another example, in + // order to use an iOS simulator older than iOS 8, you must download an older version of Xcode + // (<9), and at least Xcode 7 has been tested to not even run on macOS 10.15 - Xcode 8 might? + // + // The minimum required iOS version is likely to grow in the future. + assert!(atleast_ios_8, "`winit` requires iOS version 8 or greater"); + process_info.operatingSystemVersion() +} + +pub fn os_capabilities() -> OSCapabilities { + // Cache the version lookup for efficiency + static OS_CAPABILITIES: OnceLock = OnceLock::new(); + OS_CAPABILITIES.get_or_init(|| OSCapabilities::from_os_version(get_version())).clone() +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/event_loop.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/event_loop.rs new file mode 100644 index 00000000..a093b7b5 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/event_loop.rs @@ -0,0 +1,494 @@ +use std::collections::VecDeque; +use std::ffi::{c_char, c_int, c_void}; +use std::marker::PhantomData; +use std::ptr::{self, NonNull}; +use std::sync::mpsc::{self, Receiver, Sender}; + +use core_foundation::base::{CFIndex, CFRelease}; +use core_foundation::runloop::{ + kCFRunLoopAfterWaiting, kCFRunLoopBeforeWaiting, kCFRunLoopCommonModes, kCFRunLoopDefaultMode, + kCFRunLoopExit, CFRunLoopActivity, CFRunLoopAddObserver, CFRunLoopAddSource, CFRunLoopGetMain, + CFRunLoopObserverCreate, CFRunLoopObserverRef, CFRunLoopSourceContext, CFRunLoopSourceCreate, + CFRunLoopSourceInvalidate, CFRunLoopSourceRef, CFRunLoopSourceSignal, CFRunLoopWakeUp, +}; +use objc2::rc::Retained; +use objc2::{msg_send_id, ClassType}; +use objc2_foundation::{MainThreadMarker, NSNotificationCenter, NSObject}; +use objc2_ui_kit::{ + UIApplication, UIApplicationDidBecomeActiveNotification, + UIApplicationDidEnterBackgroundNotification, UIApplicationDidFinishLaunchingNotification, + UIApplicationDidReceiveMemoryWarningNotification, UIApplicationMain, + UIApplicationWillEnterForegroundNotification, UIApplicationWillResignActiveNotification, + UIApplicationWillTerminateNotification, UIDevice, UIScreen, UIUserInterfaceIdiom, +}; + +use crate::error::EventLoopError; +use crate::event::Event; +use crate::event_loop::{ + ActiveEventLoop as RootActiveEventLoop, ControlFlow, DeviceEvents, EventLoopClosed, +}; +use crate::platform::ios::Idiom; +use crate::platform_impl::ios::app_state::{EventLoopHandler, HandlePendingUserEvents}; +use crate::window::{CustomCursor, CustomCursorSource, Theme}; + +use super::app_state::{send_occluded_event_for_all_windows, AppState, EventWrapper}; +use super::notification_center::create_observer; +use super::{app_state, monitor, MonitorHandle}; + +#[derive(Debug)] +pub struct ActiveEventLoop { + pub(super) mtm: MainThreadMarker, +} + +impl ActiveEventLoop { + pub fn create_custom_cursor(&self, source: CustomCursorSource) -> CustomCursor { + let _ = source.inner; + CustomCursor { inner: super::PlatformCustomCursor } + } + + pub fn available_monitors(&self) -> VecDeque { + monitor::uiscreens(self.mtm) + } + + pub fn primary_monitor(&self) -> Option { + #[allow(deprecated)] + Some(MonitorHandle::new(UIScreen::mainScreen(self.mtm))) + } + + #[inline] + pub fn listen_device_events(&self, _allowed: DeviceEvents) {} + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::UiKit(rwh_05::UiKitDisplayHandle::empty()) + } + + #[inline] + pub fn system_theme(&self) -> Option { + None + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::UiKit(rwh_06::UiKitDisplayHandle::new())) + } + + pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) { + AppState::get_mut(self.mtm).set_control_flow(control_flow) + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + AppState::get_mut(self.mtm).control_flow() + } + + pub(crate) fn exit(&self) { + // https://developer.apple.com/library/archive/qa/qa1561/_index.html + // it is not possible to quit an iOS app gracefully and programmatically + tracing::warn!("`ControlFlow::Exit` ignored on iOS"); + } + + pub(crate) fn exiting(&self) -> bool { + false + } + + pub(crate) fn owned_display_handle(&self) -> OwnedDisplayHandle { + OwnedDisplayHandle + } +} + +#[derive(Clone)] +pub(crate) struct OwnedDisplayHandle; + +impl OwnedDisplayHandle { + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::UiKitDisplayHandle::empty().into() + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::UiKitDisplayHandle::new().into()) + } +} + +fn map_user_event( + mut handler: impl FnMut(Event, &RootActiveEventLoop), + receiver: mpsc::Receiver, +) -> impl FnMut(Event, &RootActiveEventLoop) { + move |event, window_target| match event.map_nonuser_event() { + Ok(event) => (handler)(event, window_target), + Err(_) => { + for event in receiver.try_iter() { + (handler)(Event::UserEvent(event), window_target); + } + }, + } +} + +pub struct EventLoop { + mtm: MainThreadMarker, + sender: Sender, + receiver: Receiver, + window_target: RootActiveEventLoop, + + // Since iOS 9.0, we no longer need to remove the observers before they are deallocated; the + // system instead cleans it up next time it would have posted a notification to it. + // + // Though we do still need to keep the observers around to prevent them from being deallocated. + _did_finish_launching_observer: Retained, + _did_become_active_observer: Retained, + _will_resign_active_observer: Retained, + _will_enter_foreground_observer: Retained, + _did_enter_background_observer: Retained, + _will_terminate_observer: Retained, + _did_receive_memory_warning_observer: Retained, +} + +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PlatformSpecificEventLoopAttributes {} + +impl EventLoop { + pub(crate) fn new( + _: &PlatformSpecificEventLoopAttributes, + ) -> Result, EventLoopError> { + let mtm = MainThreadMarker::new() + .expect("On iOS, `EventLoop` must be created on the main thread"); + + static mut SINGLETON_INIT: bool = false; + unsafe { + assert!( + !SINGLETON_INIT, + "Only one `EventLoop` is supported on iOS. `EventLoopProxy` might be helpful" + ); + SINGLETON_INIT = true; + } + + let (sender, receiver) = mpsc::channel(); + + // this line sets up the main run loop before `UIApplicationMain` + setup_control_flow_observers(); + + let center = unsafe { NSNotificationCenter::defaultCenter() }; + + let _did_finish_launching_observer = create_observer( + ¢er, + // `application:didFinishLaunchingWithOptions:` + unsafe { UIApplicationDidFinishLaunchingNotification }, + move |_| { + app_state::did_finish_launching(mtm); + }, + ); + let _did_become_active_observer = create_observer( + ¢er, + // `applicationDidBecomeActive:` + unsafe { UIApplicationDidBecomeActiveNotification }, + move |_| { + app_state::handle_nonuser_event(mtm, EventWrapper::StaticEvent(Event::Resumed)); + }, + ); + let _will_resign_active_observer = create_observer( + ¢er, + // `applicationWillResignActive:` + unsafe { UIApplicationWillResignActiveNotification }, + move |_| { + app_state::handle_nonuser_event(mtm, EventWrapper::StaticEvent(Event::Suspended)); + }, + ); + let _will_enter_foreground_observer = create_observer( + ¢er, + // `applicationWillEnterForeground:` + unsafe { UIApplicationWillEnterForegroundNotification }, + move |notification| { + let app = unsafe { notification.object() }.expect( + "UIApplicationWillEnterForegroundNotification to have application object", + ); + // SAFETY: The `object` in `UIApplicationWillEnterForegroundNotification` is + // documented to be `UIApplication`. + let app: Retained = unsafe { Retained::cast(app) }; + send_occluded_event_for_all_windows(&app, false); + }, + ); + let _did_enter_background_observer = create_observer( + ¢er, + // `applicationDidEnterBackground:` + unsafe { UIApplicationDidEnterBackgroundNotification }, + move |notification| { + let app = unsafe { notification.object() }.expect( + "UIApplicationDidEnterBackgroundNotification to have application object", + ); + // SAFETY: The `object` in `UIApplicationDidEnterBackgroundNotification` is + // documented to be `UIApplication`. + let app: Retained = unsafe { Retained::cast(app) }; + send_occluded_event_for_all_windows(&app, true); + }, + ); + let _will_terminate_observer = create_observer( + ¢er, + // `applicationWillTerminate:` + unsafe { UIApplicationWillTerminateNotification }, + move |notification| { + let app = unsafe { notification.object() } + .expect("UIApplicationWillTerminateNotification to have application object"); + // SAFETY: The `object` in `UIApplicationWillTerminateNotification` is + // (somewhat) documented to be `UIApplication`. + let app: Retained = unsafe { Retained::cast(app) }; + app_state::terminated(&app); + }, + ); + let _did_receive_memory_warning_observer = create_observer( + ¢er, + // `applicationDidReceiveMemoryWarning:` + unsafe { UIApplicationDidReceiveMemoryWarningNotification }, + move |_| { + app_state::handle_nonuser_event( + mtm, + EventWrapper::StaticEvent(Event::MemoryWarning), + ); + }, + ); + + Ok(EventLoop { + mtm, + sender, + receiver, + window_target: RootActiveEventLoop { p: ActiveEventLoop { mtm }, _marker: PhantomData }, + _did_finish_launching_observer, + _did_become_active_observer, + _will_resign_active_observer, + _will_enter_foreground_observer, + _did_enter_background_observer, + _will_terminate_observer, + _did_receive_memory_warning_observer, + }) + } + + pub fn run(self, handler: F) -> ! + where + F: FnMut(Event, &RootActiveEventLoop), + { + let application: Option> = + unsafe { msg_send_id![UIApplication::class(), sharedApplication] }; + assert!( + application.is_none(), + "\ + `EventLoop` cannot be `run` after a call to `UIApplicationMain` on iOS\nNote: \ + `EventLoop::run_app` calls `UIApplicationMain` on iOS", + ); + + let handler = map_user_event(handler, self.receiver); + + let handler = unsafe { + std::mem::transmute::< + Box, &RootActiveEventLoop)>, + Box, &RootActiveEventLoop)>, + >(Box::new(handler)) + }; + + let handler = EventLoopHandler { handler, event_loop: self.window_target }; + + app_state::will_launch(self.mtm, handler); + + extern "C" { + // These functions are in crt_externs.h. + fn _NSGetArgc() -> *mut c_int; + fn _NSGetArgv() -> *mut *mut *mut c_char; + } + + unsafe { + UIApplicationMain( + *_NSGetArgc(), + NonNull::new(*_NSGetArgv()).unwrap(), + // We intentionally override neither the application nor the delegate, to allow the + // user to do so themselves! + None, + None, + ) + }; + unreachable!() + } + + pub fn create_proxy(&self) -> EventLoopProxy { + EventLoopProxy::new(self.sender.clone()) + } + + pub fn window_target(&self) -> &RootActiveEventLoop { + &self.window_target + } +} + +// EventLoopExtIOS +impl EventLoop { + pub fn idiom(&self) -> Idiom { + match UIDevice::currentDevice(self.mtm).userInterfaceIdiom() { + UIUserInterfaceIdiom::Unspecified => Idiom::Unspecified, + UIUserInterfaceIdiom::Phone => Idiom::Phone, + UIUserInterfaceIdiom::Pad => Idiom::Pad, + UIUserInterfaceIdiom::TV => Idiom::TV, + UIUserInterfaceIdiom::CarPlay => Idiom::CarPlay, + _ => Idiom::Unspecified, + } + } +} + +pub struct EventLoopProxy { + sender: Sender, + source: CFRunLoopSourceRef, +} + +unsafe impl Send for EventLoopProxy {} +unsafe impl Sync for EventLoopProxy {} + +impl Clone for EventLoopProxy { + fn clone(&self) -> EventLoopProxy { + EventLoopProxy::new(self.sender.clone()) + } +} + +impl Drop for EventLoopProxy { + fn drop(&mut self) { + unsafe { + CFRunLoopSourceInvalidate(self.source); + CFRelease(self.source as _); + } + } +} + +impl EventLoopProxy { + fn new(sender: Sender) -> EventLoopProxy { + unsafe { + // just wake up the eventloop + extern "C" fn event_loop_proxy_handler(_: *const c_void) {} + + // adding a Source to the main CFRunLoop lets us wake it up and + // process user events through the normal OS EventLoop mechanisms. + let rl = CFRunLoopGetMain(); + let mut context = CFRunLoopSourceContext { + version: 0, + info: ptr::null_mut(), + retain: None, + release: None, + copyDescription: None, + equal: None, + hash: None, + schedule: None, + cancel: None, + perform: event_loop_proxy_handler, + }; + let source = CFRunLoopSourceCreate(ptr::null_mut(), CFIndex::MAX - 1, &mut context); + CFRunLoopAddSource(rl, source, kCFRunLoopCommonModes); + CFRunLoopWakeUp(rl); + + EventLoopProxy { sender, source } + } + } + + pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed> { + self.sender.send(event).map_err(|::std::sync::mpsc::SendError(x)| EventLoopClosed(x))?; + unsafe { + // let the main thread know there's a new event + CFRunLoopSourceSignal(self.source); + let rl = CFRunLoopGetMain(); + CFRunLoopWakeUp(rl); + } + Ok(()) + } +} + +fn setup_control_flow_observers() { + unsafe { + // begin is queued with the highest priority to ensure it is processed before other + // observers + extern "C" fn control_flow_begin_handler( + _: CFRunLoopObserverRef, + activity: CFRunLoopActivity, + _: *mut c_void, + ) { + let mtm = MainThreadMarker::new().unwrap(); + #[allow(non_upper_case_globals)] + match activity { + kCFRunLoopAfterWaiting => app_state::handle_wakeup_transition(mtm), + _ => unreachable!(), + } + } + + // Core Animation registers its `CFRunLoopObserver` that performs drawing operations in + // `CA::Transaction::ensure_implicit` with a priority of `0x1e8480`. We set the main_end + // priority to be 0, in order to send AboutToWait before RedrawRequested. This value was + // chosen conservatively to guard against apple using different priorities for their redraw + // observers in different OS's or on different devices. If it so happens that it's too + // conservative, the main symptom would be non-redraw events coming in after `AboutToWait`. + // + // The value of `0x1e8480` was determined by inspecting stack traces and the associated + // registers for every `CFRunLoopAddObserver` call on an iPad Air 2 running iOS 11.4. + // + // Also tested to be `0x1e8480` on iPhone 8, iOS 13 beta 4. + extern "C" fn control_flow_main_end_handler( + _: CFRunLoopObserverRef, + activity: CFRunLoopActivity, + _: *mut c_void, + ) { + let mtm = MainThreadMarker::new().unwrap(); + #[allow(non_upper_case_globals)] + match activity { + kCFRunLoopBeforeWaiting => app_state::handle_main_events_cleared(mtm), + kCFRunLoopExit => {}, // may happen when running on macOS + _ => unreachable!(), + } + } + + // end is queued with the lowest priority to ensure it is processed after other observers + extern "C" fn control_flow_end_handler( + _: CFRunLoopObserverRef, + activity: CFRunLoopActivity, + _: *mut c_void, + ) { + let mtm = MainThreadMarker::new().unwrap(); + #[allow(non_upper_case_globals)] + match activity { + kCFRunLoopBeforeWaiting => app_state::handle_events_cleared(mtm), + kCFRunLoopExit => {}, // may happen when running on macOS + _ => unreachable!(), + } + } + + let main_loop = CFRunLoopGetMain(); + + let begin_observer = CFRunLoopObserverCreate( + ptr::null_mut(), + kCFRunLoopAfterWaiting, + 1, // repeat = true + CFIndex::MIN, + control_flow_begin_handler, + ptr::null_mut(), + ); + CFRunLoopAddObserver(main_loop, begin_observer, kCFRunLoopDefaultMode); + + let main_end_observer = CFRunLoopObserverCreate( + ptr::null_mut(), + kCFRunLoopExit | kCFRunLoopBeforeWaiting, + 1, // repeat = true + 0, // see comment on `control_flow_main_end_handler` + control_flow_main_end_handler, + ptr::null_mut(), + ); + CFRunLoopAddObserver(main_loop, main_end_observer, kCFRunLoopDefaultMode); + + let end_observer = CFRunLoopObserverCreate( + ptr::null_mut(), + kCFRunLoopExit | kCFRunLoopBeforeWaiting, + 1, // repeat = true + CFIndex::MAX, + control_flow_end_handler, + ptr::null_mut(), + ); + CFRunLoopAddObserver(main_loop, end_observer, kCFRunLoopDefaultMode); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/mod.rs new file mode 100644 index 00000000..69e79c92 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/mod.rs @@ -0,0 +1,52 @@ +#![allow(clippy::let_unit_value)] + +mod app_state; +mod event_loop; +mod monitor; +mod notification_center; +mod view; +mod view_controller; +mod window; + +use std::fmt; + +use crate::event::DeviceId as RootDeviceId; + +pub(crate) use self::event_loop::{ + ActiveEventLoop, EventLoop, EventLoopProxy, OwnedDisplayHandle, + PlatformSpecificEventLoopAttributes, +}; +pub(crate) use self::monitor::{MonitorHandle, VideoModeHandle}; +pub(crate) use self::window::{PlatformSpecificWindowAttributes, Window, WindowId}; +pub(crate) use crate::cursor::{ + NoCustomCursor as PlatformCustomCursor, NoCustomCursor as PlatformCustomCursorSource, +}; +pub(crate) use crate::icon::NoIcon as PlatformIcon; +pub(crate) use crate::platform_impl::Fullscreen; + +/// There is no way to detect which device that performed a certain event in +/// UIKit (i.e. you can't differentiate between different external keyboards, +/// or whether it was the main touchscreen, assistive technologies, or some +/// other pointer device that caused a touch event). +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeviceId; + +impl DeviceId { + pub const fn dummy() -> Self { + DeviceId + } +} + +pub(crate) const DEVICE_ID: RootDeviceId = RootDeviceId(DeviceId); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct KeyEventExtra {} + +#[derive(Debug)] +pub enum OsError {} + +impl fmt::Display for OsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "os error") + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/monitor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/monitor.rs new file mode 100644 index 00000000..9f017a24 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/monitor.rs @@ -0,0 +1,277 @@ +#![allow(clippy::unnecessary_cast)] + +use std::collections::{BTreeSet, VecDeque}; +use std::{fmt, hash, ptr}; + +use objc2::mutability::IsRetainable; +use objc2::rc::Retained; +use objc2::Message; +use objc2_foundation::{run_on_main, MainThreadBound, MainThreadMarker, NSInteger}; +use objc2_ui_kit::{UIScreen, UIScreenMode}; + +use crate::dpi::{PhysicalPosition, PhysicalSize}; +use crate::monitor::VideoModeHandle as RootVideoModeHandle; +use crate::platform_impl::platform::app_state; + +// Workaround for `MainThreadBound` implementing almost no traits +#[derive(Debug)] +struct MainThreadBoundDelegateImpls(MainThreadBound>); + +impl Clone for MainThreadBoundDelegateImpls { + fn clone(&self) -> Self { + Self(run_on_main(|mtm| MainThreadBound::new(Retained::clone(self.0.get(mtm)), mtm))) + } +} + +impl hash::Hash for MainThreadBoundDelegateImpls { + fn hash(&self, state: &mut H) { + // SAFETY: Marker only used to get the pointer + let mtm = unsafe { MainThreadMarker::new_unchecked() }; + Retained::as_ptr(self.0.get(mtm)).hash(state); + } +} + +impl PartialEq for MainThreadBoundDelegateImpls { + fn eq(&self, other: &Self) -> bool { + // SAFETY: Marker only used to get the pointer + let mtm = unsafe { MainThreadMarker::new_unchecked() }; + Retained::as_ptr(self.0.get(mtm)) == Retained::as_ptr(other.0.get(mtm)) + } +} + +impl Eq for MainThreadBoundDelegateImpls {} + +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub struct VideoModeHandle { + pub(crate) size: (u32, u32), + pub(crate) bit_depth: u16, + pub(crate) refresh_rate_millihertz: u32, + screen_mode: MainThreadBoundDelegateImpls, + pub(crate) monitor: MonitorHandle, +} + +impl VideoModeHandle { + fn new( + uiscreen: Retained, + screen_mode: Retained, + mtm: MainThreadMarker, + ) -> VideoModeHandle { + let refresh_rate_millihertz = refresh_rate_millihertz(&uiscreen); + let size = screen_mode.size(); + VideoModeHandle { + size: (size.width as u32, size.height as u32), + bit_depth: 32, + refresh_rate_millihertz, + screen_mode: MainThreadBoundDelegateImpls(MainThreadBound::new(screen_mode, mtm)), + monitor: MonitorHandle::new(uiscreen), + } + } + + pub fn size(&self) -> PhysicalSize { + self.size.into() + } + + pub fn bit_depth(&self) -> u16 { + self.bit_depth + } + + pub fn refresh_rate_millihertz(&self) -> u32 { + self.refresh_rate_millihertz + } + + pub fn monitor(&self) -> MonitorHandle { + self.monitor.clone() + } + + pub(super) fn screen_mode(&self, mtm: MainThreadMarker) -> &Retained { + self.screen_mode.0.get(mtm) + } +} + +pub struct MonitorHandle { + ui_screen: MainThreadBound>, +} + +impl Clone for MonitorHandle { + fn clone(&self) -> Self { + run_on_main(|mtm| Self { + ui_screen: MainThreadBound::new(self.ui_screen.get(mtm).clone(), mtm), + }) + } +} + +impl hash::Hash for MonitorHandle { + fn hash(&self, state: &mut H) { + // SAFETY: Only getting the pointer. + let mtm = unsafe { MainThreadMarker::new_unchecked() }; + Retained::as_ptr(self.ui_screen.get(mtm)).hash(state); + } +} + +impl PartialEq for MonitorHandle { + fn eq(&self, other: &Self) -> bool { + // SAFETY: Only getting the pointer. + let mtm = unsafe { MainThreadMarker::new_unchecked() }; + ptr::eq( + Retained::as_ptr(self.ui_screen.get(mtm)), + Retained::as_ptr(other.ui_screen.get(mtm)), + ) + } +} + +impl Eq for MonitorHandle {} + +impl PartialOrd for MonitorHandle { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for MonitorHandle { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // SAFETY: Only getting the pointer. + // TODO: Make a better ordering + let mtm = unsafe { MainThreadMarker::new_unchecked() }; + Retained::as_ptr(self.ui_screen.get(mtm)).cmp(&Retained::as_ptr(other.ui_screen.get(mtm))) + } +} + +impl fmt::Debug for MonitorHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MonitorHandle") + .field("name", &self.name()) + .field("size", &self.size()) + .field("position", &self.position()) + .field("scale_factor", &self.scale_factor()) + .field("refresh_rate_millihertz", &self.refresh_rate_millihertz()) + .finish_non_exhaustive() + } +} + +impl MonitorHandle { + pub(crate) fn new(ui_screen: Retained) -> Self { + // Holding `Retained` implies we're on the main thread. + let mtm = MainThreadMarker::new().unwrap(); + Self { ui_screen: MainThreadBound::new(ui_screen, mtm) } + } + + pub fn name(&self) -> Option { + run_on_main(|mtm| { + #[allow(deprecated)] + let main = UIScreen::mainScreen(mtm); + if *self.ui_screen(mtm) == main { + Some("Primary".to_string()) + } else if Some(self.ui_screen(mtm)) == main.mirroredScreen().as_ref() { + Some("Mirrored".to_string()) + } else { + #[allow(deprecated)] + UIScreen::screens(mtm) + .iter() + .position(|rhs| rhs == &**self.ui_screen(mtm)) + .map(|idx| idx.to_string()) + } + }) + } + + pub fn size(&self) -> PhysicalSize { + let bounds = self.ui_screen.get_on_main(|ui_screen| ui_screen.nativeBounds()); + PhysicalSize::new(bounds.size.width as u32, bounds.size.height as u32) + } + + pub fn position(&self) -> PhysicalPosition { + let bounds = self.ui_screen.get_on_main(|ui_screen| ui_screen.nativeBounds()); + (bounds.origin.x as f64, bounds.origin.y as f64).into() + } + + pub fn scale_factor(&self) -> f64 { + self.ui_screen.get_on_main(|ui_screen| ui_screen.nativeScale()) as f64 + } + + pub fn refresh_rate_millihertz(&self) -> Option { + Some(self.ui_screen.get_on_main(|ui_screen| refresh_rate_millihertz(ui_screen))) + } + + pub fn video_modes(&self) -> impl Iterator { + run_on_main(|mtm| { + let ui_screen = self.ui_screen(mtm); + // Use Ord impl of RootVideoModeHandle + + let modes: BTreeSet<_> = ui_screen + .availableModes() + .into_iter() + .map(|mode| RootVideoModeHandle { + video_mode: VideoModeHandle::new(ui_screen.clone(), mode, mtm), + }) + .collect(); + + modes.into_iter().map(|mode| mode.video_mode) + }) + } + + pub(crate) fn ui_screen(&self, mtm: MainThreadMarker) -> &Retained { + self.ui_screen.get(mtm) + } + + pub fn preferred_video_mode(&self) -> VideoModeHandle { + run_on_main(|mtm| { + VideoModeHandle::new( + self.ui_screen(mtm).clone(), + self.ui_screen(mtm).preferredMode().unwrap(), + mtm, + ) + }) + } +} + +fn refresh_rate_millihertz(uiscreen: &UIScreen) -> u32 { + let refresh_rate_millihertz: NSInteger = { + let os_capabilities = app_state::os_capabilities(); + if os_capabilities.maximum_frames_per_second { + uiscreen.maximumFramesPerSecond() + } else { + // https://developer.apple.com/library/archive/technotes/tn2460/_index.html + // https://en.wikipedia.org/wiki/IPad_Pro#Model_comparison + // + // All iOS devices support 60 fps, and on devices where `maximumFramesPerSecond` is not + // supported, they are all guaranteed to have 60hz refresh rates. This does not + // correctly handle external displays. ProMotion displays support 120fps, but they were + // introduced at the same time as the `maximumFramesPerSecond` API. + // + // FIXME: earlier OSs could calculate the refresh rate using + // `-[CADisplayLink duration]`. + os_capabilities.maximum_frames_per_second_err_msg("defaulting to 60 fps"); + 60 + } + }; + + refresh_rate_millihertz as u32 * 1000 +} + +pub fn uiscreens(mtm: MainThreadMarker) -> VecDeque { + #[allow(deprecated)] + UIScreen::screens(mtm).into_iter().map(MonitorHandle::new).collect() +} + +#[cfg(test)] +mod tests { + use objc2_foundation::NSSet; + + use super::*; + + // Test that UIScreen pointer comparisons are correct. + #[test] + #[allow(deprecated)] + fn screen_comparisons() { + // Test code, doesn't matter that it's not thread safe + let mtm = unsafe { MainThreadMarker::new_unchecked() }; + + assert!(ptr::eq(&*UIScreen::mainScreen(mtm), &*UIScreen::mainScreen(mtm))); + + let main = UIScreen::mainScreen(mtm); + assert!(UIScreen::screens(mtm).iter().any(|screen| ptr::eq(screen, &*main))); + + assert!(unsafe { + NSSet::setWithArray(&UIScreen::screens(mtm)).containsObject(&UIScreen::mainScreen(mtm)) + }); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/notification_center.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/notification_center.rs new file mode 100644 index 00000000..652bf1d0 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/notification_center.rs @@ -0,0 +1,27 @@ +use std::ptr::NonNull; + +use block2::RcBlock; +use objc2::rc::Retained; +use objc2_foundation::{NSNotification, NSNotificationCenter, NSNotificationName, NSObject}; + +/// Observe the given notification. +/// +/// This is used in Winit as an alternative to declaring an application delegate, as we want to +/// give the user full control over those. +pub fn create_observer( + center: &NSNotificationCenter, + name: &NSNotificationName, + handler: impl Fn(&NSNotification) + 'static, +) -> Retained { + let block = RcBlock::new(move |notification: NonNull| { + handler(unsafe { notification.as_ref() }); + }); + unsafe { + center.addObserverForName_object_queue_usingBlock( + Some(name), + None, // No sender filter + None, // No queue, run on posting thread (i.e. main thread) + &block, + ) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/view.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/view.rs new file mode 100644 index 00000000..418968c7 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/view.rs @@ -0,0 +1,607 @@ +#![allow(clippy::unnecessary_cast)] +use std::cell::{Cell, RefCell}; + +use objc2::rc::Retained; +use objc2::runtime::{NSObjectProtocol, ProtocolObject}; +use objc2::{declare_class, msg_send, msg_send_id, mutability, sel, ClassType, DeclaredClass}; +use objc2_foundation::{CGFloat, CGPoint, CGRect, MainThreadMarker, NSObject, NSSet, NSString}; +use objc2_ui_kit::{ + UICoordinateSpace, UIEvent, UIForceTouchCapability, UIGestureRecognizer, + UIGestureRecognizerDelegate, UIGestureRecognizerState, UIKeyInput, UIPanGestureRecognizer, + UIPinchGestureRecognizer, UIResponder, UIRotationGestureRecognizer, UITapGestureRecognizer, + UITextInputTraits, UITouch, UITouchPhase, UITouchType, UITraitEnvironment, UIView, +}; + +use super::app_state::{self, EventWrapper}; +use super::window::WinitUIWindow; +use crate::dpi::PhysicalPosition; +use crate::event::{ElementState, Event, Force, KeyEvent, Touch, TouchPhase, WindowEvent}; +use crate::keyboard::{Key, KeyCode, KeyLocation, NamedKey, NativeKeyCode, PhysicalKey}; +use crate::platform_impl::platform::DEVICE_ID; +use crate::platform_impl::KeyEventExtra; +use crate::window::{WindowAttributes, WindowId as RootWindowId}; + +pub struct WinitViewState { + pinch_gesture_recognizer: RefCell>>, + doubletap_gesture_recognizer: RefCell>>, + rotation_gesture_recognizer: RefCell>>, + pan_gesture_recognizer: RefCell>>, + + // for iOS delta references the start of the Gesture + rotation_last_delta: Cell, + pinch_last_delta: Cell, + pan_last_delta: Cell, +} + +declare_class!( + pub(crate) struct WinitView; + + unsafe impl ClassType for WinitView { + #[inherits(UIResponder, NSObject)] + type Super = UIView; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "WinitUIView"; + } + + impl DeclaredClass for WinitView { + type Ivars = WinitViewState; + } + + unsafe impl WinitView { + #[method(drawRect:)] + fn draw_rect(&self, rect: CGRect) { + let mtm = MainThreadMarker::new().unwrap(); + let window = self.window().unwrap(); + app_state::handle_nonuser_event( + mtm, + EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::RedrawRequested, + }), + ); + let _: () = unsafe { msg_send![super(self), drawRect: rect] }; + } + + #[method(layoutSubviews)] + fn layout_subviews(&self) { + let mtm = MainThreadMarker::new().unwrap(); + let _: () = unsafe { msg_send![super(self), layoutSubviews] }; + + let window = self.window().unwrap(); + let window_bounds = window.bounds(); + let screen = window.screen(); + let screen_space = screen.coordinateSpace(); + let screen_frame = self.convertRect_toCoordinateSpace(window_bounds, &screen_space); + let scale_factor = screen.scale(); + let size = crate::dpi::LogicalSize { + width: screen_frame.size.width as f64, + height: screen_frame.size.height as f64, + } + .to_physical(scale_factor as f64); + + // If the app is started in landscape, the view frame and window bounds can be mismatched. + // The view frame will be in portrait and the window bounds in landscape. So apply the + // window bounds to the view frame to make it consistent. + let view_frame = self.frame(); + if view_frame != window_bounds { + self.setFrame(window_bounds); + } + + app_state::handle_nonuser_event( + mtm, + EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::Resized(size), + }), + ); + } + + #[method(setContentScaleFactor:)] + fn set_content_scale_factor(&self, untrusted_scale_factor: CGFloat) { + let mtm = MainThreadMarker::new().unwrap(); + let _: () = + unsafe { msg_send![super(self), setContentScaleFactor: untrusted_scale_factor] }; + + // `window` is null when `setContentScaleFactor` is invoked prior to `[UIWindow + // makeKeyAndVisible]` at window creation time (either manually or internally by + // UIKit when the `UIView` is first created), in which case we send no events here + let window = match self.window() { + Some(window) => window, + None => return, + }; + // `setContentScaleFactor` may be called with a value of 0, which means "reset the + // content scale factor to a device-specific default value", so we can't use the + // parameter here. We can query the actual factor using the getter + let scale_factor = self.contentScaleFactor(); + assert!( + !scale_factor.is_nan() + && scale_factor.is_finite() + && scale_factor.is_sign_positive() + && scale_factor > 0.0, + "invalid scale_factor set on UIView", + ); + let scale_factor = scale_factor as f64; + let bounds = self.bounds(); + let screen = window.screen(); + let screen_space = screen.coordinateSpace(); + let screen_frame = self.convertRect_toCoordinateSpace(bounds, &screen_space); + let size = crate::dpi::LogicalSize { + width: screen_frame.size.width as f64, + height: screen_frame.size.height as f64, + }; + let window_id = RootWindowId(window.id()); + app_state::handle_nonuser_events( + mtm, + std::iter::once(EventWrapper::ScaleFactorChanged( + app_state::ScaleFactorChanged { + window, + scale_factor, + suggested_size: size.to_physical(scale_factor), + }, + )) + .chain(std::iter::once(EventWrapper::StaticEvent( + Event::WindowEvent { + window_id, + event: WindowEvent::Resized(size.to_physical(scale_factor)), + }, + ))), + ); + } + + #[method(touchesBegan:withEvent:)] + fn touches_began(&self, touches: &NSSet, _event: Option<&UIEvent>) { + self.handle_touches(touches) + } + + #[method(touchesMoved:withEvent:)] + fn touches_moved(&self, touches: &NSSet, _event: Option<&UIEvent>) { + self.handle_touches(touches) + } + + #[method(touchesEnded:withEvent:)] + fn touches_ended(&self, touches: &NSSet, _event: Option<&UIEvent>) { + self.handle_touches(touches) + } + + #[method(touchesCancelled:withEvent:)] + fn touches_cancelled(&self, touches: &NSSet, _event: Option<&UIEvent>) { + self.handle_touches(touches) + } + + #[method(pinchGesture:)] + fn pinch_gesture(&self, recognizer: &UIPinchGestureRecognizer) { + let window = self.window().unwrap(); + + let (phase, delta) = match recognizer.state() { + UIGestureRecognizerState::Began => { + self.ivars().pinch_last_delta.set(recognizer.scale()); + (TouchPhase::Started, 0.0) + } + UIGestureRecognizerState::Changed => { + let last_scale: f64 = self.ivars().pinch_last_delta.replace(recognizer.scale()); + (TouchPhase::Moved, recognizer.scale() - last_scale) + } + UIGestureRecognizerState::Ended => { + let last_scale: f64 = self.ivars().pinch_last_delta.replace(0.0); + (TouchPhase::Moved, recognizer.scale() - last_scale) + } + UIGestureRecognizerState::Cancelled | UIGestureRecognizerState::Failed => { + self.ivars().rotation_last_delta.set(0.0); + // Pass -delta so that action is reversed + (TouchPhase::Cancelled, -recognizer.scale()) + } + state => panic!("unexpected recognizer state: {state:?}"), + }; + + let gesture_event = EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::PinchGesture { + device_id: DEVICE_ID, + delta: delta as f64, + phase, + }, + }); + + let mtm = MainThreadMarker::new().unwrap(); + app_state::handle_nonuser_event(mtm, gesture_event); + } + + #[method(doubleTapGesture:)] + fn double_tap_gesture(&self, recognizer: &UITapGestureRecognizer) { + let window = self.window().unwrap(); + + if recognizer.state() == UIGestureRecognizerState::Ended { + let gesture_event = EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::DoubleTapGesture { + device_id: DEVICE_ID, + }, + }); + + let mtm = MainThreadMarker::new().unwrap(); + app_state::handle_nonuser_event(mtm, gesture_event); + } + } + + #[method(rotationGesture:)] + fn rotation_gesture(&self, recognizer: &UIRotationGestureRecognizer) { + let window = self.window().unwrap(); + + let (phase, delta) = match recognizer.state() { + UIGestureRecognizerState::Began => { + self.ivars().rotation_last_delta.set(0.0); + + (TouchPhase::Started, 0.0) + } + UIGestureRecognizerState::Changed => { + let last_rotation = self.ivars().rotation_last_delta.replace(recognizer.rotation()); + + (TouchPhase::Moved, recognizer.rotation() - last_rotation) + } + UIGestureRecognizerState::Ended => { + let last_rotation = self.ivars().rotation_last_delta.replace(0.0); + + (TouchPhase::Ended, recognizer.rotation() - last_rotation) + } + UIGestureRecognizerState::Cancelled | UIGestureRecognizerState::Failed => { + self.ivars().rotation_last_delta.set(0.0); + + // Pass -delta so that action is reversed + (TouchPhase::Cancelled, -recognizer.rotation()) + } + state => panic!("unexpected recognizer state: {state:?}"), + }; + + // Make delta negative to match macos, convert to degrees + let gesture_event = EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::RotationGesture { + device_id: DEVICE_ID, + delta: -delta.to_degrees() as _, + phase, + }, + }); + + let mtm = MainThreadMarker::new().unwrap(); + app_state::handle_nonuser_event(mtm, gesture_event); + } + + #[method(panGesture:)] + fn pan_gesture(&self, recognizer: &UIPanGestureRecognizer) { + let window = self.window().unwrap(); + + let translation = recognizer.translationInView(Some(self)); + + let (phase, dx, dy) = match recognizer.state() { + UIGestureRecognizerState::Began => { + self.ivars().pan_last_delta.set(translation); + + (TouchPhase::Started, 0.0, 0.0) + } + UIGestureRecognizerState::Changed => { + let last_pan: CGPoint = self.ivars().pan_last_delta.replace(translation); + + let dx = translation.x - last_pan.x; + let dy = translation.y - last_pan.y; + + (TouchPhase::Moved, dx, dy) + } + UIGestureRecognizerState::Ended => { + let last_pan: CGPoint = self.ivars().pan_last_delta.replace(CGPoint{x:0.0, y:0.0}); + + let dx = translation.x - last_pan.x; + let dy = translation.y - last_pan.y; + + (TouchPhase::Ended, dx, dy) + } + UIGestureRecognizerState::Cancelled | UIGestureRecognizerState::Failed => { + let last_pan: CGPoint = self.ivars().pan_last_delta.replace(CGPoint{x:0.0, y:0.0}); + + // Pass -delta so that action is reversed + (TouchPhase::Cancelled, -last_pan.x, -last_pan.y) + } + state => panic!("unexpected recognizer state: {state:?}"), + }; + + + let gesture_event = EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::PanGesture { + device_id: DEVICE_ID, + delta: PhysicalPosition::new(dx as _, dy as _), + phase, + }, + }); + + let mtm = MainThreadMarker::new().unwrap(); + app_state::handle_nonuser_event(mtm, gesture_event); + } + + #[method(canBecomeFirstResponder)] + fn can_become_first_responder(&self) -> bool { + true + } + } + + unsafe impl NSObjectProtocol for WinitView {} + + unsafe impl UIGestureRecognizerDelegate for WinitView { + #[method(gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:)] + fn should_recognize_simultaneously(&self, _gesture_recognizer: &UIGestureRecognizer, _other_gesture_recognizer: &UIGestureRecognizer) -> bool { + true + } + } + + unsafe impl UITextInputTraits for WinitView { + } + + unsafe impl UIKeyInput for WinitView { + #[method(hasText)] + fn has_text(&self) -> bool { + true + } + + #[method(insertText:)] + fn insert_text(&self, text: &NSString) { + self.handle_insert_text(text) + } + + #[method(deleteBackward)] + fn delete_backward(&self) { + self.handle_delete_backward() + } + } +); + +impl WinitView { + pub(crate) fn new( + mtm: MainThreadMarker, + window_attributes: &WindowAttributes, + frame: CGRect, + ) -> Retained { + let this = mtm.alloc().set_ivars(WinitViewState { + pinch_gesture_recognizer: RefCell::new(None), + doubletap_gesture_recognizer: RefCell::new(None), + rotation_gesture_recognizer: RefCell::new(None), + pan_gesture_recognizer: RefCell::new(None), + + rotation_last_delta: Cell::new(0.0), + pinch_last_delta: Cell::new(0.0), + pan_last_delta: Cell::new(CGPoint { x: 0.0, y: 0.0 }), + }); + let this: Retained = unsafe { msg_send_id![super(this), initWithFrame: frame] }; + + this.setMultipleTouchEnabled(true); + + if let Some(scale_factor) = window_attributes.platform_specific.scale_factor { + this.setContentScaleFactor(scale_factor as _); + } + + this + } + + fn window(&self) -> Option> { + // SAFETY: `WinitView`s are always installed in a `WinitUIWindow` + (**self).window().map(|window| unsafe { Retained::cast(window) }) + } + + pub(crate) fn recognize_pinch_gesture(&self, should_recognize: bool) { + let mtm = MainThreadMarker::from(self); + if should_recognize { + if self.ivars().pinch_gesture_recognizer.borrow().is_none() { + let pinch = unsafe { + UIPinchGestureRecognizer::initWithTarget_action( + mtm.alloc(), + Some(self), + Some(sel!(pinchGesture:)), + ) + }; + pinch.setDelegate(Some(ProtocolObject::from_ref(self))); + self.addGestureRecognizer(&pinch); + self.ivars().pinch_gesture_recognizer.replace(Some(pinch)); + } + } else if let Some(recognizer) = self.ivars().pinch_gesture_recognizer.take() { + self.removeGestureRecognizer(&recognizer); + } + } + + pub(crate) fn recognize_pan_gesture( + &self, + should_recognize: bool, + minimum_number_of_touches: u8, + maximum_number_of_touches: u8, + ) { + let mtm = MainThreadMarker::from(self); + if should_recognize { + if self.ivars().pan_gesture_recognizer.borrow().is_none() { + let pan = unsafe { + UIPanGestureRecognizer::initWithTarget_action( + mtm.alloc(), + Some(self), + Some(sel!(panGesture:)), + ) + }; + pan.setDelegate(Some(ProtocolObject::from_ref(self))); + pan.setMinimumNumberOfTouches(minimum_number_of_touches as _); + pan.setMaximumNumberOfTouches(maximum_number_of_touches as _); + self.addGestureRecognizer(&pan); + self.ivars().pan_gesture_recognizer.replace(Some(pan)); + } + } else if let Some(recognizer) = self.ivars().pan_gesture_recognizer.take() { + self.removeGestureRecognizer(&recognizer); + } + } + + pub(crate) fn recognize_doubletap_gesture(&self, should_recognize: bool) { + let mtm = MainThreadMarker::from(self); + if should_recognize { + if self.ivars().doubletap_gesture_recognizer.borrow().is_none() { + let tap = unsafe { + UITapGestureRecognizer::initWithTarget_action( + mtm.alloc(), + Some(self), + Some(sel!(doubleTapGesture:)), + ) + }; + tap.setDelegate(Some(ProtocolObject::from_ref(self))); + tap.setNumberOfTapsRequired(2); + tap.setNumberOfTouchesRequired(1); + self.addGestureRecognizer(&tap); + self.ivars().doubletap_gesture_recognizer.replace(Some(tap)); + } + } else if let Some(recognizer) = self.ivars().doubletap_gesture_recognizer.take() { + self.removeGestureRecognizer(&recognizer); + } + } + + pub(crate) fn recognize_rotation_gesture(&self, should_recognize: bool) { + let mtm = MainThreadMarker::from(self); + if should_recognize { + if self.ivars().rotation_gesture_recognizer.borrow().is_none() { + let rotation = unsafe { + UIRotationGestureRecognizer::initWithTarget_action( + mtm.alloc(), + Some(self), + Some(sel!(rotationGesture:)), + ) + }; + rotation.setDelegate(Some(ProtocolObject::from_ref(self))); + self.addGestureRecognizer(&rotation); + self.ivars().rotation_gesture_recognizer.replace(Some(rotation)); + } + } else if let Some(recognizer) = self.ivars().rotation_gesture_recognizer.take() { + self.removeGestureRecognizer(&recognizer); + } + } + + fn handle_touches(&self, touches: &NSSet) { + let window = self.window().unwrap(); + let mut touch_events = Vec::new(); + let os_supports_force = app_state::os_capabilities().force_touch; + for touch in touches { + let logical_location = touch.locationInView(None); + let touch_type = touch.r#type(); + let force = if os_supports_force { + let trait_collection = self.traitCollection(); + let touch_capability = trait_collection.forceTouchCapability(); + // Both the OS _and_ the device need to be checked for force touch support. + if touch_capability == UIForceTouchCapability::Available + || touch_type == UITouchType::Pencil + { + let force = touch.force(); + let max_possible_force = touch.maximumPossibleForce(); + let altitude_angle: Option = if touch_type == UITouchType::Pencil { + let angle = touch.altitudeAngle(); + Some(angle as _) + } else { + None + }; + Some(Force::Calibrated { + force: force as _, + max_possible_force: max_possible_force as _, + altitude_angle, + }) + } else { + None + } + } else { + None + }; + let touch_id = touch as *const UITouch as u64; + let phase = touch.phase(); + let phase = match phase { + UITouchPhase::Began => TouchPhase::Started, + UITouchPhase::Moved => TouchPhase::Moved, + // 2 is UITouchPhase::Stationary and is not expected here + UITouchPhase::Ended => TouchPhase::Ended, + UITouchPhase::Cancelled => TouchPhase::Cancelled, + _ => panic!("unexpected touch phase: {phase:?}"), + }; + + let physical_location = { + let scale_factor = self.contentScaleFactor(); + PhysicalPosition::from_logical::<(f64, f64), f64>( + (logical_location.x as _, logical_location.y as _), + scale_factor as f64, + ) + }; + touch_events.push(EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(window.id()), + event: WindowEvent::Touch(Touch { + device_id: DEVICE_ID, + id: touch_id, + location: physical_location, + force, + phase, + }), + })); + } + let mtm = MainThreadMarker::new().unwrap(); + app_state::handle_nonuser_events(mtm, touch_events); + } + + fn handle_insert_text(&self, text: &NSString) { + let window = self.window().unwrap(); + let window_id = RootWindowId(window.id()); + let mtm = MainThreadMarker::new().unwrap(); + // send individual events for each character + app_state::handle_nonuser_events( + mtm, + text.to_string().chars().flat_map(|c| { + let text = smol_str::SmolStr::from_iter([c]); + // Emit both press and release events + [ElementState::Pressed, ElementState::Released].map(|state| { + EventWrapper::StaticEvent(Event::WindowEvent { + window_id, + event: WindowEvent::KeyboardInput { + event: KeyEvent { + text: if state == ElementState::Pressed { + Some(text.clone()) + } else { + None + }, + state, + location: KeyLocation::Standard, + repeat: false, + logical_key: Key::Character(text.clone()), + physical_key: PhysicalKey::Unidentified( + NativeKeyCode::Unidentified, + ), + platform_specific: KeyEventExtra {}, + }, + is_synthetic: false, + device_id: DEVICE_ID, + }, + }) + }) + }), + ); + } + + fn handle_delete_backward(&self) { + let window = self.window().unwrap(); + let window_id = RootWindowId(window.id()); + let mtm = MainThreadMarker::new().unwrap(); + app_state::handle_nonuser_events( + mtm, + [ElementState::Pressed, ElementState::Released].map(|state| { + EventWrapper::StaticEvent(Event::WindowEvent { + window_id, + event: WindowEvent::KeyboardInput { + device_id: DEVICE_ID, + event: KeyEvent { + state, + logical_key: Key::Named(NamedKey::Backspace), + physical_key: PhysicalKey::Code(KeyCode::Backspace), + platform_specific: KeyEventExtra {}, + repeat: false, + location: KeyLocation::Standard, + text: None, + }, + is_synthetic: false, + }, + }) + }), + ); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/view_controller.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/view_controller.rs new file mode 100644 index 00000000..dd658682 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/view_controller.rs @@ -0,0 +1,176 @@ +use std::cell::Cell; + +use objc2::rc::Retained; +use objc2::{declare_class, msg_send_id, mutability, ClassType, DeclaredClass}; +use objc2_foundation::{MainThreadMarker, NSObject}; +use objc2_ui_kit::{ + UIDevice, UIInterfaceOrientationMask, UIRectEdge, UIResponder, UIStatusBarStyle, + UIUserInterfaceIdiom, UIView, UIViewController, +}; + +use super::app_state::{self}; +use crate::platform::ios::{ScreenEdge, StatusBarStyle, ValidOrientations}; +use crate::window::WindowAttributes; + +pub struct ViewControllerState { + prefers_status_bar_hidden: Cell, + preferred_status_bar_style: Cell, + prefers_home_indicator_auto_hidden: Cell, + supported_orientations: Cell, + preferred_screen_edges_deferring_system_gestures: Cell, +} + +declare_class!( + pub(crate) struct WinitViewController; + + unsafe impl ClassType for WinitViewController { + #[inherits(UIResponder, NSObject)] + type Super = UIViewController; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "WinitUIViewController"; + } + + impl DeclaredClass for WinitViewController { + type Ivars = ViewControllerState; + } + + unsafe impl WinitViewController { + #[method(shouldAutorotate)] + fn should_autorotate(&self) -> bool { + true + } + + #[method(prefersStatusBarHidden)] + fn prefers_status_bar_hidden(&self) -> bool { + self.ivars().prefers_status_bar_hidden.get() + } + + #[method(preferredStatusBarStyle)] + fn preferred_status_bar_style(&self) -> UIStatusBarStyle { + self.ivars().preferred_status_bar_style.get() + } + + #[method(prefersHomeIndicatorAutoHidden)] + fn prefers_home_indicator_auto_hidden(&self) -> bool { + self.ivars().prefers_home_indicator_auto_hidden.get() + } + + #[method(supportedInterfaceOrientations)] + fn supported_orientations(&self) -> UIInterfaceOrientationMask { + self.ivars().supported_orientations.get() + } + + #[method(preferredScreenEdgesDeferringSystemGestures)] + fn preferred_screen_edges_deferring_system_gestures(&self) -> UIRectEdge { + self.ivars() + .preferred_screen_edges_deferring_system_gestures + .get() + } + } +); + +impl WinitViewController { + pub(crate) fn set_prefers_status_bar_hidden(&self, val: bool) { + self.ivars().prefers_status_bar_hidden.set(val); + self.setNeedsStatusBarAppearanceUpdate(); + } + + pub(crate) fn set_preferred_status_bar_style(&self, val: StatusBarStyle) { + let val = match val { + StatusBarStyle::Default => UIStatusBarStyle::Default, + StatusBarStyle::LightContent => UIStatusBarStyle::LightContent, + StatusBarStyle::DarkContent => UIStatusBarStyle::DarkContent, + }; + self.ivars().preferred_status_bar_style.set(val); + self.setNeedsStatusBarAppearanceUpdate(); + } + + pub(crate) fn set_prefers_home_indicator_auto_hidden(&self, val: bool) { + self.ivars().prefers_home_indicator_auto_hidden.set(val); + let os_capabilities = app_state::os_capabilities(); + if os_capabilities.home_indicator_hidden { + self.setNeedsUpdateOfHomeIndicatorAutoHidden(); + } else { + os_capabilities.home_indicator_hidden_err_msg("ignoring") + } + } + + pub(crate) fn set_preferred_screen_edges_deferring_system_gestures(&self, val: ScreenEdge) { + let val = { + assert_eq!(val.bits() & !ScreenEdge::ALL.bits(), 0, "invalid `ScreenEdge`"); + UIRectEdge(val.bits().into()) + }; + self.ivars().preferred_screen_edges_deferring_system_gestures.set(val); + let os_capabilities = app_state::os_capabilities(); + if os_capabilities.defer_system_gestures { + self.setNeedsUpdateOfScreenEdgesDeferringSystemGestures(); + } else { + os_capabilities.defer_system_gestures_err_msg("ignoring") + } + } + + pub(crate) fn set_supported_interface_orientations( + &self, + mtm: MainThreadMarker, + valid_orientations: ValidOrientations, + ) { + let mask = match (valid_orientations, UIDevice::currentDevice(mtm).userInterfaceIdiom()) { + (ValidOrientations::LandscapeAndPortrait, UIUserInterfaceIdiom::Phone) => { + UIInterfaceOrientationMask::AllButUpsideDown + }, + (ValidOrientations::LandscapeAndPortrait, _) => UIInterfaceOrientationMask::All, + (ValidOrientations::Landscape, _) => UIInterfaceOrientationMask::Landscape, + (ValidOrientations::Portrait, UIUserInterfaceIdiom::Phone) => { + UIInterfaceOrientationMask::Portrait + }, + (ValidOrientations::Portrait, _) => { + UIInterfaceOrientationMask::Portrait + | UIInterfaceOrientationMask::PortraitUpsideDown + }, + }; + self.ivars().supported_orientations.set(mask); + #[allow(deprecated)] + UIViewController::attemptRotationToDeviceOrientation(mtm); + } + + pub(crate) fn new( + mtm: MainThreadMarker, + window_attributes: &WindowAttributes, + view: &UIView, + ) -> Retained { + // These are set properly below, we just to set them to something in the meantime. + let this = mtm.alloc().set_ivars(ViewControllerState { + prefers_status_bar_hidden: Cell::new(false), + preferred_status_bar_style: Cell::new(UIStatusBarStyle::Default), + prefers_home_indicator_auto_hidden: Cell::new(false), + supported_orientations: Cell::new(UIInterfaceOrientationMask::All), + preferred_screen_edges_deferring_system_gestures: Cell::new(UIRectEdge::empty()), + }); + let this: Retained = unsafe { msg_send_id![super(this), init] }; + + this.set_prefers_status_bar_hidden( + window_attributes.platform_specific.prefers_status_bar_hidden, + ); + + this.set_preferred_status_bar_style( + window_attributes.platform_specific.preferred_status_bar_style, + ); + + this.set_supported_interface_orientations( + mtm, + window_attributes.platform_specific.valid_orientations, + ); + + this.set_prefers_home_indicator_auto_hidden( + window_attributes.platform_specific.prefers_home_indicator_hidden, + ); + + this.set_preferred_screen_edges_deferring_system_gestures( + window_attributes.platform_specific.preferred_screen_edges_deferring_system_gestures, + ); + + this.setView(Some(view)); + + this + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/window.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/window.rs new file mode 100644 index 00000000..be027595 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/ios/window.rs @@ -0,0 +1,750 @@ +#![allow(clippy::unnecessary_cast)] + +use std::collections::VecDeque; + +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, NSObject}; +use objc2::{class, declare_class, msg_send, msg_send_id, mutability, ClassType, DeclaredClass}; +use objc2_foundation::{ + CGFloat, CGPoint, CGRect, CGSize, MainThreadBound, MainThreadMarker, NSObjectProtocol, +}; +use objc2_ui_kit::{ + UIApplication, UICoordinateSpace, UIResponder, UIScreen, UIScreenOverscanCompensation, + UIViewController, UIWindow, +}; +use tracing::{debug, warn}; + +use super::app_state::EventWrapper; +use super::view::WinitView; +use super::view_controller::WinitViewController; +use crate::cursor::Cursor; +use crate::dpi::{LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize, Position, Size}; +use crate::error::{ExternalError, NotSupportedError, OsError as RootOsError}; +use crate::event::{Event, WindowEvent}; +use crate::icon::Icon; +use crate::platform::ios::{ScreenEdge, StatusBarStyle, ValidOrientations}; +use crate::platform_impl::platform::{ + app_state, monitor, ActiveEventLoop, Fullscreen, MonitorHandle, +}; +use crate::window::{ + CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, WindowAttributes, + WindowButtons, WindowId as RootWindowId, WindowLevel, +}; + +declare_class!( + #[derive(Debug, PartialEq, Eq, Hash)] + pub(crate) struct WinitUIWindow; + + unsafe impl ClassType for WinitUIWindow { + #[inherits(UIResponder, NSObject)] + type Super = UIWindow; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "WinitUIWindow"; + } + + impl DeclaredClass for WinitUIWindow {} + + unsafe impl WinitUIWindow { + #[method(becomeKeyWindow)] + fn become_key_window(&self) { + let mtm = MainThreadMarker::new().unwrap(); + app_state::handle_nonuser_event( + mtm, + EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(self.id()), + event: WindowEvent::Focused(true), + }), + ); + let _: () = unsafe { msg_send![super(self), becomeKeyWindow] }; + } + + #[method(resignKeyWindow)] + fn resign_key_window(&self) { + let mtm = MainThreadMarker::new().unwrap(); + app_state::handle_nonuser_event( + mtm, + EventWrapper::StaticEvent(Event::WindowEvent { + window_id: RootWindowId(self.id()), + event: WindowEvent::Focused(false), + }), + ); + let _: () = unsafe { msg_send![super(self), resignKeyWindow] }; + } + } +); + +impl WinitUIWindow { + pub(crate) fn new( + mtm: MainThreadMarker, + window_attributes: &WindowAttributes, + frame: CGRect, + view_controller: &UIViewController, + ) -> Retained { + let this: Retained = unsafe { msg_send_id![mtm.alloc(), initWithFrame: frame] }; + + this.setRootViewController(Some(view_controller)); + + match window_attributes.fullscreen.clone().map(Into::into) { + Some(Fullscreen::Exclusive(ref video_mode)) => { + let monitor = video_mode.monitor(); + let screen = monitor.ui_screen(mtm); + screen.setCurrentMode(Some(video_mode.screen_mode(mtm))); + this.setScreen(screen); + }, + Some(Fullscreen::Borderless(Some(ref monitor))) => { + let screen = monitor.ui_screen(mtm); + this.setScreen(screen); + }, + _ => (), + } + + this + } + + pub(crate) fn id(&self) -> WindowId { + (self as *const Self as usize as u64).into() + } +} + +pub struct Inner { + window: Retained, + view_controller: Retained, + view: Retained, + gl_or_metal_backed: bool, +} + +impl Inner { + pub fn set_title(&self, _title: &str) { + debug!("`Window::set_title` is ignored on iOS") + } + + pub fn set_transparent(&self, _transparent: bool) { + debug!("`Window::set_transparent` is ignored on iOS") + } + + pub fn set_blur(&self, _blur: bool) { + debug!("`Window::set_blur` is ignored on iOS") + } + + pub fn set_visible(&self, visible: bool) { + self.window.setHidden(!visible) + } + + pub fn is_visible(&self) -> Option { + warn!("`Window::is_visible` is ignored on iOS"); + None + } + + pub fn request_redraw(&self) { + if self.gl_or_metal_backed { + let mtm = MainThreadMarker::new().unwrap(); + // `setNeedsDisplay` does nothing on UIViews which are directly backed by CAEAGLLayer or + // CAMetalLayer. Ordinarily the OS sets up a bunch of UIKit state before + // calling drawRect: on a UIView, but when using raw or gl/metal for drawing + // this work is completely avoided. + // + // The docs for `setNeedsDisplay` don't mention `CAMetalLayer`; however, this has been + // confirmed via testing. + // + // https://developer.apple.com/documentation/uikit/uiview/1622437-setneedsdisplay?language=objc + app_state::queue_gl_or_metal_redraw(mtm, self.window.clone()); + } else { + self.view.setNeedsDisplay(); + } + } + + pub fn pre_present_notify(&self) {} + + pub fn inner_position(&self) -> Result, NotSupportedError> { + let safe_area = self.safe_area_screen_space(); + let position = + LogicalPosition { x: safe_area.origin.x as f64, y: safe_area.origin.y as f64 }; + let scale_factor = self.scale_factor(); + Ok(position.to_physical(scale_factor)) + } + + pub fn outer_position(&self) -> Result, NotSupportedError> { + let screen_frame = self.screen_frame(); + let position = + LogicalPosition { x: screen_frame.origin.x as f64, y: screen_frame.origin.y as f64 }; + let scale_factor = self.scale_factor(); + Ok(position.to_physical(scale_factor)) + } + + pub fn set_outer_position(&self, physical_position: Position) { + let scale_factor = self.scale_factor(); + let position = physical_position.to_logical::(scale_factor); + let screen_frame = self.screen_frame(); + let new_screen_frame = CGRect { + origin: CGPoint { x: position.x as _, y: position.y as _ }, + size: screen_frame.size, + }; + let bounds = self.rect_from_screen_space(new_screen_frame); + self.window.setBounds(bounds); + } + + pub fn inner_size(&self) -> PhysicalSize { + let scale_factor = self.scale_factor(); + let safe_area = self.safe_area_screen_space(); + let size = LogicalSize { + width: safe_area.size.width as f64, + height: safe_area.size.height as f64, + }; + size.to_physical(scale_factor) + } + + pub fn outer_size(&self) -> PhysicalSize { + let scale_factor = self.scale_factor(); + let screen_frame = self.screen_frame(); + let size = LogicalSize { + width: screen_frame.size.width as f64, + height: screen_frame.size.height as f64, + }; + size.to_physical(scale_factor) + } + + pub fn request_inner_size(&self, _size: Size) -> Option> { + Some(self.inner_size()) + } + + pub fn set_min_inner_size(&self, _dimensions: Option) { + warn!("`Window::set_min_inner_size` is ignored on iOS") + } + + pub fn set_max_inner_size(&self, _dimensions: Option) { + warn!("`Window::set_max_inner_size` is ignored on iOS") + } + + pub fn resize_increments(&self) -> Option> { + None + } + + #[inline] + pub fn set_resize_increments(&self, _increments: Option) { + warn!("`Window::set_resize_increments` is ignored on iOS") + } + + pub fn set_resizable(&self, _resizable: bool) { + warn!("`Window::set_resizable` is ignored on iOS") + } + + pub fn is_resizable(&self) -> bool { + warn!("`Window::is_resizable` is ignored on iOS"); + false + } + + #[inline] + pub fn set_enabled_buttons(&self, _buttons: WindowButtons) { + warn!("`Window::set_enabled_buttons` is ignored on iOS"); + } + + #[inline] + pub fn enabled_buttons(&self) -> WindowButtons { + warn!("`Window::enabled_buttons` is ignored on iOS"); + WindowButtons::all() + } + + pub fn scale_factor(&self) -> f64 { + self.view.contentScaleFactor() as _ + } + + pub fn set_cursor(&self, _cursor: Cursor) { + debug!("`Window::set_cursor` ignored on iOS") + } + + pub fn set_cursor_position(&self, _position: Position) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + pub fn set_cursor_grab(&self, _: CursorGrabMode) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + pub fn set_cursor_visible(&self, _visible: bool) { + debug!("`Window::set_cursor_visible` is ignored on iOS") + } + + pub fn drag_window(&self) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + pub fn drag_resize_window(&self, _direction: ResizeDirection) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + #[inline] + pub fn show_window_menu(&self, _position: Position) {} + + pub fn set_cursor_hittest(&self, _hittest: bool) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + pub fn set_minimized(&self, _minimized: bool) { + warn!("`Window::set_minimized` is ignored on iOS") + } + + pub fn is_minimized(&self) -> Option { + warn!("`Window::is_minimized` is ignored on iOS"); + None + } + + pub fn set_maximized(&self, _maximized: bool) { + warn!("`Window::set_maximized` is ignored on iOS") + } + + pub fn is_maximized(&self) -> bool { + warn!("`Window::is_maximized` is ignored on iOS"); + false + } + + pub(crate) fn set_fullscreen(&self, monitor: Option) { + let mtm = MainThreadMarker::new().unwrap(); + let uiscreen = match &monitor { + Some(Fullscreen::Exclusive(video_mode)) => { + let uiscreen = video_mode.monitor.ui_screen(mtm); + uiscreen.setCurrentMode(Some(video_mode.screen_mode(mtm))); + uiscreen.clone() + }, + Some(Fullscreen::Borderless(Some(monitor))) => monitor.ui_screen(mtm).clone(), + Some(Fullscreen::Borderless(None)) => { + self.current_monitor_inner().ui_screen(mtm).clone() + }, + None => { + warn!("`Window::set_fullscreen(None)` ignored on iOS"); + return; + }, + }; + + // this is pretty slow on iOS, so avoid doing it if we can + let current = self.window.screen(); + if uiscreen != current { + self.window.setScreen(&uiscreen); + } + + let bounds = uiscreen.bounds(); + self.window.setFrame(bounds); + + // For external displays, we must disable overscan compensation or + // the displayed image will have giant black bars surrounding it on + // each side + uiscreen.setOverscanCompensation(UIScreenOverscanCompensation::None); + } + + pub(crate) fn fullscreen(&self) -> Option { + let mtm = MainThreadMarker::new().unwrap(); + let monitor = self.current_monitor_inner(); + let uiscreen = monitor.ui_screen(mtm); + let screen_space_bounds = self.screen_frame(); + let screen_bounds = uiscreen.bounds(); + + // TODO: track fullscreen instead of relying on brittle float comparisons + if screen_space_bounds.origin.x == screen_bounds.origin.x + && screen_space_bounds.origin.y == screen_bounds.origin.y + && screen_space_bounds.size.width == screen_bounds.size.width + && screen_space_bounds.size.height == screen_bounds.size.height + { + Some(Fullscreen::Borderless(Some(monitor))) + } else { + None + } + } + + pub fn set_decorations(&self, _decorations: bool) {} + + pub fn is_decorated(&self) -> bool { + true + } + + pub fn set_window_level(&self, _level: WindowLevel) { + warn!("`Window::set_window_level` is ignored on iOS") + } + + pub fn set_window_icon(&self, _icon: Option) { + warn!("`Window::set_window_icon` is ignored on iOS") + } + + pub fn set_ime_cursor_area(&self, _position: Position, _size: Size) { + warn!("`Window::set_ime_cursor_area` is ignored on iOS") + } + + /// Show / hide the keyboard. To show the keyboard, we call `becomeFirstResponder`, + /// requesting focus for the [WinitView]. Since [WinitView] implements + /// [objc2_ui_kit::UIKeyInput], the keyboard will be shown. + /// + pub fn set_ime_allowed(&self, allowed: bool) { + if allowed { + unsafe { + self.view.becomeFirstResponder(); + } + } else { + unsafe { + self.view.resignFirstResponder(); + } + } + } + + pub fn set_ime_purpose(&self, _purpose: ImePurpose) { + warn!("`Window::set_ime_purpose` is ignored on iOS") + } + + pub fn focus_window(&self) { + warn!("`Window::set_focus` is ignored on iOS") + } + + pub fn request_user_attention(&self, _request_type: Option) { + warn!("`Window::request_user_attention` is ignored on iOS") + } + + // Allow directly accessing the current monitor internally without unwrapping. + fn current_monitor_inner(&self) -> MonitorHandle { + MonitorHandle::new(self.window.screen()) + } + + pub fn current_monitor(&self) -> Option { + Some(self.current_monitor_inner()) + } + + pub fn available_monitors(&self) -> VecDeque { + monitor::uiscreens(MainThreadMarker::new().unwrap()) + } + + pub fn primary_monitor(&self) -> Option { + #[allow(deprecated)] + Some(MonitorHandle::new(UIScreen::mainScreen(MainThreadMarker::new().unwrap()))) + } + + pub fn id(&self) -> WindowId { + self.window.id() + } + + #[cfg(feature = "rwh_04")] + pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle { + let mut window_handle = rwh_04::UiKitHandle::empty(); + window_handle.ui_window = Retained::as_ptr(&self.window) as _; + window_handle.ui_view = Retained::as_ptr(&self.view) as _; + window_handle.ui_view_controller = Retained::as_ptr(&self.view_controller) as _; + rwh_04::RawWindowHandle::UiKit(window_handle) + } + + #[cfg(feature = "rwh_05")] + pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle { + let mut window_handle = rwh_05::UiKitWindowHandle::empty(); + window_handle.ui_window = Retained::as_ptr(&self.window) as _; + window_handle.ui_view = Retained::as_ptr(&self.view) as _; + window_handle.ui_view_controller = Retained::as_ptr(&self.view_controller) as _; + rwh_05::RawWindowHandle::UiKit(window_handle) + } + + #[cfg(feature = "rwh_05")] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::UiKit(rwh_05::UiKitDisplayHandle::empty()) + } + + #[cfg(feature = "rwh_06")] + pub fn raw_window_handle_rwh_06(&self) -> rwh_06::RawWindowHandle { + let mut window_handle = rwh_06::UiKitWindowHandle::new({ + let ui_view = Retained::as_ptr(&self.view) as _; + std::ptr::NonNull::new(ui_view).expect("Retained should never be null") + }); + window_handle.ui_view_controller = + std::ptr::NonNull::new(Retained::as_ptr(&self.view_controller) as _); + rwh_06::RawWindowHandle::UiKit(window_handle) + } + + pub fn theme(&self) -> Option { + warn!("`Window::theme` is ignored on iOS"); + None + } + + pub fn set_content_protected(&self, _protected: bool) {} + + pub fn has_focus(&self) -> bool { + self.window.isKeyWindow() + } + + #[inline] + pub fn set_theme(&self, _theme: Option) { + warn!("`Window::set_theme` is ignored on iOS"); + } + + pub fn title(&self) -> String { + warn!("`Window::title` is ignored on iOS"); + String::new() + } + + pub fn reset_dead_keys(&self) { + // Noop + } +} + +pub struct Window { + inner: MainThreadBound, +} + +impl Window { + pub(crate) fn new( + event_loop: &ActiveEventLoop, + window_attributes: WindowAttributes, + ) -> Result { + let mtm = event_loop.mtm; + + if window_attributes.min_inner_size.is_some() { + warn!("`WindowAttributes::min_inner_size` is ignored on iOS"); + } + if window_attributes.max_inner_size.is_some() { + warn!("`WindowAttributes::max_inner_size` is ignored on iOS"); + } + + // TODO: transparency, visible + + #[allow(deprecated)] + let main_screen = UIScreen::mainScreen(mtm); + let fullscreen = window_attributes.fullscreen.clone().map(Into::into); + let screen = match fullscreen { + Some(Fullscreen::Exclusive(ref video_mode)) => video_mode.monitor.ui_screen(mtm), + Some(Fullscreen::Borderless(Some(ref monitor))) => monitor.ui_screen(mtm), + Some(Fullscreen::Borderless(None)) | None => &main_screen, + }; + + let screen_bounds = screen.bounds(); + + let frame = match window_attributes.inner_size { + Some(dim) => { + let scale_factor = screen.scale(); + let size = dim.to_logical::(scale_factor as f64); + CGRect { + origin: screen_bounds.origin, + size: CGSize { width: size.width as _, height: size.height as _ }, + } + }, + None => screen_bounds, + }; + + let view = WinitView::new(mtm, &window_attributes, frame); + + let gl_or_metal_backed = + view.isKindOfClass(class!(CAMetalLayer)) || view.isKindOfClass(class!(CAEAGLLayer)); + + let view_controller = WinitViewController::new(mtm, &window_attributes, &view); + let window = WinitUIWindow::new(mtm, &window_attributes, frame, &view_controller); + + app_state::set_key_window(mtm, &window); + + // Like the Windows and macOS backends, we send a `ScaleFactorChanged` and `Resized` + // event on window creation if the DPI factor != 1.0 + let scale_factor = view.contentScaleFactor(); + let scale_factor = scale_factor as f64; + if scale_factor != 1.0 { + let bounds = view.bounds(); + let screen = window.screen(); + let screen_space = screen.coordinateSpace(); + let screen_frame = view.convertRect_toCoordinateSpace(bounds, &screen_space); + let size = LogicalSize { + width: screen_frame.size.width as f64, + height: screen_frame.size.height as f64, + }; + let window_id = RootWindowId(window.id()); + app_state::handle_nonuser_events( + mtm, + std::iter::once(EventWrapper::ScaleFactorChanged(app_state::ScaleFactorChanged { + window: window.clone(), + scale_factor, + suggested_size: size.to_physical(scale_factor), + })) + .chain(std::iter::once(EventWrapper::StaticEvent( + Event::WindowEvent { + window_id, + event: WindowEvent::Resized(size.to_physical(scale_factor)), + }, + ))), + ); + } + + let inner = Inner { window, view_controller, view, gl_or_metal_backed }; + Ok(Window { inner: MainThreadBound::new(inner, mtm) }) + } + + pub(crate) fn maybe_queue_on_main(&self, f: impl FnOnce(&Inner) + Send + 'static) { + // For now, don't actually do queuing, since it may be less predictable + self.maybe_wait_on_main(f) + } + + pub(crate) fn maybe_wait_on_main(&self, f: impl FnOnce(&Inner) -> R + Send) -> R { + self.inner.get_on_main(|inner| f(inner)) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub(crate) fn raw_window_handle_rwh_06( + &self, + ) -> Result { + if let Some(mtm) = MainThreadMarker::new() { + Ok(self.inner.get(mtm).raw_window_handle_rwh_06()) + } else { + Err(rwh_06::HandleError::Unavailable) + } + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub(crate) fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::UiKit(rwh_06::UiKitDisplayHandle::new())) + } +} + +// WindowExtIOS +impl Inner { + pub fn set_scale_factor(&self, scale_factor: f64) { + assert!( + dpi::validate_scale_factor(scale_factor), + "`WindowExtIOS::set_scale_factor` received an invalid hidpi factor" + ); + let scale_factor = scale_factor as CGFloat; + self.view.setContentScaleFactor(scale_factor); + } + + pub fn set_valid_orientations(&self, valid_orientations: ValidOrientations) { + self.view_controller.set_supported_interface_orientations( + MainThreadMarker::new().unwrap(), + valid_orientations, + ); + } + + pub fn set_prefers_home_indicator_hidden(&self, hidden: bool) { + self.view_controller.set_prefers_home_indicator_auto_hidden(hidden); + } + + pub fn set_preferred_screen_edges_deferring_system_gestures(&self, edges: ScreenEdge) { + self.view_controller.set_preferred_screen_edges_deferring_system_gestures(edges); + } + + pub fn set_prefers_status_bar_hidden(&self, hidden: bool) { + self.view_controller.set_prefers_status_bar_hidden(hidden); + } + + pub fn set_preferred_status_bar_style(&self, status_bar_style: StatusBarStyle) { + self.view_controller.set_preferred_status_bar_style(status_bar_style); + } + + pub fn recognize_pinch_gesture(&self, should_recognize: bool) { + self.view.recognize_pinch_gesture(should_recognize); + } + + pub fn recognize_pan_gesture( + &self, + should_recognize: bool, + minimum_number_of_touches: u8, + maximum_number_of_touches: u8, + ) { + self.view.recognize_pan_gesture( + should_recognize, + minimum_number_of_touches, + maximum_number_of_touches, + ); + } + + pub fn recognize_doubletap_gesture(&self, should_recognize: bool) { + self.view.recognize_doubletap_gesture(should_recognize); + } + + pub fn recognize_rotation_gesture(&self, should_recognize: bool) { + self.view.recognize_rotation_gesture(should_recognize); + } +} + +impl Inner { + fn screen_frame(&self) -> CGRect { + self.rect_to_screen_space(self.window.bounds()) + } + + fn rect_to_screen_space(&self, rect: CGRect) -> CGRect { + let screen_space = self.window.screen().coordinateSpace(); + self.window.convertRect_toCoordinateSpace(rect, &screen_space) + } + + fn rect_from_screen_space(&self, rect: CGRect) -> CGRect { + let screen_space = self.window.screen().coordinateSpace(); + self.window.convertRect_fromCoordinateSpace(rect, &screen_space) + } + + fn safe_area_screen_space(&self) -> CGRect { + let bounds = self.window.bounds(); + if app_state::os_capabilities().safe_area { + let safe_area = self.window.safeAreaInsets(); + let safe_bounds = CGRect { + origin: CGPoint { + x: bounds.origin.x + safe_area.left, + y: bounds.origin.y + safe_area.top, + }, + size: CGSize { + width: bounds.size.width - safe_area.left - safe_area.right, + height: bounds.size.height - safe_area.top - safe_area.bottom, + }, + }; + self.rect_to_screen_space(safe_bounds) + } else { + let screen_frame = self.rect_to_screen_space(bounds); + let status_bar_frame = { + let app = UIApplication::sharedApplication(MainThreadMarker::new().unwrap()); + #[allow(deprecated)] + app.statusBarFrame() + }; + let (y, height) = if screen_frame.origin.y > status_bar_frame.size.height { + (screen_frame.origin.y, screen_frame.size.height) + } else { + let y = status_bar_frame.size.height; + let height = screen_frame.size.height + - (status_bar_frame.size.height - screen_frame.origin.y); + (y, height) + }; + CGRect { + origin: CGPoint { x: screen_frame.origin.x, y }, + size: CGSize { width: screen_frame.size.width, height }, + } + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WindowId { + window: *mut WinitUIWindow, +} + +impl WindowId { + pub const fn dummy() -> Self { + WindowId { window: std::ptr::null_mut() } + } +} + +impl From for u64 { + fn from(window_id: WindowId) -> Self { + window_id.window as u64 + } +} + +impl From for WindowId { + fn from(raw_id: u64) -> Self { + Self { window: raw_id as _ } + } +} + +unsafe impl Send for WindowId {} +unsafe impl Sync for WindowId {} + +impl From<&AnyObject> for WindowId { + fn from(window: &AnyObject) -> WindowId { + WindowId { window: window as *const _ as _ } + } +} + +#[derive(Clone, Debug, Default)] +pub struct PlatformSpecificWindowAttributes { + pub scale_factor: Option, + pub valid_orientations: ValidOrientations, + pub prefers_home_indicator_hidden: bool, + pub prefers_status_bar_hidden: bool, + pub preferred_status_bar_style: StatusBarStyle, + pub preferred_screen_edges_deferring_system_gestures: ScreenEdge, +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/mod.rs new file mode 100644 index 00000000..ed7f82cd --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/mod.rs @@ -0,0 +1 @@ +pub mod xkb; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/compose.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/compose.rs new file mode 100644 index 00000000..9d4f590a --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/compose.rs @@ -0,0 +1,123 @@ +//! XKB compose handling. + +use std::env; +use std::ffi::CString; +use std::ops::Deref; +use std::os::unix::ffi::OsStringExt; +use std::ptr::NonNull; + +use super::{XkbContext, XKBCH}; +use smol_str::SmolStr; +use xkbcommon_dl::{ + xkb_compose_compile_flags, xkb_compose_feed_result, xkb_compose_state, xkb_compose_state_flags, + xkb_compose_status, xkb_compose_table, xkb_keysym_t, +}; + +#[derive(Debug)] +pub struct XkbComposeTable { + table: NonNull, +} + +impl XkbComposeTable { + pub fn new(context: &XkbContext) -> Option { + let locale = env::var_os("LC_ALL") + .and_then(|v| if v.is_empty() { None } else { Some(v) }) + .or_else(|| env::var_os("LC_CTYPE")) + .and_then(|v| if v.is_empty() { None } else { Some(v) }) + .or_else(|| env::var_os("LANG")) + .and_then(|v| if v.is_empty() { None } else { Some(v) }) + .unwrap_or_else(|| "C".into()); + let locale = CString::new(locale.into_vec()).unwrap(); + + let table = unsafe { + (XKBCH.xkb_compose_table_new_from_locale)( + context.as_ptr(), + locale.as_ptr(), + xkb_compose_compile_flags::XKB_COMPOSE_COMPILE_NO_FLAGS, + ) + }; + + let table = NonNull::new(table)?; + Some(Self { table }) + } + + /// Create new state with the given compose table. + pub fn new_state(&self) -> Option { + let state = unsafe { + (XKBCH.xkb_compose_state_new)( + self.table.as_ptr(), + xkb_compose_state_flags::XKB_COMPOSE_STATE_NO_FLAGS, + ) + }; + + let state = NonNull::new(state)?; + Some(XkbComposeState { state }) + } +} + +impl Deref for XkbComposeTable { + type Target = NonNull; + + fn deref(&self) -> &Self::Target { + &self.table + } +} + +impl Drop for XkbComposeTable { + fn drop(&mut self) { + unsafe { + (XKBCH.xkb_compose_table_unref)(self.table.as_ptr()); + } + } +} + +#[derive(Debug)] +pub struct XkbComposeState { + state: NonNull, +} + +impl XkbComposeState { + pub fn get_string(&mut self, scratch_buffer: &mut Vec) -> Option { + super::make_string_with(scratch_buffer, |ptr, len| unsafe { + (XKBCH.xkb_compose_state_get_utf8)(self.state.as_ptr(), ptr, len) + }) + } + + #[inline] + pub fn feed(&mut self, keysym: xkb_keysym_t) -> ComposeStatus { + let feed_result = unsafe { (XKBCH.xkb_compose_state_feed)(self.state.as_ptr(), keysym) }; + match feed_result { + xkb_compose_feed_result::XKB_COMPOSE_FEED_IGNORED => ComposeStatus::Ignored, + xkb_compose_feed_result::XKB_COMPOSE_FEED_ACCEPTED => { + ComposeStatus::Accepted(self.status()) + }, + } + } + + #[inline] + pub fn reset(&mut self) { + unsafe { + (XKBCH.xkb_compose_state_reset)(self.state.as_ptr()); + } + } + + #[inline] + pub fn status(&mut self) -> xkb_compose_status { + unsafe { (XKBCH.xkb_compose_state_get_status)(self.state.as_ptr()) } + } +} + +impl Drop for XkbComposeState { + fn drop(&mut self) { + unsafe { + (XKBCH.xkb_compose_state_unref)(self.state.as_ptr()); + }; + } +} + +#[derive(Copy, Clone, Debug)] +pub enum ComposeStatus { + Accepted(xkb_compose_status), + Ignored, + None, +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/keymap.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/keymap.rs new file mode 100644 index 00000000..67aac5c1 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/keymap.rs @@ -0,0 +1,1044 @@ +//! XKB keymap. + +use std::ffi::c_char; +use std::ops::Deref; +use std::ptr::{self, NonNull}; + +#[cfg(x11_platform)] +use x11_dl::xlib_xcb::xcb_connection_t; +#[cfg(wayland_platform)] +use {memmap2::MmapOptions, std::os::unix::io::OwnedFd}; + +use xkb::XKB_MOD_INVALID; +use xkbcommon_dl::{ + self as xkb, xkb_keycode_t, xkb_keymap, xkb_keymap_compile_flags, xkb_keysym_t, + xkb_layout_index_t, xkb_mod_index_t, +}; + +use crate::keyboard::{Key, KeyCode, KeyLocation, NamedKey, NativeKey, NativeKeyCode, PhysicalKey}; +#[cfg(x11_platform)] +use crate::platform_impl::common::xkb::XKBXH; +use crate::platform_impl::common::xkb::{XkbContext, XKBH}; + +/// Map the raw X11-style keycode to the `KeyCode` enum. +/// +/// X11-style keycodes are offset by 8 from the keycodes the Linux kernel uses. +pub fn raw_keycode_to_physicalkey(keycode: u32) -> PhysicalKey { + scancode_to_physicalkey(keycode.saturating_sub(8)) +} + +/// Map the linux scancode to Keycode. +/// +/// Both X11 and Wayland use keys with `+ 8` offset to linux scancode. +pub fn scancode_to_physicalkey(scancode: u32) -> PhysicalKey { + // The keycode values are taken from linux/include/uapi/linux/input-event-codes.h, as + // libxkbcommon's documentation seems to suggest that the keycode values we're interested in + // are defined by the Linux kernel. If Winit programs end up being run on other Unix-likes, + // I can only hope they agree on what the keycodes mean. + // + // Some of the keycodes are likely superfluous for our purposes, and some are ones which are + // difficult to test the correctness of, or discover the purpose of. Because of this, they've + // either been commented out here, or not included at all. + PhysicalKey::Code(match scancode { + 0 => return PhysicalKey::Unidentified(NativeKeyCode::Xkb(0)), + 1 => KeyCode::Escape, + 2 => KeyCode::Digit1, + 3 => KeyCode::Digit2, + 4 => KeyCode::Digit3, + 5 => KeyCode::Digit4, + 6 => KeyCode::Digit5, + 7 => KeyCode::Digit6, + 8 => KeyCode::Digit7, + 9 => KeyCode::Digit8, + 10 => KeyCode::Digit9, + 11 => KeyCode::Digit0, + 12 => KeyCode::Minus, + 13 => KeyCode::Equal, + 14 => KeyCode::Backspace, + 15 => KeyCode::Tab, + 16 => KeyCode::KeyQ, + 17 => KeyCode::KeyW, + 18 => KeyCode::KeyE, + 19 => KeyCode::KeyR, + 20 => KeyCode::KeyT, + 21 => KeyCode::KeyY, + 22 => KeyCode::KeyU, + 23 => KeyCode::KeyI, + 24 => KeyCode::KeyO, + 25 => KeyCode::KeyP, + 26 => KeyCode::BracketLeft, + 27 => KeyCode::BracketRight, + 28 => KeyCode::Enter, + 29 => KeyCode::ControlLeft, + 30 => KeyCode::KeyA, + 31 => KeyCode::KeyS, + 32 => KeyCode::KeyD, + 33 => KeyCode::KeyF, + 34 => KeyCode::KeyG, + 35 => KeyCode::KeyH, + 36 => KeyCode::KeyJ, + 37 => KeyCode::KeyK, + 38 => KeyCode::KeyL, + 39 => KeyCode::Semicolon, + 40 => KeyCode::Quote, + 41 => KeyCode::Backquote, + 42 => KeyCode::ShiftLeft, + 43 => KeyCode::Backslash, + 44 => KeyCode::KeyZ, + 45 => KeyCode::KeyX, + 46 => KeyCode::KeyC, + 47 => KeyCode::KeyV, + 48 => KeyCode::KeyB, + 49 => KeyCode::KeyN, + 50 => KeyCode::KeyM, + 51 => KeyCode::Comma, + 52 => KeyCode::Period, + 53 => KeyCode::Slash, + 54 => KeyCode::ShiftRight, + 55 => KeyCode::NumpadMultiply, + 56 => KeyCode::AltLeft, + 57 => KeyCode::Space, + 58 => KeyCode::CapsLock, + 59 => KeyCode::F1, + 60 => KeyCode::F2, + 61 => KeyCode::F3, + 62 => KeyCode::F4, + 63 => KeyCode::F5, + 64 => KeyCode::F6, + 65 => KeyCode::F7, + 66 => KeyCode::F8, + 67 => KeyCode::F9, + 68 => KeyCode::F10, + 69 => KeyCode::NumLock, + 70 => KeyCode::ScrollLock, + 71 => KeyCode::Numpad7, + 72 => KeyCode::Numpad8, + 73 => KeyCode::Numpad9, + 74 => KeyCode::NumpadSubtract, + 75 => KeyCode::Numpad4, + 76 => KeyCode::Numpad5, + 77 => KeyCode::Numpad6, + 78 => KeyCode::NumpadAdd, + 79 => KeyCode::Numpad1, + 80 => KeyCode::Numpad2, + 81 => KeyCode::Numpad3, + 82 => KeyCode::Numpad0, + 83 => KeyCode::NumpadDecimal, + 85 => KeyCode::Lang5, + 86 => KeyCode::IntlBackslash, + 87 => KeyCode::F11, + 88 => KeyCode::F12, + 89 => KeyCode::IntlRo, + 90 => KeyCode::Lang3, + 91 => KeyCode::Lang4, + 92 => KeyCode::Convert, + 93 => KeyCode::KanaMode, + 94 => KeyCode::NonConvert, + // 95 => KeyCode::KPJPCOMMA, + 96 => KeyCode::NumpadEnter, + 97 => KeyCode::ControlRight, + 98 => KeyCode::NumpadDivide, + 99 => KeyCode::PrintScreen, + 100 => KeyCode::AltRight, + // 101 => KeyCode::LINEFEED, + 102 => KeyCode::Home, + 103 => KeyCode::ArrowUp, + 104 => KeyCode::PageUp, + 105 => KeyCode::ArrowLeft, + 106 => KeyCode::ArrowRight, + 107 => KeyCode::End, + 108 => KeyCode::ArrowDown, + 109 => KeyCode::PageDown, + 110 => KeyCode::Insert, + 111 => KeyCode::Delete, + // 112 => KeyCode::MACRO, + 113 => KeyCode::AudioVolumeMute, + 114 => KeyCode::AudioVolumeDown, + 115 => KeyCode::AudioVolumeUp, + // 116 => KeyCode::POWER, + 117 => KeyCode::NumpadEqual, + // 118 => KeyCode::KPPLUSMINUS, + 119 => KeyCode::Pause, + // 120 => KeyCode::SCALE, + 121 => KeyCode::NumpadComma, + 122 => KeyCode::Lang1, + 123 => KeyCode::Lang2, + 124 => KeyCode::IntlYen, + 125 => KeyCode::SuperLeft, + 126 => KeyCode::SuperRight, + 127 => KeyCode::ContextMenu, + // 128 => KeyCode::STOP, + // 129 => KeyCode::AGAIN, + // 130 => KeyCode::PROPS, + // 131 => KeyCode::UNDO, + // 132 => KeyCode::FRONT, + // 133 => KeyCode::COPY, + // 134 => KeyCode::OPEN, + // 135 => KeyCode::PASTE, + // 136 => KeyCode::FIND, + // 137 => KeyCode::CUT, + // 138 => KeyCode::HELP, + // 139 => KeyCode::MENU, + // 140 => KeyCode::CALC, + // 141 => KeyCode::SETUP, + // 142 => KeyCode::SLEEP, + // 143 => KeyCode::WAKEUP, + // 144 => KeyCode::FILE, + // 145 => KeyCode::SENDFILE, + // 146 => KeyCode::DELETEFILE, + // 147 => KeyCode::XFER, + // 148 => KeyCode::PROG1, + // 149 => KeyCode::PROG2, + // 150 => KeyCode::WWW, + // 151 => KeyCode::MSDOS, + // 152 => KeyCode::COFFEE, + // 153 => KeyCode::ROTATE_DISPLAY, + // 154 => KeyCode::CYCLEWINDOWS, + // 155 => KeyCode::MAIL, + // 156 => KeyCode::BOOKMARKS, + // 157 => KeyCode::COMPUTER, + // 158 => KeyCode::BACK, + // 159 => KeyCode::FORWARD, + // 160 => KeyCode::CLOSECD, + // 161 => KeyCode::EJECTCD, + // 162 => KeyCode::EJECTCLOSECD, + 163 => KeyCode::MediaTrackNext, + 164 => KeyCode::MediaPlayPause, + 165 => KeyCode::MediaTrackPrevious, + 166 => KeyCode::MediaStop, + // 167 => KeyCode::RECORD, + // 168 => KeyCode::REWIND, + // 169 => KeyCode::PHONE, + // 170 => KeyCode::ISO, + // 171 => KeyCode::CONFIG, + // 172 => KeyCode::HOMEPAGE, + // 173 => KeyCode::REFRESH, + // 174 => KeyCode::EXIT, + // 175 => KeyCode::MOVE, + // 176 => KeyCode::EDIT, + // 177 => KeyCode::SCROLLUP, + // 178 => KeyCode::SCROLLDOWN, + // 179 => KeyCode::KPLEFTPAREN, + // 180 => KeyCode::KPRIGHTPAREN, + // 181 => KeyCode::NEW, + // 182 => KeyCode::REDO, + 183 => KeyCode::F13, + 184 => KeyCode::F14, + 185 => KeyCode::F15, + 186 => KeyCode::F16, + 187 => KeyCode::F17, + 188 => KeyCode::F18, + 189 => KeyCode::F19, + 190 => KeyCode::F20, + 191 => KeyCode::F21, + 192 => KeyCode::F22, + 193 => KeyCode::F23, + 194 => KeyCode::F24, + // 200 => KeyCode::PLAYCD, + // 201 => KeyCode::PAUSECD, + // 202 => KeyCode::PROG3, + // 203 => KeyCode::PROG4, + // 204 => KeyCode::DASHBOARD, + // 205 => KeyCode::SUSPEND, + // 206 => KeyCode::CLOSE, + // 207 => KeyCode::PLAY, + // 208 => KeyCode::FASTFORWARD, + // 209 => KeyCode::BASSBOOST, + // 210 => KeyCode::PRINT, + // 211 => KeyCode::HP, + // 212 => KeyCode::CAMERA, + // 213 => KeyCode::SOUND, + // 214 => KeyCode::QUESTION, + // 215 => KeyCode::EMAIL, + // 216 => KeyCode::CHAT, + // 217 => KeyCode::SEARCH, + // 218 => KeyCode::CONNECT, + // 219 => KeyCode::FINANCE, + // 220 => KeyCode::SPORT, + // 221 => KeyCode::SHOP, + // 222 => KeyCode::ALTERASE, + // 223 => KeyCode::CANCEL, + // 224 => KeyCode::BRIGHTNESSDOW, + // 225 => KeyCode::BRIGHTNESSU, + // 226 => KeyCode::MEDIA, + // 227 => KeyCode::SWITCHVIDEOMODE, + // 228 => KeyCode::KBDILLUMTOGGLE, + // 229 => KeyCode::KBDILLUMDOWN, + // 230 => KeyCode::KBDILLUMUP, + // 231 => KeyCode::SEND, + // 232 => KeyCode::REPLY, + // 233 => KeyCode::FORWARDMAIL, + // 234 => KeyCode::SAVE, + // 235 => KeyCode::DOCUMENTS, + // 236 => KeyCode::BATTERY, + // 237 => KeyCode::BLUETOOTH, + // 238 => KeyCode::WLAN, + // 239 => KeyCode::UWB, + 240 => return PhysicalKey::Unidentified(NativeKeyCode::Unidentified), + // 241 => KeyCode::VIDEO_NEXT, + // 242 => KeyCode::VIDEO_PREV, + // 243 => KeyCode::BRIGHTNESS_CYCLE, + // 244 => KeyCode::BRIGHTNESS_AUTO, + // 245 => KeyCode::DISPLAY_OFF, + // 246 => KeyCode::WWAN, + // 247 => KeyCode::RFKILL, + // 248 => KeyCode::KEY_MICMUTE, + _ => return PhysicalKey::Unidentified(NativeKeyCode::Xkb(scancode)), + }) +} + +pub fn physicalkey_to_scancode(key: PhysicalKey) -> Option { + let code = match key { + PhysicalKey::Code(code) => code, + PhysicalKey::Unidentified(code) => { + return match code { + NativeKeyCode::Unidentified => Some(240), + NativeKeyCode::Xkb(raw) => Some(raw), + _ => None, + }; + }, + }; + + match code { + KeyCode::Escape => Some(1), + KeyCode::Digit1 => Some(2), + KeyCode::Digit2 => Some(3), + KeyCode::Digit3 => Some(4), + KeyCode::Digit4 => Some(5), + KeyCode::Digit5 => Some(6), + KeyCode::Digit6 => Some(7), + KeyCode::Digit7 => Some(8), + KeyCode::Digit8 => Some(9), + KeyCode::Digit9 => Some(10), + KeyCode::Digit0 => Some(11), + KeyCode::Minus => Some(12), + KeyCode::Equal => Some(13), + KeyCode::Backspace => Some(14), + KeyCode::Tab => Some(15), + KeyCode::KeyQ => Some(16), + KeyCode::KeyW => Some(17), + KeyCode::KeyE => Some(18), + KeyCode::KeyR => Some(19), + KeyCode::KeyT => Some(20), + KeyCode::KeyY => Some(21), + KeyCode::KeyU => Some(22), + KeyCode::KeyI => Some(23), + KeyCode::KeyO => Some(24), + KeyCode::KeyP => Some(25), + KeyCode::BracketLeft => Some(26), + KeyCode::BracketRight => Some(27), + KeyCode::Enter => Some(28), + KeyCode::ControlLeft => Some(29), + KeyCode::KeyA => Some(30), + KeyCode::KeyS => Some(31), + KeyCode::KeyD => Some(32), + KeyCode::KeyF => Some(33), + KeyCode::KeyG => Some(34), + KeyCode::KeyH => Some(35), + KeyCode::KeyJ => Some(36), + KeyCode::KeyK => Some(37), + KeyCode::KeyL => Some(38), + KeyCode::Semicolon => Some(39), + KeyCode::Quote => Some(40), + KeyCode::Backquote => Some(41), + KeyCode::ShiftLeft => Some(42), + KeyCode::Backslash => Some(43), + KeyCode::KeyZ => Some(44), + KeyCode::KeyX => Some(45), + KeyCode::KeyC => Some(46), + KeyCode::KeyV => Some(47), + KeyCode::KeyB => Some(48), + KeyCode::KeyN => Some(49), + KeyCode::KeyM => Some(50), + KeyCode::Comma => Some(51), + KeyCode::Period => Some(52), + KeyCode::Slash => Some(53), + KeyCode::ShiftRight => Some(54), + KeyCode::NumpadMultiply => Some(55), + KeyCode::AltLeft => Some(56), + KeyCode::Space => Some(57), + KeyCode::CapsLock => Some(58), + KeyCode::F1 => Some(59), + KeyCode::F2 => Some(60), + KeyCode::F3 => Some(61), + KeyCode::F4 => Some(62), + KeyCode::F5 => Some(63), + KeyCode::F6 => Some(64), + KeyCode::F7 => Some(65), + KeyCode::F8 => Some(66), + KeyCode::F9 => Some(67), + KeyCode::F10 => Some(68), + KeyCode::NumLock => Some(69), + KeyCode::ScrollLock => Some(70), + KeyCode::Numpad7 => Some(71), + KeyCode::Numpad8 => Some(72), + KeyCode::Numpad9 => Some(73), + KeyCode::NumpadSubtract => Some(74), + KeyCode::Numpad4 => Some(75), + KeyCode::Numpad5 => Some(76), + KeyCode::Numpad6 => Some(77), + KeyCode::NumpadAdd => Some(78), + KeyCode::Numpad1 => Some(79), + KeyCode::Numpad2 => Some(80), + KeyCode::Numpad3 => Some(81), + KeyCode::Numpad0 => Some(82), + KeyCode::NumpadDecimal => Some(83), + KeyCode::Lang5 => Some(85), + KeyCode::IntlBackslash => Some(86), + KeyCode::F11 => Some(87), + KeyCode::F12 => Some(88), + KeyCode::IntlRo => Some(89), + KeyCode::Lang3 => Some(90), + KeyCode::Lang4 => Some(91), + KeyCode::Convert => Some(92), + KeyCode::KanaMode => Some(93), + KeyCode::NonConvert => Some(94), + KeyCode::NumpadEnter => Some(96), + KeyCode::ControlRight => Some(97), + KeyCode::NumpadDivide => Some(98), + KeyCode::PrintScreen => Some(99), + KeyCode::AltRight => Some(100), + KeyCode::Home => Some(102), + KeyCode::ArrowUp => Some(103), + KeyCode::PageUp => Some(104), + KeyCode::ArrowLeft => Some(105), + KeyCode::ArrowRight => Some(106), + KeyCode::End => Some(107), + KeyCode::ArrowDown => Some(108), + KeyCode::PageDown => Some(109), + KeyCode::Insert => Some(110), + KeyCode::Delete => Some(111), + KeyCode::AudioVolumeMute => Some(113), + KeyCode::AudioVolumeDown => Some(114), + KeyCode::AudioVolumeUp => Some(115), + KeyCode::NumpadEqual => Some(117), + KeyCode::Pause => Some(119), + KeyCode::NumpadComma => Some(121), + KeyCode::Lang1 => Some(122), + KeyCode::Lang2 => Some(123), + KeyCode::IntlYen => Some(124), + KeyCode::SuperLeft => Some(125), + KeyCode::SuperRight => Some(126), + KeyCode::ContextMenu => Some(127), + KeyCode::MediaTrackNext => Some(163), + KeyCode::MediaPlayPause => Some(164), + KeyCode::MediaTrackPrevious => Some(165), + KeyCode::MediaStop => Some(166), + KeyCode::F13 => Some(183), + KeyCode::F14 => Some(184), + KeyCode::F15 => Some(185), + KeyCode::F16 => Some(186), + KeyCode::F17 => Some(187), + KeyCode::F18 => Some(188), + KeyCode::F19 => Some(189), + KeyCode::F20 => Some(190), + KeyCode::F21 => Some(191), + KeyCode::F22 => Some(192), + KeyCode::F23 => Some(193), + KeyCode::F24 => Some(194), + _ => None, + } +} + +pub fn keysym_to_key(keysym: u32) -> Key { + use xkbcommon_dl::keysyms; + Key::Named(match keysym { + // TTY function keys + keysyms::BackSpace => NamedKey::Backspace, + keysyms::Tab => NamedKey::Tab, + // keysyms::Linefeed => NamedKey::Linefeed, + keysyms::Clear => NamedKey::Clear, + keysyms::Return => NamedKey::Enter, + keysyms::Pause => NamedKey::Pause, + keysyms::Scroll_Lock => NamedKey::ScrollLock, + keysyms::Sys_Req => NamedKey::PrintScreen, + keysyms::Escape => NamedKey::Escape, + keysyms::Delete => NamedKey::Delete, + + // IME keys + keysyms::Multi_key => NamedKey::Compose, + keysyms::Codeinput => NamedKey::CodeInput, + keysyms::SingleCandidate => NamedKey::SingleCandidate, + keysyms::MultipleCandidate => NamedKey::AllCandidates, + keysyms::PreviousCandidate => NamedKey::PreviousCandidate, + + // Japanese keys + keysyms::Kanji => NamedKey::KanjiMode, + keysyms::Muhenkan => NamedKey::NonConvert, + keysyms::Henkan_Mode => NamedKey::Convert, + keysyms::Romaji => NamedKey::Romaji, + keysyms::Hiragana => NamedKey::Hiragana, + keysyms::Hiragana_Katakana => NamedKey::HiraganaKatakana, + keysyms::Zenkaku => NamedKey::Zenkaku, + keysyms::Hankaku => NamedKey::Hankaku, + keysyms::Zenkaku_Hankaku => NamedKey::ZenkakuHankaku, + // keysyms::Touroku => NamedKey::Touroku, + // keysyms::Massyo => NamedKey::Massyo, + keysyms::Kana_Lock => NamedKey::KanaMode, + keysyms::Kana_Shift => NamedKey::KanaMode, + keysyms::Eisu_Shift => NamedKey::Alphanumeric, + keysyms::Eisu_toggle => NamedKey::Alphanumeric, + // NOTE: The next three items are aliases for values we've already mapped. + // keysyms::Kanji_Bangou => NamedKey::CodeInput, + // keysyms::Zen_Koho => NamedKey::AllCandidates, + // keysyms::Mae_Koho => NamedKey::PreviousCandidate, + + // Cursor control & motion + keysyms::Home => NamedKey::Home, + keysyms::Left => NamedKey::ArrowLeft, + keysyms::Up => NamedKey::ArrowUp, + keysyms::Right => NamedKey::ArrowRight, + keysyms::Down => NamedKey::ArrowDown, + // keysyms::Prior => NamedKey::PageUp, + keysyms::Page_Up => NamedKey::PageUp, + // keysyms::Next => NamedKey::PageDown, + keysyms::Page_Down => NamedKey::PageDown, + keysyms::End => NamedKey::End, + // keysyms::Begin => NamedKey::Begin, + + // Misc. functions + keysyms::Select => NamedKey::Select, + keysyms::Print => NamedKey::PrintScreen, + keysyms::Execute => NamedKey::Execute, + keysyms::Insert => NamedKey::Insert, + keysyms::Undo => NamedKey::Undo, + keysyms::Redo => NamedKey::Redo, + keysyms::Menu => NamedKey::ContextMenu, + keysyms::Find => NamedKey::Find, + keysyms::Cancel => NamedKey::Cancel, + keysyms::Help => NamedKey::Help, + keysyms::Break => NamedKey::Pause, + keysyms::Mode_switch => NamedKey::ModeChange, + // keysyms::script_switch => NamedKey::ModeChange, + keysyms::Num_Lock => NamedKey::NumLock, + + // Keypad keys + // keysyms::KP_Space => return Key::Character(" "), + keysyms::KP_Tab => NamedKey::Tab, + keysyms::KP_Enter => NamedKey::Enter, + keysyms::KP_F1 => NamedKey::F1, + keysyms::KP_F2 => NamedKey::F2, + keysyms::KP_F3 => NamedKey::F3, + keysyms::KP_F4 => NamedKey::F4, + keysyms::KP_Home => NamedKey::Home, + keysyms::KP_Left => NamedKey::ArrowLeft, + keysyms::KP_Up => NamedKey::ArrowUp, + keysyms::KP_Right => NamedKey::ArrowRight, + keysyms::KP_Down => NamedKey::ArrowDown, + // keysyms::KP_Prior => NamedKey::PageUp, + keysyms::KP_Page_Up => NamedKey::PageUp, + // keysyms::KP_Next => NamedKey::PageDown, + keysyms::KP_Page_Down => NamedKey::PageDown, + keysyms::KP_End => NamedKey::End, + // This is the key labeled "5" on the numpad when NumLock is off. + // keysyms::KP_Begin => NamedKey::Begin, + keysyms::KP_Insert => NamedKey::Insert, + keysyms::KP_Delete => NamedKey::Delete, + // keysyms::KP_Equal => NamedKey::Equal, + // keysyms::KP_Multiply => NamedKey::Multiply, + // keysyms::KP_Add => NamedKey::Add, + // keysyms::KP_Separator => NamedKey::Separator, + // keysyms::KP_Subtract => NamedKey::Subtract, + // keysyms::KP_Decimal => NamedKey::Decimal, + // keysyms::KP_Divide => NamedKey::Divide, + + // keysyms::KP_0 => return Key::Character("0"), + // keysyms::KP_1 => return Key::Character("1"), + // keysyms::KP_2 => return Key::Character("2"), + // keysyms::KP_3 => return Key::Character("3"), + // keysyms::KP_4 => return Key::Character("4"), + // keysyms::KP_5 => return Key::Character("5"), + // keysyms::KP_6 => return Key::Character("6"), + // keysyms::KP_7 => return Key::Character("7"), + // keysyms::KP_8 => return Key::Character("8"), + // keysyms::KP_9 => return Key::Character("9"), + + // Function keys + keysyms::F1 => NamedKey::F1, + keysyms::F2 => NamedKey::F2, + keysyms::F3 => NamedKey::F3, + keysyms::F4 => NamedKey::F4, + keysyms::F5 => NamedKey::F5, + keysyms::F6 => NamedKey::F6, + keysyms::F7 => NamedKey::F7, + keysyms::F8 => NamedKey::F8, + keysyms::F9 => NamedKey::F9, + keysyms::F10 => NamedKey::F10, + keysyms::F11 => NamedKey::F11, + keysyms::F12 => NamedKey::F12, + keysyms::F13 => NamedKey::F13, + keysyms::F14 => NamedKey::F14, + keysyms::F15 => NamedKey::F15, + keysyms::F16 => NamedKey::F16, + keysyms::F17 => NamedKey::F17, + keysyms::F18 => NamedKey::F18, + keysyms::F19 => NamedKey::F19, + keysyms::F20 => NamedKey::F20, + keysyms::F21 => NamedKey::F21, + keysyms::F22 => NamedKey::F22, + keysyms::F23 => NamedKey::F23, + keysyms::F24 => NamedKey::F24, + keysyms::F25 => NamedKey::F25, + keysyms::F26 => NamedKey::F26, + keysyms::F27 => NamedKey::F27, + keysyms::F28 => NamedKey::F28, + keysyms::F29 => NamedKey::F29, + keysyms::F30 => NamedKey::F30, + keysyms::F31 => NamedKey::F31, + keysyms::F32 => NamedKey::F32, + keysyms::F33 => NamedKey::F33, + keysyms::F34 => NamedKey::F34, + keysyms::F35 => NamedKey::F35, + + // Modifiers + keysyms::Shift_L => NamedKey::Shift, + keysyms::Shift_R => NamedKey::Shift, + keysyms::Control_L => NamedKey::Control, + keysyms::Control_R => NamedKey::Control, + keysyms::Caps_Lock => NamedKey::CapsLock, + // keysyms::Shift_Lock => NamedKey::ShiftLock, + + // keysyms::Meta_L => NamedKey::Meta, + // keysyms::Meta_R => NamedKey::Meta, + keysyms::Alt_L => NamedKey::Alt, + keysyms::Alt_R => NamedKey::Alt, + keysyms::Super_L => NamedKey::Super, + keysyms::Super_R => NamedKey::Super, + keysyms::Hyper_L => NamedKey::Hyper, + keysyms::Hyper_R => NamedKey::Hyper, + + // XKB function and modifier keys + // keysyms::ISO_Lock => NamedKey::IsoLock, + // keysyms::ISO_Level2_Latch => NamedKey::IsoLevel2Latch, + keysyms::ISO_Level3_Shift => NamedKey::AltGraph, + keysyms::ISO_Level3_Latch => NamedKey::AltGraph, + keysyms::ISO_Level3_Lock => NamedKey::AltGraph, + // keysyms::ISO_Level5_Shift => NamedKey::IsoLevel5Shift, + // keysyms::ISO_Level5_Latch => NamedKey::IsoLevel5Latch, + // keysyms::ISO_Level5_Lock => NamedKey::IsoLevel5Lock, + // keysyms::ISO_Group_Shift => NamedKey::IsoGroupShift, + // keysyms::ISO_Group_Latch => NamedKey::IsoGroupLatch, + // keysyms::ISO_Group_Lock => NamedKey::IsoGroupLock, + keysyms::ISO_Next_Group => NamedKey::GroupNext, + // keysyms::ISO_Next_Group_Lock => NamedKey::GroupNextLock, + keysyms::ISO_Prev_Group => NamedKey::GroupPrevious, + // keysyms::ISO_Prev_Group_Lock => NamedKey::GroupPreviousLock, + keysyms::ISO_First_Group => NamedKey::GroupFirst, + // keysyms::ISO_First_Group_Lock => NamedKey::GroupFirstLock, + keysyms::ISO_Last_Group => NamedKey::GroupLast, + // keysyms::ISO_Last_Group_Lock => NamedKey::GroupLastLock, + keysyms::ISO_Left_Tab => NamedKey::Tab, + // keysyms::ISO_Move_Line_Up => NamedKey::IsoMoveLineUp, + // keysyms::ISO_Move_Line_Down => NamedKey::IsoMoveLineDown, + // keysyms::ISO_Partial_Line_Up => NamedKey::IsoPartialLineUp, + // keysyms::ISO_Partial_Line_Down => NamedKey::IsoPartialLineDown, + // keysyms::ISO_Partial_Space_Left => NamedKey::IsoPartialSpaceLeft, + // keysyms::ISO_Partial_Space_Right => NamedKey::IsoPartialSpaceRight, + // keysyms::ISO_Set_Margin_Left => NamedKey::IsoSetMarginLeft, + // keysyms::ISO_Set_Margin_Right => NamedKey::IsoSetMarginRight, + // keysyms::ISO_Release_Margin_Left => NamedKey::IsoReleaseMarginLeft, + // keysyms::ISO_Release_Margin_Right => NamedKey::IsoReleaseMarginRight, + // keysyms::ISO_Release_Both_Margins => NamedKey::IsoReleaseBothMargins, + // keysyms::ISO_Fast_Cursor_Left => NamedKey::IsoFastCursorLeft, + // keysyms::ISO_Fast_Cursor_Right => NamedKey::IsoFastCursorRight, + // keysyms::ISO_Fast_Cursor_Up => NamedKey::IsoFastCursorUp, + // keysyms::ISO_Fast_Cursor_Down => NamedKey::IsoFastCursorDown, + // keysyms::ISO_Continuous_Underline => NamedKey::IsoContinuousUnderline, + // keysyms::ISO_Discontinuous_Underline => NamedKey::IsoDiscontinuousUnderline, + // keysyms::ISO_Emphasize => NamedKey::IsoEmphasize, + // keysyms::ISO_Center_Object => NamedKey::IsoCenterObject, + keysyms::ISO_Enter => NamedKey::Enter, + + // dead_grave..dead_currency + + // dead_lowline..dead_longsolidusoverlay + + // dead_a..dead_capital_schwa + + // dead_greek + + // First_Virtual_Screen..Terminate_Server + + // AccessX_Enable..AudibleBell_Enable + + // Pointer_Left..Pointer_Drag5 + + // Pointer_EnableKeys..Pointer_DfltBtnPrev + + // ch..C_H + + // 3270 terminal keys + // keysyms::3270_Duplicate => NamedKey::Duplicate, + // keysyms::3270_FieldMark => NamedKey::FieldMark, + // keysyms::3270_Right2 => NamedKey::Right2, + // keysyms::3270_Left2 => NamedKey::Left2, + // keysyms::3270_BackTab => NamedKey::BackTab, + keysyms::_3270_EraseEOF => NamedKey::EraseEof, + // keysyms::3270_EraseInput => NamedKey::EraseInput, + // keysyms::3270_Reset => NamedKey::Reset, + // keysyms::3270_Quit => NamedKey::Quit, + // keysyms::3270_PA1 => NamedKey::Pa1, + // keysyms::3270_PA2 => NamedKey::Pa2, + // keysyms::3270_PA3 => NamedKey::Pa3, + // keysyms::3270_Test => NamedKey::Test, + keysyms::_3270_Attn => NamedKey::Attn, + // keysyms::3270_CursorBlink => NamedKey::CursorBlink, + // keysyms::3270_AltCursor => NamedKey::AltCursor, + // keysyms::3270_KeyClick => NamedKey::KeyClick, + // keysyms::3270_Jump => NamedKey::Jump, + // keysyms::3270_Ident => NamedKey::Ident, + // keysyms::3270_Rule => NamedKey::Rule, + // keysyms::3270_Copy => NamedKey::Copy, + keysyms::_3270_Play => NamedKey::Play, + // keysyms::3270_Setup => NamedKey::Setup, + // keysyms::3270_Record => NamedKey::Record, + // keysyms::3270_ChangeScreen => NamedKey::ChangeScreen, + // keysyms::3270_DeleteWord => NamedKey::DeleteWord, + keysyms::_3270_ExSelect => NamedKey::ExSel, + keysyms::_3270_CursorSelect => NamedKey::CrSel, + keysyms::_3270_PrintScreen => NamedKey::PrintScreen, + keysyms::_3270_Enter => NamedKey::Enter, + + keysyms::space => NamedKey::Space, + // exclam..Sinh_kunddaliya + + // XFree86 + // keysyms::XF86_ModeLock => NamedKey::ModeLock, + + // XFree86 - Backlight controls + keysyms::XF86_MonBrightnessUp => NamedKey::BrightnessUp, + keysyms::XF86_MonBrightnessDown => NamedKey::BrightnessDown, + // keysyms::XF86_KbdLightOnOff => NamedKey::LightOnOff, + // keysyms::XF86_KbdBrightnessUp => NamedKey::KeyboardBrightnessUp, + // keysyms::XF86_KbdBrightnessDown => NamedKey::KeyboardBrightnessDown, + + // XFree86 - "Internet" + keysyms::XF86_Standby => NamedKey::Standby, + keysyms::XF86_AudioLowerVolume => NamedKey::AudioVolumeDown, + keysyms::XF86_AudioRaiseVolume => NamedKey::AudioVolumeUp, + keysyms::XF86_AudioPlay => NamedKey::MediaPlay, + keysyms::XF86_AudioStop => NamedKey::MediaStop, + keysyms::XF86_AudioPrev => NamedKey::MediaTrackPrevious, + keysyms::XF86_AudioNext => NamedKey::MediaTrackNext, + keysyms::XF86_HomePage => NamedKey::BrowserHome, + keysyms::XF86_Mail => NamedKey::LaunchMail, + // keysyms::XF86_Start => NamedKey::Start, + keysyms::XF86_Search => NamedKey::BrowserSearch, + keysyms::XF86_AudioRecord => NamedKey::MediaRecord, + + // XFree86 - PDA + keysyms::XF86_Calculator => NamedKey::LaunchApplication2, + // keysyms::XF86_Memo => NamedKey::Memo, + // keysyms::XF86_ToDoList => NamedKey::ToDoList, + keysyms::XF86_Calendar => NamedKey::LaunchCalendar, + keysyms::XF86_PowerDown => NamedKey::Power, + // keysyms::XF86_ContrastAdjust => NamedKey::AdjustContrast, + // keysyms::XF86_RockerUp => NamedKey::RockerUp, + // keysyms::XF86_RockerDown => NamedKey::RockerDown, + // keysyms::XF86_RockerEnter => NamedKey::RockerEnter, + + // XFree86 - More "Internet" + keysyms::XF86_Back => NamedKey::BrowserBack, + keysyms::XF86_Forward => NamedKey::BrowserForward, + // keysyms::XF86_Stop => NamedKey::Stop, + keysyms::XF86_Refresh => NamedKey::BrowserRefresh, + keysyms::XF86_PowerOff => NamedKey::Power, + keysyms::XF86_WakeUp => NamedKey::WakeUp, + keysyms::XF86_Eject => NamedKey::Eject, + keysyms::XF86_ScreenSaver => NamedKey::LaunchScreenSaver, + keysyms::XF86_WWW => NamedKey::LaunchWebBrowser, + keysyms::XF86_Sleep => NamedKey::Standby, + keysyms::XF86_Favorites => NamedKey::BrowserFavorites, + keysyms::XF86_AudioPause => NamedKey::MediaPause, + // keysyms::XF86_AudioMedia => NamedKey::AudioMedia, + keysyms::XF86_MyComputer => NamedKey::LaunchApplication1, + // keysyms::XF86_VendorHome => NamedKey::VendorHome, + // keysyms::XF86_LightBulb => NamedKey::LightBulb, + // keysyms::XF86_Shop => NamedKey::BrowserShop, + // keysyms::XF86_History => NamedKey::BrowserHistory, + // keysyms::XF86_OpenURL => NamedKey::OpenUrl, + // keysyms::XF86_AddFavorite => NamedKey::AddFavorite, + // keysyms::XF86_HotLinks => NamedKey::HotLinks, + // keysyms::XF86_BrightnessAdjust => NamedKey::BrightnessAdjust, + // keysyms::XF86_Finance => NamedKey::BrowserFinance, + // keysyms::XF86_Community => NamedKey::BrowserCommunity, + keysyms::XF86_AudioRewind => NamedKey::MediaRewind, + // keysyms::XF86_BackForward => Key::???, + // XF86_Launch0..XF86_LaunchF + + // XF86_ApplicationLeft..XF86_CD + keysyms::XF86_Calculater => NamedKey::LaunchApplication2, // Nice typo, libxkbcommon :) + // XF86_Clear + keysyms::XF86_Close => NamedKey::Close, + keysyms::XF86_Copy => NamedKey::Copy, + keysyms::XF86_Cut => NamedKey::Cut, + // XF86_Display..XF86_Documents + keysyms::XF86_Excel => NamedKey::LaunchSpreadsheet, + // XF86_Explorer..XF86iTouch + keysyms::XF86_LogOff => NamedKey::LogOff, + // XF86_Market..XF86_MenuPB + keysyms::XF86_MySites => NamedKey::BrowserFavorites, + keysyms::XF86_New => NamedKey::New, + // XF86_News..XF86_OfficeHome + keysyms::XF86_Open => NamedKey::Open, + // XF86_Option + keysyms::XF86_Paste => NamedKey::Paste, + keysyms::XF86_Phone => NamedKey::LaunchPhone, + // XF86_Q + keysyms::XF86_Reply => NamedKey::MailReply, + keysyms::XF86_Reload => NamedKey::BrowserRefresh, + // XF86_RotateWindows..XF86_RotationKB + keysyms::XF86_Save => NamedKey::Save, + // XF86_ScrollUp..XF86_ScrollClick + keysyms::XF86_Send => NamedKey::MailSend, + keysyms::XF86_Spell => NamedKey::SpellCheck, + keysyms::XF86_SplitScreen => NamedKey::SplitScreenToggle, + // XF86_Support..XF86_User2KB + keysyms::XF86_Video => NamedKey::LaunchMediaPlayer, + // XF86_WheelButton + keysyms::XF86_Word => NamedKey::LaunchWordProcessor, + // XF86_Xfer + keysyms::XF86_ZoomIn => NamedKey::ZoomIn, + keysyms::XF86_ZoomOut => NamedKey::ZoomOut, + + // XF86_Away..XF86_Messenger + keysyms::XF86_WebCam => NamedKey::LaunchWebCam, + keysyms::XF86_MailForward => NamedKey::MailForward, + // XF86_Pictures + keysyms::XF86_Music => NamedKey::LaunchMusicPlayer, + + // XF86_Battery..XF86_UWB + keysyms::XF86_AudioForward => NamedKey::MediaFastForward, + // XF86_AudioRepeat + keysyms::XF86_AudioRandomPlay => NamedKey::RandomToggle, + keysyms::XF86_Subtitle => NamedKey::Subtitle, + keysyms::XF86_AudioCycleTrack => NamedKey::MediaAudioTrack, + // XF86_CycleAngle..XF86_Blue + keysyms::XF86_Suspend => NamedKey::Standby, + keysyms::XF86_Hibernate => NamedKey::Hibernate, + // XF86_TouchpadToggle..XF86_TouchpadOff + keysyms::XF86_AudioMute => NamedKey::AudioVolumeMute, + + // XF86_Switch_VT_1..XF86_Switch_VT_12 + + // XF86_Ungrab..XF86_ClearGrab + keysyms::XF86_Next_VMode => NamedKey::VideoModeNext, + // keysyms::XF86_Prev_VMode => NamedKey::VideoModePrevious, + // XF86_LogWindowTree..XF86_LogGrabInfo + + // SunFA_Grave..SunFA_Cedilla + + // keysyms::SunF36 => NamedKey::F36 | NamedKey::F11, + // keysyms::SunF37 => NamedKey::F37 | NamedKey::F12, + + // keysyms::SunSys_Req => NamedKey::PrintScreen, + // The next couple of xkb (until SunStop) are already handled. + // SunPrint_Screen..SunPageDown + + // SunUndo..SunFront + keysyms::SUN_Copy => NamedKey::Copy, + keysyms::SUN_Open => NamedKey::Open, + keysyms::SUN_Paste => NamedKey::Paste, + keysyms::SUN_Cut => NamedKey::Cut, + + // SunPowerSwitch + keysyms::SUN_AudioLowerVolume => NamedKey::AudioVolumeDown, + keysyms::SUN_AudioMute => NamedKey::AudioVolumeMute, + keysyms::SUN_AudioRaiseVolume => NamedKey::AudioVolumeUp, + // SUN_VideoDegauss + keysyms::SUN_VideoLowerBrightness => NamedKey::BrightnessDown, + keysyms::SUN_VideoRaiseBrightness => NamedKey::BrightnessUp, + // SunPowerSwitchShift + 0 => return Key::Unidentified(NativeKey::Unidentified), + _ => return Key::Unidentified(NativeKey::Xkb(keysym)), + }) +} + +pub fn keysym_location(keysym: u32) -> KeyLocation { + use xkbcommon_dl::keysyms; + match keysym { + keysyms::Shift_L + | keysyms::Control_L + | keysyms::Meta_L + | keysyms::Alt_L + | keysyms::Super_L + | keysyms::Hyper_L => KeyLocation::Left, + keysyms::Shift_R + | keysyms::Control_R + | keysyms::Meta_R + | keysyms::Alt_R + | keysyms::Super_R + | keysyms::Hyper_R => KeyLocation::Right, + keysyms::KP_0 + | keysyms::KP_1 + | keysyms::KP_2 + | keysyms::KP_3 + | keysyms::KP_4 + | keysyms::KP_5 + | keysyms::KP_6 + | keysyms::KP_7 + | keysyms::KP_8 + | keysyms::KP_9 + | keysyms::KP_Space + | keysyms::KP_Tab + | keysyms::KP_Enter + | keysyms::KP_F1 + | keysyms::KP_F2 + | keysyms::KP_F3 + | keysyms::KP_F4 + | keysyms::KP_Home + | keysyms::KP_Left + | keysyms::KP_Up + | keysyms::KP_Right + | keysyms::KP_Down + | keysyms::KP_Page_Up + | keysyms::KP_Page_Down + | keysyms::KP_End + | keysyms::KP_Begin + | keysyms::KP_Insert + | keysyms::KP_Delete + | keysyms::KP_Equal + | keysyms::KP_Multiply + | keysyms::KP_Add + | keysyms::KP_Separator + | keysyms::KP_Subtract + | keysyms::KP_Decimal + | keysyms::KP_Divide => KeyLocation::Numpad, + _ => KeyLocation::Standard, + } +} + +#[derive(Debug)] +pub struct XkbKeymap { + keymap: NonNull, + _mods_indices: ModsIndices, + pub _core_keyboard_id: i32, +} + +impl XkbKeymap { + #[cfg(wayland_platform)] + pub fn from_fd(context: &XkbContext, fd: OwnedFd, size: usize) -> Option { + let map = unsafe { MmapOptions::new().len(size).map_copy_read_only(&fd).ok()? }; + + let keymap = unsafe { + let keymap = (XKBH.xkb_keymap_new_from_string)( + (*context).as_ptr(), + map.as_ptr() as *const _, + xkb::xkb_keymap_format::XKB_KEYMAP_FORMAT_TEXT_V1, + xkb_keymap_compile_flags::XKB_KEYMAP_COMPILE_NO_FLAGS, + ); + NonNull::new(keymap)? + }; + + Some(Self::new_inner(keymap, 0)) + } + + #[cfg(x11_platform)] + pub fn from_x11_keymap( + context: &XkbContext, + xcb: *mut xcb_connection_t, + core_keyboard_id: i32, + ) -> Option { + let keymap = unsafe { + (XKBXH.xkb_x11_keymap_new_from_device)( + context.as_ptr(), + xcb, + core_keyboard_id, + xkb_keymap_compile_flags::XKB_KEYMAP_COMPILE_NO_FLAGS, + ) + }; + let keymap = NonNull::new(keymap)?; + Some(Self::new_inner(keymap, core_keyboard_id)) + } + + fn new_inner(keymap: NonNull, _core_keyboard_id: i32) -> Self { + let mods_indices = ModsIndices { + shift: mod_index_for_name(keymap, xkb::XKB_MOD_NAME_SHIFT), + caps: mod_index_for_name(keymap, xkb::XKB_MOD_NAME_CAPS), + ctrl: mod_index_for_name(keymap, xkb::XKB_MOD_NAME_CTRL), + alt: mod_index_for_name(keymap, xkb::XKB_MOD_NAME_ALT), + num: mod_index_for_name(keymap, xkb::XKB_MOD_NAME_NUM), + mod3: mod_index_for_name(keymap, b"Mod3\0"), + logo: mod_index_for_name(keymap, xkb::XKB_MOD_NAME_LOGO), + mod5: mod_index_for_name(keymap, b"Mod5\0"), + }; + + Self { keymap, _mods_indices: mods_indices, _core_keyboard_id } + } + + #[cfg(x11_platform)] + pub fn mods_indices(&self) -> ModsIndices { + self._mods_indices + } + + pub fn first_keysym_by_level( + &mut self, + layout: xkb_layout_index_t, + keycode: xkb_keycode_t, + ) -> xkb_keysym_t { + unsafe { + let mut keysyms = ptr::null(); + let count = (XKBH.xkb_keymap_key_get_syms_by_level)( + self.keymap.as_ptr(), + keycode, + layout, + // NOTE: The level should be zero to ignore modifiers. + 0, + &mut keysyms, + ); + + if count == 1 { + *keysyms + } else { + 0 + } + } + } + + /// Check whether the given key repeats. + pub fn key_repeats(&mut self, keycode: xkb_keycode_t) -> bool { + unsafe { (XKBH.xkb_keymap_key_repeats)(self.keymap.as_ptr(), keycode) == 1 } + } +} + +impl Drop for XkbKeymap { + fn drop(&mut self) { + unsafe { + (XKBH.xkb_keymap_unref)(self.keymap.as_ptr()); + }; + } +} + +impl Deref for XkbKeymap { + type Target = NonNull; + + fn deref(&self) -> &Self::Target { + &self.keymap + } +} + +/// Modifier index in the keymap. +#[cfg_attr(not(x11_platform), allow(dead_code))] +#[derive(Default, Debug, Clone, Copy)] +pub struct ModsIndices { + pub shift: Option, + pub caps: Option, + pub ctrl: Option, + pub alt: Option, + pub num: Option, + pub mod3: Option, + pub logo: Option, + pub mod5: Option, +} + +fn mod_index_for_name(keymap: NonNull, name: &[u8]) -> Option { + unsafe { + let mod_index = + (XKBH.xkb_keymap_mod_get_index)(keymap.as_ptr(), name.as_ptr() as *const c_char); + if mod_index == XKB_MOD_INVALID { + None + } else { + Some(mod_index) + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/mod.rs new file mode 100644 index 00000000..706397f6 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/mod.rs @@ -0,0 +1,416 @@ +use std::ops::Deref; +use std::os::raw::c_char; +use std::ptr::{self, NonNull}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::utils::Lazy; +use smol_str::SmolStr; +#[cfg(wayland_platform)] +use std::os::unix::io::OwnedFd; +use tracing::warn; +use xkbcommon_dl::{ + self as xkb, xkb_compose_status, xkb_context, xkb_context_flags, xkbcommon_compose_handle, + xkbcommon_handle, XkbCommon, XkbCommonCompose, +}; +#[cfg(x11_platform)] +use {x11_dl::xlib_xcb::xcb_connection_t, xkbcommon_dl::x11::xkbcommon_x11_handle}; + +use crate::event::{ElementState, KeyEvent}; +use crate::keyboard::{Key, KeyLocation}; +use crate::platform_impl::KeyEventExtra; + +mod compose; +mod keymap; +mod state; + +use compose::{ComposeStatus, XkbComposeState, XkbComposeTable}; +use keymap::XkbKeymap; + +#[cfg(x11_platform)] +pub use keymap::raw_keycode_to_physicalkey; +pub use keymap::{physicalkey_to_scancode, scancode_to_physicalkey}; +pub use state::XkbState; + +// TODO: Wire this up without using a static `AtomicBool`. +static RESET_DEAD_KEYS: AtomicBool = AtomicBool::new(false); + +static XKBH: Lazy<&'static XkbCommon> = Lazy::new(xkbcommon_handle); +static XKBCH: Lazy<&'static XkbCommonCompose> = Lazy::new(xkbcommon_compose_handle); +#[cfg(feature = "x11")] +static XKBXH: Lazy<&'static xkb::x11::XkbCommonX11> = Lazy::new(xkbcommon_x11_handle); + +#[inline(always)] +pub fn reset_dead_keys() { + RESET_DEAD_KEYS.store(true, Ordering::SeqCst); +} + +#[derive(Debug)] +pub enum Error { + /// libxkbcommon is not available + XKBNotFound, +} + +#[derive(Debug)] +pub struct Context { + // NOTE: field order matters. + #[cfg(x11_platform)] + pub core_keyboard_id: i32, + state: Option, + keymap: Option, + compose_state1: Option, + compose_state2: Option, + _compose_table: Option, + context: XkbContext, + scratch_buffer: Vec, +} + +impl Context { + pub fn new() -> Result { + if xkb::xkbcommon_option().is_none() { + return Err(Error::XKBNotFound); + } + + let context = XkbContext::new()?; + let mut compose_table = XkbComposeTable::new(&context); + let mut compose_state1 = compose_table.as_ref().and_then(|table| table.new_state()); + let mut compose_state2 = compose_table.as_ref().and_then(|table| table.new_state()); + + // Disable compose if anything compose related failed to initialize. + if compose_table.is_none() || compose_state1.is_none() || compose_state2.is_none() { + compose_state2 = None; + compose_state1 = None; + compose_table = None; + } + + Ok(Self { + state: None, + keymap: None, + compose_state1, + compose_state2, + #[cfg(x11_platform)] + core_keyboard_id: 0, + _compose_table: compose_table, + context, + scratch_buffer: Vec::with_capacity(8), + }) + } + + #[cfg(feature = "x11")] + pub fn from_x11_xkb(xcb: *mut xcb_connection_t) -> Result { + let result = unsafe { + (XKBXH.xkb_x11_setup_xkb_extension)( + xcb, + 1, + 2, + xkbcommon_dl::x11::xkb_x11_setup_xkb_extension_flags::XKB_X11_SETUP_XKB_EXTENSION_NO_FLAGS, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + + if result != 1 { + return Err(Error::XKBNotFound); + } + + let mut this = Self::new()?; + this.core_keyboard_id = unsafe { (XKBXH.xkb_x11_get_core_keyboard_device_id)(xcb) }; + this.set_keymap_from_x11(xcb); + Ok(this) + } + + pub fn state_mut(&mut self) -> Option<&mut XkbState> { + self.state.as_mut() + } + + pub fn keymap_mut(&mut self) -> Option<&mut XkbKeymap> { + self.keymap.as_mut() + } + + #[cfg(wayland_platform)] + pub fn set_keymap_from_fd(&mut self, fd: OwnedFd, size: usize) { + let keymap = XkbKeymap::from_fd(&self.context, fd, size); + let state = keymap.as_ref().and_then(XkbState::new_wayland); + if keymap.is_none() || state.is_none() { + warn!("failed to update xkb keymap"); + } + self.state = state; + self.keymap = keymap; + } + + #[cfg(x11_platform)] + pub fn set_keymap_from_x11(&mut self, xcb: *mut xcb_connection_t) { + let keymap = XkbKeymap::from_x11_keymap(&self.context, xcb, self.core_keyboard_id); + let state = keymap.as_ref().and_then(|keymap| XkbState::new_x11(xcb, keymap)); + if keymap.is_none() || state.is_none() { + warn!("failed to update xkb keymap"); + } + self.state = state; + self.keymap = keymap; + } + + /// Key builder context with the user provided xkb state. + pub fn key_context(&mut self) -> Option> { + let state = self.state.as_mut()?; + let keymap = self.keymap.as_mut()?; + let compose_state1 = self.compose_state1.as_mut(); + let compose_state2 = self.compose_state2.as_mut(); + let scratch_buffer = &mut self.scratch_buffer; + Some(KeyContext { state, keymap, compose_state1, compose_state2, scratch_buffer }) + } + + /// Key builder context with the user provided xkb state. + /// + /// Should be used when the original context must not be altered. + #[cfg(x11_platform)] + pub fn key_context_with_state<'a>( + &'a mut self, + state: &'a mut XkbState, + ) -> Option> { + let keymap = self.keymap.as_mut()?; + let compose_state1 = self.compose_state1.as_mut(); + let compose_state2 = self.compose_state2.as_mut(); + let scratch_buffer = &mut self.scratch_buffer; + Some(KeyContext { state, keymap, compose_state1, compose_state2, scratch_buffer }) + } +} + +pub struct KeyContext<'a> { + pub state: &'a mut XkbState, + pub keymap: &'a mut XkbKeymap, + compose_state1: Option<&'a mut XkbComposeState>, + compose_state2: Option<&'a mut XkbComposeState>, + scratch_buffer: &'a mut Vec, +} + +impl KeyContext<'_> { + pub fn process_key_event( + &mut self, + keycode: u32, + state: ElementState, + repeat: bool, + ) -> KeyEvent { + let mut event = + KeyEventResults::new(self, keycode, !repeat && state == ElementState::Pressed); + let physical_key = keymap::raw_keycode_to_physicalkey(keycode); + let (logical_key, location) = event.key(); + let text = event.text(); + let (key_without_modifiers, _) = event.key_without_modifiers(); + let text_with_all_modifiers = event.text_with_all_modifiers(); + + let platform_specific = KeyEventExtra { text_with_all_modifiers, key_without_modifiers }; + + KeyEvent { physical_key, logical_key, text, location, state, repeat, platform_specific } + } + + fn keysym_to_utf8_raw(&mut self, keysym: u32) -> Option { + self.scratch_buffer.clear(); + self.scratch_buffer.reserve(8); + loop { + let bytes_written = unsafe { + (XKBH.xkb_keysym_to_utf8)( + keysym, + self.scratch_buffer.as_mut_ptr().cast(), + self.scratch_buffer.capacity(), + ) + }; + if bytes_written == 0 { + return None; + } else if bytes_written == -1 { + self.scratch_buffer.reserve(8); + } else { + unsafe { self.scratch_buffer.set_len(bytes_written.try_into().unwrap()) }; + break; + } + } + + // Remove the null-terminator + self.scratch_buffer.pop(); + byte_slice_to_smol_str(self.scratch_buffer) + } +} + +struct KeyEventResults<'a, 'b> { + context: &'a mut KeyContext<'b>, + keycode: u32, + keysym: u32, + compose: ComposeStatus, +} + +impl<'a, 'b> KeyEventResults<'a, 'b> { + fn new(context: &'a mut KeyContext<'b>, keycode: u32, compose: bool) -> Self { + let keysym = context.state.get_one_sym_raw(keycode); + + let compose = if let Some(state) = context.compose_state1.as_mut().filter(|_| compose) { + if RESET_DEAD_KEYS.swap(false, Ordering::SeqCst) { + state.reset(); + context.compose_state2.as_mut().unwrap().reset(); + } + state.feed(keysym) + } else { + ComposeStatus::None + }; + + KeyEventResults { context, keycode, keysym, compose } + } + + pub fn key(&mut self) -> (Key, KeyLocation) { + let (key, location) = match self.keysym_to_key(self.keysym) { + Ok(known) => return known, + Err(undefined) => undefined, + }; + + if let ComposeStatus::Accepted(xkb_compose_status::XKB_COMPOSE_COMPOSING) = self.compose { + let compose_state = self.context.compose_state2.as_mut().unwrap(); + // When pressing a dead key twice, the non-combining variant of that character will + // be produced. Since this function only concerns itself with a single keypress, we + // simulate this double press here by feeding the keysym to the compose state + // twice. + + compose_state.feed(self.keysym); + if matches!(compose_state.feed(self.keysym), ComposeStatus::Accepted(_)) { + // Extracting only a single `char` here *should* be fine, assuming that no + // dead key's non-combining variant ever occupies more than one `char`. + let text = compose_state.get_string(self.context.scratch_buffer); + let key = Key::Dead(text.and_then(|s| s.chars().next())); + (key, location) + } else { + (key, location) + } + } else { + let key = self + .composed_text() + .unwrap_or_else(|_| self.context.keysym_to_utf8_raw(self.keysym)) + .map(Key::Character) + .unwrap_or(key); + (key, location) + } + } + + pub fn key_without_modifiers(&mut self) -> (Key, KeyLocation) { + // This will become a pointer to an array which libxkbcommon owns, so we don't need to + // deallocate it. + let layout = self.context.state.layout(self.keycode); + let keysym = self.context.keymap.first_keysym_by_level(layout, self.keycode); + + match self.keysym_to_key(keysym) { + Ok((key, location)) => (key, location), + Err((key, location)) => { + let key = + self.context.keysym_to_utf8_raw(keysym).map(Key::Character).unwrap_or(key); + (key, location) + }, + } + } + + fn keysym_to_key(&self, keysym: u32) -> Result<(Key, KeyLocation), (Key, KeyLocation)> { + let location = keymap::keysym_location(keysym); + let key = keymap::keysym_to_key(keysym); + if matches!(key, Key::Unidentified(_)) { + Err((key, location)) + } else { + Ok((key, location)) + } + } + + pub fn text(&mut self) -> Option { + self.composed_text().unwrap_or_else(|_| self.context.keysym_to_utf8_raw(self.keysym)) + } + + // The current behaviour makes it so composing a character overrides attempts to input a + // control character with the `Ctrl` key. We can potentially add a configuration option + // if someone specifically wants the opposite behaviour. + pub fn text_with_all_modifiers(&mut self) -> Option { + match self.composed_text() { + Ok(text) => text, + Err(_) => self.context.state.get_utf8_raw(self.keycode, self.context.scratch_buffer), + } + } + + fn composed_text(&mut self) -> Result, ()> { + match self.compose { + ComposeStatus::Accepted(status) => match status { + xkb_compose_status::XKB_COMPOSE_COMPOSED => { + let state = self.context.compose_state1.as_mut().unwrap(); + Ok(state.get_string(self.context.scratch_buffer)) + }, + xkb_compose_status::XKB_COMPOSE_COMPOSING + | xkb_compose_status::XKB_COMPOSE_CANCELLED => Ok(None), + xkb_compose_status::XKB_COMPOSE_NOTHING => Err(()), + }, + _ => Err(()), + } + } +} + +#[derive(Debug)] +pub struct XkbContext { + context: NonNull, +} + +impl XkbContext { + pub fn new() -> Result { + let context = unsafe { (XKBH.xkb_context_new)(xkb_context_flags::XKB_CONTEXT_NO_FLAGS) }; + + let context = match NonNull::new(context) { + Some(context) => context, + None => return Err(Error::XKBNotFound), + }; + + Ok(Self { context }) + } +} + +impl Drop for XkbContext { + fn drop(&mut self) { + unsafe { + (XKBH.xkb_context_unref)(self.context.as_ptr()); + } + } +} + +impl Deref for XkbContext { + type Target = NonNull; + + fn deref(&self) -> &Self::Target { + &self.context + } +} + +/// Shared logic for constructing a string with `xkb_compose_state_get_utf8` and +/// `xkb_state_key_get_utf8`. +fn make_string_with(scratch_buffer: &mut Vec, mut f: F) -> Option +where + F: FnMut(*mut c_char, usize) -> i32, +{ + let size = f(ptr::null_mut(), 0); + if size == 0 { + return None; + } + let size = usize::try_from(size).unwrap(); + scratch_buffer.clear(); + // The allocated buffer must include space for the null-terminator. + scratch_buffer.reserve(size + 1); + unsafe { + let written = f(scratch_buffer.as_mut_ptr().cast(), scratch_buffer.capacity()); + if usize::try_from(written).unwrap() != size { + // This will likely never happen. + return None; + } + scratch_buffer.set_len(size); + }; + + byte_slice_to_smol_str(scratch_buffer) +} + +// NOTE: This is track_caller so we can have more informative line numbers when logging +#[track_caller] +fn byte_slice_to_smol_str(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes) + .map(SmolStr::new) + .map_err(|e| { + tracing::warn!("UTF-8 received from libxkbcommon ({:?}) was invalid: {e}", bytes) + }) + .ok() +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/state.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/state.rs new file mode 100644 index 00000000..27c055aa --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/common/xkb/state.rs @@ -0,0 +1,189 @@ +//! XKB state. + +use std::os::raw::c_char; +use std::ptr::NonNull; + +use smol_str::SmolStr; +#[cfg(x11_platform)] +use x11_dl::xlib_xcb::xcb_connection_t; +use xkbcommon_dl::{ + self as xkb, xkb_keycode_t, xkb_keysym_t, xkb_layout_index_t, xkb_state, xkb_state_component, +}; + +use crate::platform_impl::common::xkb::keymap::XkbKeymap; +#[cfg(x11_platform)] +use crate::platform_impl::common::xkb::XKBXH; +use crate::platform_impl::common::xkb::{make_string_with, XKBH}; + +#[derive(Debug)] +pub struct XkbState { + state: NonNull, + modifiers: ModifiersState, +} + +impl XkbState { + #[cfg(wayland_platform)] + pub fn new_wayland(keymap: &XkbKeymap) -> Option { + let state = NonNull::new(unsafe { (XKBH.xkb_state_new)(keymap.as_ptr()) })?; + Some(Self::new_inner(state)) + } + + #[cfg(x11_platform)] + pub fn new_x11(xcb: *mut xcb_connection_t, keymap: &XkbKeymap) -> Option { + let state = unsafe { + (XKBXH.xkb_x11_state_new_from_device)(keymap.as_ptr(), xcb, keymap._core_keyboard_id) + }; + let state = NonNull::new(state)?; + Some(Self::new_inner(state)) + } + + fn new_inner(state: NonNull) -> Self { + let modifiers = ModifiersState::default(); + let mut this = Self { state, modifiers }; + this.reload_modifiers(); + this + } + + pub fn get_one_sym_raw(&mut self, keycode: xkb_keycode_t) -> xkb_keysym_t { + unsafe { (XKBH.xkb_state_key_get_one_sym)(self.state.as_ptr(), keycode) } + } + + pub fn layout(&mut self, key: xkb_keycode_t) -> xkb_layout_index_t { + unsafe { (XKBH.xkb_state_key_get_layout)(self.state.as_ptr(), key) } + } + + #[cfg(x11_platform)] + pub fn depressed_modifiers(&mut self) -> xkb::xkb_mod_mask_t { + unsafe { + (XKBH.xkb_state_serialize_mods)( + self.state.as_ptr(), + xkb_state_component::XKB_STATE_MODS_DEPRESSED, + ) + } + } + + #[cfg(x11_platform)] + pub fn latched_modifiers(&mut self) -> xkb::xkb_mod_mask_t { + unsafe { + (XKBH.xkb_state_serialize_mods)( + self.state.as_ptr(), + xkb_state_component::XKB_STATE_MODS_LATCHED, + ) + } + } + + #[cfg(x11_platform)] + pub fn locked_modifiers(&mut self) -> xkb::xkb_mod_mask_t { + unsafe { + (XKBH.xkb_state_serialize_mods)( + self.state.as_ptr(), + xkb_state_component::XKB_STATE_MODS_LOCKED, + ) + } + } + + pub fn get_utf8_raw( + &mut self, + keycode: xkb_keycode_t, + scratch_buffer: &mut Vec, + ) -> Option { + make_string_with(scratch_buffer, |ptr, len| unsafe { + (XKBH.xkb_state_key_get_utf8)(self.state.as_ptr(), keycode, ptr, len) + }) + } + + pub fn modifiers(&self) -> ModifiersState { + self.modifiers + } + + pub fn update_modifiers( + &mut self, + mods_depressed: u32, + mods_latched: u32, + mods_locked: u32, + depressed_group: u32, + latched_group: u32, + locked_group: u32, + ) { + let mask = unsafe { + (XKBH.xkb_state_update_mask)( + self.state.as_ptr(), + mods_depressed, + mods_latched, + mods_locked, + depressed_group, + latched_group, + locked_group, + ) + }; + + if mask.contains(xkb_state_component::XKB_STATE_MODS_EFFECTIVE) { + // Effective value of mods have changed, we need to update our state. + self.reload_modifiers(); + } + } + + /// Reload the modifiers. + fn reload_modifiers(&mut self) { + self.modifiers.ctrl = self.mod_name_is_active(xkb::XKB_MOD_NAME_CTRL); + self.modifiers.alt = self.mod_name_is_active(xkb::XKB_MOD_NAME_ALT); + self.modifiers.shift = self.mod_name_is_active(xkb::XKB_MOD_NAME_SHIFT); + self.modifiers.caps_lock = self.mod_name_is_active(xkb::XKB_MOD_NAME_CAPS); + self.modifiers.logo = self.mod_name_is_active(xkb::XKB_MOD_NAME_LOGO); + self.modifiers.num_lock = self.mod_name_is_active(xkb::XKB_MOD_NAME_NUM); + } + + /// Check if the modifier is active within xkb. + fn mod_name_is_active(&mut self, name: &[u8]) -> bool { + unsafe { + (XKBH.xkb_state_mod_name_is_active)( + self.state.as_ptr(), + name.as_ptr() as *const c_char, + xkb_state_component::XKB_STATE_MODS_EFFECTIVE, + ) > 0 + } + } +} + +impl Drop for XkbState { + fn drop(&mut self) { + unsafe { + (XKBH.xkb_state_unref)(self.state.as_ptr()); + } + } +} + +/// Represents the current state of the keyboard modifiers +/// +/// Each field of this struct represents a modifier and is `true` if this modifier is active. +/// +/// For some modifiers, this means that the key is currently pressed, others are toggled +/// (like caps lock). +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub struct ModifiersState { + /// The "control" key + pub ctrl: bool, + /// The "alt" key + pub alt: bool, + /// The "shift" key + pub shift: bool, + /// The "Caps lock" key + pub caps_lock: bool, + /// The "logo" key + /// + /// Also known as the "windows" key on most keyboards + pub logo: bool, + /// The "Num lock" key + pub num_lock: bool, +} + +impl From for crate::keyboard::ModifiersState { + fn from(mods: ModifiersState) -> crate::keyboard::ModifiersState { + let mut to_mods = crate::keyboard::ModifiersState::empty(); + to_mods.set(crate::keyboard::ModifiersState::SHIFT, mods.shift); + to_mods.set(crate::keyboard::ModifiersState::CONTROL, mods.ctrl); + to_mods.set(crate::keyboard::ModifiersState::ALT, mods.alt); + to_mods.set(crate::keyboard::ModifiersState::SUPER, mods.logo); + to_mods + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/mod.rs new file mode 100644 index 00000000..bc0e71c8 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/mod.rs @@ -0,0 +1,1044 @@ +#![cfg(free_unix)] + +#[cfg(all(not(x11_platform), not(wayland_platform)))] +compile_error!("Please select a feature to build for unix: `x11`, `wayland`"); + +use std::collections::VecDeque; +use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, RawFd}; +use std::sync::Arc; +use std::time::Duration; +use std::{env, fmt}; +#[cfg(x11_platform)] +use std::{ffi::CStr, mem::MaybeUninit, os::raw::*, sync::Mutex}; + +#[cfg(x11_platform)] +use crate::utils::Lazy; +use smol_str::SmolStr; + +#[cfg(x11_platform)] +use self::x11::{X11Error, XConnection, XError, XNotSupported}; +use crate::dpi::{PhysicalPosition, PhysicalSize, Position, Size}; +use crate::error::{EventLoopError, ExternalError, NotSupportedError, OsError as RootOsError}; +use crate::event_loop::{ + ActiveEventLoop as RootELW, AsyncRequestSerial, ControlFlow, DeviceEvents, EventLoopClosed, +}; +use crate::icon::Icon; +use crate::keyboard::Key; +use crate::platform::pump_events::PumpStatus; +#[cfg(x11_platform)] +use crate::platform::x11::{WindowType as XWindowType, XlibErrorHook}; +use crate::window::{ + ActivationToken, Cursor, CursorGrabMode, CustomCursor, CustomCursorSource, ImePurpose, + ResizeDirection, Theme, UserAttentionType, WindowAttributes, WindowButtons, WindowLevel, +}; + +pub(crate) use self::common::xkb::{physicalkey_to_scancode, scancode_to_physicalkey}; +pub(crate) use crate::cursor::OnlyCursorImageSource as PlatformCustomCursorSource; +pub(crate) use crate::icon::RgbaIcon as PlatformIcon; +pub(crate) use crate::platform_impl::Fullscreen; + +pub(crate) mod common; +#[cfg(wayland_platform)] +pub(crate) mod wayland; +#[cfg(x11_platform)] +pub(crate) mod x11; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub(crate) enum Backend { + #[cfg(x11_platform)] + X, + #[cfg(wayland_platform)] + Wayland, +} + +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PlatformSpecificEventLoopAttributes { + pub(crate) forced_backend: Option, + pub(crate) any_thread: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApplicationName { + pub general: String, + pub instance: String, +} + +impl ApplicationName { + pub fn new(general: String, instance: String) -> Self { + Self { general, instance } + } +} + +#[derive(Clone, Debug)] +pub struct PlatformSpecificWindowAttributes { + pub name: Option, + pub activation_token: Option, + #[cfg(x11_platform)] + pub x11: X11WindowAttributes, +} + +#[derive(Clone, Debug)] +#[cfg(x11_platform)] +pub struct X11WindowAttributes { + pub visual_id: Option, + pub screen_id: Option, + pub base_size: Option, + pub override_redirect: bool, + pub x11_window_types: Vec, + + /// The parent window to embed this window into. + pub embed_window: Option, +} + +#[cfg_attr(not(x11_platform), allow(clippy::derivable_impls))] +impl Default for PlatformSpecificWindowAttributes { + fn default() -> Self { + Self { + name: None, + activation_token: None, + #[cfg(x11_platform)] + x11: X11WindowAttributes { + visual_id: None, + screen_id: None, + base_size: None, + override_redirect: false, + x11_window_types: vec![XWindowType::Normal], + embed_window: None, + }, + } + } +} + +#[cfg(x11_platform)] +pub(crate) static X11_BACKEND: Lazy, XNotSupported>>> = + Lazy::new(|| Mutex::new(XConnection::new(Some(x_error_callback)).map(Arc::new))); + +#[derive(Debug, Clone)] +pub enum OsError { + Misc(&'static str), + #[cfg(x11_platform)] + XNotSupported(XNotSupported), + #[cfg(x11_platform)] + XError(Arc), + #[cfg(wayland_platform)] + WaylandError(Arc), +} + +impl fmt::Display for OsError { + fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + match *self { + OsError::Misc(e) => _f.pad(e), + #[cfg(x11_platform)] + OsError::XNotSupported(ref e) => fmt::Display::fmt(e, _f), + #[cfg(x11_platform)] + OsError::XError(ref e) => fmt::Display::fmt(e, _f), + #[cfg(wayland_platform)] + OsError::WaylandError(ref e) => fmt::Display::fmt(e, _f), + } + } +} + +pub(crate) enum Window { + #[cfg(x11_platform)] + X(x11::Window), + #[cfg(wayland_platform)] + Wayland(wayland::Window), +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WindowId(u64); + +impl From for u64 { + fn from(window_id: WindowId) -> Self { + window_id.0 + } +} + +impl From for WindowId { + fn from(raw_id: u64) -> Self { + Self(raw_id) + } +} + +impl WindowId { + pub const fn dummy() -> Self { + Self(0) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeviceId { + #[cfg(x11_platform)] + X(x11::DeviceId), + #[cfg(wayland_platform)] + Wayland(wayland::DeviceId), +} + +impl DeviceId { + pub const fn dummy() -> Self { + #[cfg(wayland_platform)] + return DeviceId::Wayland(wayland::DeviceId::dummy()); + #[cfg(all(not(wayland_platform), x11_platform))] + return DeviceId::X(x11::DeviceId::dummy()); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum MonitorHandle { + #[cfg(x11_platform)] + X(x11::MonitorHandle), + #[cfg(wayland_platform)] + Wayland(wayland::MonitorHandle), +} + +/// `x11_or_wayland!(match expr; Enum(foo) => foo.something())` +/// expands to the equivalent of +/// ```ignore +/// match self { +/// Enum::X(foo) => foo.something(), +/// Enum::Wayland(foo) => foo.something(), +/// } +/// ``` +/// The result can be converted to another enum by adding `; as AnotherEnum` +macro_rules! x11_or_wayland { + (match $what:expr; $enum:ident ( $($c1:tt)* ) => $x:expr; as $enum2:ident ) => { + match $what { + #[cfg(x11_platform)] + $enum::X($($c1)*) => $enum2::X($x), + #[cfg(wayland_platform)] + $enum::Wayland($($c1)*) => $enum2::Wayland($x), + } + }; + (match $what:expr; $enum:ident ( $($c1:tt)* ) => $x:expr) => { + match $what { + #[cfg(x11_platform)] + $enum::X($($c1)*) => $x, + #[cfg(wayland_platform)] + $enum::Wayland($($c1)*) => $x, + } + }; +} + +impl MonitorHandle { + #[inline] + pub fn name(&self) -> Option { + x11_or_wayland!(match self; MonitorHandle(m) => m.name()) + } + + #[inline] + pub fn native_identifier(&self) -> u32 { + x11_or_wayland!(match self; MonitorHandle(m) => m.native_identifier()) + } + + #[inline] + pub fn size(&self) -> PhysicalSize { + x11_or_wayland!(match self; MonitorHandle(m) => m.size()) + } + + #[inline] + pub fn position(&self) -> PhysicalPosition { + x11_or_wayland!(match self; MonitorHandle(m) => m.position()) + } + + #[inline] + pub fn refresh_rate_millihertz(&self) -> Option { + x11_or_wayland!(match self; MonitorHandle(m) => m.refresh_rate_millihertz()) + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + x11_or_wayland!(match self; MonitorHandle(m) => m.scale_factor() as _) + } + + #[inline] + pub fn video_modes(&self) -> Box> { + x11_or_wayland!(match self; MonitorHandle(m) => Box::new(m.video_modes())) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum VideoModeHandle { + #[cfg(x11_platform)] + X(x11::VideoModeHandle), + #[cfg(wayland_platform)] + Wayland(wayland::VideoModeHandle), +} + +impl VideoModeHandle { + #[inline] + pub fn size(&self) -> PhysicalSize { + x11_or_wayland!(match self; VideoModeHandle(m) => m.size()) + } + + #[inline] + pub fn bit_depth(&self) -> u16 { + x11_or_wayland!(match self; VideoModeHandle(m) => m.bit_depth()) + } + + #[inline] + pub fn refresh_rate_millihertz(&self) -> u32 { + x11_or_wayland!(match self; VideoModeHandle(m) => m.refresh_rate_millihertz()) + } + + #[inline] + pub fn monitor(&self) -> MonitorHandle { + x11_or_wayland!(match self; VideoModeHandle(m) => m.monitor(); as MonitorHandle) + } +} + +impl Window { + #[inline] + pub(crate) fn new( + window_target: &ActiveEventLoop, + attribs: WindowAttributes, + ) -> Result { + match *window_target { + #[cfg(wayland_platform)] + ActiveEventLoop::Wayland(ref window_target) => { + wayland::Window::new(window_target, attribs).map(Window::Wayland) + }, + #[cfg(x11_platform)] + ActiveEventLoop::X(ref window_target) => { + x11::Window::new(window_target, attribs).map(Window::X) + }, + } + } + + pub(crate) fn maybe_queue_on_main(&self, f: impl FnOnce(&Self) + Send + 'static) { + f(self) + } + + pub(crate) fn maybe_wait_on_main(&self, f: impl FnOnce(&Self) -> R + Send) -> R { + f(self) + } + + #[inline] + pub fn id(&self) -> WindowId { + x11_or_wayland!(match self; Window(w) => w.id()) + } + + #[inline] + pub fn set_title(&self, title: &str) { + x11_or_wayland!(match self; Window(w) => w.set_title(title)); + } + + #[inline] + pub fn set_transparent(&self, transparent: bool) { + x11_or_wayland!(match self; Window(w) => w.set_transparent(transparent)); + } + + #[inline] + pub fn set_blur(&self, blur: bool) { + x11_or_wayland!(match self; Window(w) => w.set_blur(blur)); + } + + #[inline] + pub fn set_visible(&self, visible: bool) { + x11_or_wayland!(match self; Window(w) => w.set_visible(visible)) + } + + #[inline] + pub fn is_visible(&self) -> Option { + x11_or_wayland!(match self; Window(w) => w.is_visible()) + } + + #[inline] + pub fn outer_position(&self) -> Result, NotSupportedError> { + x11_or_wayland!(match self; Window(w) => w.outer_position()) + } + + #[inline] + pub fn inner_position(&self) -> Result, NotSupportedError> { + x11_or_wayland!(match self; Window(w) => w.inner_position()) + } + + #[inline] + pub fn set_outer_position(&self, position: Position) { + x11_or_wayland!(match self; Window(w) => w.set_outer_position(position)) + } + + #[inline] + pub fn inner_size(&self) -> PhysicalSize { + x11_or_wayland!(match self; Window(w) => w.inner_size()) + } + + #[inline] + pub fn outer_size(&self) -> PhysicalSize { + x11_or_wayland!(match self; Window(w) => w.outer_size()) + } + + #[inline] + pub fn request_inner_size(&self, size: Size) -> Option> { + x11_or_wayland!(match self; Window(w) => w.request_inner_size(size)) + } + + #[inline] + pub(crate) fn request_activation_token(&self) -> Result { + x11_or_wayland!(match self; Window(w) => w.request_activation_token()) + } + + #[inline] + pub fn set_min_inner_size(&self, dimensions: Option) { + x11_or_wayland!(match self; Window(w) => w.set_min_inner_size(dimensions)) + } + + #[inline] + pub fn set_max_inner_size(&self, dimensions: Option) { + x11_or_wayland!(match self; Window(w) => w.set_max_inner_size(dimensions)) + } + + #[inline] + pub fn resize_increments(&self) -> Option> { + x11_or_wayland!(match self; Window(w) => w.resize_increments()) + } + + #[inline] + pub fn set_resize_increments(&self, increments: Option) { + x11_or_wayland!(match self; Window(w) => w.set_resize_increments(increments)) + } + + #[inline] + pub fn set_resizable(&self, resizable: bool) { + x11_or_wayland!(match self; Window(w) => w.set_resizable(resizable)) + } + + #[inline] + pub fn is_resizable(&self) -> bool { + x11_or_wayland!(match self; Window(w) => w.is_resizable()) + } + + #[inline] + pub fn set_enabled_buttons(&self, buttons: WindowButtons) { + x11_or_wayland!(match self; Window(w) => w.set_enabled_buttons(buttons)) + } + + #[inline] + pub fn enabled_buttons(&self) -> WindowButtons { + x11_or_wayland!(match self; Window(w) => w.enabled_buttons()) + } + + #[inline] + pub fn set_cursor(&self, cursor: Cursor) { + x11_or_wayland!(match self; Window(w) => w.set_cursor(cursor)) + } + + #[inline] + pub fn set_cursor_grab(&self, mode: CursorGrabMode) -> Result<(), ExternalError> { + x11_or_wayland!(match self; Window(window) => window.set_cursor_grab(mode)) + } + + #[inline] + pub fn set_cursor_visible(&self, visible: bool) { + x11_or_wayland!(match self; Window(window) => window.set_cursor_visible(visible)) + } + + #[inline] + pub fn drag_window(&self) -> Result<(), ExternalError> { + x11_or_wayland!(match self; Window(window) => window.drag_window()) + } + + #[inline] + pub fn drag_resize_window(&self, direction: ResizeDirection) -> Result<(), ExternalError> { + x11_or_wayland!(match self; Window(window) => window.drag_resize_window(direction)) + } + + #[inline] + pub fn show_window_menu(&self, position: Position) { + x11_or_wayland!(match self; Window(w) => w.show_window_menu(position)) + } + + #[inline] + pub fn set_cursor_hittest(&self, hittest: bool) -> Result<(), ExternalError> { + x11_or_wayland!(match self; Window(w) => w.set_cursor_hittest(hittest)) + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + x11_or_wayland!(match self; Window(w) => w.scale_factor()) + } + + #[inline] + pub fn set_cursor_position(&self, position: Position) -> Result<(), ExternalError> { + x11_or_wayland!(match self; Window(w) => w.set_cursor_position(position)) + } + + #[inline] + pub fn set_maximized(&self, maximized: bool) { + x11_or_wayland!(match self; Window(w) => w.set_maximized(maximized)) + } + + #[inline] + pub fn is_maximized(&self) -> bool { + x11_or_wayland!(match self; Window(w) => w.is_maximized()) + } + + #[inline] + pub fn set_minimized(&self, minimized: bool) { + x11_or_wayland!(match self; Window(w) => w.set_minimized(minimized)) + } + + #[inline] + pub fn is_minimized(&self) -> Option { + x11_or_wayland!(match self; Window(w) => w.is_minimized()) + } + + #[inline] + pub(crate) fn fullscreen(&self) -> Option { + x11_or_wayland!(match self; Window(w) => w.fullscreen()) + } + + #[inline] + pub(crate) fn set_fullscreen(&self, monitor: Option) { + x11_or_wayland!(match self; Window(w) => w.set_fullscreen(monitor)) + } + + #[inline] + pub fn set_decorations(&self, decorations: bool) { + x11_or_wayland!(match self; Window(w) => w.set_decorations(decorations)) + } + + #[inline] + pub fn is_decorated(&self) -> bool { + x11_or_wayland!(match self; Window(w) => w.is_decorated()) + } + + #[inline] + pub fn set_window_level(&self, level: WindowLevel) { + x11_or_wayland!(match self; Window(w) => w.set_window_level(level)) + } + + #[inline] + pub fn set_window_icon(&self, window_icon: Option) { + x11_or_wayland!(match self; Window(w) => w.set_window_icon(window_icon.map(|icon| icon.inner))) + } + + #[inline] + pub fn set_ime_cursor_area(&self, position: Position, size: Size) { + x11_or_wayland!(match self; Window(w) => w.set_ime_cursor_area(position, size)) + } + + #[inline] + pub fn reset_dead_keys(&self) { + common::xkb::reset_dead_keys() + } + + #[inline] + pub fn set_ime_allowed(&self, allowed: bool) { + x11_or_wayland!(match self; Window(w) => w.set_ime_allowed(allowed)) + } + + #[inline] + pub fn set_ime_purpose(&self, purpose: ImePurpose) { + x11_or_wayland!(match self; Window(w) => w.set_ime_purpose(purpose)) + } + + #[inline] + pub fn focus_window(&self) { + x11_or_wayland!(match self; Window(w) => w.focus_window()) + } + + pub fn request_user_attention(&self, request_type: Option) { + x11_or_wayland!(match self; Window(w) => w.request_user_attention(request_type)) + } + + #[inline] + pub fn request_redraw(&self) { + x11_or_wayland!(match self; Window(w) => w.request_redraw()) + } + + #[inline] + pub fn pre_present_notify(&self) { + x11_or_wayland!(match self; Window(w) => w.pre_present_notify()) + } + + #[inline] + pub fn current_monitor(&self) -> Option { + Some(x11_or_wayland!(match self; Window(w) => w.current_monitor()?; as MonitorHandle)) + } + + #[inline] + pub fn available_monitors(&self) -> VecDeque { + match self { + #[cfg(x11_platform)] + Window::X(ref window) => { + window.available_monitors().into_iter().map(MonitorHandle::X).collect() + }, + #[cfg(wayland_platform)] + Window::Wayland(ref window) => { + window.available_monitors().into_iter().map(MonitorHandle::Wayland).collect() + }, + } + } + + #[inline] + pub fn primary_monitor(&self) -> Option { + Some(x11_or_wayland!(match self; Window(w) => w.primary_monitor()?; as MonitorHandle)) + } + + #[cfg(feature = "rwh_04")] + #[inline] + pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle { + x11_or_wayland!(match self; Window(window) => window.raw_window_handle_rwh_04()) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle { + x11_or_wayland!(match self; Window(window) => window.raw_window_handle_rwh_05()) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + x11_or_wayland!(match self; Window(window) => window.raw_display_handle_rwh_05()) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_window_handle_rwh_06(&self) -> Result { + x11_or_wayland!(match self; Window(window) => window.raw_window_handle_rwh_06()) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + x11_or_wayland!(match self; Window(window) => window.raw_display_handle_rwh_06()) + } + + #[inline] + pub fn set_theme(&self, theme: Option) { + x11_or_wayland!(match self; Window(window) => window.set_theme(theme)) + } + + #[inline] + pub fn theme(&self) -> Option { + x11_or_wayland!(match self; Window(window) => window.theme()) + } + + pub fn set_content_protected(&self, protected: bool) { + x11_or_wayland!(match self; Window(window) => window.set_content_protected(protected)) + } + + #[inline] + pub fn has_focus(&self) -> bool { + x11_or_wayland!(match self; Window(window) => window.has_focus()) + } + + pub fn title(&self) -> String { + x11_or_wayland!(match self; Window(window) => window.title()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct KeyEventExtra { + pub text_with_all_modifiers: Option, + pub key_without_modifiers: Key, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) enum PlatformCustomCursor { + #[cfg(wayland_platform)] + Wayland(wayland::CustomCursor), + #[cfg(x11_platform)] + X(x11::CustomCursor), +} + +/// Hooks for X11 errors. +#[cfg(x11_platform)] +pub(crate) static XLIB_ERROR_HOOKS: Mutex> = Mutex::new(Vec::new()); + +#[cfg(x11_platform)] +unsafe extern "C" fn x_error_callback( + display: *mut x11::ffi::Display, + event: *mut x11::ffi::XErrorEvent, +) -> c_int { + let xconn_lock = X11_BACKEND.lock().unwrap_or_else(|e| e.into_inner()); + if let Ok(ref xconn) = *xconn_lock { + // Call all the hooks. + let mut error_handled = false; + for hook in XLIB_ERROR_HOOKS.lock().unwrap().iter() { + error_handled |= hook(display as *mut _, event as *mut _); + } + + // `assume_init` is safe here because the array consists of `MaybeUninit` values, + // which do not require initialization. + let mut buf: [MaybeUninit; 1024] = unsafe { MaybeUninit::uninit().assume_init() }; + unsafe { + (xconn.xlib.XGetErrorText)( + display, + (*event).error_code as c_int, + buf.as_mut_ptr() as *mut c_char, + buf.len() as c_int, + ) + }; + let description = + unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }.to_string_lossy(); + + let error = unsafe { + XError { + description: description.into_owned(), + error_code: (*event).error_code, + request_code: (*event).request_code, + minor_code: (*event).minor_code, + } + }; + + // Don't log error. + if !error_handled { + tracing::error!("X11 error: {:#?}", error); + // XXX only update the error, if it wasn't handled by any of the hooks. + *xconn.latest_error.lock().unwrap() = Some(error); + } + } + // Fun fact: this return value is completely ignored. + 0 +} + +#[allow(clippy::large_enum_variant)] +pub enum EventLoop { + #[cfg(wayland_platform)] + Wayland(Box>), + #[cfg(x11_platform)] + X(x11::EventLoop), +} + +pub enum EventLoopProxy { + #[cfg(x11_platform)] + X(x11::EventLoopProxy), + #[cfg(wayland_platform)] + Wayland(wayland::EventLoopProxy), +} + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + x11_or_wayland!(match self; EventLoopProxy(proxy) => proxy.clone(); as EventLoopProxy) + } +} + +impl EventLoop { + pub(crate) fn new( + attributes: &PlatformSpecificEventLoopAttributes, + ) -> Result { + if !attributes.any_thread && !is_main_thread() { + panic!( + "Initializing the event loop outside of the main thread is a significant \ + cross-platform compatibility hazard. If you absolutely need to create an \ + EventLoop on a different thread, you can use the \ + `EventLoopBuilderExtX11::any_thread` or `EventLoopBuilderExtWayland::any_thread` \ + functions." + ); + } + + // NOTE: Wayland first because of X11 could be present under Wayland as well. Empty + // variables are also treated as not set. + let backend = match ( + attributes.forced_backend, + env::var("WAYLAND_DISPLAY") + .ok() + .filter(|var| !var.is_empty()) + .or_else(|| env::var("WAYLAND_SOCKET").ok()) + .filter(|var| !var.is_empty()) + .is_some(), + env::var("DISPLAY").map(|var| !var.is_empty()).unwrap_or(false), + ) { + // User is forcing a backend. + (Some(backend), ..) => backend, + // Wayland is present. + #[cfg(wayland_platform)] + (None, true, _) => Backend::Wayland, + // X11 is present. + #[cfg(x11_platform)] + (None, _, true) => Backend::X, + // No backend is present. + (_, wayland_display, x11_display) => { + let msg = if wayland_display && !cfg!(wayland_platform) { + "DISPLAY is not set; note: enable the `winit/wayland` feature to support \ + Wayland" + } else if x11_display && !cfg!(x11_platform) { + "neither WAYLAND_DISPLAY nor WAYLAND_SOCKET is set; note: enable the \ + `winit/x11` feature to support X11" + } else { + "neither WAYLAND_DISPLAY nor WAYLAND_SOCKET nor DISPLAY is set." + }; + return Err(EventLoopError::Os(os_error!(OsError::Misc(msg)))); + }, + }; + + // Create the display based on the backend. + match backend { + #[cfg(wayland_platform)] + Backend::Wayland => EventLoop::new_wayland_any_thread(), + #[cfg(x11_platform)] + Backend::X => EventLoop::new_x11_any_thread(), + } + } + + #[cfg(wayland_platform)] + fn new_wayland_any_thread() -> Result, EventLoopError> { + wayland::EventLoop::new().map(|evlp| EventLoop::Wayland(Box::new(evlp))) + } + + #[cfg(x11_platform)] + fn new_x11_any_thread() -> Result, EventLoopError> { + let xconn = match X11_BACKEND.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { + Ok(xconn) => xconn.clone(), + Err(err) => { + return Err(EventLoopError::Os(os_error!(OsError::XNotSupported(err.clone())))) + }, + }; + + Ok(EventLoop::X(x11::EventLoop::new(xconn))) + } + + #[inline] + pub fn is_wayland(&self) -> bool { + match *self { + #[cfg(wayland_platform)] + EventLoop::Wayland(_) => true, + #[cfg(x11_platform)] + _ => false, + } + } + + pub fn create_proxy(&self) -> EventLoopProxy { + x11_or_wayland!(match self; EventLoop(evlp) => evlp.create_proxy(); as EventLoopProxy) + } + + pub fn run(mut self, callback: F) -> Result<(), EventLoopError> + where + F: FnMut(crate::event::Event, &RootELW), + { + self.run_on_demand(callback) + } + + pub fn run_on_demand(&mut self, callback: F) -> Result<(), EventLoopError> + where + F: FnMut(crate::event::Event, &RootELW), + { + x11_or_wayland!(match self; EventLoop(evlp) => evlp.run_on_demand(callback)) + } + + pub fn pump_events(&mut self, timeout: Option, callback: F) -> PumpStatus + where + F: FnMut(crate::event::Event, &RootELW), + { + x11_or_wayland!(match self; EventLoop(evlp) => evlp.pump_events(timeout, callback)) + } + + pub fn window_target(&self) -> &crate::event_loop::ActiveEventLoop { + x11_or_wayland!(match self; EventLoop(evlp) => evlp.window_target()) + } +} + +impl AsFd for EventLoop { + fn as_fd(&self) -> BorrowedFd<'_> { + x11_or_wayland!(match self; EventLoop(evlp) => evlp.as_fd()) + } +} + +impl AsRawFd for EventLoop { + fn as_raw_fd(&self) -> RawFd { + x11_or_wayland!(match self; EventLoop(evlp) => evlp.as_raw_fd()) + } +} + +impl EventLoopProxy { + pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed> { + x11_or_wayland!(match self; EventLoopProxy(proxy) => proxy.send_event(event)) + } +} + +#[allow(clippy::large_enum_variant)] +pub enum ActiveEventLoop { + #[cfg(wayland_platform)] + Wayland(wayland::ActiveEventLoop), + #[cfg(x11_platform)] + X(x11::ActiveEventLoop), +} + +impl ActiveEventLoop { + #[inline] + pub fn is_wayland(&self) -> bool { + match *self { + #[cfg(wayland_platform)] + ActiveEventLoop::Wayland(_) => true, + #[cfg(x11_platform)] + _ => false, + } + } + + pub fn create_custom_cursor(&self, cursor: CustomCursorSource) -> CustomCursor { + x11_or_wayland!(match self; ActiveEventLoop(evlp) => evlp.create_custom_cursor(cursor)) + } + + #[inline] + pub fn available_monitors(&self) -> VecDeque { + match *self { + #[cfg(wayland_platform)] + ActiveEventLoop::Wayland(ref evlp) => { + evlp.available_monitors().map(MonitorHandle::Wayland).collect() + }, + #[cfg(x11_platform)] + ActiveEventLoop::X(ref evlp) => { + evlp.available_monitors().map(MonitorHandle::X).collect() + }, + } + } + + #[inline] + pub fn primary_monitor(&self) -> Option { + Some( + x11_or_wayland!(match self; ActiveEventLoop(evlp) => evlp.primary_monitor()?; as MonitorHandle), + ) + } + + #[inline] + pub fn listen_device_events(&self, allowed: DeviceEvents) { + x11_or_wayland!(match self; Self(evlp) => evlp.listen_device_events(allowed)) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + x11_or_wayland!(match self; Self(evlp) => evlp.raw_display_handle_rwh_05()) + } + + #[inline] + pub fn system_theme(&self) -> Option { + None + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + x11_or_wayland!(match self; Self(evlp) => evlp.raw_display_handle_rwh_06()) + } + + pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) { + x11_or_wayland!(match self; Self(evlp) => evlp.set_control_flow(control_flow)) + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + x11_or_wayland!(match self; Self(evlp) => evlp.control_flow()) + } + + pub(crate) fn clear_exit(&self) { + x11_or_wayland!(match self; Self(evlp) => evlp.clear_exit()) + } + + pub(crate) fn exit(&self) { + x11_or_wayland!(match self; Self(evlp) => evlp.exit()) + } + + pub(crate) fn exiting(&self) -> bool { + x11_or_wayland!(match self; Self(evlp) => evlp.exiting()) + } + + pub(crate) fn owned_display_handle(&self) -> OwnedDisplayHandle { + match self { + #[cfg(x11_platform)] + Self::X(conn) => OwnedDisplayHandle::X(conn.x_connection().clone()), + #[cfg(wayland_platform)] + Self::Wayland(conn) => OwnedDisplayHandle::Wayland(conn.connection.clone()), + } + } + + #[allow(dead_code)] + fn set_exit_code(&self, code: i32) { + x11_or_wayland!(match self; Self(evlp) => evlp.set_exit_code(code)) + } + + #[allow(dead_code)] + fn exit_code(&self) -> Option { + x11_or_wayland!(match self; Self(evlp) => evlp.exit_code()) + } +} + +#[derive(Clone)] +#[allow(dead_code)] +pub(crate) enum OwnedDisplayHandle { + #[cfg(x11_platform)] + X(Arc), + #[cfg(wayland_platform)] + Wayland(wayland_client::Connection), +} + +impl OwnedDisplayHandle { + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + match self { + #[cfg(x11_platform)] + Self::X(xconn) => { + let mut xlib_handle = rwh_05::XlibDisplayHandle::empty(); + xlib_handle.display = xconn.display.cast(); + xlib_handle.screen = xconn.default_screen_index() as _; + xlib_handle.into() + }, + + #[cfg(wayland_platform)] + Self::Wayland(conn) => { + use sctk::reexports::client::Proxy; + + let mut wayland_handle = rwh_05::WaylandDisplayHandle::empty(); + wayland_handle.display = conn.display().id().as_ptr() as *mut _; + wayland_handle.into() + }, + } + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + use std::ptr::NonNull; + + match self { + #[cfg(x11_platform)] + Self::X(xconn) => Ok(rwh_06::XlibDisplayHandle::new( + NonNull::new(xconn.display.cast()), + xconn.default_screen_index() as _, + ) + .into()), + + #[cfg(wayland_platform)] + Self::Wayland(conn) => { + use sctk::reexports::client::Proxy; + + Ok(rwh_06::WaylandDisplayHandle::new( + NonNull::new(conn.display().id().as_ptr().cast()).unwrap(), + ) + .into()) + }, + } + } +} + +/// Returns the minimum `Option`, taking into account that `None` +/// equates to an infinite timeout, not a zero timeout (so can't just use +/// `Option::min`) +fn min_timeout(a: Option, b: Option) -> Option { + a.map_or(b, |a_timeout| b.map_or(Some(a_timeout), |b_timeout| Some(a_timeout.min(b_timeout)))) +} + +#[cfg(target_os = "linux")] +fn is_main_thread() -> bool { + rustix::thread::gettid() == rustix::process::getpid() +} + +#[cfg(any(target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd"))] +fn is_main_thread() -> bool { + use libc::pthread_main_np; + + unsafe { pthread_main_np() == 1 } +} + +#[cfg(target_os = "netbsd")] +fn is_main_thread() -> bool { + std::thread::current().name() == Some("main") +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/mod.rs new file mode 100644 index 00000000..fef13f45 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/mod.rs @@ -0,0 +1,810 @@ +//! The event-loop routines. + +use std::cell::{Cell, RefCell}; +use std::io::Result as IOResult; +use std::marker::PhantomData; +use std::mem; +use std::os::fd::OwnedFd; +use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, RawFd}; +use std::rc::Rc; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use calloop::ping::Ping; +use rustix::event::{PollFd, PollFlags}; +use rustix::pipe::{self, PipeFlags}; +use sctk::reexports::calloop::Error as CalloopError; +use sctk::reexports::calloop_wayland_source::WaylandSource; +use sctk::reexports::client::{globals, Connection, QueueHandle}; +use tracing::warn; + +use crate::cursor::OnlyCursorImage; +use crate::dpi::LogicalSize; +use crate::error::{EventLoopError, OsError as RootOsError}; +use crate::event::{Event, InnerSizeWriter, StartCause, WindowEvent}; +use crate::event_loop::{ActiveEventLoop as RootActiveEventLoop, ControlFlow, DeviceEvents}; +use crate::platform::pump_events::PumpStatus; +use crate::platform_impl::platform::min_timeout; +use crate::platform_impl::{ + ActiveEventLoop as PlatformActiveEventLoop, OsError, PlatformCustomCursor, +}; +use crate::window::{CustomCursor as RootCustomCursor, CustomCursorSource}; + +mod proxy; +pub mod sink; + +pub use proxy::EventLoopProxy; +use sink::EventSink; + +use super::state::{WindowCompositorUpdate, WinitState}; +use super::window::state::FrameCallbackState; +use super::{logical_to_physical_rounded, DeviceId, WaylandError, WindowId}; + +type WaylandDispatcher = calloop::Dispatcher<'static, WaylandSource, WinitState>; + +/// The Wayland event loop. +pub struct EventLoop { + /// Has `run` or `run_on_demand` been called or a call to `pump_events` that starts the loop + loop_running: bool, + + buffer_sink: EventSink, + compositor_updates: Vec, + window_ids: Vec, + + /// Sender of user events. + user_events_sender: calloop::channel::Sender, + + // XXX can't remove RefCell out of here, unless we can plumb generics into the `Window`, which + // we don't really want, since it'll break public API by a lot. + /// Pending events from the user. + pending_user_events: Rc>>, + + /// The Wayland dispatcher to has raw access to the queue when needed, such as + /// when creating a new window. + wayland_dispatcher: WaylandDispatcher, + + /// Connection to the wayland server. + connection: Connection, + + /// Event loop window target. + window_target: RootActiveEventLoop, + + // XXX drop after everything else, just to be safe. + /// Calloop's event loop. + event_loop: calloop::EventLoop<'static, WinitState>, + + pump_event_notifier: Option, +} + +impl EventLoop { + pub fn new() -> Result, EventLoopError> { + macro_rules! map_err { + ($e:expr, $err:expr) => { + $e.map_err(|error| os_error!($err(error).into())) + }; + } + + let connection = map_err!(Connection::connect_to_env(), WaylandError::Connection)?; + + let (globals, mut event_queue) = + map_err!(globals::registry_queue_init(&connection), WaylandError::Global)?; + let queue_handle = event_queue.handle(); + + let event_loop = + map_err!(calloop::EventLoop::::try_new(), WaylandError::Calloop)?; + + let mut winit_state = WinitState::new(&globals, &queue_handle, event_loop.handle()) + .map_err(|error| os_error!(error))?; + + // NOTE: do a roundtrip after binding the globals to prevent potential + // races with the server. + map_err!(event_queue.roundtrip(&mut winit_state), WaylandError::Dispatch)?; + + // Register Wayland source. + let wayland_source = WaylandSource::new(connection.clone(), event_queue); + let wayland_dispatcher = + calloop::Dispatcher::new(wayland_source, |_, queue, winit_state: &mut WinitState| { + let result = queue.dispatch_pending(winit_state); + if result.is_ok() + && (!winit_state.events_sink.is_empty() + || !winit_state.window_compositor_updates.is_empty()) + { + winit_state.dispatched_events = true; + } + result + }); + + map_err!( + event_loop.handle().register_dispatcher(wayland_dispatcher.clone()), + WaylandError::Calloop + )?; + + // Setup the user proxy. + let pending_user_events = Rc::new(RefCell::new(Vec::new())); + let pending_user_events_clone = pending_user_events.clone(); + let (user_events_sender, user_events_channel) = calloop::channel::channel(); + let result = event_loop + .handle() + .insert_source(user_events_channel, move |event, _, winit_state: &mut WinitState| { + if let calloop::channel::Event::Msg(msg) = event { + winit_state.dispatched_events = true; + pending_user_events_clone.borrow_mut().push(msg); + } + }) + .map_err(|error| error.error); + map_err!(result, WaylandError::Calloop)?; + + // An event's loop awakener to wake up for window events from winit's windows. + let (event_loop_awakener, event_loop_awakener_source) = map_err!( + calloop::ping::make_ping() + .map_err(|error| CalloopError::OtherError(Box::new(error).into())), + WaylandError::Calloop + )?; + + let result = event_loop + .handle() + .insert_source(event_loop_awakener_source, move |_, _, winit_state: &mut WinitState| { + // Mark that we have something to dispatch. + winit_state.dispatched_events = true; + }) + .map_err(|error| error.error); + map_err!(result, WaylandError::Calloop)?; + + let window_target = ActiveEventLoop { + connection: connection.clone(), + wayland_dispatcher: wayland_dispatcher.clone(), + event_loop_awakener, + queue_handle, + control_flow: Cell::new(ControlFlow::default()), + exit: Cell::new(None), + state: RefCell::new(winit_state), + }; + + let event_loop = Self { + loop_running: false, + compositor_updates: Vec::new(), + buffer_sink: EventSink::default(), + window_ids: Vec::new(), + connection, + wayland_dispatcher, + user_events_sender, + pending_user_events, + event_loop, + window_target: RootActiveEventLoop { + p: PlatformActiveEventLoop::Wayland(window_target), + _marker: PhantomData, + }, + pump_event_notifier: None, + }; + + Ok(event_loop) + } + + pub fn run_on_demand(&mut self, mut event_handler: F) -> Result<(), EventLoopError> + where + F: FnMut(Event, &RootActiveEventLoop), + { + let exit = loop { + match self.pump_events(None, &mut event_handler) { + PumpStatus::Exit(0) => { + break Ok(()); + }, + PumpStatus::Exit(code) => { + break Err(EventLoopError::ExitFailure(code)); + }, + _ => { + continue; + }, + } + }; + + // Applications aren't allowed to carry windows between separate + // `run_on_demand` calls but if they have only just dropped their + // windows we need to make sure those last requests are sent to the + // compositor. + let _ = self.roundtrip().map_err(EventLoopError::Os); + + exit + } + + pub fn pump_events(&mut self, timeout: Option, mut callback: F) -> PumpStatus + where + F: FnMut(Event, &RootActiveEventLoop), + { + if !self.loop_running { + self.loop_running = true; + + // Run the initial loop iteration. + self.single_iteration(&mut callback, StartCause::Init); + } + + // Consider the possibility that the `StartCause::Init` iteration could + // request to Exit. + if !self.exiting() { + self.poll_events_with_timeout(timeout, &mut callback); + } + if let Some(code) = self.exit_code() { + self.loop_running = false; + + callback(Event::LoopExiting, self.window_target()); + + PumpStatus::Exit(code) + } else { + // NOTE: spawn a wake-up thread, thus if we have code reading the wayland connection + // in parallel to winit, we ensure that the loop itself is marked as having events. + if timeout.is_some() && self.pump_event_notifier.is_none() { + let awakener = match &self.window_target.p { + PlatformActiveEventLoop::Wayland(window_target) => { + window_target.event_loop_awakener.clone() + }, + #[cfg(x11_platform)] + PlatformActiveEventLoop::X(_) => unreachable!(), + }; + + self.pump_event_notifier = + Some(PumpEventNotifier::spawn(self.connection.clone(), awakener)); + } + + if let Some(pump_event_notifier) = self.pump_event_notifier.as_ref() { + // Notify that we don't have to wait, since we're out of winit. + *pump_event_notifier.control.0.lock().unwrap() = PumpEventNotifierAction::Monitor; + pump_event_notifier.control.1.notify_one(); + } + + PumpStatus::Continue + } + } + + pub fn poll_events_with_timeout(&mut self, mut timeout: Option, mut callback: F) + where + F: FnMut(Event, &RootActiveEventLoop), + { + let cause = loop { + let start = Instant::now(); + + timeout = { + let control_flow_timeout = match self.control_flow() { + ControlFlow::Wait => None, + ControlFlow::Poll => Some(Duration::ZERO), + ControlFlow::WaitUntil(wait_deadline) => { + Some(wait_deadline.saturating_duration_since(start)) + }, + }; + min_timeout(control_flow_timeout, timeout) + }; + + // NOTE Ideally we should flush as the last thing we do before polling + // to wait for events, and this should be done by the calloop + // WaylandSource but we currently need to flush writes manually. + // + // Checking for flush error is essential to perform an exit with error, since + // once we have a protocol error, we could get stuck retrying... + if self.connection.flush().is_err() { + self.set_exit_code(1); + return; + } + + if let Err(error) = self.loop_dispatch(timeout) { + // NOTE We exit on errors from dispatches, since if we've got protocol error + // libwayland-client/wayland-rs will inform us anyway, but crashing downstream is + // not really an option. Instead we inform that the event loop got + // destroyed. We may communicate an error that something was + // terminated, but winit doesn't provide us with an API to do that + // via some event. Still, we set the exit code to the error's OS + // error code, or to 1 if not possible. + let exit_code = error.raw_os_error().unwrap_or(1); + self.set_exit_code(exit_code); + return; + } + + // NB: `StartCause::Init` is handled as a special case and doesn't need + // to be considered here + let cause = match self.control_flow() { + ControlFlow::Poll => StartCause::Poll, + ControlFlow::Wait => StartCause::WaitCancelled { start, requested_resume: None }, + ControlFlow::WaitUntil(deadline) => { + if Instant::now() < deadline { + StartCause::WaitCancelled { start, requested_resume: Some(deadline) } + } else { + StartCause::ResumeTimeReached { start, requested_resume: deadline } + } + }, + }; + + // Reduce spurious wake-ups. + let dispatched_events = self.with_state(|state| state.dispatched_events); + if matches!(cause, StartCause::WaitCancelled { .. }) + && !dispatched_events + && timeout.is_none() + { + continue; + } + + break cause; + }; + + self.single_iteration(&mut callback, cause); + } + + fn single_iteration(&mut self, callback: &mut F, cause: StartCause) + where + F: FnMut(Event, &RootActiveEventLoop), + { + // NOTE currently just indented to simplify the diff + + // We retain these grow-only scratch buffers as part of the EventLoop + // for the sake of avoiding lots of reallocs. We take them here to avoid + // trying to mutably borrow `self` more than once and we swap them back + // when finished. + let mut compositor_updates = std::mem::take(&mut self.compositor_updates); + let mut buffer_sink = std::mem::take(&mut self.buffer_sink); + let mut window_ids = std::mem::take(&mut self.window_ids); + + callback(Event::NewEvents(cause), &self.window_target); + + // NB: For consistency all platforms must emit a 'resumed' event even though Wayland + // applications don't themselves have a formal suspend/resume lifecycle. + if cause == StartCause::Init { + callback(Event::Resumed, &self.window_target); + } + + // Handle pending user events. We don't need back buffer, since we can't dispatch + // user events indirectly via callback to the user. + for user_event in self.pending_user_events.borrow_mut().drain(..) { + callback(Event::UserEvent(user_event), &self.window_target); + } + + // Drain the pending compositor updates. + self.with_state(|state| compositor_updates.append(&mut state.window_compositor_updates)); + + for mut compositor_update in compositor_updates.drain(..) { + let window_id = compositor_update.window_id; + if compositor_update.scale_changed { + let (physical_size, scale_factor) = self.with_state(|state| { + let windows = state.windows.get_mut(); + let window = windows.get(&window_id).unwrap().lock().unwrap(); + let scale_factor = window.scale_factor(); + let size = logical_to_physical_rounded(window.inner_size(), scale_factor); + (size, scale_factor) + }); + + // Stash the old window size. + let old_physical_size = physical_size; + + let new_inner_size = Arc::new(Mutex::new(physical_size)); + callback( + Event::WindowEvent { + window_id: crate::window::WindowId(window_id), + event: WindowEvent::ScaleFactorChanged { + scale_factor, + inner_size_writer: InnerSizeWriter::new(Arc::downgrade( + &new_inner_size, + )), + }, + }, + &self.window_target, + ); + + let physical_size = *new_inner_size.lock().unwrap(); + drop(new_inner_size); + + // Resize the window when user altered the size. + if old_physical_size != physical_size { + self.with_state(|state| { + let windows = state.windows.get_mut(); + let mut window = windows.get(&window_id).unwrap().lock().unwrap(); + + let new_logical_size: LogicalSize = + physical_size.to_logical(scale_factor); + window.request_inner_size(new_logical_size.into()); + }); + + // Make it queue resize. + compositor_update.resized = true; + } + } + + // NOTE: Rescale changed the physical size which winit operates in, thus we should + // resize. + if compositor_update.resized || compositor_update.scale_changed { + let physical_size = self.with_state(|state| { + let windows = state.windows.get_mut(); + let window = windows.get(&window_id).unwrap().lock().unwrap(); + + let scale_factor = window.scale_factor(); + let size = logical_to_physical_rounded(window.inner_size(), scale_factor); + + // Mark the window as needed a redraw. + state + .window_requests + .get_mut() + .get_mut(&window_id) + .unwrap() + .redraw_requested + .store(true, Ordering::Relaxed); + + size + }); + + callback( + Event::WindowEvent { + window_id: crate::window::WindowId(window_id), + event: WindowEvent::Resized(physical_size), + }, + &self.window_target, + ); + } + + if compositor_update.close_window { + callback( + Event::WindowEvent { + window_id: crate::window::WindowId(window_id), + event: WindowEvent::CloseRequested, + }, + &self.window_target, + ); + } + } + + // Push the events directly from the window. + self.with_state(|state| { + buffer_sink.append(&mut state.window_events_sink.lock().unwrap()); + }); + for event in buffer_sink.drain() { + let event = event.map_nonuser_event().unwrap(); + callback(event, &self.window_target); + } + + // Handle non-synthetic events. + self.with_state(|state| { + buffer_sink.append(&mut state.events_sink); + }); + for event in buffer_sink.drain() { + let event = event.map_nonuser_event().unwrap(); + callback(event, &self.window_target); + } + + // Collect the window ids + self.with_state(|state| { + window_ids.extend(state.window_requests.get_mut().keys()); + }); + + for window_id in window_ids.iter() { + let event = self.with_state(|state| { + let window_requests = state.window_requests.get_mut(); + if window_requests.get(window_id).unwrap().take_closed() { + mem::drop(window_requests.remove(window_id)); + mem::drop(state.windows.get_mut().remove(window_id)); + return Some(WindowEvent::Destroyed); + } + + let mut window = + state.windows.get_mut().get_mut(window_id).unwrap().lock().unwrap(); + + if window.frame_callback_state() == FrameCallbackState::Requested { + return None; + } + + // Reset the frame callbacks state. + window.frame_callback_reset(); + let mut redraw_requested = + window_requests.get(window_id).unwrap().take_redraw_requested(); + + // Redraw the frame while at it. + redraw_requested |= window.refresh_frame(); + + redraw_requested.then_some(WindowEvent::RedrawRequested) + }); + + if let Some(event) = event { + callback( + Event::WindowEvent { window_id: crate::window::WindowId(*window_id), event }, + &self.window_target, + ); + } + } + + // Reset the hint that we've dispatched events. + self.with_state(|state| { + state.dispatched_events = false; + }); + + // This is always the last event we dispatch before poll again + callback(Event::AboutToWait, &self.window_target); + + // Update the window frames and schedule redraws. + let mut wake_up = false; + for window_id in window_ids.drain(..) { + wake_up |= self.with_state(|state| match state.windows.get_mut().get_mut(&window_id) { + Some(window) => { + let refresh = window.lock().unwrap().refresh_frame(); + if refresh { + state + .window_requests + .get_mut() + .get_mut(&window_id) + .unwrap() + .redraw_requested + .store(true, Ordering::Relaxed); + } + + refresh + }, + None => false, + }); + } + + // Wakeup event loop if needed. + // + // If the user draws from the `AboutToWait` this is likely not required, however + // we can't do much about it. + if wake_up { + match &self.window_target.p { + PlatformActiveEventLoop::Wayland(window_target) => { + window_target.event_loop_awakener.ping(); + }, + #[cfg(x11_platform)] + PlatformActiveEventLoop::X(_) => unreachable!(), + } + } + + std::mem::swap(&mut self.compositor_updates, &mut compositor_updates); + std::mem::swap(&mut self.buffer_sink, &mut buffer_sink); + std::mem::swap(&mut self.window_ids, &mut window_ids); + } + + #[inline] + pub fn create_proxy(&self) -> EventLoopProxy { + EventLoopProxy::new(self.user_events_sender.clone()) + } + + #[inline] + pub fn window_target(&self) -> &RootActiveEventLoop { + &self.window_target + } + + fn with_state<'a, U: 'a, F: FnOnce(&'a mut WinitState) -> U>(&'a mut self, callback: F) -> U { + let state = match &mut self.window_target.p { + PlatformActiveEventLoop::Wayland(window_target) => window_target.state.get_mut(), + #[cfg(x11_platform)] + _ => unreachable!(), + }; + + callback(state) + } + + fn loop_dispatch>>(&mut self, timeout: D) -> IOResult<()> { + let state = match &mut self.window_target.p { + PlatformActiveEventLoop::Wayland(window_target) => window_target.state.get_mut(), + #[cfg(feature = "x11")] + _ => unreachable!(), + }; + + self.event_loop.dispatch(timeout, state).map_err(|error| { + tracing::error!("Error dispatching event loop: {}", error); + error.into() + }) + } + + fn roundtrip(&mut self) -> Result { + let state = match &mut self.window_target.p { + PlatformActiveEventLoop::Wayland(window_target) => window_target.state.get_mut(), + #[cfg(feature = "x11")] + _ => unreachable!(), + }; + + let mut wayland_source = self.wayland_dispatcher.as_source_mut(); + let event_queue = wayland_source.queue(); + event_queue.roundtrip(state).map_err(|error| { + os_error!(OsError::WaylandError(Arc::new(WaylandError::Dispatch(error)))) + }) + } + + fn control_flow(&self) -> ControlFlow { + self.window_target.p.control_flow() + } + + fn exiting(&self) -> bool { + self.window_target.p.exiting() + } + + fn set_exit_code(&self, code: i32) { + self.window_target.p.set_exit_code(code) + } + + fn exit_code(&self) -> Option { + self.window_target.p.exit_code() + } +} + +impl AsFd for EventLoop { + fn as_fd(&self) -> BorrowedFd<'_> { + self.event_loop.as_fd() + } +} + +impl AsRawFd for EventLoop { + fn as_raw_fd(&self) -> RawFd { + self.event_loop.as_raw_fd() + } +} + +pub struct ActiveEventLoop { + /// The event loop wakeup source. + pub event_loop_awakener: Ping, + + /// The main queue used by the event loop. + pub queue_handle: QueueHandle, + + /// The application's latest control_flow state + pub(crate) control_flow: Cell, + + /// The application's exit state. + pub(crate) exit: Cell>, + + // TODO remove that RefCell once we can pass `&mut` in `Window::new`. + /// Winit state. + pub state: RefCell, + + /// Dispatcher of Wayland events. + pub wayland_dispatcher: WaylandDispatcher, + + /// Connection to the wayland server. + pub connection: Connection, +} + +impl ActiveEventLoop { + pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) { + self.control_flow.set(control_flow) + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + self.control_flow.get() + } + + pub(crate) fn exit(&self) { + self.exit.set(Some(0)) + } + + pub(crate) fn clear_exit(&self) { + self.exit.set(None) + } + + pub(crate) fn exiting(&self) -> bool { + self.exit.get().is_some() + } + + pub(crate) fn set_exit_code(&self, code: i32) { + self.exit.set(Some(code)) + } + + pub(crate) fn exit_code(&self) -> Option { + self.exit.get() + } + + #[inline] + pub fn listen_device_events(&self, _allowed: DeviceEvents) {} + + pub(crate) fn create_custom_cursor(&self, cursor: CustomCursorSource) -> RootCustomCursor { + RootCustomCursor { + inner: PlatformCustomCursor::Wayland(OnlyCursorImage(Arc::from(cursor.inner.0))), + } + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + use sctk::reexports::client::Proxy; + + let mut display_handle = rwh_05::WaylandDisplayHandle::empty(); + display_handle.display = self.connection.display().id().as_ptr() as *mut _; + rwh_05::RawDisplayHandle::Wayland(display_handle) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + use sctk::reexports::client::Proxy; + + Ok(rwh_06::WaylandDisplayHandle::new({ + let ptr = self.connection.display().id().as_ptr(); + std::ptr::NonNull::new(ptr as *mut _).expect("wl_display should never be null") + }) + .into()) + } +} + +#[derive(Debug)] +struct PumpEventNotifier { + /// Whether we're in winit or not. + control: Arc<(Mutex, Condvar)>, + /// Waker handle for the working thread. + worker_waker: Option, + /// Thread handle. + handle: Option>, +} + +impl Drop for PumpEventNotifier { + fn drop(&mut self) { + // Wake-up the thread. + if let Some(worker_waker) = self.worker_waker.as_ref() { + let _ = rustix::io::write(worker_waker.as_fd(), &[0u8]); + } + *self.control.0.lock().unwrap() = PumpEventNotifierAction::Shutdown; + self.control.1.notify_one(); + + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +impl PumpEventNotifier { + fn spawn(connection: Connection, awakener: Ping) -> Self { + // Start from the waiting state. + let control = Arc::new((Mutex::new(PumpEventNotifierAction::Pause), Condvar::new())); + let control_thread = Arc::clone(&control); + + let (read, write) = match pipe::pipe_with(PipeFlags::CLOEXEC | PipeFlags::NONBLOCK) { + Ok((read, write)) => (read, write), + Err(_) => return Self { control, handle: None, worker_waker: None }, + }; + + let handle = + std::thread::Builder::new().name(String::from("pump_events mon")).spawn(move || { + let (lock, cvar) = &*control_thread; + 'outer: loop { + let mut wait = lock.lock().unwrap(); + while *wait == PumpEventNotifierAction::Pause { + wait = cvar.wait(wait).unwrap(); + } + + // Exit the loop when we're asked to. Given that we poll + // only once we can take the `prepare_read`, but in some cases + // it could be not possible, we may block on `join`. + if *wait == PumpEventNotifierAction::Shutdown { + break 'outer; + } + + // Wake-up the main loop and put this one back to sleep. + *wait = PumpEventNotifierAction::Pause; + drop(wait); + + while let Some(read_guard) = connection.prepare_read() { + let _ = connection.flush(); + let poll_fd = PollFd::from_borrowed_fd(connection.as_fd(), PollFlags::IN); + let pipe_poll_fd = PollFd::from_borrowed_fd(read.as_fd(), PollFlags::IN); + // Read from the `fd` before going back to poll. + if Ok(1) == rustix::io::read(read.as_fd(), &mut [0u8; 1]) { + break 'outer; + } + let _ = rustix::event::poll(&mut [poll_fd, pipe_poll_fd], -1); + // Non-blocking read the connection. + let _ = read_guard.read_without_dispatch(); + } + + awakener.ping(); + } + }); + + if let Some(err) = handle.as_ref().err() { + warn!("failed to spawn pump_events wake-up thread: {err}"); + } + + PumpEventNotifier { control, handle: handle.ok(), worker_waker: Some(write) } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum PumpEventNotifierAction { + /// Monitor the wayland queue. + Monitor, + /// Pause monitoring. + Pause, + /// Shutdown the thread. + Shutdown, +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/proxy.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/proxy.rs new file mode 100644 index 00000000..9dc7d992 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/proxy.rs @@ -0,0 +1,28 @@ +//! An event loop proxy. + +use std::sync::mpsc::SendError; + +use sctk::reexports::calloop::channel::Sender; + +use crate::event_loop::EventLoopClosed; + +/// A handle that can be sent across the threads and used to wake up the `EventLoop`. +pub struct EventLoopProxy { + user_events_sender: Sender, +} + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + EventLoopProxy { user_events_sender: self.user_events_sender.clone() } + } +} + +impl EventLoopProxy { + pub fn new(user_events_sender: Sender) -> Self { + Self { user_events_sender } + } + + pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed> { + self.user_events_sender.send(event).map_err(|SendError(error)| EventLoopClosed(error)) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/sink.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/sink.rs new file mode 100644 index 00000000..e506b4a8 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/event_loop/sink.rs @@ -0,0 +1,53 @@ +//! An event loop's sink to deliver events from the Wayland event callbacks. + +use std::vec::Drain; + +use crate::event::{DeviceEvent, DeviceId as RootDeviceId, Event, WindowEvent}; +use crate::platform_impl::platform::DeviceId as PlatformDeviceId; +use crate::window::WindowId as RootWindowId; + +use super::{DeviceId, WindowId}; + +/// An event loop's sink to deliver events from the Wayland event callbacks +/// to the winit's user. +#[derive(Default)] +pub struct EventSink { + pub window_events: Vec>, +} + +impl EventSink { + pub fn new() -> Self { + Default::default() + } + + /// Return `true` if there're pending events. + #[inline] + pub fn is_empty(&self) -> bool { + self.window_events.is_empty() + } + + /// Add new device event to a queue. + #[inline] + pub fn push_device_event(&mut self, event: DeviceEvent, device_id: DeviceId) { + self.window_events.push(Event::DeviceEvent { + event, + device_id: RootDeviceId(PlatformDeviceId::Wayland(device_id)), + }); + } + + /// Add new window event to a queue. + #[inline] + pub fn push_window_event(&mut self, event: WindowEvent, window_id: WindowId) { + self.window_events.push(Event::WindowEvent { event, window_id: RootWindowId(window_id) }); + } + + #[inline] + pub fn append(&mut self, other: &mut Self) { + self.window_events.append(&mut other.window_events); + } + + #[inline] + pub fn drain(&mut self) -> Drain<'_, Event<()>> { + self.window_events.drain(..) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/mod.rs new file mode 100644 index 00000000..63052b78 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/mod.rs @@ -0,0 +1,85 @@ +//! Winit's Wayland backend. + +use std::fmt::Display; +use std::sync::Arc; + +use sctk::reexports::client::globals::{BindError, GlobalError}; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{self, ConnectError, DispatchError, Proxy}; + +pub(super) use crate::cursor::OnlyCursorImage as CustomCursor; +use crate::dpi::{LogicalSize, PhysicalSize}; +pub use crate::platform_impl::platform::{OsError, WindowId}; +pub use event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}; +pub use output::{MonitorHandle, VideoModeHandle}; +pub use window::Window; + +mod event_loop; +mod output; +mod seat; +mod state; +mod types; +mod window; + +#[derive(Debug)] +pub enum WaylandError { + /// Error connecting to the socket. + Connection(ConnectError), + + /// Error binding the global. + Global(GlobalError), + + // Bind error. + Bind(BindError), + + /// Error during the dispatching the event queue. + Dispatch(DispatchError), + + /// Calloop error. + Calloop(calloop::Error), + + /// Wayland + Wire(client::backend::WaylandError), +} + +impl Display for WaylandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WaylandError::Connection(error) => error.fmt(f), + WaylandError::Global(error) => error.fmt(f), + WaylandError::Bind(error) => error.fmt(f), + WaylandError::Dispatch(error) => error.fmt(f), + WaylandError::Calloop(error) => error.fmt(f), + WaylandError::Wire(error) => error.fmt(f), + } + } +} + +impl From for OsError { + fn from(value: WaylandError) -> Self { + Self::WaylandError(Arc::new(value)) + } +} + +/// Dummy device id, since Wayland doesn't have device events. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeviceId; + +impl DeviceId { + pub const fn dummy() -> Self { + DeviceId + } +} + +/// Get the WindowId out of the surface. +#[inline] +fn make_wid(surface: &WlSurface) -> WindowId { + WindowId(surface.id().as_ptr() as u64) +} + +/// The default routine does floor, but we need round on Wayland. +fn logical_to_physical_rounded(size: LogicalSize, scale_factor: f64) -> PhysicalSize { + let width = size.width as f64 * scale_factor; + let height = size.height as f64 * scale_factor; + (width.round(), height.round()).into() +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/output.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/output.rs new file mode 100644 index 00000000..ae6c1b06 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/output.rs @@ -0,0 +1,163 @@ +use sctk::reexports::client::protocol::wl_output::WlOutput; +use sctk::reexports::client::Proxy; + +use sctk::output::OutputData; + +use crate::dpi::{LogicalPosition, PhysicalPosition, PhysicalSize}; +use crate::platform_impl::platform::VideoModeHandle as PlatformVideoModeHandle; + +use super::event_loop::ActiveEventLoop; + +impl ActiveEventLoop { + #[inline] + pub fn available_monitors(&self) -> impl Iterator { + self.state.borrow().output_state.outputs().map(MonitorHandle::new) + } + + #[inline] + pub fn primary_monitor(&self) -> Option { + // There's no primary monitor on Wayland. + None + } +} + +#[derive(Clone, Debug)] +pub struct MonitorHandle { + pub(crate) proxy: WlOutput, +} + +impl MonitorHandle { + #[inline] + pub(crate) fn new(proxy: WlOutput) -> Self { + Self { proxy } + } + + #[inline] + pub fn name(&self) -> Option { + let output_data = self.proxy.data::().unwrap(); + output_data.with_output_info(|info| info.name.clone()) + } + + #[inline] + pub fn native_identifier(&self) -> u32 { + let output_data = self.proxy.data::().unwrap(); + output_data.with_output_info(|info| info.id) + } + + #[inline] + pub fn size(&self) -> PhysicalSize { + let output_data = self.proxy.data::().unwrap(); + let dimensions = output_data.with_output_info(|info| { + info.modes.iter().find_map(|mode| mode.current.then_some(mode.dimensions)) + }); + + match dimensions { + Some((width, height)) => (width as u32, height as u32), + _ => (0, 0), + } + .into() + } + + #[inline] + pub fn position(&self) -> PhysicalPosition { + let output_data = self.proxy.data::().unwrap(); + output_data.with_output_info(|info| { + info.logical_position.map_or_else( + || { + LogicalPosition::::from(info.location) + .to_physical(info.scale_factor as f64) + }, + |logical_position| { + LogicalPosition::::from(logical_position) + .to_physical(info.scale_factor as f64) + }, + ) + }) + } + + #[inline] + pub fn refresh_rate_millihertz(&self) -> Option { + let output_data = self.proxy.data::().unwrap(); + output_data.with_output_info(|info| { + info.modes.iter().find_map(|mode| mode.current.then_some(mode.refresh_rate as u32)) + }) + } + + #[inline] + pub fn scale_factor(&self) -> i32 { + let output_data = self.proxy.data::().unwrap(); + output_data.scale_factor() + } + + #[inline] + pub fn video_modes(&self) -> impl Iterator { + let output_data = self.proxy.data::().unwrap(); + let modes = output_data.with_output_info(|info| info.modes.clone()); + + let monitor = self.clone(); + + modes.into_iter().map(move |mode| { + PlatformVideoModeHandle::Wayland(VideoModeHandle { + size: (mode.dimensions.0 as u32, mode.dimensions.1 as u32).into(), + refresh_rate_millihertz: mode.refresh_rate as u32, + bit_depth: 32, + monitor: monitor.clone(), + }) + }) + } +} + +impl PartialEq for MonitorHandle { + fn eq(&self, other: &Self) -> bool { + self.native_identifier() == other.native_identifier() + } +} + +impl Eq for MonitorHandle {} + +impl PartialOrd for MonitorHandle { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for MonitorHandle { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.native_identifier().cmp(&other.native_identifier()) + } +} + +impl std::hash::Hash for MonitorHandle { + fn hash(&self, state: &mut H) { + self.native_identifier().hash(state); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct VideoModeHandle { + pub(crate) size: PhysicalSize, + pub(crate) bit_depth: u16, + pub(crate) refresh_rate_millihertz: u32, + pub(crate) monitor: MonitorHandle, +} + +impl VideoModeHandle { + #[inline] + pub fn size(&self) -> PhysicalSize { + self.size + } + + #[inline] + pub fn bit_depth(&self) -> u16 { + self.bit_depth + } + + #[inline] + pub fn refresh_rate_millihertz(&self) -> u32 { + self.refresh_rate_millihertz + } + + pub fn monitor(&self) -> MonitorHandle { + self.monitor.clone() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/keyboard/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/keyboard/mod.rs new file mode 100644 index 00000000..f84c3867 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/keyboard/mod.rs @@ -0,0 +1,380 @@ +//! The keyboard input handling. + +use std::sync::Mutex; +use std::time::Duration; + +use calloop::timer::{TimeoutAction, Timer}; +use calloop::{LoopHandle, RegistrationToken}; +use tracing::warn; + +use sctk::reexports::client::protocol::wl_keyboard::{ + Event as WlKeyboardEvent, KeyState as WlKeyState, KeymapFormat as WlKeymapFormat, WlKeyboard, +}; +use sctk::reexports::client::protocol::wl_seat::WlSeat; +use sctk::reexports::client::{Connection, Dispatch, Proxy, QueueHandle, WEnum}; + +use crate::event::{ElementState, WindowEvent}; +use crate::keyboard::ModifiersState; + +use crate::platform_impl::common::xkb::Context; +use crate::platform_impl::wayland::event_loop::sink::EventSink; +use crate::platform_impl::wayland::state::WinitState; +use crate::platform_impl::wayland::{self, DeviceId, WindowId}; + +impl Dispatch for WinitState { + fn event( + state: &mut WinitState, + wl_keyboard: &WlKeyboard, + event: ::Event, + data: &KeyboardData, + _: &Connection, + _: &QueueHandle, + ) { + let seat_state = match state.seats.get_mut(&data.seat.id()) { + Some(seat_state) => seat_state, + None => { + warn!("Received keyboard event {event:?} without seat"); + return; + }, + }; + let keyboard_state = match seat_state.keyboard_state.as_mut() { + Some(keyboard_state) => keyboard_state, + None => { + warn!("Received keyboard event {event:?} without keyboard"); + return; + }, + }; + + match event { + WlKeyboardEvent::Keymap { format, fd, size } => match format { + WEnum::Value(format) => match format { + WlKeymapFormat::NoKeymap => { + warn!("non-xkb compatible keymap") + }, + WlKeymapFormat::XkbV1 => { + let context = &mut keyboard_state.xkb_context; + context.set_keymap_from_fd(fd, size as usize); + }, + _ => unreachable!(), + }, + WEnum::Unknown(value) => { + warn!("unknown keymap format 0x{:x}", value) + }, + }, + WlKeyboardEvent::Enter { surface, .. } => { + let window_id = wayland::make_wid(&surface); + + // Mark the window as focused. + let was_unfocused = match state.windows.get_mut().get(&window_id) { + Some(window) => { + let mut window = window.lock().unwrap(); + let was_unfocused = !window.has_focus(); + window.add_seat_focus(data.seat.id()); + was_unfocused + }, + None => return, + }; + + // Drop the repeat, if there were any. + keyboard_state.current_repeat = None; + if let Some(token) = keyboard_state.repeat_token.take() { + keyboard_state.loop_handle.remove(token); + } + + *data.window_id.lock().unwrap() = Some(window_id); + + // The keyboard focus is considered as general focus. + if was_unfocused { + state.events_sink.push_window_event(WindowEvent::Focused(true), window_id); + } + + // HACK: this is just for GNOME not fixing their ordering issue of modifiers. + if std::mem::take(&mut seat_state.modifiers_pending) { + state.events_sink.push_window_event( + WindowEvent::ModifiersChanged(seat_state.modifiers.into()), + window_id, + ); + } + }, + WlKeyboardEvent::Leave { surface, .. } => { + let window_id = wayland::make_wid(&surface); + + // NOTE: we should drop the repeat regardless whether it was for the present + // window of for the window which just went gone. + keyboard_state.current_repeat = None; + if let Some(token) = keyboard_state.repeat_token.take() { + keyboard_state.loop_handle.remove(token); + } + + // NOTE: The check whether the window exists is essential as we might get a + // nil surface, regardless of what protocol says. + let focused = match state.windows.get_mut().get(&window_id) { + Some(window) => { + let mut window = window.lock().unwrap(); + window.remove_seat_focus(&data.seat.id()); + window.has_focus() + }, + None => return, + }; + + // We don't need to update it above, because the next `Enter` will overwrite + // anyway. + *data.window_id.lock().unwrap() = None; + + if !focused { + // Notify that no modifiers are being pressed. + state.events_sink.push_window_event( + WindowEvent::ModifiersChanged(ModifiersState::empty().into()), + window_id, + ); + + state.events_sink.push_window_event(WindowEvent::Focused(false), window_id); + } + }, + WlKeyboardEvent::Key { key, state: WEnum::Value(WlKeyState::Pressed), .. } => { + let key = key + 8; + + key_input( + keyboard_state, + &mut state.events_sink, + data, + key, + ElementState::Pressed, + false, + ); + + let delay = match keyboard_state.repeat_info { + RepeatInfo::Repeat { delay, .. } => delay, + RepeatInfo::Disable => return, + }; + + if !keyboard_state.xkb_context.keymap_mut().unwrap().key_repeats(key) { + return; + } + + keyboard_state.current_repeat = Some(key); + + // NOTE terminate ongoing timer and start a new timer. + + if let Some(token) = keyboard_state.repeat_token.take() { + keyboard_state.loop_handle.remove(token); + } + + let timer = Timer::from_duration(delay); + let wl_keyboard = wl_keyboard.clone(); + keyboard_state.repeat_token = keyboard_state + .loop_handle + .insert_source(timer, move |_, _, state| { + // Required to handle the wakeups from the repeat sources. + state.dispatched_events = true; + + let data = wl_keyboard.data::().unwrap(); + let seat_state = match state.seats.get_mut(&data.seat.id()) { + Some(seat_state) => seat_state, + None => return TimeoutAction::Drop, + }; + + let keyboard_state = match seat_state.keyboard_state.as_mut() { + Some(keyboard_state) => keyboard_state, + None => return TimeoutAction::Drop, + }; + + // NOTE: The removed on event source is batched, but key change to `None` + // is instant. + let repeat_keycode = match keyboard_state.current_repeat { + Some(repeat_keycode) => repeat_keycode, + None => return TimeoutAction::Drop, + }; + + key_input( + keyboard_state, + &mut state.events_sink, + data, + repeat_keycode, + ElementState::Pressed, + true, + ); + + // NOTE: the gap could change dynamically while repeat is going. + match keyboard_state.repeat_info { + RepeatInfo::Repeat { gap, .. } => TimeoutAction::ToDuration(gap), + RepeatInfo::Disable => TimeoutAction::Drop, + } + }) + .ok(); + }, + WlKeyboardEvent::Key { key, state: WEnum::Value(WlKeyState::Released), .. } => { + let key = key + 8; + + key_input( + keyboard_state, + &mut state.events_sink, + data, + key, + ElementState::Released, + false, + ); + + if keyboard_state.repeat_info != RepeatInfo::Disable + && keyboard_state.xkb_context.keymap_mut().unwrap().key_repeats(key) + && Some(key) == keyboard_state.current_repeat + { + keyboard_state.current_repeat = None; + if let Some(token) = keyboard_state.repeat_token.take() { + keyboard_state.loop_handle.remove(token); + } + } + }, + WlKeyboardEvent::Modifiers { + mods_depressed, mods_latched, mods_locked, group, .. + } => { + let xkb_context = &mut keyboard_state.xkb_context; + let xkb_state = match xkb_context.state_mut() { + Some(state) => state, + None => return, + }; + + xkb_state.update_modifiers(mods_depressed, mods_latched, mods_locked, 0, 0, group); + seat_state.modifiers = xkb_state.modifiers().into(); + + // HACK: part of the workaround from `WlKeyboardEvent::Enter`. + let window_id = match *data.window_id.lock().unwrap() { + Some(window_id) => window_id, + None => { + seat_state.modifiers_pending = true; + return; + }, + }; + + state.events_sink.push_window_event( + WindowEvent::ModifiersChanged(seat_state.modifiers.into()), + window_id, + ); + }, + WlKeyboardEvent::RepeatInfo { rate, delay } => { + keyboard_state.repeat_info = if rate == 0 { + // Stop the repeat once we get a disable event. + keyboard_state.current_repeat = None; + if let Some(repeat_token) = keyboard_state.repeat_token.take() { + keyboard_state.loop_handle.remove(repeat_token); + } + RepeatInfo::Disable + } else { + let gap = Duration::from_micros(1_000_000 / rate as u64); + let delay = Duration::from_millis(delay as u64); + RepeatInfo::Repeat { gap, delay } + }; + }, + _ => unreachable!(), + } + } +} + +/// The state of the keyboard on the current seat. +#[derive(Debug)] +pub struct KeyboardState { + /// The underlying WlKeyboard. + pub keyboard: WlKeyboard, + + /// Loop handle to handle key repeat. + pub loop_handle: LoopHandle<'static, WinitState>, + + /// The state of the keyboard. + pub xkb_context: Context, + + /// The information about the repeat rate obtained from the compositor. + pub repeat_info: RepeatInfo, + + /// The token of the current handle inside the calloop's event loop. + pub repeat_token: Option, + + /// The current repeat raw key. + pub current_repeat: Option, +} + +impl KeyboardState { + pub fn new(keyboard: WlKeyboard, loop_handle: LoopHandle<'static, WinitState>) -> Self { + Self { + keyboard, + loop_handle, + xkb_context: Context::new().unwrap(), + repeat_info: RepeatInfo::default(), + repeat_token: None, + current_repeat: None, + } + } +} + +impl Drop for KeyboardState { + fn drop(&mut self) { + if self.keyboard.version() >= 3 { + self.keyboard.release(); + } + + if let Some(token) = self.repeat_token.take() { + self.loop_handle.remove(token); + } + } +} + +/// The rate at which a pressed key is repeated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepeatInfo { + /// Keys will be repeated at the specified rate and delay. + Repeat { + /// The time between the key repeats. + gap: Duration, + + /// Delay (in milliseconds) between a key press and the start of repetition. + delay: Duration, + }, + + /// Keys should not be repeated. + Disable, +} + +impl Default for RepeatInfo { + /// The default repeat rate is 25 keys per second with the delay of 200ms. + /// + /// The values are picked based on the default in various compositors and Xorg. + fn default() -> Self { + Self::Repeat { gap: Duration::from_millis(40), delay: Duration::from_millis(200) } + } +} + +/// Keyboard user data. +#[derive(Debug)] +pub struct KeyboardData { + /// The currently focused window surface. Could be `None` on bugged compositors, like mutter. + window_id: Mutex>, + + /// The seat used to create this keyboard. + seat: WlSeat, +} + +impl KeyboardData { + pub fn new(seat: WlSeat) -> Self { + Self { window_id: Default::default(), seat } + } +} + +fn key_input( + keyboard_state: &mut KeyboardState, + event_sink: &mut EventSink, + data: &KeyboardData, + keycode: u32, + state: ElementState, + repeat: bool, +) { + let window_id = match *data.window_id.lock().unwrap() { + Some(window_id) => window_id, + None => return, + }; + + let device_id = crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(DeviceId)); + if let Some(mut key_context) = keyboard_state.xkb_context.key_context() { + let event = key_context.process_key_event(keycode, state, repeat); + let event = WindowEvent::KeyboardInput { device_id, event, is_synthetic: false }; + event_sink.push_window_event(event, window_id); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/mod.rs new file mode 100644 index 00000000..eaecd93b --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/mod.rs @@ -0,0 +1,235 @@ +//! Seat handling. + +use std::sync::Arc; + +use ahash::AHashMap; +use tracing::warn; + +use sctk::reexports::client::backend::ObjectId; +use sctk::reexports::client::protocol::wl_seat::WlSeat; +use sctk::reexports::client::protocol::wl_touch::WlTouch; +use sctk::reexports::client::{Connection, Proxy, QueueHandle}; +use sctk::reexports::protocols::wp::relative_pointer::zv1::client::zwp_relative_pointer_v1::ZwpRelativePointerV1; +use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_v3::ZwpTextInputV3; + +use sctk::seat::pointer::{ThemeSpec, ThemedPointer}; +use sctk::seat::{Capability as SeatCapability, SeatHandler, SeatState}; + +use crate::event::WindowEvent; +use crate::keyboard::ModifiersState; +use crate::platform_impl::wayland::state::WinitState; + +mod keyboard; +mod pointer; +mod text_input; +mod touch; + +pub use pointer::relative_pointer::RelativePointerState; +pub use pointer::{PointerConstraintsState, WinitPointerData, WinitPointerDataExt}; +pub use text_input::{TextInputState, ZwpTextInputV3Ext}; + +use keyboard::{KeyboardData, KeyboardState}; +use text_input::TextInputData; +use touch::TouchPoint; + +#[derive(Debug, Default)] +pub struct WinitSeatState { + /// The pointer bound on the seat. + pointer: Option>>, + + /// The touch bound on the seat. + touch: Option, + + /// The mapping from touched points to the surfaces they're present. + touch_map: AHashMap, + + /// The text input bound on the seat. + text_input: Option>, + + /// The relative pointer bound on the seat. + relative_pointer: Option, + + /// The keyboard bound on the seat. + keyboard_state: Option, + + /// The current modifiers state on the seat. + modifiers: ModifiersState, + + /// Whether we have pending modifiers. + modifiers_pending: bool, +} + +impl WinitSeatState { + pub fn new() -> Self { + Default::default() + } +} + +impl SeatHandler for WinitState { + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat_state + } + + fn new_capability( + &mut self, + _: &Connection, + queue_handle: &QueueHandle, + seat: WlSeat, + capability: SeatCapability, + ) { + let seat_state = match self.seats.get_mut(&seat.id()) { + Some(seat_state) => seat_state, + None => { + warn!("Received wl_seat::new_capability for unknown seat"); + return; + }, + }; + + match capability { + SeatCapability::Touch if seat_state.touch.is_none() => { + seat_state.touch = self.seat_state.get_touch(queue_handle, &seat).ok(); + }, + SeatCapability::Keyboard if seat_state.keyboard_state.is_none() => { + let keyboard = seat.get_keyboard(queue_handle, KeyboardData::new(seat.clone())); + seat_state.keyboard_state = + Some(KeyboardState::new(keyboard, self.loop_handle.clone())); + }, + SeatCapability::Pointer if seat_state.pointer.is_none() => { + let surface = self.compositor_state.create_surface(queue_handle); + let viewport = self + .viewporter_state + .as_ref() + .map(|state| state.get_viewport(&surface, queue_handle)); + let surface_id = surface.id(); + let pointer_data = WinitPointerData::new(seat.clone(), viewport); + let themed_pointer = self + .seat_state + .get_pointer_with_theme_and_data( + queue_handle, + &seat, + self.shm.wl_shm(), + surface, + ThemeSpec::System, + pointer_data, + ) + .expect("failed to create pointer with present capability."); + + seat_state.relative_pointer = self.relative_pointer.as_ref().map(|manager| { + manager.get_relative_pointer( + themed_pointer.pointer(), + queue_handle, + sctk::globals::GlobalData, + ) + }); + + let themed_pointer = Arc::new(themed_pointer); + + // Register cursor surface. + self.pointer_surfaces.insert(surface_id, themed_pointer.clone()); + + seat_state.pointer = Some(themed_pointer); + }, + _ => (), + } + + if let Some(text_input_state) = + seat_state.text_input.is_none().then_some(self.text_input_state.as_ref()).flatten() + { + seat_state.text_input = Some(Arc::new(text_input_state.get_text_input( + &seat, + queue_handle, + TextInputData::default(), + ))); + } + } + + fn remove_capability( + &mut self, + _: &Connection, + _queue_handle: &QueueHandle, + seat: WlSeat, + capability: SeatCapability, + ) { + let seat_state = match self.seats.get_mut(&seat.id()) { + Some(seat_state) => seat_state, + None => { + warn!("Received wl_seat::remove_capability for unknown seat"); + return; + }, + }; + + if let Some(text_input) = seat_state.text_input.take() { + text_input.destroy(); + } + + match capability { + SeatCapability::Touch => { + if let Some(touch) = seat_state.touch.take() { + if touch.version() >= 3 { + touch.release(); + } + } + }, + SeatCapability::Pointer => { + if let Some(relative_pointer) = seat_state.relative_pointer.take() { + relative_pointer.destroy(); + } + + if let Some(pointer) = seat_state.pointer.take() { + let pointer_data = pointer.pointer().winit_data(); + + // Remove the cursor from the mapping. + let surface_id = pointer.surface().id(); + let _ = self.pointer_surfaces.remove(&surface_id); + + // Remove the inner locks/confines before dropping the pointer. + pointer_data.unlock_pointer(); + pointer_data.unconfine_pointer(); + + if pointer.pointer().version() >= 3 { + pointer.pointer().release(); + } + } + }, + SeatCapability::Keyboard => { + seat_state.keyboard_state = None; + self.on_keyboard_destroy(&seat.id()); + }, + _ => (), + } + } + + fn new_seat( + &mut self, + _connection: &Connection, + _queue_handle: &QueueHandle, + seat: WlSeat, + ) { + self.seats.insert(seat.id(), WinitSeatState::new()); + } + + fn remove_seat( + &mut self, + _connection: &Connection, + _queue_handle: &QueueHandle, + seat: WlSeat, + ) { + let _ = self.seats.remove(&seat.id()); + self.on_keyboard_destroy(&seat.id()); + } +} + +impl WinitState { + fn on_keyboard_destroy(&mut self, seat: &ObjectId) { + for (window_id, window) in self.windows.get_mut() { + let mut window = window.lock().unwrap(); + let had_focus = window.has_focus(); + window.remove_seat_focus(seat); + if had_focus != window.has_focus() { + self.events_sink.push_window_event(WindowEvent::Focused(false), *window_id); + } + } + } +} + +sctk::delegate_seat!(WinitState); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/pointer/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/pointer/mod.rs new file mode 100644 index 00000000..3dcb00b2 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/pointer/mod.rs @@ -0,0 +1,502 @@ +//! The pointer events. + +use std::ops::Deref; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tracing::warn; + +use sctk::reexports::client::delegate_dispatch; +use sctk::reexports::client::protocol::wl_pointer::WlPointer; +use sctk::reexports::client::protocol::wl_seat::WlSeat; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{Connection, Proxy, QueueHandle, Dispatch}; +use sctk::reexports::protocols::wp::pointer_constraints::zv1::client::zwp_confined_pointer_v1::ZwpConfinedPointerV1; +use sctk::reexports::protocols::wp::pointer_constraints::zv1::client::zwp_locked_pointer_v1::ZwpLockedPointerV1; +use sctk::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1; +use sctk::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1; +use sctk::reexports::protocols::wp::pointer_constraints::zv1::client::zwp_pointer_constraints_v1::{Lifetime, ZwpPointerConstraintsV1}; +use sctk::reexports::client::globals::{BindError, GlobalList}; +use sctk::reexports::csd_frame::FrameClick; +use sctk::reexports::protocols::wp::viewporter::client::wp_viewport::WpViewport; + +use sctk::compositor::SurfaceData; +use sctk::globals::GlobalData; +use sctk::seat::pointer::{ + PointerData, PointerDataExt, PointerEvent, PointerEventKind, PointerHandler, +}; +use sctk::seat::SeatState; + +use crate::dpi::{LogicalPosition, PhysicalPosition}; +use crate::event::{ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent}; + +use crate::platform_impl::wayland::state::WinitState; +use crate::platform_impl::wayland::{self, DeviceId, WindowId}; + +pub mod relative_pointer; + +impl PointerHandler for WinitState { + fn pointer_frame( + &mut self, + connection: &Connection, + _: &QueueHandle, + pointer: &WlPointer, + events: &[PointerEvent], + ) { + let seat = pointer.winit_data().seat(); + let seat_state = match self.seats.get(&seat.id()) { + Some(seat_state) => seat_state, + None => { + warn!("Received pointer event without seat"); + return; + }, + }; + + let themed_pointer = match seat_state.pointer.as_ref() { + Some(pointer) => pointer, + None => { + warn!("Received pointer event without pointer"); + return; + }, + }; + + let device_id = crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(DeviceId)); + + for event in events { + let surface = &event.surface; + + // The parent surface. + let parent_surface = match event.surface.data::() { + Some(data) => data.parent_surface().unwrap_or(surface), + None => continue, + }; + + let window_id = wayland::make_wid(parent_surface); + + // Ensure that window exists. + let mut window = match self.windows.get_mut().get_mut(&window_id) { + Some(window) => window.lock().unwrap(), + None => continue, + }; + + let scale_factor = window.scale_factor(); + let position: PhysicalPosition = + LogicalPosition::new(event.position.0, event.position.1).to_physical(scale_factor); + + match event.kind { + // Pointer movements on decorations. + PointerEventKind::Enter { .. } | PointerEventKind::Motion { .. } + if parent_surface != surface => + { + if let Some(icon) = window.frame_point_moved( + seat, + surface, + Duration::ZERO, + event.position.0, + event.position.1, + ) { + let _ = themed_pointer.set_cursor(connection, icon); + } + }, + PointerEventKind::Leave { .. } if parent_surface != surface => { + window.frame_point_left(); + }, + ref kind @ PointerEventKind::Press { button, serial, time } + | ref kind @ PointerEventKind::Release { button, serial, time } + if parent_surface != surface => + { + let click = match wayland_button_to_winit(button) { + MouseButton::Left => FrameClick::Normal, + MouseButton::Right => FrameClick::Alternate, + _ => continue, + }; + let pressed = matches!(kind, PointerEventKind::Press { .. }); + + // Emulate click on the frame. + window.frame_click( + click, + pressed, + seat, + serial, + Duration::from_millis(time as u64), + window_id, + &mut self.window_compositor_updates, + ); + }, + // Regular events on the main surface. + PointerEventKind::Enter { .. } => { + self.events_sink + .push_window_event(WindowEvent::CursorEntered { device_id }, window_id); + + window.pointer_entered(Arc::downgrade(themed_pointer)); + + // Set the currently focused surface. + pointer.winit_data().inner.lock().unwrap().surface = Some(window_id); + + self.events_sink.push_window_event( + WindowEvent::CursorMoved { device_id, position }, + window_id, + ); + }, + PointerEventKind::Leave { .. } => { + window.pointer_left(Arc::downgrade(themed_pointer)); + + // Remove the active surface. + pointer.winit_data().inner.lock().unwrap().surface = None; + + self.events_sink + .push_window_event(WindowEvent::CursorLeft { device_id }, window_id); + }, + PointerEventKind::Motion { .. } => { + self.events_sink.push_window_event( + WindowEvent::CursorMoved { device_id, position }, + window_id, + ); + }, + ref kind @ PointerEventKind::Press { button, serial, .. } + | ref kind @ PointerEventKind::Release { button, serial, .. } => { + // Update the last button serial. + pointer.winit_data().inner.lock().unwrap().latest_button_serial = serial; + + let button = wayland_button_to_winit(button); + let state = if matches!(kind, PointerEventKind::Press { .. }) { + ElementState::Pressed + } else { + ElementState::Released + }; + self.events_sink.push_window_event( + WindowEvent::MouseInput { device_id, state, button }, + window_id, + ); + }, + PointerEventKind::Axis { horizontal, vertical, .. } => { + // Get the current phase. + let mut pointer_data = pointer.winit_data().inner.lock().unwrap(); + + let has_discrete_scroll = horizontal.discrete != 0 || vertical.discrete != 0; + + // Figure out what to do about start/ended phases here. + // + // Figure out how to deal with `Started`. Also the `Ended` is not guaranteed + // to be sent for mouse wheels. + let phase = if horizontal.stop || vertical.stop { + TouchPhase::Ended + } else { + match pointer_data.phase { + // Discrete scroll only results in moved events. + _ if has_discrete_scroll => TouchPhase::Moved, + TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved, + _ => TouchPhase::Started, + } + }; + + // Update the phase. + pointer_data.phase = phase; + + // Mice events have both pixel and discrete delta's at the same time. So prefer + // the discrete values if they are present. + let delta = if has_discrete_scroll { + // NOTE: Wayland sign convention is the inverse of winit. + MouseScrollDelta::LineDelta( + (-horizontal.discrete) as f32, + (-vertical.discrete) as f32, + ) + } else { + // NOTE: Wayland sign convention is the inverse of winit. + MouseScrollDelta::PixelDelta( + LogicalPosition::new(-horizontal.absolute, -vertical.absolute) + .to_physical(scale_factor), + ) + }; + + self.events_sink.push_window_event( + WindowEvent::MouseWheel { device_id, delta, phase }, + window_id, + ) + }, + } + } + } +} + +#[derive(Debug)] +pub struct WinitPointerData { + /// The inner winit data associated with the pointer. + inner: Mutex, + + /// The data required by the sctk. + sctk_data: PointerData, + + /// Viewport for fractional cursor. + viewport: Option, +} + +impl WinitPointerData { + pub fn new(seat: WlSeat, viewport: Option) -> Self { + Self { + inner: Mutex::new(WinitPointerDataInner::default()), + sctk_data: PointerData::new(seat), + viewport, + } + } + + pub fn lock_pointer( + &self, + pointer_constraints: &PointerConstraintsState, + surface: &WlSurface, + pointer: &WlPointer, + queue_handle: &QueueHandle, + ) { + let mut inner = self.inner.lock().unwrap(); + if inner.locked_pointer.is_none() { + inner.locked_pointer = Some(pointer_constraints.lock_pointer( + surface, + pointer, + None, + Lifetime::Persistent, + queue_handle, + GlobalData, + )); + } + } + + pub fn unlock_pointer(&self) { + let mut inner = self.inner.lock().unwrap(); + if let Some(locked_pointer) = inner.locked_pointer.take() { + locked_pointer.destroy(); + } + } + + pub fn confine_pointer( + &self, + pointer_constraints: &PointerConstraintsState, + surface: &WlSurface, + pointer: &WlPointer, + queue_handle: &QueueHandle, + ) { + self.inner.lock().unwrap().confined_pointer = Some(pointer_constraints.confine_pointer( + surface, + pointer, + None, + Lifetime::Persistent, + queue_handle, + GlobalData, + )); + } + + pub fn unconfine_pointer(&self) { + let inner = self.inner.lock().unwrap(); + if let Some(confined_pointer) = inner.confined_pointer.as_ref() { + confined_pointer.destroy(); + } + } + + /// Seat associated with this pointer. + pub fn seat(&self) -> &WlSeat { + self.sctk_data.seat() + } + + /// Active window. + pub fn focused_window(&self) -> Option { + self.inner.lock().unwrap().surface + } + + /// Last button serial. + pub fn latest_button_serial(&self) -> u32 { + self.sctk_data.latest_button_serial().unwrap_or_default() + } + + /// Last enter serial. + pub fn latest_enter_serial(&self) -> u32 { + self.sctk_data.latest_enter_serial().unwrap_or_default() + } + + pub fn set_locked_cursor_position(&self, surface_x: f64, surface_y: f64) { + let inner = self.inner.lock().unwrap(); + if let Some(locked_pointer) = inner.locked_pointer.as_ref() { + locked_pointer.set_cursor_position_hint(surface_x, surface_y); + } + } + + pub fn viewport(&self) -> Option<&WpViewport> { + self.viewport.as_ref() + } +} + +impl Drop for WinitPointerData { + fn drop(&mut self) { + if let Some(viewport) = self.viewport.take() { + viewport.destroy(); + } + } +} + +impl PointerDataExt for WinitPointerData { + fn pointer_data(&self) -> &PointerData { + &self.sctk_data + } +} + +#[derive(Debug)] +pub struct WinitPointerDataInner { + /// The associated locked pointer. + locked_pointer: Option, + + /// The associated confined pointer. + confined_pointer: Option, + + /// Serial of the last button event. + latest_button_serial: u32, + + /// Currently focused window. + surface: Option, + + /// Current axis phase. + phase: TouchPhase, +} + +impl Drop for WinitPointerDataInner { + fn drop(&mut self) { + if let Some(locked_pointer) = self.locked_pointer.take() { + locked_pointer.destroy(); + } + + if let Some(confined_pointer) = self.confined_pointer.take() { + confined_pointer.destroy(); + } + } +} + +impl Default for WinitPointerDataInner { + fn default() -> Self { + Self { + surface: None, + locked_pointer: None, + confined_pointer: None, + latest_button_serial: 0, + phase: TouchPhase::Ended, + } + } +} + +/// Convert the Wayland button into winit. +fn wayland_button_to_winit(button: u32) -> MouseButton { + // These values are coming from . + const BTN_LEFT: u32 = 0x110; + const BTN_RIGHT: u32 = 0x111; + const BTN_MIDDLE: u32 = 0x112; + const BTN_SIDE: u32 = 0x113; + const BTN_EXTRA: u32 = 0x114; + const BTN_FORWARD: u32 = 0x115; + const BTN_BACK: u32 = 0x116; + + match button { + BTN_LEFT => MouseButton::Left, + BTN_RIGHT => MouseButton::Right, + BTN_MIDDLE => MouseButton::Middle, + BTN_BACK | BTN_SIDE => MouseButton::Back, + BTN_FORWARD | BTN_EXTRA => MouseButton::Forward, + button => MouseButton::Other(button as u16), + } +} + +pub trait WinitPointerDataExt { + fn winit_data(&self) -> &WinitPointerData; +} + +impl WinitPointerDataExt for WlPointer { + fn winit_data(&self) -> &WinitPointerData { + self.data::().expect("failed to get pointer data.") + } +} + +pub struct PointerConstraintsState { + pointer_constraints: ZwpPointerConstraintsV1, +} + +impl PointerConstraintsState { + pub fn new( + globals: &GlobalList, + queue_handle: &QueueHandle, + ) -> Result { + let pointer_constraints = globals.bind(queue_handle, 1..=1, GlobalData)?; + Ok(Self { pointer_constraints }) + } +} + +impl Deref for PointerConstraintsState { + type Target = ZwpPointerConstraintsV1; + + fn deref(&self) -> &Self::Target { + &self.pointer_constraints + } +} + +impl Dispatch for PointerConstraintsState { + fn event( + _state: &mut WinitState, + _proxy: &ZwpPointerConstraintsV1, + _event: ::Event, + _data: &GlobalData, + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + } +} + +impl Dispatch for PointerConstraintsState { + fn event( + _state: &mut WinitState, + _proxy: &ZwpLockedPointerV1, + _event: ::Event, + _data: &GlobalData, + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + } +} + +impl Dispatch for PointerConstraintsState { + fn event( + _state: &mut WinitState, + _proxy: &ZwpConfinedPointerV1, + _event: ::Event, + _data: &GlobalData, + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + } +} + +impl Dispatch for SeatState { + fn event( + _: &mut WinitState, + _: &WpCursorShapeDeviceV1, + _: ::Event, + _: &GlobalData, + _: &Connection, + _: &QueueHandle, + ) { + unreachable!("wp_cursor_shape_manager has no events") + } +} + +impl Dispatch for SeatState { + fn event( + _: &mut WinitState, + _: &WpCursorShapeManagerV1, + _: ::Event, + _: &GlobalData, + _: &Connection, + _: &QueueHandle, + ) { + unreachable!("wp_cursor_device_manager has no events") + } +} + +delegate_dispatch!(WinitState: [ WlPointer: WinitPointerData] => SeatState); +delegate_dispatch!(WinitState: [ WpCursorShapeManagerV1: GlobalData] => SeatState); +delegate_dispatch!(WinitState: [ WpCursorShapeDeviceV1: GlobalData] => SeatState); +delegate_dispatch!(WinitState: [ZwpPointerConstraintsV1: GlobalData] => PointerConstraintsState); +delegate_dispatch!(WinitState: [ZwpLockedPointerV1: GlobalData] => PointerConstraintsState); +delegate_dispatch!(WinitState: [ZwpConfinedPointerV1: GlobalData] => PointerConstraintsState); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/pointer/relative_pointer.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/pointer/relative_pointer.rs new file mode 100644 index 00000000..a9ce2760 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/pointer/relative_pointer.rs @@ -0,0 +1,83 @@ +//! Relative pointer. + +use std::ops::Deref; + +use sctk::reexports::client::globals::{BindError, GlobalList}; +use sctk::reexports::client::{delegate_dispatch, Dispatch}; +use sctk::reexports::client::{Connection, QueueHandle}; +use sctk::reexports::protocols::wp::relative_pointer::zv1::{ + client::zwp_relative_pointer_manager_v1::ZwpRelativePointerManagerV1, + client::zwp_relative_pointer_v1::{self, ZwpRelativePointerV1}, +}; + +use sctk::globals::GlobalData; + +use crate::event::DeviceEvent; +use crate::platform_impl::wayland::state::WinitState; + +/// Wrapper around the relative pointer. +pub struct RelativePointerState { + manager: ZwpRelativePointerManagerV1, +} + +impl RelativePointerState { + /// Create new relative pointer manager. + pub fn new( + globals: &GlobalList, + queue_handle: &QueueHandle, + ) -> Result { + let manager = globals.bind(queue_handle, 1..=1, GlobalData)?; + Ok(Self { manager }) + } +} + +impl Deref for RelativePointerState { + type Target = ZwpRelativePointerManagerV1; + + fn deref(&self) -> &Self::Target { + &self.manager + } +} + +impl Dispatch for RelativePointerState { + fn event( + _state: &mut WinitState, + _proxy: &ZwpRelativePointerManagerV1, + _event: ::Event, + _data: &GlobalData, + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + } +} + +impl Dispatch for RelativePointerState { + fn event( + state: &mut WinitState, + _proxy: &ZwpRelativePointerV1, + event: ::Event, + _data: &GlobalData, + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + let (dx_unaccel, dy_unaccel) = match event { + zwp_relative_pointer_v1::Event::RelativeMotion { dx_unaccel, dy_unaccel, .. } => { + (dx_unaccel, dy_unaccel) + }, + _ => return, + }; + state + .events_sink + .push_device_event(DeviceEvent::Motion { axis: 0, value: dx_unaccel }, super::DeviceId); + state + .events_sink + .push_device_event(DeviceEvent::Motion { axis: 1, value: dy_unaccel }, super::DeviceId); + state.events_sink.push_device_event( + DeviceEvent::MouseMotion { delta: (dx_unaccel, dy_unaccel) }, + super::DeviceId, + ); + } +} + +delegate_dispatch!(WinitState: [ZwpRelativePointerV1: GlobalData] => RelativePointerState); +delegate_dispatch!(WinitState: [ZwpRelativePointerManagerV1: GlobalData] => RelativePointerState); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/text_input/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/text_input/mod.rs new file mode 100644 index 00000000..db724893 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/text_input/mod.rs @@ -0,0 +1,201 @@ +use std::ops::Deref; + +use sctk::globals::GlobalData; +use sctk::reexports::client::{Connection, Proxy, QueueHandle}; + +use sctk::reexports::client::globals::{BindError, GlobalList}; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{delegate_dispatch, Dispatch}; +use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_manager_v3::ZwpTextInputManagerV3; +use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_v3::{ + ContentHint, ContentPurpose, Event as TextInputEvent, ZwpTextInputV3, +}; + +use crate::event::{Ime, WindowEvent}; +use crate::platform_impl::wayland; +use crate::platform_impl::wayland::state::WinitState; +use crate::window::ImePurpose; + +pub struct TextInputState { + text_input_manager: ZwpTextInputManagerV3, +} + +impl TextInputState { + pub fn new( + globals: &GlobalList, + queue_handle: &QueueHandle, + ) -> Result { + let text_input_manager = globals.bind(queue_handle, 1..=1, GlobalData)?; + Ok(Self { text_input_manager }) + } +} + +impl Deref for TextInputState { + type Target = ZwpTextInputManagerV3; + + fn deref(&self) -> &Self::Target { + &self.text_input_manager + } +} + +impl Dispatch for TextInputState { + fn event( + _state: &mut WinitState, + _proxy: &ZwpTextInputManagerV3, + _event: ::Event, + _data: &GlobalData, + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + } +} + +impl Dispatch for TextInputState { + fn event( + state: &mut WinitState, + text_input: &ZwpTextInputV3, + event: ::Event, + data: &TextInputData, + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + let windows = state.windows.get_mut(); + let mut text_input_data = data.inner.lock().unwrap(); + match event { + TextInputEvent::Enter { surface } => { + let window_id = wayland::make_wid(&surface); + text_input_data.surface = Some(surface); + + let mut window = match windows.get(&window_id) { + Some(window) => window.lock().unwrap(), + None => return, + }; + + if window.ime_allowed() { + text_input.enable(); + text_input.set_content_type_by_purpose(window.ime_purpose()); + text_input.commit(); + state.events_sink.push_window_event(WindowEvent::Ime(Ime::Enabled), window_id); + } + + window.text_input_entered(text_input); + }, + TextInputEvent::Leave { surface } => { + text_input_data.surface = None; + + // Always issue a disable. + text_input.disable(); + text_input.commit(); + + let window_id = wayland::make_wid(&surface); + + // XXX this check is essential, because `leave` could have a + // reference to nil surface... + let mut window = match windows.get(&window_id) { + Some(window) => window.lock().unwrap(), + None => return, + }; + + window.text_input_left(text_input); + + state.events_sink.push_window_event(WindowEvent::Ime(Ime::Disabled), window_id); + }, + TextInputEvent::PreeditString { text, cursor_begin, cursor_end } => { + let text = text.unwrap_or_default(); + let cursor_begin = usize::try_from(cursor_begin) + .ok() + .and_then(|idx| text.is_char_boundary(idx).then_some(idx)); + let cursor_end = usize::try_from(cursor_end) + .ok() + .and_then(|idx| text.is_char_boundary(idx).then_some(idx)); + + text_input_data.pending_preedit = Some(Preedit { text, cursor_begin, cursor_end }) + }, + TextInputEvent::CommitString { text } => { + text_input_data.pending_preedit = None; + text_input_data.pending_commit = text; + }, + TextInputEvent::Done { .. } => { + let window_id = match text_input_data.surface.as_ref() { + Some(surface) => wayland::make_wid(surface), + None => return, + }; + + // Clear preedit, unless all we'll be doing next is sending a new preedit. + if text_input_data.pending_commit.is_some() + || text_input_data.pending_preedit.is_none() + { + state.events_sink.push_window_event( + WindowEvent::Ime(Ime::Preedit(String::new(), None)), + window_id, + ); + } + + // Send `Commit`. + if let Some(text) = text_input_data.pending_commit.take() { + state + .events_sink + .push_window_event(WindowEvent::Ime(Ime::Commit(text)), window_id); + } + + // Send preedit. + if let Some(preedit) = text_input_data.pending_preedit.take() { + let cursor_range = + preedit.cursor_begin.map(|b| (b, preedit.cursor_end.unwrap_or(b))); + + state.events_sink.push_window_event( + WindowEvent::Ime(Ime::Preedit(preedit.text, cursor_range)), + window_id, + ); + } + }, + TextInputEvent::DeleteSurroundingText { .. } => { + // Not handled. + }, + _ => {}, + } + } +} + +pub trait ZwpTextInputV3Ext { + fn set_content_type_by_purpose(&self, purpose: ImePurpose); +} + +impl ZwpTextInputV3Ext for ZwpTextInputV3 { + fn set_content_type_by_purpose(&self, purpose: ImePurpose) { + let (hint, purpose) = match purpose { + ImePurpose::Normal => (ContentHint::None, ContentPurpose::Normal), + ImePurpose::Password => (ContentHint::SensitiveData, ContentPurpose::Password), + ImePurpose::Terminal => (ContentHint::None, ContentPurpose::Terminal), + }; + self.set_content_type(hint, purpose); + } +} + +/// The Data associated with the text input. +#[derive(Default)] +pub struct TextInputData { + inner: std::sync::Mutex, +} + +#[derive(Default)] +pub struct TextInputDataInner { + /// The `WlSurface` we're performing input to. + surface: Option, + + /// The commit to submit on `done`. + pending_commit: Option, + + /// The preedit to submit on `done`. + pending_preedit: Option, +} + +/// The state of the preedit. +struct Preedit { + text: String, + cursor_begin: Option, + cursor_end: Option, +} + +delegate_dispatch!(WinitState: [ZwpTextInputManagerV3: GlobalData] => TextInputState); +delegate_dispatch!(WinitState: [ZwpTextInputV3: TextInputData] => TextInputState); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/touch/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/touch/mod.rs new file mode 100644 index 00000000..124504fe --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/seat/touch/mod.rs @@ -0,0 +1,220 @@ +//! Touch handling. + +use tracing::warn; + +use sctk::reexports::client::protocol::wl_seat::WlSeat; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::protocol::wl_touch::WlTouch; +use sctk::reexports::client::{Connection, Proxy, QueueHandle}; + +use sctk::seat::touch::{TouchData, TouchHandler}; + +use crate::dpi::LogicalPosition; +use crate::event::{Touch, TouchPhase, WindowEvent}; + +use crate::platform_impl::wayland::state::WinitState; +use crate::platform_impl::wayland::{self, DeviceId}; + +impl TouchHandler for WinitState { + fn down( + &mut self, + _: &Connection, + _: &QueueHandle, + touch: &WlTouch, + _: u32, + _: u32, + surface: WlSurface, + id: i32, + position: (f64, f64), + ) { + let window_id = wayland::make_wid(&surface); + let scale_factor = match self.windows.get_mut().get(&window_id) { + Some(window) => window.lock().unwrap().scale_factor(), + None => return, + }; + + let seat_state = match self.seats.get_mut(&touch.seat().id()) { + Some(seat_state) => seat_state, + None => { + warn!("Received wl_touch::down without seat"); + return; + }, + }; + + // Update the state of the point. + let location = LogicalPosition::::from(position); + seat_state.touch_map.insert(id, TouchPoint { surface, location }); + + self.events_sink.push_window_event( + WindowEvent::Touch(Touch { + device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland( + DeviceId, + )), + phase: TouchPhase::Started, + location: location.to_physical(scale_factor), + force: None, + id: id as u64, + }), + window_id, + ); + } + + fn up( + &mut self, + _: &Connection, + _: &QueueHandle, + touch: &WlTouch, + _: u32, + _: u32, + id: i32, + ) { + let seat_state = match self.seats.get_mut(&touch.seat().id()) { + Some(seat_state) => seat_state, + None => { + warn!("Received wl_touch::up without seat"); + return; + }, + }; + + // Remove the touch point. + let touch_point = match seat_state.touch_map.remove(&id) { + Some(touch_point) => touch_point, + None => return, + }; + + let window_id = wayland::make_wid(&touch_point.surface); + let scale_factor = match self.windows.get_mut().get(&window_id) { + Some(window) => window.lock().unwrap().scale_factor(), + None => return, + }; + + self.events_sink.push_window_event( + WindowEvent::Touch(Touch { + device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland( + DeviceId, + )), + phase: TouchPhase::Ended, + location: touch_point.location.to_physical(scale_factor), + force: None, + id: id as u64, + }), + window_id, + ); + } + + fn motion( + &mut self, + _: &Connection, + _: &QueueHandle, + touch: &WlTouch, + _: u32, + id: i32, + position: (f64, f64), + ) { + let seat_state = match self.seats.get_mut(&touch.seat().id()) { + Some(seat_state) => seat_state, + None => { + warn!("Received wl_touch::motion without seat"); + return; + }, + }; + + // Remove the touch point. + let touch_point = match seat_state.touch_map.get_mut(&id) { + Some(touch_point) => touch_point, + None => return, + }; + + let window_id = wayland::make_wid(&touch_point.surface); + let scale_factor = match self.windows.get_mut().get(&window_id) { + Some(window) => window.lock().unwrap().scale_factor(), + None => return, + }; + + touch_point.location = LogicalPosition::::from(position); + + self.events_sink.push_window_event( + WindowEvent::Touch(Touch { + device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland( + DeviceId, + )), + phase: TouchPhase::Moved, + location: touch_point.location.to_physical(scale_factor), + force: None, + id: id as u64, + }), + window_id, + ); + } + + fn cancel(&mut self, _: &Connection, _: &QueueHandle, touch: &WlTouch) { + let seat_state = match self.seats.get_mut(&touch.seat().id()) { + Some(seat_state) => seat_state, + None => { + warn!("Received wl_touch::cancel without seat"); + return; + }, + }; + + for (id, touch_point) in seat_state.touch_map.drain() { + let window_id = wayland::make_wid(&touch_point.surface); + let scale_factor = match self.windows.get_mut().get(&window_id) { + Some(window) => window.lock().unwrap().scale_factor(), + None => return, + }; + + let location = touch_point.location.to_physical(scale_factor); + + self.events_sink.push_window_event( + WindowEvent::Touch(Touch { + device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland( + DeviceId, + )), + phase: TouchPhase::Cancelled, + location, + force: None, + id: id as u64, + }), + window_id, + ); + } + } + + fn shape( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &WlTouch, + _: i32, + _: f64, + _: f64, + ) { + // Blank. + } + + fn orientation(&mut self, _: &Connection, _: &QueueHandle, _: &WlTouch, _: i32, _: f64) { + // Blank. + } +} + +/// The state of the touch point. +#[derive(Debug)] +pub struct TouchPoint { + /// The surface on which the point is present. + pub surface: WlSurface, + + /// The location of the point on the surface. + pub location: LogicalPosition, +} + +pub trait TouchDataExt { + fn seat(&self) -> &WlSeat; +} + +impl TouchDataExt for WlTouch { + fn seat(&self) -> &WlSeat { + self.data::().expect("failed to get touch data.").seat() + } +} + +sctk::delegate_touch!(WinitState); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/state.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/state.rs new file mode 100644 index 00000000..13ef99c2 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/state.rs @@ -0,0 +1,435 @@ +use std::cell::RefCell; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; + +use ahash::AHashMap; + +use sctk::reexports::calloop::LoopHandle; +use sctk::reexports::client::backend::ObjectId; +use sctk::reexports::client::globals::GlobalList; +use sctk::reexports::client::protocol::wl_output::WlOutput; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{Connection, Proxy, QueueHandle}; + +use sctk::compositor::{CompositorHandler, CompositorState}; +use sctk::output::{OutputHandler, OutputState}; +use sctk::registry::{ProvidesRegistryState, RegistryState}; +use sctk::seat::pointer::ThemedPointer; +use sctk::seat::SeatState; +use sctk::shell::xdg::window::{Window, WindowConfigure, WindowHandler}; +use sctk::shell::xdg::XdgShell; +use sctk::shell::WaylandSurface; +use sctk::shm::slot::SlotPool; +use sctk::shm::{Shm, ShmHandler}; +use sctk::subcompositor::SubcompositorState; + +use crate::platform_impl::wayland::event_loop::sink::EventSink; +use crate::platform_impl::wayland::output::MonitorHandle; +use crate::platform_impl::wayland::seat::{ + PointerConstraintsState, RelativePointerState, TextInputState, WinitPointerData, + WinitPointerDataExt, WinitSeatState, +}; +use crate::platform_impl::wayland::types::kwin_blur::KWinBlurManager; +use crate::platform_impl::wayland::types::wp_fractional_scaling::FractionalScalingManager; +use crate::platform_impl::wayland::types::wp_viewporter::ViewporterState; +use crate::platform_impl::wayland::types::xdg_activation::XdgActivationState; +use crate::platform_impl::wayland::window::{WindowRequests, WindowState}; +use crate::platform_impl::wayland::{WaylandError, WindowId}; +use crate::platform_impl::OsError; + +/// Winit's Wayland state. +pub struct WinitState { + /// The WlRegistry. + pub registry_state: RegistryState, + + /// The state of the WlOutput handling. + pub output_state: OutputState, + + /// The compositor state which is used to create new windows and regions. + pub compositor_state: Arc, + + /// The state of the subcompositor. + pub subcompositor_state: Option>, + + /// The seat state responsible for all sorts of input. + pub seat_state: SeatState, + + /// The shm for software buffers, such as cursors. + pub shm: Shm, + + /// The pool where custom cursors are allocated. + pub custom_cursor_pool: Arc>, + + /// The XDG shell that is used for windows. + pub xdg_shell: XdgShell, + + /// The currently present windows. + pub windows: RefCell>>>, + + /// The requests from the `Window` to EventLoop, such as close operations and redraw requests. + pub window_requests: RefCell>>, + + /// The events that were generated directly from the window. + pub window_events_sink: Arc>, + + /// The update for the `windows` coming from the compositor. + pub window_compositor_updates: Vec, + + /// Currently handled seats. + pub seats: AHashMap, + + /// Currently present cursor surfaces. + pub pointer_surfaces: AHashMap>>, + + /// The state of the text input on the client. + pub text_input_state: Option, + + /// Observed monitors. + pub monitors: Arc>>, + + /// Sink to accumulate window events from the compositor, which is latter dispatched in + /// event loop run. + pub events_sink: EventSink, + + /// Xdg activation. + pub xdg_activation: Option, + + /// Relative pointer. + pub relative_pointer: Option, + + /// Pointer constraints to handle pointer locking and confining. + pub pointer_constraints: Option>, + + /// Viewporter state on the given window. + pub viewporter_state: Option, + + /// Fractional scaling manager. + pub fractional_scaling_manager: Option, + + /// KWin blur manager. + pub kwin_blur_manager: Option, + + /// Loop handle to re-register event sources, such as keyboard repeat. + pub loop_handle: LoopHandle<'static, Self>, + + /// Whether we have dispatched events to the user thus we want to + /// send `AboutToWait` and normally wakeup the user. + pub dispatched_events: bool, +} + +impl WinitState { + pub fn new( + globals: &GlobalList, + queue_handle: &QueueHandle, + loop_handle: LoopHandle<'static, WinitState>, + ) -> Result { + let registry_state = RegistryState::new(globals); + let compositor_state = + CompositorState::bind(globals, queue_handle).map_err(WaylandError::Bind)?; + let subcompositor_state = match SubcompositorState::bind( + compositor_state.wl_compositor().clone(), + globals, + queue_handle, + ) { + Ok(c) => Some(c), + Err(e) => { + tracing::warn!("Subcompositor protocol not available, ignoring CSD: {e:?}"); + None + }, + }; + + let output_state = OutputState::new(globals, queue_handle); + let monitors = output_state.outputs().map(MonitorHandle::new).collect(); + + let seat_state = SeatState::new(globals, queue_handle); + + let mut seats = AHashMap::default(); + for seat in seat_state.seats() { + seats.insert(seat.id(), WinitSeatState::new()); + } + + let (viewporter_state, fractional_scaling_manager) = + if let Ok(fsm) = FractionalScalingManager::new(globals, queue_handle) { + (ViewporterState::new(globals, queue_handle).ok(), Some(fsm)) + } else { + (None, None) + }; + + let shm = Shm::bind(globals, queue_handle).map_err(WaylandError::Bind)?; + let custom_cursor_pool = Arc::new(Mutex::new(SlotPool::new(2, &shm).unwrap())); + + Ok(Self { + registry_state, + compositor_state: Arc::new(compositor_state), + subcompositor_state: subcompositor_state.map(Arc::new), + output_state, + seat_state, + shm, + custom_cursor_pool, + + xdg_shell: XdgShell::bind(globals, queue_handle).map_err(WaylandError::Bind)?, + xdg_activation: XdgActivationState::bind(globals, queue_handle).ok(), + + windows: Default::default(), + window_requests: Default::default(), + window_compositor_updates: Vec::new(), + window_events_sink: Default::default(), + viewporter_state, + fractional_scaling_manager, + kwin_blur_manager: KWinBlurManager::new(globals, queue_handle).ok(), + + seats, + text_input_state: TextInputState::new(globals, queue_handle).ok(), + + relative_pointer: RelativePointerState::new(globals, queue_handle).ok(), + pointer_constraints: PointerConstraintsState::new(globals, queue_handle) + .map(Arc::new) + .ok(), + pointer_surfaces: Default::default(), + + monitors: Arc::new(Mutex::new(monitors)), + events_sink: EventSink::new(), + loop_handle, + // Make it true by default. + dispatched_events: true, + }) + } + + pub fn scale_factor_changed( + &mut self, + surface: &WlSurface, + scale_factor: f64, + is_legacy: bool, + ) { + // Check if the cursor surface. + let window_id = super::make_wid(surface); + + if let Some(window) = self.windows.get_mut().get(&window_id) { + // Don't update the scaling factor, when legacy method is used. + if is_legacy && self.fractional_scaling_manager.is_some() { + return; + } + + // The scale factor change is for the window. + let pos = if let Some(pos) = self + .window_compositor_updates + .iter() + .position(|update| update.window_id == window_id) + { + pos + } else { + self.window_compositor_updates.push(WindowCompositorUpdate::new(window_id)); + self.window_compositor_updates.len() - 1 + }; + + // Update the scale factor right away. + window.lock().unwrap().set_scale_factor(scale_factor); + self.window_compositor_updates[pos].scale_changed = true; + } else if let Some(pointer) = self.pointer_surfaces.get(&surface.id()) { + // Get the window, where the pointer resides right now. + let focused_window = match pointer.pointer().winit_data().focused_window() { + Some(focused_window) => focused_window, + None => return, + }; + + if let Some(window_state) = self.windows.get_mut().get(&focused_window) { + window_state.lock().unwrap().reload_cursor_style() + } + } + } + + pub fn queue_close(updates: &mut Vec, window_id: WindowId) { + let pos = if let Some(pos) = updates.iter().position(|update| update.window_id == window_id) + { + pos + } else { + updates.push(WindowCompositorUpdate::new(window_id)); + updates.len() - 1 + }; + + updates[pos].close_window = true; + } +} + +impl ShmHandler for WinitState { + fn shm_state(&mut self) -> &mut Shm { + &mut self.shm + } +} + +impl WindowHandler for WinitState { + fn request_close(&mut self, _: &Connection, _: &QueueHandle, window: &Window) { + let window_id = super::make_wid(window.wl_surface()); + Self::queue_close(&mut self.window_compositor_updates, window_id); + } + + fn configure( + &mut self, + _: &Connection, + _: &QueueHandle, + window: &Window, + configure: WindowConfigure, + _serial: u32, + ) { + let window_id = super::make_wid(window.wl_surface()); + + let pos = if let Some(pos) = + self.window_compositor_updates.iter().position(|update| update.window_id == window_id) + { + pos + } else { + self.window_compositor_updates.push(WindowCompositorUpdate::new(window_id)); + self.window_compositor_updates.len() - 1 + }; + + // Populate the configure to the window. + self.window_compositor_updates[pos].resized |= self + .windows + .get_mut() + .get_mut(&window_id) + .expect("got configure for dead window.") + .lock() + .unwrap() + .configure(configure, &self.shm, &self.subcompositor_state); + + // NOTE: configure demands wl_surface::commit, however winit doesn't commit on behalf of the + // users, since it can break a lot of things, thus it'll ask users to redraw instead. + self.window_requests + .get_mut() + .get(&window_id) + .unwrap() + .redraw_requested + .store(true, Ordering::Relaxed); + + // Manually mark that we've got an event, since configure may not generate a resize. + self.dispatched_events = true; + } +} + +impl OutputHandler for WinitState { + fn output_state(&mut self) -> &mut OutputState { + &mut self.output_state + } + + fn new_output(&mut self, _: &Connection, _: &QueueHandle, output: WlOutput) { + self.monitors.lock().unwrap().push(MonitorHandle::new(output)); + } + + fn update_output(&mut self, _: &Connection, _: &QueueHandle, updated: WlOutput) { + let mut monitors = self.monitors.lock().unwrap(); + let updated = MonitorHandle::new(updated); + if let Some(pos) = monitors.iter().position(|output| output == &updated) { + monitors[pos] = updated + } else { + monitors.push(updated) + } + } + + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, removed: WlOutput) { + let mut monitors = self.monitors.lock().unwrap(); + let removed = MonitorHandle::new(removed); + if let Some(pos) = monitors.iter().position(|output| output == &removed) { + monitors.remove(pos); + } + } +} + +impl CompositorHandler for WinitState { + fn transform_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &WlSurface, + _: wayland_client::protocol::wl_output::Transform, + ) { + // TODO(kchibisov) we need to expose it somehow in winit. + } + + fn surface_enter( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &WlSurface, + _: &WlOutput, + ) { + } + + fn surface_leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &WlSurface, + _: &WlOutput, + ) { + } + + fn scale_factor_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + surface: &WlSurface, + scale_factor: i32, + ) { + self.scale_factor_changed(surface, scale_factor as f64, true) + } + + fn frame(&mut self, _: &Connection, _: &QueueHandle, surface: &WlSurface, _: u32) { + let window_id = super::make_wid(surface); + let window = match self.windows.get_mut().get(&window_id) { + Some(window) => window, + None => return, + }; + + // In case we have a redraw requested we must indicate the wake up. + if self + .window_requests + .get_mut() + .get(&window_id) + .unwrap() + .redraw_requested + .load(Ordering::Relaxed) + { + self.dispatched_events = true; + } + + window.lock().unwrap().frame_callback_received(); + } +} + +impl ProvidesRegistryState for WinitState { + sctk::registry_handlers![OutputState, SeatState]; + + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry_state + } +} + +// The window update coming from the compositor. +#[derive(Debug, Clone, Copy)] +pub struct WindowCompositorUpdate { + /// The id of the window this updates belongs to. + pub window_id: WindowId, + + /// New window size. + pub resized: bool, + + /// New scale factor. + pub scale_changed: bool, + + /// Close the window. + pub close_window: bool, +} + +impl WindowCompositorUpdate { + fn new(window_id: WindowId) -> Self { + Self { window_id, resized: false, scale_changed: false, close_window: false } + } +} + +sctk::delegate_subcompositor!(WinitState); +sctk::delegate_compositor!(WinitState); +sctk::delegate_output!(WinitState); +sctk::delegate_registry!(WinitState); +sctk::delegate_shm!(WinitState); +sctk::delegate_xdg_shell!(WinitState); +sctk::delegate_xdg_window!(WinitState); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/cursor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/cursor.rs new file mode 100644 index 00000000..c1a0c261 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/cursor.rs @@ -0,0 +1,59 @@ +use cursor_icon::CursorIcon; + +use sctk::reexports::client::protocol::wl_shm::Format; +use sctk::shm::slot::{Buffer, SlotPool}; + +use crate::cursor::CursorImage; + +#[derive(Debug)] +pub enum SelectedCursor { + Named(CursorIcon), + Custom(CustomCursor), +} + +impl Default for SelectedCursor { + fn default() -> Self { + Self::Named(Default::default()) + } +} + +#[derive(Debug)] +pub struct CustomCursor { + pub buffer: Buffer, + pub w: i32, + pub h: i32, + pub hotspot_x: i32, + pub hotspot_y: i32, +} + +impl CustomCursor { + pub(crate) fn new(pool: &mut SlotPool, image: &CursorImage) -> Self { + let (buffer, canvas) = pool + .create_buffer( + image.width as i32, + image.height as i32, + 4 * (image.width as i32), + Format::Argb8888, + ) + .unwrap(); + + for (canvas_chunk, rgba) in canvas.chunks_exact_mut(4).zip(image.rgba.chunks_exact(4)) { + // Alpha in buffer is premultiplied. + let alpha = rgba[3] as f32 / 255.; + let r = (rgba[0] as f32 * alpha) as u32; + let g = (rgba[1] as f32 * alpha) as u32; + let b = (rgba[2] as f32 * alpha) as u32; + let color = ((rgba[3] as u32) << 24) + (r << 16) + (g << 8) + b; + let array: &mut [u8; 4] = canvas_chunk.try_into().unwrap(); + *array = color.to_le_bytes(); + } + + CustomCursor { + buffer, + w: image.width as i32, + h: image.height as i32, + hotspot_x: image.hotspot_x as i32, + hotspot_y: image.hotspot_y as i32, + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/kwin_blur.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/kwin_blur.rs new file mode 100644 index 00000000..83e82ad7 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/kwin_blur.rs @@ -0,0 +1,68 @@ +//! Handling of KDE-compatible blur. + +use sctk::reexports::client::globals::{BindError, GlobalList}; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{delegate_dispatch, Connection, Dispatch, Proxy, QueueHandle}; +use wayland_protocols_plasma::blur::client::org_kde_kwin_blur::OrgKdeKwinBlur; +use wayland_protocols_plasma::blur::client::org_kde_kwin_blur_manager::OrgKdeKwinBlurManager; + +use sctk::globals::GlobalData; + +use crate::platform_impl::wayland::state::WinitState; + +/// KWin blur manager. +#[derive(Debug, Clone)] +pub struct KWinBlurManager { + manager: OrgKdeKwinBlurManager, +} + +impl KWinBlurManager { + pub fn new( + globals: &GlobalList, + queue_handle: &QueueHandle, + ) -> Result { + let manager = globals.bind(queue_handle, 1..=1, GlobalData)?; + Ok(Self { manager }) + } + + pub fn blur( + &self, + surface: &WlSurface, + queue_handle: &QueueHandle, + ) -> OrgKdeKwinBlur { + self.manager.create(surface, queue_handle, ()) + } + + pub fn unset(&self, surface: &WlSurface) { + self.manager.unset(surface) + } +} + +impl Dispatch for KWinBlurManager { + fn event( + _: &mut WinitState, + _: &OrgKdeKwinBlurManager, + _: ::Event, + _: &GlobalData, + _: &Connection, + _: &QueueHandle, + ) { + unreachable!("no events defined for org_kde_kwin_blur_manager"); + } +} + +impl Dispatch for KWinBlurManager { + fn event( + _: &mut WinitState, + _: &OrgKdeKwinBlur, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + unreachable!("no events defined for org_kde_kwin_blur"); + } +} + +delegate_dispatch!(WinitState: [OrgKdeKwinBlurManager: GlobalData] => KWinBlurManager); +delegate_dispatch!(WinitState: [OrgKdeKwinBlur: ()] => KWinBlurManager); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/mod.rs new file mode 100644 index 00000000..77e67f48 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/mod.rs @@ -0,0 +1,7 @@ +//! Wayland protocol implementation boilerplate. + +pub mod cursor; +pub mod kwin_blur; +pub mod wp_fractional_scaling; +pub mod wp_viewporter; +pub mod xdg_activation; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/wp_fractional_scaling.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/wp_fractional_scaling.rs new file mode 100644 index 00000000..2dfc9dbb --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/wp_fractional_scaling.rs @@ -0,0 +1,78 @@ +//! Handling of the fractional scaling. + +use sctk::reexports::client::globals::{BindError, GlobalList}; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{delegate_dispatch, Connection, Dispatch, Proxy, QueueHandle}; +use sctk::reexports::protocols::wp::fractional_scale::v1::client::wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1; +use sctk::reexports::protocols::wp::fractional_scale::v1::client::wp_fractional_scale_v1::{ + Event as FractionalScalingEvent, WpFractionalScaleV1, +}; + +use sctk::globals::GlobalData; + +use crate::platform_impl::wayland::state::WinitState; + +/// The scaling factor denominator. +const SCALE_DENOMINATOR: f64 = 120.; + +/// Fractional scaling manager. +#[derive(Debug)] +pub struct FractionalScalingManager { + manager: WpFractionalScaleManagerV1, +} + +pub struct FractionalScaling { + /// The surface used for scaling. + surface: WlSurface, +} + +impl FractionalScalingManager { + /// Create new viewporter. + pub fn new( + globals: &GlobalList, + queue_handle: &QueueHandle, + ) -> Result { + let manager = globals.bind(queue_handle, 1..=1, GlobalData)?; + Ok(Self { manager }) + } + + pub fn fractional_scaling( + &self, + surface: &WlSurface, + queue_handle: &QueueHandle, + ) -> WpFractionalScaleV1 { + let data = FractionalScaling { surface: surface.clone() }; + self.manager.get_fractional_scale(surface, queue_handle, data) + } +} + +impl Dispatch for FractionalScalingManager { + fn event( + _: &mut WinitState, + _: &WpFractionalScaleManagerV1, + _: ::Event, + _: &GlobalData, + _: &Connection, + _: &QueueHandle, + ) { + // No events. + } +} + +impl Dispatch for FractionalScalingManager { + fn event( + state: &mut WinitState, + _: &WpFractionalScaleV1, + event: ::Event, + data: &FractionalScaling, + _: &Connection, + _: &QueueHandle, + ) { + if let FractionalScalingEvent::PreferredScale { scale } = event { + state.scale_factor_changed(&data.surface, scale as f64 / SCALE_DENOMINATOR, false); + } + } +} + +delegate_dispatch!(WinitState: [WpFractionalScaleManagerV1: GlobalData] => FractionalScalingManager); +delegate_dispatch!(WinitState: [WpFractionalScaleV1: FractionalScaling] => FractionalScalingManager); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/wp_viewporter.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/wp_viewporter.rs new file mode 100644 index 00000000..90764825 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/wp_viewporter.rs @@ -0,0 +1,65 @@ +//! Handling of the wp-viewporter. + +use sctk::reexports::client::globals::{BindError, GlobalList}; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{delegate_dispatch, Connection, Dispatch, Proxy, QueueHandle}; +use sctk::reexports::protocols::wp::viewporter::client::wp_viewport::WpViewport; +use sctk::reexports::protocols::wp::viewporter::client::wp_viewporter::WpViewporter; + +use sctk::globals::GlobalData; + +use crate::platform_impl::wayland::state::WinitState; + +/// Viewporter. +#[derive(Debug)] +pub struct ViewporterState { + viewporter: WpViewporter, +} + +impl ViewporterState { + /// Create new viewporter. + pub fn new( + globals: &GlobalList, + queue_handle: &QueueHandle, + ) -> Result { + let viewporter = globals.bind(queue_handle, 1..=1, GlobalData)?; + Ok(Self { viewporter }) + } + + /// Get the viewport for the given object. + pub fn get_viewport( + &self, + surface: &WlSurface, + queue_handle: &QueueHandle, + ) -> WpViewport { + self.viewporter.get_viewport(surface, queue_handle, GlobalData) + } +} + +impl Dispatch for ViewporterState { + fn event( + _: &mut WinitState, + _: &WpViewporter, + _: ::Event, + _: &GlobalData, + _: &Connection, + _: &QueueHandle, + ) { + // No events. + } +} +impl Dispatch for ViewporterState { + fn event( + _: &mut WinitState, + _: &WpViewport, + _: ::Event, + _: &GlobalData, + _: &Connection, + _: &QueueHandle, + ) { + // No events. + } +} + +delegate_dispatch!(WinitState: [WpViewporter: GlobalData] => ViewporterState); +delegate_dispatch!(WinitState: [WpViewport: GlobalData] => ViewporterState); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/xdg_activation.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/xdg_activation.rs new file mode 100644 index 00000000..9efc75da --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/types/xdg_activation.rs @@ -0,0 +1,103 @@ +//! Handling of xdg activation, which is used for user attention requests. + +use std::sync::atomic::AtomicBool; +use std::sync::Weak; + +use sctk::reexports::client::globals::{BindError, GlobalList}; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{delegate_dispatch, Connection, Dispatch, Proxy, QueueHandle}; +use sctk::reexports::protocols::xdg::activation::v1::client::xdg_activation_token_v1::{ + Event as ActivationTokenEvent, XdgActivationTokenV1, +}; +use sctk::reexports::protocols::xdg::activation::v1::client::xdg_activation_v1::XdgActivationV1; + +use sctk::globals::GlobalData; + +use crate::event_loop::AsyncRequestSerial; +use crate::platform_impl::wayland::state::WinitState; +use crate::platform_impl::WindowId; +use crate::window::ActivationToken; + +pub struct XdgActivationState { + xdg_activation: XdgActivationV1, +} + +impl XdgActivationState { + pub fn bind( + globals: &GlobalList, + queue_handle: &QueueHandle, + ) -> Result { + let xdg_activation = globals.bind(queue_handle, 1..=1, GlobalData)?; + Ok(Self { xdg_activation }) + } + + pub fn global(&self) -> &XdgActivationV1 { + &self.xdg_activation + } +} + +impl Dispatch for XdgActivationState { + fn event( + _state: &mut WinitState, + _proxy: &XdgActivationV1, + _event: ::Event, + _data: &GlobalData, + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + } +} + +impl Dispatch for XdgActivationState { + fn event( + state: &mut WinitState, + proxy: &XdgActivationTokenV1, + event: ::Event, + data: &XdgActivationTokenData, + _: &Connection, + _: &QueueHandle, + ) { + let token = match event { + ActivationTokenEvent::Done { token } => token, + _ => return, + }; + + let global = state + .xdg_activation + .as_ref() + .expect("got xdg_activation event without global.") + .global(); + + match data { + XdgActivationTokenData::Attention((surface, fence)) => { + global.activate(token, surface); + // Mark that no request attention is in process. + if let Some(attention_requested) = fence.upgrade() { + attention_requested.store(false, std::sync::atomic::Ordering::Relaxed); + } + }, + XdgActivationTokenData::Obtain((window_id, serial)) => { + state.events_sink.push_window_event( + crate::event::WindowEvent::ActivationTokenDone { + serial: *serial, + token: ActivationToken::from_raw(token), + }, + *window_id, + ); + }, + } + + proxy.destroy(); + } +} + +/// The data associated with the activation request. +pub enum XdgActivationTokenData { + /// Request user attention for the given surface. + Attention((WlSurface, Weak)), + /// Get a token to be passed outside of the winit. + Obtain((WindowId, AsyncRequestSerial)), +} + +delegate_dispatch!(WinitState: [ XdgActivationV1: GlobalData] => XdgActivationState); +delegate_dispatch!(WinitState: [ XdgActivationTokenV1: XdgActivationTokenData] => XdgActivationState); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/window/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/window/mod.rs new file mode 100644 index 00000000..6d29a5a5 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/window/mod.rs @@ -0,0 +1,763 @@ +//! The Wayland window. + +use std::ffi::c_void; +use std::ptr::NonNull; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use sctk::reexports::client::protocol::wl_display::WlDisplay; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{Proxy, QueueHandle}; + +use sctk::compositor::{CompositorState, Region, SurfaceData}; +use sctk::reexports::protocols::xdg::activation::v1::client::xdg_activation_v1::XdgActivationV1; +use sctk::shell::xdg::window::{Window as SctkWindow, WindowDecorations}; +use sctk::shell::WaylandSurface; + +use tracing::warn; + +use crate::dpi::{LogicalSize, PhysicalPosition, PhysicalSize, Position, Size}; +use crate::error::{ExternalError, NotSupportedError, OsError as RootOsError}; +use crate::event::{Ime, WindowEvent}; +use crate::event_loop::AsyncRequestSerial; +use crate::platform_impl::{ + Fullscreen, MonitorHandle as PlatformMonitorHandle, OsError, PlatformIcon, +}; +use crate::window::{ + Cursor, CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, + WindowAttributes, WindowButtons, WindowLevel, +}; + +use super::event_loop::sink::EventSink; +use super::output::MonitorHandle; +use super::state::WinitState; +use super::types::xdg_activation::XdgActivationTokenData; +use super::{ActiveEventLoop, WaylandError, WindowId}; + +pub(crate) mod state; + +pub use state::WindowState; + +/// The Wayland window. +pub struct Window { + /// Reference to the underlying SCTK window. + window: SctkWindow, + + /// Window id. + window_id: WindowId, + + /// The state of the window. + window_state: Arc>, + + /// Compositor to handle WlRegion stuff. + compositor: Arc, + + /// The wayland display used solely for raw window handle. + #[allow(dead_code)] + display: WlDisplay, + + /// Xdg activation to request user attention. + xdg_activation: Option, + + /// The state of the requested attention from the `xdg_activation`. + attention_requested: Arc, + + /// Handle to the main queue to perform requests. + queue_handle: QueueHandle, + + /// Window requests to the event loop. + window_requests: Arc, + + /// Observed monitors. + monitors: Arc>>, + + /// Source to wake-up the event-loop for window requests. + event_loop_awakener: calloop::ping::Ping, + + /// The event sink to deliver synthetic events. + window_events_sink: Arc>, +} + +impl Window { + pub(crate) fn new( + event_loop_window_target: &ActiveEventLoop, + attributes: WindowAttributes, + ) -> Result { + let queue_handle = event_loop_window_target.queue_handle.clone(); + let mut state = event_loop_window_target.state.borrow_mut(); + + let monitors = state.monitors.clone(); + + let surface = state.compositor_state.create_surface(&queue_handle); + let compositor = state.compositor_state.clone(); + let xdg_activation = + state.xdg_activation.as_ref().map(|activation_state| activation_state.global().clone()); + let display = event_loop_window_target.connection.display(); + + let size: Size = attributes.inner_size.unwrap_or(LogicalSize::new(800., 600.).into()); + + // We prefer server side decorations, however to not have decorations we ask for client + // side decorations instead. + let default_decorations = if attributes.decorations { + WindowDecorations::RequestServer + } else { + WindowDecorations::RequestClient + }; + + let window = + state.xdg_shell.create_window(surface.clone(), default_decorations, &queue_handle); + + let mut window_state = WindowState::new( + event_loop_window_target.connection.clone(), + &event_loop_window_target.queue_handle, + &state, + size, + window.clone(), + attributes.preferred_theme, + ); + + // Set transparency hint. + window_state.set_transparent(attributes.transparent); + + window_state.set_blur(attributes.blur); + + // Set the decorations hint. + window_state.set_decorate(attributes.decorations); + + // Set the app_id. + if let Some(name) = attributes.platform_specific.name.map(|name| name.general) { + window.set_app_id(name); + } + + // Set the window title. + window_state.set_title(attributes.title); + + // Set the min and max sizes. We must set the hints upon creating a window, so + // we use the default `1.` scaling... + let min_size = attributes.min_inner_size.map(|size| size.to_logical(1.)); + let max_size = attributes.max_inner_size.map(|size| size.to_logical(1.)); + window_state.set_min_inner_size(min_size); + window_state.set_max_inner_size(max_size); + + // Non-resizable implies that the min and max sizes are set to the same value. + window_state.set_resizable(attributes.resizable); + + // Set startup mode. + match attributes.fullscreen.map(Into::into) { + Some(Fullscreen::Exclusive(_)) => { + warn!("`Fullscreen::Exclusive` is ignored on Wayland"); + }, + #[cfg_attr(not(x11_platform), allow(clippy::bind_instead_of_map))] + Some(Fullscreen::Borderless(monitor)) => { + let output = monitor.and_then(|monitor| match monitor { + PlatformMonitorHandle::Wayland(monitor) => Some(monitor.proxy), + #[cfg(x11_platform)] + PlatformMonitorHandle::X(_) => None, + }); + + window.set_fullscreen(output.as_ref()) + }, + _ if attributes.maximized => window.set_maximized(), + _ => (), + }; + + match attributes.cursor { + Cursor::Icon(icon) => window_state.set_cursor(icon), + Cursor::Custom(cursor) => window_state.set_custom_cursor(cursor), + } + + // Apply resize increments. + if let Some(increments) = attributes.resize_increments { + let increments = increments.to_logical(window_state.scale_factor()); + window_state.set_resize_increments(Some(increments)); + } + + // Activate the window when the token is passed. + if let (Some(xdg_activation), Some(token)) = + (xdg_activation.as_ref(), attributes.platform_specific.activation_token) + { + xdg_activation.activate(token.token, &surface); + } + + // XXX Do initial commit. + window.commit(); + + // Add the window and window requests into the state. + let window_state = Arc::new(Mutex::new(window_state)); + let window_id = super::make_wid(&surface); + state.windows.get_mut().insert(window_id, window_state.clone()); + + let window_requests = WindowRequests { + redraw_requested: AtomicBool::new(true), + closed: AtomicBool::new(false), + }; + let window_requests = Arc::new(window_requests); + state.window_requests.get_mut().insert(window_id, window_requests.clone()); + + // Setup the event sync to insert `WindowEvents` right from the window. + let window_events_sink = state.window_events_sink.clone(); + + let mut wayland_source = event_loop_window_target.wayland_dispatcher.as_source_mut(); + let event_queue = wayland_source.queue(); + + // Do a roundtrip. + event_queue.roundtrip(&mut state).map_err(|error| { + os_error!(OsError::WaylandError(Arc::new(WaylandError::Dispatch(error)))) + })?; + + // XXX Wait for the initial configure to arrive. + while !window_state.lock().unwrap().is_configured() { + event_queue.blocking_dispatch(&mut state).map_err(|error| { + os_error!(OsError::WaylandError(Arc::new(WaylandError::Dispatch(error)))) + })?; + } + + // Wake-up event loop, so it'll send initial redraw requested. + let event_loop_awakener = event_loop_window_target.event_loop_awakener.clone(); + event_loop_awakener.ping(); + + Ok(Self { + window, + display, + monitors, + window_id, + compositor, + window_state, + queue_handle, + xdg_activation, + attention_requested: Arc::new(AtomicBool::new(false)), + event_loop_awakener, + window_requests, + window_events_sink, + }) + } + + pub(crate) fn xdg_toplevel(&self) -> Option> { + NonNull::new(self.window.xdg_toplevel().id().as_ptr().cast()) + } +} + +impl Window { + #[inline] + pub fn id(&self) -> WindowId { + self.window_id + } + + #[inline] + pub fn set_title(&self, title: impl ToString) { + let new_title = title.to_string(); + self.window_state.lock().unwrap().set_title(new_title); + } + + #[inline] + pub fn set_visible(&self, _visible: bool) { + // Not possible on Wayland. + } + + #[inline] + pub fn is_visible(&self) -> Option { + None + } + + #[inline] + pub fn outer_position(&self) -> Result, NotSupportedError> { + Err(NotSupportedError::new()) + } + + #[inline] + pub fn inner_position(&self) -> Result, NotSupportedError> { + Err(NotSupportedError::new()) + } + + #[inline] + pub fn set_outer_position(&self, _: Position) { + // Not possible on Wayland. + } + + #[inline] + pub fn inner_size(&self) -> PhysicalSize { + let window_state = self.window_state.lock().unwrap(); + let scale_factor = window_state.scale_factor(); + super::logical_to_physical_rounded(window_state.inner_size(), scale_factor) + } + + #[inline] + pub fn request_redraw(&self) { + // NOTE: try to not wake up the loop when the event was already scheduled and not yet + // processed by the loop, because if at this point the value was `true` it could only + // mean that the loop still haven't dispatched the value to the client and will do + // eventually, resetting it to `false`. + if self + .window_requests + .redraw_requested + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + self.event_loop_awakener.ping(); + } + } + + #[inline] + pub fn pre_present_notify(&self) { + self.window_state.lock().unwrap().request_frame_callback(); + } + + #[inline] + pub fn outer_size(&self) -> PhysicalSize { + let window_state = self.window_state.lock().unwrap(); + let scale_factor = window_state.scale_factor(); + super::logical_to_physical_rounded(window_state.outer_size(), scale_factor) + } + + #[inline] + pub fn request_inner_size(&self, size: Size) -> Option> { + let mut window_state = self.window_state.lock().unwrap(); + let new_size = window_state.request_inner_size(size); + self.request_redraw(); + Some(new_size) + } + + /// Set the minimum inner size for the window. + #[inline] + pub fn set_min_inner_size(&self, min_size: Option) { + let scale_factor = self.scale_factor(); + let min_size = min_size.map(|size| size.to_logical(scale_factor)); + self.window_state.lock().unwrap().set_min_inner_size(min_size); + // NOTE: Requires commit to be applied. + self.request_redraw(); + } + + /// Set the maximum inner size for the window. + #[inline] + pub fn set_max_inner_size(&self, max_size: Option) { + let scale_factor = self.scale_factor(); + let max_size = max_size.map(|size| size.to_logical(scale_factor)); + self.window_state.lock().unwrap().set_max_inner_size(max_size); + // NOTE: Requires commit to be applied. + self.request_redraw(); + } + + #[inline] + pub fn resize_increments(&self) -> Option> { + let window_state = self.window_state.lock().unwrap(); + let scale_factor = window_state.scale_factor(); + window_state + .resize_increments() + .map(|size| super::logical_to_physical_rounded(size, scale_factor)) + } + + #[inline] + pub fn set_resize_increments(&self, increments: Option) { + let mut window_state = self.window_state.lock().unwrap(); + let scale_factor = window_state.scale_factor(); + let increments = increments.map(|size| size.to_logical(scale_factor)); + window_state.set_resize_increments(increments); + } + + #[inline] + pub fn set_transparent(&self, transparent: bool) { + self.window_state.lock().unwrap().set_transparent(transparent); + } + + #[inline] + pub fn has_focus(&self) -> bool { + self.window_state.lock().unwrap().has_focus() + } + + #[inline] + pub fn is_minimized(&self) -> Option { + // XXX clients don't know whether they are minimized or not. + None + } + + #[inline] + pub fn show_window_menu(&self, position: Position) { + let scale_factor = self.scale_factor(); + let position = position.to_logical(scale_factor); + self.window_state.lock().unwrap().show_window_menu(position); + } + + #[inline] + pub fn drag_resize_window(&self, direction: ResizeDirection) -> Result<(), ExternalError> { + self.window_state.lock().unwrap().drag_resize_window(direction) + } + + #[inline] + pub fn set_resizable(&self, resizable: bool) { + if self.window_state.lock().unwrap().set_resizable(resizable) { + // NOTE: Requires commit to be applied. + self.request_redraw(); + } + } + + #[inline] + pub fn is_resizable(&self) -> bool { + self.window_state.lock().unwrap().resizable() + } + + #[inline] + pub fn set_enabled_buttons(&self, _buttons: WindowButtons) { + // TODO(kchibisov) v5 of the xdg_shell allows that. + } + + #[inline] + pub fn enabled_buttons(&self) -> WindowButtons { + // TODO(kchibisov) v5 of the xdg_shell allows that. + WindowButtons::all() + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + self.window_state.lock().unwrap().scale_factor() + } + + #[inline] + pub fn set_blur(&self, blur: bool) { + self.window_state.lock().unwrap().set_blur(blur); + } + + #[inline] + pub fn set_decorations(&self, decorate: bool) { + self.window_state.lock().unwrap().set_decorate(decorate) + } + + #[inline] + pub fn is_decorated(&self) -> bool { + self.window_state.lock().unwrap().is_decorated() + } + + #[inline] + pub fn set_window_level(&self, _level: WindowLevel) {} + + #[inline] + pub(crate) fn set_window_icon(&self, _window_icon: Option) {} + + #[inline] + pub fn set_minimized(&self, minimized: bool) { + // You can't unminimize the window on Wayland. + if !minimized { + warn!("Unminimizing is ignored on Wayland."); + return; + } + + self.window.set_minimized(); + } + + #[inline] + pub fn is_maximized(&self) -> bool { + self.window_state + .lock() + .unwrap() + .last_configure + .as_ref() + .map(|last_configure| last_configure.is_maximized()) + .unwrap_or_default() + } + + #[inline] + pub fn set_maximized(&self, maximized: bool) { + if maximized { + self.window.set_maximized() + } else { + self.window.unset_maximized() + } + } + + #[inline] + pub(crate) fn fullscreen(&self) -> Option { + let is_fullscreen = self + .window_state + .lock() + .unwrap() + .last_configure + .as_ref() + .map(|last_configure| last_configure.is_fullscreen()) + .unwrap_or_default(); + + if is_fullscreen { + let current_monitor = self.current_monitor().map(PlatformMonitorHandle::Wayland); + Some(Fullscreen::Borderless(current_monitor)) + } else { + None + } + } + + #[inline] + pub(crate) fn set_fullscreen(&self, fullscreen: Option) { + match fullscreen { + Some(Fullscreen::Exclusive(_)) => { + warn!("`Fullscreen::Exclusive` is ignored on Wayland"); + }, + #[cfg_attr(not(x11_platform), allow(clippy::bind_instead_of_map))] + Some(Fullscreen::Borderless(monitor)) => { + let output = monitor.and_then(|monitor| match monitor { + PlatformMonitorHandle::Wayland(monitor) => Some(monitor.proxy), + #[cfg(x11_platform)] + PlatformMonitorHandle::X(_) => None, + }); + + self.window.set_fullscreen(output.as_ref()) + }, + None => self.window.unset_fullscreen(), + } + } + + #[inline] + pub fn set_cursor(&self, cursor: Cursor) { + let window_state = &mut self.window_state.lock().unwrap(); + + match cursor { + Cursor::Icon(icon) => window_state.set_cursor(icon), + Cursor::Custom(cursor) => window_state.set_custom_cursor(cursor), + } + } + + #[inline] + pub fn set_cursor_visible(&self, visible: bool) { + self.window_state.lock().unwrap().set_cursor_visible(visible); + } + + pub fn request_user_attention(&self, request_type: Option) { + let xdg_activation = match self.xdg_activation.as_ref() { + Some(xdg_activation) => xdg_activation, + None => { + warn!("`request_user_attention` isn't supported"); + return; + }, + }; + + // Urgency is only removed by the compositor and there's no need to raise urgency when it + // was already raised. + if request_type.is_none() || self.attention_requested.load(Ordering::Relaxed) { + return; + } + + self.attention_requested.store(true, Ordering::Relaxed); + let surface = self.surface().clone(); + let data = XdgActivationTokenData::Attention(( + surface.clone(), + Arc::downgrade(&self.attention_requested), + )); + let xdg_activation_token = xdg_activation.get_activation_token(&self.queue_handle, data); + xdg_activation_token.set_surface(&surface); + xdg_activation_token.commit(); + } + + pub fn request_activation_token(&self) -> Result { + let xdg_activation = match self.xdg_activation.as_ref() { + Some(xdg_activation) => xdg_activation, + None => return Err(NotSupportedError::new()), + }; + + let serial = AsyncRequestSerial::get(); + + let data = XdgActivationTokenData::Obtain((self.window_id, serial)); + let xdg_activation_token = xdg_activation.get_activation_token(&self.queue_handle, data); + xdg_activation_token.set_surface(self.surface()); + xdg_activation_token.commit(); + + Ok(serial) + } + + #[inline] + pub fn set_cursor_grab(&self, mode: CursorGrabMode) -> Result<(), ExternalError> { + self.window_state.lock().unwrap().set_cursor_grab(mode) + } + + #[inline] + pub fn set_cursor_position(&self, position: Position) -> Result<(), ExternalError> { + let scale_factor = self.scale_factor(); + let position = position.to_logical(scale_factor); + self.window_state + .lock() + .unwrap() + .set_cursor_position(position) + // Request redraw on success, since the state is double buffered. + .map(|_| self.request_redraw()) + } + + #[inline] + pub fn drag_window(&self) -> Result<(), ExternalError> { + self.window_state.lock().unwrap().drag_window() + } + + #[inline] + pub fn set_cursor_hittest(&self, hittest: bool) -> Result<(), ExternalError> { + let surface = self.window.wl_surface(); + + if hittest { + surface.set_input_region(None); + Ok(()) + } else { + let region = Region::new(&*self.compositor).map_err(|_| { + ExternalError::Os(os_error!(OsError::Misc("failed to set input region."))) + })?; + region.add(0, 0, 0, 0); + surface.set_input_region(Some(region.wl_region())); + Ok(()) + } + } + + #[inline] + pub fn set_ime_cursor_area(&self, position: Position, size: Size) { + let window_state = self.window_state.lock().unwrap(); + if window_state.ime_allowed() { + let scale_factor = window_state.scale_factor(); + let position = position.to_logical(scale_factor); + let size = size.to_logical(scale_factor); + window_state.set_ime_cursor_area(position, size); + } + } + + #[inline] + pub fn set_ime_allowed(&self, allowed: bool) { + let mut window_state = self.window_state.lock().unwrap(); + + if window_state.ime_allowed() != allowed && window_state.set_ime_allowed(allowed) { + let event = WindowEvent::Ime(if allowed { Ime::Enabled } else { Ime::Disabled }); + self.window_events_sink.lock().unwrap().push_window_event(event, self.window_id); + self.event_loop_awakener.ping(); + } + } + + #[inline] + pub fn set_ime_purpose(&self, purpose: ImePurpose) { + self.window_state.lock().unwrap().set_ime_purpose(purpose); + } + + #[inline] + pub fn focus_window(&self) {} + + #[inline] + pub fn surface(&self) -> &WlSurface { + self.window.wl_surface() + } + + #[inline] + pub fn current_monitor(&self) -> Option { + let data = self.window.wl_surface().data::()?; + data.outputs().next().map(MonitorHandle::new) + } + + #[inline] + pub fn available_monitors(&self) -> Vec { + self.monitors.lock().unwrap().clone() + } + + #[inline] + pub fn primary_monitor(&self) -> Option { + // XXX there's no such concept on Wayland. + None + } + + #[cfg(feature = "rwh_04")] + #[inline] + pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle { + let mut window_handle = rwh_04::WaylandHandle::empty(); + window_handle.surface = self.window.wl_surface().id().as_ptr() as *mut _; + window_handle.display = self.display.id().as_ptr() as *mut _; + rwh_04::RawWindowHandle::Wayland(window_handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle { + let mut window_handle = rwh_05::WaylandWindowHandle::empty(); + window_handle.surface = self.window.wl_surface().id().as_ptr() as *mut _; + rwh_05::RawWindowHandle::Wayland(window_handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + let mut display_handle = rwh_05::WaylandDisplayHandle::empty(); + display_handle.display = self.display.id().as_ptr() as *mut _; + rwh_05::RawDisplayHandle::Wayland(display_handle) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_window_handle_rwh_06(&self) -> Result { + Ok(rwh_06::WaylandWindowHandle::new({ + let ptr = self.window.wl_surface().id().as_ptr(); + std::ptr::NonNull::new(ptr as *mut _).expect("wl_surface will never be null") + }) + .into()) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::WaylandDisplayHandle::new({ + let ptr = self.display.id().as_ptr(); + std::ptr::NonNull::new(ptr as *mut _).expect("wl_proxy should never be null") + }) + .into()) + } + + #[inline] + pub fn set_theme(&self, theme: Option) { + self.window_state.lock().unwrap().set_theme(theme) + } + + #[inline] + pub fn theme(&self) -> Option { + self.window_state.lock().unwrap().theme() + } + + pub fn set_content_protected(&self, _protected: bool) {} + + #[inline] + pub fn title(&self) -> String { + self.window_state.lock().unwrap().title().to_owned() + } +} + +impl Drop for Window { + fn drop(&mut self) { + self.window_requests.closed.store(true, Ordering::Relaxed); + self.event_loop_awakener.ping(); + } +} + +/// The request from the window to the event loop. +#[derive(Debug)] +pub struct WindowRequests { + /// The window was closed. + pub closed: AtomicBool, + + /// Redraw Requested. + pub redraw_requested: AtomicBool, +} + +impl WindowRequests { + pub fn take_closed(&self) -> bool { + self.closed.swap(false, Ordering::Relaxed) + } + + pub fn take_redraw_requested(&self) -> bool { + self.redraw_requested.swap(false, Ordering::Relaxed) + } +} + +impl TryFrom<&str> for Theme { + type Error = (); + + /// ``` + /// use winit::window::Theme; + /// + /// assert_eq!("dark".try_into(), Ok(Theme::Dark)); + /// assert_eq!("lIghT".try_into(), Ok(Theme::Light)); + /// ``` + fn try_from(theme: &str) -> Result { + if theme.eq_ignore_ascii_case("dark") { + Ok(Self::Dark) + } else if theme.eq_ignore_ascii_case("light") { + Ok(Self::Light) + } else { + Err(()) + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/window/state.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/window/state.rs new file mode 100644 index 00000000..1ef7a065 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/wayland/window/state.rs @@ -0,0 +1,1220 @@ +//! The state of the window, which is shared with the event-loop. + +use std::num::NonZeroU32; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +use ahash::HashSet; +use tracing::{info, warn}; + +use sctk::reexports::client::backend::ObjectId; +use sctk::reexports::client::protocol::wl_seat::WlSeat; +use sctk::reexports::client::protocol::wl_shm::WlShm; +use sctk::reexports::client::protocol::wl_surface::WlSurface; +use sctk::reexports::client::{Connection, Proxy, QueueHandle}; +use sctk::reexports::csd_frame::{ + DecorationsFrame, FrameAction, FrameClick, ResizeEdge, WindowState as XdgWindowState, +}; +use sctk::reexports::protocols::wp::fractional_scale::v1::client::wp_fractional_scale_v1::WpFractionalScaleV1; +use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_v3::ZwpTextInputV3; +use sctk::reexports::protocols::wp::viewporter::client::wp_viewport::WpViewport; +use sctk::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge as XdgResizeEdge; + +use sctk::compositor::{CompositorState, Region, SurfaceData, SurfaceDataExt}; +use sctk::seat::pointer::{PointerDataExt, ThemedPointer}; +use sctk::shell::xdg::window::{DecorationMode, Window, WindowConfigure}; +use sctk::shell::xdg::XdgSurface; +use sctk::shell::WaylandSurface; +use sctk::shm::slot::SlotPool; +use sctk::shm::Shm; +use sctk::subcompositor::SubcompositorState; +use wayland_protocols_plasma::blur::client::org_kde_kwin_blur::OrgKdeKwinBlur; + +use crate::cursor::CustomCursor as RootCustomCursor; +use crate::dpi::{LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize, Size}; +use crate::error::{ExternalError, NotSupportedError}; +use crate::platform_impl::wayland::logical_to_physical_rounded; +use crate::platform_impl::wayland::types::cursor::{CustomCursor, SelectedCursor}; +use crate::platform_impl::wayland::types::kwin_blur::KWinBlurManager; +use crate::platform_impl::{PlatformCustomCursor, WindowId}; +use crate::window::{CursorGrabMode, CursorIcon, ImePurpose, ResizeDirection, Theme}; + +use crate::platform_impl::wayland::seat::{ + PointerConstraintsState, WinitPointerData, WinitPointerDataExt, ZwpTextInputV3Ext, +}; +use crate::platform_impl::wayland::state::{WindowCompositorUpdate, WinitState}; + +#[cfg(feature = "sctk-adwaita")] +pub type WinitFrame = sctk_adwaita::AdwaitaFrame; +#[cfg(not(feature = "sctk-adwaita"))] +pub type WinitFrame = sctk::shell::xdg::fallback_frame::FallbackFrame; + +// Minimum window inner size. +const MIN_WINDOW_SIZE: LogicalSize = LogicalSize::new(2, 1); + +/// The state of the window which is being updated from the [`WinitState`]. +pub struct WindowState { + /// The connection to Wayland server. + pub connection: Connection, + + /// The `Shm` to set cursor. + pub shm: WlShm, + + // A shared pool where to allocate custom cursors. + custom_cursor_pool: Arc>, + + /// The last received configure. + pub last_configure: Option, + + /// The pointers observed on the window. + pub pointers: Vec>>, + + selected_cursor: SelectedCursor, + + /// Whether the cursor is visible. + pub cursor_visible: bool, + + /// Pointer constraints to lock/confine pointer. + pub pointer_constraints: Option>, + + /// Queue handle. + pub queue_handle: QueueHandle, + + /// Theme variant. + theme: Option, + + /// The current window title. + title: String, + + /// Whether the frame is resizable. + resizable: bool, + + // NOTE: we can't use simple counter, since it's racy when seat getting destroyed and new + // is created, since add/removed stuff could be delivered a bit out of order. + /// Seats that has keyboard focus on that window. + seat_focus: HashSet, + + /// The scale factor of the window. + scale_factor: f64, + + /// Whether the window is transparent. + transparent: bool, + + /// The state of the compositor to create WlRegions. + compositor: Arc, + + /// The current cursor grabbing mode. + cursor_grab_mode: GrabState, + + /// Whether the IME input is allowed for that window. + ime_allowed: bool, + + /// The current IME purpose. + ime_purpose: ImePurpose, + + /// The text inputs observed on the window. + text_inputs: Vec, + + /// The inner size of the window, as in without client side decorations. + size: LogicalSize, + + /// Whether the CSD fail to create, so we don't try to create them on each iteration. + csd_fails: bool, + + /// Whether we should decorate the frame. + decorate: bool, + + /// Min size. + min_inner_size: LogicalSize, + max_inner_size: Option>, + resize_increments: Option>, + + /// The size of the window when no states were applied to it. The primary use for it + /// is to fallback to original window size, before it was maximized, if the compositor + /// sends `None` for the new size in the configure. + stateless_size: LogicalSize, + + /// Initial window size provided by the user. Removed on the first + /// configure. + initial_size: Option, + + /// The state of the frame callback. + frame_callback_state: FrameCallbackState, + + viewport: Option, + fractional_scale: Option, + blur: Option, + blur_manager: Option, + + /// Whether the client side decorations have pending move operations. + /// + /// The value is the serial of the event triggered moved. + has_pending_move: Option, + + /// The underlying SCTK window. + pub window: Window, + + // NOTE: The spec says that destroying parent(`window` in our case), will unmap the + // subsurfaces. Thus to achieve atomic unmap of the client, drop the decorations + // frame after the `window` is dropped. To achieve that we rely on rust's struct + // field drop order guarantees. + /// The window frame, which is created from the configure request. + frame: Option, +} + +impl WindowState { + /// Create new window state. + pub fn new( + connection: Connection, + queue_handle: &QueueHandle, + winit_state: &WinitState, + initial_size: Size, + window: Window, + theme: Option, + ) -> Self { + let compositor = winit_state.compositor_state.clone(); + let pointer_constraints = winit_state.pointer_constraints.clone(); + let viewport = winit_state + .viewporter_state + .as_ref() + .map(|state| state.get_viewport(window.wl_surface(), queue_handle)); + let fractional_scale = winit_state + .fractional_scaling_manager + .as_ref() + .map(|fsm| fsm.fractional_scaling(window.wl_surface(), queue_handle)); + + Self { + blur: None, + blur_manager: winit_state.kwin_blur_manager.clone(), + compositor, + connection, + csd_fails: false, + cursor_grab_mode: GrabState::new(), + selected_cursor: Default::default(), + cursor_visible: true, + decorate: true, + fractional_scale, + frame: None, + frame_callback_state: FrameCallbackState::None, + seat_focus: Default::default(), + has_pending_move: None, + ime_allowed: false, + ime_purpose: ImePurpose::Normal, + last_configure: None, + max_inner_size: None, + min_inner_size: MIN_WINDOW_SIZE, + resize_increments: None, + pointer_constraints, + pointers: Default::default(), + queue_handle: queue_handle.clone(), + resizable: true, + scale_factor: 1., + shm: winit_state.shm.wl_shm().clone(), + custom_cursor_pool: winit_state.custom_cursor_pool.clone(), + size: initial_size.to_logical(1.), + stateless_size: initial_size.to_logical(1.), + initial_size: Some(initial_size), + text_inputs: Vec::new(), + theme, + title: String::default(), + transparent: false, + viewport, + window, + } + } + + /// Apply closure on the given pointer. + fn apply_on_pointer, &WinitPointerData)>( + &self, + mut callback: F, + ) { + self.pointers.iter().filter_map(Weak::upgrade).for_each(|pointer| { + let data = pointer.pointer().winit_data(); + callback(pointer.as_ref(), data); + }) + } + + /// Get the current state of the frame callback. + pub fn frame_callback_state(&self) -> FrameCallbackState { + self.frame_callback_state + } + + /// The frame callback was received, but not yet sent to the user. + pub fn frame_callback_received(&mut self) { + self.frame_callback_state = FrameCallbackState::Received; + } + + /// Reset the frame callbacks state. + pub fn frame_callback_reset(&mut self) { + self.frame_callback_state = FrameCallbackState::None; + } + + /// Request a frame callback if we don't have one for this window in flight. + pub fn request_frame_callback(&mut self) { + let surface = self.window.wl_surface(); + match self.frame_callback_state { + FrameCallbackState::None | FrameCallbackState::Received => { + self.frame_callback_state = FrameCallbackState::Requested; + surface.frame(&self.queue_handle, surface.clone()); + }, + FrameCallbackState::Requested => (), + } + } + + pub fn configure( + &mut self, + configure: WindowConfigure, + shm: &Shm, + subcompositor: &Option>, + ) -> bool { + // NOTE: when using fractional scaling or wl_compositor@v6 the scaling + // should be delivered before the first configure, thus apply it to + // properly scale the physical sizes provided by the users. + if let Some(initial_size) = self.initial_size.take() { + self.size = initial_size.to_logical(self.scale_factor()); + self.stateless_size = self.size; + } + + if let Some(subcompositor) = subcompositor.as_ref().filter(|_| { + configure.decoration_mode == DecorationMode::Client + && self.frame.is_none() + && !self.csd_fails + }) { + match WinitFrame::new( + &self.window, + shm, + #[cfg(feature = "sctk-adwaita")] + self.compositor.clone(), + subcompositor.clone(), + self.queue_handle.clone(), + #[cfg(feature = "sctk-adwaita")] + into_sctk_adwaita_config(self.theme), + ) { + Ok(mut frame) => { + frame.set_title(&self.title); + frame.set_scaling_factor(self.scale_factor); + // Hide the frame if we were asked to not decorate. + frame.set_hidden(!self.decorate); + self.frame = Some(frame); + }, + Err(err) => { + warn!("Failed to create client side decorations frame: {err}"); + self.csd_fails = true; + }, + } + } else if configure.decoration_mode == DecorationMode::Server { + // Drop the frame for server side decorations to save resources. + self.frame = None; + } + + let stateless = Self::is_stateless(&configure); + + let (mut new_size, constrain) = if let Some(frame) = self.frame.as_mut() { + // Configure the window states. + frame.update_state(configure.state); + + match configure.new_size { + (Some(width), Some(height)) => { + let (width, height) = frame.subtract_borders(width, height); + let width = width.map(|w| w.get()).unwrap_or(1); + let height = height.map(|h| h.get()).unwrap_or(1); + ((width, height).into(), false) + }, + (..) if stateless => (self.stateless_size, true), + _ => (self.size, true), + } + } else { + match configure.new_size { + (Some(width), Some(height)) => ((width.get(), height.get()).into(), false), + _ if stateless => (self.stateless_size, true), + _ => (self.size, true), + } + }; + + // Apply configure bounds only when compositor let the user decide what size to pick. + if constrain { + let bounds = self.inner_size_bounds(&configure); + new_size.width = + bounds.0.map(|bound_w| new_size.width.min(bound_w.get())).unwrap_or(new_size.width); + new_size.height = bounds + .1 + .map(|bound_h| new_size.height.min(bound_h.get())) + .unwrap_or(new_size.height); + } + + // Apply size increments. + // + // We conditionally apply increments to avoid conflicts with the compositor's layout rules: + // 1. If the window is floating (constrain == true), we snap to increments to ensure the + // app's grid alignment. + // 2. If the user is interactively resizing (is_resizing), we snap the size to provide + // feedback. + // + // However, we MUST NOT snap if the compositor enforces a specific size (constrain == false, + // or states like Maximized/Tiled). Snapping in these cases (e.g. corner tiling) would + // shrink the window below the allocated area, creating visible gaps between valid + // windows or screen edges. + if (constrain || configure.is_resizing()) + && !configure.is_maximized() + && !configure.is_fullscreen() + && !configure.is_tiled() + { + if let Some(increments) = self.resize_increments { + // We use min size as a base size for the increments, similar to how X11 does it. + // + // This ensures that we can always reach the min size and the increments are + // calculated from it. + let (delta_width, delta_height) = ( + new_size.width.saturating_sub(self.min_inner_size.width), + new_size.height.saturating_sub(self.min_inner_size.height), + ); + + let width = + self.min_inner_size.width + (delta_width / increments.width) * increments.width; + let height = self.min_inner_size.height + + (delta_height / increments.height) * increments.height; + + new_size = (width, height).into(); + } + } + + let new_state = configure.state; + let old_state = self.last_configure.as_ref().map(|configure| configure.state); + + let state_change_requires_resize = old_state + .map(|old_state| { + !old_state + .symmetric_difference(new_state) + .difference(XdgWindowState::ACTIVATED | XdgWindowState::SUSPENDED) + .is_empty() + }) + // NOTE: `None` is present for the initial configure, thus we must always resize. + .unwrap_or(true); + + // NOTE: Set the configure before doing a resize, since we query it during it. + self.last_configure = Some(configure); + + if state_change_requires_resize || new_size != self.inner_size() { + self.resize(new_size); + true + } else { + false + } + } + + /// Compute the bounds for the inner size of the surface. + fn inner_size_bounds( + &self, + configure: &WindowConfigure, + ) -> (Option, Option) { + let configure_bounds = match configure.suggested_bounds { + Some((width, height)) => (NonZeroU32::new(width), NonZeroU32::new(height)), + None => (None, None), + }; + + if let Some(frame) = self.frame.as_ref() { + let (width, height) = frame.subtract_borders( + configure_bounds.0.unwrap_or(NonZeroU32::new(1).unwrap()), + configure_bounds.1.unwrap_or(NonZeroU32::new(1).unwrap()), + ); + (configure_bounds.0.and(width), configure_bounds.1.and(height)) + } else { + configure_bounds + } + } + + #[inline] + fn is_stateless(configure: &WindowConfigure) -> bool { + !(configure.is_maximized() || configure.is_fullscreen() || configure.is_tiled()) + } + + /// Start interacting drag resize. + pub fn drag_resize_window(&self, direction: ResizeDirection) -> Result<(), ExternalError> { + let xdg_toplevel = self.window.xdg_toplevel(); + + // TODO(kchibisov) handle touch serials. + self.apply_on_pointer(|_, data| { + let serial = data.latest_button_serial(); + let seat = data.seat(); + xdg_toplevel.resize(seat, serial, direction.into()); + }); + + Ok(()) + } + + /// Start the window drag. + pub fn drag_window(&self) -> Result<(), ExternalError> { + let xdg_toplevel = self.window.xdg_toplevel(); + // TODO(kchibisov) handle touch serials. + self.apply_on_pointer(|_, data| { + let serial = data.latest_button_serial(); + let seat = data.seat(); + xdg_toplevel._move(seat, serial); + }); + + Ok(()) + } + + /// Tells whether the window should be closed. + #[allow(clippy::too_many_arguments)] + pub fn frame_click( + &mut self, + click: FrameClick, + pressed: bool, + seat: &WlSeat, + serial: u32, + timestamp: Duration, + window_id: WindowId, + updates: &mut Vec, + ) -> Option { + match self.frame.as_mut()?.on_click(timestamp, click, pressed)? { + FrameAction::Minimize => self.window.set_minimized(), + FrameAction::Maximize => self.window.set_maximized(), + FrameAction::UnMaximize => self.window.unset_maximized(), + FrameAction::Close => WinitState::queue_close(updates, window_id), + FrameAction::Move => self.has_pending_move = Some(serial), + FrameAction::Resize(edge) => { + let edge = match edge { + ResizeEdge::None => XdgResizeEdge::None, + ResizeEdge::Top => XdgResizeEdge::Top, + ResizeEdge::Bottom => XdgResizeEdge::Bottom, + ResizeEdge::Left => XdgResizeEdge::Left, + ResizeEdge::TopLeft => XdgResizeEdge::TopLeft, + ResizeEdge::BottomLeft => XdgResizeEdge::BottomLeft, + ResizeEdge::Right => XdgResizeEdge::Right, + ResizeEdge::TopRight => XdgResizeEdge::TopRight, + ResizeEdge::BottomRight => XdgResizeEdge::BottomRight, + _ => return None, + }; + self.window.resize(seat, serial, edge); + }, + FrameAction::ShowMenu(x, y) => self.window.show_window_menu(seat, serial, (x, y)), + _ => (), + }; + + Some(false) + } + + pub fn frame_point_left(&mut self) { + if let Some(frame) = self.frame.as_mut() { + frame.click_point_left(); + } + } + + // Move the point over decorations. + pub fn frame_point_moved( + &mut self, + seat: &WlSeat, + surface: &WlSurface, + timestamp: Duration, + x: f64, + y: f64, + ) -> Option { + // Take the serial if we had any, so it doesn't stick around. + let serial = self.has_pending_move.take(); + + if let Some(frame) = self.frame.as_mut() { + let cursor = frame.click_point_moved(timestamp, &surface.id(), x, y); + // If we have a cursor change, that means that cursor is over the decorations, + // so try to apply move. + if let Some(serial) = cursor.is_some().then_some(serial).flatten() { + self.window.move_(seat, serial); + None + } else { + cursor + } + } else { + None + } + } + + /// Get the stored resizable state. + #[inline] + pub fn resizable(&self) -> bool { + self.resizable + } + + /// Set the resizable state on the window. + /// + /// Returns `true` when the state was applied. + #[inline] + pub fn set_resizable(&mut self, resizable: bool) -> bool { + if self.resizable == resizable { + return false; + } + + self.resizable = resizable; + if resizable { + // Restore min/max sizes of the window. + self.reload_min_max_hints(); + } else { + self.set_min_inner_size(Some(self.size)); + self.set_max_inner_size(Some(self.size)); + } + + // Reload the state on the frame as well. + if let Some(frame) = self.frame.as_mut() { + frame.set_resizable(resizable); + } + + true + } + + /// Whether the window is focused by any seat. + #[inline] + pub fn has_focus(&self) -> bool { + !self.seat_focus.is_empty() + } + + /// Whether the IME is allowed. + #[inline] + pub fn ime_allowed(&self) -> bool { + self.ime_allowed + } + + /// Get the size of the window. + #[inline] + pub fn inner_size(&self) -> LogicalSize { + self.size + } + + /// Whether the window received initial configure event from the compositor. + #[inline] + pub fn is_configured(&self) -> bool { + self.last_configure.is_some() + } + + #[inline] + pub fn is_decorated(&mut self) -> bool { + let csd = self + .last_configure + .as_ref() + .map(|configure| configure.decoration_mode == DecorationMode::Client) + .unwrap_or(false); + if let Some(frame) = csd.then_some(self.frame.as_ref()).flatten() { + !frame.is_hidden() + } else { + // Server side decorations. + true + } + } + + /// Get the outer size of the window. + #[inline] + pub fn outer_size(&self) -> LogicalSize { + self.frame + .as_ref() + .map(|frame| frame.add_borders(self.size.width, self.size.height).into()) + .unwrap_or(self.size) + } + + /// Register pointer on the top-level. + pub fn pointer_entered(&mut self, added: Weak>) { + self.pointers.push(added); + self.reload_cursor_style(); + + let mode = self.cursor_grab_mode.user_grab_mode; + let _ = self.set_cursor_grab_inner(mode); + } + + /// Pointer has left the top-level. + pub fn pointer_left(&mut self, removed: Weak>) { + let mut new_pointers = Vec::new(); + for pointer in self.pointers.drain(..) { + if let Some(pointer) = pointer.upgrade() { + if pointer.pointer() != removed.upgrade().unwrap().pointer() { + new_pointers.push(Arc::downgrade(&pointer)); + } + } + } + + self.pointers = new_pointers; + } + + /// Refresh the decorations frame if it's present returning whether the client should redraw. + pub fn refresh_frame(&mut self) -> bool { + if let Some(frame) = self.frame.as_mut() { + if !frame.is_hidden() && frame.is_dirty() { + return frame.draw(); + } + } + + false + } + + /// Reload the cursor style on the given window. + pub fn reload_cursor_style(&mut self) { + if self.cursor_visible { + match &self.selected_cursor { + SelectedCursor::Named(icon) => self.set_cursor(*icon), + SelectedCursor::Custom(cursor) => self.apply_custom_cursor(cursor), + } + } else { + self.set_cursor_visible(self.cursor_visible); + } + } + + /// Reissue the transparency hint to the compositor. + pub fn reload_transparency_hint(&self) { + let surface = self.window.wl_surface(); + + if self.transparent { + surface.set_opaque_region(None); + } else if let Ok(region) = Region::new(&*self.compositor) { + region.add(0, 0, i32::MAX, i32::MAX); + surface.set_opaque_region(Some(region.wl_region())); + } else { + warn!("Failed to mark window opaque."); + } + } + + /// Try to resize the window when the user can do so. + pub fn request_inner_size(&mut self, inner_size: Size) -> PhysicalSize { + if self.last_configure.as_ref().map(Self::is_stateless).unwrap_or(true) { + self.resize(inner_size.to_logical(self.scale_factor())) + } + + logical_to_physical_rounded(self.inner_size(), self.scale_factor()) + } + + /// Resize the window to the new inner size. + fn resize(&mut self, inner_size: LogicalSize) { + self.size = inner_size; + + // Update the stateless size. + if Some(true) == self.last_configure.as_ref().map(Self::is_stateless) { + self.stateless_size = inner_size; + } + + // Update the inner frame. + let ((x, y), outer_size) = if let Some(frame) = self.frame.as_mut() { + // Resize only visible frame. + if !frame.is_hidden() { + frame.resize( + NonZeroU32::new(self.size.width).unwrap(), + NonZeroU32::new(self.size.height).unwrap(), + ); + } + + (frame.location(), frame.add_borders(self.size.width, self.size.height).into()) + } else { + ((0, 0), self.size) + }; + + // Reload the hint. + self.reload_transparency_hint(); + + // Set the window geometry. + self.window.xdg_surface().set_window_geometry( + x, + y, + outer_size.width as i32, + outer_size.height as i32, + ); + + // Update the target viewport, this is used if and only if fractional scaling is in use. + if let Some(viewport) = self.viewport.as_ref() { + // Set inner size without the borders. + viewport.set_destination(self.size.width as _, self.size.height as _); + } + } + + /// Get the scale factor of the window. + #[inline] + pub fn scale_factor(&self) -> f64 { + self.scale_factor + } + + /// Set the cursor icon. + pub fn set_cursor(&mut self, cursor_icon: CursorIcon) { + self.selected_cursor = SelectedCursor::Named(cursor_icon); + + if !self.cursor_visible { + return; + } + + self.apply_on_pointer(|pointer, _| { + if pointer.set_cursor(&self.connection, cursor_icon).is_err() { + warn!("Failed to set cursor to {:?}", cursor_icon); + } + }) + } + + /// Set the custom cursor icon. + pub(crate) fn set_custom_cursor(&mut self, cursor: RootCustomCursor) { + let cursor = match cursor { + RootCustomCursor { inner: PlatformCustomCursor::Wayland(cursor) } => cursor.0, + #[cfg(x11_platform)] + RootCustomCursor { inner: PlatformCustomCursor::X(_) } => { + tracing::error!("passed a X11 cursor to Wayland backend"); + return; + }, + }; + + let cursor = { + let mut pool = self.custom_cursor_pool.lock().unwrap(); + CustomCursor::new(&mut pool, &cursor) + }; + + if self.cursor_visible { + self.apply_custom_cursor(&cursor); + } + + self.selected_cursor = SelectedCursor::Custom(cursor); + } + + /// Set the resize increments of the window. + pub fn set_resize_increments(&mut self, increments: Option>) { + self.resize_increments = increments; + // NOTE: We don't update the window size here, because it will be done on the next resize + // or configure event. + } + + /// Get the resize increments of the window. + pub fn resize_increments(&self) -> Option> { + self.resize_increments + } + + fn apply_custom_cursor(&self, cursor: &CustomCursor) { + self.apply_on_pointer(|pointer, data| { + let surface = pointer.surface(); + + let scale = if let Some(viewport) = data.viewport() { + let scale = self.scale_factor(); + let size = PhysicalSize::new(cursor.w, cursor.h).to_logical(scale); + viewport.set_destination(size.width, size.height); + scale + } else { + let scale = surface.data::().unwrap().surface_data().scale_factor(); + surface.set_buffer_scale(scale); + scale as f64 + }; + + surface.attach(Some(cursor.buffer.wl_buffer()), 0, 0); + if surface.version() >= 4 { + surface.damage_buffer(0, 0, cursor.w, cursor.h); + } else { + let size = PhysicalSize::new(cursor.w, cursor.h).to_logical(scale); + surface.damage(0, 0, size.width, size.height); + } + surface.commit(); + + let serial = pointer + .pointer() + .data::() + .and_then(|data| data.pointer_data().latest_enter_serial()) + .unwrap(); + + let hotspot = + PhysicalPosition::new(cursor.hotspot_x, cursor.hotspot_y).to_logical(scale); + pointer.pointer().set_cursor(serial, Some(surface), hotspot.x, hotspot.y); + }); + } + + /// Set maximum inner window size. + pub fn set_min_inner_size(&mut self, size: Option>) { + // Ensure that the window has the right minimum size. + let mut size = size.unwrap_or(MIN_WINDOW_SIZE); + size.width = size.width.max(MIN_WINDOW_SIZE.width); + size.height = size.height.max(MIN_WINDOW_SIZE.height); + + // Add the borders. + let size = self + .frame + .as_ref() + .map(|frame| frame.add_borders(size.width, size.height).into()) + .unwrap_or(size); + + self.min_inner_size = size; + self.window.set_min_size(Some(size.into())); + } + + /// Set maximum inner window size. + pub fn set_max_inner_size(&mut self, size: Option>) { + let size = size.map(|size| { + self.frame + .as_ref() + .map(|frame| frame.add_borders(size.width, size.height).into()) + .unwrap_or(size) + }); + + self.max_inner_size = size; + self.window.set_max_size(size.map(Into::into)); + } + + /// Set the CSD theme. + pub fn set_theme(&mut self, theme: Option) { + self.theme = theme; + #[cfg(feature = "sctk-adwaita")] + if let Some(frame) = self.frame.as_mut() { + frame.set_config(into_sctk_adwaita_config(theme)) + } + } + + /// The current theme for CSD decorations. + #[inline] + pub fn theme(&self) -> Option { + self.theme + } + + /// Set the cursor grabbing state on the top-level. + pub fn set_cursor_grab(&mut self, mode: CursorGrabMode) -> Result<(), ExternalError> { + if self.cursor_grab_mode.user_grab_mode == mode { + return Ok(()); + } + + self.set_cursor_grab_inner(mode)?; + // Update user grab on success. + self.cursor_grab_mode.user_grab_mode = mode; + Ok(()) + } + + /// Reload the hints for minimum and maximum sizes. + pub fn reload_min_max_hints(&mut self) { + self.set_min_inner_size(Some(self.min_inner_size)); + self.set_max_inner_size(self.max_inner_size); + } + + /// Set the grabbing state on the surface. + fn set_cursor_grab_inner(&mut self, mode: CursorGrabMode) -> Result<(), ExternalError> { + let pointer_constraints = match self.pointer_constraints.as_ref() { + Some(pointer_constraints) => pointer_constraints, + None if mode == CursorGrabMode::None => return Ok(()), + None => return Err(ExternalError::NotSupported(NotSupportedError::new())), + }; + + let mut unset_old = false; + match self.cursor_grab_mode.current_grab_mode { + CursorGrabMode::None => unset_old = true, + CursorGrabMode::Confined => self.apply_on_pointer(|_, data| { + data.unconfine_pointer(); + unset_old = true; + }), + CursorGrabMode::Locked => { + self.apply_on_pointer(|_, data| { + data.unlock_pointer(); + unset_old = true; + }); + }, + } + + // In case we haven't unset the old mode, it means that we don't have a cursor above + // the window, thus just wait for it to re-appear. + if !unset_old { + return Ok(()); + } + + let mut set_mode = false; + let surface = self.window.wl_surface(); + match mode { + CursorGrabMode::Locked => self.apply_on_pointer(|pointer, data| { + let pointer = pointer.pointer(); + data.lock_pointer(pointer_constraints, surface, pointer, &self.queue_handle); + set_mode = true; + }), + CursorGrabMode::Confined => self.apply_on_pointer(|pointer, data| { + let pointer = pointer.pointer(); + data.confine_pointer(pointer_constraints, surface, pointer, &self.queue_handle); + set_mode = true; + }), + CursorGrabMode::None => { + // Current lock/confine was already removed. + set_mode = true; + }, + } + + // Replace the current grab mode after we've ensure that it got updated. + if set_mode { + self.cursor_grab_mode.current_grab_mode = mode; + } + + Ok(()) + } + + pub fn show_window_menu(&self, position: LogicalPosition) { + // TODO(kchibisov) handle touch serials. + self.apply_on_pointer(|_, data| { + let serial = data.latest_button_serial(); + let seat = data.seat(); + self.window.show_window_menu(seat, serial, position.into()); + }); + } + + /// Set the position of the cursor. + pub fn set_cursor_position(&self, position: LogicalPosition) -> Result<(), ExternalError> { + if self.pointer_constraints.is_none() { + return Err(ExternalError::NotSupported(NotSupportedError::new())); + } + + // Position can be set only for locked cursor. + if self.cursor_grab_mode.current_grab_mode != CursorGrabMode::Locked { + return Err(ExternalError::Os(os_error!(crate::platform_impl::OsError::Misc( + "cursor position can be set only for locked cursor." + )))); + } + + self.apply_on_pointer(|_, data| { + data.set_locked_cursor_position(position.x, position.y); + }); + + Ok(()) + } + + /// Set the visibility state of the cursor. + pub fn set_cursor_visible(&mut self, cursor_visible: bool) { + self.cursor_visible = cursor_visible; + + if self.cursor_visible { + match &self.selected_cursor { + SelectedCursor::Named(icon) => self.set_cursor(*icon), + SelectedCursor::Custom(cursor) => self.apply_custom_cursor(cursor), + } + } else { + for pointer in self.pointers.iter().filter_map(|pointer| pointer.upgrade()) { + let latest_enter_serial = pointer.pointer().winit_data().latest_enter_serial(); + + pointer.pointer().set_cursor(latest_enter_serial, None, 0, 0); + } + } + } + + /// Whether show or hide client side decorations. + #[inline] + pub fn set_decorate(&mut self, decorate: bool) { + if decorate == self.decorate { + return; + } + + self.decorate = decorate; + + match self.last_configure.as_ref().map(|configure| configure.decoration_mode) { + Some(DecorationMode::Server) if !self.decorate => { + // To disable decorations we should request client and hide the frame. + self.window.request_decoration_mode(Some(DecorationMode::Client)) + }, + _ if self.decorate => self.window.request_decoration_mode(Some(DecorationMode::Server)), + _ => (), + } + + if let Some(frame) = self.frame.as_mut() { + frame.set_hidden(!decorate); + // Force the resize. + self.resize(self.size); + } + } + + /// Add seat focus for the window. + #[inline] + pub fn add_seat_focus(&mut self, seat: ObjectId) { + self.seat_focus.insert(seat); + } + + /// Remove seat focus from the window. + #[inline] + pub fn remove_seat_focus(&mut self, seat: &ObjectId) { + self.seat_focus.remove(seat); + } + + /// Returns `true` if the requested state was applied. + pub fn set_ime_allowed(&mut self, allowed: bool) -> bool { + self.ime_allowed = allowed; + + let mut applied = false; + for text_input in &self.text_inputs { + applied = true; + if allowed { + text_input.enable(); + text_input.set_content_type_by_purpose(self.ime_purpose); + } else { + text_input.disable(); + } + text_input.commit(); + } + + applied + } + + /// Set the IME position. + pub fn set_ime_cursor_area(&self, position: LogicalPosition, size: LogicalSize) { + // FIXME: This won't fly unless user will have a way to request IME window per seat, since + // the ime windows will be overlapping, but winit doesn't expose API to specify for + // which seat we're setting IME position. + let (x, y) = (position.x as i32, position.y as i32); + let (width, height) = (size.width as i32, size.height as i32); + for text_input in self.text_inputs.iter() { + text_input.set_cursor_rectangle(x, y, width, height); + text_input.commit(); + } + } + + /// Set the IME purpose. + pub fn set_ime_purpose(&mut self, purpose: ImePurpose) { + self.ime_purpose = purpose; + + for text_input in &self.text_inputs { + text_input.set_content_type_by_purpose(purpose); + text_input.commit(); + } + } + + /// Get the IME purpose. + pub fn ime_purpose(&self) -> ImePurpose { + self.ime_purpose + } + + /// Set the scale factor for the given window. + #[inline] + pub fn set_scale_factor(&mut self, scale_factor: f64) { + self.scale_factor = scale_factor; + + // NOTE: When fractional scaling is not used update the buffer scale. + if self.fractional_scale.is_none() { + let _ = self.window.set_buffer_scale(self.scale_factor as _); + } + + if let Some(frame) = self.frame.as_mut() { + frame.set_scaling_factor(scale_factor); + } + } + + /// Make window background blurred + #[inline] + pub fn set_blur(&mut self, blurred: bool) { + if blurred && self.blur.is_none() { + if let Some(blur_manager) = self.blur_manager.as_ref() { + let blur = blur_manager.blur(self.window.wl_surface(), &self.queue_handle); + blur.commit(); + self.blur = Some(blur); + } else { + info!("Blur manager unavailable, unable to change blur") + } + } else if !blurred && self.blur.is_some() { + self.blur_manager.as_ref().unwrap().unset(self.window.wl_surface()); + self.blur.take().unwrap().release(); + } + } + + /// Set the window title to a new value. + /// + /// This will automatically truncate the title to something meaningful. + pub fn set_title(&mut self, mut title: String) { + // Truncate the title to at most 1024 bytes, so that it does not blow up the protocol + // messages + if title.len() > 1024 { + let mut new_len = 1024; + while !title.is_char_boundary(new_len) { + new_len -= 1; + } + title.truncate(new_len); + } + + // Update the CSD title. + if let Some(frame) = self.frame.as_mut() { + frame.set_title(&title); + } + + self.window.set_title(&title); + self.title = title; + } + + /// Mark the window as transparent. + #[inline] + pub fn set_transparent(&mut self, transparent: bool) { + self.transparent = transparent; + self.reload_transparency_hint(); + } + + /// Register text input on the top-level. + #[inline] + pub fn text_input_entered(&mut self, text_input: &ZwpTextInputV3) { + if !self.text_inputs.iter().any(|t| t == text_input) { + self.text_inputs.push(text_input.clone()); + } + } + + /// The text input left the top-level. + #[inline] + pub fn text_input_left(&mut self, text_input: &ZwpTextInputV3) { + if let Some(position) = self.text_inputs.iter().position(|t| t == text_input) { + self.text_inputs.remove(position); + } + } + + /// Get the cached title. + #[inline] + pub fn title(&self) -> &str { + &self.title + } +} + +impl Drop for WindowState { + fn drop(&mut self) { + if let Some(blur) = self.blur.take() { + blur.release(); + } + + if let Some(fs) = self.fractional_scale.take() { + fs.destroy(); + } + + if let Some(viewport) = self.viewport.take() { + viewport.destroy(); + } + + // NOTE: the wl_surface used by the window is being cleaned up when + // dropping SCTK `Window`. + } +} + +/// The state of the cursor grabs. +#[derive(Clone, Copy)] +struct GrabState { + /// The grab mode requested by the user. + user_grab_mode: CursorGrabMode, + + /// The current grab mode. + current_grab_mode: CursorGrabMode, +} + +impl GrabState { + fn new() -> Self { + Self { user_grab_mode: CursorGrabMode::None, current_grab_mode: CursorGrabMode::None } + } +} + +/// The state of the frame callback. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameCallbackState { + /// No frame callback was requested. + #[default] + None, + /// The frame callback was requested, but not yet arrived, the redraw events are throttled. + Requested, + /// The callback was marked as done, and user could receive redraw requested + Received, +} + +impl From for XdgResizeEdge { + fn from(value: ResizeDirection) -> Self { + match value { + ResizeDirection::North => XdgResizeEdge::Top, + ResizeDirection::West => XdgResizeEdge::Left, + ResizeDirection::NorthWest => XdgResizeEdge::TopLeft, + ResizeDirection::NorthEast => XdgResizeEdge::TopRight, + ResizeDirection::East => XdgResizeEdge::Right, + ResizeDirection::SouthWest => XdgResizeEdge::BottomLeft, + ResizeDirection::SouthEast => XdgResizeEdge::BottomRight, + ResizeDirection::South => XdgResizeEdge::Bottom, + } + } +} + +// NOTE: Rust doesn't allow `From>`. +#[cfg(feature = "sctk-adwaita")] +fn into_sctk_adwaita_config(theme: Option) -> sctk_adwaita::FrameConfig { + match theme { + Some(Theme::Light) => sctk_adwaita::FrameConfig::light(), + Some(Theme::Dark) => sctk_adwaita::FrameConfig::dark(), + None => sctk_adwaita::FrameConfig::auto(), + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/activation.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/activation.rs new file mode 100644 index 00000000..8f4aa796 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/activation.rs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! X11 activation handling. +//! +//! X11 has a "startup notification" specification similar to Wayland's, see this URL: +//! + +use super::atoms::*; +use super::{VoidCookie, X11Error, XConnection}; + +use std::ffi::CString; +use std::fmt::Write; + +use x11rb::protocol::xproto::{self, ConnectionExt as _}; + +impl XConnection { + /// "Request" a new activation token from the server. + pub(crate) fn request_activation_token(&self, window_title: &str) -> Result { + // The specification recommends the format "hostname+pid+"_TIME"+current time" + let uname = rustix::system::uname(); + let pid = rustix::process::getpid(); + let time = self.timestamp(); + + let activation_token = format!( + "{}{}_TIME{}", + uname.nodename().to_str().unwrap_or("winit"), + pid.as_raw_nonzero(), + time + ); + + // Set up the new startup notification. + let notification = { + let mut buffer = Vec::new(); + buffer.extend_from_slice(b"new: ID="); + quote_string(&activation_token, &mut buffer); + buffer.extend_from_slice(b" NAME="); + quote_string(window_title, &mut buffer); + buffer.extend_from_slice(b" SCREEN="); + push_display(&mut buffer, &self.default_screen_index()); + + CString::new(buffer) + .map_err(|err| X11Error::InvalidActivationToken(err.into_vec()))? + .into_bytes_with_nul() + }; + self.send_message(¬ification)?; + + Ok(activation_token) + } + + /// Finish launching a window with the given startup ID. + pub(crate) fn remove_activation_token( + &self, + window: xproto::Window, + startup_id: &str, + ) -> Result<(), X11Error> { + let atoms = self.atoms(); + + // Set the _NET_STARTUP_ID property on the window. + self.xcb_connection() + .change_property( + xproto::PropMode::REPLACE, + window, + atoms[_NET_STARTUP_ID], + xproto::AtomEnum::STRING, + 8, + startup_id.len().try_into().unwrap(), + startup_id.as_bytes(), + )? + .check()?; + + // Send the message indicating that the startup is over. + let message = { + const MESSAGE_ROOT: &str = "remove: ID="; + + let mut buffer = Vec::with_capacity( + MESSAGE_ROOT + .len() + .checked_add(startup_id.len()) + .and_then(|x| x.checked_add(1)) + .unwrap(), + ); + buffer.extend_from_slice(MESSAGE_ROOT.as_bytes()); + quote_string(startup_id, &mut buffer); + CString::new(buffer) + .map_err(|err| X11Error::InvalidActivationToken(err.into_vec()))? + .into_bytes_with_nul() + }; + + self.send_message(&message) + } + + /// Send a startup notification message to the window manager. + fn send_message(&self, message: &[u8]) -> Result<(), X11Error> { + let atoms = self.atoms(); + + // Create a new window to send the message over. + let screen = self.default_root(); + let window = xproto::WindowWrapper::create_window( + self.xcb_connection(), + screen.root_depth, + screen.root, + -100, + -100, + 1, + 1, + 0, + xproto::WindowClass::INPUT_OUTPUT, + screen.root_visual, + &xproto::CreateWindowAux::new().override_redirect(1).event_mask( + xproto::EventMask::STRUCTURE_NOTIFY | xproto::EventMask::PROPERTY_CHANGE, + ), + )?; + + // Serialize the messages in 20-byte chunks. + let mut message_type = atoms[_NET_STARTUP_INFO_BEGIN]; + message + .chunks(20) + .map(|chunk| { + let mut buffer = [0u8; 20]; + buffer[..chunk.len()].copy_from_slice(chunk); + let event = + xproto::ClientMessageEvent::new(8, window.window(), message_type, buffer); + + // Set the message type to the continuation atom for the next chunk. + message_type = atoms[_NET_STARTUP_INFO]; + + event + }) + .try_for_each(|event| { + // Send each event in order. + self.xcb_connection() + .send_event(false, screen.root, xproto::EventMask::PROPERTY_CHANGE, event) + .map(VoidCookie::ignore_error) + })?; + + Ok(()) + } +} + +/// Quote a literal string as per the startup notification specification. +fn quote_string(s: &str, target: &mut Vec) { + let total_len = s.len().checked_add(3).expect("quote string overflow"); + target.reserve(total_len); + + // Add the opening quote. + target.push(b'"'); + + // Iterate over the string split by literal quotes. + s.as_bytes().split(|&b| b == b'"').for_each(|part| { + // Add the part. + target.extend_from_slice(part); + + // Escape the quote. + target.push(b'\\'); + target.push(b'"'); + }); + + // Un-escape the last quote. + target.remove(target.len() - 2); +} + +/// Push a `Display` implementation to the buffer. +fn push_display(buffer: &mut Vec, display: &impl std::fmt::Display) { + struct Writer<'a> { + buffer: &'a mut Vec, + } + + impl std::fmt::Write for Writer<'_> { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.buffer.extend_from_slice(s.as_bytes()); + Ok(()) + } + } + + write!(Writer { buffer }, "{display}").unwrap(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn properly_escapes_x11_messages() { + let assert_eq = |input: &str, output: &[u8]| { + let mut buf = vec![]; + quote_string(input, &mut buf); + assert_eq!(buf, output); + }; + + assert_eq("", b"\"\""); + assert_eq("foo", b"\"foo\""); + assert_eq("foo\"bar", b"\"foo\\\"bar\""); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/atoms.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/atoms.rs new file mode 100644 index 00000000..c6d96c8f --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/atoms.rs @@ -0,0 +1,117 @@ +//! Collects every atom used by the platform implementation. + +use core::ops::Index; + +macro_rules! atom_manager { + ($($name:ident $(:$lit:literal)?),*) => { + x11rb::atom_manager! { + /// The atoms used by `winit` + pub Atoms: AtomsCookie { + $($name $(:$lit)?,)* + } + } + + /// Indices into the `Atoms` struct. + #[derive(Copy, Clone, Debug)] + #[allow(non_camel_case_types)] + pub enum AtomName { + $($name,)* + } + + impl AtomName { + pub(crate) fn atom_from( + self, + atoms: &Atoms + ) -> &x11rb::protocol::xproto::Atom { + match self { + $(AtomName::$name => &atoms.$name,)* + } + } + } + }; +} + +atom_manager! { + // General Use Atoms + CARD32, + UTF8_STRING, + WM_CHANGE_STATE, + WM_CLIENT_MACHINE, + WM_DELETE_WINDOW, + WM_PROTOCOLS, + WM_STATE, + XIM_SERVERS, + + // Assorted ICCCM Atoms + _NET_WM_ICON, + _NET_WM_MOVERESIZE, + _NET_WM_NAME, + _NET_WM_PID, + _NET_WM_PING, + _NET_WM_STATE, + _NET_WM_STATE_ABOVE, + _NET_WM_STATE_BELOW, + _NET_WM_STATE_FULLSCREEN, + _NET_WM_STATE_HIDDEN, + _NET_WM_STATE_MAXIMIZED_HORZ, + _NET_WM_STATE_MAXIMIZED_VERT, + _NET_WM_WINDOW_TYPE, + + // Activation atoms. + _NET_STARTUP_INFO_BEGIN, + _NET_STARTUP_INFO, + _NET_STARTUP_ID, + + // WM window types. + _NET_WM_WINDOW_TYPE_DESKTOP, + _NET_WM_WINDOW_TYPE_DOCK, + _NET_WM_WINDOW_TYPE_TOOLBAR, + _NET_WM_WINDOW_TYPE_MENU, + _NET_WM_WINDOW_TYPE_UTILITY, + _NET_WM_WINDOW_TYPE_SPLASH, + _NET_WM_WINDOW_TYPE_DIALOG, + _NET_WM_WINDOW_TYPE_DROPDOWN_MENU, + _NET_WM_WINDOW_TYPE_POPUP_MENU, + _NET_WM_WINDOW_TYPE_TOOLTIP, + _NET_WM_WINDOW_TYPE_NOTIFICATION, + _NET_WM_WINDOW_TYPE_COMBO, + _NET_WM_WINDOW_TYPE_DND, + _NET_WM_WINDOW_TYPE_NORMAL, + + // Drag-N-Drop Atoms + XdndAware, + XdndEnter, + XdndLeave, + XdndDrop, + XdndPosition, + XdndStatus, + XdndActionPrivate, + XdndSelection, + XdndFinished, + XdndTypeList, + TextUriList: b"text/uri-list", + None: b"None", + + // Miscellaneous Atoms + _GTK_THEME_VARIANT, + _MOTIF_WM_HINTS, + _NET_ACTIVE_WINDOW, + _NET_CLIENT_LIST, + _NET_FRAME_EXTENTS, + _NET_SUPPORTED, + _NET_SUPPORTING_WM_CHECK, + _XEMBED, + _XSETTINGS_SETTINGS +} + +impl Index for Atoms { + type Output = x11rb::protocol::xproto::Atom; + + fn index(&self, index: AtomName) -> &Self::Output { + index.atom_from(self) + } +} + +pub(crate) use AtomName::*; +// Make sure `None` is still defined. +pub(crate) use core::option::Option::None; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/dnd.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/dnd.rs new file mode 100644 index 00000000..691e40af --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/dnd.rs @@ -0,0 +1,174 @@ +use std::io; +use std::os::raw::*; +use std::path::{Path, PathBuf}; +use std::str::Utf8Error; +use std::sync::Arc; + +use percent_encoding::percent_decode; +use x11rb::protocol::xproto::{self, ConnectionExt}; + +use super::atoms::AtomName::None as DndNone; +use super::atoms::*; +use super::{util, CookieResultExt, X11Error, XConnection}; + +#[derive(Debug, Clone, Copy)] +pub enum DndState { + Accepted, + Rejected, +} + +#[derive(Debug)] +pub enum DndDataParseError { + EmptyData, + InvalidUtf8(#[allow(dead_code)] Utf8Error), + HostnameSpecified(#[allow(dead_code)] String), + UnexpectedProtocol(#[allow(dead_code)] String), + UnresolvablePath(#[allow(dead_code)] io::Error), +} + +impl From for DndDataParseError { + fn from(e: Utf8Error) -> Self { + DndDataParseError::InvalidUtf8(e) + } +} + +impl From for DndDataParseError { + fn from(e: io::Error) -> Self { + DndDataParseError::UnresolvablePath(e) + } +} + +pub struct Dnd { + xconn: Arc, + // Populated by XdndEnter event handler + pub version: Option, + pub type_list: Option>, + // Populated by XdndPosition event handler + pub source_window: Option, + // Populated by SelectionNotify event handler (triggered by XdndPosition event handler) + pub result: Option, DndDataParseError>>, +} + +impl Dnd { + pub fn new(xconn: Arc) -> Result { + Ok(Dnd { xconn, version: None, type_list: None, source_window: None, result: None }) + } + + pub fn reset(&mut self) { + self.version = None; + self.type_list = None; + self.source_window = None; + self.result = None; + } + + pub unsafe fn send_status( + &self, + this_window: xproto::Window, + target_window: xproto::Window, + state: DndState, + ) -> Result<(), X11Error> { + let atoms = self.xconn.atoms(); + let (accepted, action) = match state { + DndState::Accepted => (1, atoms[XdndActionPrivate]), + DndState::Rejected => (0, atoms[DndNone]), + }; + self.xconn + .send_client_msg(target_window, target_window, atoms[XdndStatus] as _, None, [ + this_window, + accepted, + 0, + 0, + action as _, + ])? + .ignore_error(); + + Ok(()) + } + + pub unsafe fn send_finished( + &self, + this_window: xproto::Window, + target_window: xproto::Window, + state: DndState, + ) -> Result<(), X11Error> { + let atoms = self.xconn.atoms(); + let (accepted, action) = match state { + DndState::Accepted => (1, atoms[XdndActionPrivate]), + DndState::Rejected => (0, atoms[DndNone]), + }; + self.xconn + .send_client_msg(target_window, target_window, atoms[XdndFinished] as _, None, [ + this_window, + accepted, + action as _, + 0, + 0, + ])? + .ignore_error(); + + Ok(()) + } + + pub unsafe fn get_type_list( + &self, + source_window: xproto::Window, + ) -> Result, util::GetPropertyError> { + let atoms = self.xconn.atoms(); + self.xconn.get_property( + source_window, + atoms[XdndTypeList], + xproto::Atom::from(xproto::AtomEnum::ATOM), + ) + } + + pub unsafe fn convert_selection(&self, window: xproto::Window, time: xproto::Timestamp) { + let atoms = self.xconn.atoms(); + self.xconn + .xcb_connection() + .convert_selection( + window, + atoms[XdndSelection], + atoms[TextUriList], + atoms[XdndSelection], + time, + ) + .expect_then_ignore_error("Failed to send XdndSelection event") + } + + pub unsafe fn read_data( + &self, + window: xproto::Window, + ) -> Result, util::GetPropertyError> { + let atoms = self.xconn.atoms(); + self.xconn.get_property(window, atoms[XdndSelection], atoms[TextUriList]) + } + + pub fn parse_data(&self, data: &mut [c_uchar]) -> Result, DndDataParseError> { + if !data.is_empty() { + let mut path_list = Vec::new(); + let decoded = percent_decode(data).decode_utf8()?.into_owned(); + for uri in decoded.split("\r\n").filter(|u| !u.is_empty()) { + // The format is specified as protocol://host/path + // However, it's typically simply protocol:///path + let path_str = if uri.starts_with("file://") { + let path_str = uri.replace("file://", ""); + if !path_str.starts_with('/') { + // A hostname is specified + // Supporting this case is beyond the scope of my mental health + return Err(DndDataParseError::HostnameSpecified(path_str)); + } + path_str + } else { + // Only the file protocol is supported + return Err(DndDataParseError::UnexpectedProtocol(uri.to_owned())); + }; + + let path = Path::new(&path_str).canonicalize()?; + path_list.push(path); + } + Ok(path_list) + } else { + Err(DndDataParseError::EmptyData) + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/event_processor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/event_processor.rs new file mode 100644 index 00000000..79f5c114 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/event_processor.rs @@ -0,0 +1,1883 @@ +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::os::raw::{c_char, c_int, c_long, c_ulong}; +use std::slice; +use std::sync::{Arc, Mutex}; + +use x11_dl::xinput2::{ + self, XIDeviceEvent, XIEnterEvent, XIFocusInEvent, XIFocusOutEvent, XIHierarchyEvent, + XILeaveEvent, XIModifierState, XIRawEvent, +}; +use x11_dl::xlib::{ + self, Display as XDisplay, Window as XWindow, XAnyEvent, XClientMessageEvent, XConfigureEvent, + XDestroyWindowEvent, XEvent, XExposeEvent, XKeyEvent, XMapEvent, XPropertyEvent, + XReparentEvent, XSelectionEvent, XVisibilityEvent, XkbAnyEvent, XkbStateRec, +}; +use x11rb::protocol::xinput; +use x11rb::protocol::xkb::ID as XkbId; +use x11rb::protocol::xproto::{self, ConnectionExt as _, ModMask}; +use x11rb::x11_utils::{ExtensionInformation, Serialize}; +use xkbcommon_dl::xkb_mod_mask_t; + +use crate::dpi::{PhysicalPosition, PhysicalSize}; +use crate::event::{ + DeviceEvent, ElementState, Event, Ime, InnerSizeWriter, MouseButton, MouseScrollDelta, + RawKeyEvent, Touch, TouchPhase, WindowEvent, +}; +use crate::event_loop::ActiveEventLoop as RootAEL; +use crate::keyboard::ModifiersState; +use crate::platform_impl::common::xkb::{self, XkbState}; +use crate::platform_impl::platform::common::xkb::Context; +use crate::platform_impl::platform::x11::ime::{ImeEvent, ImeEventReceiver, ImeRequest}; +use crate::platform_impl::platform::x11::ActiveEventLoop; +use crate::platform_impl::platform::ActiveEventLoop as PlatformActiveEventLoop; +use crate::platform_impl::x11::atoms::*; +use crate::platform_impl::x11::util::cookie::GenericEventCookie; +use crate::platform_impl::x11::{ + mkdid, mkwid, util, CookieResultExt, Device, DeviceId, DeviceInfo, Dnd, DndState, ImeReceiver, + ScrollOrientation, UnownedWindow, WindowId, +}; + +/// The maximum amount of X modifiers to replay. +pub const MAX_MOD_REPLAY_LEN: usize = 32; + +/// The X11 documentation states: "Keycodes lie in the inclusive range `[8, 255]`". +const KEYCODE_OFFSET: u8 = 8; + +pub struct EventProcessor { + pub dnd: Dnd, + pub ime_receiver: ImeReceiver, + pub ime_event_receiver: ImeEventReceiver, + pub randr_event_offset: u8, + pub devices: RefCell>, + pub xi2ext: ExtensionInformation, + pub xkbext: ExtensionInformation, + pub target: RootAEL, + pub xkb_context: Context, + // Number of touch events currently in progress + pub num_touch: u32, + // This is the last pressed key that is repeatable (if it hasn't been + // released). + // + // Used to detect key repeats. + pub held_key_press: Option, + pub first_touch: Option, + // Currently focused window belonging to this process + pub active_window: Option, + /// Latest modifiers we've sent for the user to trigger change in event. + pub modifiers: Cell, + // Track modifiers based on keycodes. NOTE: that serials generally don't work for tracking + // since they are not unique and could be duplicated in case of sequence of key events is + // delivered at near the same time. + pub xfiltered_modifiers: VecDeque, + pub xmodmap: util::ModifierKeymap, + pub is_composing: bool, +} + +impl EventProcessor { + pub fn process_event(&mut self, xev: &mut XEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + self.process_xevent(xev, &mut callback); + + let window_target = Self::window_target_mut(&mut self.target); + + // Handle IME requests. + while let Ok(request) = self.ime_receiver.try_recv() { + let ime = match window_target.ime.as_mut() { + Some(ime) => ime, + None => continue, + }; + let ime = ime.get_mut(); + match request { + ImeRequest::Position(window_id, x, y) => { + ime.send_xim_spot(window_id, x, y); + }, + ImeRequest::Allow(window_id, allowed) => { + ime.set_ime_allowed(window_id, allowed); + }, + } + } + + // Drain IME events. + while let Ok((window, event)) = self.ime_event_receiver.try_recv() { + let window_id = mkwid(window as xproto::Window); + let event = match event { + ImeEvent::Enabled => WindowEvent::Ime(Ime::Enabled), + ImeEvent::Start => { + self.is_composing = true; + WindowEvent::Ime(Ime::Preedit("".to_owned(), None)) + }, + ImeEvent::Update(text, position) if self.is_composing => { + WindowEvent::Ime(Ime::Preedit(text, Some((position, position)))) + }, + ImeEvent::End => { + self.is_composing = false; + // Issue empty preedit on `Done`. + WindowEvent::Ime(Ime::Preedit(String::new(), None)) + }, + ImeEvent::Disabled => { + self.is_composing = false; + WindowEvent::Ime(Ime::Disabled) + }, + _ => continue, + }; + + callback(&self.target, Event::WindowEvent { window_id, event }); + } + } + + /// XFilterEvent tells us when an event has been discarded by the input method. + /// Specifically, this involves all of the KeyPress events in compose/pre-edit sequences, + /// along with an extra copy of the KeyRelease events. This also prevents backspace and + /// arrow keys from being detected twice. + #[must_use] + fn filter_event(&mut self, xev: &mut XEvent) -> bool { + let wt = Self::window_target(&self.target); + unsafe { + (wt.xconn.xlib.XFilterEvent)(xev, { + let xev: &XAnyEvent = xev.as_ref(); + xev.window + }) == xlib::True + } + } + + fn process_xevent(&mut self, xev: &mut XEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let event_type = xev.get_type(); + + // If we have IME disabled, don't try to `filter_event`, since only IME can consume them + // and forward back. This is not desired for e.g. games since some IMEs may delay the input + // and game can toggle IME back when e.g. typing into some field where latency won't really + // matter. + let filtered = if event_type == xlib::KeyPress || event_type == xlib::KeyRelease { + let wt = Self::window_target(&self.target); + let ime = wt.ime.as_ref(); + let window = self.active_window.map(|window| window as XWindow); + let forward_to_ime = ime + .and_then(|ime| window.map(|window| ime.borrow().is_ime_allowed(window))) + .unwrap_or(false); + + let filtered = forward_to_ime && self.filter_event(xev); + if filtered { + let xev: &XKeyEvent = xev.as_ref(); + if self.xmodmap.is_modifier(xev.keycode as u8) { + // Don't grow the buffer past the `MAX_MOD_REPLAY_LEN`. This could happen + // when the modifiers are consumed entirely. + if self.xfiltered_modifiers.len() == MAX_MOD_REPLAY_LEN { + self.xfiltered_modifiers.pop_back(); + } + self.xfiltered_modifiers.push_front(xev.keycode as u8); + } + } + + filtered + } else { + self.filter_event(xev) + }; + + // Don't process event if it was filtered. + if filtered { + return; + } + + match event_type { + xlib::ClientMessage => self.client_message(xev.as_ref(), &mut callback), + xlib::SelectionNotify => self.selection_notify(xev.as_ref(), &mut callback), + xlib::ConfigureNotify => self.configure_notify(xev.as_ref(), &mut callback), + xlib::ReparentNotify => self.reparent_notify(xev.as_ref()), + xlib::MapNotify => self.map_notify(xev.as_ref(), &mut callback), + xlib::DestroyNotify => self.destroy_notify(xev.as_ref(), &mut callback), + xlib::PropertyNotify => self.property_notify(xev.as_ref(), &mut callback), + xlib::VisibilityNotify => self.visibility_notify(xev.as_ref(), &mut callback), + xlib::Expose => self.expose(xev.as_ref(), &mut callback), + // Note that in compose/pre-edit sequences, we'll always receive KeyRelease events. + ty @ xlib::KeyPress | ty @ xlib::KeyRelease => { + let state = if ty == xlib::KeyPress { + ElementState::Pressed + } else { + ElementState::Released + }; + + self.xinput_key_input(xev.as_mut(), state, &mut callback); + }, + xlib::GenericEvent => { + let wt = Self::window_target(&self.target); + let xev: GenericEventCookie = + match GenericEventCookie::from_event(wt.xconn.clone(), *xev) { + Some(xev) if xev.extension() == self.xi2ext.major_opcode => xev, + _ => return, + }; + + let evtype = xev.evtype(); + + match evtype { + ty @ xinput2::XI_ButtonPress | ty @ xinput2::XI_ButtonRelease => { + let state = if ty == xinput2::XI_ButtonPress { + ElementState::Pressed + } else { + ElementState::Released + }; + + let xev: &XIDeviceEvent = unsafe { xev.as_event() }; + self.update_mods_from_xinput2_event( + &xev.mods, + &xev.group, + false, + &mut callback, + ); + self.xinput2_button_input(xev, state, &mut callback); + }, + xinput2::XI_Motion => { + let xev: &XIDeviceEvent = unsafe { xev.as_event() }; + self.update_mods_from_xinput2_event( + &xev.mods, + &xev.group, + false, + &mut callback, + ); + self.xinput2_mouse_motion(xev, &mut callback); + }, + xinput2::XI_Enter => { + let xev: &XIEnterEvent = unsafe { xev.as_event() }; + self.xinput2_mouse_enter(xev, &mut callback); + }, + xinput2::XI_Leave => { + let xev: &XILeaveEvent = unsafe { xev.as_event() }; + self.update_mods_from_xinput2_event( + &xev.mods, + &xev.group, + false, + &mut callback, + ); + self.xinput2_mouse_left(xev, &mut callback); + }, + xinput2::XI_FocusIn => { + let xev: &XIFocusInEvent = unsafe { xev.as_event() }; + self.xinput2_focused(xev, &mut callback); + }, + xinput2::XI_FocusOut => { + let xev: &XIFocusOutEvent = unsafe { xev.as_event() }; + self.xinput2_unfocused(xev, &mut callback); + }, + xinput2::XI_TouchBegin | xinput2::XI_TouchUpdate | xinput2::XI_TouchEnd => { + let phase = match evtype { + xinput2::XI_TouchBegin => TouchPhase::Started, + xinput2::XI_TouchUpdate => TouchPhase::Moved, + xinput2::XI_TouchEnd => TouchPhase::Ended, + _ => unreachable!(), + }; + + let xev: &XIDeviceEvent = unsafe { xev.as_event() }; + self.xinput2_touch(xev, phase, &mut callback); + }, + xinput2::XI_RawButtonPress | xinput2::XI_RawButtonRelease => { + let state = match evtype { + xinput2::XI_RawButtonPress => ElementState::Pressed, + xinput2::XI_RawButtonRelease => ElementState::Released, + _ => unreachable!(), + }; + + let xev: &XIRawEvent = unsafe { xev.as_event() }; + self.xinput2_raw_button_input(xev, state, &mut callback); + }, + xinput2::XI_RawMotion => { + let xev: &XIRawEvent = unsafe { xev.as_event() }; + self.xinput2_raw_mouse_motion(xev, &mut callback); + }, + xinput2::XI_RawKeyPress | xinput2::XI_RawKeyRelease => { + let state = match evtype { + xinput2::XI_RawKeyPress => ElementState::Pressed, + xinput2::XI_RawKeyRelease => ElementState::Released, + _ => unreachable!(), + }; + + let xev: &xinput2::XIRawEvent = unsafe { xev.as_event() }; + self.xinput2_raw_key_input(xev, state, &mut callback); + }, + + xinput2::XI_HierarchyChanged => { + let xev: &XIHierarchyEvent = unsafe { xev.as_event() }; + self.xinput2_hierarchy_changed(xev, &mut callback); + }, + _ => {}, + } + }, + _ => { + if event_type == self.xkbext.first_event as _ { + let xev: &XkbAnyEvent = unsafe { &*(xev as *const _ as *const XkbAnyEvent) }; + self.xkb_event(xev, &mut callback); + } + if event_type == self.randr_event_offset as c_int { + self.process_dpi_change(&mut callback); + } + }, + } + } + + pub fn poll(&self) -> bool { + let window_target = Self::window_target(&self.target); + let result = unsafe { (window_target.xconn.xlib.XPending)(window_target.xconn.display) }; + + result != 0 + } + + pub unsafe fn poll_one_event(&mut self, event_ptr: *mut XEvent) -> bool { + let window_target = Self::window_target(&self.target); + // This function is used to poll and remove a single event + // from the Xlib event queue in a non-blocking, atomic way. + // XCheckIfEvent is non-blocking and removes events from queue. + // XNextEvent can't be used because it blocks while holding the + // global Xlib mutex. + // XPeekEvent does not remove events from the queue. + unsafe extern "C" fn predicate( + _display: *mut XDisplay, + _event: *mut XEvent, + _arg: *mut c_char, + ) -> c_int { + // This predicate always returns "true" (1) to accept all events + 1 + } + + let result = unsafe { + (window_target.xconn.xlib.XCheckIfEvent)( + window_target.xconn.display, + event_ptr, + Some(predicate), + std::ptr::null_mut(), + ) + }; + + result != 0 + } + + pub fn init_device(&self, device: xinput::DeviceId) { + let window_target = Self::window_target(&self.target); + let mut devices = self.devices.borrow_mut(); + if let Some(info) = DeviceInfo::get(&window_target.xconn, device as _) { + for info in info.iter() { + devices.insert(DeviceId(info.deviceid as _), Device::new(info)); + } + } + } + + pub fn with_window(&self, window_id: xproto::Window, callback: F) -> Option + where + F: Fn(&Arc) -> Ret, + { + let mut deleted = false; + let window_id = WindowId(window_id as _); + let window_target = Self::window_target(&self.target); + let result = window_target + .windows + .borrow() + .get(&window_id) + .and_then(|window| { + let arc = window.upgrade(); + deleted = arc.is_none(); + arc + }) + .map(|window| callback(&window)); + + if deleted { + // Garbage collection + window_target.windows.borrow_mut().remove(&window_id); + } + + result + } + + // NOTE: we avoid `self` to not borrow the entire `self` as not mut. + /// Get the platform window target. + pub fn window_target(window_target: &RootAEL) -> &ActiveEventLoop { + match &window_target.p { + PlatformActiveEventLoop::X(target) => target, + #[cfg(wayland_platform)] + _ => unreachable!(), + } + } + + /// Get the platform window target. + pub fn window_target_mut(window_target: &mut RootAEL) -> &mut ActiveEventLoop { + match &mut window_target.p { + PlatformActiveEventLoop::X(target) => target, + #[cfg(wayland_platform)] + _ => unreachable!(), + } + } + + fn client_message(&mut self, xev: &XClientMessageEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + let atoms = wt.xconn.atoms(); + + let window = xev.window as xproto::Window; + let window_id = mkwid(window); + + if xev.data.get_long(0) as xproto::Atom == wt.wm_delete_window { + let event = Event::WindowEvent { window_id, event: WindowEvent::CloseRequested }; + callback(&self.target, event); + return; + } + + if xev.data.get_long(0) as xproto::Atom == wt.net_wm_ping { + let client_msg = xproto::ClientMessageEvent { + response_type: xproto::CLIENT_MESSAGE_EVENT, + format: xev.format as _, + sequence: xev.serial as _, + window: wt.root, + type_: xev.message_type as _, + data: xproto::ClientMessageData::from({ + let [a, b, c, d, e]: [c_long; 5] = xev.data.as_longs().try_into().unwrap(); + [a as u32, b as u32, c as u32, d as u32, e as u32] + }), + }; + + wt.xconn + .xcb_connection() + .send_event( + false, + wt.root, + xproto::EventMask::SUBSTRUCTURE_NOTIFY + | xproto::EventMask::SUBSTRUCTURE_REDIRECT, + client_msg.serialize(), + ) + .expect_then_ignore_error("Failed to send `ClientMessage` event."); + return; + } + + if xev.message_type == atoms[XdndEnter] as c_ulong { + let source_window = xev.data.get_long(0) as xproto::Window; + let flags = xev.data.get_long(1); + let version = flags >> 24; + self.dnd.version = Some(version); + let has_more_types = flags - (flags & (c_long::MAX - 1)) == 1; + if !has_more_types { + let type_list = vec![ + xev.data.get_long(2) as xproto::Atom, + xev.data.get_long(3) as xproto::Atom, + xev.data.get_long(4) as xproto::Atom, + ]; + self.dnd.type_list = Some(type_list); + } else if let Ok(more_types) = unsafe { self.dnd.get_type_list(source_window) } { + self.dnd.type_list = Some(more_types); + } + return; + } + + if xev.message_type == atoms[XdndPosition] as c_ulong { + // This event occurs every time the mouse moves while a file's being dragged + // over our window. We emit HoveredFile in response; while the macOS backend + // does that upon a drag entering, XDND doesn't have access to the actual drop + // data until this event. For parity with other platforms, we only emit + // `HoveredFile` the first time, though if winit's API is later extended to + // supply position updates with `HoveredFile` or another event, implementing + // that here would be trivial. + + let source_window = xev.data.get_long(0) as xproto::Window; + + // Equivalent to `(x << shift) | y` + // where `shift = mem::size_of::() * 8` + // Note that coordinates are in "desktop space", not "window space" + // (in X11 parlance, they're root window coordinates) + // let packed_coordinates = xev.data.get_long(2); + // let shift = mem::size_of::() * 8; + // let x = packed_coordinates >> shift; + // let y = packed_coordinates & !(x << shift); + + // By our own state flow, `version` should never be `None` at this point. + let version = self.dnd.version.unwrap_or(5); + + // Action is specified in versions 2 and up, though we don't need it anyway. + // let action = xev.data.get_long(4); + + let accepted = if let Some(ref type_list) = self.dnd.type_list { + type_list.contains(&atoms[TextUriList]) + } else { + false + }; + + if !accepted { + unsafe { + self.dnd + .send_status(window, source_window, DndState::Rejected) + .expect("Failed to send `XdndStatus` message."); + } + self.dnd.reset(); + return; + } + + self.dnd.source_window = Some(source_window); + if self.dnd.result.is_none() { + let time = if version >= 1 { + xev.data.get_long(3) as xproto::Timestamp + } else { + // In version 0, time isn't specified + x11rb::CURRENT_TIME + }; + + // Log this timestamp. + wt.xconn.set_timestamp(time); + + // This results in the `SelectionNotify` event below + unsafe { + self.dnd.convert_selection(window, time); + } + } + + unsafe { + self.dnd + .send_status(window, source_window, DndState::Accepted) + .expect("Failed to send `XdndStatus` message."); + } + return; + } + + if xev.message_type == atoms[XdndDrop] as c_ulong { + let (source_window, state) = if let Some(source_window) = self.dnd.source_window { + if let Some(Ok(ref path_list)) = self.dnd.result { + for path in path_list { + let event = Event::WindowEvent { + window_id, + event: WindowEvent::DroppedFile(path.clone()), + }; + callback(&self.target, event); + } + } + (source_window, DndState::Accepted) + } else { + // `source_window` won't be part of our DND state if we already rejected the drop in + // our `XdndPosition` handler. + let source_window = xev.data.get_long(0) as xproto::Window; + (source_window, DndState::Rejected) + }; + + unsafe { + self.dnd + .send_finished(window, source_window, state) + .expect("Failed to send `XdndFinished` message."); + } + + self.dnd.reset(); + return; + } + + if xev.message_type == atoms[XdndLeave] as c_ulong { + self.dnd.reset(); + let event = Event::WindowEvent { window_id, event: WindowEvent::HoveredFileCancelled }; + callback(&self.target, event); + } + } + + fn selection_notify(&mut self, xev: &XSelectionEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + let atoms = wt.xconn.atoms(); + + let window = xev.requestor as xproto::Window; + let window_id = mkwid(window); + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + if xev.property != atoms[XdndSelection] as c_ulong { + return; + } + + // This is where we receive data from drag and drop + self.dnd.result = None; + if let Ok(mut data) = unsafe { self.dnd.read_data(window) } { + let parse_result = self.dnd.parse_data(&mut data); + if let Ok(ref path_list) = parse_result { + for path in path_list { + let event = Event::WindowEvent { + window_id, + event: WindowEvent::HoveredFile(path.clone()), + }; + callback(&self.target, event); + } + } + self.dnd.result = Some(parse_result); + } + } + + fn configure_notify(&self, xev: &XConfigureEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + let xwindow = xev.window as xproto::Window; + let window_id = mkwid(xwindow); + + let window = match self.with_window(xwindow, Arc::clone) { + Some(window) => window, + None => return, + }; + + // So apparently... + // `XSendEvent` (synthetic `ConfigureNotify`) -> position relative to root + // `XConfigureNotify` (real `ConfigureNotify`) -> position relative to parent + // https://tronche.com/gui/x/icccm/sec-4.html#s-4.1.5 + // We don't want to send `Moved` when this is false, since then every `Resized` + // (whether the window moved or not) is accompanied by an extraneous `Moved` event + // that has a position relative to the parent window. + let is_synthetic = xev.send_event == xlib::True; + + // These are both in physical space. + let new_inner_size = (xev.width as u32, xev.height as u32); + let new_inner_position = (xev.x, xev.y); + + let (mut resized, moved) = { + let mut shared_state_lock = window.shared_state_lock(); + + let resized = util::maybe_change(&mut shared_state_lock.size, new_inner_size); + let moved = if is_synthetic { + util::maybe_change(&mut shared_state_lock.inner_position, new_inner_position) + } else { + // Detect when frame extents change. + // Since this isn't synthetic, as per the notes above, this position is relative to + // the parent window. + let rel_parent = new_inner_position; + if util::maybe_change(&mut shared_state_lock.inner_position_rel_parent, rel_parent) + { + // This ensures we process the next `Moved`. + shared_state_lock.inner_position = None; + // Extra insurance against stale frame extents. + shared_state_lock.frame_extents = None; + } + false + }; + (resized, moved) + }; + + let position = window.shared_state_lock().position; + + let new_outer_position = if let (Some(position), false) = (position, moved) { + position + } else { + let mut shared_state_lock = window.shared_state_lock(); + + // We need to convert client area position to window position. + let frame_extents = + shared_state_lock.frame_extents.as_ref().cloned().unwrap_or_else(|| { + let frame_extents = wt.xconn.get_frame_extents_heuristic(xwindow, wt.root); + shared_state_lock.frame_extents = Some(frame_extents.clone()); + frame_extents + }); + let outer = + frame_extents.inner_pos_to_outer(new_inner_position.0, new_inner_position.1); + shared_state_lock.position = Some(outer); + + // Unlock shared state to prevent deadlock in callback below + drop(shared_state_lock); + + if moved { + callback(&self.target, Event::WindowEvent { + window_id, + event: WindowEvent::Moved(outer.into()), + }); + } + outer + }; + + if is_synthetic { + let mut shared_state_lock = window.shared_state_lock(); + // If we don't use the existing adjusted value when available, then the user can screw + // up the resizing by dragging across monitors *without* dropping the + // window. + let (width, height) = + shared_state_lock.dpi_adjusted.unwrap_or((xev.width as u32, xev.height as u32)); + + let last_scale_factor = shared_state_lock.last_monitor.scale_factor; + let new_scale_factor = { + let window_rect = util::AaRect::new(new_outer_position, new_inner_size); + let monitor = wt + .xconn + .get_monitor_for_window(Some(window_rect)) + .expect("Failed to find monitor for window"); + + if monitor.is_dummy() { + // Avoid updating monitor using a dummy monitor handle + last_scale_factor + } else { + shared_state_lock.last_monitor = monitor.clone(); + monitor.scale_factor + } + }; + if last_scale_factor != new_scale_factor { + let (new_width, new_height) = window.adjust_for_dpi( + last_scale_factor, + new_scale_factor, + width, + height, + &shared_state_lock, + ); + + let old_inner_size = PhysicalSize::new(width, height); + let new_inner_size = PhysicalSize::new(new_width, new_height); + + // Unlock shared state to prevent deadlock in callback below + drop(shared_state_lock); + + let inner_size = Arc::new(Mutex::new(new_inner_size)); + callback(&self.target, Event::WindowEvent { + window_id, + event: WindowEvent::ScaleFactorChanged { + scale_factor: new_scale_factor, + inner_size_writer: InnerSizeWriter::new(Arc::downgrade(&inner_size)), + }, + }); + + let new_inner_size = *inner_size.lock().unwrap(); + drop(inner_size); + + if new_inner_size != old_inner_size { + window.request_inner_size_physical(new_inner_size.width, new_inner_size.height); + window.shared_state_lock().dpi_adjusted = Some(new_inner_size.into()); + // if the DPI factor changed, force a resize event to ensure the logical + // size is computed with the right DPI factor + resized = true; + } + } + } + + // NOTE: Ensure that the lock is dropped before handling the resized and + // sending the event back to user. + let hittest = { + let mut shared_state_lock = window.shared_state_lock(); + let hittest = shared_state_lock.cursor_hittest; + + // This is a hack to ensure that the DPI adjusted resize is actually + // applied on all WMs. KWin doesn't need this, but Xfwm does. The hack + // should not be run on other WMs, since tiling WMs constrain the window + // size, making the resize fail. This would cause an endless stream of + // XResizeWindow requests, making Xorg, the winit client, and the WM + // consume 100% of CPU. + if let Some(adjusted_size) = shared_state_lock.dpi_adjusted { + if new_inner_size == adjusted_size || !util::wm_name_is_one_of(&["Xfwm4"]) { + // When this finally happens, the event will not be synthetic. + shared_state_lock.dpi_adjusted = None; + } else { + // Unlock shared state to prevent deadlock in callback below + drop(shared_state_lock); + window.request_inner_size_physical(adjusted_size.0, adjusted_size.1); + } + } + + hittest + }; + + // Reload hittest. + if hittest.unwrap_or(false) { + let _ = window.set_cursor_hittest(true); + } + + if resized { + callback(&self.target, Event::WindowEvent { + window_id, + event: WindowEvent::Resized(new_inner_size.into()), + }); + } + } + + /// This is generally a reliable way to detect when the window manager's been + /// replaced, though this event is only fired by reparenting window managers + /// (which is almost all of them). Failing to correctly update WM info doesn't + /// really have much impact, since on the WMs affected (xmonad, dwm, etc.) the only + /// effect is that we waste some time trying to query unsupported properties. + fn reparent_notify(&self, xev: &XReparentEvent) { + let wt = Self::window_target(&self.target); + + wt.xconn.update_cached_wm_info(wt.root); + + self.with_window(xev.window as xproto::Window, |window| { + window.invalidate_cached_frame_extents(); + }); + } + + fn map_notify(&self, xev: &XMapEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let window = xev.window as xproto::Window; + let window_id = mkwid(window); + + // NOTE: Re-issue the focus state when mapping the window. + // + // The purpose of it is to deliver initial focused state of the newly created + // window, given that we can't rely on `CreateNotify`, due to it being not + // sent. + let focus = self.with_window(window, |window| window.has_focus()).unwrap_or_default(); + let event = Event::WindowEvent { window_id, event: WindowEvent::Focused(focus) }; + + callback(&self.target, event); + } + + fn destroy_notify(&self, xev: &XDestroyWindowEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + let window = xev.window as xproto::Window; + let window_id = mkwid(window); + + // In the event that the window's been destroyed without being dropped first, we + // cleanup again here. + wt.windows.borrow_mut().remove(&WindowId(window as _)); + + // Since all XIM stuff needs to happen from the same thread, we destroy the input + // context here instead of when dropping the window. + if let Some(ime) = wt.ime.as_ref() { + ime.borrow_mut() + .remove_context(window as XWindow) + .expect("Failed to destroy input context"); + } + + callback(&self.target, Event::WindowEvent { window_id, event: WindowEvent::Destroyed }); + } + + fn property_notify(&mut self, xev: &XPropertyEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + let atoms = wt.x_connection().atoms(); + let atom = xev.atom as xproto::Atom; + + if atom == xproto::Atom::from(xproto::AtomEnum::RESOURCE_MANAGER) + || atom == atoms[_XSETTINGS_SETTINGS] + { + self.process_dpi_change(&mut callback); + } + } + + fn visibility_notify(&self, xev: &XVisibilityEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let xwindow = xev.window as xproto::Window; + + let event = Event::WindowEvent { + window_id: mkwid(xwindow), + event: WindowEvent::Occluded(xev.state == xlib::VisibilityFullyObscured), + }; + callback(&self.target, event); + + self.with_window(xwindow, |window| { + window.visibility_notify(); + }); + } + + fn expose(&self, xev: &XExposeEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + // Multiple Expose events may be received for subareas of a window. + // We issue `RedrawRequested` only for the last event of such a series. + if xev.count == 0 { + let window = xev.window as xproto::Window; + let window_id = mkwid(window); + + let event = Event::WindowEvent { window_id, event: WindowEvent::RedrawRequested }; + + callback(&self.target, event); + } + } + + fn xinput_key_input( + &mut self, + xev: &mut XKeyEvent, + state: ElementState, + mut callback: F, + ) where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + let window = match self.active_window { + Some(window) => window, + None => return, + }; + + let window_id = mkwid(window); + let device_id = mkdid(util::VIRTUAL_CORE_KEYBOARD); + + let keycode = xev.keycode as _; + + // Update state to track key repeats and determine whether this key was a repeat. + // + // Note, when a key is held before focusing on this window the first + // (non-synthetic) event will not be flagged as a repeat (also note that the + // synthetic press event that is generated before this when the window gains focus + // will also not be flagged as a repeat). + // + // Only keys that can repeat should change the held_key_press state since a + // continuously held repeatable key may continue repeating after the press of a + // non-repeatable key. + let key_repeats = + self.xkb_context.keymap_mut().map(|k| k.key_repeats(keycode)).unwrap_or(false); + let repeat = if key_repeats { + let is_latest_held = self.held_key_press == Some(keycode); + + if state == ElementState::Pressed { + self.held_key_press = Some(keycode); + is_latest_held + } else { + // Check that the released key is the latest repeatable key that has been + // pressed, since repeats will continue for the latest key press if a + // different previously pressed key is released. + if is_latest_held { + self.held_key_press = None; + } + false + } + } else { + false + }; + + // NOTE: When the modifier was captured by the XFilterEvents the modifiers for the modifier + // itself are out of sync due to XkbState being delivered before XKeyEvent, since it's + // being replayed by the XIM, thus we should replay ourselves. + let replay = if let Some(position) = + self.xfiltered_modifiers.iter().rev().position(|&s| s == xev.keycode as u8) + { + // We don't have to replay modifiers pressed before the current event if some events + // were not forwarded to us, since their state is irrelevant. + self.xfiltered_modifiers.resize(self.xfiltered_modifiers.len() - 1 - position, 0); + true + } else { + false + }; + + // Always update the modifiers when we're not replaying. + if !replay { + self.update_mods_from_core_event(window_id, xev.state as u16, &mut callback); + } + + if keycode != 0 && !self.is_composing { + // Don't alter the modifiers state from replaying. + if replay { + self.send_synthic_modifier_from_core(window_id, xev.state as u16, &mut callback); + } + + if let Some(mut key_processor) = self.xkb_context.key_context() { + let event = key_processor.process_key_event(keycode, state, repeat); + let event = Event::WindowEvent { + window_id, + event: WindowEvent::KeyboardInput { device_id, event, is_synthetic: false }, + }; + callback(&self.target, event); + } + + // Restore the client's modifiers state after replay. + if replay { + self.send_modifiers(window_id, self.modifiers.get(), true, &mut callback); + } + + return; + } + + let wt = Self::window_target(&self.target); + + if let Some(ic) = + wt.ime.as_ref().and_then(|ime| ime.borrow().get_context(window as XWindow)) + { + let written = wt.xconn.lookup_utf8(ic, xev); + if !written.is_empty() { + let event = Event::WindowEvent { + window_id, + event: WindowEvent::Ime(Ime::Preedit(String::new(), None)), + }; + callback(&self.target, event); + + let event = + Event::WindowEvent { window_id, event: WindowEvent::Ime(Ime::Commit(written)) }; + + self.is_composing = false; + callback(&self.target, event); + } + } + } + + fn send_synthic_modifier_from_core( + &mut self, + window_id: crate::window::WindowId, + state: u16, + mut callback: F, + ) where + F: FnMut(&RootAEL, Event), + { + let keymap = match self.xkb_context.keymap_mut() { + Some(keymap) => keymap, + None => return, + }; + + let wt = Self::window_target(&self.target); + let xcb = wt.xconn.xcb_connection().get_raw_xcb_connection(); + + // Use synthetic state since we're replaying the modifier. The user modifier state + // will be restored later. + let mut xkb_state = match XkbState::new_x11(xcb, keymap) { + Some(xkb_state) => xkb_state, + None => return, + }; + + let mask = self.xkb_mod_mask_from_core(state); + xkb_state.update_modifiers(mask, 0, 0, 0, 0, Self::core_keyboard_group(state)); + let mods: ModifiersState = xkb_state.modifiers().into(); + + let event = + Event::WindowEvent { window_id, event: WindowEvent::ModifiersChanged(mods.into()) }; + + callback(&self.target, event); + } + + fn xinput2_button_input( + &self, + event: &XIDeviceEvent, + state: ElementState, + mut callback: F, + ) where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + let window_id = mkwid(event.event as xproto::Window); + let device_id = mkdid(event.deviceid as xinput::DeviceId); + + // Set the timestamp. + wt.xconn.set_timestamp(event.time as xproto::Timestamp); + + // Deliver multi-touch events instead of emulated mouse events. + if (event.flags & xinput2::XIPointerEmulated) != 0 { + return; + } + + let event = match event.detail as u32 { + xlib::Button1 => { + WindowEvent::MouseInput { device_id, state, button: MouseButton::Left } + }, + xlib::Button2 => { + WindowEvent::MouseInput { device_id, state, button: MouseButton::Middle } + }, + + xlib::Button3 => { + WindowEvent::MouseInput { device_id, state, button: MouseButton::Right } + }, + + // Suppress emulated scroll wheel clicks, since we handle the real motion events for + // those. In practice, even clicky scroll wheels appear to be reported by + // evdev (and XInput2 in turn) as axis motion, so we don't otherwise + // special-case these button presses. + 4..=7 => WindowEvent::MouseWheel { + device_id, + delta: match event.detail { + 4 => MouseScrollDelta::LineDelta(0.0, 1.0), + 5 => MouseScrollDelta::LineDelta(0.0, -1.0), + 6 => MouseScrollDelta::LineDelta(1.0, 0.0), + 7 => MouseScrollDelta::LineDelta(-1.0, 0.0), + _ => unreachable!(), + }, + phase: TouchPhase::Moved, + }, + 8 => WindowEvent::MouseInput { device_id, state, button: MouseButton::Back }, + + 9 => WindowEvent::MouseInput { device_id, state, button: MouseButton::Forward }, + x => WindowEvent::MouseInput { device_id, state, button: MouseButton::Other(x as u16) }, + }; + + let event = Event::WindowEvent { window_id, event }; + callback(&self.target, event); + } + + fn xinput2_mouse_motion(&self, event: &XIDeviceEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + // Set the timestamp. + wt.xconn.set_timestamp(event.time as xproto::Timestamp); + + let device_id = mkdid(event.deviceid as xinput::DeviceId); + let window = event.event as xproto::Window; + let window_id = mkwid(window); + let new_cursor_pos = (event.event_x, event.event_y); + + let cursor_moved = self.with_window(window, |window| { + let mut shared_state_lock = window.shared_state_lock(); + util::maybe_change(&mut shared_state_lock.cursor_pos, new_cursor_pos) + }); + + if cursor_moved == Some(true) { + let position = PhysicalPosition::new(event.event_x, event.event_y); + + let event = Event::WindowEvent { + window_id, + event: WindowEvent::CursorMoved { device_id, position }, + }; + callback(&self.target, event); + } else if cursor_moved.is_none() { + return; + } + + // More gymnastics, for self.devices + let mask = unsafe { + slice::from_raw_parts(event.valuators.mask, event.valuators.mask_len as usize) + }; + let mut devices = self.devices.borrow_mut(); + let physical_device = match devices.get_mut(&DeviceId(event.sourceid as xinput::DeviceId)) { + Some(device) => device, + None => return, + }; + + let mut events = Vec::new(); + let mut value = event.valuators.values; + for i in 0..event.valuators.mask_len * 8 { + if !xinput2::XIMaskIsSet(mask, i) { + continue; + } + + let x = unsafe { *value }; + + let event = if let Some(&mut (_, ref mut info)) = + physical_device.scroll_axes.iter_mut().find(|&&mut (axis, _)| axis == i as _) + { + let delta = (x - info.position) / info.increment; + info.position = x; + // X11 vertical scroll coordinates are opposite to winit's + let delta = match info.orientation { + ScrollOrientation::Horizontal => { + MouseScrollDelta::LineDelta(-delta as f32, 0.0) + }, + ScrollOrientation::Vertical => MouseScrollDelta::LineDelta(0.0, -delta as f32), + }; + + WindowEvent::MouseWheel { device_id, delta, phase: TouchPhase::Moved } + } else { + WindowEvent::AxisMotion { device_id, axis: i as u32, value: unsafe { *value } } + }; + + events.push(Event::WindowEvent { window_id, event }); + + value = unsafe { value.offset(1) }; + } + + for event in events { + callback(&self.target, event); + } + } + + fn xinput2_mouse_enter(&self, event: &XIEnterEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + // Set the timestamp. + wt.xconn.set_timestamp(event.time as xproto::Timestamp); + + let window = event.event as xproto::Window; + let window_id = mkwid(window); + let device_id = mkdid(event.deviceid as xinput::DeviceId); + + if let Some(all_info) = DeviceInfo::get(&wt.xconn, super::ALL_DEVICES.into()) { + let mut devices = self.devices.borrow_mut(); + for device_info in all_info.iter() { + // The second expression is need for resetting to work correctly on i3, and + // presumably some other WMs. On those, `XI_Enter` doesn't include the physical + // device ID, so both `sourceid` and `deviceid` are the virtual device. + if device_info.deviceid == event.sourceid + || device_info.attachment == event.sourceid + { + let device_id = DeviceId(device_info.deviceid as _); + if let Some(device) = devices.get_mut(&device_id) { + device.reset_scroll_position(device_info); + } + } + } + } + + if self.window_exists(window) { + let position = PhysicalPosition::new(event.event_x, event.event_y); + + let event = + Event::WindowEvent { window_id, event: WindowEvent::CursorEntered { device_id } }; + callback(&self.target, event); + + let event = Event::WindowEvent { + window_id, + event: WindowEvent::CursorMoved { device_id, position }, + }; + callback(&self.target, event); + } + } + + fn xinput2_mouse_left(&self, event: &XILeaveEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + let window = event.event as xproto::Window; + + // Set the timestamp. + wt.xconn.set_timestamp(event.time as xproto::Timestamp); + + // Leave, FocusIn, and FocusOut can be received by a window that's already + // been destroyed, which the user presumably doesn't want to deal with. + if self.window_exists(window) { + let event = Event::WindowEvent { + window_id: mkwid(window), + event: WindowEvent::CursorLeft { + device_id: mkdid(event.deviceid as xinput::DeviceId), + }, + }; + callback(&self.target, event); + } + } + + fn xinput2_focused(&mut self, xev: &XIFocusInEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + let window = xev.event as xproto::Window; + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + if let Some(ime) = wt.ime.as_ref() { + ime.borrow_mut().focus(xev.event).expect("Failed to focus input context"); + } + + if self.active_window == Some(window) { + return; + } + + self.active_window = Some(window); + + wt.update_listen_device_events(true); + + let window_id = mkwid(window); + let position = PhysicalPosition::new(xev.event_x, xev.event_y); + + if let Some(window) = self.with_window(window, Arc::clone) { + window.shared_state_lock().has_focus = true; + } + + let event = Event::WindowEvent { window_id, event: WindowEvent::Focused(true) }; + callback(&self.target, event); + + // Issue key press events for all pressed keys + Self::handle_pressed_keys( + &self.target, + window_id, + ElementState::Pressed, + &mut self.xkb_context, + &mut callback, + ); + + self.update_mods_from_query(window_id, &mut callback); + + // The deviceid for this event is for a keyboard instead of a pointer, + // so we have to do a little extra work. + let pointer_id = self + .devices + .borrow() + .get(&DeviceId(xev.deviceid as xinput::DeviceId)) + .map(|device| device.attachment) + .unwrap_or(2); + + let event = Event::WindowEvent { + window_id, + event: WindowEvent::CursorMoved { device_id: mkdid(pointer_id as _), position }, + }; + callback(&self.target, event); + } + + fn xinput2_unfocused(&mut self, xev: &XIFocusOutEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + let window = xev.event as xproto::Window; + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + if !self.window_exists(window) { + return; + } + + if let Some(ime) = wt.ime.as_ref() { + ime.borrow_mut().unfocus(xev.event).expect("Failed to unfocus input context"); + } + + if self.active_window.take() == Some(window) { + let window_id = mkwid(window); + + wt.update_listen_device_events(false); + + // Clear the modifiers when unfocusing the window. + if let Some(xkb_state) = self.xkb_context.state_mut() { + xkb_state.update_modifiers(0, 0, 0, 0, 0, 0); + let mods = xkb_state.modifiers(); + self.send_modifiers(window_id, mods.into(), true, &mut callback); + } + + // Issue key release events for all pressed keys + Self::handle_pressed_keys( + &self.target, + window_id, + ElementState::Released, + &mut self.xkb_context, + &mut callback, + ); + + // Clear this so detecting key repeats is consistently handled when the + // window regains focus. + self.held_key_press = None; + + if let Some(window) = self.with_window(window, Arc::clone) { + window.shared_state_lock().has_focus = false; + } + + let event = Event::WindowEvent { window_id, event: WindowEvent::Focused(false) }; + callback(&self.target, event) + } + } + + fn xinput2_touch( + &mut self, + xev: &XIDeviceEvent, + phase: TouchPhase, + mut callback: F, + ) where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + let window = xev.event as xproto::Window; + if self.window_exists(window) { + let window_id = mkwid(window); + let id = xev.detail as u64; + let location = PhysicalPosition::new(xev.event_x, xev.event_y); + + // Mouse cursor position changes when touch events are received. + // Only the first concurrently active touch ID moves the mouse cursor. + if is_first_touch(&mut self.first_touch, &mut self.num_touch, id, phase) { + let event = Event::WindowEvent { + window_id, + event: WindowEvent::CursorMoved { + device_id: mkdid(util::VIRTUAL_CORE_POINTER), + position: location.cast(), + }, + }; + callback(&self.target, event); + } + + let event = Event::WindowEvent { + window_id, + event: WindowEvent::Touch(Touch { + device_id: mkdid(xev.deviceid as xinput::DeviceId), + phase, + location, + force: None, // TODO + id, + }), + }; + callback(&self.target, event) + } + } + + fn xinput2_raw_button_input( + &self, + xev: &XIRawEvent, + state: ElementState, + mut callback: F, + ) where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + if xev.flags & xinput2::XIPointerEmulated == 0 { + let event = Event::DeviceEvent { + device_id: mkdid(xev.deviceid as xinput::DeviceId), + event: DeviceEvent::Button { state, button: xev.detail as u32 }, + }; + callback(&self.target, event); + } + } + + fn xinput2_raw_mouse_motion(&self, xev: &XIRawEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + let did = mkdid(xev.deviceid as xinput::DeviceId); + + let mask = + unsafe { slice::from_raw_parts(xev.valuators.mask, xev.valuators.mask_len as usize) }; + let mut value = xev.raw_values; + let mut mouse_delta = util::Delta::default(); + let mut scroll_delta = util::Delta::default(); + for i in 0..xev.valuators.mask_len * 8 { + if !xinput2::XIMaskIsSet(mask, i) { + continue; + } + let x = unsafe { value.read_unaligned() }; + + // We assume that every XInput2 device with analog axes is a pointing device emitting + // relative coordinates. + match i { + 0 => mouse_delta.set_x(x), + 1 => mouse_delta.set_y(x), + 2 => scroll_delta.set_x(x as f32), + 3 => scroll_delta.set_y(x as f32), + _ => {}, + } + + let event = Event::DeviceEvent { + device_id: did, + event: DeviceEvent::Motion { axis: i as u32, value: x }, + }; + callback(&self.target, event); + + value = unsafe { value.offset(1) }; + } + + if let Some(mouse_delta) = mouse_delta.consume() { + let event = Event::DeviceEvent { + device_id: did, + event: DeviceEvent::MouseMotion { delta: mouse_delta }, + }; + callback(&self.target, event); + } + + if let Some(scroll_delta) = scroll_delta.consume() { + let event = Event::DeviceEvent { + device_id: did, + event: DeviceEvent::MouseWheel { + delta: MouseScrollDelta::LineDelta(scroll_delta.0, scroll_delta.1), + }, + }; + callback(&self.target, event); + } + } + + fn xinput2_raw_key_input( + &mut self, + xev: &XIRawEvent, + state: ElementState, + mut callback: F, + ) where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + let device_id = mkdid(xev.sourceid as xinput::DeviceId); + let keycode = xev.detail as u32; + if keycode < KEYCODE_OFFSET as u32 { + return; + } + let physical_key = xkb::raw_keycode_to_physicalkey(keycode); + + callback(&self.target, Event::DeviceEvent { + device_id, + event: DeviceEvent::Key(RawKeyEvent { physical_key, state }), + }); + } + + fn xinput2_hierarchy_changed(&mut self, xev: &XIHierarchyEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + let infos = unsafe { slice::from_raw_parts(xev.info, xev.num_info as usize) }; + for info in infos { + if 0 != info.flags & (xinput2::XISlaveAdded | xinput2::XIMasterAdded) { + self.init_device(info.deviceid as xinput::DeviceId); + callback(&self.target, Event::DeviceEvent { + device_id: mkdid(info.deviceid as xinput::DeviceId), + event: DeviceEvent::Added, + }); + } else if 0 != info.flags & (xinput2::XISlaveRemoved | xinput2::XIMasterRemoved) { + callback(&self.target, Event::DeviceEvent { + device_id: mkdid(info.deviceid as xinput::DeviceId), + event: DeviceEvent::Removed, + }); + let mut devices = self.devices.borrow_mut(); + devices.remove(&DeviceId(info.deviceid as xinput::DeviceId)); + } + } + } + + fn xkb_event(&mut self, xev: &XkbAnyEvent, mut callback: F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + match xev.xkb_type { + xlib::XkbNewKeyboardNotify => { + let xev = unsafe { &*(xev as *const _ as *const xlib::XkbNewKeyboardNotifyEvent) }; + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + let keycodes_changed_flag = 0x1; + let geometry_changed_flag = 0x1 << 1; + + let keycodes_changed = util::has_flag(xev.changed, keycodes_changed_flag); + let geometry_changed = util::has_flag(xev.changed, geometry_changed_flag); + + if xev.device == self.xkb_context.core_keyboard_id + && (keycodes_changed || geometry_changed) + { + let xcb = wt.xconn.xcb_connection().get_raw_xcb_connection(); + self.xkb_context.set_keymap_from_x11(xcb); + self.xmodmap.reload_from_x_connection(&wt.xconn); + + let window_id = match self.active_window.map(super::mkwid) { + Some(window_id) => window_id, + None => return, + }; + + if let Some(state) = self.xkb_context.state_mut() { + let mods = state.modifiers().into(); + self.send_modifiers(window_id, mods, true, &mut callback); + } + } + }, + xlib::XkbMapNotify => { + let xcb = wt.xconn.xcb_connection().get_raw_xcb_connection(); + self.xkb_context.set_keymap_from_x11(xcb); + self.xmodmap.reload_from_x_connection(&wt.xconn); + let window_id = match self.active_window.map(super::mkwid) { + Some(window_id) => window_id, + None => return, + }; + + if let Some(state) = self.xkb_context.state_mut() { + let mods = state.modifiers().into(); + self.send_modifiers(window_id, mods, true, &mut callback); + } + }, + xlib::XkbStateNotify => { + let xev = unsafe { &*(xev as *const _ as *const xlib::XkbStateNotifyEvent) }; + + // Set the timestamp. + wt.xconn.set_timestamp(xev.time as xproto::Timestamp); + + if let Some(state) = self.xkb_context.state_mut() { + state.update_modifiers( + xev.base_mods, + xev.latched_mods, + xev.locked_mods, + xev.base_group as u32, + xev.latched_group as u32, + xev.locked_group as u32, + ); + + let window_id = match self.active_window.map(super::mkwid) { + Some(window_id) => window_id, + None => return, + }; + + let mods = state.modifiers().into(); + self.send_modifiers(window_id, mods, true, &mut callback); + } + }, + _ => {}, + } + } + + pub fn update_mods_from_xinput2_event( + &mut self, + mods: &XIModifierState, + group: &XIModifierState, + force: bool, + mut callback: F, + ) where + F: FnMut(&RootAEL, Event), + { + if let Some(state) = self.xkb_context.state_mut() { + state.update_modifiers( + mods.base as u32, + mods.latched as u32, + mods.locked as u32, + group.base as u32, + group.latched as u32, + group.locked as u32, + ); + + // NOTE: we use active window since generally sub windows don't have keyboard input, + // and winit assumes that unfocused window doesn't have modifiers. + let window_id = match self.active_window.map(super::mkwid) { + Some(window_id) => window_id, + None => return, + }; + + let mods = state.modifiers(); + self.send_modifiers(window_id, mods.into(), force, &mut callback); + } + } + + fn update_mods_from_query( + &mut self, + window_id: crate::window::WindowId, + mut callback: F, + ) where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + + let xkb_state = match self.xkb_context.state_mut() { + Some(xkb_state) => xkb_state, + None => return, + }; + + unsafe { + let mut state: XkbStateRec = std::mem::zeroed(); + if (wt.xconn.xlib.XkbGetState)(wt.xconn.display, XkbId::USE_CORE_KBD.into(), &mut state) + == xlib::True + { + xkb_state.update_modifiers( + state.base_mods as u32, + state.latched_mods as u32, + state.locked_mods as u32, + state.base_group as u32, + state.latched_group as u32, + state.locked_group as u32, + ); + } + } + + let mods = xkb_state.modifiers(); + self.send_modifiers(window_id, mods.into(), true, &mut callback) + } + + pub fn update_mods_from_core_event( + &mut self, + window_id: crate::window::WindowId, + state: u16, + mut callback: F, + ) where + F: FnMut(&RootAEL, Event), + { + let xkb_mask = self.xkb_mod_mask_from_core(state); + let xkb_state = match self.xkb_context.state_mut() { + Some(xkb_state) => xkb_state, + None => return, + }; + + // NOTE: this is inspired by Qt impl. + let mut depressed = xkb_state.depressed_modifiers() & xkb_mask; + let latched = xkb_state.latched_modifiers() & xkb_mask; + let locked = xkb_state.locked_modifiers() & xkb_mask; + // Set modifiers in depressed if they don't appear in any of the final masks. + depressed |= !(depressed | latched | locked) & xkb_mask; + + xkb_state.update_modifiers( + depressed, + latched, + locked, + 0, + 0, + Self::core_keyboard_group(state), + ); + + let mods = xkb_state.modifiers(); + self.send_modifiers(window_id, mods.into(), false, &mut callback); + } + + // Bits 13 and 14 report the state keyboard group. + pub fn core_keyboard_group(state: u16) -> u32 { + ((state >> 13) & 3) as u32 + } + + pub fn xkb_mod_mask_from_core(&mut self, state: u16) -> xkb_mod_mask_t { + let mods_indices = match self.xkb_context.keymap_mut() { + Some(keymap) => keymap.mods_indices(), + None => return 0, + }; + + // Build the XKB modifiers from the regular state. + let mut depressed = 0u32; + if let Some(shift) = mods_indices.shift.filter(|_| ModMask::SHIFT.intersects(state)) { + depressed |= 1 << shift; + } + if let Some(caps) = mods_indices.caps.filter(|_| ModMask::LOCK.intersects(state)) { + depressed |= 1 << caps; + } + if let Some(ctrl) = mods_indices.ctrl.filter(|_| ModMask::CONTROL.intersects(state)) { + depressed |= 1 << ctrl; + } + if let Some(alt) = mods_indices.alt.filter(|_| ModMask::M1.intersects(state)) { + depressed |= 1 << alt; + } + if let Some(num) = mods_indices.num.filter(|_| ModMask::M2.intersects(state)) { + depressed |= 1 << num; + } + if let Some(mod3) = mods_indices.mod3.filter(|_| ModMask::M3.intersects(state)) { + depressed |= 1 << mod3; + } + if let Some(logo) = mods_indices.logo.filter(|_| ModMask::M4.intersects(state)) { + depressed |= 1 << logo; + } + if let Some(mod5) = mods_indices.mod5.filter(|_| ModMask::M5.intersects(state)) { + depressed |= 1 << mod5; + } + + depressed + } + + /// Send modifiers for the active window. + /// + /// The event won't be sent when the `modifiers` match the previously `sent` modifiers value, + /// unless `force` is passed. The `force` should be passed when the active window changes. + fn send_modifiers)>( + &self, + window_id: crate::window::WindowId, + modifiers: ModifiersState, + force: bool, + callback: &mut F, + ) { + // NOTE: Always update the modifiers to account for case when they've changed + // and forced was `true`. + if self.modifiers.replace(modifiers) != modifiers || force { + let event = Event::WindowEvent { + window_id, + event: WindowEvent::ModifiersChanged(self.modifiers.get().into()), + }; + callback(&self.target, event); + } + } + + fn handle_pressed_keys( + target: &RootAEL, + window_id: crate::window::WindowId, + state: ElementState, + xkb_context: &mut Context, + callback: &mut F, + ) where + F: FnMut(&RootAEL, Event), + { + let device_id = mkdid(util::VIRTUAL_CORE_KEYBOARD); + + // Update modifiers state and emit key events based on which keys are currently pressed. + let window_target = Self::window_target(target); + let xcb = window_target.xconn.xcb_connection().get_raw_xcb_connection(); + + let keymap = match xkb_context.keymap_mut() { + Some(keymap) => keymap, + None => return, + }; + + // Send the keys using the synthetic state to not alter the main state. + let mut xkb_state = match XkbState::new_x11(xcb, keymap) { + Some(xkb_state) => xkb_state, + None => return, + }; + let mut key_processor = match xkb_context.key_context_with_state(&mut xkb_state) { + Some(key_processor) => key_processor, + None => return, + }; + + for keycode in + window_target.xconn.query_keymap().into_iter().filter(|k| *k >= KEYCODE_OFFSET) + { + let event = key_processor.process_key_event(keycode as u32, state, false); + let event = Event::WindowEvent { + window_id, + event: WindowEvent::KeyboardInput { device_id, event, is_synthetic: true }, + }; + callback(target, event); + } + } + + fn process_dpi_change(&self, callback: &mut F) + where + F: FnMut(&RootAEL, Event), + { + let wt = Self::window_target(&self.target); + wt.xconn.reload_database().expect("failed to reload Xft database"); + + // In the future, it would be quite easy to emit monitor hotplug events. + let prev_list = { + let prev_list = wt.xconn.invalidate_cached_monitor_list(); + match prev_list { + Some(prev_list) => prev_list, + None => return, + } + }; + + let new_list = wt.xconn.available_monitors().expect("Failed to get monitor list"); + for new_monitor in new_list { + // Previous list may be empty, in case of disconnecting and + // reconnecting the only one monitor. We still need to emit events in + // this case. + let maybe_prev_scale_factor = prev_list + .iter() + .find(|prev_monitor| prev_monitor.name == new_monitor.name) + .map(|prev_monitor| prev_monitor.scale_factor); + if Some(new_monitor.scale_factor) != maybe_prev_scale_factor { + for window in wt.windows.borrow().iter().filter_map(|(_, w)| w.upgrade()) { + window.refresh_dpi_for_monitor(&new_monitor, maybe_prev_scale_factor, |event| { + callback(&self.target, event); + }) + } + } + } + } + + fn window_exists(&self, window_id: xproto::Window) -> bool { + self.with_window(window_id, |_| ()).is_some() + } +} + +fn is_first_touch(first: &mut Option, num: &mut u32, id: u64, phase: TouchPhase) -> bool { + match phase { + TouchPhase::Started => { + if *num == 0 { + *first = Some(id); + } + *num += 1; + }, + TouchPhase::Cancelled | TouchPhase::Ended => { + if *first == Some(id) { + *first = None; + } + *num = num.saturating_sub(1); + }, + _ => (), + } + + *first == Some(id) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ffi.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ffi.rs new file mode 100644 index 00000000..57bd78e9 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ffi.rs @@ -0,0 +1,5 @@ +pub use x11_dl::error::OpenError; +pub use x11_dl::xcursor::*; +pub use x11_dl::xinput2::*; +pub use x11_dl::xlib::*; +pub use x11_dl::xlib_xcb::*; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/callbacks.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/callbacks.rs new file mode 100644 index 00000000..fa0fa5c6 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/callbacks.rs @@ -0,0 +1,206 @@ +use std::collections::HashMap; +use std::os::raw::c_char; +use std::ptr; +use std::sync::Arc; + +use super::{ffi, XConnection, XError}; + +use super::context::{ImeContext, ImeContextCreationError}; +use super::inner::{close_im, ImeInner}; +use super::input_method::PotentialInputMethods; + +pub(crate) unsafe fn xim_set_callback( + xconn: &Arc, + xim: ffi::XIM, + field: *const c_char, + callback: *mut ffi::XIMCallback, +) -> Result<(), XError> { + // It's advisable to wrap variadic FFI functions in our own functions, as we want to minimize + // access that isn't type-checked. + unsafe { (xconn.xlib.XSetIMValues)(xim, field, callback, ptr::null_mut::<()>()) }; + xconn.check_errors() +} + +// Set a callback for when an input method matching the current locale modifiers becomes +// available. Note that this has nothing to do with what input methods are open or able to be +// opened, and simply uses the modifiers that are set when the callback is set. +// * This is called per locale modifier, not per input method opened with that locale modifier. +// * Trying to set this for multiple locale modifiers causes problems, i.e. one of the rebuilt input +// contexts would always silently fail to use the input method. +pub(crate) unsafe fn set_instantiate_callback( + xconn: &Arc, + client_data: ffi::XPointer, +) -> Result<(), XError> { + unsafe { + (xconn.xlib.XRegisterIMInstantiateCallback)( + xconn.display, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + Some(xim_instantiate_callback), + client_data, + ) + }; + xconn.check_errors() +} + +pub(crate) unsafe fn unset_instantiate_callback( + xconn: &Arc, + client_data: ffi::XPointer, +) -> Result<(), XError> { + unsafe { + (xconn.xlib.XUnregisterIMInstantiateCallback)( + xconn.display, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + Some(xim_instantiate_callback), + client_data, + ) + }; + xconn.check_errors() +} + +pub(crate) unsafe fn set_destroy_callback( + xconn: &Arc, + im: ffi::XIM, + inner: &ImeInner, +) -> Result<(), XError> { + unsafe { + xim_set_callback( + xconn, + im, + ffi::XNDestroyCallback_0.as_ptr() as *const _, + &inner.destroy_callback as *const _ as *mut _, + ) + } +} + +#[derive(Debug)] +#[allow(clippy::enum_variant_names)] +enum ReplaceImError { + // Boxed to prevent large error type + MethodOpenFailed(#[allow(dead_code)] Box), + ContextCreationFailed(#[allow(dead_code)] ImeContextCreationError), + SetDestroyCallbackFailed(#[allow(dead_code)] XError), +} + +// Attempt to replace current IM (which may or may not be presently valid) with a new one. This +// includes replacing all existing input contexts and free'ing resources as necessary. This only +// modifies existing state if all operations succeed. +unsafe fn replace_im(inner: *mut ImeInner) -> Result<(), ReplaceImError> { + let xconn = unsafe { &(*inner).xconn }; + + let (new_im, is_fallback) = { + let new_im = unsafe { (*inner).potential_input_methods.open_im(xconn, None) }; + let is_fallback = new_im.is_fallback(); + ( + new_im.ok().ok_or_else(|| { + ReplaceImError::MethodOpenFailed(Box::new(unsafe { + (*inner).potential_input_methods.clone() + })) + })?, + is_fallback, + ) + }; + + // It's important to always set a destroy callback, since there's otherwise potential for us + // to try to use or free a resource that's already been destroyed on the server. + { + let result = unsafe { set_destroy_callback(xconn, new_im.im, &*inner) }; + if result.is_err() { + let _ = unsafe { close_im(xconn, new_im.im) }; + } + result + } + .map_err(ReplaceImError::SetDestroyCallbackFailed)?; + + let mut new_contexts = HashMap::new(); + for (window, old_context) in unsafe { (*inner).contexts.iter() } { + let spot = old_context.as_ref().map(|old_context| old_context.ic_spot); + + // Check if the IME was allowed on that context. + let is_allowed = + old_context.as_ref().map(|old_context| old_context.is_allowed()).unwrap_or_default(); + + let new_context = { + let result = unsafe { + ImeContext::new( + xconn, + &new_im, + *window, + spot, + (*inner).event_sender.clone(), + is_allowed, + ) + }; + if result.is_err() { + let _ = unsafe { close_im(xconn, new_im.im) }; + } + result.map_err(ReplaceImError::ContextCreationFailed)? + }; + new_contexts.insert(*window, Some(new_context)); + } + + // If we've made it this far, everything succeeded. + unsafe { + let _ = (*inner).destroy_all_contexts_if_necessary(); + let _ = (*inner).close_im_if_necessary(); + (*inner).im = Some(new_im); + (*inner).contexts = new_contexts; + (*inner).is_destroyed = false; + (*inner).is_fallback = is_fallback; + } + Ok(()) +} + +pub unsafe extern "C" fn xim_instantiate_callback( + _display: *mut ffi::Display, + client_data: ffi::XPointer, + // This field is unsupplied. + _call_data: ffi::XPointer, +) { + let inner: *mut ImeInner = client_data as _; + if !inner.is_null() { + let xconn = unsafe { &(*inner).xconn }; + match unsafe { replace_im(inner) } { + Ok(()) => unsafe { + let _ = unset_instantiate_callback(xconn, client_data); + (*inner).is_fallback = false; + }, + Err(err) => unsafe { + if (*inner).is_destroyed { + // We have no usable input methods! + panic!("Failed to reopen input method: {err:?}"); + } + }, + } + } +} + +// This callback is triggered when the input method is closed on the server end. When this +// happens, XCloseIM/XDestroyIC doesn't need to be called, as the resources have already been +// free'd (attempting to do so causes our connection to freeze). +pub unsafe extern "C" fn xim_destroy_callback( + _xim: ffi::XIM, + client_data: ffi::XPointer, + // This field is unsupplied. + _call_data: ffi::XPointer, +) { + let inner: *mut ImeInner = client_data as _; + if !inner.is_null() { + unsafe { (*inner).is_destroyed = true }; + let xconn = unsafe { &(*inner).xconn }; + if unsafe { !(*inner).is_fallback } { + let _ = unsafe { set_instantiate_callback(xconn, client_data) }; + // Attempt to open fallback input method. + match unsafe { replace_im(inner) } { + Ok(()) => unsafe { (*inner).is_fallback = true }, + Err(err) => { + // We have no usable input methods! + panic!("Failed to open fallback input method: {err:?}"); + }, + } + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/context.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/context.rs new file mode 100644 index 00000000..2c6c075e --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/context.rs @@ -0,0 +1,376 @@ +use std::ffi::CStr; +use std::os::raw::c_short; +use std::sync::Arc; +use std::{mem, ptr}; + +use x11_dl::xlib::{XIMCallback, XIMPreeditCaretCallbackStruct, XIMPreeditDrawCallbackStruct}; + +use super::{ffi, util, XConnection, XError}; +use crate::platform_impl::platform::x11::ime::input_method::{InputMethod, Style, XIMStyle}; +use crate::platform_impl::platform::x11::ime::{ImeEvent, ImeEventSender}; + +/// IME creation error. +#[derive(Debug)] +pub enum ImeContextCreationError { + /// Got the error from Xlib. + XError(XError), + + /// Got null pointer from Xlib but without exact reason. + Null, +} + +/// The callback used by XIM preedit functions. +type XIMProcNonnull = unsafe extern "C" fn(ffi::XIM, ffi::XPointer, ffi::XPointer); + +/// Wrapper for creating XIM callbacks. +#[inline] +fn create_xim_callback(client_data: ffi::XPointer, callback: XIMProcNonnull) -> ffi::XIMCallback { + XIMCallback { client_data, callback: Some(callback) } +} + +/// The server started preedit. +extern "C" fn preedit_start_callback( + _xim: ffi::XIM, + client_data: ffi::XPointer, + _call_data: ffi::XPointer, +) -> i32 { + let client_data = unsafe { &mut *(client_data as *mut ImeContextClientData) }; + + client_data.text.clear(); + client_data.cursor_pos = 0; + client_data + .event_sender + .send((client_data.window, ImeEvent::Start)) + .expect("failed to send preedit start event"); + -1 +} + +/// Done callback is used when the preedit should be hidden. +extern "C" fn preedit_done_callback( + _xim: ffi::XIM, + client_data: ffi::XPointer, + _call_data: ffi::XPointer, +) { + let client_data = unsafe { &mut *(client_data as *mut ImeContextClientData) }; + + // Drop text buffer and reset cursor position on done. + client_data.text = Vec::new(); + client_data.cursor_pos = 0; + + client_data + .event_sender + .send((client_data.window, ImeEvent::End)) + .expect("failed to send preedit end event"); +} + +fn calc_byte_position(text: &[char], pos: usize) -> usize { + text.iter().take(pos).fold(0, |byte_pos, text| byte_pos + text.len_utf8()) +} + +/// Preedit text information to be drawn inline by the client. +extern "C" fn preedit_draw_callback( + _xim: ffi::XIM, + client_data: ffi::XPointer, + call_data: ffi::XPointer, +) { + let client_data = unsafe { &mut *(client_data as *mut ImeContextClientData) }; + let call_data = unsafe { &mut *(call_data as *mut XIMPreeditDrawCallbackStruct) }; + client_data.cursor_pos = call_data.caret as usize; + + let chg_range = + call_data.chg_first as usize..(call_data.chg_first + call_data.chg_length) as usize; + if chg_range.start > client_data.text.len() || chg_range.end > client_data.text.len() { + tracing::warn!( + "invalid chg range: buffer length={}, but chg_first={} chg_length={}", + client_data.text.len(), + call_data.chg_first, + call_data.chg_length + ); + return; + } + + // NULL indicate text deletion + let mut new_chars = if call_data.text.is_null() { + Vec::new() + } else { + let xim_text = unsafe { &mut *(call_data.text) }; + if xim_text.encoding_is_wchar > 0 { + return; + } + + let new_text = unsafe { xim_text.string.multi_byte }; + + if new_text.is_null() { + return; + } + + let new_text = unsafe { CStr::from_ptr(new_text) }; + + String::from(new_text.to_str().expect("Invalid UTF-8 String from IME")).chars().collect() + }; + let mut old_text_tail = client_data.text.split_off(chg_range.end); + client_data.text.truncate(chg_range.start); + client_data.text.append(&mut new_chars); + client_data.text.append(&mut old_text_tail); + let cursor_byte_pos = calc_byte_position(&client_data.text, client_data.cursor_pos); + + client_data + .event_sender + .send(( + client_data.window, + ImeEvent::Update(client_data.text.iter().collect(), cursor_byte_pos), + )) + .expect("failed to send preedit update event"); +} + +/// Handling of cursor movements in preedit text. +extern "C" fn preedit_caret_callback( + _xim: ffi::XIM, + client_data: ffi::XPointer, + call_data: ffi::XPointer, +) { + let client_data = unsafe { &mut *(client_data as *mut ImeContextClientData) }; + let call_data = unsafe { &mut *(call_data as *mut XIMPreeditCaretCallbackStruct) }; + + if call_data.direction == ffi::XIMCaretDirection::XIMAbsolutePosition { + client_data.cursor_pos = call_data.position as usize; + let cursor_byte_pos = calc_byte_position(&client_data.text, client_data.cursor_pos); + + client_data + .event_sender + .send(( + client_data.window, + ImeEvent::Update(client_data.text.iter().collect(), cursor_byte_pos), + )) + .expect("failed to send preedit update event"); + } +} + +/// Struct to simplify callback creation and latter passing into Xlib XIM. +struct PreeditCallbacks { + start_callback: ffi::XIMCallback, + done_callback: ffi::XIMCallback, + draw_callback: ffi::XIMCallback, + caret_callback: ffi::XIMCallback, +} + +impl PreeditCallbacks { + pub fn new(client_data: ffi::XPointer) -> PreeditCallbacks { + let start_callback = create_xim_callback(client_data, unsafe { + mem::transmute::( + preedit_start_callback as *const () as usize, + ) + }); + let done_callback = create_xim_callback(client_data, preedit_done_callback); + let caret_callback = create_xim_callback(client_data, preedit_caret_callback); + let draw_callback = create_xim_callback(client_data, preedit_draw_callback); + + PreeditCallbacks { start_callback, done_callback, caret_callback, draw_callback } + } +} + +struct ImeContextClientData { + window: ffi::Window, + event_sender: ImeEventSender, + text: Vec, + cursor_pos: usize, +} + +// XXX: this struct doesn't destroy its XIC resource when dropped. +// This is intentional, as it doesn't have enough information to know whether or not the context +// still exists on the server. Since `ImeInner` has that awareness, destruction must be handled +// through `ImeInner`. +pub struct ImeContext { + pub(crate) ic: ffi::XIC, + pub(crate) ic_spot: ffi::XPoint, + pub(crate) allowed: bool, + // Since the data is passed shared between X11 XIM callbacks, but couldn't be directly free + // from there we keep the pointer to automatically deallocate it. + _client_data: Box, +} + +impl ImeContext { + pub(crate) unsafe fn new( + xconn: &Arc, + im: &InputMethod, + window: ffi::Window, + ic_spot: Option, + event_sender: ImeEventSender, + allowed: bool, + ) -> Result { + let client_data = Box::into_raw(Box::new(ImeContextClientData { + window, + event_sender, + text: Vec::new(), + cursor_pos: 0, + })); + + let style = if allowed { im.preedit_style } else { im.none_style }; + + let ic = match style as _ { + Style::Preedit(style) => unsafe { + ImeContext::create_preedit_ic( + xconn, + im.im, + style, + window, + client_data as ffi::XPointer, + ) + }, + Style::Nothing(style) => unsafe { + ImeContext::create_nothing_ic(xconn, im.im, style, window) + }, + Style::None(style) => unsafe { + ImeContext::create_none_ic(xconn, im.im, style, window) + }, + } + .ok_or(ImeContextCreationError::Null)?; + + xconn.check_errors().map_err(ImeContextCreationError::XError)?; + + let mut context = ImeContext { + ic, + ic_spot: ffi::XPoint { x: 0, y: 0 }, + allowed, + _client_data: unsafe { Box::from_raw(client_data) }, + }; + + // Set the spot location, if it's present. + if let Some(ic_spot) = ic_spot { + context.set_spot(xconn, ic_spot.x, ic_spot.y) + } + + Ok(context) + } + + unsafe fn create_none_ic( + xconn: &Arc, + im: ffi::XIM, + style: XIMStyle, + window: ffi::Window, + ) -> Option { + let ic = unsafe { + (xconn.xlib.XCreateIC)( + im, + ffi::XNInputStyle_0.as_ptr() as *const _, + style, + ffi::XNClientWindow_0.as_ptr() as *const _, + window, + ptr::null_mut::<()>(), + ) + }; + + (!ic.is_null()).then_some(ic) + } + + unsafe fn create_preedit_ic( + xconn: &Arc, + im: ffi::XIM, + style: XIMStyle, + window: ffi::Window, + client_data: ffi::XPointer, + ) -> Option { + let preedit_callbacks = PreeditCallbacks::new(client_data); + let preedit_attr = util::memory::XSmartPointer::new(xconn, unsafe { + (xconn.xlib.XVaCreateNestedList)( + 0, + ffi::XNPreeditStartCallback_0.as_ptr() as *const _, + &(preedit_callbacks.start_callback) as *const _, + ffi::XNPreeditDoneCallback_0.as_ptr() as *const _, + &(preedit_callbacks.done_callback) as *const _, + ffi::XNPreeditCaretCallback_0.as_ptr() as *const _, + &(preedit_callbacks.caret_callback) as *const _, + ffi::XNPreeditDrawCallback_0.as_ptr() as *const _, + &(preedit_callbacks.draw_callback) as *const _, + ptr::null_mut::<()>(), + ) + }) + .expect("XVaCreateNestedList returned NULL"); + + let ic = unsafe { + (xconn.xlib.XCreateIC)( + im, + ffi::XNInputStyle_0.as_ptr() as *const _, + style, + ffi::XNClientWindow_0.as_ptr() as *const _, + window, + ffi::XNPreeditAttributes_0.as_ptr() as *const _, + preedit_attr.ptr, + ptr::null_mut::<()>(), + ) + }; + + (!ic.is_null()).then_some(ic) + } + + unsafe fn create_nothing_ic( + xconn: &Arc, + im: ffi::XIM, + style: XIMStyle, + window: ffi::Window, + ) -> Option { + let ic = unsafe { + (xconn.xlib.XCreateIC)( + im, + ffi::XNInputStyle_0.as_ptr() as *const _, + style, + ffi::XNClientWindow_0.as_ptr() as *const _, + window, + ptr::null_mut::<()>(), + ) + }; + + (!ic.is_null()).then_some(ic) + } + + pub(crate) fn focus(&self, xconn: &Arc) -> Result<(), XError> { + unsafe { + (xconn.xlib.XSetICFocus)(self.ic); + } + xconn.check_errors() + } + + pub(crate) fn unfocus(&self, xconn: &Arc) -> Result<(), XError> { + unsafe { + (xconn.xlib.XUnsetICFocus)(self.ic); + } + xconn.check_errors() + } + + pub fn is_allowed(&self) -> bool { + self.allowed + } + + // Set the spot for preedit text. Setting spot isn't working with libX11 when preedit callbacks + // are being used. Certain IMEs do show selection window, but it's placed in bottom left of the + // window and couldn't be changed. + // + // For me see: https://bugs.freedesktop.org/show_bug.cgi?id=1580. + pub(crate) fn set_spot(&mut self, xconn: &Arc, x: c_short, y: c_short) { + if !self.is_allowed() || self.ic_spot.x == x && self.ic_spot.y == y { + return; + } + + self.ic_spot = ffi::XPoint { x, y }; + + unsafe { + let preedit_attr = util::memory::XSmartPointer::new( + xconn, + (xconn.xlib.XVaCreateNestedList)( + 0, + ffi::XNSpotLocation_0.as_ptr(), + &self.ic_spot, + ptr::null_mut::<()>(), + ), + ) + .expect("XVaCreateNestedList returned NULL"); + + (xconn.xlib.XSetICValues)( + self.ic, + ffi::XNPreeditAttributes_0.as_ptr() as *const _, + preedit_attr.ptr, + ptr::null_mut::<()>(), + ); + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/inner.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/inner.rs new file mode 100644 index 00000000..da1ccf40 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/inner.rs @@ -0,0 +1,74 @@ +use std::collections::HashMap; +use std::mem; +use std::sync::Arc; + +use super::{ffi, XConnection, XError}; + +use super::context::ImeContext; +use super::input_method::{InputMethod, PotentialInputMethods}; +use crate::platform_impl::platform::x11::ime::ImeEventSender; + +pub(crate) unsafe fn close_im(xconn: &Arc, im: ffi::XIM) -> Result<(), XError> { + unsafe { (xconn.xlib.XCloseIM)(im) }; + xconn.check_errors() +} + +pub(crate) unsafe fn destroy_ic(xconn: &Arc, ic: ffi::XIC) -> Result<(), XError> { + unsafe { (xconn.xlib.XDestroyIC)(ic) }; + xconn.check_errors() +} + +pub(crate) struct ImeInner { + pub xconn: Arc, + pub im: Option, + pub potential_input_methods: PotentialInputMethods, + pub contexts: HashMap>, + // WARNING: this is initially zeroed! + pub destroy_callback: ffi::XIMCallback, + pub event_sender: ImeEventSender, + // Indicates whether or not the input method was destroyed on the server end + // (i.e. if ibus/fcitx/etc. was terminated/restarted) + pub is_destroyed: bool, + pub is_fallback: bool, +} + +impl ImeInner { + pub(crate) fn new( + xconn: Arc, + potential_input_methods: PotentialInputMethods, + event_sender: ImeEventSender, + ) -> Self { + ImeInner { + xconn, + im: None, + potential_input_methods, + contexts: HashMap::new(), + destroy_callback: unsafe { mem::zeroed() }, + event_sender, + is_destroyed: false, + is_fallback: false, + } + } + + pub unsafe fn close_im_if_necessary(&self) -> Result { + match self.im.as_ref() { + Some(im) if !self.is_destroyed => unsafe { close_im(&self.xconn, im.im).map(|_| true) }, + _ => Ok(false), + } + } + + pub unsafe fn destroy_ic_if_necessary(&self, ic: ffi::XIC) -> Result { + if !self.is_destroyed { + unsafe { destroy_ic(&self.xconn, ic) }.map(|_| true) + } else { + Ok(false) + } + } + + pub unsafe fn destroy_all_contexts_if_necessary(&self) -> Result { + for context in self.contexts.values().flatten() { + unsafe { self.destroy_ic_if_necessary(context.ic)? }; + } + Ok(!self.is_destroyed) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/input_method.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/input_method.rs new file mode 100644 index 00000000..e1f2fbd2 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/input_method.rs @@ -0,0 +1,345 @@ +use std::ffi::{CStr, CString, IntoStringError}; +use std::os::raw::{c_char, c_ulong, c_ushort}; +use std::sync::{Arc, Mutex}; +use std::{env, fmt, ptr}; + +use super::super::atoms::*; +use super::{ffi, util, XConnection, XError}; +use x11rb::protocol::xproto; + +static GLOBAL_LOCK: Mutex<()> = Mutex::new(()); + +unsafe fn open_im(xconn: &Arc, locale_modifiers: &CStr) -> Option { + let _lock = GLOBAL_LOCK.lock(); + + // XSetLocaleModifiers returns... + // * The current locale modifiers if it's given a NULL pointer. + // * The new locale modifiers if we succeeded in setting them. + // * NULL if the locale modifiers string is malformed or if the current locale is not supported + // by Xlib. + unsafe { (xconn.xlib.XSetLocaleModifiers)(locale_modifiers.as_ptr()) }; + + let im = unsafe { + (xconn.xlib.XOpenIM)(xconn.display, ptr::null_mut(), ptr::null_mut(), ptr::null_mut()) + }; + + if im.is_null() { + None + } else { + Some(im) + } +} + +#[derive(Debug)] +pub struct InputMethod { + pub im: ffi::XIM, + pub preedit_style: Style, + pub none_style: Style, + _name: String, +} + +impl InputMethod { + fn new(xconn: &Arc, im: ffi::XIM, name: String) -> Option { + let mut styles: *mut XIMStyles = std::ptr::null_mut(); + + // Query the styles supported by the XIM. + unsafe { + if !(xconn.xlib.XGetIMValues)( + im, + ffi::XNQueryInputStyle_0.as_ptr() as *const _, + (&mut styles) as *mut _, + std::ptr::null_mut::<()>(), + ) + .is_null() + { + return None; + } + } + + let mut preedit_style = None; + let mut none_style = None; + + unsafe { + std::slice::from_raw_parts((*styles).supported_styles, (*styles).count_styles as _) + .iter() + .for_each(|style| match *style { + XIM_PREEDIT_STYLE => { + preedit_style = Some(Style::Preedit(*style)); + }, + XIM_NOTHING_STYLE if preedit_style.is_none() => { + preedit_style = Some(Style::Nothing(*style)) + }, + XIM_NONE_STYLE => none_style = Some(Style::None(*style)), + _ => (), + }); + + (xconn.xlib.XFree)(styles.cast()); + }; + + if preedit_style.is_none() && none_style.is_none() { + return None; + } + + let preedit_style = preedit_style.unwrap_or_else(|| none_style.unwrap()); + let none_style = none_style.unwrap_or(preedit_style); + + Some(InputMethod { im, _name: name, preedit_style, none_style }) + } +} + +const XIM_PREEDIT_STYLE: XIMStyle = (ffi::XIMPreeditCallbacks | ffi::XIMStatusNothing) as XIMStyle; +const XIM_NOTHING_STYLE: XIMStyle = (ffi::XIMPreeditNothing | ffi::XIMStatusNothing) as XIMStyle; +const XIM_NONE_STYLE: XIMStyle = (ffi::XIMPreeditNone | ffi::XIMStatusNone) as XIMStyle; + +/// Style of the IME context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Style { + /// Preedit callbacks. + Preedit(XIMStyle), + + /// Nothing. + Nothing(XIMStyle), + + /// No IME. + None(XIMStyle), +} + +impl Default for Style { + fn default() -> Self { + Style::None(XIM_NONE_STYLE) + } +} + +#[repr(C)] +#[derive(Debug)] +struct XIMStyles { + count_styles: c_ushort, + supported_styles: *const XIMStyle, +} + +pub(crate) type XIMStyle = c_ulong; + +#[derive(Debug)] +pub enum InputMethodResult { + /// Input method used locale modifier from `XMODIFIERS` environment variable. + XModifiers(InputMethod), + /// Input method used internal fallback locale modifier. + Fallback(InputMethod), + /// Input method could not be opened using any locale modifier tried. + Failure, +} + +impl InputMethodResult { + pub fn is_fallback(&self) -> bool { + matches!(self, InputMethodResult::Fallback(_)) + } + + pub fn ok(self) -> Option { + use self::InputMethodResult::*; + match self { + XModifiers(im) | Fallback(im) => Some(im), + Failure => None, + } + } +} + +#[derive(Debug, Clone)] +enum GetXimServersError { + XError(#[allow(dead_code)] XError), + GetPropertyError(#[allow(dead_code)] util::GetPropertyError), + InvalidUtf8(#[allow(dead_code)] IntoStringError), +} + +impl From for GetXimServersError { + fn from(error: util::GetPropertyError) -> Self { + GetXimServersError::GetPropertyError(error) + } +} + +// The root window has a property named XIM_SERVERS, which contains a list of atoms representing +// the available XIM servers. For instance, if you're using ibus, it would contain an atom named +// "@server=ibus". It's possible for this property to contain multiple atoms, though presumably +// rare. Note that we replace "@server=" with "@im=" in order to match the format of locale +// modifiers, since we don't want a user who's looking at logs to ask "am I supposed to set +// XMODIFIERS to `@server=ibus`?!?" +unsafe fn get_xim_servers(xconn: &Arc) -> Result, GetXimServersError> { + let atoms = xconn.atoms(); + let servers_atom = atoms[XIM_SERVERS]; + + let root = unsafe { (xconn.xlib.XDefaultRootWindow)(xconn.display) }; + + let mut atoms: Vec = xconn + .get_property::( + root as xproto::Window, + servers_atom, + xproto::Atom::from(xproto::AtomEnum::ATOM), + ) + .map_err(GetXimServersError::GetPropertyError)? + .into_iter() + .map(|atom| atom as _) + .collect::>(); + + let mut names: Vec<*const c_char> = Vec::with_capacity(atoms.len()); + unsafe { + (xconn.xlib.XGetAtomNames)( + xconn.display, + atoms.as_mut_ptr(), + atoms.len() as _, + names.as_mut_ptr() as _, + ) + }; + unsafe { names.set_len(atoms.len()) }; + + let mut formatted_names = Vec::with_capacity(names.len()); + for name in names { + let string = unsafe { CStr::from_ptr(name) } + .to_owned() + .into_string() + .map_err(GetXimServersError::InvalidUtf8)?; + unsafe { (xconn.xlib.XFree)(name as _) }; + formatted_names.push(string.replace("@server=", "@im=")); + } + xconn.check_errors().map_err(GetXimServersError::XError)?; + Ok(formatted_names) +} + +#[derive(Clone)] +struct InputMethodName { + c_string: CString, + string: String, +} + +impl InputMethodName { + pub fn from_string(string: String) -> Self { + let c_string = CString::new(string.clone()) + .expect("String used to construct CString contained null byte"); + InputMethodName { c_string, string } + } + + pub fn from_str(string: &str) -> Self { + let c_string = + CString::new(string).expect("String used to construct CString contained null byte"); + InputMethodName { c_string, string: string.to_owned() } + } +} + +impl fmt::Debug for InputMethodName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.string.fmt(f) + } +} + +#[derive(Debug, Clone)] +struct PotentialInputMethod { + name: InputMethodName, + successful: Option, +} + +impl PotentialInputMethod { + pub fn from_string(string: String) -> Self { + PotentialInputMethod { name: InputMethodName::from_string(string), successful: None } + } + + pub fn from_str(string: &str) -> Self { + PotentialInputMethod { name: InputMethodName::from_str(string), successful: None } + } + + pub fn reset(&mut self) { + self.successful = None; + } + + pub fn open_im(&mut self, xconn: &Arc) -> Option { + let im = unsafe { open_im(xconn, &self.name.c_string) }; + self.successful = Some(im.is_some()); + im.and_then(|im| InputMethod::new(xconn, im, self.name.string.clone())) + } +} + +// By logging this struct, you get a sequential listing of every locale modifier tried, where it +// came from, and if it succeeded. +#[derive(Debug, Clone)] +pub(crate) struct PotentialInputMethods { + // On correctly configured systems, the XMODIFIERS environment variable tells us everything we + // need to know. + xmodifiers: Option, + // We have some standard options at our disposal that should ostensibly always work. For users + // who only need compose sequences, this ensures that the program launches without a hitch + // For users who need more sophisticated IME features, this is more or less a silent failure. + // Logging features should be added in the future to allow both audiences to be effectively + // served. + fallbacks: [PotentialInputMethod; 2], + // For diagnostic purposes, we include the list of XIM servers that the server reports as + // being available. + _xim_servers: Result, GetXimServersError>, +} + +impl PotentialInputMethods { + pub fn new(xconn: &Arc) -> Self { + let xmodifiers = env::var("XMODIFIERS").ok().map(PotentialInputMethod::from_string); + PotentialInputMethods { + // Since passing "" to XSetLocaleModifiers results in it defaulting to the value of + // XMODIFIERS, it's worth noting what happens if XMODIFIERS is also "". If simply + // running the program with `XMODIFIERS="" cargo run`, then assuming XMODIFIERS is + // defined in the profile (or parent environment) then that parent XMODIFIERS is used. + // If that XMODIFIERS value is also "" (i.e. if you ran `export XMODIFIERS=""`), then + // XSetLocaleModifiers uses the default local input method. Note that defining + // XMODIFIERS as "" is different from XMODIFIERS not being defined at all, since in + // that case, we get `None` and end up skipping ahead to the next method. + xmodifiers, + fallbacks: [ + // This is a standard input method that supports compose sequences, which should + // always be available. `@im=none` appears to mean the same thing. + PotentialInputMethod::from_str("@im=local"), + // This explicitly specifies to use the implementation-dependent default, though + // that seems to be equivalent to just using the local input method. + PotentialInputMethod::from_str("@im="), + ], + // The XIM_SERVERS property can have surprising values. For instance, when I exited + // ibus to run fcitx, it retained the value denoting ibus. Even more surprising is + // that the fcitx input method could only be successfully opened using "@im=ibus". + // Presumably due to this quirk, it's actually possible to alternate between ibus and + // fcitx in a running application. + _xim_servers: unsafe { get_xim_servers(xconn) }, + } + } + + // This resets the `successful` field of every potential input method, ensuring we have + // accurate information when this struct is re-used by the destruction/instantiation callbacks. + fn reset(&mut self) { + if let Some(ref mut input_method) = self.xmodifiers { + input_method.reset(); + } + + for input_method in &mut self.fallbacks { + input_method.reset(); + } + } + + pub fn open_im( + &mut self, + xconn: &Arc, + callback: Option<&dyn Fn()>, + ) -> InputMethodResult { + use self::InputMethodResult::*; + + self.reset(); + + if let Some(ref mut input_method) = self.xmodifiers { + let im = input_method.open_im(xconn); + if let Some(im) = im { + return XModifiers(im); + } else if let Some(ref callback) = callback { + callback(); + } + } + + for input_method in &mut self.fallbacks { + let im = input_method.open_im(xconn); + if let Some(im) = im { + return Fallback(im); + } + } + + Failure + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/mod.rs new file mode 100644 index 00000000..0a419c8d --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/ime/mod.rs @@ -0,0 +1,233 @@ +// Important: all XIM calls need to happen from the same thread! + +mod callbacks; +mod context; +mod inner; +mod input_method; + +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::Arc; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use self::callbacks::*; +use self::context::ImeContext; +pub use self::context::ImeContextCreationError; +use self::inner::{close_im, ImeInner}; +use self::input_method::PotentialInputMethods; +use super::{ffi, util, XConnection, XError}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum ImeEvent { + Enabled, + Start, + Update(String, usize), + End, + Disabled, +} + +pub type ImeReceiver = Receiver; +pub type ImeSender = Sender; +pub type ImeEventReceiver = Receiver<(ffi::Window, ImeEvent)>; +pub type ImeEventSender = Sender<(ffi::Window, ImeEvent)>; + +/// Request to control XIM handler from the window. +pub enum ImeRequest { + /// Set IME spot position for given `window_id`. + Position(ffi::Window, i16, i16), + + /// Allow IME input for the given `window_id`. + Allow(ffi::Window, bool), +} + +#[derive(Debug)] +pub(crate) enum ImeCreationError { + // Boxed to prevent large error type + OpenFailure(Box), + SetDestroyCallbackFailed(#[allow(dead_code)] XError), +} + +pub(crate) struct Ime { + xconn: Arc, + // The actual meat of this struct is boxed away, since it needs to have a fixed location in + // memory so we can pass a pointer to it around. + inner: Box, +} + +impl Ime { + pub fn new( + xconn: Arc, + event_sender: ImeEventSender, + ) -> Result { + let potential_input_methods = PotentialInputMethods::new(&xconn); + + let (mut inner, client_data) = { + let mut inner = Box::new(ImeInner::new(xconn, potential_input_methods, event_sender)); + let inner_ptr = Box::into_raw(inner); + let client_data = inner_ptr as _; + let destroy_callback = + ffi::XIMCallback { client_data, callback: Some(xim_destroy_callback) }; + inner = unsafe { Box::from_raw(inner_ptr) }; + inner.destroy_callback = destroy_callback; + (inner, client_data) + }; + + let xconn = Arc::clone(&inner.xconn); + + let input_method = inner.potential_input_methods.open_im( + &xconn, + Some(&|| { + let _ = unsafe { set_instantiate_callback(&xconn, client_data) }; + }), + ); + + let is_fallback = input_method.is_fallback(); + if let Some(input_method) = input_method.ok() { + inner.is_fallback = is_fallback; + unsafe { + let result = set_destroy_callback(&xconn, input_method.im, &inner) + .map_err(ImeCreationError::SetDestroyCallbackFailed); + if result.is_err() { + let _ = close_im(&xconn, input_method.im); + } + result?; + } + inner.im = Some(input_method); + Ok(Ime { xconn, inner }) + } else { + Err(ImeCreationError::OpenFailure(Box::new(inner.potential_input_methods))) + } + } + + pub fn is_destroyed(&self) -> bool { + self.inner.is_destroyed + } + + // This pattern is used for various methods here: + // Ok(_) indicates that nothing went wrong internally + // Ok(true) indicates that the action was actually performed + // Ok(false) indicates that the action is not presently applicable + pub fn create_context( + &mut self, + window: ffi::Window, + with_ime: bool, + ) -> Result { + let context = if self.is_destroyed() { + // Create empty entry in map, so that when IME is rebuilt, this window has a context. + None + } else { + let im = self.inner.im.as_ref().unwrap(); + + let context = unsafe { + ImeContext::new( + &self.inner.xconn, + im, + window, + None, + self.inner.event_sender.clone(), + with_ime, + )? + }; + + let event = if context.is_allowed() { ImeEvent::Enabled } else { ImeEvent::Disabled }; + self.inner.event_sender.send((window, event)).expect("Failed to send enabled event"); + + Some(context) + }; + + self.inner.contexts.insert(window, context); + Ok(!self.is_destroyed()) + } + + pub fn get_context(&self, window: ffi::Window) -> Option { + if self.is_destroyed() { + return None; + } + if let Some(Some(context)) = self.inner.contexts.get(&window) { + Some(context.ic) + } else { + None + } + } + + pub fn remove_context(&mut self, window: ffi::Window) -> Result { + if let Some(Some(context)) = self.inner.contexts.remove(&window) { + unsafe { + self.inner.destroy_ic_if_necessary(context.ic)?; + } + Ok(true) + } else { + Ok(false) + } + } + + pub fn focus(&mut self, window: ffi::Window) -> Result { + if self.is_destroyed() { + return Ok(false); + } + if let Some(&mut Some(ref mut context)) = self.inner.contexts.get_mut(&window) { + context.focus(&self.xconn).map(|_| true) + } else { + Ok(false) + } + } + + pub fn unfocus(&mut self, window: ffi::Window) -> Result { + if self.is_destroyed() { + return Ok(false); + } + if let Some(&mut Some(ref mut context)) = self.inner.contexts.get_mut(&window) { + context.unfocus(&self.xconn).map(|_| true) + } else { + Ok(false) + } + } + + pub fn send_xim_spot(&mut self, window: ffi::Window, x: i16, y: i16) { + if self.is_destroyed() { + return; + } + if let Some(&mut Some(ref mut context)) = self.inner.contexts.get_mut(&window) { + context.set_spot(&self.xconn, x as _, y as _); + } + } + + pub fn set_ime_allowed(&mut self, window: ffi::Window, allowed: bool) { + if self.is_destroyed() { + return; + } + + if let Some(&mut Some(ref mut context)) = self.inner.contexts.get_mut(&window) { + if allowed == context.is_allowed() { + return; + } + } + + // Remove context for that window. + let _ = self.remove_context(window); + + // Create new context supporting IME input. + let _ = self.create_context(window, allowed); + } + + pub fn is_ime_allowed(&self, window: ffi::Window) -> bool { + if self.is_destroyed() { + false + } else if let Some(Some(context)) = self.inner.contexts.get(&window) { + context.is_allowed() + } else { + false + } + } +} + +impl Drop for Ime { + fn drop(&mut self) { + unsafe { + let _ = self.inner.destroy_all_contexts_if_necessary(); + let _ = self.inner.close_im_if_necessary(); + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/mod.rs new file mode 100644 index 00000000..f29b314a --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/mod.rs @@ -0,0 +1,1064 @@ +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::ffi::CStr; +use std::marker::PhantomData; +use std::mem::MaybeUninit; +use std::ops::Deref; +use std::os::raw::*; +use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, RawFd}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::sync::{Arc, Weak}; +use std::time::{Duration, Instant}; +use std::{fmt, ptr, slice, str}; + +use calloop::generic::Generic; +use calloop::ping::Ping; +use calloop::{EventLoop as Loop, Readiness}; +use libc::{setlocale, LC_CTYPE}; +use tracing::warn; + +use x11rb::connection::RequestConnection; +use x11rb::errors::{ConnectError, ConnectionError, IdsExhausted, ReplyError}; +use x11rb::protocol::xinput::{self, ConnectionExt as _}; +use x11rb::protocol::xkb; +use x11rb::protocol::xproto::{self, ConnectionExt as _}; +use x11rb::x11_utils::X11Error as LogicalError; +use x11rb::xcb_ffi::ReplyOrIdError; + +use crate::error::{EventLoopError, OsError as RootOsError}; +use crate::event::{Event, StartCause, WindowEvent}; +use crate::event_loop::{ActiveEventLoop as RootAEL, ControlFlow, DeviceEvents, EventLoopClosed}; +use crate::platform::pump_events::PumpStatus; +use crate::platform_impl::common::xkb::Context; +use crate::platform_impl::platform::{min_timeout, WindowId}; +use crate::platform_impl::{ + ActiveEventLoop as PlatformActiveEventLoop, OsError, PlatformCustomCursor, +}; +use crate::window::{CustomCursor as RootCustomCursor, CustomCursorSource, WindowAttributes}; + +mod activation; +mod atoms; +mod dnd; +mod event_processor; +pub mod ffi; +mod ime; +mod monitor; +mod util; +mod window; +mod xdisplay; +mod xsettings; + +pub use util::CustomCursor; + +use atoms::*; +use dnd::{Dnd, DndState}; +use event_processor::{EventProcessor, MAX_MOD_REPLAY_LEN}; +use ime::{Ime, ImeCreationError, ImeReceiver, ImeRequest, ImeSender}; +pub(crate) use monitor::{MonitorHandle, VideoModeHandle}; +use window::UnownedWindow; +pub(crate) use xdisplay::{XConnection, XError, XNotSupported}; + +// Xinput constants not defined in x11rb +const ALL_DEVICES: u16 = 0; +const ALL_MASTER_DEVICES: u16 = 1; +const ICONIC_STATE: u32 = 3; + +/// The underlying x11rb connection that we are using. +type X11rbConnection = x11rb::xcb_ffi::XCBConnection; + +type X11Source = Generic>; + +struct WakeSender { + sender: Sender, + waker: Ping, +} + +impl Clone for WakeSender { + fn clone(&self) -> Self { + Self { sender: self.sender.clone(), waker: self.waker.clone() } + } +} + +impl WakeSender { + pub fn send(&self, t: T) -> Result<(), EventLoopClosed> { + let res = self.sender.send(t).map_err(|e| EventLoopClosed(e.0)); + if res.is_ok() { + self.waker.ping(); + } + res + } +} + +struct PeekableReceiver { + recv: Receiver, + first: Option, +} + +impl PeekableReceiver { + pub fn from_recv(recv: Receiver) -> Self { + Self { recv, first: None } + } + + pub fn has_incoming(&mut self) -> bool { + if self.first.is_some() { + return true; + } + + match self.recv.try_recv() { + Ok(v) => { + self.first = Some(v); + true + }, + Err(TryRecvError::Empty) => false, + Err(TryRecvError::Disconnected) => { + warn!("Channel was disconnected when checking incoming"); + false + }, + } + } + + pub fn try_recv(&mut self) -> Result { + if let Some(first) = self.first.take() { + return Ok(first); + } + self.recv.try_recv() + } +} + +pub struct ActiveEventLoop { + xconn: Arc, + wm_delete_window: xproto::Atom, + net_wm_ping: xproto::Atom, + ime_sender: ImeSender, + control_flow: Cell, + exit: Cell>, + root: xproto::Window, + ime: Option>, + windows: RefCell>>, + redraw_sender: WakeSender, + activation_sender: WakeSender, + device_events: Cell, +} + +pub struct EventLoop { + loop_running: bool, + event_loop: Loop<'static, EventLoopState>, + waker: calloop::ping::Ping, + event_processor: EventProcessor, + redraw_receiver: PeekableReceiver, + user_receiver: PeekableReceiver, + activation_receiver: PeekableReceiver, + user_sender: Sender, + + /// The current state of the event loop. + state: EventLoopState, +} + +type ActivationToken = (WindowId, crate::event_loop::AsyncRequestSerial); + +struct EventLoopState { + /// The latest readiness state for the x11 file descriptor + x11_readiness: Readiness, +} + +pub struct EventLoopProxy { + user_sender: WakeSender, +} + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + EventLoopProxy { user_sender: self.user_sender.clone() } + } +} + +impl EventLoop { + pub(crate) fn new(xconn: Arc) -> EventLoop { + let root = xconn.default_root().root; + let atoms = xconn.atoms(); + + let wm_delete_window = atoms[WM_DELETE_WINDOW]; + let net_wm_ping = atoms[_NET_WM_PING]; + + let dnd = Dnd::new(Arc::clone(&xconn)) + .expect("Failed to call XInternAtoms when initializing drag and drop"); + + let (ime_sender, ime_receiver) = mpsc::channel(); + let (ime_event_sender, ime_event_receiver) = mpsc::channel(); + // Input methods will open successfully without setting the locale, but it won't be + // possible to actually commit pre-edit sequences. + unsafe { + // Remember default locale to restore it if target locale is unsupported + // by Xlib + let default_locale = setlocale(LC_CTYPE, ptr::null()); + setlocale(LC_CTYPE, b"\0".as_ptr() as *const _); + + // Check if set locale is supported by Xlib. + // If not, calls to some Xlib functions like `XSetLocaleModifiers` + // will fail. + let locale_supported = (xconn.xlib.XSupportsLocale)() == 1; + if !locale_supported { + let unsupported_locale = setlocale(LC_CTYPE, ptr::null()); + warn!( + "Unsupported locale \"{}\". Restoring default locale \"{}\".", + CStr::from_ptr(unsupported_locale).to_string_lossy(), + CStr::from_ptr(default_locale).to_string_lossy() + ); + // Restore default locale + setlocale(LC_CTYPE, default_locale); + } + } + + let ime = Ime::new(Arc::clone(&xconn), ime_event_sender); + if let Err(ImeCreationError::OpenFailure(state)) = ime.as_ref() { + warn!("Failed to open input method: {state:#?}"); + } else if let Err(err) = ime.as_ref() { + warn!("Failed to set input method destruction callback: {err:?}"); + } + + let ime = ime.ok().map(RefCell::new); + + let randr_event_offset = + xconn.select_xrandr_input(root).expect("Failed to query XRandR extension"); + + let xi2ext = xconn + .xcb_connection() + .extension_information(xinput::X11_EXTENSION_NAME) + .expect("Failed to query XInput extension") + .expect("X server missing XInput extension"); + let xkbext = xconn + .xcb_connection() + .extension_information(xkb::X11_EXTENSION_NAME) + .expect("Failed to query XKB extension") + .expect("X server missing XKB extension"); + + // Check for XInput2 support. + xconn + .xcb_connection() + .xinput_xi_query_version(2, 3) + .expect("Failed to send XInput2 query version request") + .reply() + .expect("Error while checking for XInput2 query version reply"); + + xconn.update_cached_wm_info(root); + + // Create an event loop. + let event_loop = + Loop::::try_new().expect("Failed to initialize the event loop"); + let handle = event_loop.handle(); + + // Create the X11 event dispatcher. + let source = X11Source::new( + // SAFETY: xcb owns the FD and outlives the source. + unsafe { BorrowedFd::borrow_raw(xconn.xcb_connection().as_raw_fd()) }, + calloop::Interest::READ, + calloop::Mode::Level, + ); + handle + .insert_source(source, |readiness, _, state| { + state.x11_readiness = readiness; + Ok(calloop::PostAction::Continue) + }) + .expect("Failed to register the X11 event dispatcher"); + + let (waker, waker_source) = + calloop::ping::make_ping().expect("Failed to create event loop waker"); + event_loop + .handle() + .insert_source(waker_source, move |_, _, _| { + // No extra handling is required, we just need to wake-up. + }) + .expect("Failed to register the event loop waker source"); + + // Create a channel for handling redraw requests. + let (redraw_sender, redraw_channel) = mpsc::channel(); + + // Create a channel for sending activation tokens. + let (activation_token_sender, activation_token_channel) = mpsc::channel(); + + // Create a channel for sending user events. + let (user_sender, user_channel) = mpsc::channel(); + + let xkb_context = + Context::from_x11_xkb(xconn.xcb_connection().get_raw_xcb_connection()).unwrap(); + + let mut xmodmap = util::ModifierKeymap::new(); + xmodmap.reload_from_x_connection(&xconn); + + let window_target = ActiveEventLoop { + ime, + root, + control_flow: Cell::new(ControlFlow::default()), + exit: Cell::new(None), + windows: Default::default(), + ime_sender, + xconn, + wm_delete_window, + net_wm_ping, + redraw_sender: WakeSender { + sender: redraw_sender, // not used again so no clone + waker: waker.clone(), + }, + activation_sender: WakeSender { + sender: activation_token_sender, // not used again so no clone + waker: waker.clone(), + }, + device_events: Default::default(), + }; + + // Set initial device event filter. + window_target.update_listen_device_events(true); + + let root_window_target = + RootAEL { p: PlatformActiveEventLoop::X(window_target), _marker: PhantomData }; + + let event_processor = EventProcessor { + target: root_window_target, + dnd, + devices: Default::default(), + randr_event_offset, + ime_receiver, + ime_event_receiver, + xi2ext, + xfiltered_modifiers: VecDeque::with_capacity(MAX_MOD_REPLAY_LEN), + xmodmap, + xkbext, + xkb_context, + num_touch: 0, + held_key_press: None, + first_touch: None, + active_window: None, + modifiers: Default::default(), + is_composing: false, + }; + + // Register for device hotplug events + // (The request buffer is flushed during `init_device`) + let xconn = &EventProcessor::window_target(&event_processor.target).xconn; + + xconn + .select_xinput_events( + root, + ALL_DEVICES, + x11rb::protocol::xinput::XIEventMask::HIERARCHY, + ) + .expect_then_ignore_error("Failed to register for XInput2 device hotplug events"); + + xconn + .select_xkb_events( + 0x100, // Use the "core keyboard device" + xkb::EventType::NEW_KEYBOARD_NOTIFY + | xkb::EventType::MAP_NOTIFY + | xkb::EventType::STATE_NOTIFY, + ) + .unwrap(); + + event_processor.init_device(ALL_DEVICES); + + EventLoop { + loop_running: false, + event_loop, + waker, + event_processor, + redraw_receiver: PeekableReceiver::from_recv(redraw_channel), + activation_receiver: PeekableReceiver::from_recv(activation_token_channel), + user_receiver: PeekableReceiver::from_recv(user_channel), + user_sender, + state: EventLoopState { x11_readiness: Readiness::EMPTY }, + } + } + + pub fn create_proxy(&self) -> EventLoopProxy { + EventLoopProxy { + user_sender: WakeSender { sender: self.user_sender.clone(), waker: self.waker.clone() }, + } + } + + pub(crate) fn window_target(&self) -> &RootAEL { + &self.event_processor.target + } + + pub fn run_on_demand(&mut self, mut event_handler: F) -> Result<(), EventLoopError> + where + F: FnMut(Event, &RootAEL), + { + let exit = loop { + match self.pump_events(None, &mut event_handler) { + PumpStatus::Exit(0) => { + break Ok(()); + }, + PumpStatus::Exit(code) => { + break Err(EventLoopError::ExitFailure(code)); + }, + _ => { + continue; + }, + } + }; + + // Applications aren't allowed to carry windows between separate + // `run_on_demand` calls but if they have only just dropped their + // windows we need to make sure those last requests are sent to the + // X Server. + let wt = EventProcessor::window_target(&self.event_processor.target); + wt.x_connection().sync_with_server().map_err(|x_err| { + EventLoopError::Os(os_error!(OsError::XError(Arc::new(X11Error::Xlib(x_err))))) + })?; + + exit + } + + pub fn pump_events(&mut self, timeout: Option, mut callback: F) -> PumpStatus + where + F: FnMut(Event, &RootAEL), + { + if !self.loop_running { + self.loop_running = true; + + // run the initial loop iteration + self.single_iteration(&mut callback, StartCause::Init); + } + + // Consider the possibility that the `StartCause::Init` iteration could + // request to Exit. + if !self.exiting() { + self.poll_events_with_timeout(timeout, &mut callback); + } + if let Some(code) = self.exit_code() { + self.loop_running = false; + + callback(Event::LoopExiting, self.window_target()); + + PumpStatus::Exit(code) + } else { + PumpStatus::Continue + } + } + + fn has_pending(&mut self) -> bool { + self.event_processor.poll() + || self.user_receiver.has_incoming() + || self.redraw_receiver.has_incoming() + } + + pub fn poll_events_with_timeout(&mut self, mut timeout: Option, mut callback: F) + where + F: FnMut(Event, &RootAEL), + { + let start = Instant::now(); + + let has_pending = self.has_pending(); + + timeout = if has_pending { + // If we already have work to do then we don't want to block on the next poll. + Some(Duration::ZERO) + } else { + let control_flow_timeout = match self.control_flow() { + ControlFlow::Wait => None, + ControlFlow::Poll => Some(Duration::ZERO), + ControlFlow::WaitUntil(wait_deadline) => { + Some(wait_deadline.saturating_duration_since(start)) + }, + }; + + min_timeout(control_flow_timeout, timeout) + }; + + self.state.x11_readiness = Readiness::EMPTY; + if let Err(error) = + self.event_loop.dispatch(timeout, &mut self.state).map_err(std::io::Error::from) + { + tracing::error!("Failed to poll for events: {error:?}"); + let exit_code = error.raw_os_error().unwrap_or(1); + self.set_exit_code(exit_code); + return; + } + + // NB: `StartCause::Init` is handled as a special case and doesn't need + // to be considered here + let cause = match self.control_flow() { + ControlFlow::Poll => StartCause::Poll, + ControlFlow::Wait => StartCause::WaitCancelled { start, requested_resume: None }, + ControlFlow::WaitUntil(deadline) => { + if Instant::now() < deadline { + StartCause::WaitCancelled { start, requested_resume: Some(deadline) } + } else { + StartCause::ResumeTimeReached { start, requested_resume: deadline } + } + }, + }; + + // False positive / spurious wake ups could lead to us spamming + // redundant iterations of the event loop with no new events to + // dispatch. + // + // If there's no readable event source then we just double check if we + // have any pending `_receiver` events and if not we return without + // running a loop iteration. + // If we don't have any pending `_receiver` + if !self.has_pending() + && !matches!(&cause, StartCause::ResumeTimeReached { .. } | StartCause::Poll) + && timeout.is_none() + { + return; + } + + self.single_iteration(&mut callback, cause); + } + + fn single_iteration(&mut self, callback: &mut F, cause: StartCause) + where + F: FnMut(Event, &RootAEL), + { + callback(Event::NewEvents(cause), &self.event_processor.target); + + // NB: For consistency all platforms must emit a 'resumed' event even though X11 + // applications don't themselves have a formal suspend/resume lifecycle. + if cause == StartCause::Init { + callback(Event::Resumed, &self.event_processor.target); + } + + // Process all pending events + self.drain_events(callback); + + // Empty activation tokens. + while let Ok((window_id, serial)) = self.activation_receiver.try_recv() { + let token = self.event_processor.with_window(window_id.0 as xproto::Window, |window| { + window.generate_activation_token() + }); + + match token { + Some(Ok(token)) => { + let event = Event::WindowEvent { + window_id: crate::window::WindowId(window_id), + event: WindowEvent::ActivationTokenDone { + serial, + token: crate::window::ActivationToken::from_raw(token), + }, + }; + callback(event, &self.event_processor.target) + }, + Some(Err(e)) => { + tracing::error!("Failed to get activation token: {}", e); + }, + None => {}, + } + } + + // Empty the user event buffer + { + while let Ok(event) = self.user_receiver.try_recv() { + callback(Event::UserEvent(event), &self.event_processor.target); + } + } + + // Empty the redraw requests + { + let mut windows = HashSet::new(); + + while let Ok(window_id) = self.redraw_receiver.try_recv() { + windows.insert(window_id); + } + + for window_id in windows { + let window_id = crate::window::WindowId(window_id); + callback( + Event::WindowEvent { window_id, event: WindowEvent::RedrawRequested }, + &self.event_processor.target, + ); + } + } + + // This is always the last event we dispatch before poll again + { + callback(Event::AboutToWait, &self.event_processor.target); + } + } + + fn drain_events(&mut self, callback: &mut F) + where + F: FnMut(Event, &RootAEL), + { + let mut xev = MaybeUninit::uninit(); + + while unsafe { self.event_processor.poll_one_event(xev.as_mut_ptr()) } { + let mut xev = unsafe { xev.assume_init() }; + self.event_processor.process_event(&mut xev, |window_target, event| { + if let Event::WindowEvent { + window_id: crate::window::WindowId(wid), + event: WindowEvent::RedrawRequested, + } = event + { + let window_target = EventProcessor::window_target(window_target); + window_target.redraw_sender.send(wid).unwrap(); + } else { + callback(event, window_target); + } + }); + } + } + + fn control_flow(&self) -> ControlFlow { + let window_target = EventProcessor::window_target(&self.event_processor.target); + window_target.control_flow() + } + + fn exiting(&self) -> bool { + let window_target = EventProcessor::window_target(&self.event_processor.target); + window_target.exiting() + } + + fn set_exit_code(&self, code: i32) { + let window_target = EventProcessor::window_target(&self.event_processor.target); + window_target.set_exit_code(code); + } + + fn exit_code(&self) -> Option { + let window_target = EventProcessor::window_target(&self.event_processor.target); + window_target.exit_code() + } +} + +impl AsFd for EventLoop { + fn as_fd(&self) -> BorrowedFd<'_> { + self.event_loop.as_fd() + } +} + +impl AsRawFd for EventLoop { + fn as_raw_fd(&self) -> RawFd { + self.event_loop.as_raw_fd() + } +} + +impl ActiveEventLoop { + /// Returns the `XConnection` of this events loop. + #[inline] + pub(crate) fn x_connection(&self) -> &Arc { + &self.xconn + } + + pub fn available_monitors(&self) -> impl Iterator { + self.xconn.available_monitors().into_iter().flatten() + } + + pub fn primary_monitor(&self) -> Option { + self.xconn.primary_monitor().ok() + } + + pub(crate) fn create_custom_cursor(&self, cursor: CustomCursorSource) -> RootCustomCursor { + RootCustomCursor { inner: PlatformCustomCursor::X(CustomCursor::new(self, cursor.inner)) } + } + + pub fn listen_device_events(&self, allowed: DeviceEvents) { + self.device_events.set(allowed); + } + + /// Update the device event based on window focus. + pub fn update_listen_device_events(&self, focus: bool) { + let device_events = self.device_events.get() == DeviceEvents::Always + || (focus && self.device_events.get() == DeviceEvents::WhenFocused); + + let mut mask = xinput::XIEventMask::from(0u32); + if device_events { + mask = xinput::XIEventMask::RAW_MOTION + | xinput::XIEventMask::RAW_BUTTON_PRESS + | xinput::XIEventMask::RAW_BUTTON_RELEASE + | xinput::XIEventMask::RAW_KEY_PRESS + | xinput::XIEventMask::RAW_KEY_RELEASE; + } + + self.xconn + .select_xinput_events(self.root, ALL_MASTER_DEVICES, mask) + .expect_then_ignore_error("Failed to update device event filter"); + } + + #[cfg(feature = "rwh_05")] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + let mut display_handle = rwh_05::XlibDisplayHandle::empty(); + display_handle.display = self.xconn.display as *mut _; + display_handle.screen = self.xconn.default_screen_index() as c_int; + display_handle.into() + } + + #[cfg(feature = "rwh_06")] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + let display_handle = rwh_06::XlibDisplayHandle::new( + // SAFETY: display will never be null + Some( + std::ptr::NonNull::new(self.xconn.display as *mut _) + .expect("X11 display should never be null"), + ), + self.xconn.default_screen_index() as c_int, + ); + Ok(display_handle.into()) + } + + pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) { + self.control_flow.set(control_flow) + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + self.control_flow.get() + } + + pub(crate) fn exit(&self) { + self.exit.set(Some(0)) + } + + pub(crate) fn clear_exit(&self) { + self.exit.set(None) + } + + pub(crate) fn exiting(&self) -> bool { + self.exit.get().is_some() + } + + pub(crate) fn set_exit_code(&self, code: i32) { + self.exit.set(Some(code)) + } + + pub(crate) fn exit_code(&self) -> Option { + self.exit.get() + } +} + +impl EventLoopProxy { + pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed> { + self.user_sender.send(event).map_err(|e| EventLoopClosed(e.0)) + } +} + +struct DeviceInfo<'a> { + xconn: &'a XConnection, + info: *const ffi::XIDeviceInfo, + count: usize, +} + +impl<'a> DeviceInfo<'a> { + fn get(xconn: &'a XConnection, device: c_int) -> Option { + unsafe { + let mut count = 0; + let info = (xconn.xinput2.XIQueryDevice)(xconn.display, device, &mut count); + xconn.check_errors().ok()?; + + if info.is_null() || count == 0 { + None + } else { + Some(DeviceInfo { xconn, info, count: count as usize }) + } + } + } +} + +impl Drop for DeviceInfo<'_> { + fn drop(&mut self) { + assert!(!self.info.is_null()); + unsafe { (self.xconn.xinput2.XIFreeDeviceInfo)(self.info as *mut _) }; + } +} + +impl Deref for DeviceInfo<'_> { + type Target = [ffi::XIDeviceInfo]; + + fn deref(&self) -> &Self::Target { + unsafe { slice::from_raw_parts(self.info, self.count) } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeviceId(xinput::DeviceId); + +impl DeviceId { + #[allow(unused)] + pub const fn dummy() -> Self { + DeviceId(0) + } +} + +pub(crate) struct Window(Arc); + +impl Deref for Window { + type Target = UnownedWindow; + + #[inline] + fn deref(&self) -> &UnownedWindow { + &self.0 + } +} + +impl Window { + pub(crate) fn new( + event_loop: &ActiveEventLoop, + attribs: WindowAttributes, + ) -> Result { + let window = Arc::new(UnownedWindow::new(event_loop, attribs)?); + event_loop.windows.borrow_mut().insert(window.id(), Arc::downgrade(&window)); + Ok(Window(window)) + } +} + +impl Drop for Window { + fn drop(&mut self) { + let window = self.deref(); + let xconn = &window.xconn; + + if let Ok(c) = xconn.xcb_connection().destroy_window(window.id().0 as xproto::Window) { + c.ignore_error(); + } + } +} + +/// Generic sum error type for X11 errors. +#[derive(Debug)] +pub enum X11Error { + /// An error from the Xlib library. + Xlib(XError), + + /// An error that occurred while trying to connect to the X server. + Connect(ConnectError), + + /// An error that occurred over the connection medium. + Connection(ConnectionError), + + /// An error that occurred logically on the X11 end. + X11(LogicalError), + + /// The XID range has been exhausted. + XidsExhausted(IdsExhausted), + + /// Got `null` from an Xlib function without a reason. + UnexpectedNull(&'static str), + + /// Got an invalid activation token. + InvalidActivationToken(Vec), + + /// An extension that we rely on is not available. + MissingExtension(&'static str), + + /// Could not find a matching X11 visual for this visualid + NoSuchVisual(xproto::Visualid), + + /// Unable to parse xsettings. + XsettingsParse(xsettings::ParserError), + + /// Failed to get property. + GetProperty(util::GetPropertyError), +} + +impl fmt::Display for X11Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + X11Error::Xlib(e) => write!(f, "Xlib error: {e}"), + X11Error::Connect(e) => write!(f, "X11 connection error: {e}"), + X11Error::Connection(e) => write!(f, "X11 connection error: {e}"), + X11Error::XidsExhausted(e) => write!(f, "XID range exhausted: {e}"), + X11Error::GetProperty(e) => write!(f, "Failed to get X property {e}"), + X11Error::X11(e) => write!(f, "X11 error: {e:?}"), + X11Error::UnexpectedNull(s) => write!(f, "Xlib function returned null: {s}"), + X11Error::InvalidActivationToken(s) => write!( + f, + "Invalid activation token: {}", + std::str::from_utf8(s).unwrap_or("") + ), + X11Error::MissingExtension(s) => write!(f, "Missing X11 extension: {s}"), + X11Error::NoSuchVisual(visualid) => { + write!(f, "Could not find a matching X11 visual for ID `{visualid:x}`") + }, + X11Error::XsettingsParse(err) => { + write!(f, "Failed to parse xsettings: {err:?}") + }, + } + } +} + +impl std::error::Error for X11Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + X11Error::Xlib(e) => Some(e), + X11Error::Connect(e) => Some(e), + X11Error::Connection(e) => Some(e), + X11Error::XidsExhausted(e) => Some(e), + _ => None, + } + } +} + +impl From for X11Error { + fn from(e: XError) -> Self { + X11Error::Xlib(e) + } +} + +impl From for X11Error { + fn from(e: ConnectError) -> Self { + X11Error::Connect(e) + } +} + +impl From for X11Error { + fn from(e: ConnectionError) -> Self { + X11Error::Connection(e) + } +} + +impl From for X11Error { + fn from(e: LogicalError) -> Self { + X11Error::X11(e) + } +} + +impl From for X11Error { + fn from(value: ReplyError) -> Self { + match value { + ReplyError::ConnectionError(e) => e.into(), + ReplyError::X11Error(e) => e.into(), + } + } +} + +impl From for X11Error { + fn from(value: ime::ImeContextCreationError) -> Self { + match value { + ime::ImeContextCreationError::XError(e) => e.into(), + ime::ImeContextCreationError::Null => Self::UnexpectedNull("XOpenIM"), + } + } +} + +impl From for X11Error { + fn from(value: ReplyOrIdError) -> Self { + match value { + ReplyOrIdError::ConnectionError(e) => e.into(), + ReplyOrIdError::X11Error(e) => e.into(), + ReplyOrIdError::IdsExhausted => Self::XidsExhausted(IdsExhausted), + } + } +} + +impl From for X11Error { + fn from(value: xsettings::ParserError) -> Self { + Self::XsettingsParse(value) + } +} + +impl From for X11Error { + fn from(value: util::GetPropertyError) -> Self { + Self::GetProperty(value) + } +} + +/// Type alias for a void cookie. +type VoidCookie<'a> = x11rb::cookie::VoidCookie<'a, X11rbConnection>; + +/// Extension trait for `Result`. +trait CookieResultExt { + /// Unwrap the send error and ignore the result. + fn expect_then_ignore_error(self, msg: &str); +} + +impl CookieResultExt for Result, E> { + fn expect_then_ignore_error(self, msg: &str) { + self.expect(msg).ignore_error() + } +} + +fn mkwid(w: xproto::Window) -> crate::window::WindowId { + crate::window::WindowId(crate::platform_impl::platform::WindowId(w as _)) +} +fn mkdid(w: xinput::DeviceId) -> crate::event::DeviceId { + crate::event::DeviceId(crate::platform_impl::DeviceId::X(DeviceId(w))) +} + +#[derive(Debug)] +pub struct Device { + _name: String, + scroll_axes: Vec<(i32, ScrollAxis)>, + // For master devices, this is the paired device (pointer <-> keyboard). + // For slave devices, this is the master. + attachment: c_int, +} + +#[derive(Debug, Copy, Clone)] +struct ScrollAxis { + increment: f64, + orientation: ScrollOrientation, + position: f64, +} + +#[derive(Debug, Copy, Clone)] +enum ScrollOrientation { + Vertical, + Horizontal, +} + +impl Device { + fn new(info: &ffi::XIDeviceInfo) -> Self { + let name = unsafe { CStr::from_ptr(info.name).to_string_lossy() }; + let mut scroll_axes = Vec::new(); + + if Device::physical_device(info) { + // Identify scroll axes + for &class_ptr in Device::classes(info) { + let ty = unsafe { (*class_ptr)._type }; + if ty == ffi::XIScrollClass { + let info = unsafe { &*(class_ptr as *const ffi::XIScrollClassInfo) }; + scroll_axes.push((info.number, ScrollAxis { + increment: info.increment, + orientation: match info.scroll_type { + ffi::XIScrollTypeHorizontal => ScrollOrientation::Horizontal, + ffi::XIScrollTypeVertical => ScrollOrientation::Vertical, + _ => unreachable!(), + }, + position: 0.0, + })); + } + } + } + + let mut device = + Device { _name: name.into_owned(), scroll_axes, attachment: info.attachment }; + device.reset_scroll_position(info); + device + } + + fn reset_scroll_position(&mut self, info: &ffi::XIDeviceInfo) { + if Device::physical_device(info) { + for &class_ptr in Device::classes(info) { + let ty = unsafe { (*class_ptr)._type }; + if ty == ffi::XIValuatorClass { + let info = unsafe { &*(class_ptr as *const ffi::XIValuatorClassInfo) }; + if let Some(&mut (_, ref mut axis)) = + self.scroll_axes.iter_mut().find(|&&mut (axis, _)| axis == info.number) + { + axis.position = info.value; + } + } + } + } + } + + #[inline] + fn physical_device(info: &ffi::XIDeviceInfo) -> bool { + info._use == ffi::XISlaveKeyboard + || info._use == ffi::XISlavePointer + || info._use == ffi::XIFloatingSlave + } + + #[inline] + fn classes(info: &ffi::XIDeviceInfo) -> &[*const ffi::XIAnyClassInfo] { + unsafe { + slice::from_raw_parts( + info.classes as *const *const ffi::XIAnyClassInfo, + info.num_classes as usize, + ) + } + } +} + +/// Convert the raw X11 representation for a 32-bit floating point to a double. +#[inline] +fn xinput_fp1616_to_float(fp: xinput::Fp1616) -> f64 { + (fp as f64) / ((1 << 16) as f64) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/monitor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/monitor.rs new file mode 100644 index 00000000..1964bc93 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/monitor.rs @@ -0,0 +1,355 @@ +use super::{util, X11Error, XConnection}; +use crate::dpi::{PhysicalPosition, PhysicalSize}; +use crate::platform_impl::VideoModeHandle as PlatformVideoModeHandle; +use x11rb::connection::RequestConnection; +use x11rb::protocol::randr::{self, ConnectionExt as _}; +use x11rb::protocol::xproto; + +// Used for testing. This should always be committed as false. +const DISABLE_MONITOR_LIST_CACHING: bool = false; + +impl XConnection { + pub fn invalidate_cached_monitor_list(&self) -> Option> { + // We update this lazily. + self.monitor_handles.lock().unwrap().take() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct VideoModeHandle { + pub(crate) size: (u32, u32), + pub(crate) bit_depth: u16, + pub(crate) refresh_rate_millihertz: u32, + pub(crate) native_mode: randr::Mode, + pub(crate) monitor: Option, +} + +impl VideoModeHandle { + #[inline] + pub fn size(&self) -> PhysicalSize { + self.size.into() + } + + #[inline] + pub fn bit_depth(&self) -> u16 { + self.bit_depth + } + + #[inline] + pub fn refresh_rate_millihertz(&self) -> u32 { + self.refresh_rate_millihertz + } + + #[inline] + pub fn monitor(&self) -> MonitorHandle { + self.monitor.clone().unwrap() + } +} + +#[derive(Debug, Clone)] +pub struct MonitorHandle { + /// The actual id + pub(crate) id: randr::Crtc, + /// The name of the monitor + pub(crate) name: String, + /// The size of the monitor + dimensions: (u32, u32), + /// The position of the monitor in the X screen + position: (i32, i32), + /// If the monitor is the primary one + primary: bool, + /// The refresh rate used by monitor. + refresh_rate_millihertz: Option, + /// The DPI scale factor + pub(crate) scale_factor: f64, + /// Used to determine which windows are on this monitor + pub(crate) rect: util::AaRect, + /// Supported video modes on this monitor + video_modes: Vec, +} + +impl PartialEq for MonitorHandle { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for MonitorHandle {} + +impl PartialOrd for MonitorHandle { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for MonitorHandle { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.id.cmp(&other.id) + } +} + +impl std::hash::Hash for MonitorHandle { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + +#[inline] +pub fn mode_refresh_rate_millihertz(mode: &randr::ModeInfo) -> Option { + if mode.dot_clock > 0 && mode.htotal > 0 && mode.vtotal > 0 { + #[allow(clippy::unnecessary_cast)] + Some((mode.dot_clock as u64 * 1000 / (mode.htotal as u64 * mode.vtotal as u64)) as u32) + } else { + None + } +} + +impl MonitorHandle { + fn new( + xconn: &XConnection, + resources: &ScreenResources, + id: randr::Crtc, + crtc: &randr::GetCrtcInfoReply, + primary: bool, + ) -> Option { + let (name, scale_factor, video_modes) = xconn.get_output_info(resources, crtc)?; + let dimensions = (crtc.width as u32, crtc.height as u32); + let position = (crtc.x as i32, crtc.y as i32); + + // Get the refresh rate of the current video mode. + let current_mode = crtc.mode; + let screen_modes = resources.modes(); + let refresh_rate_millihertz = screen_modes + .iter() + .find(|mode| mode.id == current_mode) + .and_then(mode_refresh_rate_millihertz); + + let rect = util::AaRect::new(position, dimensions); + + Some(MonitorHandle { + id, + name, + refresh_rate_millihertz, + scale_factor, + dimensions, + position, + primary, + rect, + video_modes, + }) + } + + pub fn dummy() -> Self { + MonitorHandle { + id: 0, + name: "".into(), + scale_factor: 1.0, + dimensions: (1, 1), + position: (0, 0), + refresh_rate_millihertz: None, + primary: true, + rect: util::AaRect::new((0, 0), (1, 1)), + video_modes: Vec::new(), + } + } + + pub(crate) fn is_dummy(&self) -> bool { + // Zero is an invalid XID value; no real monitor will have it + self.id == 0 + } + + pub fn name(&self) -> Option { + Some(self.name.clone()) + } + + #[inline] + pub fn native_identifier(&self) -> u32 { + self.id as _ + } + + pub fn size(&self) -> PhysicalSize { + self.dimensions.into() + } + + pub fn position(&self) -> PhysicalPosition { + self.position.into() + } + + pub fn refresh_rate_millihertz(&self) -> Option { + self.refresh_rate_millihertz + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + self.scale_factor + } + + #[inline] + pub fn video_modes(&self) -> impl Iterator { + let monitor = self.clone(); + self.video_modes.clone().into_iter().map(move |mut x| { + x.monitor = Some(monitor.clone()); + PlatformVideoModeHandle::X(x) + }) + } +} + +impl XConnection { + pub fn get_monitor_for_window( + &self, + window_rect: Option, + ) -> Result { + let monitors = self.available_monitors()?; + + if monitors.is_empty() { + // Return a dummy monitor to avoid panicking + return Ok(MonitorHandle::dummy()); + } + + let default = monitors.first().unwrap(); + + let window_rect = match window_rect { + Some(rect) => rect, + None => return Ok(default.to_owned()), + }; + + let mut largest_overlap = 0; + let mut matched_monitor = default; + for monitor in &monitors { + let overlapping_area = window_rect.get_overlapping_area(&monitor.rect); + if overlapping_area > largest_overlap { + largest_overlap = overlapping_area; + matched_monitor = monitor; + } + } + + Ok(matched_monitor.to_owned()) + } + + fn query_monitor_list(&self) -> Result, X11Error> { + let root = self.default_root(); + let resources = + ScreenResources::from_connection(self.xcb_connection(), root, self.randr_version())?; + + // Pipeline all of the get-crtc requests. + let mut crtc_cookies = Vec::with_capacity(resources.crtcs().len()); + for &crtc in resources.crtcs() { + crtc_cookies + .push(self.xcb_connection().randr_get_crtc_info(crtc, x11rb::CURRENT_TIME)?); + } + + // Do this here so we do all of our requests in one shot. + let primary = self.xcb_connection().randr_get_output_primary(root.root)?.reply()?.output; + + let mut crtc_infos = Vec::with_capacity(crtc_cookies.len()); + for cookie in crtc_cookies { + let reply = cookie.reply()?; + crtc_infos.push(reply); + } + + let mut has_primary = false; + let mut available_monitors = Vec::with_capacity(resources.crtcs().len()); + for (crtc_id, crtc) in resources.crtcs().iter().zip(crtc_infos.iter()) { + if crtc.width == 0 || crtc.height == 0 || crtc.outputs.is_empty() { + continue; + } + + let is_primary = crtc.outputs[0] == primary; + has_primary |= is_primary; + let monitor = MonitorHandle::new(self, &resources, *crtc_id, crtc, is_primary); + available_monitors.extend(monitor); + } + + // If we don't have a primary monitor, just pick one ourselves! + if !has_primary { + if let Some(ref mut fallback) = available_monitors.first_mut() { + // Setting this here will come in handy if we ever add an `is_primary` method. + fallback.primary = true; + } + } + + Ok(available_monitors) + } + + pub fn available_monitors(&self) -> Result, X11Error> { + let mut monitors_lock = self.monitor_handles.lock().unwrap(); + match *monitors_lock { + Some(ref monitors) => Ok(monitors.clone()), + None => { + let monitors = self.query_monitor_list()?; + if !DISABLE_MONITOR_LIST_CACHING { + *monitors_lock = Some(monitors.clone()); + } + Ok(monitors) + }, + } + } + + #[inline] + pub fn primary_monitor(&self) -> Result { + Ok(self + .available_monitors()? + .into_iter() + .find(|monitor| monitor.primary) + .unwrap_or_else(MonitorHandle::dummy)) + } + + pub fn select_xrandr_input(&self, root: xproto::Window) -> Result { + use randr::NotifyMask; + + // Get extension info. + let info = self + .xcb_connection() + .extension_information(randr::X11_EXTENSION_NAME)? + .ok_or(X11Error::MissingExtension(randr::X11_EXTENSION_NAME))?; + + // Select input data. + let event_mask = + NotifyMask::CRTC_CHANGE | NotifyMask::OUTPUT_PROPERTY | NotifyMask::SCREEN_CHANGE; + self.xcb_connection().randr_select_input(root, event_mask)?; + + Ok(info.first_event) + } +} + +pub struct ScreenResources { + /// List of attached modes. + modes: Vec, + + /// List of attached CRTCs. + crtcs: Vec, +} + +impl ScreenResources { + pub(crate) fn modes(&self) -> &[randr::ModeInfo] { + &self.modes + } + + pub(crate) fn crtcs(&self) -> &[randr::Crtc] { + &self.crtcs + } + + pub(crate) fn from_connection( + conn: &impl x11rb::connection::Connection, + root: &x11rb::protocol::xproto::Screen, + (major_version, minor_version): (u32, u32), + ) -> Result { + if (major_version == 1 && minor_version >= 3) || major_version > 1 { + let reply = conn.randr_get_screen_resources_current(root.root)?.reply()?; + Ok(Self::from_get_screen_resources_current_reply(reply)) + } else { + let reply = conn.randr_get_screen_resources(root.root)?.reply()?; + Ok(Self::from_get_screen_resources_reply(reply)) + } + } + + pub(crate) fn from_get_screen_resources_reply(reply: randr::GetScreenResourcesReply) -> Self { + Self { modes: reply.modes, crtcs: reply.crtcs } + } + + pub(crate) fn from_get_screen_resources_current_reply( + reply: randr::GetScreenResourcesCurrentReply, + ) -> Self { + Self { modes: reply.modes, crtcs: reply.crtcs } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/tests/xsettings.dat b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/tests/xsettings.dat new file mode 100644 index 00000000..e04eeb14 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/tests/xsettings.dat @@ -0,0 +1 @@ +0x6c,0x00,0x00,0x00,0x02,0x00,0x00,0x00,0x22,0x00,0x00,0x00,0x00,0x00,0x0b,0x00,0x58,0x66,0x74,0x2f,0x48,0x69,0x6e,0x74,0x69,0x6e,0x67,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x14,0x00,0x47,0x74,0x6b,0x2f,0x44,0x69,0x61,0x6c,0x6f,0x67,0x73,0x55,0x73,0x65,0x48,0x65,0x61,0x64,0x65,0x72,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x0c,0x00,0x47,0x74,0x6b,0x2f,0x46,0x6f,0x6e,0x74,0x4e,0x61,0x6d,0x65,0x00,0x00,0x00,0x00,0x0b,0x00,0x00,0x00,0x4e,0x6f,0x74,0x6f,0x20,0x53,0x61,0x6e,0x73,0x20,0x39,0x00,0x01,0x00,0x0d,0x00,0x58,0x66,0x74,0x2f,0x4c,0x63,0x64,0x66,0x69,0x6c,0x74,0x65,0x72,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0a,0x00,0x00,0x00,0x6c,0x63,0x64,0x64,0x65,0x66,0x61,0x75,0x6c,0x74,0x00,0x00,0x01,0x00,0x10,0x00,0x47,0x74,0x6b,0x2f,0x4b,0x65,0x79,0x54,0x68,0x65,0x6d,0x65,0x4e,0x61,0x6d,0x65,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x0d,0x00,0x58,0x66,0x74,0x2f,0x48,0x69,0x6e,0x74,0x53,0x74,0x79,0x6c,0x65,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0a,0x00,0x00,0x00,0x68,0x69,0x6e,0x74,0x73,0x6c,0x69,0x67,0x68,0x74,0x00,0x00,0x01,0x00,0x11,0x00,0x4e,0x65,0x74,0x2f,0x49,0x63,0x6f,0x6e,0x54,0x68,0x65,0x6d,0x65,0x4e,0x61,0x6d,0x65,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x00,0x00,0x00,0x65,0x6c,0x65,0x6d,0x65,0x6e,0x74,0x61,0x72,0x79,0x2d,0x78,0x66,0x63,0x65,0x2d,0x64,0x61,0x72,0x6b,0x00,0x00,0x0d,0x00,0x58,0x66,0x74,0x2f,0x41,0x6e,0x74,0x69,0x61,0x6c,0x69,0x61,0x73,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x00,0x08,0x00,0x58,0x66,0x74,0x2f,0x52,0x47,0x42,0x41,0x00,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x72,0x67,0x62,0x00,0x00,0x00,0x13,0x00,0x4e,0x65,0x74,0x2f,0x43,0x75,0x72,0x73,0x6f,0x72,0x42,0x6c,0x69,0x6e,0x6b,0x54,0x69,0x6d,0x65,0x00,0x00,0x00,0x00,0x00,0xb0,0x04,0x00,0x00,0x00,0x00,0x13,0x00,0x47,0x74,0x6b,0x2f,0x43,0x75,0x72,0x73,0x6f,0x72,0x54,0x68,0x65,0x6d,0x65,0x53,0x69,0x7a,0x65,0x00,0x00,0x00,0x00,0x00,0x18,0x00,0x00,0x00,0x01,0x00,0x15,0x00,0x4e,0x65,0x74,0x2f,0x46,0x61,0x6c,0x6c,0x62,0x61,0x63,0x6b,0x49,0x63,0x6f,0x6e,0x54,0x68,0x65,0x6d,0x65,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x67,0x6e,0x6f,0x6d,0x65,0x00,0x00,0x00,0x01,0x00,0x10,0x00,0x47,0x74,0x6b,0x2f,0x54,0x6f,0x6f,0x6c,0x62,0x61,0x72,0x53,0x74,0x79,0x6c,0x65,0x00,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x69,0x63,0x6f,0x6e,0x73,0x00,0x00,0x00,0x01,0x00,0x12,0x00,0x4e,0x65,0x74,0x2f,0x53,0x6f,0x75,0x6e,0x64,0x54,0x68,0x65,0x6d,0x65,0x4e,0x61,0x6d,0x65,0x00,0x00,0x00,0x00,0x00,0x00,0x07,0x00,0x00,0x00,0x64,0x65,0x66,0x61,0x75,0x6c,0x74,0x00,0x00,0x00,0x15,0x00,0x4e,0x65,0x74,0x2f,0x45,0x6e,0x61,0x62,0x6c,0x65,0x45,0x76,0x65,0x6e,0x74,0x53,0x6f,0x75,0x6e,0x64,0x73,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0f,0x00,0x4e,0x65,0x74,0x2f,0x43,0x75,0x72,0x73,0x6f,0x72,0x42,0x6c,0x69,0x6e,0x6b,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x00,0x10,0x00,0x47,0x74,0x6b,0x2f,0x43,0x6f,0x6c,0x6f,0x72,0x50,0x61,0x6c,0x65,0x74,0x74,0x65,0x00,0x00,0x00,0x00,0x94,0x00,0x00,0x00,0x62,0x6c,0x61,0x63,0x6b,0x3a,0x77,0x68,0x69,0x74,0x65,0x3a,0x67,0x72,0x61,0x79,0x35,0x30,0x3a,0x72,0x65,0x64,0x3a,0x70,0x75,0x72,0x70,0x6c,0x65,0x3a,0x62,0x6c,0x75,0x65,0x3a,0x6c,0x69,0x67,0x68,0x74,0x20,0x62,0x6c,0x75,0x65,0x3a,0x67,0x72,0x65,0x65,0x6e,0x3a,0x79,0x65,0x6c,0x6c,0x6f,0x77,0x3a,0x6f,0x72,0x61,0x6e,0x67,0x65,0x3a,0x6c,0x61,0x76,0x65,0x6e,0x64,0x65,0x72,0x3a,0x62,0x72,0x6f,0x77,0x6e,0x3a,0x67,0x6f,0x6c,0x64,0x65,0x6e,0x72,0x6f,0x64,0x34,0x3a,0x64,0x6f,0x64,0x67,0x65,0x72,0x20,0x62,0x6c,0x75,0x65,0x3a,0x70,0x69,0x6e,0x6b,0x3a,0x6c,0x69,0x67,0x68,0x74,0x20,0x67,0x72,0x65,0x65,0x6e,0x3a,0x67,0x72,0x61,0x79,0x31,0x30,0x3a,0x67,0x72,0x61,0x79,0x33,0x30,0x3a,0x67,0x72,0x61,0x79,0x37,0x35,0x3a,0x67,0x72,0x61,0x79,0x39,0x30,0x00,0x00,0x13,0x00,0x4e,0x65,0x74,0x2f,0x44,0x6f,0x75,0x62,0x6c,0x65,0x43,0x6c,0x69,0x63,0x6b,0x54,0x69,0x6d,0x65,0x00,0x00,0x00,0x00,0x00,0x90,0x01,0x00,0x00,0x00,0x00,0x13,0x00,0x47,0x74,0x6b,0x2f,0x43,0x61,0x6e,0x43,0x68,0x61,0x6e,0x67,0x65,0x41,0x63,0x63,0x65,0x6c,0x73,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x10,0x00,0x47,0x74,0x6b,0x2f,0x4d,0x65,0x6e,0x75,0x42,0x61,0x72,0x41,0x63,0x63,0x65,0x6c,0x00,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x46,0x31,0x30,0x00,0x01,0x00,0x0d,0x00,0x4e,0x65,0x74,0x2f,0x54,0x68,0x65,0x6d,0x65,0x4e,0x61,0x6d,0x65,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x08,0x00,0x00,0x00,0x47,0x72,0x65,0x79,0x62,0x69,0x72,0x64,0x01,0x00,0x17,0x00,0x47,0x74,0x6b,0x2f,0x54,0x69,0x74,0x6c,0x65,0x62,0x61,0x72,0x4d,0x69,0x64,0x64,0x6c,0x65,0x43,0x6c,0x69,0x63,0x6b,0x00,0x00,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x6c,0x6f,0x77,0x65,0x72,0x00,0x00,0x00,0x00,0x00,0x10,0x00,0x47,0x74,0x6b,0x2f,0x42,0x75,0x74,0x74,0x6f,0x6e,0x49,0x6d,0x61,0x67,0x65,0x73,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x17,0x00,0x4e,0x65,0x74,0x2f,0x44,0x6f,0x75,0x62,0x6c,0x65,0x43,0x6c,0x69,0x63,0x6b,0x44,0x69,0x73,0x74,0x61,0x6e,0x63,0x65,0x00,0x00,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x01,0x00,0x15,0x00,0x47,0x74,0x6b,0x2f,0x4d,0x6f,0x6e,0x6f,0x73,0x70,0x61,0x63,0x65,0x46,0x6f,0x6e,0x74,0x4e,0x61,0x6d,0x65,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0c,0x00,0x00,0x00,0x4d,0x6f,0x6e,0x6f,0x73,0x70,0x61,0x63,0x65,0x20,0x31,0x30,0x00,0x00,0x07,0x00,0x58,0x66,0x74,0x2f,0x44,0x50,0x49,0x00,0x02,0x00,0x00,0x00,0x00,0x80,0x01,0x00,0x01,0x00,0x13,0x00,0x47,0x74,0x6b,0x2f,0x43,0x75,0x72,0x73,0x6f,0x72,0x54,0x68,0x65,0x6d,0x65,0x4e,0x61,0x6d,0x65,0x00,0x00,0x00,0x00,0x00,0x09,0x00,0x00,0x00,0x44,0x4d,0x5a,0x2d,0x57,0x68,0x69,0x74,0x65,0x00,0x00,0x00,0x00,0x00,0x13,0x00,0x47,0x74,0x6b,0x2f,0x54,0x6f,0x6f,0x6c,0x62,0x61,0x72,0x49,0x63,0x6f,0x6e,0x53,0x69,0x7a,0x65,0x00,0x00,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x00,0x00,0x14,0x00,0x4e,0x65,0x74,0x2f,0x44,0x6e,0x64,0x44,0x72,0x61,0x67,0x54,0x68,0x72,0x65,0x73,0x68,0x6f,0x6c,0x64,0x00,0x00,0x00,0x00,0x08,0x00,0x00,0x00,0x01,0x00,0x14,0x00,0x47,0x74,0x6b,0x2f,0x44,0x65,0x63,0x6f,0x72,0x61,0x74,0x69,0x6f,0x6e,0x4c,0x61,0x79,0x6f,0x75,0x74,0x00,0x00,0x00,0x00,0x1c,0x00,0x00,0x00,0x6d,0x65,0x6e,0x75,0x3a,0x6d,0x69,0x6e,0x69,0x6d,0x69,0x7a,0x65,0x2c,0x6d,0x61,0x78,0x69,0x6d,0x69,0x7a,0x65,0x2c,0x63,0x6c,0x6f,0x73,0x65,0x00,0x00,0x1d,0x00,0x4e,0x65,0x74,0x2f,0x45,0x6e,0x61,0x62,0x6c,0x65,0x49,0x6e,0x70,0x75,0x74,0x46,0x65,0x65,0x64,0x62,0x61,0x63,0x6b,0x53,0x6f,0x75,0x6e,0x64,0x73,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x17,0x00,0x47,0x64,0x6b,0x2f,0x57,0x69,0x6e,0x64,0x6f,0x77,0x53,0x63,0x61,0x6c,0x69,0x6e,0x67,0x46,0x61,0x63,0x74,0x6f,0x72,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x00,0x0d,0x00,0x47,0x74,0x6b,0x2f,0x49,0x63,0x6f,0x6e,0x53,0x69,0x7a,0x65,0x73,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x10,0x00,0x00,0x00,0x67,0x74,0x6b,0x2d,0x62,0x75,0x74,0x74,0x6f,0x6e,0x3d,0x31,0x36,0x2c,0x31,0x36,0x00,0x00,0x0e,0x00,0x47,0x74,0x6b,0x2f,0x4d,0x65,0x6e,0x75,0x49,0x6d,0x61,0x67,0x65,0x73,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00 diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/client_msg.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/client_msg.rs new file mode 100644 index 00000000..cf551776 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/client_msg.rs @@ -0,0 +1,31 @@ +use super::*; +use x11rb::x11_utils::Serialize; + +impl XConnection { + pub fn send_client_msg( + &self, + window: xproto::Window, // The window this is "about"; not necessarily this window + target_window: xproto::Window, // The window we're sending to + message_type: xproto::Atom, + event_mask: Option, + data: impl Into, + ) -> Result, X11Error> { + let event = xproto::ClientMessageEvent { + response_type: xproto::CLIENT_MESSAGE_EVENT, + window, + format: 32, + data: data.into(), + sequence: 0, + type_: message_type, + }; + + self.xcb_connection() + .send_event( + false, + target_window, + event_mask.unwrap_or(xproto::EventMask::NO_EVENT), + event.serialize(), + ) + .map_err(Into::into) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/cookie.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/cookie.rs new file mode 100644 index 00000000..ccdfa8cd --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/cookie.rs @@ -0,0 +1,55 @@ +use std::ffi::c_int; +use std::sync::Arc; + +use x11_dl::xlib::{self, XEvent, XGenericEventCookie}; + +use crate::platform_impl::x11::XConnection; + +/// XEvents of type GenericEvent store their actual data in an XGenericEventCookie data structure. +/// This is a wrapper to extract the cookie from a GenericEvent XEvent and release the cookie data +/// once it has been processed +pub struct GenericEventCookie { + cookie: XGenericEventCookie, + xconn: Arc, +} + +impl GenericEventCookie { + pub fn from_event(xconn: Arc, event: XEvent) -> Option { + unsafe { + let mut cookie: XGenericEventCookie = From::from(event); + if (xconn.xlib.XGetEventData)(xconn.display, &mut cookie) == xlib::True { + Some(GenericEventCookie { cookie, xconn }) + } else { + None + } + } + } + + #[inline] + pub fn extension(&self) -> u8 { + self.cookie.extension as u8 + } + + #[inline] + pub fn evtype(&self) -> c_int { + self.cookie.evtype + } + + /// Borrow inner event data as `&T`. + /// + /// ## SAFETY + /// + /// The caller must ensure that the event has the `T` inside of it. + #[inline] + pub unsafe fn as_event(&self) -> &T { + unsafe { &*(self.cookie.data as *const _) } + } +} + +impl Drop for GenericEventCookie { + fn drop(&mut self) { + unsafe { + (self.xconn.xlib.XFreeEventData)(self.xconn.display, &mut self.cookie); + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/cursor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/cursor.rs new file mode 100644 index 00000000..169748d1 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/cursor.rs @@ -0,0 +1,174 @@ +use std::ffi::CString; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; +use std::{iter, slice}; + +use x11rb::connection::Connection; + +use crate::platform_impl::PlatformCustomCursorSource; +use crate::window::CursorIcon; + +use super::super::ActiveEventLoop; +use super::*; + +impl XConnection { + pub fn set_cursor_icon(&self, window: xproto::Window, cursor: Option) { + let cursor = *self + .cursor_cache + .lock() + .unwrap() + .entry(cursor) + .or_insert_with(|| self.get_cursor(cursor)); + + self.update_cursor(window, cursor).expect("Failed to set cursor"); + } + + pub(crate) fn set_custom_cursor(&self, window: xproto::Window, cursor: &CustomCursor) { + self.update_cursor(window, cursor.inner.cursor).expect("Failed to set cursor"); + } + + fn create_empty_cursor(&self) -> ffi::Cursor { + let data = 0; + let pixmap = unsafe { + let screen = (self.xlib.XDefaultScreen)(self.display); + let window = (self.xlib.XRootWindow)(self.display, screen); + (self.xlib.XCreateBitmapFromData)(self.display, window, &data, 1, 1) + }; + + if pixmap == 0 { + panic!("failed to allocate pixmap for cursor"); + } + + unsafe { + // We don't care about this color, since it only fills bytes + // in the pixmap which are not 0 in the mask. + let mut dummy_color = MaybeUninit::uninit(); + let cursor = (self.xlib.XCreatePixmapCursor)( + self.display, + pixmap, + pixmap, + dummy_color.as_mut_ptr(), + dummy_color.as_mut_ptr(), + 0, + 0, + ); + (self.xlib.XFreePixmap)(self.display, pixmap); + + cursor + } + } + + fn get_cursor(&self, cursor: Option) -> ffi::Cursor { + let cursor = match cursor { + Some(cursor) => cursor, + None => return self.create_empty_cursor(), + }; + + let mut xcursor = 0; + for &name in iter::once(&cursor.name()).chain(cursor.alt_names().iter()) { + let name = CString::new(name).unwrap(); + xcursor = unsafe { + (self.xcursor.XcursorLibraryLoadCursor)( + self.display, + name.as_ptr() as *const c_char, + ) + }; + + if xcursor != 0 { + break; + } + } + + xcursor + } + + fn update_cursor(&self, window: xproto::Window, cursor: ffi::Cursor) -> Result<(), X11Error> { + self.xcb_connection() + .change_window_attributes( + window, + &xproto::ChangeWindowAttributesAux::new().cursor(cursor as xproto::Cursor), + )? + .ignore_error(); + + self.xcb_connection().flush()?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectedCursor { + Custom(CustomCursor), + Named(CursorIcon), +} + +#[derive(Debug, Clone)] +pub struct CustomCursor { + inner: Arc, +} + +impl Hash for CustomCursor { + fn hash(&self, state: &mut H) { + Arc::as_ptr(&self.inner).hash(state); + } +} + +impl PartialEq for CustomCursor { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) + } +} + +impl Eq for CustomCursor {} + +impl CustomCursor { + pub(crate) fn new( + event_loop: &ActiveEventLoop, + cursor: PlatformCustomCursorSource, + ) -> CustomCursor { + unsafe { + let ximage = (event_loop.xconn.xcursor.XcursorImageCreate)( + cursor.0.width as i32, + cursor.0.height as i32, + ); + if ximage.is_null() { + panic!("failed to allocate cursor image"); + } + (*ximage).xhot = cursor.0.hotspot_x as u32; + (*ximage).yhot = cursor.0.hotspot_y as u32; + (*ximage).delay = 0; + + let dst = slice::from_raw_parts_mut((*ximage).pixels, cursor.0.rgba.len() / 4); + for (dst, chunk) in dst.iter_mut().zip(cursor.0.rgba.chunks_exact(4)) { + *dst = (chunk[0] as u32) << 16 + | (chunk[1] as u32) << 8 + | (chunk[2] as u32) + | (chunk[3] as u32) << 24; + } + + let cursor = + (event_loop.xconn.xcursor.XcursorImageLoadCursor)(event_loop.xconn.display, ximage); + (event_loop.xconn.xcursor.XcursorImageDestroy)(ximage); + Self { inner: Arc::new(CustomCursorInner { xconn: event_loop.xconn.clone(), cursor }) } + } + } +} + +#[derive(Debug)] +struct CustomCursorInner { + xconn: Arc, + cursor: ffi::Cursor, +} + +impl Drop for CustomCursorInner { + fn drop(&mut self) { + unsafe { + (self.xconn.xlib.XFreeCursor)(self.xconn.display, self.cursor); + } + } +} + +impl Default for SelectedCursor { + fn default() -> Self { + SelectedCursor::Named(Default::default()) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/geometry.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/geometry.rs new file mode 100644 index 00000000..63ec5647 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/geometry.rs @@ -0,0 +1,265 @@ +use std::cmp; + +use super::*; + +// Friendly neighborhood axis-aligned rectangle +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AaRect { + x: i64, + y: i64, + width: i64, + height: i64, +} + +impl AaRect { + pub fn new((x, y): (i32, i32), (width, height): (u32, u32)) -> Self { + let (x, y) = (x as i64, y as i64); + let (width, height) = (width as i64, height as i64); + AaRect { x, y, width, height } + } + + pub fn contains_point(&self, x: i64, y: i64) -> bool { + x >= self.x && x <= self.x + self.width && y >= self.y && y <= self.y + self.height + } + + pub fn get_overlapping_area(&self, other: &Self) -> i64 { + let x_overlap = cmp::max( + 0, + cmp::min(self.x + self.width, other.x + other.width) - cmp::max(self.x, other.x), + ); + let y_overlap = cmp::max( + 0, + cmp::min(self.y + self.height, other.y + other.height) - cmp::max(self.y, other.y), + ); + x_overlap * y_overlap + } +} + +#[derive(Debug, Clone)] +pub struct FrameExtents { + pub left: u32, + pub right: u32, + pub top: u32, + pub bottom: u32, +} + +impl FrameExtents { + pub fn new(left: u32, right: u32, top: u32, bottom: u32) -> Self { + FrameExtents { left, right, top, bottom } + } + + pub fn from_border(border: u32) -> Self { + Self::new(border, border, border, border) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameExtentsHeuristicPath { + Supported, + UnsupportedNested, + UnsupportedBordered, +} + +#[derive(Debug, Clone)] +pub struct FrameExtentsHeuristic { + pub frame_extents: FrameExtents, + pub heuristic_path: FrameExtentsHeuristicPath, +} + +impl FrameExtentsHeuristic { + pub fn inner_pos_to_outer(&self, x: i32, y: i32) -> (i32, i32) { + use self::FrameExtentsHeuristicPath::*; + if self.heuristic_path != UnsupportedBordered { + (x - self.frame_extents.left as i32, y - self.frame_extents.top as i32) + } else { + (x, y) + } + } + + pub fn inner_size_to_outer(&self, width: u32, height: u32) -> (u32, u32) { + ( + width.saturating_add( + self.frame_extents.left.saturating_add(self.frame_extents.right) as _ + ), + height.saturating_add( + self.frame_extents.top.saturating_add(self.frame_extents.bottom) as _ + ), + ) + } +} + +impl XConnection { + // This is adequate for inner_position + pub fn translate_coords( + &self, + window: xproto::Window, + root: xproto::Window, + ) -> Result { + self.xcb_connection().translate_coordinates(window, root, 0, 0)?.reply().map_err(Into::into) + } + + // This is adequate for inner_size + pub fn get_geometry( + &self, + window: xproto::Window, + ) -> Result { + self.xcb_connection().get_geometry(window)?.reply().map_err(Into::into) + } + + fn get_frame_extents(&self, window: xproto::Window) -> Option { + let atoms = self.atoms(); + let extents_atom = atoms[_NET_FRAME_EXTENTS]; + + if !hint_is_supported(extents_atom) { + return None; + } + + // Of the WMs tested, xmonad, i3, dwm, IceWM (1.3.x and earlier), and blackbox don't + // support this. As this is part of EWMH (Extended Window Manager Hints), it's likely to + // be unsupported by many smaller WMs. + let extents: Option> = self + .get_property(window, extents_atom, xproto::Atom::from(xproto::AtomEnum::CARDINAL)) + .ok(); + + extents.and_then(|extents| { + if extents.len() >= 4 { + Some(FrameExtents { + left: extents[0], + right: extents[1], + top: extents[2], + bottom: extents[3], + }) + } else { + None + } + }) + } + + pub fn is_top_level(&self, window: xproto::Window, root: xproto::Window) -> Option { + let atoms = self.atoms(); + let client_list_atom = atoms[_NET_CLIENT_LIST]; + + if !hint_is_supported(client_list_atom) { + return None; + } + + let client_list: Option> = self + .get_property(root, client_list_atom, xproto::Atom::from(xproto::AtomEnum::WINDOW)) + .ok(); + + client_list.map(|client_list| client_list.contains(&(window as xproto::Window))) + } + + fn get_parent_window(&self, window: xproto::Window) -> Result { + let parent = self.xcb_connection().query_tree(window)?.reply()?.parent; + Ok(parent) + } + + fn climb_hierarchy( + &self, + window: xproto::Window, + root: xproto::Window, + ) -> Result { + let mut outer_window = window; + loop { + let candidate = self.get_parent_window(outer_window)?; + if candidate == root { + break; + } + outer_window = candidate; + } + Ok(outer_window) + } + + pub fn get_frame_extents_heuristic( + &self, + window: xproto::Window, + root: xproto::Window, + ) -> FrameExtentsHeuristic { + use self::FrameExtentsHeuristicPath::*; + + // Position relative to root window. + // With rare exceptions, this is the position of a nested window. Cases where the window + // isn't nested are outlined in the comments throughout this function, but in addition to + // that, fullscreen windows often aren't nested. + let (inner_y_rel_root, child) = { + let coords = self + .translate_coords(window, root) + .expect("Failed to translate window coordinates"); + (coords.dst_y, coords.child) + }; + + let (width, height, border) = { + let inner_geometry = + self.get_geometry(window).expect("Failed to get inner window geometry"); + (inner_geometry.width, inner_geometry.height, inner_geometry.border_width) + }; + + // The first condition is only false for un-nested windows, but isn't always false for + // un-nested windows. Mutter/Muffin/Budgie and Marco present a mysterious discrepancy: + // when y is on the range [0, 2] and if the window has been unfocused since being + // undecorated (or was undecorated upon construction), the first condition is true, + // requiring us to rely on the second condition. + let nested = !(window == child || self.is_top_level(child, root) == Some(true)); + + // Hopefully the WM supports EWMH, allowing us to get exact info on the window frames. + if let Some(mut frame_extents) = self.get_frame_extents(window) { + // Mutter/Muffin/Budgie and Marco preserve their decorated frame extents when + // decorations are disabled, but since the window becomes un-nested, it's easy to + // catch. + if !nested { + frame_extents = FrameExtents::new(0, 0, 0, 0); + } + + // The difference between the nested window's position and the outermost window's + // position is equivalent to the frame size. In most scenarios, this is equivalent to + // manually climbing the hierarchy as is done in the case below. Here's a list of + // known discrepancies: + // * Mutter/Muffin/Budgie gives decorated windows a margin of 9px (only 7px on top) in + // addition to a 1px semi-transparent border. The margin can be easily observed by + // using a screenshot tool to get a screenshot of a selected window, and is presumably + // used for drawing drop shadows. Getting window geometry information via + // hierarchy-climbing results in this margin being included in both the position and + // outer size, so a window positioned at (0, 0) would be reported as having a position + // (-10, -8). + // * Compiz has a drop shadow margin just like Mutter/Muffin/Budgie, though it's 10px on + // all sides, and there's no additional border. + // * Enlightenment otherwise gets a y position equivalent to inner_y_rel_root. Without + // decorations, there's no difference. This is presumably related to Enlightenment's + // fairly unique concept of window position; it interprets positions given to + // XMoveWindow as a client area position rather than a position of the overall window. + + FrameExtentsHeuristic { frame_extents, heuristic_path: Supported } + } else if nested { + // If the position value we have is for a nested window used as the client area, we'll + // just climb up the hierarchy and get the geometry of the outermost window we're + // nested in. + let outer_window = + self.climb_hierarchy(window, root).expect("Failed to climb window hierarchy"); + let (outer_y, outer_width, outer_height) = { + let outer_geometry = + self.get_geometry(outer_window).expect("Failed to get outer window geometry"); + (outer_geometry.y, outer_geometry.width, outer_geometry.height) + }; + + // Since we have the geometry of the outermost window and the geometry of the client + // area, we can figure out what's in between. + let diff_x = outer_width.saturating_sub(width) as u32; + let diff_y = outer_height.saturating_sub(height) as u32; + let offset_y = inner_y_rel_root.saturating_sub(outer_y) as u32; + + let left = diff_x / 2; + let right = left; + let top = offset_y; + let bottom = diff_y.saturating_sub(offset_y); + + let frame_extents = FrameExtents::new(left, right, top, bottom); + FrameExtentsHeuristic { frame_extents, heuristic_path: UnsupportedNested } + } else { + // This is the case for xmonad and dwm, AKA the only WMs tested that supplied a + // border value. This is convenient, since we can use it to get an accurate frame. + let frame_extents = FrameExtents::from_border(border.into()); + FrameExtentsHeuristic { frame_extents, heuristic_path: UnsupportedBordered } + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/hint.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/hint.rs new file mode 100644 index 00000000..a31872d5 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/hint.rs @@ -0,0 +1,169 @@ +use crate::platform::x11::WindowType; +use std::sync::Arc; + +use super::*; + +#[derive(Debug)] +#[allow(dead_code)] +pub enum StateOperation { + Remove = 0, // _NET_WM_STATE_REMOVE + Add = 1, // _NET_WM_STATE_ADD + Toggle = 2, // _NET_WM_STATE_TOGGLE +} + +impl From for StateOperation { + fn from(op: bool) -> Self { + if op { + StateOperation::Add + } else { + StateOperation::Remove + } + } +} + +impl WindowType { + pub(crate) fn as_atom(&self, xconn: &Arc) -> xproto::Atom { + use self::WindowType::*; + let atom_name = match *self { + Desktop => _NET_WM_WINDOW_TYPE_DESKTOP, + Dock => _NET_WM_WINDOW_TYPE_DOCK, + Toolbar => _NET_WM_WINDOW_TYPE_TOOLBAR, + Menu => _NET_WM_WINDOW_TYPE_MENU, + Utility => _NET_WM_WINDOW_TYPE_UTILITY, + Splash => _NET_WM_WINDOW_TYPE_SPLASH, + Dialog => _NET_WM_WINDOW_TYPE_DIALOG, + DropdownMenu => _NET_WM_WINDOW_TYPE_DROPDOWN_MENU, + PopupMenu => _NET_WM_WINDOW_TYPE_POPUP_MENU, + Tooltip => _NET_WM_WINDOW_TYPE_TOOLTIP, + Notification => _NET_WM_WINDOW_TYPE_NOTIFICATION, + Combo => _NET_WM_WINDOW_TYPE_COMBO, + Dnd => _NET_WM_WINDOW_TYPE_DND, + Normal => _NET_WM_WINDOW_TYPE_NORMAL, + }; + + let atoms = xconn.atoms(); + atoms[atom_name] + } +} + +pub struct MotifHints { + hints: MwmHints, +} + +struct MwmHints { + flags: u32, + functions: u32, + decorations: u32, + input_mode: u32, + status: u32, +} + +#[allow(dead_code)] +mod mwm { + // Motif WM hints are obsolete, but still widely supported. + // https://stackoverflow.com/a/1909708 + pub const MWM_HINTS_FUNCTIONS: u32 = 1 << 0; + pub const MWM_HINTS_DECORATIONS: u32 = 1 << 1; + + pub const MWM_FUNC_ALL: u32 = 1 << 0; + pub const MWM_FUNC_RESIZE: u32 = 1 << 1; + pub const MWM_FUNC_MOVE: u32 = 1 << 2; + pub const MWM_FUNC_MINIMIZE: u32 = 1 << 3; + pub const MWM_FUNC_MAXIMIZE: u32 = 1 << 4; + pub const MWM_FUNC_CLOSE: u32 = 1 << 5; +} + +impl MotifHints { + pub fn new() -> MotifHints { + MotifHints { + hints: MwmHints { flags: 0, functions: 0, decorations: 0, input_mode: 0, status: 0 }, + } + } + + pub fn set_decorations(&mut self, decorations: bool) { + self.hints.flags |= mwm::MWM_HINTS_DECORATIONS; + self.hints.decorations = decorations as u32; + } + + pub fn set_maximizable(&mut self, maximizable: bool) { + if maximizable { + self.add_func(mwm::MWM_FUNC_MAXIMIZE); + } else { + self.remove_func(mwm::MWM_FUNC_MAXIMIZE); + } + } + + fn add_func(&mut self, func: u32) { + if self.hints.flags & mwm::MWM_HINTS_FUNCTIONS != 0 { + if self.hints.functions & mwm::MWM_FUNC_ALL != 0 { + self.hints.functions &= !func; + } else { + self.hints.functions |= func; + } + } + } + + fn remove_func(&mut self, func: u32) { + if self.hints.flags & mwm::MWM_HINTS_FUNCTIONS == 0 { + self.hints.flags |= mwm::MWM_HINTS_FUNCTIONS; + self.hints.functions = mwm::MWM_FUNC_ALL; + } + + if self.hints.functions & mwm::MWM_FUNC_ALL != 0 { + self.hints.functions |= func; + } else { + self.hints.functions &= !func; + } + } +} + +impl Default for MotifHints { + fn default() -> Self { + Self::new() + } +} + +impl XConnection { + pub fn get_motif_hints(&self, window: xproto::Window) -> MotifHints { + let atoms = self.atoms(); + let motif_hints = atoms[_MOTIF_WM_HINTS]; + + let mut hints = MotifHints::new(); + + if let Ok(props) = self.get_property::(window, motif_hints, motif_hints) { + hints.hints.flags = props.first().cloned().unwrap_or(0); + hints.hints.functions = props.get(1).cloned().unwrap_or(0); + hints.hints.decorations = props.get(2).cloned().unwrap_or(0); + hints.hints.input_mode = props.get(3).cloned().unwrap_or(0); + hints.hints.status = props.get(4).cloned().unwrap_or(0); + } + + hints + } + + #[allow(clippy::unnecessary_cast)] + pub fn set_motif_hints( + &self, + window: xproto::Window, + hints: &MotifHints, + ) -> Result, X11Error> { + let atoms = self.atoms(); + let motif_hints = atoms[_MOTIF_WM_HINTS]; + + let hints_data: [u32; 5] = [ + hints.hints.flags as u32, + hints.hints.functions as u32, + hints.hints.decorations as u32, + hints.hints.input_mode as u32, + hints.hints.status as u32, + ]; + + self.change_property( + window, + motif_hints, + motif_hints, + xproto::PropMode::REPLACE, + &hints_data, + ) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/icon.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/icon.rs new file mode 100644 index 00000000..07b5fee6 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/icon.rs @@ -0,0 +1,36 @@ +#![allow(clippy::assertions_on_constants)] + +use super::*; +use crate::icon::{Pixel, RgbaIcon, PIXEL_SIZE}; + +impl Pixel { + pub fn to_packed_argb(&self) -> Cardinal { + let mut cardinal = 0; + assert!(CARDINAL_SIZE >= PIXEL_SIZE); + let as_bytes = &mut cardinal as *mut _ as *mut u8; + unsafe { + *as_bytes.offset(0) = self.b; + *as_bytes.offset(1) = self.g; + *as_bytes.offset(2) = self.r; + *as_bytes.offset(3) = self.a; + } + cardinal + } +} + +impl RgbaIcon { + pub(crate) fn to_cardinals(&self) -> Vec { + assert_eq!(self.rgba.len() % PIXEL_SIZE, 0); + let pixel_count = self.rgba.len() / PIXEL_SIZE; + assert_eq!(pixel_count, (self.width * self.height) as usize); + let mut data = Vec::with_capacity(pixel_count); + data.push(self.width as Cardinal); + data.push(self.height as Cardinal); + let pixels = self.rgba.as_ptr() as *const Pixel; + for pixel_index in 0..pixel_count { + let pixel = unsafe { &*pixels.add(pixel_index) }; + data.push(pixel.to_packed_argb()); + } + data + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/input.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/input.rs new file mode 100644 index 00000000..37d21d11 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/input.rs @@ -0,0 +1,106 @@ +use std::{slice, str}; +use x11rb::protocol::xinput::{self, ConnectionExt as _}; +use x11rb::protocol::xkb; + +use super::*; + +pub const VIRTUAL_CORE_POINTER: u16 = 2; +pub const VIRTUAL_CORE_KEYBOARD: u16 = 3; + +// A base buffer size of 1kB uses a negligible amount of RAM while preventing us from having to +// re-allocate (and make another round-trip) in the *vast* majority of cases. +// To test if `lookup_utf8` works correctly, set this to 1. +const TEXT_BUFFER_SIZE: usize = 1024; + +impl XConnection { + pub fn select_xinput_events( + &self, + window: xproto::Window, + device_id: u16, + mask: xinput::XIEventMask, + ) -> Result, X11Error> { + self.xcb_connection() + .xinput_xi_select_events(window, &[xinput::EventMask { + deviceid: device_id, + mask: vec![mask], + }]) + .map_err(Into::into) + } + + pub fn select_xkb_events( + &self, + device_id: xkb::DeviceSpec, + mask: xkb::EventType, + ) -> Result { + let mask = u16::from(mask) as _; + let status = + unsafe { (self.xlib.XkbSelectEvents)(self.display, device_id as _, mask, mask) }; + + if status == ffi::True { + self.flush_requests()?; + Ok(true) + } else { + tracing::error!("Could not select XKB events: The XKB extension is not initialized!"); + Ok(false) + } + } + + pub fn query_pointer( + &self, + window: xproto::Window, + device_id: u16, + ) -> Result { + self.xcb_connection() + .xinput_xi_query_pointer(window, device_id)? + .reply() + .map_err(Into::into) + } + + fn lookup_utf8_inner( + &self, + ic: ffi::XIC, + key_event: &mut ffi::XKeyEvent, + buffer: *mut u8, + size: usize, + ) -> (ffi::KeySym, ffi::Status, c_int) { + let mut keysym: ffi::KeySym = 0; + let mut status: ffi::Status = 0; + let count = unsafe { + (self.xlib.Xutf8LookupString)( + ic, + key_event, + buffer as *mut c_char, + size as c_int, + &mut keysym, + &mut status, + ) + }; + (keysym, status, count) + } + + pub fn lookup_utf8(&self, ic: ffi::XIC, key_event: &mut ffi::XKeyEvent) -> String { + // `assume_init` is safe here because the array consists of `MaybeUninit` values, + // which do not require initialization. + let mut buffer: [MaybeUninit; TEXT_BUFFER_SIZE] = + unsafe { MaybeUninit::uninit().assume_init() }; + // If the buffer overflows, we'll make a new one on the heap. + let mut vec; + + let (_, status, count) = + self.lookup_utf8_inner(ic, key_event, buffer.as_mut_ptr() as *mut u8, buffer.len()); + + let bytes = if status == ffi::XBufferOverflow { + vec = Vec::with_capacity(count as usize); + let (_, _, new_count) = + self.lookup_utf8_inner(ic, key_event, vec.as_mut_ptr(), vec.capacity()); + debug_assert_eq!(count, new_count); + + unsafe { vec.set_len(count as usize) }; + &vec[..count as usize] + } else { + unsafe { slice::from_raw_parts(buffer.as_ptr() as *const u8, count as usize) } + }; + + str::from_utf8(bytes).unwrap_or("").to_string() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/keys.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/keys.rs new file mode 100644 index 00000000..a6ad291c --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/keys.rs @@ -0,0 +1,75 @@ +use std::iter::Enumerate; +use std::slice::Iter; + +use super::*; + +pub struct Keymap { + keys: [u8; 32], +} + +pub struct KeymapIter<'a> { + iter: Enumerate>, + index: usize, + item: Option, +} + +impl Keymap { + pub fn iter(&self) -> KeymapIter<'_> { + KeymapIter { iter: self.keys.iter().enumerate(), index: 0, item: None } + } +} + +impl<'a> IntoIterator for &'a Keymap { + type IntoIter = KeymapIter<'a>; + type Item = ffi::KeyCode; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +impl Iterator for KeymapIter<'_> { + type Item = ffi::KeyCode; + + fn next(&mut self) -> Option { + if self.item.is_none() { + for (index, &item) in self.iter.by_ref() { + if item != 0 { + self.index = index; + self.item = Some(item); + break; + } + } + } + + self.item.take().map(|item| { + debug_assert!(item != 0); + + let bit = first_bit(item); + + if item != bit { + // Remove the first bit; save the rest for further iterations + self.item = Some(item ^ bit); + } + + let shift = bit.trailing_zeros() + (self.index * 8) as u32; + shift as ffi::KeyCode + }) + } +} + +impl XConnection { + pub fn query_keymap(&self) -> Keymap { + let mut keys = [0; 32]; + + unsafe { + (self.xlib.XQueryKeymap)(self.display, keys.as_mut_ptr() as *mut c_char); + } + + Keymap { keys } + } +} + +fn first_bit(b: u8) -> u8 { + 1 << b.trailing_zeros() +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/memory.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/memory.rs new file mode 100644 index 00000000..4e052a75 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/memory.rs @@ -0,0 +1,42 @@ +use std::ops::{Deref, DerefMut}; + +use super::*; + +pub(crate) struct XSmartPointer<'a, T> { + xconn: &'a XConnection, + pub ptr: *mut T, +} + +impl<'a, T> XSmartPointer<'a, T> { + // You're responsible for only passing things to this that should be XFree'd. + // Returns None if ptr is null. + pub fn new(xconn: &'a XConnection, ptr: *mut T) -> Option { + if !ptr.is_null() { + Some(XSmartPointer { xconn, ptr }) + } else { + None + } + } +} + +impl Deref for XSmartPointer<'_, T> { + type Target = T; + + fn deref(&self) -> &T { + unsafe { &*self.ptr } + } +} + +impl DerefMut for XSmartPointer<'_, T> { + fn deref_mut(&mut self) -> &mut T { + unsafe { &mut *self.ptr } + } +} + +impl Drop for XSmartPointer<'_, T> { + fn drop(&mut self) { + unsafe { + (self.xconn.xlib.XFree)(self.ptr as *mut _); + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/mod.rs new file mode 100644 index 00000000..55e6c2dd --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/mod.rs @@ -0,0 +1,77 @@ +// Welcome to the util module, where we try to keep you from shooting yourself in the foot. +// *results may vary + +use std::mem::{self, MaybeUninit}; +use std::ops::BitAnd; +use std::os::raw::*; + +mod client_msg; +pub mod cookie; +mod cursor; +mod geometry; +mod hint; +mod icon; +mod input; +pub mod keys; +pub(crate) mod memory; +mod mouse; +mod randr; +mod window_property; +mod wm; +mod xmodmap; + +pub use self::cursor::*; +pub use self::geometry::*; +pub use self::hint::*; +pub use self::input::*; +pub use self::mouse::*; +pub use self::window_property::*; +pub use self::wm::*; +pub use self::xmodmap::ModifierKeymap; + +use super::atoms::*; +use super::{ffi, VoidCookie, X11Error, XConnection, XError}; +use x11rb::protocol::xproto::{self, ConnectionExt as _}; + +pub fn maybe_change(field: &mut Option, value: T) -> bool { + let wrapped = Some(value); + if *field != wrapped { + *field = wrapped; + true + } else { + false + } +} + +pub fn has_flag(bitset: T, flag: T) -> bool +where + T: Copy + PartialEq + BitAnd, +{ + bitset & flag == flag +} + +impl XConnection { + // This is important, so pay attention! + // Xlib has an output buffer, and tries to hide the async nature of X from you. + // This buffer contains the requests you make, and is flushed under various circumstances: + // 1. `XPending`, `XNextEvent`, and `XWindowEvent` flush "as needed" + // 2. `XFlush` explicitly flushes + // 3. `XSync` flushes and blocks until all requests are responded to + // 4. Calls that have a return dependent on a response (i.e. `XGetWindowProperty`) sync + // internally. When in doubt, check the X11 source; if a function calls `_XReply`, it flushes + // and waits. + // All util functions that abstract an async function will return a `Flusher`. + pub fn flush_requests(&self) -> Result<(), XError> { + unsafe { (self.xlib.XFlush)(self.display) }; + // println!("XFlush"); + // This isn't necessarily a useful time to check for errors (since our request hasn't + // necessarily been processed yet) + self.check_errors() + } + + pub fn sync_with_server(&self) -> Result<(), XError> { + unsafe { (self.xlib.XSync)(self.display, ffi::False) }; + // println!("XSync"); + self.check_errors() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/modifiers.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/modifiers.rs new file mode 100644 index 00000000..bb157e44 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/modifiers.rs @@ -0,0 +1,187 @@ +use std::{collections::HashMap, slice}; + +use super::*; + +use crate::event::{ElementState, ModifiersState}; + +// Offsets within XModifierKeymap to each set of keycodes. +// We are only interested in Shift, Control, Alt, and Logo. +// +// There are 8 sets total. The order of keycode sets is: +// Shift, Lock, Control, Mod1 (Alt), Mod2, Mod3, Mod4 (Logo), Mod5 +// +// https://tronche.com/gui/x/xlib/input/XSetModifierMapping.html +const SHIFT_OFFSET: usize = 0; +const CONTROL_OFFSET: usize = 2; +const ALT_OFFSET: usize = 3; +const LOGO_OFFSET: usize = 6; +const NUM_MODS: usize = 8; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Modifier { + Alt, + Ctrl, + Shift, + Logo, +} + +#[derive(Debug, Default)] +pub(crate) struct ModifierKeymap { + // Maps keycodes to modifiers + keys: HashMap, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ModifierKeyState { + // Contains currently pressed modifier keys and their corresponding modifiers + keys: HashMap, + state: ModifiersState, +} + +impl ModifierKeymap { + pub fn new() -> ModifierKeymap { + ModifierKeymap::default() + } + + pub fn get_modifier(&self, keycode: ffi::KeyCode) -> Option { + self.keys.get(&keycode).cloned() + } + + pub fn reset_from_x_connection(&mut self, xconn: &XConnection) { + { + let keymap = xconn.xcb_connection().get_modifier_mapping().expect("get_modifier_mapping failed").reply().expect("get_modifier_mapping failed"); + + if keymap.is_null() { + panic!("failed to allocate XModifierKeymap"); + } + + self.reset_from_x_keymap(&*keymap); + + (xconn.xlib.XFreeModifiermap)(keymap); + } + } + + pub fn reset_from_x_keymap(&mut self, keymap: &ffi::XModifierKeymap) { + let keys_per_mod = keymap.max_keypermod as usize; + + let keys = unsafe { + slice::from_raw_parts(keymap.modifiermap as *const _, keys_per_mod * NUM_MODS) + }; + + self.keys.clear(); + + self.read_x_keys(keys, SHIFT_OFFSET, keys_per_mod, Modifier::Shift); + self.read_x_keys(keys, CONTROL_OFFSET, keys_per_mod, Modifier::Ctrl); + self.read_x_keys(keys, ALT_OFFSET, keys_per_mod, Modifier::Alt); + self.read_x_keys(keys, LOGO_OFFSET, keys_per_mod, Modifier::Logo); + } + + fn read_x_keys( + &mut self, + keys: &[ffi::KeyCode], + offset: usize, + keys_per_mod: usize, + modifier: Modifier, + ) { + let start = offset * keys_per_mod; + let end = start + keys_per_mod; + + for &keycode in &keys[start..end] { + if keycode != 0 { + self.keys.insert(keycode, modifier); + } + } + } +} + +impl ModifierKeyState { + pub fn update_keymap(&mut self, mods: &ModifierKeymap) { + self.keys.retain(|k, v| { + if let Some(m) = mods.get_modifier(*k) { + *v = m; + true + } else { + false + } + }); + + self.reset_state(); + } + + pub fn update_state( + &mut self, + state: &ModifiersState, + except: Option, + ) -> Option { + let mut new_state = *state; + + match except { + Some(Modifier::Alt) => new_state.set(ModifiersState::ALT, self.state.alt()), + Some(Modifier::Ctrl) => new_state.set(ModifiersState::CTRL, self.state.ctrl()), + Some(Modifier::Shift) => new_state.set(ModifiersState::SHIFT, self.state.shift()), + Some(Modifier::Logo) => new_state.set(ModifiersState::LOGO, self.state.logo()), + None => (), + } + + if self.state == new_state { + None + } else { + self.keys.retain(|_k, v| get_modifier(&new_state, *v)); + self.state = new_state; + Some(new_state) + } + } + + pub fn modifiers(&self) -> ModifiersState { + self.state + } + + pub fn key_event(&mut self, state: ElementState, keycode: ffi::KeyCode, modifier: Modifier) { + match state { + ElementState::Pressed => self.key_press(keycode, modifier), + ElementState::Released => self.key_release(keycode), + } + } + + pub fn key_press(&mut self, keycode: ffi::KeyCode, modifier: Modifier) { + self.keys.insert(keycode, modifier); + + set_modifier(&mut self.state, modifier, true); + } + + pub fn key_release(&mut self, keycode: ffi::KeyCode) { + if let Some(modifier) = self.keys.remove(&keycode) { + if !self.keys.values().any(|&m| m == modifier) { + set_modifier(&mut self.state, modifier, false); + } + } + } + + fn reset_state(&mut self) { + let mut new_state = ModifiersState::default(); + + for &m in self.keys.values() { + set_modifier(&mut new_state, m, true); + } + + self.state = new_state; + } +} + +fn get_modifier(state: &ModifiersState, modifier: Modifier) -> bool { + match modifier { + Modifier::Alt => state.alt(), + Modifier::Ctrl => state.ctrl(), + Modifier::Shift => state.shift(), + Modifier::Logo => state.logo(), + } +} + +fn set_modifier(state: &mut ModifiersState, modifier: Modifier, value: bool) { + match modifier { + Modifier::Alt => state.set(ModifiersState::ALT, value), + Modifier::Ctrl => state.set(ModifiersState::CTRL, value), + Modifier::Shift => state.set(ModifiersState::SHIFT, value), + Modifier::Logo => state.set(ModifiersState::LOGO, value), + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/mouse.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/mouse.rs new file mode 100644 index 00000000..e66a76c3 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/mouse.rs @@ -0,0 +1,49 @@ +//! Utilities for handling mouse events. + +/// Recorded mouse delta designed to filter out noise. +pub struct Delta { + x: T, + y: T, +} + +impl Default for Delta { + fn default() -> Self { + Self { x: Default::default(), y: Default::default() } + } +} + +impl Delta { + pub(crate) fn set_x(&mut self, x: T) { + self.x = x; + } + + pub(crate) fn set_y(&mut self, y: T) { + self.y = y; + } +} + +macro_rules! consume { + ($this:expr, $ty:ty) => {{ + let this = $this; + let (x, y) = match (this.x.abs() < <$ty>::EPSILON, this.y.abs() < <$ty>::EPSILON) { + (true, true) => return None, + (false, true) => (this.x, 0.0), + (true, false) => (0.0, this.y), + (false, false) => (this.x, this.y), + }; + + Some((x, y)) + }}; +} + +impl Delta { + pub(crate) fn consume(self) -> Option<(f32, f32)> { + consume!(self, f32) + } +} + +impl Delta { + pub(crate) fn consume(self) -> Option<(f64, f64)> { + consume!(self, f64) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/randr.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/randr.rs new file mode 100644 index 00000000..d10c97e5 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/randr.rs @@ -0,0 +1,186 @@ +use std::str::FromStr; +use std::{env, str}; + +use super::*; +use crate::dpi::validate_scale_factor; +use crate::platform_impl::platform::x11::{monitor, VideoModeHandle}; + +use tracing::warn; +use x11rb::protocol::randr::{self, ConnectionExt as _}; + +/// Represents values of `WINIT_HIDPI_FACTOR`. +pub enum EnvVarDPI { + Randr, + Scale(f64), + NotSet, +} + +pub fn calc_dpi_factor( + (width_px, height_px): (u32, u32), + (width_mm, height_mm): (u64, u64), +) -> f64 { + // See http://xpra.org/trac/ticket/728 for more information. + if width_mm == 0 || height_mm == 0 { + warn!("XRandR reported that the display's 0mm in size, which is certifiably insane"); + return 1.0; + } + + let ppmm = ((width_px as f64 * height_px as f64) / (width_mm as f64 * height_mm as f64)).sqrt(); + // Quantize 1/12 step size + let dpi_factor = ((ppmm * (12.0 * 25.4 / 96.0)).round() / 12.0).max(1.0); + assert!(validate_scale_factor(dpi_factor)); + if dpi_factor <= 20. { + dpi_factor + } else { + 1. + } +} + +impl XConnection { + // Retrieve DPI from Xft.dpi property + pub fn get_xft_dpi(&self) -> Option { + // Try to get it from XSETTINGS first. + if let Some(xsettings_screen) = self.xsettings_screen() { + match self.xsettings_dpi(xsettings_screen) { + Ok(Some(dpi)) => return Some(dpi), + Ok(None) => {}, + Err(err) => { + tracing::warn!("failed to fetch XSettings: {err}"); + }, + } + } + + self.database().get_string("Xft.dpi", "").and_then(|s| f64::from_str(s).ok()) + } + + pub fn get_output_info( + &self, + resources: &monitor::ScreenResources, + crtc: &randr::GetCrtcInfoReply, + ) -> Option<(String, f64, Vec)> { + let output_info = match self + .xcb_connection() + .randr_get_output_info(crtc.outputs[0], x11rb::CURRENT_TIME) + .map_err(X11Error::from) + .and_then(|r| r.reply().map_err(X11Error::from)) + { + Ok(output_info) => output_info, + Err(err) => { + warn!("Failed to get output info: {:?}", err); + return None; + }, + }; + + let bit_depth = self.default_root().root_depth; + let output_modes = &output_info.modes; + let resource_modes = resources.modes(); + + let modes = resource_modes + .iter() + // XRROutputInfo contains an array of mode ids that correspond to + // modes in the array in XRRScreenResources + .filter(|x| output_modes.contains(&x.id)) + .map(|mode| { + VideoModeHandle { + size: (mode.width.into(), mode.height.into()), + refresh_rate_millihertz: monitor::mode_refresh_rate_millihertz(mode) + .unwrap_or(0), + bit_depth: bit_depth as u16, + native_mode: mode.id, + // This is populated in `MonitorHandle::video_modes` as the + // video mode is returned to the user + monitor: None, + } + }) + .collect(); + + let name = match str::from_utf8(&output_info.name) { + Ok(name) => name.to_owned(), + Err(err) => { + warn!("Failed to get output name: {:?}", err); + return None; + }, + }; + // Override DPI if `WINIT_X11_SCALE_FACTOR` variable is set + let deprecated_dpi_override = env::var("WINIT_HIDPI_FACTOR").ok(); + if deprecated_dpi_override.is_some() { + warn!( + "The WINIT_HIDPI_FACTOR environment variable is deprecated; use \ + WINIT_X11_SCALE_FACTOR" + ) + } + let dpi_env = env::var("WINIT_X11_SCALE_FACTOR").ok().map_or_else( + || EnvVarDPI::NotSet, + |var| { + if var.to_lowercase() == "randr" { + EnvVarDPI::Randr + } else if let Ok(dpi) = f64::from_str(&var) { + EnvVarDPI::Scale(dpi) + } else if var.is_empty() { + EnvVarDPI::NotSet + } else { + panic!( + "`WINIT_X11_SCALE_FACTOR` invalid; DPI factors must be either normal \ + floats greater than 0, or `randr`. Got `{var}`" + ); + } + }, + ); + + let scale_factor = match dpi_env { + EnvVarDPI::Randr => calc_dpi_factor( + (crtc.width.into(), crtc.height.into()), + (output_info.mm_width as _, output_info.mm_height as _), + ), + EnvVarDPI::Scale(dpi_override) => { + if !validate_scale_factor(dpi_override) { + panic!( + "`WINIT_X11_SCALE_FACTOR` invalid; DPI factors must be either normal \ + floats greater than 0, or `randr`. Got `{dpi_override}`", + ); + } + dpi_override + }, + EnvVarDPI::NotSet => { + if let Some(dpi) = self.get_xft_dpi() { + dpi / 96. + } else { + calc_dpi_factor( + (crtc.width.into(), crtc.height.into()), + (output_info.mm_width as _, output_info.mm_height as _), + ) + } + }, + }; + + Some((name, scale_factor, modes)) + } + + pub fn set_crtc_config( + &self, + crtc_id: randr::Crtc, + mode_id: randr::Mode, + ) -> Result<(), X11Error> { + let crtc = + self.xcb_connection().randr_get_crtc_info(crtc_id, x11rb::CURRENT_TIME)?.reply()?; + + self.xcb_connection() + .randr_set_crtc_config( + crtc_id, + crtc.timestamp, + x11rb::CURRENT_TIME, + crtc.x, + crtc.y, + mode_id, + crtc.rotation, + &crtc.outputs, + )? + .reply() + .map(|_| ()) + .map_err(Into::into) + } + + pub fn get_crtc_mode(&self, crtc_id: randr::Crtc) -> Result { + Ok(self.xcb_connection().randr_get_crtc_info(crtc_id, x11rb::CURRENT_TIME)?.reply()?.mode) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/window_property.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/window_property.rs new file mode 100644 index 00000000..0f4ca165 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/window_property.rs @@ -0,0 +1,196 @@ +use std::error::Error; +use std::fmt; +use std::sync::Arc; + +use bytemuck::{NoUninit, Pod}; + +use x11rb::connection::Connection; +use x11rb::errors::ReplyError; + +use super::*; + +pub const CARDINAL_SIZE: usize = mem::size_of::(); + +pub type Cardinal = u32; + +#[derive(Debug, Clone)] +pub enum GetPropertyError { + X11rbError(Arc), + TypeMismatch(xproto::Atom), + FormatMismatch(c_int), +} + +impl GetPropertyError { + pub fn is_actual_property_type(&self, t: xproto::Atom) -> bool { + if let GetPropertyError::TypeMismatch(actual_type) = *self { + actual_type == t + } else { + false + } + } +} + +impl> From for GetPropertyError { + fn from(e: T) -> Self { + Self::X11rbError(Arc::new(e.into())) + } +} + +impl fmt::Display for GetPropertyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GetPropertyError::X11rbError(err) => err.fmt(f), + GetPropertyError::TypeMismatch(err) => write!(f, "type mismatch: {err}"), + GetPropertyError::FormatMismatch(err) => write!(f, "format mismatch: {err}"), + } + } +} + +impl Error for GetPropertyError {} + +// Number of 32-bit chunks to retrieve per iteration of get_property's inner loop. +// To test if `get_property` works correctly, set this to 1. +const PROPERTY_BUFFER_SIZE: u32 = 1024; // 4k of RAM ought to be enough for anyone! + +impl XConnection { + pub fn get_property( + &self, + window: xproto::Window, + property: xproto::Atom, + property_type: xproto::Atom, + ) -> Result, GetPropertyError> { + let mut iter = PropIterator::new(self.xcb_connection(), window, property, property_type); + let mut data = vec![]; + + loop { + if !iter.next_window(&mut data)? { + break; + } + } + + Ok(data) + } + + pub fn change_property<'a, T: NoUninit>( + &'a self, + window: xproto::Window, + property: xproto::Atom, + property_type: xproto::Atom, + mode: xproto::PropMode, + new_value: &[T], + ) -> Result, X11Error> { + assert!([1usize, 2, 4].contains(&mem::size_of::())); + self.xcb_connection() + .change_property( + mode, + window, + property, + property_type, + (mem::size_of::() * 8) as u8, + new_value.len().try_into().expect("too many items for property"), + bytemuck::cast_slice::(new_value), + ) + .map_err(Into::into) + } +} + +/// An iterator over the "windows" of the property that we are fetching. +struct PropIterator<'a, C: ?Sized, T> { + /// Handle to the connection. + conn: &'a C, + + /// The window that we're fetching the property from. + window: xproto::Window, + + /// The property that we're fetching. + property: xproto::Atom, + + /// The type of the property that we're fetching. + property_type: xproto::Atom, + + /// The offset of the next window, in 32-bit chunks. + offset: u32, + + /// The format of the type. + format: u8, + + /// Keep a reference to `T`. + _phantom: std::marker::PhantomData, +} + +impl<'a, C: Connection + ?Sized, T: Pod> PropIterator<'a, C, T> { + /// Create a new property iterator. + fn new( + conn: &'a C, + window: xproto::Window, + property: xproto::Atom, + property_type: xproto::Atom, + ) -> Self { + let format = match mem::size_of::() { + 1 => 8, + 2 => 16, + 4 => 32, + _ => unreachable!(), + }; + + Self { + conn, + window, + property, + property_type, + offset: 0, + format, + _phantom: Default::default(), + } + } + + /// Get the next window and append it to `data`. + /// + /// Returns whether there are more windows to fetch. + fn next_window(&mut self, data: &mut Vec) -> Result { + // Send the request and wait for the reply. + let reply = self + .conn + .get_property( + false, + self.window, + self.property, + self.property_type, + self.offset, + PROPERTY_BUFFER_SIZE, + )? + .reply()?; + + // Make sure that the reply is of the correct type. + if reply.type_ != self.property_type { + return Err(GetPropertyError::TypeMismatch(reply.type_)); + } + + // Make sure that the reply is of the correct format. + if reply.format != self.format { + return Err(GetPropertyError::FormatMismatch(reply.format.into())); + } + + // Append the data to the output. + if mem::size_of::() == 1 && mem::align_of::() == 1 { + // We can just do a bytewise append. + data.extend_from_slice(bytemuck::cast_slice(&reply.value)); + } else { + // Rust's borrowing and types system makes this a bit tricky. + // + // We need to make sure that the data is properly aligned. Unfortunately the best + // safe way to do this is to copy the data to another buffer and then append. + // + // TODO(notgull): It may be worth it to use `unsafe` to copy directly from + // `reply.value` to `data`; check if this is faster. Use benchmarks! + let old_len = data.len(); + let added_len = reply.value.len() / mem::size_of::(); + data.resize(old_len + added_len, T::zeroed()); + bytemuck::cast_slice_mut::(&mut data[old_len..]).copy_from_slice(&reply.value); + } + + // Check `bytes_after` to see if there are more windows to fetch. + self.offset += PROPERTY_BUFFER_SIZE; + Ok(reply.bytes_after != 0) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/wm.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/wm.rs new file mode 100644 index 00000000..be70b835 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/wm.rs @@ -0,0 +1,137 @@ +use std::sync::Mutex; + +use super::*; + +// https://specifications.freedesktop.org/wm-spec/latest/ar01s04.html#idm46075117309248 +pub const MOVERESIZE_TOPLEFT: isize = 0; +pub const MOVERESIZE_TOP: isize = 1; +pub const MOVERESIZE_TOPRIGHT: isize = 2; +pub const MOVERESIZE_RIGHT: isize = 3; +pub const MOVERESIZE_BOTTOMRIGHT: isize = 4; +pub const MOVERESIZE_BOTTOM: isize = 5; +pub const MOVERESIZE_BOTTOMLEFT: isize = 6; +pub const MOVERESIZE_LEFT: isize = 7; +pub const MOVERESIZE_MOVE: isize = 8; + +// This info is global to the window manager. +static SUPPORTED_HINTS: Mutex> = Mutex::new(Vec::new()); +static WM_NAME: Mutex> = Mutex::new(None); + +pub fn hint_is_supported(hint: xproto::Atom) -> bool { + (*SUPPORTED_HINTS.lock().unwrap()).contains(&hint) +} + +pub fn wm_name_is_one_of(names: &[&str]) -> bool { + if let Some(ref name) = *WM_NAME.lock().unwrap() { + names.contains(&name.as_str()) + } else { + false + } +} + +impl XConnection { + pub fn update_cached_wm_info(&self, root: xproto::Window) { + *SUPPORTED_HINTS.lock().unwrap() = self.get_supported_hints(root); + *WM_NAME.lock().unwrap() = self.get_wm_name(root); + } + + fn get_supported_hints(&self, root: xproto::Window) -> Vec { + let atoms = self.atoms(); + let supported_atom = atoms[_NET_SUPPORTED]; + self.get_property(root, supported_atom, xproto::Atom::from(xproto::AtomEnum::ATOM)) + .unwrap_or_else(|_| Vec::with_capacity(0)) + } + + #[allow(clippy::useless_conversion)] + fn get_wm_name(&self, root: xproto::Window) -> Option { + let atoms = self.atoms(); + let check_atom = atoms[_NET_SUPPORTING_WM_CHECK]; + let wm_name_atom = atoms[_NET_WM_NAME]; + + // Mutter/Muffin/Budgie doesn't have _NET_SUPPORTING_WM_CHECK in its _NET_SUPPORTED, despite + // it working and being supported. This has been reported upstream, but due to the + // inavailability of time machines, we'll just try to get _NET_SUPPORTING_WM_CHECK + // regardless of whether or not the WM claims to support it. + // + // Blackbox 0.70 also incorrectly reports not supporting this, though that appears to be + // fixed in 0.72. + // if !supported_hints.contains(&check_atom) { + // return None; + // } + + // IceWM (1.3.x and earlier) doesn't report supporting _NET_WM_NAME, but will nonetheless + // provide us with a value for it. Note that the unofficial 1.4 fork of IceWM works fine. + // if !supported_hints.contains(&wm_name_atom) { + // return None; + // } + + // Of the WMs tested, only xmonad and dwm fail to provide a WM name. + + // Querying this property on the root window will give us the ID of a child window created + // by the WM. + let root_window_wm_check = { + let result = self.get_property::( + root, + check_atom, + xproto::Atom::from(xproto::AtomEnum::WINDOW), + ); + + let wm_check = result.ok().and_then(|wm_check| wm_check.first().cloned()); + + wm_check? + }; + + // Querying the same property on the child window we were given, we should get this child + // window's ID again. + let child_window_wm_check = { + let result = self.get_property::( + root_window_wm_check.into(), + check_atom, + xproto::Atom::from(xproto::AtomEnum::WINDOW), + ); + + let wm_check = result.ok().and_then(|wm_check| wm_check.first().cloned()); + + wm_check? + }; + + // These values should be the same. + if root_window_wm_check != child_window_wm_check { + return None; + } + + // All of that work gives us a window ID that we can get the WM name from. + let wm_name = { + let atoms = self.atoms(); + let utf8_string_atom = atoms[UTF8_STRING]; + + let result = + self.get_property(root_window_wm_check.into(), wm_name_atom, utf8_string_atom); + + // IceWM requires this. IceWM was also the only WM tested that returns a null-terminated + // string. For more fun trivia, IceWM is also unique in including version and uname + // information in this string (this means you'll have to be careful if you want to match + // against it, though). + // The unofficial 1.4 fork of IceWM still includes the extra details, but properly + // returns a UTF8 string that isn't null-terminated. + let no_utf8 = if let Err(ref err) = result { + err.is_actual_property_type(xproto::Atom::from(xproto::AtomEnum::STRING)) + } else { + false + }; + + if no_utf8 { + self.get_property( + root_window_wm_check.into(), + wm_name_atom, + xproto::Atom::from(xproto::AtomEnum::STRING), + ) + } else { + result + } + } + .ok(); + + wm_name.and_then(|wm_name| String::from_utf8(wm_name).ok()) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/xmodmap.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/xmodmap.rs new file mode 100644 index 00000000..565fd729 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/util/xmodmap.rs @@ -0,0 +1,56 @@ +use std::collections::HashSet; +use std::slice; + +use x11_dl::xlib::{KeyCode as XKeyCode, XModifierKeymap}; + +// Offsets within XModifierKeymap to each set of keycodes. +// We are only interested in Shift, Control, Alt, and Logo. +// +// There are 8 sets total. The order of keycode sets is: +// Shift, Lock, Control, Mod1 (Alt), Mod2, Mod3, Mod4 (Logo), Mod5 +// +// https://tronche.com/gui/x/xlib/input/XSetModifierMapping.html +const NUM_MODS: usize = 8; + +/// Track which keys are modifiers, so we can properly replay them when they were filtered. +#[derive(Debug, Default)] +pub struct ModifierKeymap { + // Maps keycodes to modifiers + modifiers: HashSet, +} + +impl ModifierKeymap { + pub fn new() -> ModifierKeymap { + ModifierKeymap::default() + } + + pub fn is_modifier(&self, keycode: XKeyCode) -> bool { + self.modifiers.contains(&keycode) + } + + pub fn reload_from_x_connection(&mut self, xconn: &super::XConnection) { + unsafe { + let keymap = (xconn.xlib.XGetModifierMapping)(xconn.display); + + if keymap.is_null() { + return; + } + + self.reset_from_x_keymap(&*keymap); + + (xconn.xlib.XFreeModifiermap)(keymap); + } + } + + fn reset_from_x_keymap(&mut self, keymap: &XModifierKeymap) { + let keys_per_mod = keymap.max_keypermod as usize; + + let keys = unsafe { + slice::from_raw_parts(keymap.modifiermap as *const _, keys_per_mod * NUM_MODS) + }; + self.modifiers.clear(); + for key in keys { + self.modifiers.insert(*key); + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/window.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/window.rs new file mode 100644 index 00000000..7e15d5f2 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/window.rs @@ -0,0 +1,1938 @@ +use std::ffi::CString; +use std::mem::replace; +use std::os::raw::*; +use std::path::Path; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::{cmp, env}; + +use tracing::{debug, info, warn}; +use x11rb::connection::Connection; +use x11rb::properties::{WmHints, WmSizeHints, WmSizeHintsSpecification}; +use x11rb::protocol::shape::{ConnectionExt as ShapeExt, SK, SO}; +use x11rb::protocol::xproto::{self, ClipOrdering, ConnectionExt as _, Rectangle}; +use x11rb::protocol::{randr, xinput}; + +use crate::cursor::{Cursor, CustomCursor as RootCustomCursor}; +use crate::dpi::{PhysicalPosition, PhysicalSize, Position, Size}; +use crate::error::{ExternalError, NotSupportedError, OsError as RootOsError}; +use crate::event::{Event, InnerSizeWriter, WindowEvent}; +use crate::event_loop::AsyncRequestSerial; +use crate::platform::x11::WindowType; +use crate::platform_impl::x11::atoms::*; +use crate::platform_impl::x11::{ + xinput_fp1616_to_float, MonitorHandle as X11MonitorHandle, WakeSender, X11Error, +}; +use crate::platform_impl::{ + Fullscreen, MonitorHandle as PlatformMonitorHandle, OsError, PlatformCustomCursor, + PlatformIcon, VideoModeHandle as PlatformVideoModeHandle, +}; +use crate::window::{ + CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, WindowAttributes, + WindowButtons, WindowLevel, +}; + +use super::util::{self, SelectedCursor}; +use super::{ + ffi, ActiveEventLoop, CookieResultExt, ImeRequest, ImeSender, VoidCookie, WindowId, XConnection, +}; + +#[derive(Debug)] +pub struct SharedState { + pub cursor_pos: Option<(f64, f64)>, + pub size: Option<(u32, u32)>, + pub position: Option<(i32, i32)>, + pub inner_position: Option<(i32, i32)>, + pub inner_position_rel_parent: Option<(i32, i32)>, + pub is_resizable: bool, + pub is_decorated: bool, + pub last_monitor: X11MonitorHandle, + pub dpi_adjusted: Option<(u32, u32)>, + pub(crate) fullscreen: Option, + // Set when application calls `set_fullscreen` when window is not visible + pub(crate) desired_fullscreen: Option>, + // Used to restore position after exiting fullscreen + pub restore_position: Option<(i32, i32)>, + // Used to restore video mode after exiting fullscreen + pub desktop_video_mode: Option<(randr::Crtc, randr::Mode)>, + pub frame_extents: Option, + pub min_inner_size: Option, + pub max_inner_size: Option, + pub resize_increments: Option, + pub base_size: Option, + pub visibility: Visibility, + pub has_focus: bool, + // Use `Option` to not apply hittest logic when it was never requested. + pub cursor_hittest: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Visibility { + No, + Yes, + // Waiting for VisibilityNotify + YesWait, +} + +impl SharedState { + fn new(last_monitor: X11MonitorHandle, window_attributes: &WindowAttributes) -> Mutex { + let visibility = + if window_attributes.visible { Visibility::YesWait } else { Visibility::No }; + + Mutex::new(SharedState { + last_monitor, + visibility, + + is_resizable: window_attributes.resizable, + is_decorated: window_attributes.decorations, + cursor_pos: None, + size: None, + position: None, + inner_position: None, + inner_position_rel_parent: None, + dpi_adjusted: None, + fullscreen: None, + desired_fullscreen: None, + restore_position: None, + desktop_video_mode: None, + frame_extents: None, + min_inner_size: None, + max_inner_size: None, + resize_increments: None, + base_size: None, + has_focus: false, + cursor_hittest: None, + }) + } +} + +unsafe impl Send for UnownedWindow {} +unsafe impl Sync for UnownedWindow {} + +pub struct UnownedWindow { + pub(crate) xconn: Arc, // never changes + xwindow: xproto::Window, // never changes + #[allow(dead_code)] + visual: u32, // never changes + root: xproto::Window, // never changes + #[allow(dead_code)] + screen_id: i32, // never changes + selected_cursor: Mutex, + cursor_grabbed_mode: Mutex, + #[allow(clippy::mutex_atomic)] + cursor_visible: Mutex, + ime_sender: Mutex, + pub shared_state: Mutex, + redraw_sender: WakeSender, + activation_sender: WakeSender, +} + +macro_rules! leap { + ($e:expr) => { + match $e { + Ok(x) => x, + Err(err) => return Err(os_error!(OsError::XError(X11Error::from(err).into()))), + } + }; +} + +impl UnownedWindow { + #[allow(clippy::unnecessary_cast)] + pub(crate) fn new( + event_loop: &ActiveEventLoop, + window_attrs: WindowAttributes, + ) -> Result { + let xconn = &event_loop.xconn; + let atoms = xconn.atoms(); + + let screen_id = match window_attrs.platform_specific.x11.screen_id { + Some(id) => id, + None => xconn.default_screen_index() as c_int, + }; + + let screen = { + let screen_id_usize = usize::try_from(screen_id) + .map_err(|_| os_error!(OsError::Misc("screen id must be non-negative")))?; + xconn.xcb_connection().setup().roots.get(screen_id_usize).ok_or(os_error!( + OsError::Misc("requested screen id not present in server's response") + ))? + }; + + #[cfg(feature = "rwh_06")] + let root = match window_attrs.parent_window.as_ref().map(|handle| handle.0) { + Some(rwh_06::RawWindowHandle::Xlib(handle)) => handle.window as xproto::Window, + Some(rwh_06::RawWindowHandle::Xcb(handle)) => handle.window.get(), + Some(raw) => unreachable!("Invalid raw window handle {raw:?} on X11"), + None => screen.root, + }; + #[cfg(not(feature = "rwh_06"))] + let root = event_loop.root; + + let mut monitors = leap!(xconn.available_monitors()); + let guessed_monitor = if monitors.is_empty() { + X11MonitorHandle::dummy() + } else { + xconn + .query_pointer(root, util::VIRTUAL_CORE_POINTER) + .ok() + .and_then(|pointer_state| { + let (x, y) = (pointer_state.root_x as i64, pointer_state.root_y as i64); + + for i in 0..monitors.len() { + if monitors[i].rect.contains_point(x, y) { + return Some(monitors.swap_remove(i)); + } + } + + None + }) + .unwrap_or_else(|| monitors.swap_remove(0)) + }; + let scale_factor = guessed_monitor.scale_factor(); + + info!("Guessed window scale factor: {}", scale_factor); + + let max_inner_size: Option<(u32, u32)> = + window_attrs.max_inner_size.map(|size| size.to_physical::(scale_factor).into()); + let min_inner_size: Option<(u32, u32)> = + window_attrs.min_inner_size.map(|size| size.to_physical::(scale_factor).into()); + + let position = + window_attrs.position.map(|position| position.to_physical::(scale_factor)); + + let dimensions = { + // x11 only applies constraints when the window is actively resized + // by the user, so we have to manually apply the initial constraints + let mut dimensions: (u32, u32) = window_attrs + .inner_size + .map(|size| size.to_physical::(scale_factor)) + .or_else(|| Some((800, 600).into())) + .map(Into::into) + .unwrap(); + if let Some(max) = max_inner_size { + dimensions.0 = cmp::min(dimensions.0, max.0); + dimensions.1 = cmp::min(dimensions.1, max.1); + } + if let Some(min) = min_inner_size { + dimensions.0 = cmp::max(dimensions.0, min.0); + dimensions.1 = cmp::max(dimensions.1, min.1); + } + debug!("Calculated physical dimensions: {}x{}", dimensions.0, dimensions.1); + dimensions + }; + + // An iterator over the visuals matching screen id combined with their depths. + let mut all_visuals = screen + .allowed_depths + .iter() + .flat_map(|depth| depth.visuals.iter().map(move |visual| (visual, depth.depth))); + + // creating + let (visualtype, depth, require_colormap) = + match window_attrs.platform_specific.x11.visual_id { + Some(vi) => { + // Find this specific visual. + let (visualtype, depth) = + all_visuals.find(|(visual, _)| visual.visual_id == vi).ok_or_else( + || os_error!(OsError::XError(X11Error::NoSuchVisual(vi).into())), + )?; + + (Some(visualtype), depth, true) + }, + None if window_attrs.transparent => { + // Find a suitable visual, true color with 32 bits of depth. + all_visuals + .find_map(|(visual, depth)| { + (depth == 32 && visual.class == xproto::VisualClass::TRUE_COLOR) + .then_some((Some(visual), depth, true)) + }) + .unwrap_or_else(|| { + debug!( + "Could not set transparency, because XMatchVisualInfo returned \ + zero for the required parameters" + ); + (None as _, x11rb::COPY_FROM_PARENT as _, false) + }) + }, + _ => (None, x11rb::COPY_FROM_PARENT as _, false), + }; + let mut visual = visualtype.map_or(x11rb::COPY_FROM_PARENT, |v| v.visual_id); + + let window_attributes = { + use xproto::EventMask; + + let mut aux = xproto::CreateWindowAux::new(); + let event_mask = EventMask::EXPOSURE + | EventMask::STRUCTURE_NOTIFY + | EventMask::VISIBILITY_CHANGE + | EventMask::KEY_PRESS + | EventMask::KEY_RELEASE + | EventMask::KEYMAP_STATE + | EventMask::BUTTON_PRESS + | EventMask::BUTTON_RELEASE + | EventMask::POINTER_MOTION + | EventMask::PROPERTY_CHANGE; + + aux = aux.event_mask(event_mask).border_pixel(0); + + if window_attrs.platform_specific.x11.override_redirect { + aux = aux.override_redirect(true as u32); + } + + // Add a colormap if needed. + let colormap_visual = match window_attrs.platform_specific.x11.visual_id { + Some(vi) => Some(vi), + None if require_colormap => Some(visual), + _ => None, + }; + + if let Some(visual) = colormap_visual { + let colormap = leap!(xconn.xcb_connection().generate_id()); + leap!(xconn.xcb_connection().create_colormap( + xproto::ColormapAlloc::NONE, + colormap, + root, + visual, + )); + aux = aux.colormap(colormap); + } else { + aux = aux.colormap(0); + } + + aux + }; + + // Figure out the window's parent. + let parent = window_attrs.platform_specific.x11.embed_window.unwrap_or(root); + + // finally creating the window + let xwindow = { + let (x, y) = position.map_or((0, 0), Into::into); + let wid = leap!(xconn.xcb_connection().generate_id()); + let result = xconn.xcb_connection().create_window( + depth, + wid, + parent, + x, + y, + dimensions.0.try_into().unwrap(), + dimensions.1.try_into().unwrap(), + 0, + xproto::WindowClass::INPUT_OUTPUT, + visual, + &window_attributes, + ); + leap!(leap!(result).check()); + + wid + }; + + // The COPY_FROM_PARENT is a special value for the visual used to copy + // the visual from the parent window, thus we have to query the visual + // we've got when we built the window above. + if visual == x11rb::COPY_FROM_PARENT { + visual = leap!(leap!(xconn + .xcb_connection() + .get_window_attributes(xwindow as xproto::Window)) + .reply()) + .visual; + } + + #[allow(clippy::mutex_atomic)] + let mut window = UnownedWindow { + xconn: Arc::clone(xconn), + xwindow: xwindow as xproto::Window, + visual, + root, + screen_id, + selected_cursor: Default::default(), + cursor_grabbed_mode: Mutex::new(CursorGrabMode::None), + cursor_visible: Mutex::new(true), + ime_sender: Mutex::new(event_loop.ime_sender.clone()), + shared_state: SharedState::new(guessed_monitor, &window_attrs), + redraw_sender: event_loop.redraw_sender.clone(), + activation_sender: event_loop.activation_sender.clone(), + }; + + // Title must be set before mapping. Some tiling window managers (i.e. i3) use the window + // title to determine placement/etc., so doing this after mapping would cause the WM to + // act on the wrong title state. + leap!(window.set_title_inner(&window_attrs.title)).ignore_error(); + leap!(window.set_decorations_inner(window_attrs.decorations)).ignore_error(); + + if let Some(theme) = window_attrs.preferred_theme { + leap!(window.set_theme_inner(Some(theme))).ignore_error(); + } + + // Embed the window if needed. + if window_attrs.platform_specific.x11.embed_window.is_some() { + window.embed_window()?; + } + + { + // Enable drag and drop (TODO: extend API to make this toggleable) + { + let dnd_aware_atom = atoms[XdndAware]; + let version = &[5u32]; // Latest version; hasn't changed since 2002 + leap!(xconn.change_property( + window.xwindow, + dnd_aware_atom, + u32::from(xproto::AtomEnum::ATOM), + xproto::PropMode::REPLACE, + version, + )) + .ignore_error(); + } + + // WM_CLASS must be set *before* mapping the window, as per ICCCM! + { + let (instance, class) = if let Some(name) = window_attrs.platform_specific.name { + (name.instance, name.general) + } else { + let class = env::args_os() + .next() + .as_ref() + // Default to the name of the binary (via argv[0]) + .and_then(|path| Path::new(path).file_name()) + .and_then(|bin_name| bin_name.to_str()) + .map(|bin_name| bin_name.to_owned()) + .unwrap_or_else(|| window_attrs.title.clone()); + // This environment variable is extraordinarily unlikely to actually be used... + let instance = env::var("RESOURCE_NAME").ok().unwrap_or_else(|| class.clone()); + (instance, class) + }; + + let class = format!("{instance}\0{class}\0"); + leap!(xconn.change_property( + window.xwindow, + xproto::Atom::from(xproto::AtomEnum::WM_CLASS), + xproto::Atom::from(xproto::AtomEnum::STRING), + xproto::PropMode::REPLACE, + class.as_bytes(), + )) + .ignore_error(); + } + + if let Some(flusher) = leap!(window.set_pid()) { + flusher.ignore_error() + } + + leap!(window.set_window_types(window_attrs.platform_specific.x11.x11_window_types)) + .ignore_error(); + + // Set size hints. + let mut min_inner_size = + window_attrs.min_inner_size.map(|size| size.to_physical::(scale_factor)); + let mut max_inner_size = + window_attrs.max_inner_size.map(|size| size.to_physical::(scale_factor)); + + if !window_attrs.resizable { + if util::wm_name_is_one_of(&["Xfwm4"]) { + warn!("To avoid a WM bug, disabling resizing has no effect on Xfwm4"); + } else { + max_inner_size = Some(dimensions.into()); + min_inner_size = Some(dimensions.into()); + } + } + + let shared_state = window.shared_state.get_mut().unwrap(); + shared_state.min_inner_size = min_inner_size.map(Into::into); + shared_state.max_inner_size = max_inner_size.map(Into::into); + shared_state.resize_increments = window_attrs.resize_increments; + shared_state.base_size = window_attrs.platform_specific.x11.base_size; + + let normal_hints = WmSizeHints { + position: position.map(|PhysicalPosition { x, y }| { + (WmSizeHintsSpecification::UserSpecified, x, y) + }), + size: Some(( + WmSizeHintsSpecification::UserSpecified, + cast_dimension_to_hint(dimensions.0), + cast_dimension_to_hint(dimensions.1), + )), + max_size: max_inner_size.map(cast_physical_size_to_hint), + min_size: min_inner_size.map(cast_physical_size_to_hint), + size_increment: window_attrs + .resize_increments + .map(|size| cast_size_to_hint(size, scale_factor)), + base_size: window_attrs + .platform_specific + .x11 + .base_size + .map(|size| cast_size_to_hint(size, scale_factor)), + aspect: None, + win_gravity: None, + }; + leap!(leap!(normal_hints.set( + xconn.xcb_connection(), + window.xwindow as xproto::Window, + xproto::AtomEnum::WM_NORMAL_HINTS, + )) + .check()); + + // Set window icons + if let Some(icon) = window_attrs.window_icon { + leap!(window.set_icon_inner(icon.inner)).ignore_error(); + } + + // Opt into handling window close + let result = xconn.xcb_connection().change_property( + xproto::PropMode::REPLACE, + window.xwindow, + atoms[WM_PROTOCOLS], + xproto::AtomEnum::ATOM, + 32, + 2, + bytemuck::cast_slice::(&[ + atoms[WM_DELETE_WINDOW], + atoms[_NET_WM_PING], + ]), + ); + leap!(result).ignore_error(); + + // Select XInput2 events + let mask = xinput::XIEventMask::MOTION + | xinput::XIEventMask::BUTTON_PRESS + | xinput::XIEventMask::BUTTON_RELEASE + | xinput::XIEventMask::ENTER + | xinput::XIEventMask::LEAVE + | xinput::XIEventMask::FOCUS_IN + | xinput::XIEventMask::FOCUS_OUT + | xinput::XIEventMask::TOUCH_BEGIN + | xinput::XIEventMask::TOUCH_UPDATE + | xinput::XIEventMask::TOUCH_END; + leap!(xconn.select_xinput_events(window.xwindow, super::ALL_MASTER_DEVICES, mask)) + .ignore_error(); + + // Set visibility (map window) + if window_attrs.visible { + leap!(xconn.xcb_connection().map_window(window.xwindow)).ignore_error(); + leap!(xconn.xcb_connection().configure_window( + xwindow, + &xproto::ConfigureWindowAux::new().stack_mode(xproto::StackMode::ABOVE) + )) + .ignore_error(); + } + + // Attempt to make keyboard input repeat detectable + unsafe { + let mut supported_ptr = ffi::False; + (xconn.xlib.XkbSetDetectableAutoRepeat)( + xconn.display, + ffi::True, + &mut supported_ptr, + ); + if supported_ptr == ffi::False { + return Err(os_error!(OsError::Misc("`XkbSetDetectableAutoRepeat` failed"))); + } + } + + // Try to create input context for the window. + if let Some(ime) = event_loop.ime.as_ref() { + let result = ime.borrow_mut().create_context(window.xwindow as ffi::Window, false); + leap!(result); + } + + // These properties must be set after mapping + if window_attrs.maximized { + leap!(window.set_maximized_inner(window_attrs.maximized)).ignore_error(); + } + if window_attrs.fullscreen.is_some() { + if let Some(flusher) = + leap!(window + .set_fullscreen_inner(window_attrs.fullscreen.clone().map(Into::into))) + { + flusher.ignore_error() + } + + if let Some(PhysicalPosition { x, y }) = position { + let shared_state = window.shared_state.get_mut().unwrap(); + + shared_state.restore_position = Some((x, y)); + } + } + + leap!(window.set_window_level_inner(window_attrs.window_level)).ignore_error(); + } + + window.set_cursor(window_attrs.cursor); + + // Remove the startup notification if we have one. + if let Some(startup) = window_attrs.platform_specific.activation_token.as_ref() { + leap!(xconn.remove_activation_token(xwindow, &startup.token)); + } + + // We never want to give the user a broken window, since by then, it's too late to handle. + let window = leap!(xconn.sync_with_server().map(|_| window)); + + Ok(window) + } + + /// Embed this window into a parent window. + pub(super) fn embed_window(&self) -> Result<(), RootOsError> { + let atoms = self.xconn.atoms(); + leap!(leap!(self.xconn.change_property( + self.xwindow, + atoms[_XEMBED], + atoms[_XEMBED], + xproto::PropMode::REPLACE, + &[0u32, 1u32], + )) + .check()); + + Ok(()) + } + + pub(super) fn shared_state_lock(&self) -> MutexGuard<'_, SharedState> { + self.shared_state.lock().unwrap() + } + + fn set_pid(&self) -> Result>, X11Error> { + let atoms = self.xconn.atoms(); + let pid_atom = atoms[_NET_WM_PID]; + let client_machine_atom = atoms[WM_CLIENT_MACHINE]; + + // Get the hostname and the PID. + let uname = rustix::system::uname(); + let pid = rustix::process::getpid(); + + self.xconn + .change_property( + self.xwindow, + pid_atom, + xproto::Atom::from(xproto::AtomEnum::CARDINAL), + xproto::PropMode::REPLACE, + &[pid.as_raw_nonzero().get() as util::Cardinal], + )? + .ignore_error(); + let flusher = self.xconn.change_property( + self.xwindow, + client_machine_atom, + xproto::Atom::from(xproto::AtomEnum::STRING), + xproto::PropMode::REPLACE, + uname.nodename().to_bytes(), + ); + flusher.map(Some) + } + + fn set_window_types(&self, window_types: Vec) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + let hint_atom = atoms[_NET_WM_WINDOW_TYPE]; + let atoms: Vec<_> = window_types.iter().map(|t| t.as_atom(&self.xconn)).collect(); + + self.xconn.change_property( + self.xwindow, + hint_atom, + xproto::Atom::from(xproto::AtomEnum::ATOM), + xproto::PropMode::REPLACE, + &atoms, + ) + } + + pub fn set_theme_inner(&self, theme: Option) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + let hint_atom = atoms[_GTK_THEME_VARIANT]; + let utf8_atom = atoms[UTF8_STRING]; + let variant = match theme { + Some(Theme::Dark) => "dark", + Some(Theme::Light) => "light", + None => "dark", + }; + let variant = CString::new(variant).expect("`_GTK_THEME_VARIANT` contained null byte"); + self.xconn.change_property( + self.xwindow, + hint_atom, + utf8_atom, + xproto::PropMode::REPLACE, + variant.as_bytes(), + ) + } + + #[inline] + pub fn set_theme(&self, theme: Option) { + self.set_theme_inner(theme).expect("Failed to change window theme").ignore_error(); + + self.xconn.flush_requests().expect("Failed to change window theme"); + } + + fn set_netwm( + &self, + operation: util::StateOperation, + properties: (u32, u32, u32, u32), + ) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + let state_atom = atoms[_NET_WM_STATE]; + self.xconn.send_client_msg( + self.xwindow, + self.root, + state_atom, + Some(xproto::EventMask::SUBSTRUCTURE_REDIRECT | xproto::EventMask::SUBSTRUCTURE_NOTIFY), + [operation as u32, properties.0, properties.1, properties.2, properties.3], + ) + } + + fn set_fullscreen_hint(&self, fullscreen: bool) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + let fullscreen_atom = atoms[_NET_WM_STATE_FULLSCREEN]; + let flusher = self.set_netwm(fullscreen.into(), (fullscreen_atom, 0, 0, 0)); + + if fullscreen { + // Ensure that the fullscreen window receives input focus to prevent + // locking up the user's display. + self.xconn + .xcb_connection() + .set_input_focus(xproto::InputFocus::PARENT, self.xwindow, x11rb::CURRENT_TIME)? + .ignore_error(); + } + + flusher + } + + fn set_fullscreen_inner( + &self, + fullscreen: Option, + ) -> Result>, X11Error> { + let mut shared_state_lock = self.shared_state_lock(); + + match shared_state_lock.visibility { + // Setting fullscreen on a window that is not visible will generate an error. + Visibility::No | Visibility::YesWait => { + shared_state_lock.desired_fullscreen = Some(fullscreen); + return Ok(None); + }, + Visibility::Yes => (), + } + + let old_fullscreen = shared_state_lock.fullscreen.clone(); + if old_fullscreen == fullscreen { + return Ok(None); + } + shared_state_lock.fullscreen.clone_from(&fullscreen); + + match (&old_fullscreen, &fullscreen) { + // Store the desktop video mode before entering exclusive + // fullscreen, so we can restore it upon exit, as XRandR does not + // provide a mechanism to set this per app-session or restore this + // to the desktop video mode as macOS and Windows do + (&None, &Some(Fullscreen::Exclusive(PlatformVideoModeHandle::X(ref video_mode)))) + | ( + &Some(Fullscreen::Borderless(_)), + &Some(Fullscreen::Exclusive(PlatformVideoModeHandle::X(ref video_mode))), + ) => { + let monitor = video_mode.monitor.as_ref().unwrap(); + shared_state_lock.desktop_video_mode = Some(( + monitor.id, + self.xconn.get_crtc_mode(monitor.id).expect("Failed to get desktop video mode"), + )); + }, + // Restore desktop video mode upon exiting exclusive fullscreen + (&Some(Fullscreen::Exclusive(_)), &None) + | (&Some(Fullscreen::Exclusive(_)), &Some(Fullscreen::Borderless(_))) => { + let (monitor_id, mode_id) = shared_state_lock.desktop_video_mode.take().unwrap(); + self.xconn + .set_crtc_config(monitor_id, mode_id) + .expect("failed to restore desktop video mode"); + }, + _ => (), + } + + drop(shared_state_lock); + + match fullscreen { + None => { + let flusher = self.set_fullscreen_hint(false); + let mut shared_state_lock = self.shared_state_lock(); + if let Some(position) = shared_state_lock.restore_position.take() { + drop(shared_state_lock); + self.set_position_inner(position.0, position.1) + .expect_then_ignore_error("Failed to restore window position"); + } + flusher.map(Some) + }, + Some(fullscreen) => { + let (video_mode, monitor) = match fullscreen { + Fullscreen::Exclusive(PlatformVideoModeHandle::X(ref video_mode)) => { + (Some(video_mode), video_mode.monitor.clone().unwrap()) + }, + Fullscreen::Borderless(Some(PlatformMonitorHandle::X(monitor))) => { + (None, monitor) + }, + Fullscreen::Borderless(None) => { + (None, self.shared_state_lock().last_monitor.clone()) + }, + #[cfg(wayland_platform)] + _ => unreachable!(), + }; + + // Don't set fullscreen on an invalid dummy monitor handle + if monitor.is_dummy() { + return Ok(None); + } + + if let Some(video_mode) = video_mode { + // FIXME: this is actually not correct if we're setting the + // video mode to a resolution higher than the current + // desktop resolution, because XRandR does not automatically + // reposition the monitors to the right and below this + // monitor. + // + // What ends up happening is we will get the fullscreen + // window showing up on those monitors as well, because + // their virtual position now overlaps with the monitor that + // we just made larger.. + // + // It'd be quite a bit of work to handle this correctly (and + // nobody else seems to bother doing this correctly either), + // so we're just leaving this broken. Fixing this would + // involve storing all CRTCs upon entering fullscreen, + // restoring them upon exit, and after entering fullscreen, + // repositioning displays to the right and below this + // display. I think there would still be edge cases that are + // difficult or impossible to handle correctly, e.g. what if + // a new monitor was plugged in while in fullscreen? + // + // I think we might just want to disallow setting the video + // mode higher than the current desktop video mode (I'm sure + // this will make someone unhappy, but it's very unusual for + // games to want to do this anyway). + self.xconn + .set_crtc_config(monitor.id, video_mode.native_mode) + .expect("failed to set video mode"); + } + + let window_position = self.outer_position_physical(); + self.shared_state_lock().restore_position = Some(window_position); + let monitor_origin: (i32, i32) = monitor.position().into(); + self.set_position_inner(monitor_origin.0, monitor_origin.1) + .expect_then_ignore_error("Failed to set window position"); + self.set_fullscreen_hint(true).map(Some) + }, + } + } + + #[inline] + pub(crate) fn fullscreen(&self) -> Option { + let shared_state = self.shared_state_lock(); + + shared_state.desired_fullscreen.clone().unwrap_or_else(|| shared_state.fullscreen.clone()) + } + + #[inline] + pub(crate) fn set_fullscreen(&self, fullscreen: Option) { + if let Some(flusher) = + self.set_fullscreen_inner(fullscreen).expect("Failed to change window fullscreen state") + { + flusher.check().expect("Failed to change window fullscreen state"); + self.invalidate_cached_frame_extents(); + } + } + + // Called by EventProcessor when a VisibilityNotify event is received + pub(crate) fn visibility_notify(&self) { + let mut shared_state = self.shared_state_lock(); + + match shared_state.visibility { + Visibility::No => self + .xconn + .xcb_connection() + .unmap_window(self.xwindow) + .expect_then_ignore_error("Failed to unmap window"), + Visibility::Yes => (), + Visibility::YesWait => { + shared_state.visibility = Visibility::Yes; + + if let Some(fullscreen) = shared_state.desired_fullscreen.take() { + drop(shared_state); + self.set_fullscreen(fullscreen); + } + }, + } + } + + pub fn current_monitor(&self) -> Option { + Some(self.shared_state_lock().last_monitor.clone()) + } + + pub fn available_monitors(&self) -> Vec { + self.xconn.available_monitors().expect("Failed to get available monitors") + } + + pub fn primary_monitor(&self) -> Option { + Some(self.xconn.primary_monitor().expect("Failed to get primary monitor")) + } + + #[inline] + pub fn is_minimized(&self) -> Option { + let atoms = self.xconn.atoms(); + let state_atom = atoms[_NET_WM_STATE]; + let state = self.xconn.get_property( + self.xwindow, + state_atom, + xproto::Atom::from(xproto::AtomEnum::ATOM), + ); + let hidden_atom = atoms[_NET_WM_STATE_HIDDEN]; + + Some(match state { + Ok(atoms) => { + atoms.iter().any(|atom: &xproto::Atom| *atom as xproto::Atom == hidden_atom) + }, + _ => false, + }) + } + + /// Refresh the API for the given monitor. + #[inline] + pub(super) fn refresh_dpi_for_monitor( + &self, + new_monitor: &X11MonitorHandle, + maybe_prev_scale_factor: Option, + mut callback: impl FnMut(Event), + ) { + // Check if the self is on this monitor + let monitor = self.shared_state_lock().last_monitor.clone(); + if monitor.name == new_monitor.name { + let (width, height) = self.inner_size_physical(); + let (new_width, new_height) = self.adjust_for_dpi( + // If we couldn't determine the previous scale + // factor (e.g., because all monitors were closed + // before), just pick whatever the current monitor + // has set as a baseline. + maybe_prev_scale_factor.unwrap_or(monitor.scale_factor), + new_monitor.scale_factor, + width, + height, + &self.shared_state_lock(), + ); + + let window_id = crate::window::WindowId(self.id()); + let old_inner_size = PhysicalSize::new(width, height); + let inner_size = Arc::new(Mutex::new(PhysicalSize::new(new_width, new_height))); + callback(Event::WindowEvent { + window_id, + event: WindowEvent::ScaleFactorChanged { + scale_factor: new_monitor.scale_factor, + inner_size_writer: InnerSizeWriter::new(Arc::downgrade(&inner_size)), + }, + }); + + let new_inner_size = *inner_size.lock().unwrap(); + drop(inner_size); + + if new_inner_size != old_inner_size { + let (new_width, new_height) = new_inner_size.into(); + self.request_inner_size_physical(new_width, new_height); + } + } + } + + fn set_minimized_inner(&self, minimized: bool) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + + if minimized { + let root_window = self.xconn.default_root().root; + + self.xconn.send_client_msg( + self.xwindow, + root_window, + atoms[WM_CHANGE_STATE], + Some( + xproto::EventMask::SUBSTRUCTURE_REDIRECT + | xproto::EventMask::SUBSTRUCTURE_NOTIFY, + ), + [3u32, 0, 0, 0, 0], + ) + } else { + self.xconn.send_client_msg( + self.xwindow, + self.root, + atoms[_NET_ACTIVE_WINDOW], + Some( + xproto::EventMask::SUBSTRUCTURE_REDIRECT + | xproto::EventMask::SUBSTRUCTURE_NOTIFY, + ), + [1, x11rb::CURRENT_TIME, 0, 0, 0], + ) + } + } + + #[inline] + pub fn set_minimized(&self, minimized: bool) { + self.set_minimized_inner(minimized) + .expect_then_ignore_error("Failed to change window minimization"); + + self.xconn.flush_requests().expect("Failed to change window minimization"); + } + + #[inline] + pub fn is_maximized(&self) -> bool { + let atoms = self.xconn.atoms(); + let state_atom = atoms[_NET_WM_STATE]; + let state = self.xconn.get_property( + self.xwindow, + state_atom, + xproto::Atom::from(xproto::AtomEnum::ATOM), + ); + let horz_atom = atoms[_NET_WM_STATE_MAXIMIZED_HORZ]; + let vert_atom = atoms[_NET_WM_STATE_MAXIMIZED_VERT]; + match state { + Ok(atoms) => { + let horz_maximized = atoms.contains(&horz_atom); + let vert_maximized = atoms.contains(&vert_atom); + horz_maximized && vert_maximized + }, + _ => false, + } + } + + fn set_maximized_inner(&self, maximized: bool) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + let horz_atom = atoms[_NET_WM_STATE_MAXIMIZED_HORZ]; + let vert_atom = atoms[_NET_WM_STATE_MAXIMIZED_VERT]; + + self.set_netwm(maximized.into(), (horz_atom, vert_atom, 0, 0)) + } + + #[inline] + pub fn set_maximized(&self, maximized: bool) { + self.set_maximized_inner(maximized) + .expect_then_ignore_error("Failed to change window maximization"); + self.xconn.flush_requests().expect("Failed to change window maximization"); + self.invalidate_cached_frame_extents(); + } + + fn set_title_inner(&self, title: &str) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + + let title = CString::new(title).expect("Window title contained null byte"); + self.xconn + .change_property( + self.xwindow, + xproto::Atom::from(xproto::AtomEnum::WM_NAME), + xproto::Atom::from(xproto::AtomEnum::STRING), + xproto::PropMode::REPLACE, + title.as_bytes(), + )? + .ignore_error(); + self.xconn.change_property( + self.xwindow, + atoms[_NET_WM_NAME], + atoms[UTF8_STRING], + xproto::PropMode::REPLACE, + title.as_bytes(), + ) + } + + #[inline] + pub fn set_title(&self, title: &str) { + self.set_title_inner(title).expect_then_ignore_error("Failed to set window title"); + + self.xconn.flush_requests().expect("Failed to set window title"); + } + + #[inline] + pub fn set_transparent(&self, _transparent: bool) {} + + #[inline] + pub fn set_blur(&self, _blur: bool) {} + + fn set_decorations_inner(&self, decorations: bool) -> Result, X11Error> { + self.shared_state_lock().is_decorated = decorations; + let mut hints = self.xconn.get_motif_hints(self.xwindow); + + hints.set_decorations(decorations); + + self.xconn.set_motif_hints(self.xwindow, &hints) + } + + #[inline] + pub fn set_decorations(&self, decorations: bool) { + self.set_decorations_inner(decorations) + .expect_then_ignore_error("Failed to set decoration state"); + self.xconn.flush_requests().expect("Failed to set decoration state"); + self.invalidate_cached_frame_extents(); + } + + #[inline] + pub fn is_decorated(&self) -> bool { + self.shared_state_lock().is_decorated + } + + fn set_maximizable_inner(&self, maximizable: bool) -> Result, X11Error> { + let mut hints = self.xconn.get_motif_hints(self.xwindow); + + hints.set_maximizable(maximizable); + + self.xconn.set_motif_hints(self.xwindow, &hints) + } + + fn toggle_atom(&self, atom_name: AtomName, enable: bool) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + let atom = atoms[atom_name]; + self.set_netwm(enable.into(), (atom, 0, 0, 0)) + } + + fn set_window_level_inner(&self, level: WindowLevel) -> Result, X11Error> { + self.toggle_atom(_NET_WM_STATE_ABOVE, level == WindowLevel::AlwaysOnTop)?.ignore_error(); + self.toggle_atom(_NET_WM_STATE_BELOW, level == WindowLevel::AlwaysOnBottom) + } + + #[inline] + pub fn set_window_level(&self, level: WindowLevel) { + self.set_window_level_inner(level) + .expect_then_ignore_error("Failed to set window-level state"); + self.xconn.flush_requests().expect("Failed to set window-level state"); + } + + fn set_icon_inner(&self, icon: PlatformIcon) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + let icon_atom = atoms[_NET_WM_ICON]; + let data = icon.to_cardinals(); + self.xconn.change_property( + self.xwindow, + icon_atom, + xproto::Atom::from(xproto::AtomEnum::CARDINAL), + xproto::PropMode::REPLACE, + data.as_slice(), + ) + } + + fn unset_icon_inner(&self) -> Result, X11Error> { + let atoms = self.xconn.atoms(); + let icon_atom = atoms[_NET_WM_ICON]; + let empty_data: [util::Cardinal; 0] = []; + self.xconn.change_property( + self.xwindow, + icon_atom, + xproto::Atom::from(xproto::AtomEnum::CARDINAL), + xproto::PropMode::REPLACE, + &empty_data, + ) + } + + #[inline] + pub(crate) fn set_window_icon(&self, icon: Option) { + match icon { + Some(icon) => self.set_icon_inner(icon), + None => self.unset_icon_inner(), + } + .expect_then_ignore_error("Failed to set icons"); + + self.xconn.flush_requests().expect("Failed to set icons"); + } + + #[inline] + pub fn set_visible(&self, visible: bool) { + let mut shared_state = self.shared_state_lock(); + + match (visible, shared_state.visibility) { + (true, Visibility::Yes) | (true, Visibility::YesWait) | (false, Visibility::No) => { + return + }, + _ => (), + } + + if visible { + self.xconn + .xcb_connection() + .map_window(self.xwindow) + .expect_then_ignore_error("Failed to call `xcb_map_window`"); + self.xconn + .xcb_connection() + .configure_window( + self.xwindow, + &xproto::ConfigureWindowAux::new().stack_mode(xproto::StackMode::ABOVE), + ) + .expect_then_ignore_error("Failed to call `xcb_configure_window`"); + self.xconn.flush_requests().expect("Failed to call XMapRaised"); + shared_state.visibility = Visibility::YesWait; + } else { + self.xconn + .xcb_connection() + .unmap_window(self.xwindow) + .expect_then_ignore_error("Failed to call `xcb_unmap_window`"); + self.xconn.flush_requests().expect("Failed to call XUnmapWindow"); + shared_state.visibility = Visibility::No; + } + } + + #[inline] + pub fn is_visible(&self) -> Option { + Some(self.shared_state_lock().visibility == Visibility::Yes) + } + + fn update_cached_frame_extents(&self) { + let extents = self.xconn.get_frame_extents_heuristic(self.xwindow, self.root); + self.shared_state_lock().frame_extents = Some(extents); + } + + pub(crate) fn invalidate_cached_frame_extents(&self) { + self.shared_state_lock().frame_extents.take(); + } + + pub(crate) fn outer_position_physical(&self) -> (i32, i32) { + let extents = self.shared_state_lock().frame_extents.clone(); + if let Some(extents) = extents { + let (x, y) = self.inner_position_physical(); + extents.inner_pos_to_outer(x, y) + } else { + self.update_cached_frame_extents(); + self.outer_position_physical() + } + } + + #[inline] + pub fn outer_position(&self) -> Result, NotSupportedError> { + let extents = self.shared_state_lock().frame_extents.clone(); + if let Some(extents) = extents { + let (x, y) = self.inner_position_physical(); + Ok(extents.inner_pos_to_outer(x, y).into()) + } else { + self.update_cached_frame_extents(); + self.outer_position() + } + } + + pub(crate) fn inner_position_physical(&self) -> (i32, i32) { + // This should be okay to unwrap since the only error XTranslateCoordinates can return + // is BadWindow, and if the window handle is bad we have bigger problems. + self.xconn + .translate_coords(self.xwindow, self.root) + .map(|coords| (coords.dst_x.into(), coords.dst_y.into())) + .unwrap() + } + + #[inline] + pub fn inner_position(&self) -> Result, NotSupportedError> { + Ok(self.inner_position_physical().into()) + } + + pub(crate) fn set_position_inner( + &self, + mut x: i32, + mut y: i32, + ) -> Result, X11Error> { + // There are a few WMs that set client area position rather than window position, so + // we'll translate for consistency. + if util::wm_name_is_one_of(&["Enlightenment", "FVWM"]) { + let extents = self.shared_state_lock().frame_extents.clone(); + if let Some(extents) = extents { + x += cast_dimension_to_hint(extents.frame_extents.left); + y += cast_dimension_to_hint(extents.frame_extents.top); + } else { + self.update_cached_frame_extents(); + return self.set_position_inner(x, y); + } + } + + self.xconn + .xcb_connection() + .configure_window(self.xwindow, &xproto::ConfigureWindowAux::new().x(x).y(y)) + .map_err(Into::into) + } + + pub(crate) fn set_position_physical(&self, x: i32, y: i32) { + self.set_position_inner(x, y).expect_then_ignore_error("Failed to call `XMoveWindow`"); + } + + #[inline] + pub fn set_outer_position(&self, position: Position) { + let (x, y) = position.to_physical::(self.scale_factor()).into(); + self.set_position_physical(x, y); + } + + pub(crate) fn inner_size_physical(&self) -> (u32, u32) { + // This should be okay to unwrap since the only error XGetGeometry can return + // is BadWindow, and if the window handle is bad we have bigger problems. + self.xconn + .get_geometry(self.xwindow) + .map(|geo| (geo.width.into(), geo.height.into())) + .unwrap() + } + + #[inline] + pub fn inner_size(&self) -> PhysicalSize { + self.inner_size_physical().into() + } + + #[inline] + pub fn outer_size(&self) -> PhysicalSize { + let extents = self.shared_state_lock().frame_extents.clone(); + if let Some(extents) = extents { + let (width, height) = self.inner_size_physical(); + extents.inner_size_to_outer(width, height).into() + } else { + self.update_cached_frame_extents(); + self.outer_size() + } + } + + pub(crate) fn request_inner_size_physical(&self, width: u32, height: u32) { + self.xconn + .xcb_connection() + .configure_window( + self.xwindow, + &xproto::ConfigureWindowAux::new().width(width).height(height), + ) + .expect_then_ignore_error("Failed to call `xcb_configure_window`"); + self.xconn.flush_requests().expect("Failed to call XResizeWindow"); + // cursor_hittest needs to be reapplied after each window resize. + if self.shared_state_lock().cursor_hittest.unwrap_or(false) { + let _ = self.set_cursor_hittest(true); + } + } + + #[inline] + pub fn request_inner_size(&self, size: Size) -> Option> { + let scale_factor = self.scale_factor(); + let size = size.to_physical::(scale_factor).into(); + if !self.shared_state_lock().is_resizable { + self.update_normal_hints(|normal_hints| { + normal_hints.min_size = Some(size); + normal_hints.max_size = Some(size); + }) + .expect("Failed to call `XSetWMNormalHints`"); + } + self.request_inner_size_physical(size.0 as u32, size.1 as u32); + + None + } + + fn update_normal_hints(&self, callback: F) -> Result<(), X11Error> + where + F: FnOnce(&mut WmSizeHints), + { + let mut normal_hints = WmSizeHints::get( + self.xconn.xcb_connection(), + self.xwindow as xproto::Window, + xproto::AtomEnum::WM_NORMAL_HINTS, + )? + .reply()? + .unwrap_or_default(); + callback(&mut normal_hints); + normal_hints + .set( + self.xconn.xcb_connection(), + self.xwindow as xproto::Window, + xproto::AtomEnum::WM_NORMAL_HINTS, + )? + .ignore_error(); + Ok(()) + } + + pub(crate) fn set_min_inner_size_physical(&self, dimensions: Option<(u32, u32)>) { + self.update_normal_hints(|normal_hints| { + normal_hints.min_size = + dimensions.map(|(w, h)| (cast_dimension_to_hint(w), cast_dimension_to_hint(h))) + }) + .expect("Failed to call `XSetWMNormalHints`"); + } + + #[inline] + pub fn set_min_inner_size(&self, dimensions: Option) { + self.shared_state_lock().min_inner_size = dimensions; + let physical_dimensions = + dimensions.map(|dimensions| dimensions.to_physical::(self.scale_factor()).into()); + self.set_min_inner_size_physical(physical_dimensions); + } + + pub(crate) fn set_max_inner_size_physical(&self, dimensions: Option<(u32, u32)>) { + self.update_normal_hints(|normal_hints| { + normal_hints.max_size = + dimensions.map(|(w, h)| (cast_dimension_to_hint(w), cast_dimension_to_hint(h))) + }) + .expect("Failed to call `XSetWMNormalHints`"); + } + + #[inline] + pub fn set_max_inner_size(&self, dimensions: Option) { + self.shared_state_lock().max_inner_size = dimensions; + let physical_dimensions = + dimensions.map(|dimensions| dimensions.to_physical::(self.scale_factor()).into()); + self.set_max_inner_size_physical(physical_dimensions); + } + + #[inline] + pub fn resize_increments(&self) -> Option> { + WmSizeHints::get( + self.xconn.xcb_connection(), + self.xwindow as xproto::Window, + xproto::AtomEnum::WM_NORMAL_HINTS, + ) + .ok() + .and_then(|cookie| cookie.reply().ok()) + .flatten() + .and_then(|hints| hints.size_increment) + .map(|(width, height)| (width as u32, height as u32).into()) + } + + #[inline] + pub fn set_resize_increments(&self, increments: Option) { + self.shared_state_lock().resize_increments = increments; + let physical_increments = + increments.map(|increments| cast_size_to_hint(increments, self.scale_factor())); + self.update_normal_hints(|hints| hints.size_increment = physical_increments) + .expect("Failed to call `XSetWMNormalHints`"); + } + + pub(crate) fn adjust_for_dpi( + &self, + old_scale_factor: f64, + new_scale_factor: f64, + width: u32, + height: u32, + shared_state: &SharedState, + ) -> (u32, u32) { + let scale_factor = new_scale_factor / old_scale_factor; + self.update_normal_hints(|normal_hints| { + let dpi_adjuster = |size: Size| -> (i32, i32) { cast_size_to_hint(size, scale_factor) }; + let max_size = shared_state.max_inner_size.map(dpi_adjuster); + let min_size = shared_state.min_inner_size.map(dpi_adjuster); + let resize_increments = shared_state.resize_increments.map(dpi_adjuster); + let base_size = shared_state.base_size.map(dpi_adjuster); + + normal_hints.max_size = max_size; + normal_hints.min_size = min_size; + normal_hints.size_increment = resize_increments; + normal_hints.base_size = base_size; + }) + .expect("Failed to update normal hints"); + + let new_width = (width as f64 * scale_factor).round() as u32; + let new_height = (height as f64 * scale_factor).round() as u32; + + (new_width, new_height) + } + + pub fn set_resizable(&self, resizable: bool) { + if util::wm_name_is_one_of(&["Xfwm4"]) { + // Making the window unresizable on Xfwm prevents further changes to `WM_NORMAL_HINTS` + // from being detected. This makes it impossible for resizing to be + // re-enabled, and also breaks DPI scaling. As such, we choose the lesser of + // two evils and do nothing. + warn!("To avoid a WM bug, disabling resizing has no effect on Xfwm4"); + return; + } + + let (min_size, max_size) = if resizable { + let shared_state_lock = self.shared_state_lock(); + (shared_state_lock.min_inner_size, shared_state_lock.max_inner_size) + } else { + let window_size = Some(Size::from(self.inner_size())); + (window_size, window_size) + }; + self.shared_state_lock().is_resizable = resizable; + + self.set_maximizable_inner(resizable) + .expect_then_ignore_error("Failed to call `XSetWMNormalHints`"); + + let scale_factor = self.scale_factor(); + let min_inner_size = min_size.map(|size| cast_size_to_hint(size, scale_factor)); + let max_inner_size = max_size.map(|size| cast_size_to_hint(size, scale_factor)); + self.update_normal_hints(|normal_hints| { + normal_hints.min_size = min_inner_size; + normal_hints.max_size = max_inner_size; + }) + .expect("Failed to call `XSetWMNormalHints`"); + } + + #[inline] + pub fn is_resizable(&self) -> bool { + self.shared_state_lock().is_resizable + } + + #[inline] + pub fn set_enabled_buttons(&self, _buttons: WindowButtons) {} + + #[inline] + pub fn enabled_buttons(&self) -> WindowButtons { + WindowButtons::all() + } + + #[allow(dead_code)] + #[inline] + pub fn xlib_display(&self) -> *mut c_void { + self.xconn.display as _ + } + + #[allow(dead_code)] + #[inline] + pub fn xlib_window(&self) -> c_ulong { + self.xwindow as ffi::Window + } + + #[inline] + pub fn set_cursor(&self, cursor: Cursor) { + match cursor { + Cursor::Icon(icon) => { + let old_cursor = replace( + &mut *self.selected_cursor.lock().unwrap(), + SelectedCursor::Named(icon), + ); + + #[allow(clippy::mutex_atomic)] + if SelectedCursor::Named(icon) != old_cursor && *self.cursor_visible.lock().unwrap() + { + self.xconn.set_cursor_icon(self.xwindow, Some(icon)); + } + }, + Cursor::Custom(RootCustomCursor { inner: PlatformCustomCursor::X(cursor) }) => { + #[allow(clippy::mutex_atomic)] + if *self.cursor_visible.lock().unwrap() { + self.xconn.set_custom_cursor(self.xwindow, &cursor); + } + + *self.selected_cursor.lock().unwrap() = SelectedCursor::Custom(cursor); + }, + #[cfg(wayland_platform)] + Cursor::Custom(RootCustomCursor { inner: PlatformCustomCursor::Wayland(_) }) => { + tracing::error!("passed a Wayland cursor to X11 backend") + }, + } + } + + #[inline] + pub fn set_cursor_grab(&self, mode: CursorGrabMode) -> Result<(), ExternalError> { + // We don't support the locked cursor yet, so ignore it early on. + if mode == CursorGrabMode::Locked { + return Err(ExternalError::NotSupported(NotSupportedError::new())); + } + + let mut grabbed_lock = self.cursor_grabbed_mode.lock().unwrap(); + if mode == *grabbed_lock { + return Ok(()); + } + + // We ungrab before grabbing to prevent passive grabs from causing `AlreadyGrabbed`. + // Therefore, this is common to both codepaths. + self.xconn + .xcb_connection() + .ungrab_pointer(x11rb::CURRENT_TIME) + .expect_then_ignore_error("Failed to call `xcb_ungrab_pointer`"); + *grabbed_lock = CursorGrabMode::None; + + let result = match mode { + CursorGrabMode::None => self.xconn.flush_requests().map_err(|err| { + ExternalError::Os(os_error!(OsError::XError(X11Error::Xlib(err).into()))) + }), + CursorGrabMode::Confined => { + let result = self + .xconn + .xcb_connection() + .grab_pointer( + true as _, + self.xwindow, + xproto::EventMask::BUTTON_PRESS + | xproto::EventMask::BUTTON_RELEASE + | xproto::EventMask::ENTER_WINDOW + | xproto::EventMask::LEAVE_WINDOW + | xproto::EventMask::POINTER_MOTION + | xproto::EventMask::POINTER_MOTION_HINT + | xproto::EventMask::BUTTON1_MOTION + | xproto::EventMask::BUTTON2_MOTION + | xproto::EventMask::BUTTON3_MOTION + | xproto::EventMask::BUTTON4_MOTION + | xproto::EventMask::BUTTON5_MOTION + | xproto::EventMask::KEYMAP_STATE, + xproto::GrabMode::ASYNC, + xproto::GrabMode::ASYNC, + self.xwindow, + 0u32, + x11rb::CURRENT_TIME, + ) + .expect("Failed to call `grab_pointer`") + .reply() + .expect("Failed to receive reply from `grab_pointer`"); + + match result.status { + xproto::GrabStatus::SUCCESS => Ok(()), + xproto::GrabStatus::ALREADY_GRABBED => { + Err("Cursor could not be confined: already confined by another client") + }, + xproto::GrabStatus::INVALID_TIME => { + Err("Cursor could not be confined: invalid time") + }, + xproto::GrabStatus::NOT_VIEWABLE => { + Err("Cursor could not be confined: confine location not viewable") + }, + xproto::GrabStatus::FROZEN => { + Err("Cursor could not be confined: frozen by another client") + }, + _ => unreachable!(), + } + .map_err(|err| ExternalError::Os(os_error!(OsError::Misc(err)))) + }, + CursorGrabMode::Locked => return Ok(()), + }; + + if result.is_ok() { + *grabbed_lock = mode; + } + + result + } + + #[inline] + pub fn set_cursor_visible(&self, visible: bool) { + #[allow(clippy::mutex_atomic)] + let mut visible_lock = self.cursor_visible.lock().unwrap(); + if visible == *visible_lock { + return; + } + let cursor = + if visible { Some((*self.selected_cursor.lock().unwrap()).clone()) } else { None }; + *visible_lock = visible; + drop(visible_lock); + match cursor { + Some(SelectedCursor::Custom(cursor)) => { + self.xconn.set_custom_cursor(self.xwindow, &cursor); + }, + Some(SelectedCursor::Named(cursor)) => { + self.xconn.set_cursor_icon(self.xwindow, Some(cursor)); + }, + None => { + self.xconn.set_cursor_icon(self.xwindow, None); + }, + } + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + self.shared_state_lock().last_monitor.scale_factor + } + + pub fn set_cursor_position_physical(&self, x: i32, y: i32) -> Result<(), ExternalError> { + { + self.xconn + .xcb_connection() + .warp_pointer(x11rb::NONE, self.xwindow, 0, 0, 0, 0, x as _, y as _) + .map_err(|e| { + ExternalError::Os(os_error!(OsError::XError(X11Error::from(e).into()))) + })?; + self.xconn.flush_requests().map_err(|e| { + ExternalError::Os(os_error!(OsError::XError(X11Error::Xlib(e).into()))) + }) + } + } + + #[inline] + pub fn set_cursor_position(&self, position: Position) -> Result<(), ExternalError> { + let (x, y) = position.to_physical::(self.scale_factor()).into(); + self.set_cursor_position_physical(x, y) + } + + #[inline] + pub fn set_cursor_hittest(&self, hittest: bool) -> Result<(), ExternalError> { + // Implement cursor hittest for X11 by either setting an empty or full window input shape. + + // In X11, every window has two "shapes": + // * Bounding shape: defines the visible outline of the window. + // * Input shape: defines the region of the window that receives pointer/keyboard events. + // If the input shape is the full window rectangle, the window behaves normally. + // If the input shape is empty, the window is completely click‑through. + // Here, we implement hit test by mapping `hittest = true` to "restore a full input shape" + // and `hittest = false` to "clear the input shape" (empty list of rectangles). + let mut rectangles: Vec = Vec::new(); + if hittest { + let size = self.inner_size(); + rectangles.push(Rectangle { + x: 0, + y: 0, + width: size.width as u16, + height: size.height as u16, + }) + } + + self.xconn + .xcb_connection() + .shape_rectangles( + SO::SET, + SK::INPUT, + ClipOrdering::UNSORTED, + self.xwindow, + 0, + 0, + &rectangles, + ) + .map_err(|_e| ExternalError::Ignored)?; + self.shared_state_lock().cursor_hittest = Some(hittest); + Ok(()) + } + + /// Moves the window while it is being dragged. + pub fn drag_window(&self) -> Result<(), ExternalError> { + self.drag_initiate(util::MOVERESIZE_MOVE) + } + + #[inline] + pub fn show_window_menu(&self, _position: Position) {} + + /// Resizes the window while it is being dragged. + pub fn drag_resize_window(&self, direction: ResizeDirection) -> Result<(), ExternalError> { + self.drag_initiate(match direction { + ResizeDirection::East => util::MOVERESIZE_RIGHT, + ResizeDirection::North => util::MOVERESIZE_TOP, + ResizeDirection::NorthEast => util::MOVERESIZE_TOPRIGHT, + ResizeDirection::NorthWest => util::MOVERESIZE_TOPLEFT, + ResizeDirection::South => util::MOVERESIZE_BOTTOM, + ResizeDirection::SouthEast => util::MOVERESIZE_BOTTOMRIGHT, + ResizeDirection::SouthWest => util::MOVERESIZE_BOTTOMLEFT, + ResizeDirection::West => util::MOVERESIZE_LEFT, + }) + } + + /// Initiates a drag operation while the left mouse button is pressed. + fn drag_initiate(&self, action: isize) -> Result<(), ExternalError> { + let pointer = self + .xconn + .query_pointer(self.xwindow, util::VIRTUAL_CORE_POINTER) + .map_err(|err| ExternalError::Os(os_error!(OsError::XError(err.into()))))?; + + let window = self.inner_position().map_err(ExternalError::NotSupported)?; + + let atoms = self.xconn.atoms(); + let message = atoms[_NET_WM_MOVERESIZE]; + + // we can't use `set_cursor_grab(false)` here because it doesn't run `XUngrabPointer` + // if the cursor isn't currently grabbed + let mut grabbed_lock = self.cursor_grabbed_mode.lock().unwrap(); + self.xconn + .xcb_connection() + .ungrab_pointer(x11rb::CURRENT_TIME) + .map_err(|err| { + ExternalError::Os(os_error!(OsError::XError(X11Error::from(err).into()))) + })? + .ignore_error(); + self.xconn.flush_requests().map_err(|err| { + ExternalError::Os(os_error!(OsError::XError(X11Error::Xlib(err).into()))) + })?; + *grabbed_lock = CursorGrabMode::None; + + // we keep the lock until we are done + self.xconn + .send_client_msg( + self.xwindow, + self.root, + message, + Some( + xproto::EventMask::SUBSTRUCTURE_REDIRECT + | xproto::EventMask::SUBSTRUCTURE_NOTIFY, + ), + [ + (window.x + xinput_fp1616_to_float(pointer.win_x) as i32) as u32, + (window.y + xinput_fp1616_to_float(pointer.win_y) as i32) as u32, + action.try_into().unwrap(), + 1, // Button 1 + 1, + ], + ) + .map_err(|err| ExternalError::Os(os_error!(OsError::XError(err.into()))))?; + + self.xconn.flush_requests().map_err(|err| { + ExternalError::Os(os_error!(OsError::XError(X11Error::Xlib(err).into()))) + }) + } + + #[inline] + pub fn set_ime_cursor_area(&self, spot: Position, _size: Size) { + let (x, y) = spot.to_physical::(self.scale_factor()).into(); + let _ = self.ime_sender.lock().unwrap().send(ImeRequest::Position( + self.xwindow as ffi::Window, + x, + y, + )); + } + + #[inline] + pub fn set_ime_allowed(&self, allowed: bool) { + let _ = self + .ime_sender + .lock() + .unwrap() + .send(ImeRequest::Allow(self.xwindow as ffi::Window, allowed)); + } + + #[inline] + pub fn set_ime_purpose(&self, _purpose: ImePurpose) {} + + #[inline] + pub fn focus_window(&self) { + let atoms = self.xconn.atoms(); + let state_atom = atoms[WM_STATE]; + let state_type_atom = atoms[CARD32]; + let is_minimized = if let Ok(state) = + self.xconn.get_property::(self.xwindow, state_atom, state_type_atom) + { + state.contains(&super::ICONIC_STATE) + } else { + false + }; + let is_visible = match self.shared_state_lock().visibility { + Visibility::Yes => true, + Visibility::YesWait | Visibility::No => false, + }; + + if is_visible && !is_minimized { + self.xconn + .send_client_msg( + self.xwindow, + self.root, + atoms[_NET_ACTIVE_WINDOW], + Some( + xproto::EventMask::SUBSTRUCTURE_REDIRECT + | xproto::EventMask::SUBSTRUCTURE_NOTIFY, + ), + [1, x11rb::CURRENT_TIME, 0, 0, 0], + ) + .expect_then_ignore_error("Failed to send client message"); + if let Err(e) = self.xconn.flush_requests() { + tracing::error!( + "`flush` returned an error when focusing the window. Error was: {}", + e + ); + } + } + } + + #[inline] + pub fn request_user_attention(&self, request_type: Option) { + let mut wm_hints = + WmHints::get(self.xconn.xcb_connection(), self.xwindow as xproto::Window) + .ok() + .and_then(|cookie| cookie.reply().ok()) + .flatten() + .unwrap_or_default(); + + wm_hints.urgent = request_type.is_some(); + wm_hints + .set(self.xconn.xcb_connection(), self.xwindow as xproto::Window) + .expect_then_ignore_error("Failed to set WM hints"); + } + + #[inline] + pub(crate) fn generate_activation_token(&self) -> Result { + // Get the title from the WM_NAME property. + let atoms = self.xconn.atoms(); + let title = { + let title_bytes = self + .xconn + .get_property(self.xwindow, atoms[_NET_WM_NAME], atoms[UTF8_STRING]) + .expect("Failed to get title"); + + String::from_utf8(title_bytes).expect("Bad title") + }; + + // Get the activation token and then put it in the event queue. + let token = self.xconn.request_activation_token(&title)?; + + Ok(token) + } + + #[inline] + pub fn request_activation_token(&self) -> Result { + let serial = AsyncRequestSerial::get(); + self.activation_sender + .send((self.id(), serial)) + .expect("activation token channel should never be closed"); + Ok(serial) + } + + #[inline] + pub fn id(&self) -> WindowId { + WindowId(self.xwindow as _) + } + + #[inline] + pub fn request_redraw(&self) { + self.redraw_sender.send(WindowId(self.xwindow as _)).unwrap(); + } + + #[inline] + pub fn pre_present_notify(&self) { + // TODO timer + } + + #[cfg(feature = "rwh_04")] + #[inline] + pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle { + let mut window_handle = rwh_04::XlibHandle::empty(); + window_handle.display = self.xlib_display(); + window_handle.window = self.xlib_window(); + window_handle.visual_id = self.visual as c_ulong; + rwh_04::RawWindowHandle::Xlib(window_handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle { + let mut window_handle = rwh_05::XlibWindowHandle::empty(); + window_handle.window = self.xlib_window(); + window_handle.visual_id = self.visual as c_ulong; + window_handle.into() + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + let mut display_handle = rwh_05::XlibDisplayHandle::empty(); + display_handle.display = self.xlib_display(); + display_handle.screen = self.screen_id; + display_handle.into() + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_window_handle_rwh_06(&self) -> Result { + let mut window_handle = rwh_06::XlibWindowHandle::new(self.xlib_window()); + window_handle.visual_id = self.visual as c_ulong; + Ok(window_handle.into()) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::XlibDisplayHandle::new( + Some( + std::ptr::NonNull::new(self.xlib_display()) + .expect("display pointer should never be null"), + ), + self.screen_id, + ) + .into()) + } + + #[inline] + pub fn theme(&self) -> Option { + None + } + + pub fn set_content_protected(&self, _protected: bool) {} + + #[inline] + pub fn has_focus(&self) -> bool { + self.shared_state_lock().has_focus + } + + pub fn title(&self) -> String { + String::new() + } +} + +/// Cast a dimension value into a hinted dimension for `WmSizeHints`, clamping if too large. +fn cast_dimension_to_hint(val: u32) -> i32 { + val.try_into().unwrap_or(i32::MAX) +} + +/// Use the above strategy to cast a physical size into a hinted size. +fn cast_physical_size_to_hint(size: PhysicalSize) -> (i32, i32) { + let PhysicalSize { width, height } = size; + (cast_dimension_to_hint(width), cast_dimension_to_hint(height)) +} + +/// Use the above strategy to cast a size into a hinted size. +fn cast_size_to_hint(size: Size, scale_factor: f64) -> (i32, i32) { + match size { + Size::Physical(size) => cast_physical_size_to_hint(size), + Size::Logical(size) => size.to_physical::(scale_factor).into(), + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/xdisplay.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/xdisplay.rs new file mode 100644 index 00000000..79da0d1f --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/xdisplay.rs @@ -0,0 +1,356 @@ +use std::collections::HashMap; +use std::error::Error; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard}; +use std::{fmt, ptr}; + +use crate::window::CursorIcon; + +use super::atoms::Atoms; +use super::ffi; +use super::monitor::MonitorHandle; +use x11rb::connection::Connection; +use x11rb::protocol::randr::ConnectionExt as _; +use x11rb::protocol::xproto::{self, ConnectionExt}; +use x11rb::resource_manager; +use x11rb::xcb_ffi::XCBConnection; + +/// A connection to an X server. +pub struct XConnection { + pub xlib: ffi::Xlib, + pub xcursor: ffi::Xcursor, + + // TODO(notgull): I'd like to remove this, but apparently Xlib and Xinput2 are tied together + // for some reason. + pub xinput2: ffi::XInput2, + + pub display: *mut ffi::Display, + + /// The manager for the XCB connection. + /// + /// The `Option` ensures that we can drop it before we close the `Display`. + xcb: Option, + + /// The atoms used by `winit`. + /// + /// This is a large structure, so I've elected to Box it to make accessing the fields of + /// this struct easier. Feel free to unbox it if you like kicking puppies. + atoms: Box, + + /// The index of the default screen. + default_screen: usize, + + /// The last timestamp received by this connection. + timestamp: AtomicU32, + + /// List of monitor handles. + pub monitor_handles: Mutex>>, + + /// The resource database. + database: RwLock, + + /// RandR version. + randr_version: (u32, u32), + + /// Atom for the XSettings screen. + xsettings_screen: Option, + + pub latest_error: Mutex>, + pub cursor_cache: Mutex, ffi::Cursor>>, +} + +unsafe impl Send for XConnection {} +unsafe impl Sync for XConnection {} + +pub type XErrorHandler = + Option std::os::raw::c_int>; + +impl XConnection { + pub fn new(error_handler: XErrorHandler) -> Result { + // opening the libraries + let xlib = ffi::Xlib::open()?; + let xcursor = ffi::Xcursor::open()?; + let xlib_xcb = ffi::Xlib_xcb::open()?; + let xinput2 = ffi::XInput2::open()?; + + unsafe { (xlib.XInitThreads)() }; + unsafe { (xlib.XSetErrorHandler)(error_handler) }; + + // calling XOpenDisplay + let display = unsafe { + let display = (xlib.XOpenDisplay)(ptr::null()); + if display.is_null() { + return Err(XNotSupported::XOpenDisplayFailed); + } + display + }; + + // Open the x11rb XCB connection. + let xcb = { + // Get a pointer to the underlying XCB connection + let xcb_connection = + unsafe { (xlib_xcb.XGetXCBConnection)(display as *mut ffi::Display) }; + assert!(!xcb_connection.is_null()); + + // Wrap the XCB connection in an x11rb XCB connection + let conn = + unsafe { XCBConnection::from_raw_xcb_connection(xcb_connection.cast(), false) }; + + conn.map_err(|e| XNotSupported::XcbConversionError(Arc::new(WrapConnectError(e))))? + }; + + // Get the default screen. + let default_screen = unsafe { (xlib.XDefaultScreen)(display) } as usize; + + // Load the database. + let database = resource_manager::new_from_default(&xcb) + .map_err(|e| XNotSupported::XcbConversionError(Arc::new(e)))?; + + // Load the RandR version. + let randr_version = xcb + .randr_query_version(1, 3) + .expect("failed to request XRandR version") + .reply() + .expect("failed to query XRandR version"); + + let xsettings_screen = Self::new_xsettings_screen(&xcb, default_screen); + if xsettings_screen.is_none() { + tracing::warn!("error setting XSETTINGS; Xft options won't reload automatically") + } + + // Fetch atoms. + let atoms = Atoms::new(&xcb) + .map_err(|e| XNotSupported::XcbConversionError(Arc::new(e)))? + .reply() + .map_err(|e| XNotSupported::XcbConversionError(Arc::new(e)))?; + + Ok(XConnection { + xlib, + xcursor, + xinput2, + display, + xcb: Some(xcb), + atoms: Box::new(atoms), + default_screen, + timestamp: AtomicU32::new(0), + latest_error: Mutex::new(None), + monitor_handles: Mutex::new(None), + database: RwLock::new(database), + cursor_cache: Default::default(), + randr_version: (randr_version.major_version, randr_version.minor_version), + xsettings_screen, + }) + } + + fn new_xsettings_screen(xcb: &XCBConnection, default_screen: usize) -> Option { + // Fetch the _XSETTINGS_S[screen number] atom. + let xsettings_screen = xcb + .intern_atom(false, format!("_XSETTINGS_S{default_screen}").as_bytes()) + .ok()? + .reply() + .ok()? + .atom; + + // Get PropertyNotify events from the XSETTINGS window. + // TODO: The XSETTINGS window here can change. In the future, listen for DestroyNotify on + // this window in order to accommodate for a changed window here. + let selector_window = xcb.get_selection_owner(xsettings_screen).ok()?.reply().ok()?.owner; + + xcb.change_window_attributes( + selector_window, + &xproto::ChangeWindowAttributesAux::new() + .event_mask(xproto::EventMask::PROPERTY_CHANGE), + ) + .ok()? + .check() + .ok()?; + + Some(xsettings_screen) + } + + /// Checks whether an error has been triggered by the previous function calls. + #[inline] + pub fn check_errors(&self) -> Result<(), XError> { + let error = self.latest_error.lock().unwrap().take(); + if let Some(error) = error { + Err(error) + } else { + Ok(()) + } + } + + #[inline] + pub fn randr_version(&self) -> (u32, u32) { + self.randr_version + } + + /// Get the underlying XCB connection. + #[inline] + pub fn xcb_connection(&self) -> &XCBConnection { + self.xcb.as_ref().expect("xcb_connection somehow called after drop?") + } + + /// Get the list of atoms. + #[inline] + pub fn atoms(&self) -> &Atoms { + &self.atoms + } + + /// Get the index of the default screen. + #[inline] + pub fn default_screen_index(&self) -> usize { + self.default_screen + } + + /// Get the default screen. + #[inline] + pub fn default_root(&self) -> &xproto::Screen { + &self.xcb_connection().setup().roots[self.default_screen] + } + + /// Get the resource database. + #[inline] + pub fn database(&self) -> RwLockReadGuard<'_, resource_manager::Database> { + self.database.read().unwrap_or_else(|e| e.into_inner()) + } + + /// Reload the resource database. + #[inline] + pub fn reload_database(&self) -> Result<(), super::X11Error> { + let database = resource_manager::new_from_default(self.xcb_connection())?; + *self.database.write().unwrap_or_else(|e| e.into_inner()) = database; + Ok(()) + } + + /// Get the latest timestamp. + #[inline] + pub fn timestamp(&self) -> u32 { + self.timestamp.load(Ordering::Relaxed) + } + + /// Set the last witnessed timestamp. + #[inline] + pub fn set_timestamp(&self, timestamp: u32) { + // Store the timestamp in the slot if it's greater than the last one. + let mut last_timestamp = self.timestamp.load(Ordering::Relaxed); + loop { + if (timestamp as i32).wrapping_sub(last_timestamp as i32) <= 0 { + break; + } + + match self.timestamp.compare_exchange( + last_timestamp, + timestamp, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => last_timestamp = x, + } + } + } + + /// Get the atom for Xsettings. + #[inline] + pub fn xsettings_screen(&self) -> Option { + self.xsettings_screen + } +} + +impl fmt::Debug for XConnection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.display.fmt(f) + } +} + +impl Drop for XConnection { + #[inline] + fn drop(&mut self) { + self.xcb = None; + unsafe { (self.xlib.XCloseDisplay)(self.display) }; + } +} + +/// Error triggered by xlib. +#[derive(Debug, Clone)] +pub struct XError { + pub description: String, + pub error_code: u8, + pub request_code: u8, + pub minor_code: u8, +} + +impl Error for XError {} + +impl fmt::Display for XError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + write!( + formatter, + "X error: {} (code: {}, request code: {}, minor code: {})", + self.description, self.error_code, self.request_code, self.minor_code + ) + } +} + +/// Error returned if this system doesn't have XLib or can't create an X connection. +#[derive(Clone, Debug)] +pub enum XNotSupported { + /// Failed to load one or several shared libraries. + LibraryOpenError(ffi::OpenError), + + /// Connecting to the X server with `XOpenDisplay` failed. + XOpenDisplayFailed, // TODO: add better message. + + /// We encountered an error while converting the connection to XCB. + XcbConversionError(Arc), +} + +impl From for XNotSupported { + #[inline] + fn from(err: ffi::OpenError) -> XNotSupported { + XNotSupported::LibraryOpenError(err) + } +} + +impl XNotSupported { + fn description(&self) -> &'static str { + match self { + XNotSupported::LibraryOpenError(_) => "Failed to load one of xlib's shared libraries", + XNotSupported::XOpenDisplayFailed => "Failed to open connection to X server", + XNotSupported::XcbConversionError(_) => "Failed to convert Xlib connection to XCB", + } + } +} + +impl Error for XNotSupported { + #[inline] + fn source(&self) -> Option<&(dyn Error + 'static)> { + match *self { + XNotSupported::LibraryOpenError(ref err) => Some(err), + XNotSupported::XcbConversionError(ref err) => Some(&**err), + _ => None, + } + } +} + +impl fmt::Display for XNotSupported { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + formatter.write_str(self.description()) + } +} + +/// A newtype wrapper around a `ConnectError` that can't be accessed by downstream libraries. +/// +/// Without this, `x11rb` would become a public dependency. +#[derive(Debug)] +struct WrapConnectError(x11rb::rust_connection::ConnectError); + +impl fmt::Display for WrapConnectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl Error for WrapConnectError { + // We can't implement `source()` here or otherwise risk exposing `x11rb`. +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/xsettings.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/xsettings.rs new file mode 100644 index 00000000..dd5b074f --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/linux/x11/xsettings.rs @@ -0,0 +1,325 @@ +//! Parser for the xsettings data format. +//! +//! Some of this code is referenced from [here]. +//! +//! [here]: https://github.com/derat/xsettingsd + +use std::iter; +use std::num::NonZeroUsize; + +use x11rb::protocol::xproto::{self, ConnectionExt}; + +use super::atoms::*; +use super::XConnection; + +type Result = core::result::Result; + +const DPI_NAME: &[u8] = b"Xft/DPI"; +const DPI_MULTIPLIER: f64 = 1024.0; +const LITTLE_ENDIAN: u8 = b'l'; +const BIG_ENDIAN: u8 = b'B'; + +impl XConnection { + /// Get the DPI from XSettings. + pub(crate) fn xsettings_dpi( + &self, + xsettings_screen: xproto::Atom, + ) -> core::result::Result, super::X11Error> { + let atoms = self.atoms(); + + // Get the current owner of the screen's settings. + let owner = self.xcb_connection().get_selection_owner(xsettings_screen)?.reply()?; + + // Read the _XSETTINGS_SETTINGS property. + let data: Vec = + self.get_property(owner.owner, atoms[_XSETTINGS_SETTINGS], atoms[_XSETTINGS_SETTINGS])?; + + // Parse the property. + let dpi_setting = read_settings(&data)? + .find(|res| res.as_ref().map_or(true, |s| s.name == DPI_NAME)) + .transpose()?; + if let Some(dpi_setting) = dpi_setting { + let base_dpi = match dpi_setting.data { + SettingData::Integer(dpi) => dpi as f64, + SettingData::String(_) => { + return Err(ParserError::BadType(SettingType::String).into()) + }, + SettingData::Color(_) => { + return Err(ParserError::BadType(SettingType::Color).into()) + }, + }; + + Ok(Some(base_dpi / DPI_MULTIPLIER)) + } else { + Ok(None) + } + } +} + +/// Read over the settings in the block of data. +fn read_settings(data: &[u8]) -> Result>> + '_> { + // Create a parser. This automatically parses the first 8 bytes for metadata. + let mut parser = Parser::new(data)?; + + // Read the total number of settings. + let total_settings = parser.i32()?; + + // Iterate over the settings. + let iter = iter::repeat_with(move || Setting::parse(&mut parser)).take(total_settings as usize); + Ok(iter) +} + +/// A setting in the settings list. +struct Setting<'a> { + /// The name of the setting. + name: &'a [u8], + + /// The data contained in the setting. + data: SettingData<'a>, +} + +/// The data contained in a setting. +enum SettingData<'a> { + Integer(i32), + String(#[allow(dead_code)] &'a [u8]), + Color(#[allow(dead_code)] [i16; 4]), +} + +impl<'a> Setting<'a> { + /// Parse a new `SettingData`. + fn parse(parser: &mut Parser<'a>) -> Result { + // Read the type. + let ty: SettingType = parser.i8()?.try_into()?; + + // Read another byte of padding. + parser.advance(1)?; + + // Read the name of the setting. + let name_len = parser.i16()?; + let name = parser.advance(name_len as usize)?; + parser.pad(name.len(), 4)?; + + // Ignore the serial number. + parser.advance(4)?; + + let data = match ty { + SettingType::Integer => { + // Read a 32-bit integer. + SettingData::Integer(parser.i32()?) + }, + + SettingType::String => { + // Read the data. + let data_len = parser.i32()?; + let data = parser.advance(data_len as usize)?; + parser.pad(data.len(), 4)?; + + SettingData::String(data) + }, + + SettingType::Color => { + // Read i16's of color. + let (red, blue, green, alpha) = + (parser.i16()?, parser.i16()?, parser.i16()?, parser.i16()?); + + SettingData::Color([red, blue, green, alpha]) + }, + }; + + Ok(Setting { name, data }) + } +} + +#[derive(Debug)] +pub enum SettingType { + Integer = 0, + String = 1, + Color = 2, +} + +impl TryFrom for SettingType { + type Error = ParserError; + + fn try_from(value: i8) -> Result { + Ok(match value { + 0 => Self::Integer, + 1 => Self::String, + 2 => Self::Color, + x => return Err(ParserError::InvalidType(x)), + }) + } +} + +/// Parser for the incoming byte stream. +struct Parser<'a> { + bytes: &'a [u8], + endianness: Endianness, +} + +impl<'a> Parser<'a> { + /// Create a new parser. + fn new(bytes: &'a [u8]) -> Result { + let (endianness, bytes) = bytes.split_first().ok_or_else(|| ParserError::ran_out(1, 0))?; + let endianness = match *endianness { + BIG_ENDIAN => Endianness::Big, + LITTLE_ENDIAN => Endianness::Little, + _ => Endianness::native(), + }; + + Ok(Self { + // Ignore three bytes of padding and the four-byte serial. + bytes: bytes.get(7..).ok_or_else(|| ParserError::ran_out(7, bytes.len()))?, + endianness, + }) + } + + /// Get a slice of bytes. + fn advance(&mut self, n: usize) -> Result<&'a [u8]> { + if n == 0 { + return Ok(&[]); + } + + if n > self.bytes.len() { + Err(ParserError::ran_out(n, self.bytes.len())) + } else { + let (part, rem) = self.bytes.split_at(n); + self.bytes = rem; + Ok(part) + } + } + + /// Skip some padding. + fn pad(&mut self, size: usize, pad: usize) -> Result<()> { + let advance = (pad - (size % pad)) % pad; + self.advance(advance)?; + Ok(()) + } + + /// Get a single byte. + fn i8(&mut self) -> Result { + self.advance(1).map(|s| s[0] as i8) + } + + /// Get two bytes. + fn i16(&mut self) -> Result { + self.advance(2).map(|s| { + let bytes: &[u8; 2] = s.try_into().unwrap(); + match self.endianness { + Endianness::Big => i16::from_be_bytes(*bytes), + Endianness::Little => i16::from_le_bytes(*bytes), + } + }) + } + + /// Get four bytes. + fn i32(&mut self) -> Result { + self.advance(4).map(|s| { + let bytes: &[u8; 4] = s.try_into().unwrap(); + match self.endianness { + Endianness::Big => i32::from_be_bytes(*bytes), + Endianness::Little => i32::from_le_bytes(*bytes), + } + }) + } +} + +/// Endianness of the incoming data. +enum Endianness { + Little, + Big, +} + +impl Endianness { + #[cfg(target_endian = "little")] + fn native() -> Self { + Endianness::Little + } + + #[cfg(target_endian = "big")] + fn native() -> Self { + Endianness::Big + } +} + +/// Parser errors. +#[allow(dead_code)] +#[derive(Debug)] +pub enum ParserError { + /// Ran out of bytes. + NoMoreBytes { expected: NonZeroUsize, found: usize }, + + /// Invalid type. + InvalidType(i8), + + /// Bad setting type. + BadType(SettingType), +} + +impl ParserError { + fn ran_out(expected: usize, found: usize) -> ParserError { + let expected = NonZeroUsize::new(expected).unwrap(); + Self::NoMoreBytes { expected, found } + } +} + +#[cfg(test)] +/// Tests for the XSETTINGS parser. +mod tests { + use super::*; + + const XSETTINGS: &str = include_str!("tests/xsettings.dat"); + + #[test] + fn empty() { + let err = match read_settings(&[]) { + Ok(_) => panic!(), + Err(err) => err, + }; + match err { + ParserError::NoMoreBytes { expected, found } => { + assert_eq!(expected.get(), 1); + assert_eq!(found, 0); + }, + + _ => panic!(), + } + } + + #[test] + fn parse_xsettings() { + let data = XSETTINGS + .trim() + .split(',') + .map(|tok| { + let val = tok.strip_prefix("0x").unwrap(); + u8::from_str_radix(val, 16).unwrap() + }) + .collect::>(); + + let settings = read_settings(&data).unwrap().collect::>>().unwrap(); + + let dpi = settings.iter().find(|s| s.name == b"Xft/DPI").unwrap(); + assert_int(&dpi.data, 96 * 1024); + let hinting = settings.iter().find(|s| s.name == b"Xft/Hinting").unwrap(); + assert_int(&hinting.data, 1); + + let rgba = settings.iter().find(|s| s.name == b"Xft/RGBA").unwrap(); + assert_string(&rgba.data, "rgb"); + let lcd = settings.iter().find(|s| s.name == b"Xft/Lcdfilter").unwrap(); + assert_string(&lcd.data, "lcddefault"); + } + + fn assert_string(dat: &SettingData<'_>, s: &str) { + match dat { + SettingData::String(left) => assert_eq!(*left, s.as_bytes()), + _ => panic!("invalid data type"), + } + } + + fn assert_int(dat: &SettingData<'_>, i: i32) { + match dat { + SettingData::Integer(left) => assert_eq!(*left, i), + _ => panic!("invalid data type"), + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/app.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/app.rs new file mode 100644 index 00000000..4fb95dbd --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/app.rs @@ -0,0 +1,195 @@ +#![allow(clippy::unnecessary_cast)] +#![allow(unknown_lints)] // New lint below +#![allow(static_mut_refs)] // Uses `MainThreadBound` in new version. + +use std::cell::Cell; +use std::mem; + +use objc2::runtime::{Imp, Sel}; +use objc2::sel; +use objc2_app_kit::{NSApplication, NSEvent, NSEventModifierFlags, NSEventType}; +use objc2_foundation::MainThreadMarker; + +use super::app_state::ApplicationDelegate; +use crate::event::{DeviceEvent, ElementState}; + +type SendEvent = extern "C" fn(&NSApplication, Sel, &NSEvent); + +// NOTE: Only used on the main thread. Ideally, we'd use `MainThreadBound`, but that isn't +// constructible from `const` with this `objc2` version. +static mut ORIGINAL: Cell> = Cell::new(None); + +extern "C" fn send_event(app: &NSApplication, sel: Sel, event: &NSEvent) { + let mtm = MainThreadMarker::from(app); + + // Normally, holding Cmd + any key never sends us a `keyUp` event for that key. + // Overriding `sendEvent:` fixes that. (https://stackoverflow.com/a/15294196) + // Fun fact: Firefox still has this bug! (https://bugzilla.mozilla.org/show_bug.cgi?id=1299553) + // + // For posterity, there are some undocumented event types + // (https://github.com/servo/cocoa-rs/issues/155) + // but that doesn't really matter here. + let event_type = unsafe { event.r#type() }; + let modifier_flags = unsafe { event.modifierFlags() }; + if event_type == NSEventType::KeyUp + && modifier_flags.contains(NSEventModifierFlags::NSEventModifierFlagCommand) + { + if let Some(key_window) = app.keyWindow() { + key_window.sendEvent(event); + } + return; + } + + // Events are generally scoped to the window level, so the best way + // to get device events is to listen for them on NSApplication. + let delegate = ApplicationDelegate::get(mtm); + maybe_dispatch_device_event(&delegate, event); + + let _ = mtm; + let original = unsafe { ORIGINAL.get().expect("no existing sendEvent: handler set") }; + original(app, sel, event) +} + +/// Override the [`sendEvent:`][NSApplication::sendEvent] method on the given application class. +/// +/// The previous implementation created a subclass of [`NSApplication`], however we would like to +/// give the user full control over their `NSApplication`, so we override the method here using +/// method swizzling instead. +/// +/// This _should_ also allow two versions of Winit to exist in the same application. +/// +/// See the following links for more info on method swizzling: +/// - +/// - +/// - +/// +/// NOTE: This function assumes that the passed in application object is the one returned from +/// [`NSApplication::sharedApplication`], i.e. the one and only global shared application object. +/// For testing though, we allow it to be a different object. +pub(crate) fn override_send_event(global_app: &NSApplication) { + let mtm = MainThreadMarker::from(global_app); + let class = global_app.class(); + + let method = + class.instance_method(sel!(sendEvent:)).expect("NSApplication must have sendEvent: method"); + + // SAFETY: Converting our `sendEvent:` implementation to an IMP. + let overridden = unsafe { mem::transmute::(send_event) }; + + // If we've already overridden the method, don't do anything. + // FIXME(madsmtm): Use `std::ptr::fn_addr_eq` (Rust 1.85) once available in MSRV. + #[allow(unknown_lints, unpredictable_function_pointer_comparisons)] + if overridden == method.implementation() { + return; + } + + // SAFETY: Our implementation has: + // 1. The same signature as `sendEvent:`. + // 2. Does not impose extra safety requirements on callers. + let original = unsafe { method.set_implementation(overridden) }; + + // SAFETY: This is the actual signature of `sendEvent:`. + let original = unsafe { mem::transmute::(original) }; + + // NOTE: If NSApplication was safe to use from multiple threads, then this would potentially be + // a (checked) race-condition, since one could call `sendEvent:` before the original had been + // stored here. + // + // It is only usable from the main thread, however, so we're good! + let _ = mtm; + unsafe { ORIGINAL.set(Some(original)) }; +} + +fn maybe_dispatch_device_event(delegate: &ApplicationDelegate, event: &NSEvent) { + let event_type = unsafe { event.r#type() }; + #[allow(non_upper_case_globals)] + match event_type { + NSEventType::MouseMoved + | NSEventType::LeftMouseDragged + | NSEventType::OtherMouseDragged + | NSEventType::RightMouseDragged => { + let delta_x = unsafe { event.deltaX() } as f64; + let delta_y = unsafe { event.deltaY() } as f64; + + if delta_x != 0.0 { + delegate.maybe_queue_device_event(DeviceEvent::Motion { axis: 0, value: delta_x }); + } + + if delta_y != 0.0 { + delegate.maybe_queue_device_event(DeviceEvent::Motion { axis: 1, value: delta_y }) + } + + if delta_x != 0.0 || delta_y != 0.0 { + delegate.maybe_queue_device_event(DeviceEvent::MouseMotion { + delta: (delta_x, delta_y), + }); + } + }, + NSEventType::LeftMouseDown | NSEventType::RightMouseDown | NSEventType::OtherMouseDown => { + delegate.maybe_queue_device_event(DeviceEvent::Button { + button: unsafe { event.buttonNumber() } as u32, + state: ElementState::Pressed, + }); + }, + NSEventType::LeftMouseUp | NSEventType::RightMouseUp | NSEventType::OtherMouseUp => { + delegate.maybe_queue_device_event(DeviceEvent::Button { + button: unsafe { event.buttonNumber() } as u32, + state: ElementState::Released, + }); + }, + _ => (), + } +} + +#[cfg(test)] +mod tests { + use objc2::rc::Retained; + use objc2::{declare_class, msg_send_id, mutability, ClassType, DeclaredClass}; + + use super::*; + + #[test] + fn test_override() { + // FIXME(madsmtm): Ensure this always runs (maybe use cargo-nextest or `--test-threads=1`?) + let Some(mtm) = MainThreadMarker::new() else { return }; + + // Create a new application, without making it the shared application. + let app = unsafe { NSApplication::new(mtm) }; + override_send_event(&app); + // Test calling twice works. + override_send_event(&app); + + // FIXME(madsmtm): Can't test this yet, need some way to mock AppState. + // unsafe { + // let event = super::super::event::dummy_event().unwrap(); + // app.sendEvent(&event) + // } + } + + #[test] + fn test_custom_class() { + let Some(_mtm) = MainThreadMarker::new() else { return }; + + declare_class!( + struct TestApplication; + + unsafe impl ClassType for TestApplication { + type Super = NSApplication; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "TestApplication"; + } + + impl DeclaredClass for TestApplication {} + + unsafe impl TestApplication { + #[method(sendEvent:)] + fn send_event(&self, _event: &NSEvent) { + todo!() + } + } + ); + + let app: Retained = unsafe { msg_send_id![TestApplication::class(), new] }; + override_send_event(&app); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/app_state.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/app_state.rs new file mode 100644 index 00000000..dd2ccd11 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/app_state.rs @@ -0,0 +1,446 @@ +use std::cell::{Cell, RefCell}; +use std::mem; +use std::rc::Weak; +use std::time::Instant; + +use objc2::rc::Retained; +use objc2::{declare_class, msg_send_id, mutability, ClassType, DeclaredClass}; +use objc2_app_kit::{ + NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate, NSRunningApplication, +}; +use objc2_foundation::{MainThreadMarker, NSNotification, NSObject, NSObjectProtocol}; + +use super::event_handler::EventHandler; +use super::event_loop::{notify_windows_of_exit, stop_app_immediately, ActiveEventLoop, PanicInfo}; +use super::observer::{EventLoopWaker, RunLoop}; +use super::{menu, WindowId, DEVICE_ID}; +use crate::event::{DeviceEvent, Event, StartCause, WindowEvent}; +use crate::event_loop::{ActiveEventLoop as RootActiveEventLoop, ControlFlow}; +use crate::window::WindowId as RootWindowId; + +#[derive(Debug)] +pub(super) struct AppState { + activation_policy: Option, + default_menu: bool, + activate_ignoring_other_apps: bool, + run_loop: RunLoop, + event_handler: EventHandler, + stop_on_launch: Cell, + stop_before_wait: Cell, + stop_after_wait: Cell, + stop_on_redraw: Cell, + /// Whether `applicationDidFinishLaunching:` has been run or not. + is_launched: Cell, + /// Whether an `EventLoop` is currently running. + is_running: Cell, + /// Whether the user has requested the event loop to exit. + exit: Cell, + control_flow: Cell, + waker: RefCell, + start_time: Cell>, + wait_timeout: Cell>, + pending_redraw: RefCell>, + // NOTE: This is strongly referenced by our `NSWindowDelegate` and our `NSView` subclass, and + // as such should be careful to not add fields that, in turn, strongly reference those. +} + +declare_class!( + #[derive(Debug)] + pub(super) struct ApplicationDelegate; + + unsafe impl ClassType for ApplicationDelegate { + type Super = NSObject; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "WinitApplicationDelegate"; + } + + impl DeclaredClass for ApplicationDelegate { + type Ivars = AppState; + } + + unsafe impl NSObjectProtocol for ApplicationDelegate {} + + unsafe impl NSApplicationDelegate for ApplicationDelegate { + #[method(applicationDidFinishLaunching:)] + fn app_did_finish_launching(&self, notification: &NSNotification) { + self.did_finish_launching(notification) + } + + #[method(applicationWillTerminate:)] + fn app_will_terminate(&self, notification: &NSNotification) { + self.will_terminate(notification) + } + } +); + +impl ApplicationDelegate { + pub(super) fn new( + mtm: MainThreadMarker, + activation_policy: Option, + default_menu: bool, + activate_ignoring_other_apps: bool, + ) -> Retained { + let this = mtm.alloc().set_ivars(AppState { + activation_policy, + default_menu, + activate_ignoring_other_apps, + run_loop: RunLoop::main(mtm), + event_handler: EventHandler::new(), + stop_on_launch: Cell::new(false), + stop_before_wait: Cell::new(false), + stop_after_wait: Cell::new(false), + stop_on_redraw: Cell::new(false), + is_launched: Cell::new(false), + is_running: Cell::new(false), + exit: Cell::new(false), + control_flow: Cell::new(ControlFlow::default()), + waker: RefCell::new(EventLoopWaker::new()), + start_time: Cell::new(None), + wait_timeout: Cell::new(None), + pending_redraw: RefCell::new(vec![]), + }); + unsafe { msg_send_id![super(this), init] } + } + + // NOTE: This will, globally, only be run once, no matter how many + // `EventLoop`s the user creates. + fn did_finish_launching(&self, _notification: &NSNotification) { + trace_scope!("applicationDidFinishLaunching:"); + self.ivars().is_launched.set(true); + + let mtm = MainThreadMarker::from(self); + let app = NSApplication::sharedApplication(mtm); + // We need to delay setting the activation policy and activating the app + // until `applicationDidFinishLaunching` has been called. Otherwise the + // menu bar is initially unresponsive on macOS 10.15. + // If no activation policy is explicitly provided, do not set it at all + // to allow the package manifest to define behavior via LSUIElement. + if let Some(activation_policy) = self.ivars().activation_policy { + app.setActivationPolicy(activation_policy); + } else { + // If no activation policy is explicitly provided, and the application + // is bundled, do not set the activation policy at all, to allow the + // package manifest to define the behavior via LSUIElement. + // + // See: + // - https://github.com/rust-windowing/winit/issues/261 + // - https://github.com/rust-windowing/winit/issues/3958 + let is_bundled = + unsafe { NSRunningApplication::currentApplication().bundleIdentifier().is_some() }; + if !is_bundled { + app.setActivationPolicy(NSApplicationActivationPolicy::Regular); + } + } + + window_activation_hack(&app); + #[allow(deprecated)] + app.activateIgnoringOtherApps(self.ivars().activate_ignoring_other_apps); + + if self.ivars().default_menu { + // The menubar initialization should be before the `NewEvents` event, to allow + // overriding of the default menu even if it's created + menu::initialize(&app); + } + + self.ivars().waker.borrow_mut().start(); + + self.set_is_running(true); + self.dispatch_init_events(); + + // If the application is being launched via `EventLoop::pump_app_events()` then we'll + // want to stop the app once it is launched (and return to the external loop) + // + // In this case we still want to consider Winit's `EventLoop` to be "running", + // so we call `start_running()` above. + if self.ivars().stop_on_launch.get() { + // NOTE: the original idea had been to only stop the underlying `RunLoop` + // for the app but that didn't work as expected (`-[NSApplication run]` + // effectively ignored the attempt to stop the RunLoop and re-started it). + // + // So we return from `pump_events` by stopping the application. + let app = NSApplication::sharedApplication(mtm); + stop_app_immediately(&app); + } + } + + fn will_terminate(&self, _notification: &NSNotification) { + trace_scope!("applicationWillTerminate:"); + let mtm = MainThreadMarker::from(self); + let app = NSApplication::sharedApplication(mtm); + notify_windows_of_exit(&app); + self.internal_exit(); + } + + pub fn get(mtm: MainThreadMarker) -> Retained { + let app = NSApplication::sharedApplication(mtm); + let delegate = + unsafe { app.delegate() }.expect("a delegate was not configured on the application"); + if delegate.is_kind_of::() { + // SAFETY: Just checked that the delegate is an instance of `ApplicationDelegate` + unsafe { Retained::cast(delegate) } + } else { + panic!("tried to get a delegate that was not the one Winit has registered") + } + } + + /// Place the event handler in the application delegate for the duration + /// of the given closure. + pub fn set_event_handler( + &self, + handler: impl FnMut(Event, &RootActiveEventLoop), + closure: impl FnOnce() -> R, + ) -> R { + self.ivars().event_handler.set(handler, closure) + } + + /// If `pump_events` is called to progress the event loop then we + /// bootstrap the event loop via `-[NSApplication run]` but will use + /// `CFRunLoopRunInMode` for subsequent calls to `pump_events`. + pub fn set_stop_on_launch(&self) { + self.ivars().stop_on_launch.set(true); + } + + pub fn set_stop_before_wait(&self, value: bool) { + self.ivars().stop_before_wait.set(value) + } + + pub fn set_stop_after_wait(&self, value: bool) { + self.ivars().stop_after_wait.set(value) + } + + pub fn set_stop_on_redraw(&self, value: bool) { + self.ivars().stop_on_redraw.set(value) + } + + pub fn set_wait_timeout(&self, value: Option) { + self.ivars().wait_timeout.set(value) + } + + /// Clears the `running` state and resets the `control_flow` state when an `EventLoop` exits. + /// + /// NOTE: that if the `NSApplication` has been launched then that state is preserved, + /// and we won't need to re-launch the app if subsequent EventLoops are run. + pub fn internal_exit(&self) { + self.handle_event(Event::LoopExiting); + + self.set_is_running(false); + self.set_stop_on_redraw(false); + self.set_stop_before_wait(false); + self.set_stop_after_wait(false); + self.set_wait_timeout(None); + } + + pub fn is_launched(&self) -> bool { + self.ivars().is_launched.get() + } + + pub fn set_is_running(&self, value: bool) { + self.ivars().is_running.set(value) + } + + pub fn is_running(&self) -> bool { + self.ivars().is_running.get() + } + + pub fn exit(&self) { + self.ivars().exit.set(true) + } + + pub fn clear_exit(&self) { + self.ivars().exit.set(false) + } + + pub fn exiting(&self) -> bool { + self.ivars().exit.get() + } + + pub fn set_control_flow(&self, value: ControlFlow) { + self.ivars().control_flow.set(value) + } + + pub fn control_flow(&self) -> ControlFlow { + self.ivars().control_flow.get() + } + + pub fn maybe_queue_window_event(&self, window_id: WindowId, event: WindowEvent) { + self.maybe_queue_event(Event::WindowEvent { window_id: RootWindowId(window_id), event }); + } + + pub fn handle_window_event(&self, window_id: WindowId, event: WindowEvent) { + self.handle_event(Event::WindowEvent { window_id: RootWindowId(window_id), event }); + } + + pub fn maybe_queue_device_event(&self, event: DeviceEvent) { + self.maybe_queue_event(Event::DeviceEvent { device_id: DEVICE_ID, event }); + } + + pub fn handle_redraw(&self, window_id: WindowId) { + let mtm = MainThreadMarker::from(self); + // Redraw request might come out of order from the OS. + // -> Don't go back into the event handler when our callstack originates from there + if !self.ivars().event_handler.in_use() { + self.handle_event(Event::WindowEvent { + window_id: RootWindowId(window_id), + event: WindowEvent::RedrawRequested, + }); + + // `pump_events` will request to stop immediately _after_ dispatching RedrawRequested + // events as a way to ensure that `pump_events` can't block an external loop + // indefinitely + if self.ivars().stop_on_redraw.get() { + let app = NSApplication::sharedApplication(mtm); + stop_app_immediately(&app); + } + } + } + + pub fn queue_redraw(&self, window_id: WindowId) { + let mut pending_redraw = self.ivars().pending_redraw.borrow_mut(); + if !pending_redraw.contains(&window_id) { + pending_redraw.push(window_id); + } + self.ivars().run_loop.wakeup(); + } + + #[track_caller] + fn maybe_queue_event(&self, event: Event) { + // Most programmer actions in AppKit (e.g. change window fullscreen, set focused, etc.) + // result in an event being queued, and applied at a later point. + // + // However, it is not documented which actions do this, and which ones are done immediately, + // so to make sure that we don't encounter re-entrancy issues, we first check if we're + // currently handling another event, and if we are, we queue the event instead. + if !self.ivars().event_handler.in_use() { + self.handle_event(event); + } else { + tracing::debug!(?event, "had to queue event since another is currently being handled"); + let this = self.retain(); + self.ivars().run_loop.queue_closure(move || this.handle_event(event)); + } + } + + #[track_caller] + fn handle_event(&self, event: Event) { + self.ivars().event_handler.handle_event(event, &ActiveEventLoop::new_root(self.retain())) + } + + /// dispatch `NewEvents(Init)` + `Resumed` + pub fn dispatch_init_events(&self) { + self.handle_event(Event::NewEvents(StartCause::Init)); + // NB: For consistency all platforms must emit a 'resumed' event even though macOS + // applications don't themselves have a formal suspend/resume lifecycle. + self.handle_event(Event::Resumed); + } + + // Called by RunLoopObserver after finishing waiting for new events + pub fn wakeup(&self, panic_info: Weak) { + let mtm = MainThreadMarker::from(self); + let panic_info = panic_info + .upgrade() + .expect("The panic info must exist here. This failure indicates a developer error."); + + // Return when in event handler due to https://github.com/rust-windowing/winit/issues/1779 + if panic_info.is_panicking() || !self.ivars().event_handler.ready() || !self.is_running() { + return; + } + + if self.ivars().stop_after_wait.get() { + let app = NSApplication::sharedApplication(mtm); + stop_app_immediately(&app); + } + + let start = self.ivars().start_time.get().unwrap(); + let cause = match self.control_flow() { + ControlFlow::Poll => StartCause::Poll, + ControlFlow::Wait => StartCause::WaitCancelled { start, requested_resume: None }, + ControlFlow::WaitUntil(requested_resume) => { + if Instant::now() >= requested_resume { + StartCause::ResumeTimeReached { start, requested_resume } + } else { + StartCause::WaitCancelled { start, requested_resume: Some(requested_resume) } + } + }, + }; + + self.handle_event(Event::NewEvents(cause)); + } + + // Called by RunLoopObserver before waiting for new events + pub fn cleared(&self, panic_info: Weak) { + let mtm = MainThreadMarker::from(self); + let panic_info = panic_info + .upgrade() + .expect("The panic info must exist here. This failure indicates a developer error."); + + // Return when in event handler due to https://github.com/rust-windowing/winit/issues/1779 + // XXX: how does it make sense that `event_handler.ready()` can ever return `false` here if + // we're about to return to the `CFRunLoop` to poll for new events? + if panic_info.is_panicking() || !self.ivars().event_handler.ready() || !self.is_running() { + return; + } + + self.handle_event(Event::UserEvent(HandlePendingUserEvents)); + + let redraw = mem::take(&mut *self.ivars().pending_redraw.borrow_mut()); + for window_id in redraw { + self.handle_event(Event::WindowEvent { + window_id: RootWindowId(window_id), + event: WindowEvent::RedrawRequested, + }); + } + + self.handle_event(Event::AboutToWait); + + if self.exiting() { + let app = NSApplication::sharedApplication(mtm); + stop_app_immediately(&app); + notify_windows_of_exit(&app); + } + + if self.ivars().stop_before_wait.get() { + let app = NSApplication::sharedApplication(mtm); + stop_app_immediately(&app); + } + self.ivars().start_time.set(Some(Instant::now())); + let wait_timeout = self.ivars().wait_timeout.get(); // configured by pump_events + let app_timeout = match self.control_flow() { + ControlFlow::Wait => None, + ControlFlow::Poll => Some(Instant::now()), + ControlFlow::WaitUntil(instant) => Some(instant), + }; + self.ivars().waker.borrow_mut().start_at(min_timeout(wait_timeout, app_timeout)); + } +} + +#[derive(Debug)] +pub(crate) struct HandlePendingUserEvents; + +/// Returns the minimum `Option`, taking into account that `None` +/// equates to an infinite timeout, not a zero timeout (so can't just use +/// `Option::min`) +fn min_timeout(a: Option, b: Option) -> Option { + a.map_or(b, |a_timeout| b.map_or(Some(a_timeout), |b_timeout| Some(a_timeout.min(b_timeout)))) +} + +/// A hack to make activation of multiple windows work when creating them before +/// `applicationDidFinishLaunching:` / `Event::Event::NewEvents(StartCause::Init)`. +/// +/// Alternative to this would be the user calling `window.set_visible(true)` in +/// `StartCause::Init`. +/// +/// If this becomes too bothersome to maintain, it can probably be removed +/// without too much damage. +fn window_activation_hack(app: &NSApplication) { + // TODO: Proper ordering of the windows + app.windows().into_iter().for_each(|window| { + // Call `makeKeyAndOrderFront` if it was called on the window in `WinitWindow::new` + // This way we preserve the user's desired initial visibility status + // TODO: Also filter on the type/"level" of the window, and maybe other things? + if window.isVisible() { + tracing::trace!("Activating visible window"); + window.makeKeyAndOrderFront(None); + } else { + tracing::trace!("Skipping activating invisible window"); + } + }) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/cursor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/cursor.rs new file mode 100644 index 00000000..9e14e8be --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/cursor.rs @@ -0,0 +1,225 @@ +use std::ffi::c_uchar; +use std::slice; +use std::sync::OnceLock; + +use objc2::rc::Retained; +use objc2::runtime::Sel; +use objc2::{msg_send, msg_send_id, sel, ClassType}; +use objc2_app_kit::{NSBitmapImageRep, NSCursor, NSDeviceRGBColorSpace, NSImage}; +use objc2_foundation::{ + ns_string, NSData, NSDictionary, NSNumber, NSObject, NSObjectProtocol, NSPoint, NSSize, + NSString, +}; + +use crate::cursor::{CursorImage, OnlyCursorImageSource}; +use crate::window::CursorIcon; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CustomCursor(pub(crate) Retained); + +// SAFETY: NSCursor is immutable and thread-safe +// TODO(madsmtm): Put this logic in objc2-app-kit itself +unsafe impl Send for CustomCursor {} +unsafe impl Sync for CustomCursor {} + +impl CustomCursor { + pub(crate) fn new(cursor: OnlyCursorImageSource) -> CustomCursor { + Self(cursor_from_image(&cursor.0)) + } +} + +pub(crate) fn cursor_from_image(cursor: &CursorImage) -> Retained { + let width = cursor.width; + let height = cursor.height; + + let bitmap = unsafe { + NSBitmapImageRep::initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bytesPerRow_bitsPerPixel( + NSBitmapImageRep::alloc(), + std::ptr::null_mut::<*mut c_uchar>(), + width as isize, + height as isize, + 8, + 4, + true, + false, + NSDeviceRGBColorSpace, + width as isize * 4, + 32, + ).unwrap() + }; + let bitmap_data = unsafe { slice::from_raw_parts_mut(bitmap.bitmapData(), cursor.rgba.len()) }; + bitmap_data.copy_from_slice(&cursor.rgba); + + let image = unsafe { + NSImage::initWithSize(NSImage::alloc(), NSSize::new(width.into(), height.into())) + }; + unsafe { image.addRepresentation(&bitmap) }; + + let hotspot = NSPoint::new(cursor.hotspot_x as f64, cursor.hotspot_y as f64); + + NSCursor::initWithImage_hotSpot(NSCursor::alloc(), &image, hotspot) +} + +pub(crate) fn default_cursor() -> Retained { + NSCursor::arrowCursor() +} + +unsafe fn try_cursor_from_selector(sel: Sel) -> Option> { + let cls = NSCursor::class(); + if msg_send![cls, respondsToSelector: sel] { + let cursor: Retained = unsafe { msg_send_id![cls, performSelector: sel] }; + Some(cursor) + } else { + tracing::warn!("cursor `{sel}` appears to be invalid"); + None + } +} + +macro_rules! def_undocumented_cursor { + {$( + $(#[$($m:meta)*])* + fn $name:ident(); + )*} => {$( + $(#[$($m)*])* + #[allow(non_snake_case)] + fn $name() -> Retained { + unsafe { try_cursor_from_selector(sel!($name)).unwrap_or_else(|| default_cursor()) } + } + )*}; +} + +def_undocumented_cursor!( + // Undocumented cursors: https://stackoverflow.com/a/46635398/5435443 + fn _helpCursor(); + fn _zoomInCursor(); + fn _zoomOutCursor(); + fn _windowResizeNorthEastCursor(); + fn _windowResizeNorthWestCursor(); + fn _windowResizeSouthEastCursor(); + fn _windowResizeSouthWestCursor(); + fn _windowResizeNorthEastSouthWestCursor(); + fn _windowResizeNorthWestSouthEastCursor(); + + // While these two are available, the former just loads a white arrow, + // and the latter loads an ugly deflated beachball! + // pub fn _moveCursor(); + // pub fn _waitCursor(); + + // An even more undocumented cursor... + // https://bugs.eclipse.org/bugs/show_bug.cgi?id=522349 + fn busyButClickableCursor(); +); + +// Note that loading `busybutclickable` with this code won't animate +// the frames; instead you'll just get them all in a column. +unsafe fn load_webkit_cursor(name: &NSString) -> Retained { + // Snatch a cursor from WebKit; They fit the style of the native + // cursors, and will seem completely standard to macOS users. + // + // https://stackoverflow.com/a/21786835/5435443 + let root = ns_string!( + "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/\ + HIServices.framework/Versions/A/Resources/cursors" + ); + let cursor_path = root.stringByAppendingPathComponent(name); + + let pdf_path = cursor_path.stringByAppendingPathComponent(ns_string!("cursor.pdf")); + let image = NSImage::initByReferencingFile(NSImage::alloc(), &pdf_path).unwrap(); + + // TODO: Handle PLists better + let info_path = cursor_path.stringByAppendingPathComponent(ns_string!("info.plist")); + let info: Retained> = unsafe { + msg_send_id![ + >::class(), + dictionaryWithContentsOfFile: &*info_path, + ] + }; + let mut x = 0.0; + if let Some(n) = info.get(&*ns_string!("hotx")) { + if n.is_kind_of::() { + let ptr: *const NSObject = n; + let ptr: *const NSNumber = ptr.cast(); + x = unsafe { &*ptr }.as_cgfloat() + } + } + let mut y = 0.0; + if let Some(n) = info.get(&*ns_string!("hotx")) { + if n.is_kind_of::() { + let ptr: *const NSObject = n; + let ptr: *const NSNumber = ptr.cast(); + y = unsafe { &*ptr }.as_cgfloat() + } + } + + let hotspot = NSPoint::new(x, y); + NSCursor::initWithImage_hotSpot(NSCursor::alloc(), &image, hotspot) +} + +fn webkit_move() -> Retained { + unsafe { load_webkit_cursor(ns_string!("move")) } +} + +fn webkit_cell() -> Retained { + unsafe { load_webkit_cursor(ns_string!("cell")) } +} + +pub(crate) fn invisible_cursor() -> Retained { + // 16x16 GIF data for invisible cursor + // You can reproduce this via ImageMagick. + // $ convert -size 16x16 xc:none cursor.gif + static CURSOR_BYTES: &[u8] = &[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x10, 0x00, 0x10, 0x00, 0xf0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, + 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x00, 0x02, 0x0e, 0x84, 0x8f, 0xa9, 0xcb, 0xed, 0x0f, + 0xa3, 0x9c, 0xb4, 0xda, 0x8b, 0xb3, 0x3e, 0x05, 0x00, 0x3b, + ]; + + fn new_invisible() -> Retained { + // TODO: Consider using `dataWithBytesNoCopy:` + let data = NSData::with_bytes(CURSOR_BYTES); + let image = NSImage::initWithData(NSImage::alloc(), &data).unwrap(); + let hotspot = NSPoint::new(0.0, 0.0); + NSCursor::initWithImage_hotSpot(NSCursor::alloc(), &image, hotspot) + } + + // Cache this for efficiency + static CURSOR: OnceLock = OnceLock::new(); + CURSOR.get_or_init(|| CustomCursor(new_invisible())).0.clone() +} + +pub(crate) fn cursor_from_icon(icon: CursorIcon) -> Retained { + match icon { + CursorIcon::Default => default_cursor(), + CursorIcon::Pointer => NSCursor::pointingHandCursor(), + CursorIcon::Grab => NSCursor::openHandCursor(), + CursorIcon::Grabbing => NSCursor::closedHandCursor(), + CursorIcon::Text => NSCursor::IBeamCursor(), + CursorIcon::VerticalText => NSCursor::IBeamCursorForVerticalLayout(), + CursorIcon::Copy => NSCursor::dragCopyCursor(), + CursorIcon::Alias => NSCursor::dragLinkCursor(), + CursorIcon::NotAllowed | CursorIcon::NoDrop => NSCursor::operationNotAllowedCursor(), + CursorIcon::ContextMenu => NSCursor::contextualMenuCursor(), + CursorIcon::Crosshair => NSCursor::crosshairCursor(), + CursorIcon::EResize => NSCursor::resizeRightCursor(), + CursorIcon::NResize => NSCursor::resizeUpCursor(), + CursorIcon::WResize => NSCursor::resizeLeftCursor(), + CursorIcon::SResize => NSCursor::resizeDownCursor(), + CursorIcon::EwResize | CursorIcon::ColResize => NSCursor::resizeLeftRightCursor(), + CursorIcon::NsResize | CursorIcon::RowResize => NSCursor::resizeUpDownCursor(), + CursorIcon::Help => _helpCursor(), + CursorIcon::ZoomIn => _zoomInCursor(), + CursorIcon::ZoomOut => _zoomOutCursor(), + CursorIcon::NeResize => _windowResizeNorthEastCursor(), + CursorIcon::NwResize => _windowResizeNorthWestCursor(), + CursorIcon::SeResize => _windowResizeSouthEastCursor(), + CursorIcon::SwResize => _windowResizeSouthWestCursor(), + CursorIcon::NeswResize => _windowResizeNorthEastSouthWestCursor(), + CursorIcon::NwseResize => _windowResizeNorthWestSouthEastCursor(), + // This is the wrong semantics for `Wait`, but it's the same as + // what's used in Safari and Chrome. + CursorIcon::Wait | CursorIcon::Progress => busyButClickableCursor(), + CursorIcon::Move | CursorIcon::AllScroll => webkit_move(), + CursorIcon::Cell => webkit_cell(), + _ => default_cursor(), + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event.rs new file mode 100644 index 00000000..9b7f35b0 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event.rs @@ -0,0 +1,616 @@ +use std::ffi::c_void; + +use core_foundation::base::CFRelease; +use core_foundation::data::{CFDataGetBytePtr, CFDataRef}; +use objc2::rc::Retained; +use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventSubtype, NSEventType}; +use objc2_foundation::{run_on_main, NSPoint}; +use smol_str::SmolStr; + +use crate::event::{ElementState, KeyEvent, Modifiers}; +use crate::keyboard::{ + Key, KeyCode, KeyLocation, ModifiersKeys, ModifiersState, NamedKey, NativeKey, NativeKeyCode, + PhysicalKey, +}; +use crate::platform_impl::platform::ffi; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct KeyEventExtra { + pub text_with_all_modifiers: Option, + pub key_without_modifiers: Key, +} + +/// Ignores ALL modifiers. +pub fn get_modifierless_char(scancode: u16) -> Key { + let mut string = [0; 16]; + let input_source; + let layout; + unsafe { + input_source = ffi::TISCopyCurrentKeyboardLayoutInputSource(); + if input_source.is_null() { + tracing::error!("`TISCopyCurrentKeyboardLayoutInputSource` returned null ptr"); + return Key::Unidentified(NativeKey::MacOS(scancode)); + } + let layout_data = + ffi::TISGetInputSourceProperty(input_source, ffi::kTISPropertyUnicodeKeyLayoutData); + if layout_data.is_null() { + CFRelease(input_source as *mut c_void); + tracing::error!("`TISGetInputSourceProperty` returned null ptr"); + return Key::Unidentified(NativeKey::MacOS(scancode)); + } + layout = CFDataGetBytePtr(layout_data as CFDataRef) as *const ffi::UCKeyboardLayout; + } + let keyboard_type = run_on_main(|_mtm| unsafe { ffi::LMGetKbdType() }); + + let mut result_len = 0; + let mut dead_keys = 0; + let modifiers = 0; + let translate_result = unsafe { + ffi::UCKeyTranslate( + layout, + scancode, + ffi::kUCKeyActionDisplay, + modifiers, + keyboard_type as u32, + ffi::kUCKeyTranslateNoDeadKeysMask, + &mut dead_keys, + string.len() as ffi::UniCharCount, + &mut result_len, + string.as_mut_ptr(), + ) + }; + unsafe { + CFRelease(input_source as *mut c_void); + } + if translate_result != 0 { + tracing::error!("`UCKeyTranslate` returned with the non-zero value: {}", translate_result); + return Key::Unidentified(NativeKey::MacOS(scancode)); + } + if result_len == 0 { + // This is fine - not all keys have text representation. + // For instance, users that have mapped the `Fn` key to toggle + // keyboard layouts will hit this code path. + return Key::Unidentified(NativeKey::MacOS(scancode)); + } + let chars = String::from_utf16_lossy(&string[0..result_len as usize]); + Key::Character(SmolStr::new(chars)) +} + +// Ignores all modifiers except for SHIFT (yes, even ALT is ignored). +fn get_logical_key_char(ns_event: &NSEvent, modifierless_chars: &str) -> Key { + let string = unsafe { ns_event.charactersIgnoringModifiers() } + .map(|s| s.to_string()) + .unwrap_or_default(); + if string.is_empty() { + // Probably a dead key + let first_char = modifierless_chars.chars().next(); + return Key::Dead(first_char); + } + Key::Character(SmolStr::new(string)) +} + +/// Create `KeyEvent` for the given `NSEvent`. +/// +/// This function shouldn't be called when the IME input is in process. +pub(crate) fn create_key_event(ns_event: &NSEvent, is_press: bool, is_repeat: bool) -> KeyEvent { + use ElementState::{Pressed, Released}; + let state = if is_press { Pressed } else { Released }; + + let scancode = unsafe { ns_event.keyCode() }; + let mut physical_key = scancode_to_physicalkey(scancode as u32); + + // NOTE: The logical key should heed both SHIFT and ALT if possible. + // For instance: + // * Pressing the A key: logical key should be "a" + // * Pressing SHIFT A: logical key should be "A" + // * Pressing CTRL SHIFT A: logical key should also be "A" + // This is not easy to tease out of `NSEvent`, but we do our best. + + let characters = unsafe { ns_event.characters() }.map(|s| s.to_string()).unwrap_or_default(); + let text_with_all_modifiers = if characters.is_empty() { + None + } else { + if matches!(physical_key, PhysicalKey::Unidentified(_)) { + // The key may be one of the funky function keys + physical_key = extra_function_key_to_code(scancode, &characters); + } + Some(SmolStr::new(characters)) + }; + + let key_from_code = code_to_key(physical_key, scancode); + let (logical_key, key_without_modifiers) = if matches!(key_from_code, Key::Unidentified(_)) { + // `get_modifierless_char/key_without_modifiers` ignores ALL modifiers. + let key_without_modifiers = get_modifierless_char(scancode); + + let modifiers = unsafe { ns_event.modifierFlags() }; + let has_ctrl = modifiers.contains(NSEventModifierFlags::NSEventModifierFlagControl); + let has_cmd = modifiers.contains(NSEventModifierFlags::NSEventModifierFlagCommand); + + let logical_key = match text_with_all_modifiers.as_ref() { + // Only checking for ctrl and cmd here, not checking for alt because we DO want to + // include its effect in the key. For example if -on the German layout- one + // presses alt+8, the logical key should be "{" + // Also not checking if this is a release event because then this issue would + // still affect the key release. + Some(text) if !has_ctrl && !has_cmd => { + // Character heeding both SHIFT and ALT. + Key::Character(text.clone()) + }, + + _ => match key_without_modifiers.as_ref() { + // Character heeding just SHIFT, ignoring ALT. + Key::Character(ch) => get_logical_key_char(ns_event, ch), + + // Character ignoring ALL modifiers. + _ => key_without_modifiers.clone(), + }, + }; + + (logical_key, key_without_modifiers) + } else { + (key_from_code.clone(), key_from_code) + }; + + let text = if is_press { logical_key.to_text().map(SmolStr::new) } else { None }; + + let location = code_to_location(physical_key); + + KeyEvent { + location, + logical_key, + physical_key, + repeat: is_repeat, + state, + text, + platform_specific: KeyEventExtra { text_with_all_modifiers, key_without_modifiers }, + } +} + +pub fn code_to_key(key: PhysicalKey, scancode: u16) -> Key { + let code = match key { + PhysicalKey::Code(code) => code, + PhysicalKey::Unidentified(code) => return Key::Unidentified(code.into()), + }; + + Key::Named(match code { + KeyCode::Enter => NamedKey::Enter, + KeyCode::Tab => NamedKey::Tab, + KeyCode::Space => NamedKey::Space, + KeyCode::Backspace => NamedKey::Backspace, + KeyCode::Escape => NamedKey::Escape, + KeyCode::SuperRight => NamedKey::Super, + KeyCode::SuperLeft => NamedKey::Super, + KeyCode::ShiftLeft => NamedKey::Shift, + KeyCode::AltLeft => NamedKey::Alt, + KeyCode::ControlLeft => NamedKey::Control, + KeyCode::ShiftRight => NamedKey::Shift, + KeyCode::AltRight => NamedKey::Alt, + KeyCode::ControlRight => NamedKey::Control, + + KeyCode::NumLock => NamedKey::NumLock, + KeyCode::AudioVolumeUp => NamedKey::AudioVolumeUp, + KeyCode::AudioVolumeDown => NamedKey::AudioVolumeDown, + + // Other numpad keys all generate text on macOS (if I understand correctly) + KeyCode::NumpadEnter => NamedKey::Enter, + + KeyCode::F1 => NamedKey::F1, + KeyCode::F2 => NamedKey::F2, + KeyCode::F3 => NamedKey::F3, + KeyCode::F4 => NamedKey::F4, + KeyCode::F5 => NamedKey::F5, + KeyCode::F6 => NamedKey::F6, + KeyCode::F7 => NamedKey::F7, + KeyCode::F8 => NamedKey::F8, + KeyCode::F9 => NamedKey::F9, + KeyCode::F10 => NamedKey::F10, + KeyCode::F11 => NamedKey::F11, + KeyCode::F12 => NamedKey::F12, + KeyCode::F13 => NamedKey::F13, + KeyCode::F14 => NamedKey::F14, + KeyCode::F15 => NamedKey::F15, + KeyCode::F16 => NamedKey::F16, + KeyCode::F17 => NamedKey::F17, + KeyCode::F18 => NamedKey::F18, + KeyCode::F19 => NamedKey::F19, + KeyCode::F20 => NamedKey::F20, + + KeyCode::Insert => NamedKey::Insert, + KeyCode::Home => NamedKey::Home, + KeyCode::PageUp => NamedKey::PageUp, + KeyCode::Delete => NamedKey::Delete, + KeyCode::End => NamedKey::End, + KeyCode::PageDown => NamedKey::PageDown, + KeyCode::ArrowLeft => NamedKey::ArrowLeft, + KeyCode::ArrowRight => NamedKey::ArrowRight, + KeyCode::ArrowDown => NamedKey::ArrowDown, + KeyCode::ArrowUp => NamedKey::ArrowUp, + _ => return Key::Unidentified(NativeKey::MacOS(scancode)), + }) +} + +pub fn code_to_location(key: PhysicalKey) -> KeyLocation { + let code = match key { + PhysicalKey::Code(code) => code, + PhysicalKey::Unidentified(_) => return KeyLocation::Standard, + }; + + match code { + KeyCode::SuperRight => KeyLocation::Right, + KeyCode::SuperLeft => KeyLocation::Left, + KeyCode::ShiftLeft => KeyLocation::Left, + KeyCode::AltLeft => KeyLocation::Left, + KeyCode::ControlLeft => KeyLocation::Left, + KeyCode::ShiftRight => KeyLocation::Right, + KeyCode::AltRight => KeyLocation::Right, + KeyCode::ControlRight => KeyLocation::Right, + + KeyCode::NumLock => KeyLocation::Numpad, + KeyCode::NumpadDecimal => KeyLocation::Numpad, + KeyCode::NumpadMultiply => KeyLocation::Numpad, + KeyCode::NumpadAdd => KeyLocation::Numpad, + KeyCode::NumpadDivide => KeyLocation::Numpad, + KeyCode::NumpadEnter => KeyLocation::Numpad, + KeyCode::NumpadSubtract => KeyLocation::Numpad, + KeyCode::NumpadEqual => KeyLocation::Numpad, + KeyCode::Numpad0 => KeyLocation::Numpad, + KeyCode::Numpad1 => KeyLocation::Numpad, + KeyCode::Numpad2 => KeyLocation::Numpad, + KeyCode::Numpad3 => KeyLocation::Numpad, + KeyCode::Numpad4 => KeyLocation::Numpad, + KeyCode::Numpad5 => KeyLocation::Numpad, + KeyCode::Numpad6 => KeyLocation::Numpad, + KeyCode::Numpad7 => KeyLocation::Numpad, + KeyCode::Numpad8 => KeyLocation::Numpad, + KeyCode::Numpad9 => KeyLocation::Numpad, + + _ => KeyLocation::Standard, + } +} + +// While F1-F20 have scancodes we can match on, we have to check against UTF-16 +// constants for the rest. +// https://developer.apple.com/documentation/appkit/1535851-function-key_unicodes?preferredLanguage=occ +pub fn extra_function_key_to_code(scancode: u16, string: &str) -> PhysicalKey { + if let Some(ch) = string.encode_utf16().next() { + match ch { + 0xf718 => PhysicalKey::Code(KeyCode::F21), + 0xf719 => PhysicalKey::Code(KeyCode::F22), + 0xf71a => PhysicalKey::Code(KeyCode::F23), + 0xf71b => PhysicalKey::Code(KeyCode::F24), + _ => PhysicalKey::Unidentified(NativeKeyCode::MacOS(scancode)), + } + } else { + PhysicalKey::Unidentified(NativeKeyCode::MacOS(scancode)) + } +} + +// The values are from the https://github.com/apple-oss-distributions/IOHIDFamily/blob/19666c840a6d896468416ff0007040a10b7b46b8/IOHIDSystem/IOKit/hidsystem/IOLLEvent.h#L258-L259 +const NX_DEVICELCTLKEYMASK: NSEventModifierFlags = NSEventModifierFlags(0x00000001); +const NX_DEVICELSHIFTKEYMASK: NSEventModifierFlags = NSEventModifierFlags(0x00000002); +const NX_DEVICERSHIFTKEYMASK: NSEventModifierFlags = NSEventModifierFlags(0x00000004); +const NX_DEVICELCMDKEYMASK: NSEventModifierFlags = NSEventModifierFlags(0x00000008); +const NX_DEVICERCMDKEYMASK: NSEventModifierFlags = NSEventModifierFlags(0x00000010); +const NX_DEVICELALTKEYMASK: NSEventModifierFlags = NSEventModifierFlags(0x00000020); +const NX_DEVICERALTKEYMASK: NSEventModifierFlags = NSEventModifierFlags(0x00000040); +const NX_DEVICERCTLKEYMASK: NSEventModifierFlags = NSEventModifierFlags(0x00002000); + +pub(super) fn lalt_pressed(event: &NSEvent) -> bool { + unsafe { event.modifierFlags() }.contains(NX_DEVICELALTKEYMASK) +} + +pub(super) fn ralt_pressed(event: &NSEvent) -> bool { + unsafe { event.modifierFlags() }.contains(NX_DEVICERALTKEYMASK) +} + +pub(super) fn event_mods(event: &NSEvent) -> Modifiers { + let flags = unsafe { event.modifierFlags() }; + let mut state = ModifiersState::empty(); + let mut pressed_mods = ModifiersKeys::empty(); + + state + .set(ModifiersState::SHIFT, flags.contains(NSEventModifierFlags::NSEventModifierFlagShift)); + pressed_mods.set(ModifiersKeys::LSHIFT, flags.contains(NX_DEVICELSHIFTKEYMASK)); + pressed_mods.set(ModifiersKeys::RSHIFT, flags.contains(NX_DEVICERSHIFTKEYMASK)); + + state.set( + ModifiersState::CONTROL, + flags.contains(NSEventModifierFlags::NSEventModifierFlagControl), + ); + pressed_mods.set(ModifiersKeys::LCONTROL, flags.contains(NX_DEVICELCTLKEYMASK)); + pressed_mods.set(ModifiersKeys::RCONTROL, flags.contains(NX_DEVICERCTLKEYMASK)); + + state.set(ModifiersState::ALT, flags.contains(NSEventModifierFlags::NSEventModifierFlagOption)); + pressed_mods.set(ModifiersKeys::LALT, flags.contains(NX_DEVICELALTKEYMASK)); + pressed_mods.set(ModifiersKeys::RALT, flags.contains(NX_DEVICERALTKEYMASK)); + + state.set( + ModifiersState::SUPER, + flags.contains(NSEventModifierFlags::NSEventModifierFlagCommand), + ); + pressed_mods.set(ModifiersKeys::LSUPER, flags.contains(NX_DEVICELCMDKEYMASK)); + pressed_mods.set(ModifiersKeys::RSUPER, flags.contains(NX_DEVICERCMDKEYMASK)); + + Modifiers { state, pressed_mods } +} + +pub(super) fn dummy_event() -> Option> { + unsafe { + NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2( + NSEventType::ApplicationDefined, + NSPoint::new(0.0, 0.0), + NSEventModifierFlags(0), + 0.0, + 0, + None, + NSEventSubtype::WindowExposed.0, + 0, + 0, + ) + } +} + +pub(crate) fn physicalkey_to_scancode(physical_key: PhysicalKey) -> Option { + let code = match physical_key { + PhysicalKey::Code(code) => code, + PhysicalKey::Unidentified(_) => return None, + }; + + match code { + KeyCode::KeyA => Some(0x00), + KeyCode::KeyS => Some(0x01), + KeyCode::KeyD => Some(0x02), + KeyCode::KeyF => Some(0x03), + KeyCode::KeyH => Some(0x04), + KeyCode::KeyG => Some(0x05), + KeyCode::KeyZ => Some(0x06), + KeyCode::KeyX => Some(0x07), + KeyCode::KeyC => Some(0x08), + KeyCode::KeyV => Some(0x09), + KeyCode::KeyB => Some(0x0b), + KeyCode::KeyQ => Some(0x0c), + KeyCode::KeyW => Some(0x0d), + KeyCode::KeyE => Some(0x0e), + KeyCode::KeyR => Some(0x0f), + KeyCode::KeyY => Some(0x10), + KeyCode::KeyT => Some(0x11), + KeyCode::Digit1 => Some(0x12), + KeyCode::Digit2 => Some(0x13), + KeyCode::Digit3 => Some(0x14), + KeyCode::Digit4 => Some(0x15), + KeyCode::Digit6 => Some(0x16), + KeyCode::Digit5 => Some(0x17), + KeyCode::Equal => Some(0x18), + KeyCode::Digit9 => Some(0x19), + KeyCode::Digit7 => Some(0x1a), + KeyCode::Minus => Some(0x1b), + KeyCode::Digit8 => Some(0x1c), + KeyCode::Digit0 => Some(0x1d), + KeyCode::BracketRight => Some(0x1e), + KeyCode::KeyO => Some(0x1f), + KeyCode::KeyU => Some(0x20), + KeyCode::BracketLeft => Some(0x21), + KeyCode::KeyI => Some(0x22), + KeyCode::KeyP => Some(0x23), + KeyCode::Enter => Some(0x24), + KeyCode::KeyL => Some(0x25), + KeyCode::KeyJ => Some(0x26), + KeyCode::Quote => Some(0x27), + KeyCode::KeyK => Some(0x28), + KeyCode::Semicolon => Some(0x29), + KeyCode::Backslash => Some(0x2a), + KeyCode::Comma => Some(0x2b), + KeyCode::Slash => Some(0x2c), + KeyCode::KeyN => Some(0x2d), + KeyCode::KeyM => Some(0x2e), + KeyCode::Period => Some(0x2f), + KeyCode::Tab => Some(0x30), + KeyCode::Space => Some(0x31), + KeyCode::Backquote => Some(0x32), + KeyCode::Backspace => Some(0x33), + KeyCode::Escape => Some(0x35), + KeyCode::SuperRight => Some(0x36), + KeyCode::SuperLeft => Some(0x37), + KeyCode::ShiftLeft => Some(0x38), + KeyCode::AltLeft => Some(0x3a), + KeyCode::ControlLeft => Some(0x3b), + KeyCode::ShiftRight => Some(0x3c), + KeyCode::AltRight => Some(0x3d), + KeyCode::ControlRight => Some(0x3e), + KeyCode::F17 => Some(0x40), + KeyCode::NumpadDecimal => Some(0x41), + KeyCode::NumpadMultiply => Some(0x43), + KeyCode::NumpadAdd => Some(0x45), + KeyCode::NumLock => Some(0x47), + KeyCode::AudioVolumeUp => Some(0x49), + KeyCode::AudioVolumeDown => Some(0x4a), + KeyCode::NumpadDivide => Some(0x4b), + KeyCode::NumpadEnter => Some(0x4c), + KeyCode::NumpadSubtract => Some(0x4e), + KeyCode::F18 => Some(0x4f), + KeyCode::F19 => Some(0x50), + KeyCode::NumpadEqual => Some(0x51), + KeyCode::Numpad0 => Some(0x52), + KeyCode::Numpad1 => Some(0x53), + KeyCode::Numpad2 => Some(0x54), + KeyCode::Numpad3 => Some(0x55), + KeyCode::Numpad4 => Some(0x56), + KeyCode::Numpad5 => Some(0x57), + KeyCode::Numpad6 => Some(0x58), + KeyCode::Numpad7 => Some(0x59), + KeyCode::F20 => Some(0x5a), + KeyCode::Numpad8 => Some(0x5b), + KeyCode::Numpad9 => Some(0x5c), + KeyCode::IntlYen => Some(0x5d), + KeyCode::F5 => Some(0x60), + KeyCode::F6 => Some(0x61), + KeyCode::F7 => Some(0x62), + KeyCode::F3 => Some(0x63), + KeyCode::F8 => Some(0x64), + KeyCode::F9 => Some(0x65), + KeyCode::F11 => Some(0x67), + KeyCode::F13 => Some(0x69), + KeyCode::F16 => Some(0x6a), + KeyCode::F14 => Some(0x6b), + KeyCode::F10 => Some(0x6d), + KeyCode::F12 => Some(0x6f), + KeyCode::F15 => Some(0x71), + KeyCode::Insert => Some(0x72), + KeyCode::Home => Some(0x73), + KeyCode::PageUp => Some(0x74), + KeyCode::Delete => Some(0x75), + KeyCode::F4 => Some(0x76), + KeyCode::End => Some(0x77), + KeyCode::F2 => Some(0x78), + KeyCode::PageDown => Some(0x79), + KeyCode::F1 => Some(0x7a), + KeyCode::ArrowLeft => Some(0x7b), + KeyCode::ArrowRight => Some(0x7c), + KeyCode::ArrowDown => Some(0x7d), + KeyCode::ArrowUp => Some(0x7e), + _ => None, + } +} + +pub(crate) fn scancode_to_physicalkey(scancode: u32) -> PhysicalKey { + PhysicalKey::Code(match scancode { + 0x00 => KeyCode::KeyA, + 0x01 => KeyCode::KeyS, + 0x02 => KeyCode::KeyD, + 0x03 => KeyCode::KeyF, + 0x04 => KeyCode::KeyH, + 0x05 => KeyCode::KeyG, + 0x06 => KeyCode::KeyZ, + 0x07 => KeyCode::KeyX, + 0x08 => KeyCode::KeyC, + 0x09 => KeyCode::KeyV, + // 0x0a => World 1, + 0x0b => KeyCode::KeyB, + 0x0c => KeyCode::KeyQ, + 0x0d => KeyCode::KeyW, + 0x0e => KeyCode::KeyE, + 0x0f => KeyCode::KeyR, + 0x10 => KeyCode::KeyY, + 0x11 => KeyCode::KeyT, + 0x12 => KeyCode::Digit1, + 0x13 => KeyCode::Digit2, + 0x14 => KeyCode::Digit3, + 0x15 => KeyCode::Digit4, + 0x16 => KeyCode::Digit6, + 0x17 => KeyCode::Digit5, + 0x18 => KeyCode::Equal, + 0x19 => KeyCode::Digit9, + 0x1a => KeyCode::Digit7, + 0x1b => KeyCode::Minus, + 0x1c => KeyCode::Digit8, + 0x1d => KeyCode::Digit0, + 0x1e => KeyCode::BracketRight, + 0x1f => KeyCode::KeyO, + 0x20 => KeyCode::KeyU, + 0x21 => KeyCode::BracketLeft, + 0x22 => KeyCode::KeyI, + 0x23 => KeyCode::KeyP, + 0x24 => KeyCode::Enter, + 0x25 => KeyCode::KeyL, + 0x26 => KeyCode::KeyJ, + 0x27 => KeyCode::Quote, + 0x28 => KeyCode::KeyK, + 0x29 => KeyCode::Semicolon, + 0x2a => KeyCode::Backslash, + 0x2b => KeyCode::Comma, + 0x2c => KeyCode::Slash, + 0x2d => KeyCode::KeyN, + 0x2e => KeyCode::KeyM, + 0x2f => KeyCode::Period, + 0x30 => KeyCode::Tab, + 0x31 => KeyCode::Space, + 0x32 => KeyCode::Backquote, + 0x33 => KeyCode::Backspace, + // 0x34 => unknown, + 0x35 => KeyCode::Escape, + 0x36 => KeyCode::SuperRight, + 0x37 => KeyCode::SuperLeft, + 0x38 => KeyCode::ShiftLeft, + 0x39 => KeyCode::CapsLock, + 0x3a => KeyCode::AltLeft, + 0x3b => KeyCode::ControlLeft, + 0x3c => KeyCode::ShiftRight, + 0x3d => KeyCode::AltRight, + 0x3e => KeyCode::ControlRight, + 0x3f => KeyCode::Fn, + 0x40 => KeyCode::F17, + 0x41 => KeyCode::NumpadDecimal, + // 0x42 -> unknown, + 0x43 => KeyCode::NumpadMultiply, + // 0x44 => unknown, + 0x45 => KeyCode::NumpadAdd, + // 0x46 => unknown, + 0x47 => KeyCode::NumLock, + // 0x48 => KeyCode::NumpadClear, + + // TODO: (Artur) for me, kVK_VolumeUp is 0x48 + // macOS 10.11 + // /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/ + // Versions/A/Headers/Events.h + 0x49 => KeyCode::AudioVolumeUp, + 0x4a => KeyCode::AudioVolumeDown, + 0x4b => KeyCode::NumpadDivide, + 0x4c => KeyCode::NumpadEnter, + // 0x4d => unknown, + 0x4e => KeyCode::NumpadSubtract, + 0x4f => KeyCode::F18, + 0x50 => KeyCode::F19, + 0x51 => KeyCode::NumpadEqual, + 0x52 => KeyCode::Numpad0, + 0x53 => KeyCode::Numpad1, + 0x54 => KeyCode::Numpad2, + 0x55 => KeyCode::Numpad3, + 0x56 => KeyCode::Numpad4, + 0x57 => KeyCode::Numpad5, + 0x58 => KeyCode::Numpad6, + 0x59 => KeyCode::Numpad7, + 0x5a => KeyCode::F20, + 0x5b => KeyCode::Numpad8, + 0x5c => KeyCode::Numpad9, + 0x5d => KeyCode::IntlYen, + // 0x5e => JIS Ro, + // 0x5f => unknown, + 0x60 => KeyCode::F5, + 0x61 => KeyCode::F6, + 0x62 => KeyCode::F7, + 0x63 => KeyCode::F3, + 0x64 => KeyCode::F8, + 0x65 => KeyCode::F9, + // 0x66 => JIS Eisuu (macOS), + 0x67 => KeyCode::F11, + // 0x68 => JIS Kanna (macOS), + 0x69 => KeyCode::F13, + 0x6a => KeyCode::F16, + 0x6b => KeyCode::F14, + // 0x6c => unknown, + 0x6d => KeyCode::F10, + // 0x6e => unknown, + 0x6f => KeyCode::F12, + // 0x70 => unknown, + 0x71 => KeyCode::F15, + 0x72 => KeyCode::Insert, + 0x73 => KeyCode::Home, + 0x74 => KeyCode::PageUp, + 0x75 => KeyCode::Delete, + 0x76 => KeyCode::F4, + 0x77 => KeyCode::End, + 0x78 => KeyCode::F2, + 0x79 => KeyCode::PageDown, + 0x7a => KeyCode::F1, + 0x7b => KeyCode::ArrowLeft, + 0x7c => KeyCode::ArrowRight, + 0x7d => KeyCode::ArrowDown, + 0x7e => KeyCode::ArrowUp, + // 0x7f => unknown, + + // 0xA is the caret (^) an macOS's German QERTZ layout. This key is at the same location as + // backquote (`) on Windows' US layout. + 0xa => KeyCode::Backquote, + _ => return PhysicalKey::Unidentified(NativeKeyCode::MacOS(scancode as u16)), + }) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event_handler.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event_handler.rs new file mode 100644 index 00000000..5c353c14 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event_handler.rs @@ -0,0 +1,139 @@ +use std::cell::RefCell; +use std::{fmt, mem}; + +use super::app_state::HandlePendingUserEvents; +use crate::event::Event; +use crate::event_loop::ActiveEventLoop as RootActiveEventLoop; + +struct EventHandlerData { + #[allow(clippy::type_complexity)] + handler: Box, &RootActiveEventLoop) + 'static>, +} + +impl fmt::Debug for EventHandlerData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EventHandlerData").finish_non_exhaustive() + } +} + +#[derive(Debug)] +pub(crate) struct EventHandler { + /// This can be in the following states: + /// - Not registered by the event loop (None). + /// - Present (Some(handler)). + /// - Currently executing the handler / in use (RefCell borrowed). + inner: RefCell>, +} + +impl EventHandler { + pub(crate) const fn new() -> Self { + Self { inner: RefCell::new(None) } + } + + /// Set the event loop handler for the duration of the given closure. + /// + /// This is similar to using the `scoped-tls` or `scoped-tls-hkt` crates + /// to store the handler in a thread local, such that it can be accessed + /// from within the closure. + pub(crate) fn set<'handler, R>( + &self, + handler: impl FnMut(Event, &RootActiveEventLoop) + 'handler, + closure: impl FnOnce() -> R, + ) -> R { + // SAFETY: We extend the lifetime of the handler here so that we can + // store it in `EventHandler`'s `RefCell`. + // + // This is sound, since we make sure to unset the handler again at the + // end of this function, and as such the lifetime isn't actually + // extended beyond `'handler`. + let handler = unsafe { + mem::transmute::< + Box, &RootActiveEventLoop) + 'handler>, + Box, &RootActiveEventLoop) + 'static>, + >(Box::new(handler)) + }; + + match self.inner.try_borrow_mut().as_deref_mut() { + Ok(Some(_)) => { + unreachable!("tried to set handler while another was already set"); + }, + Ok(data @ None) => { + *data = Some(EventHandlerData { handler }); + }, + Err(_) => { + unreachable!("tried to set handler that is currently in use"); + }, + } + + struct ClearOnDrop<'a>(&'a EventHandler); + + impl Drop for ClearOnDrop<'_> { + fn drop(&mut self) { + match self.0.inner.try_borrow_mut().as_deref_mut() { + Ok(data @ Some(_)) => { + *data = None; + }, + Ok(None) => { + tracing::error!("tried to clear handler, but no handler was set"); + }, + Err(_) => { + // Note: This is not expected to ever happen, this + // module generally controls the `RefCell`, and + // prevents it from ever being borrowed outside of it. + // + // But if it _does_ happen, it is a serious error, and + // we must abort the process, it'd be unsound if we + // weren't able to unset the handler. + eprintln!("tried to clear handler that is currently in use"); + std::process::abort(); + }, + } + } + } + + let _clear_on_drop = ClearOnDrop(self); + + // Note: The RefCell should not be borrowed while executing the + // closure, that'd defeat the whole point. + closure() + + // `_clear_on_drop` will be dropped here, or when unwinding, ensuring + // soundness. + } + + pub(crate) fn in_use(&self) -> bool { + self.inner.try_borrow().is_err() + } + + pub(crate) fn ready(&self) -> bool { + matches!(self.inner.try_borrow().as_deref(), Ok(Some(_))) + } + + pub(crate) fn handle_event( + &self, + event: Event, + event_loop: &RootActiveEventLoop, + ) { + match self.inner.try_borrow_mut().as_deref_mut() { + Ok(Some(EventHandlerData { handler })) => { + // It is important that we keep the reference borrowed here, + // so that `in_use` can properly detect that the handler is + // still in use. + // + // If the handler unwinds, the `RefMut` will ensure that the + // handler is no longer borrowed. + (handler)(event, event_loop); + }, + Ok(None) => { + // `NSApplication`, our app delegate and this handler are all + // global state and so it's not impossible that we could get + // an event after the application has exited the `EventLoop`. + tracing::error!("tried to run event handler, but no handler was set"); + }, + Err(_) => { + // Prevent re-entrancy. + panic!("tried to handle event while another event is currently being handled"); + }, + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event_loop.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event_loop.rs new file mode 100644 index 00000000..5c093d5a --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/event_loop.rs @@ -0,0 +1,523 @@ +use std::any::Any; +use std::cell::Cell; +use std::collections::VecDeque; +use std::marker::PhantomData; +use std::os::raw::c_void; +use std::panic::{catch_unwind, resume_unwind, RefUnwindSafe, UnwindSafe}; +use std::ptr; +use std::rc::{Rc, Weak}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use core_foundation::base::{CFIndex, CFRelease}; +use core_foundation::runloop::{ + kCFRunLoopCommonModes, CFRunLoopAddSource, CFRunLoopGetMain, CFRunLoopSourceContext, + CFRunLoopSourceCreate, CFRunLoopSourceRef, CFRunLoopSourceSignal, CFRunLoopWakeUp, +}; +use objc2::rc::{autoreleasepool, Retained}; +use objc2::runtime::ProtocolObject; +use objc2::sel; +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSWindow}; +use objc2_foundation::{MainThreadMarker, NSObjectProtocol}; + +use super::app::override_send_event; +use super::app_state::{ApplicationDelegate, HandlePendingUserEvents}; +use super::event::dummy_event; +use super::monitor::{self, MonitorHandle}; +use super::observer::setup_control_flow_observers; +use crate::error::EventLoopError; +use crate::event::Event; +use crate::event_loop::{ + ActiveEventLoop as RootWindowTarget, ControlFlow, DeviceEvents, EventLoopClosed, +}; +use crate::platform::macos::ActivationPolicy; +use crate::platform::pump_events::PumpStatus; +use crate::platform_impl::platform::cursor::CustomCursor; +use crate::window::{CustomCursor as RootCustomCursor, CustomCursorSource, Theme}; + +#[derive(Default)] +pub struct PanicInfo { + inner: Cell>>, +} + +// WARNING: +// As long as this struct is used through its `impl`, it is UnwindSafe. +// (If `get_mut` is called on `inner`, unwind safety may get broken.) +impl UnwindSafe for PanicInfo {} +impl RefUnwindSafe for PanicInfo {} +impl PanicInfo { + pub fn is_panicking(&self) -> bool { + let inner = self.inner.take(); + let result = inner.is_some(); + self.inner.set(inner); + result + } + + /// Overwrites the current state if the current state is not panicking + pub fn set_panic(&self, p: Box) { + if !self.is_panicking() { + self.inner.set(Some(p)); + } + } + + pub fn take(&self) -> Option> { + self.inner.take() + } +} + +#[derive(Debug)] +pub struct ActiveEventLoop { + delegate: Retained, + pub(super) mtm: MainThreadMarker, +} + +impl ActiveEventLoop { + pub(super) fn new_root(delegate: Retained) -> RootWindowTarget { + let mtm = MainThreadMarker::from(&*delegate); + let p = Self { delegate, mtm }; + RootWindowTarget { p, _marker: PhantomData } + } + + pub(super) fn app_delegate(&self) -> &ApplicationDelegate { + &self.delegate + } + + pub fn create_custom_cursor(&self, source: CustomCursorSource) -> RootCustomCursor { + RootCustomCursor { inner: CustomCursor::new(source.inner) } + } + + #[inline] + pub fn available_monitors(&self) -> VecDeque { + monitor::available_monitors() + } + + #[inline] + pub fn primary_monitor(&self) -> Option { + let monitor = monitor::primary_monitor(); + Some(monitor) + } + + #[inline] + pub fn listen_device_events(&self, _allowed: DeviceEvents) {} + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::AppKit(rwh_05::AppKitDisplayHandle::empty()) + } + + #[inline] + pub fn system_theme(&self) -> Option { + let app = NSApplication::sharedApplication(self.mtm); + + if app.respondsToSelector(sel!(effectiveAppearance)) { + Some(super::window_delegate::appearance_to_theme(&app.effectiveAppearance())) + } else { + Some(Theme::Light) + } + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::AppKit(rwh_06::AppKitDisplayHandle::new())) + } + + pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) { + self.delegate.set_control_flow(control_flow) + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + self.delegate.control_flow() + } + + pub(crate) fn exit(&self) { + self.delegate.exit() + } + + pub(crate) fn clear_exit(&self) { + self.delegate.clear_exit() + } + + pub(crate) fn exiting(&self) -> bool { + self.delegate.exiting() + } + + pub(crate) fn owned_display_handle(&self) -> OwnedDisplayHandle { + OwnedDisplayHandle + } + + pub(crate) fn hide_application(&self) { + NSApplication::sharedApplication(self.mtm).hide(None) + } + + pub(crate) fn hide_other_applications(&self) { + NSApplication::sharedApplication(self.mtm).hideOtherApplications(None) + } + + pub(crate) fn set_allows_automatic_window_tabbing(&self, enabled: bool) { + NSWindow::setAllowsAutomaticWindowTabbing(enabled, self.mtm) + } + + pub(crate) fn allows_automatic_window_tabbing(&self) -> bool { + NSWindow::allowsAutomaticWindowTabbing(self.mtm) + } +} + +fn map_user_event( + mut handler: impl FnMut(Event, &RootWindowTarget), + receiver: Rc>, +) -> impl FnMut(Event, &RootWindowTarget) { + move |event, window_target| match event.map_nonuser_event() { + Ok(event) => (handler)(event, window_target), + Err(_) => { + for event in receiver.try_iter() { + (handler)(Event::UserEvent(event), window_target); + } + }, + } +} + +pub struct EventLoop { + /// Store a reference to the application for convenience. + /// + /// We intentionally don't store `WinitApplication` since we want to have + /// the possibility of swapping that out at some point. + app: Retained, + /// The application delegate that we've registered. + /// + /// The delegate is only weakly referenced by NSApplication, so we must + /// keep it around here as well. + delegate: Retained, + + // Event sender and receiver, used for EventLoopProxy. + sender: mpsc::Sender, + receiver: Rc>, + + window_target: RootWindowTarget, + panic_info: Rc, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PlatformSpecificEventLoopAttributes { + pub(crate) activation_policy: Option, + pub(crate) default_menu: bool, + pub(crate) activate_ignoring_other_apps: bool, +} + +impl Default for PlatformSpecificEventLoopAttributes { + fn default() -> Self { + Self { activation_policy: None, default_menu: true, activate_ignoring_other_apps: true } + } +} + +impl EventLoop { + pub(crate) fn new( + attributes: &PlatformSpecificEventLoopAttributes, + ) -> Result { + let mtm = MainThreadMarker::new() + .expect("on macOS, `EventLoop` must be created on the main thread!"); + + // Initialize the application (if it has not already been). + let app = NSApplication::sharedApplication(mtm); + + let activation_policy = match attributes.activation_policy { + None => None, + Some(ActivationPolicy::Regular) => Some(NSApplicationActivationPolicy::Regular), + Some(ActivationPolicy::Accessory) => Some(NSApplicationActivationPolicy::Accessory), + Some(ActivationPolicy::Prohibited) => Some(NSApplicationActivationPolicy::Prohibited), + }; + let delegate = ApplicationDelegate::new( + mtm, + activation_policy, + attributes.default_menu, + attributes.activate_ignoring_other_apps, + ); + + autoreleasepool(|_| { + app.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + }); + + // Override `sendEvent:` on the application to forward to our application state. + override_send_event(&app); + + let panic_info: Rc = Default::default(); + setup_control_flow_observers(mtm, Rc::downgrade(&panic_info)); + + let (sender, receiver) = mpsc::channel(); + Ok(EventLoop { + app, + delegate: delegate.clone(), + sender, + receiver: Rc::new(receiver), + window_target: RootWindowTarget { + p: ActiveEventLoop { delegate, mtm }, + _marker: PhantomData, + }, + panic_info, + }) + } + + pub fn window_target(&self) -> &RootWindowTarget { + &self.window_target + } + + pub fn run(mut self, handler: F) -> Result<(), EventLoopError> + where + F: FnMut(Event, &RootWindowTarget), + { + self.run_on_demand(handler) + } + + // NB: we don't base this on `pump_events` because for `MacOs` we can't support + // `pump_events` elegantly (we just ask to run the loop for a "short" amount of + // time and so a layered implementation would end up using a lot of CPU due to + // redundant wake ups. + pub fn run_on_demand(&mut self, handler: F) -> Result<(), EventLoopError> + where + F: FnMut(Event, &RootWindowTarget), + { + let handler = map_user_event(handler, self.receiver.clone()); + + self.delegate.set_event_handler(handler, || { + autoreleasepool(|_| { + // clear / normalize pump_events state + self.delegate.set_wait_timeout(None); + self.delegate.set_stop_before_wait(false); + self.delegate.set_stop_after_wait(false); + self.delegate.set_stop_on_redraw(false); + + if self.delegate.is_launched() { + debug_assert!(!self.delegate.is_running()); + self.delegate.set_is_running(true); + self.delegate.dispatch_init_events(); + } + + // SAFETY: We do not run the application re-entrantly + unsafe { self.app.run() }; + + // While the app is running it's possible that we catch a panic + // to avoid unwinding across an objective-c ffi boundary, which + // will lead to us stopping the `NSApplication` and saving the + // `PanicInfo` so that we can resume the unwind at a controlled, + // safe point in time. + if let Some(panic) = self.panic_info.take() { + resume_unwind(panic); + } + + self.delegate.internal_exit() + }) + }); + + Ok(()) + } + + pub fn pump_events(&mut self, timeout: Option, handler: F) -> PumpStatus + where + F: FnMut(Event, &RootWindowTarget), + { + let handler = map_user_event(handler, self.receiver.clone()); + + self.delegate.set_event_handler(handler, || { + autoreleasepool(|_| { + // As a special case, if the application hasn't been launched yet then we at least + // run the loop until it has fully launched. + if !self.delegate.is_launched() { + debug_assert!(!self.delegate.is_running()); + + self.delegate.set_stop_on_launch(); + // SAFETY: We do not run the application re-entrantly + unsafe { self.app.run() }; + + // Note: we dispatch `NewEvents(Init)` + `Resumed` events after the application + // has launched + } else if !self.delegate.is_running() { + // Even though the application may have been launched, it's possible we aren't + // running if the `EventLoop` was run before and has since + // exited. This indicates that we just starting to re-run + // the same `EventLoop` again. + self.delegate.set_is_running(true); + self.delegate.dispatch_init_events(); + } else { + // Only run for as long as the given `Duration` allows so we don't block the + // external loop. + match timeout { + Some(Duration::ZERO) => { + self.delegate.set_wait_timeout(None); + self.delegate.set_stop_before_wait(true); + }, + Some(duration) => { + self.delegate.set_stop_before_wait(false); + let timeout = Instant::now() + duration; + self.delegate.set_wait_timeout(Some(timeout)); + self.delegate.set_stop_after_wait(true); + }, + None => { + self.delegate.set_wait_timeout(None); + self.delegate.set_stop_before_wait(false); + self.delegate.set_stop_after_wait(true); + }, + } + self.delegate.set_stop_on_redraw(true); + // SAFETY: We do not run the application re-entrantly + unsafe { self.app.run() }; + } + + // While the app is running it's possible that we catch a panic + // to avoid unwinding across an objective-c ffi boundary, which + // will lead to us stopping the application and saving the + // `PanicInfo` so that we can resume the unwind at a controlled, + // safe point in time. + if let Some(panic) = self.panic_info.take() { + resume_unwind(panic); + } + + if self.delegate.exiting() { + self.delegate.internal_exit(); + PumpStatus::Exit(0) + } else { + PumpStatus::Continue + } + }) + }) + } + + pub fn create_proxy(&self) -> EventLoopProxy { + EventLoopProxy::new(self.sender.clone()) + } +} + +#[derive(Clone)] +pub(crate) struct OwnedDisplayHandle; + +impl OwnedDisplayHandle { + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::AppKitDisplayHandle::empty().into() + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::AppKitDisplayHandle::new().into()) + } +} + +pub(super) fn stop_app_immediately(app: &NSApplication) { + autoreleasepool(|_| { + app.stop(None); + // To stop event loop immediately, we need to post some event here. + // See: https://stackoverflow.com/questions/48041279/stopping-the-nsapplication-main-event-loop/48064752#48064752 + app.postEvent_atStart(&dummy_event().unwrap(), true); + }); +} + +/// Tell all windows to close. +/// +/// This will synchronously trigger `WindowEvent::Destroyed` within +/// `windowWillClose:`, giving the application one last chance to handle +/// those events. It doesn't matter if the user also ends up closing the +/// windows in `Window`'s `Drop` impl, once a window has been closed once, it +/// stays closed. +/// +/// This ensures that no windows linger on after the event loop has exited, +/// see . +pub(super) fn notify_windows_of_exit(app: &NSApplication) { + for window in app.windows() { + window.close(); + } +} + +/// Catches panics that happen inside `f` and when a panic +/// happens, stops the `sharedApplication` +#[inline] +pub fn stop_app_on_panic R + UnwindSafe, R>( + mtm: MainThreadMarker, + panic_info: Weak, + f: F, +) -> Option { + match catch_unwind(f) { + Ok(r) => Some(r), + Err(e) => { + // It's important that we set the panic before requesting a `stop` + // because some callback are still called during the `stop` message + // and we need to know in those callbacks if the application is currently + // panicking + { + let panic_info = panic_info.upgrade().unwrap(); + panic_info.set_panic(e); + } + let app = NSApplication::sharedApplication(mtm); + stop_app_immediately(&app); + None + }, + } +} + +pub struct EventLoopProxy { + sender: mpsc::Sender, + source: CFRunLoopSourceRef, +} + +unsafe impl Send for EventLoopProxy {} +unsafe impl Sync for EventLoopProxy {} + +impl Drop for EventLoopProxy { + fn drop(&mut self) { + unsafe { + CFRelease(self.source as _); + } + } +} + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + EventLoopProxy::new(self.sender.clone()) + } +} + +impl EventLoopProxy { + fn new(sender: mpsc::Sender) -> Self { + unsafe { + // just wake up the eventloop + extern "C" fn event_loop_proxy_handler(_: *const c_void) {} + + // adding a Source to the main CFRunLoop lets us wake it up and + // process user events through the normal OS EventLoop mechanisms. + let rl = CFRunLoopGetMain(); + let mut context = CFRunLoopSourceContext { + version: 0, + info: ptr::null_mut(), + retain: None, + release: None, + copyDescription: None, + equal: None, + hash: None, + schedule: None, + cancel: None, + perform: event_loop_proxy_handler, + }; + let source = CFRunLoopSourceCreate(ptr::null_mut(), CFIndex::MAX - 1, &mut context); + CFRunLoopAddSource(rl, source, kCFRunLoopCommonModes); + CFRunLoopWakeUp(rl); + + EventLoopProxy { sender, source } + } + } + + pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed> { + self.sender.send(event).map_err(|mpsc::SendError(x)| EventLoopClosed(x))?; + unsafe { + // let the main thread know there's a new event + CFRunLoopSourceSignal(self.source); + let rl = CFRunLoopGetMain(); + CFRunLoopWakeUp(rl); + } + Ok(()) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/ffi.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/ffi.rs new file mode 100644 index 00000000..1daee071 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/ffi.rs @@ -0,0 +1,256 @@ +// TODO: Upstream these + +#![allow(dead_code, non_snake_case, non_upper_case_globals)] + +use std::ffi::c_void; + +use core_foundation::array::CFArrayRef; +use core_foundation::dictionary::CFDictionaryRef; +use core_foundation::string::CFStringRef; +use core_foundation::uuid::CFUUIDRef; +use core_graphics::base::CGError; +use core_graphics::display::{CGDirectDisplayID, CGDisplayConfigRef}; +use objc2::ffi::NSInteger; +use objc2::runtime::AnyObject; + +pub type CGDisplayFadeInterval = f32; +pub type CGDisplayReservationInterval = f32; +pub type CGDisplayBlendFraction = f32; + +pub const kCGDisplayBlendNormal: f32 = 0.0; +pub const kCGDisplayBlendSolidColor: f32 = 1.0; + +pub type CGDisplayFadeReservationToken = u32; +pub const kCGDisplayFadeReservationInvalidToken: CGDisplayFadeReservationToken = 0; + +pub type Boolean = u8; +pub const FALSE: Boolean = 0; +pub const TRUE: Boolean = 1; + +pub const kCGErrorSuccess: i32 = 0; +pub const kCGErrorFailure: i32 = 1000; +pub const kCGErrorIllegalArgument: i32 = 1001; +pub const kCGErrorInvalidConnection: i32 = 1002; +pub const kCGErrorInvalidContext: i32 = 1003; +pub const kCGErrorCannotComplete: i32 = 1004; +pub const kCGErrorNotImplemented: i32 = 1006; +pub const kCGErrorRangeCheck: i32 = 1007; +pub const kCGErrorTypeCheck: i32 = 1008; +pub const kCGErrorInvalidOperation: i32 = 1010; +pub const kCGErrorNoneAvailable: i32 = 1011; + +pub const IO1BitIndexedPixels: &str = "P"; +pub const IO2BitIndexedPixels: &str = "PP"; +pub const IO4BitIndexedPixels: &str = "PPPP"; +pub const IO8BitIndexedPixels: &str = "PPPPPPPP"; +pub const IO16BitDirectPixels: &str = "-RRRRRGGGGGBBBBB"; +pub const IO32BitDirectPixels: &str = "--------RRRRRRRRGGGGGGGGBBBBBBBB"; + +pub const kIO30BitDirectPixels: &str = "--RRRRRRRRRRGGGGGGGGGGBBBBBBBBBB"; +pub const kIO64BitDirectPixels: &str = "-16R16G16B16"; + +pub const kIO16BitFloatPixels: &str = "-16FR16FG16FB16"; +pub const kIO32BitFloatPixels: &str = "-32FR32FG32FB32"; + +pub const IOYUV422Pixels: &str = "Y4U2V2"; +pub const IO8BitOverlayPixels: &str = "O8"; + +pub type CGWindowLevel = i32; +pub type CGDisplayModeRef = *mut c_void; + +// `CGDisplayCreateUUIDFromDisplayID` comes from the `ColorSync` framework. +// However, that framework was only introduced "publicly" in macOS 10.13. +// +// Since we want to support older versions, we can't link to `ColorSync` +// directly. Fortunately, it has always been available as a subframework of +// `ApplicationServices`, see: +// https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/OSX_Technology_Overview/SystemFrameworks/SystemFrameworks.html#//apple_ref/doc/uid/TP40001067-CH210-BBCFFIEG +#[link(name = "ApplicationServices", kind = "framework")] +extern "C" { + pub fn CGDisplayCreateUUIDFromDisplayID(display: CGDirectDisplayID) -> CFUUIDRef; + + pub fn CGDisplayGetDisplayIDFromUUID(uuid: CFUUIDRef) -> CGDirectDisplayID; +} + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + pub fn CGRestorePermanentDisplayConfiguration(); + pub fn CGDisplayCapture(display: CGDirectDisplayID) -> CGError; + pub fn CGDisplayRelease(display: CGDirectDisplayID) -> CGError; + pub fn CGConfigureDisplayFadeEffect( + config: CGDisplayConfigRef, + fadeOutSeconds: CGDisplayFadeInterval, + fadeInSeconds: CGDisplayFadeInterval, + fadeRed: f32, + fadeGreen: f32, + fadeBlue: f32, + ) -> CGError; + pub fn CGAcquireDisplayFadeReservation( + seconds: CGDisplayReservationInterval, + token: *mut CGDisplayFadeReservationToken, + ) -> CGError; + pub fn CGDisplayFade( + token: CGDisplayFadeReservationToken, + duration: CGDisplayFadeInterval, + startBlend: CGDisplayBlendFraction, + endBlend: CGDisplayBlendFraction, + redBlend: f32, + greenBlend: f32, + blueBlend: f32, + synchronous: Boolean, + ) -> CGError; + pub fn CGReleaseDisplayFadeReservation(token: CGDisplayFadeReservationToken) -> CGError; + pub fn CGShieldingWindowLevel() -> CGWindowLevel; + pub fn CGDisplaySetDisplayMode( + display: CGDirectDisplayID, + mode: CGDisplayModeRef, + options: CFDictionaryRef, + ) -> CGError; + pub fn CGDisplayCopyAllDisplayModes( + display: CGDirectDisplayID, + options: CFDictionaryRef, + ) -> CFArrayRef; + pub fn CGDisplayModeGetPixelWidth(mode: CGDisplayModeRef) -> usize; + pub fn CGDisplayModeGetPixelHeight(mode: CGDisplayModeRef) -> usize; + pub fn CGDisplayModeGetRefreshRate(mode: CGDisplayModeRef) -> f64; + pub fn CGDisplayModeCopyPixelEncoding(mode: CGDisplayModeRef) -> CFStringRef; + pub fn CGDisplayModeRetain(mode: CGDisplayModeRef); + pub fn CGDisplayModeRelease(mode: CGDisplayModeRef); + + // Wildly used private APIs; Apple uses them for their Terminal.app. + pub fn CGSMainConnectionID() -> *mut AnyObject; + pub fn CGSSetWindowBackgroundBlurRadius( + connection_id: *mut AnyObject, + window_id: NSInteger, + radius: i64, + ) -> i32; +} + +mod core_video { + use super::*; + + #[link(name = "CoreVideo", kind = "framework")] + extern "C" {} + + // CVBase.h + + pub type CVTimeFlags = i32; // int32_t + pub const kCVTimeIsIndefinite: CVTimeFlags = 1 << 0; + + #[repr(C)] + #[derive(Debug, Clone)] + pub struct CVTime { + pub time_value: i64, // int64_t + pub time_scale: i32, // int32_t + pub flags: i32, // int32_t + } + + // CVReturn.h + + pub type CVReturn = i32; // int32_t + pub const kCVReturnSuccess: CVReturn = 0; + + // CVDisplayLink.h + + pub type CVDisplayLinkRef = *mut c_void; + + extern "C" { + pub fn CVDisplayLinkCreateWithCGDisplay( + displayID: CGDirectDisplayID, + displayLinkOut: *mut CVDisplayLinkRef, + ) -> CVReturn; + pub fn CVDisplayLinkGetNominalOutputVideoRefreshPeriod( + displayLink: CVDisplayLinkRef, + ) -> CVTime; + pub fn CVDisplayLinkRelease(displayLink: CVDisplayLinkRef); + } +} + +pub use core_video::*; +#[repr(transparent)] +pub struct TISInputSource(std::ffi::c_void); +pub type TISInputSourceRef = *mut TISInputSource; + +#[repr(transparent)] +pub struct UCKeyboardLayout(std::ffi::c_void); + +pub type OptionBits = u32; +pub type UniCharCount = std::os::raw::c_ulong; +pub type UniChar = std::os::raw::c_ushort; +pub type OSStatus = i32; + +#[allow(non_upper_case_globals)] +pub const kUCKeyActionDisplay: u16 = 3; +#[allow(non_upper_case_globals)] +pub const kUCKeyTranslateNoDeadKeysMask: OptionBits = 1; + +#[link(name = "Carbon", kind = "framework")] +extern "C" { + pub static kTISPropertyUnicodeKeyLayoutData: CFStringRef; + + #[allow(non_snake_case)] + pub fn TISGetInputSourceProperty( + inputSource: TISInputSourceRef, + propertyKey: CFStringRef, + ) -> *mut c_void; + + pub fn TISCopyCurrentKeyboardLayoutInputSource() -> TISInputSourceRef; + + pub fn LMGetKbdType() -> u8; + + #[allow(non_snake_case)] + pub fn UCKeyTranslate( + keyLayoutPtr: *const UCKeyboardLayout, + virtualKeyCode: u16, + keyAction: u16, + modifierKeyState: u32, + keyboardType: u32, + keyTranslateOptions: OptionBits, + deadKeyState: *mut u32, + maxStringLength: UniCharCount, + actualStringLength: *mut UniCharCount, + unicodeString: *mut UniChar, + ) -> OSStatus; +} + +// CGWindowLevel.h +// +// Note: There are two different things at play in this header: +// `CGWindowLevel` and `CGWindowLevelKey`. +// +// It seems like there was a push towards using "key" values instead of the +// raw window level values, and then you were supposed to use +// `CGWindowLevelForKey` to get the actual level. +// +// But the values that `NSWindowLevel` has are compiled in, and as such has +// to remain ABI compatible, so they're safe for us to hardcode as well. +#[allow(dead_code, non_upper_case_globals)] +mod window_level { + const kCGNumReservedWindowLevels: i32 = 16; + const kCGNumReservedBaseWindowLevels: i32 = 5; + + pub const kCGBaseWindowLevel: i32 = i32::MIN; + pub const kCGMinimumWindowLevel: i32 = kCGBaseWindowLevel + kCGNumReservedBaseWindowLevels; + pub const kCGMaximumWindowLevel: i32 = i32::MAX - kCGNumReservedWindowLevels; + + pub const kCGDesktopWindowLevel: i32 = kCGMinimumWindowLevel + 20; + pub const kCGDesktopIconWindowLevel: i32 = kCGDesktopWindowLevel + 20; + pub const kCGBackstopMenuLevel: i32 = -20; + pub const kCGNormalWindowLevel: i32 = 0; + pub const kCGFloatingWindowLevel: i32 = 3; + pub const kCGTornOffMenuWindowLevel: i32 = 3; + pub const kCGModalPanelWindowLevel: i32 = 8; + pub const kCGUtilityWindowLevel: i32 = 19; + pub const kCGDockWindowLevel: i32 = 20; + pub const kCGMainMenuWindowLevel: i32 = 24; + pub const kCGStatusWindowLevel: i32 = 25; + pub const kCGPopUpMenuWindowLevel: i32 = 101; + pub const kCGOverlayWindowLevel: i32 = 102; + pub const kCGHelpWindowLevel: i32 = 200; + pub const kCGDraggingWindowLevel: i32 = 500; + pub const kCGScreenSaverWindowLevel: i32 = 1000; + pub const kCGAssistiveTechHighWindowLevel: i32 = 1500; + pub const kCGCursorWindowLevel: i32 = kCGMaximumWindowLevel - 1; +} + +pub use window_level::*; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/menu.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/menu.rs new file mode 100644 index 00000000..cdebea0b --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/menu.rs @@ -0,0 +1,107 @@ +use objc2::rc::Retained; +use objc2::runtime::Sel; +use objc2::sel; +use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSMenu, NSMenuItem}; +use objc2_foundation::{ns_string, MainThreadMarker, NSProcessInfo, NSString}; + +struct KeyEquivalent<'a> { + key: &'a NSString, + masks: Option, +} + +pub fn initialize(app: &NSApplication) { + let mtm = MainThreadMarker::from(app); + let menubar = NSMenu::new(mtm); + let app_menu_item = NSMenuItem::new(mtm); + menubar.addItem(&app_menu_item); + + let app_menu = NSMenu::new(mtm); + let process_name = NSProcessInfo::processInfo().processName(); + + // About menu item + let about_item_title = ns_string!("About ").stringByAppendingString(&process_name); + let about_item = + menu_item(mtm, &about_item_title, Some(sel!(orderFrontStandardAboutPanel:)), None); + + // Services menu item + let services_menu = NSMenu::new(mtm); + let services_item = menu_item(mtm, ns_string!("Services"), None, None); + services_item.setSubmenu(Some(&services_menu)); + + // Separator menu item + let sep_first = NSMenuItem::separatorItem(mtm); + + // Hide application menu item + let hide_item_title = ns_string!("Hide ").stringByAppendingString(&process_name); + let hide_item = menu_item( + mtm, + &hide_item_title, + Some(sel!(hide:)), + Some(KeyEquivalent { key: ns_string!("h"), masks: None }), + ); + + // Hide other applications menu item + let hide_others_item_title = ns_string!("Hide Others"); + let hide_others_item = menu_item( + mtm, + hide_others_item_title, + Some(sel!(hideOtherApplications:)), + Some(KeyEquivalent { + key: ns_string!("h"), + masks: Some( + NSEventModifierFlags::NSEventModifierFlagOption + | NSEventModifierFlags::NSEventModifierFlagCommand, + ), + }), + ); + + // Show applications menu item + let show_all_item_title = ns_string!("Show All"); + let show_all_item = + menu_item(mtm, show_all_item_title, Some(sel!(unhideAllApplications:)), None); + + // Separator menu item + let sep = NSMenuItem::separatorItem(mtm); + + // Quit application menu item + let quit_item_title = ns_string!("Quit ").stringByAppendingString(&process_name); + let quit_item = menu_item( + mtm, + &quit_item_title, + Some(sel!(terminate:)), + Some(KeyEquivalent { key: ns_string!("q"), masks: None }), + ); + + app_menu.addItem(&about_item); + app_menu.addItem(&sep_first); + app_menu.addItem(&services_item); + app_menu.addItem(&hide_item); + app_menu.addItem(&hide_others_item); + app_menu.addItem(&show_all_item); + app_menu.addItem(&sep); + app_menu.addItem(&quit_item); + app_menu_item.setSubmenu(Some(&app_menu)); + + unsafe { app.setServicesMenu(Some(&services_menu)) }; + app.setMainMenu(Some(&menubar)); +} + +fn menu_item( + mtm: MainThreadMarker, + title: &NSString, + selector: Option, + key_equivalent: Option>, +) -> Retained { + let (key, masks) = match key_equivalent { + Some(ke) => (ke.key, ke.masks), + None => (ns_string!(""), None), + }; + let item = unsafe { + NSMenuItem::initWithTitle_action_keyEquivalent(mtm.alloc(), title, selector, key) + }; + if let Some(masks) = masks { + item.setKeyEquivalentModifierMask(masks) + } + + item +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/mod.rs new file mode 100644 index 00000000..1b427a87 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/mod.rs @@ -0,0 +1,61 @@ +#[macro_use] +mod util; + +mod app; +mod app_state; +mod cursor; +mod event; +mod event_handler; +mod event_loop; +mod ffi; +mod menu; +mod monitor; +mod observer; +mod view; +mod window; +mod window_delegate; + +use std::fmt; + +pub(crate) use self::event::{physicalkey_to_scancode, scancode_to_physicalkey, KeyEventExtra}; +pub(crate) use self::event_loop::{ + ActiveEventLoop, EventLoop, EventLoopProxy, OwnedDisplayHandle, + PlatformSpecificEventLoopAttributes, +}; +pub(crate) use self::monitor::{MonitorHandle, VideoModeHandle}; +pub(crate) use self::window::WindowId; +pub(crate) use self::window_delegate::PlatformSpecificWindowAttributes; +use crate::event::DeviceId as RootDeviceId; + +pub(crate) use self::cursor::CustomCursor as PlatformCustomCursor; +pub(crate) use self::window::Window; +pub(crate) use crate::cursor::OnlyCursorImageSource as PlatformCustomCursorSource; +pub(crate) use crate::icon::NoIcon as PlatformIcon; +pub(crate) use crate::platform_impl::Fullscreen; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeviceId; + +impl DeviceId { + pub const fn dummy() -> Self { + DeviceId + } +} + +// Constant device ID; to be removed when if backend is updated to report real device IDs. +pub(crate) const DEVICE_ID: RootDeviceId = RootDeviceId(DeviceId); + +#[derive(Debug)] +pub enum OsError { + CGError(core_graphics::base::CGError), + CreationError(&'static str), +} + +impl fmt::Display for OsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OsError::CGError(e) => f.pad(&format!("CGError {e}")), + OsError::CreationError(e) => f.pad(e), + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/monitor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/monitor.rs new file mode 100644 index 00000000..44e6316b --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/monitor.rs @@ -0,0 +1,409 @@ +#![allow(clippy::unnecessary_cast)] + +use std::collections::VecDeque; +use std::fmt; + +use core_foundation::array::{CFArrayGetCount, CFArrayGetValueAtIndex}; +use core_foundation::base::{CFRelease, TCFType}; +use core_foundation::string::CFString; +use core_foundation::uuid::{CFUUIDGetUUIDBytes, CFUUID}; +use core_graphics::display::{ + CGDirectDisplayID, CGDisplay, CGDisplayBounds, CGDisplayCopyDisplayMode, +}; +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2_app_kit::NSScreen; +use objc2_foundation::{ns_string, run_on_main, MainThreadMarker, NSNumber, NSPoint, NSRect}; +use tracing::warn; + +use super::ffi; +use crate::dpi::{LogicalPosition, PhysicalPosition, PhysicalSize}; + +#[derive(Clone)] +pub struct VideoModeHandle { + size: PhysicalSize, + bit_depth: u16, + refresh_rate_millihertz: u32, + pub(crate) monitor: MonitorHandle, + pub(crate) native_mode: NativeDisplayMode, +} + +impl PartialEq for VideoModeHandle { + fn eq(&self, other: &Self) -> bool { + self.size == other.size + && self.bit_depth == other.bit_depth + && self.refresh_rate_millihertz == other.refresh_rate_millihertz + && self.monitor == other.monitor + } +} + +impl Eq for VideoModeHandle {} + +impl std::hash::Hash for VideoModeHandle { + fn hash(&self, state: &mut H) { + self.size.hash(state); + self.bit_depth.hash(state); + self.refresh_rate_millihertz.hash(state); + self.monitor.hash(state); + } +} + +impl std::fmt::Debug for VideoModeHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VideoModeHandle") + .field("size", &self.size) + .field("bit_depth", &self.bit_depth) + .field("refresh_rate_millihertz", &self.refresh_rate_millihertz) + .field("monitor", &self.monitor) + .finish() + } +} + +pub struct NativeDisplayMode(pub ffi::CGDisplayModeRef); + +unsafe impl Send for NativeDisplayMode {} +unsafe impl Sync for NativeDisplayMode {} + +impl Drop for NativeDisplayMode { + fn drop(&mut self) { + unsafe { + ffi::CGDisplayModeRelease(self.0); + } + } +} + +impl Clone for NativeDisplayMode { + fn clone(&self) -> Self { + unsafe { + ffi::CGDisplayModeRetain(self.0); + } + NativeDisplayMode(self.0) + } +} + +impl VideoModeHandle { + pub fn size(&self) -> PhysicalSize { + self.size + } + + pub fn bit_depth(&self) -> u16 { + self.bit_depth + } + + pub fn refresh_rate_millihertz(&self) -> u32 { + self.refresh_rate_millihertz + } + + pub fn monitor(&self) -> MonitorHandle { + self.monitor.clone() + } +} + +/// `CGDirectDisplayID` is documented as: +/// > a framebuffer, a color correction (gamma) table, and possibly an attached monitor. +/// +/// That is, it doesn't actually represent the monitor itself. Instead, we use the UUID of the +/// monitor, as retrieved from `CGDisplayCreateUUIDFromDisplayID` (this makes the monitor ID stable, +/// even across reboots and video mode changes). +/// +/// NOTE: I'd be perfectly valid to store `[u8; 16]` in here instead, we only store `CFUUID` to +/// avoid having to re-create it when we want to fetch the display ID. +#[derive(Clone)] +pub struct MonitorHandle(CFUUID); + +// SAFETY: CFUUID is immutable. +// FIXME(madsmtm): Upstream this into `objc2-core-foundation`. +unsafe impl Send for MonitorHandle {} +unsafe impl Sync for MonitorHandle {} + +type MonitorUuid = [u8; 16]; + +impl MonitorHandle { + /// Internal comparisons of [`MonitorHandle`]s are done first requesting a UUID for the handle. + fn uuid(&self) -> MonitorUuid { + let uuid = unsafe { CFUUIDGetUUIDBytes(self.0.as_concrete_TypeRef()) }; + MonitorUuid::from([ + uuid.byte0, + uuid.byte1, + uuid.byte2, + uuid.byte3, + uuid.byte4, + uuid.byte5, + uuid.byte6, + uuid.byte7, + uuid.byte8, + uuid.byte9, + uuid.byte10, + uuid.byte11, + uuid.byte12, + uuid.byte13, + uuid.byte14, + uuid.byte15, + ]) + } + + fn display_id(&self) -> CGDirectDisplayID { + unsafe { ffi::CGDisplayGetDisplayIDFromUUID(self.0.as_concrete_TypeRef()) } + } + + #[track_caller] + pub(crate) fn new(display_id: CGDirectDisplayID) -> Option { + // kCGNullDirectDisplay + if display_id == 0 { + // `CGDisplayCreateUUIDFromDisplayID` checks kCGNullDirectDisplay internally. + warn!("constructing monitor from invalid display ID 0; falling back to main monitor"); + } + let ptr = unsafe { ffi::CGDisplayCreateUUIDFromDisplayID(display_id) }; + if ptr.is_null() { + return None; + } + Some(Self(unsafe { CFUUID::wrap_under_create_rule(ptr) })) + } +} + +impl PartialEq for MonitorHandle { + fn eq(&self, other: &Self) -> bool { + self.uuid() == other.uuid() + } +} + +impl Eq for MonitorHandle {} + +impl PartialOrd for MonitorHandle { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for MonitorHandle { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.uuid().cmp(&other.uuid()) + } +} + +impl std::hash::Hash for MonitorHandle { + fn hash(&self, state: &mut H) { + self.uuid().hash(state); + } +} + +pub fn available_monitors() -> VecDeque { + if let Ok(displays) = CGDisplay::active_displays() { + let mut monitors = VecDeque::with_capacity(displays.len()); + for display in displays { + // Display ID just fetched from `CGGetActiveDisplayList`, should be fine to unwrap. + monitors.push_back(MonitorHandle::new(display).expect("invalid display ID")); + } + monitors + } else { + VecDeque::with_capacity(0) + } +} + +pub fn primary_monitor() -> MonitorHandle { + // Display ID just fetched from `CGMainDisplayID`, should be fine to unwrap. + MonitorHandle::new(CGDisplay::main().id).expect("invalid display ID") +} + +impl fmt::Debug for MonitorHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MonitorHandle") + .field("name", &self.name()) + .field("native_identifier", &self.native_identifier()) + .field("size", &self.size()) + .field("position", &self.position()) + .field("scale_factor", &self.scale_factor()) + .field("refresh_rate_millihertz", &self.refresh_rate_millihertz()) + .finish_non_exhaustive() + } +} + +impl MonitorHandle { + // TODO: Be smarter about this: + // + pub fn name(&self) -> Option { + let screen_num = CGDisplay::new(self.display_id()).model_number(); + Some(format!("Monitor #{screen_num}")) + } + + #[inline] + pub fn native_identifier(&self) -> u32 { + self.display_id() + } + + pub fn size(&self) -> PhysicalSize { + let display = CGDisplay::new(self.display_id()); + let height = display.pixels_high(); + let width = display.pixels_wide(); + PhysicalSize::from_logical::<_, f64>((width as f64, height as f64), self.scale_factor()) + } + + #[inline] + pub fn position(&self) -> PhysicalPosition { + // This is already in screen coordinates. If we were using `NSScreen`, + // then a conversion would've been needed: + // flip_window_screen_coordinates(self.ns_screen(mtm)?.frame()) + let bounds = unsafe { CGDisplayBounds(self.native_identifier()) }; + let position = LogicalPosition::new(bounds.origin.x, bounds.origin.y); + position.to_physical(self.scale_factor()) + } + + pub fn scale_factor(&self) -> f64 { + run_on_main(|mtm| { + match self.ns_screen(mtm) { + Some(screen) => screen.backingScaleFactor() as f64, + None => 1.0, // default to 1.0 when we can't find the screen + } + }) + } + + pub fn refresh_rate_millihertz(&self) -> Option { + unsafe { + let current_display_mode = + NativeDisplayMode(CGDisplayCopyDisplayMode(self.display_id()) as _); + let refresh_rate = ffi::CGDisplayModeGetRefreshRate(current_display_mode.0); + if refresh_rate > 0.0 { + return Some((refresh_rate * 1000.0).round() as u32); + } + + let mut display_link = std::ptr::null_mut(); + if ffi::CVDisplayLinkCreateWithCGDisplay(self.display_id(), &mut display_link) + != ffi::kCVReturnSuccess + { + return None; + } + let time = ffi::CVDisplayLinkGetNominalOutputVideoRefreshPeriod(display_link); + ffi::CVDisplayLinkRelease(display_link); + + // This value is indefinite if an invalid display link was specified + if time.flags & ffi::kCVTimeIsIndefinite != 0 { + return None; + } + + (time.time_scale as i64).checked_div(time.time_value).map(|v| (v * 1000) as u32) + } + } + + pub fn video_modes(&self) -> impl Iterator { + let refresh_rate_millihertz = self.refresh_rate_millihertz().unwrap_or(0); + let monitor = self.clone(); + + unsafe { + let modes = { + let array = ffi::CGDisplayCopyAllDisplayModes(self.display_id(), std::ptr::null()); + if array.is_null() { + // Occasionally, certain CalDigit Thunderbolt Hubs report a spurious monitor + // during sleep/wake/cycling monitors. It tends to have null + // or 1 video mode only. See . + warn!(monitor = ?self, "failed to get a list of display modes"); + Vec::new() + } else { + let array_count = CFArrayGetCount(array); + let modes: Vec<_> = (0..array_count) + .map(move |i| { + let mode = CFArrayGetValueAtIndex(array, i) as *mut _; + ffi::CGDisplayModeRetain(mode); + mode + }) + .collect(); + CFRelease(array as *const _); + modes + } + }; + + modes.into_iter().map(move |mode| { + let cg_refresh_rate_hertz = ffi::CGDisplayModeGetRefreshRate(mode); + + // CGDisplayModeGetRefreshRate returns 0.0 for any display that + // isn't a CRT + let refresh_rate_millihertz = if cg_refresh_rate_hertz > 0.0 { + (cg_refresh_rate_hertz * 1000.0).round() as u32 + } else { + refresh_rate_millihertz + }; + + let pixel_encoding = + CFString::wrap_under_create_rule(ffi::CGDisplayModeCopyPixelEncoding(mode)) + .to_string(); + let bit_depth = if pixel_encoding.eq_ignore_ascii_case(ffi::IO32BitDirectPixels) { + 32 + } else if pixel_encoding.eq_ignore_ascii_case(ffi::IO16BitDirectPixels) { + 16 + } else if pixel_encoding.eq_ignore_ascii_case(ffi::kIO30BitDirectPixels) { + 30 + } else { + unimplemented!() + }; + + VideoModeHandle { + size: PhysicalSize::new( + ffi::CGDisplayModeGetPixelWidth(mode) as u32, + ffi::CGDisplayModeGetPixelHeight(mode) as u32, + ), + refresh_rate_millihertz, + bit_depth, + monitor: monitor.clone(), + native_mode: NativeDisplayMode(mode), + } + }) + } + } + + pub(crate) fn ns_screen(&self, mtm: MainThreadMarker) -> Option> { + let uuid = self.uuid(); + NSScreen::screens(mtm).into_iter().find(|screen| { + let other_native_id = get_display_id(screen); + if let Some(other) = MonitorHandle::new(other_native_id) { + uuid == other.uuid() + } else { + // Display ID was just fetched from live NSScreen, but can still result in `None` + // with certain Thunderbolt docked monitors. + warn!(other_native_id, "comparing against screen with invalid display ID"); + false + } + }) + } +} + +pub(crate) fn get_display_id(screen: &NSScreen) -> u32 { + let key = ns_string!("NSScreenNumber"); + + objc2::rc::autoreleasepool(|_| { + let device_description = screen.deviceDescription(); + + // Retrieve the CGDirectDisplayID associated with this screen + // + // SAFETY: The value from @"NSScreenNumber" in deviceDescription is guaranteed + // to be an NSNumber. See documentation for `deviceDescription` for details: + // + let obj = device_description + .get(key) + .expect("failed getting screen display id from device description"); + let obj: *const AnyObject = obj; + let obj: *const NSNumber = obj.cast(); + let obj: &NSNumber = unsafe { &*obj }; + + obj.as_u32() + }) +} + +/// Core graphics screen coordinates are relative to the top-left corner of +/// the so-called "main" display, with y increasing downwards - which is +/// exactly what we want in Winit. +/// +/// However, `NSWindow` and `NSScreen` changes these coordinates to: +/// 1. Be relative to the bottom-left corner of the "main" screen. +/// 2. Be relative to the bottom-left corner of the window/screen itself. +/// 3. Have y increasing upwards. +/// +/// This conversion happens to be symmetric, so we only need this one function +/// to convert between the two coordinate systems. +pub(crate) fn flip_window_screen_coordinates(frame: NSRect) -> NSPoint { + // It is intentional that we use `CGMainDisplayID` (as opposed to + // `NSScreen::mainScreen`), because that's what the screen coordinates + // are relative to, no matter which display the window is currently on. + let main_screen_height = CGDisplay::main().bounds().size.height; + + let y = main_screen_height - frame.size.height - frame.origin.y; + NSPoint::new(frame.origin.x, y) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/observer.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/observer.rs new file mode 100644 index 00000000..83398030 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/observer.rs @@ -0,0 +1,312 @@ +//! Utilities for working with `CFRunLoop`. +//! +//! See Apple's documentation on Run Loops for details: +//! +use std::cell::Cell; +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, UnwindSafe}; +use std::ptr; +use std::rc::Weak; +use std::time::Instant; + +use block2::Block; +use core_foundation::base::{CFIndex, CFOptionFlags, CFRelease, CFTypeRef}; +use core_foundation::date::CFAbsoluteTimeGetCurrent; +use core_foundation::runloop::{ + kCFRunLoopAfterWaiting, kCFRunLoopBeforeWaiting, kCFRunLoopCommonModes, kCFRunLoopDefaultMode, + kCFRunLoopExit, CFRunLoopActivity, CFRunLoopAddObserver, CFRunLoopAddTimer, CFRunLoopGetMain, + CFRunLoopObserverCallBack, CFRunLoopObserverContext, CFRunLoopObserverCreate, + CFRunLoopObserverRef, CFRunLoopRef, CFRunLoopTimerCreate, CFRunLoopTimerInvalidate, + CFRunLoopTimerRef, CFRunLoopTimerSetNextFireDate, CFRunLoopWakeUp, +}; +use objc2_foundation::MainThreadMarker; +use tracing::error; + +use super::app_state::ApplicationDelegate; +use super::event_loop::{stop_app_on_panic, PanicInfo}; +use super::ffi; + +unsafe fn control_flow_handler(panic_info: *mut c_void, f: F) +where + F: FnOnce(Weak) + UnwindSafe, +{ + let info_from_raw = unsafe { Weak::from_raw(panic_info as *mut PanicInfo) }; + // Asserting unwind safety on this type should be fine because `PanicInfo` is + // `RefUnwindSafe` and `Rc` is `UnwindSafe` if `T` is `RefUnwindSafe`. + let panic_info = AssertUnwindSafe(Weak::clone(&info_from_raw)); + // `from_raw` takes ownership of the data behind the pointer. + // But if this scope takes ownership of the weak pointer, then + // the weak pointer will get free'd at the end of the scope. + // However we want to keep that weak reference around after the function. + std::mem::forget(info_from_raw); + + let mtm = MainThreadMarker::new().unwrap(); + stop_app_on_panic(mtm, Weak::clone(&panic_info), move || { + let _ = &panic_info; + f(panic_info.0) + }); +} + +// begin is queued with the highest priority to ensure it is processed before other observers +extern "C" fn control_flow_begin_handler( + _: CFRunLoopObserverRef, + activity: CFRunLoopActivity, + panic_info: *mut c_void, +) { + unsafe { + control_flow_handler(panic_info, |panic_info| { + #[allow(non_upper_case_globals)] + match activity { + kCFRunLoopAfterWaiting => { + // trace!("Triggered `CFRunLoopAfterWaiting`"); + ApplicationDelegate::get(MainThreadMarker::new().unwrap()).wakeup(panic_info); + // trace!("Completed `CFRunLoopAfterWaiting`"); + }, + _ => unreachable!(), + } + }); + } +} + +// end is queued with the lowest priority to ensure it is processed after other observers +// without that, LoopExiting would get sent after AboutToWait +extern "C" fn control_flow_end_handler( + _: CFRunLoopObserverRef, + activity: CFRunLoopActivity, + panic_info: *mut c_void, +) { + unsafe { + control_flow_handler(panic_info, |panic_info| { + #[allow(non_upper_case_globals)] + match activity { + kCFRunLoopBeforeWaiting => { + // trace!("Triggered `CFRunLoopBeforeWaiting`"); + ApplicationDelegate::get(MainThreadMarker::new().unwrap()).cleared(panic_info); + // trace!("Completed `CFRunLoopBeforeWaiting`"); + }, + kCFRunLoopExit => (), // unimplemented!(), // not expected to ever happen + _ => unreachable!(), + } + }); + } +} + +#[derive(Debug)] +pub struct RunLoop(CFRunLoopRef); + +impl Default for RunLoop { + fn default() -> Self { + Self(ptr::null_mut()) + } +} + +impl RunLoop { + pub fn main(mtm: MainThreadMarker) -> Self { + // SAFETY: We have a MainThreadMarker here, which means we know we're on the main thread, so + // scheduling (and scheduling a non-`Send` block) to that thread is allowed. + let _ = mtm; + RunLoop(unsafe { CFRunLoopGetMain() }) + } + + pub fn wakeup(&self) { + unsafe { CFRunLoopWakeUp(self.0) } + } + + unsafe fn add_observer( + &self, + flags: CFOptionFlags, + priority: CFIndex, + handler: CFRunLoopObserverCallBack, + context: *mut CFRunLoopObserverContext, + ) { + let observer = unsafe { + CFRunLoopObserverCreate( + ptr::null_mut(), + flags, + ffi::TRUE, // Indicates we want this to run repeatedly + priority, // The lower the value, the sooner this will run + handler, + context, + ) + }; + unsafe { CFRunLoopAddObserver(self.0, observer, kCFRunLoopCommonModes) }; + } + + /// Submit a closure to run on the main thread as the next step in the run loop, before other + /// event sources are processed. + /// + /// This is used for running event handlers, as those are not allowed to run re-entrantly. + /// + /// # Implementation + /// + /// This queuing could be implemented in the following several ways with subtle differences in + /// timing. This list is sorted in rough order in which they are run: + /// + /// 1. Using `CFRunLoopPerformBlock` or `-[NSRunLoop performBlock:]`. + /// + /// 2. Using `-[NSObject performSelectorOnMainThread:withObject:waitUntilDone:]` or wrapping the + /// event in `NSEvent` and posting that to `-[NSApplication postEvent:atStart:]` (both + /// creates a custom `CFRunLoopSource`, and signals that to wake up the main event loop). + /// + /// a. `atStart = true`. + /// + /// b. `atStart = false`. + /// + /// 3. `dispatch_async` or `dispatch_async_f`. Note that this may appear before 2b, it does not + /// respect the ordering that runloop events have. + /// + /// We choose the first one, both for ease-of-implementation, but mostly for consistency, as we + /// want the event to be queued in a way that preserves the order the events originally arrived + /// in. + /// + /// As an example, let's assume that we receive two events from the user, a mouse click which we + /// handled by queuing it, and a window resize which we handled immediately. If we allowed + /// AppKit to choose the ordering when queuing the mouse event, it might get put in the back of + /// the queue, and the events would appear out of order to the user of Winit. So we must instead + /// put the event at the very front of the queue, to be handled as soon as possible after + /// handling whatever event it's currently handling. + pub fn queue_closure(&self, closure: impl FnOnce() + 'static) { + extern "C" { + fn CFRunLoopPerformBlock(rl: CFRunLoopRef, mode: CFTypeRef, block: &Block); + } + + // Convert `FnOnce()` to `Block`. + let closure = Cell::new(Some(closure)); + let block = block2::RcBlock::new(move || { + if let Some(closure) = closure.take() { + closure() + } else { + error!("tried to execute queued closure on main thread twice"); + } + }); + + // There are a few common modes (`kCFRunLoopCommonModes`) defined by Cocoa: + // - `NSDefaultRunLoopMode`, alias of `kCFRunLoopDefaultMode`. + // - `NSEventTrackingRunLoopMode`, used when mouse-dragging and live-resizing a window. + // - `NSModalPanelRunLoopMode`, used when running a modal inside the Winit event loop. + // - `NSConnectionReplyMode`: TODO. + // + // We only want to run event handlers in the default mode, as we support running a blocking + // modal inside a Winit event handler (see [#1779]) which outrules the modal panel mode, and + // resizing such panel window enters the event tracking run loop mode, so we can't directly + // trigger events inside that mode either. + // + // Any events that are queued while running a modal or when live-resizing will instead wait, + // and be delivered to the application afterwards. + // + // [#1779]: https://github.com/rust-windowing/winit/issues/1779 + let mode = unsafe { kCFRunLoopDefaultMode as CFTypeRef }; + + // SAFETY: The runloop is valid, the mode is a `CFStringRef`, and the block is `'static`. + unsafe { CFRunLoopPerformBlock(self.0, mode, &block) } + } +} + +pub fn setup_control_flow_observers(mtm: MainThreadMarker, panic_info: Weak) { + let run_loop = RunLoop::main(mtm); + unsafe { + let mut context = CFRunLoopObserverContext { + info: Weak::into_raw(panic_info) as *mut _, + version: 0, + retain: None, + release: None, + copyDescription: None, + }; + run_loop.add_observer( + kCFRunLoopAfterWaiting, + CFIndex::MIN, + control_flow_begin_handler, + &mut context as *mut _, + ); + run_loop.add_observer( + kCFRunLoopExit | kCFRunLoopBeforeWaiting, + CFIndex::MAX, + control_flow_end_handler, + &mut context as *mut _, + ); + } +} + +#[derive(Debug)] +pub struct EventLoopWaker { + timer: CFRunLoopTimerRef, + + /// An arbitrary instant in the past, that will trigger an immediate wake + /// We save this as the `next_fire_date` for consistency so we can + /// easily check if the next_fire_date needs updating. + start_instant: Instant, + + /// This is what the `NextFireDate` has been set to. + /// `None` corresponds to `waker.stop()` and `start_instant` is used + /// for `waker.start()` + next_fire_date: Option, +} + +impl Drop for EventLoopWaker { + fn drop(&mut self) { + unsafe { + CFRunLoopTimerInvalidate(self.timer); + CFRelease(self.timer as _); + } + } +} + +impl EventLoopWaker { + pub(crate) fn new() -> Self { + extern "C" fn wakeup_main_loop(_timer: CFRunLoopTimerRef, _info: *mut c_void) {} + unsafe { + // Create a timer with a 0.1µs interval (1ns does not work) to mimic polling. + // It is initially setup with a first fire time really far into the + // future, but that gets changed to fire immediately in did_finish_launching + let timer = CFRunLoopTimerCreate( + ptr::null_mut(), + f64::MAX, + 0.000_000_1, + 0, + 0, + wakeup_main_loop, + ptr::null_mut(), + ); + CFRunLoopAddTimer(CFRunLoopGetMain(), timer, kCFRunLoopCommonModes); + Self { timer, start_instant: Instant::now(), next_fire_date: None } + } + } + + pub fn stop(&mut self) { + if self.next_fire_date.is_some() { + self.next_fire_date = None; + unsafe { CFRunLoopTimerSetNextFireDate(self.timer, f64::MAX) } + } + } + + pub fn start(&mut self) { + if self.next_fire_date != Some(self.start_instant) { + self.next_fire_date = Some(self.start_instant); + unsafe { CFRunLoopTimerSetNextFireDate(self.timer, f64::MIN) } + } + } + + pub fn start_at(&mut self, instant: Option) { + let now = Instant::now(); + match instant { + Some(instant) if now >= instant => { + self.start(); + }, + Some(instant) => { + if self.next_fire_date != Some(instant) { + self.next_fire_date = Some(instant); + unsafe { + let current = CFAbsoluteTimeGetCurrent(); + let duration = instant - now; + let fsecs = duration.subsec_nanos() as f64 / 1_000_000_000.0 + + duration.as_secs() as f64; + CFRunLoopTimerSetNextFireDate(self.timer, current + fsecs) + } + } + }, + None => { + self.stop(); + }, + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/util.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/util.rs new file mode 100644 index 00000000..8110573a --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/util.rs @@ -0,0 +1,27 @@ +use tracing::trace; + +macro_rules! trace_scope { + ($s:literal) => { + let _crate = $crate::platform_impl::platform::util::TraceGuard::new(module_path!(), $s); + }; +} + +pub(crate) struct TraceGuard { + module_path: &'static str, + called_from_fn: &'static str, +} + +impl TraceGuard { + #[inline] + pub(crate) fn new(module_path: &'static str, called_from_fn: &'static str) -> Self { + trace!(target = module_path, "Triggered `{}`", called_from_fn); + Self { module_path, called_from_fn } + } +} + +impl Drop for TraceGuard { + #[inline] + fn drop(&mut self) { + trace!(target = self.module_path, "Completed `{}`", self.called_from_fn); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/view.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/view.rs new file mode 100644 index 00000000..d6e0e5c1 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/view.rs @@ -0,0 +1,1787 @@ +#![allow(clippy::unnecessary_cast)] +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::ptr; +use std::sync::{Arc, Mutex}; + +use objc2::rc::{Retained, WeakId}; +use objc2::runtime::{AnyObject, Sel}; +use objc2::{ + declare_class, msg_send_id, mutability, sel, ClassType, DeclaredClass, +}; +use objc2_app_kit::{ + NSApplication, NSCursor, NSEvent, NSEventPhase, NSResponder, + NSTextInputClient, NSTrackingRectTag, NSView, + NSViewFrameDidChangeNotification, NSWindow, + NSWindowDidBecomeKeyNotification, + NSWindowDidChangeBackingPropertiesNotification, + NSWindowDidChangeOcclusionStateNotification, + NSWindowDidChangeScreenNotification, NSWindowDidDeminiaturizeNotification, + NSWindowDidEnterFullScreenNotification, + NSWindowDidExitFullScreenNotification, NSWindowDidMiniaturizeNotification, + NSWindowDidMoveNotification, NSWindowDidResignKeyNotification, + NSWindowDidResizeNotification, NSWindowOcclusionState, + NSWindowWillCloseNotification, +}; +use objc2_foundation::{ + MainThreadMarker, NSArray, NSAttributedString, NSAttributedStringKey, + NSCopying, NSMutableAttributedString, NSNotFound, NSNotification, + NSNotificationCenter, NSObject, NSObjectProtocol, NSPoint, NSRange, NSRect, + NSSize, NSString, NSUInteger, +}; + +use super::app_state::ApplicationDelegate; +use super::cursor::{default_cursor, invisible_cursor}; +use super::event::{ + code_to_key, code_to_location, create_key_event, event_mods, lalt_pressed, + ralt_pressed, scancode_to_physicalkey, KeyEventExtra, +}; +use super::monitor::flip_window_screen_coordinates; +use super::observer::RunLoop; +use super::window::{WindowId, WinitWindow}; +use super::DEVICE_ID; +use crate::dpi::{ + LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize, +}; +use crate::event::{ + DeviceEvent, ElementState, Ime, InnerSizeWriter, KeyEvent, Modifiers, + MouseButton, MouseScrollDelta, TouchPhase, WindowEvent, +}; +use crate::keyboard::{Key, KeyCode, KeyLocation, ModifiersState, NamedKey}; +use crate::platform::macos::OptionAsAlt; + +#[derive(Debug)] +struct CursorState { + visible: bool, + cursor: Retained, +} + +impl Default for CursorState { + fn default() -> Self { + Self { + visible: true, + cursor: default_cursor(), + } + } +} + +#[derive(Debug, Eq, PartialEq, Clone, Copy, Default)] +enum ImeState { + #[default] + /// The IME events are disabled, so only `ReceivedCharacter` is being sent to the user. + Disabled, + + /// The ground state of enabled IME input. It means that both Preedit and regular keyboard + /// input could be start from it. + Ground, + + /// The IME is in preedit. + Preedit, + + /// The text was just committed, so the next input from the keyboard must be ignored. + Committed, +} + +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, PartialEq)] + struct ModLocationMask: u8 { + const LEFT = 0b0001; + const RIGHT = 0b0010; + } +} +impl ModLocationMask { + fn from_location(loc: KeyLocation) -> ModLocationMask { + match loc { + KeyLocation::Left => ModLocationMask::LEFT, + KeyLocation::Right => ModLocationMask::RIGHT, + _ => unreachable!(), + } + } +} + +fn key_to_modifier(key: &Key) -> Option { + match key { + Key::Named(NamedKey::Alt) => Some(ModifiersState::ALT), + Key::Named(NamedKey::Control) => Some(ModifiersState::CONTROL), + Key::Named(NamedKey::Super) => Some(ModifiersState::SUPER), + Key::Named(NamedKey::Shift) => Some(ModifiersState::SHIFT), + _ => None, + } +} + +fn get_right_modifier_code(key: &Key) -> KeyCode { + match key { + Key::Named(NamedKey::Alt) => KeyCode::AltRight, + Key::Named(NamedKey::Control) => KeyCode::ControlRight, + Key::Named(NamedKey::Shift) => KeyCode::ShiftRight, + Key::Named(NamedKey::Super) => KeyCode::SuperRight, + _ => unreachable!(), + } +} + +fn get_left_modifier_code(key: &Key) -> KeyCode { + match key { + Key::Named(NamedKey::Alt) => KeyCode::AltLeft, + Key::Named(NamedKey::Control) => KeyCode::ControlLeft, + Key::Named(NamedKey::Shift) => KeyCode::ShiftLeft, + Key::Named(NamedKey::Super) => KeyCode::SuperLeft, + _ => unreachable!(), + } +} + +#[derive(Clone, Copy, Debug)] +struct ForeignHostMetrics { + position: PhysicalPosition, + scale_factor: f64, + inner_size: PhysicalSize, +} + +#[derive(Debug)] +pub struct ViewState { + /// Strong reference to the global application state. + app_delegate: Retained, + + cursor_state: RefCell, + ime_position: Cell, + ime_size: Cell, + modifiers: Cell, + phys_modifiers: RefCell>, + tracking_rect: Cell>, + ime_state: Cell, + input_source: RefCell, + + /// True iff the application wants IME events. + /// + /// Can be set using `set_ime_allowed` + ime_allowed: Cell, + + /// True if the current key event should be forwarded + /// to the application, even during IME + forward_key_to_app: Cell, + + marked_text: RefCell>, + accepts_first_mouse: bool, + + /// Stable winit identity. AppKit may temporarily install this view in a + /// foreign window, but events must keep their original logical WindowId. + event_window_id: WindowId, + /// Weak reference because the donor window normally keeps a strong + /// reference to the view. The platform window state retains the view + /// independently for foreign hosting. + donor_window: WeakId, + foreign_hosted: Cell, + foreign_host_closing: Cell, + previous_scale_factor: Cell, + previous_host_position: Cell>>, + pending_host_metrics: Cell>, + focused: Cell>, + occluded: Cell>, + cursor_hittest: Cell, + host_metrics_scheduled: Cell, + host_metrics_force_scale: Cell, + + /// The state of the `Option` as `Alt`. + option_as_alt: Cell, +} + +declare_class!( + #[derive(Debug)] + pub(super) struct WinitView; + + unsafe impl ClassType for WinitView { + #[inherits(NSResponder, NSObject)] + type Super = NSView; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "WinitView"; + } + + impl DeclaredClass for WinitView { + type Ivars = ViewState; + } + + unsafe impl WinitView { + #[method(isFlipped)] + fn is_flipped(&self) -> bool { + // `winit` uses the upper-left corner as the origin. + true + } + + #[method(viewDidMoveToWindow)] + fn view_did_move_to_window(&self) { + trace_scope!("viewDidMoveToWindow"); + if let Some(tracking_rect) = self.ivars().tracking_rect.take() { + self.removeTrackingRect(tracking_rect); + } + + let rect = self.frame(); + let tracking_rect = unsafe { + self.addTrackingRect_owner_userData_assumeInside(rect, self, ptr::null_mut(), false) + }; + assert_ne!(tracking_rect, 0, "failed adding tracking rect"); + self.ivars().tracking_rect.set(Some(tracking_rect)); + + self.clear_foreign_window_observers(); + let actual_window = self.as_super().window(); + let donor_window = self.donor_window(); + let was_foreign_hosted = self.ivars().foreign_hosted.get(); + + // Reparenting passes through a windowless edge. Preserve the + // previous host mode until AppKit installs the view in its next + // window so the final edge can synchronize donor/foreign state. + if actual_window.is_none() { + self.set_focused(false); + return; + } + + let foreign_hosted = match (&actual_window, &donor_window) { + (Some(actual), Some(donor)) => { + Retained::as_ptr(actual).cast::<()>() + != Retained::as_ptr(donor).cast::<()>() + }, + // If the weak donor unexpectedly disappeared while the view + // still has an actual host, fail closed: external-root + // mutators must remain suppressed. + (Some(_), None) => true, + _ => false, + }; + self.ivars().foreign_hosted.set(foreign_hosted); + self.ivars().foreign_host_closing.set(false); + + if foreign_hosted { + let host = actual_window + .as_deref() + .expect("foreign-hosted WinitView to have an NSWindow"); + self.observe_foreign_window(host); + self.capture_foreign_host_metrics(); + } else if let Some(donor) = donor_window.as_deref() { + // set_cursor_hittest is view-local while foreign-hosted so it + // cannot make the hidden donor affect mpv's root. Reconcile + // the donor flag when the view returns. + donor.setIgnoresMouseEvents( + !self.ivars().cursor_hittest.get(), + ); + } + + if was_foreign_hosted != foreign_hosted { + self.reset_ime_for_host_transition(); + } + + // Initial donor setup retains upstream winit's event ordering. + // Only a real foreign-host transition needs an immediate resync. + if was_foreign_hosted || foreign_hosted { + // Reparenting is initiated from Iced's update path, while + // winit's event handler can still be mutably borrowed. + // Dispatching ScaleFactorChanged synchronously here would + // re-enter that handler and panic, so resynchronize on the + // next main-run-loop turn. + self.schedule_host_metrics(true); + } + } + + #[method(frameDidChange:)] + fn frame_did_change(&self, _event: &NSEvent) { + trace_scope!("frameDidChange:"); + if let Some(tracking_rect) = self.ivars().tracking_rect.take() { + self.removeTrackingRect(tracking_rect); + } + + let rect = self.frame(); + let tracking_rect = unsafe { + self.addTrackingRect_owner_userData_assumeInside(rect, self, ptr::null_mut(), false) + }; + assert_ne!(tracking_rect, 0, "failed adding tracking rect"); + self.ivars().tracking_rect.set(Some(tracking_rect)); + + // Reparenting may resize the view after it has reached the donor + // but before the deferred final foreign move is delivered. A raw + // donor Resized would make Iced adopt the donor scale too early, + // so fold that resize into the ordered host synchronization. + if self.has_detached_pending_host_metrics() { + self.schedule_host_metrics(true); + return; + } + + // Emit resize event here rather than from windowDidResize because: + // 1. When a new window is created as a tab, the frame size may change without a window resize occurring. + // 2. Even when a window resize does occur on a new tabbed window, it contains the wrong size (includes tab height). + let logical_size = LogicalSize::new(rect.size.width as f64, rect.size.height as f64); + let size = logical_size.to_physical::(self.scale_factor()); + self.queue_event(WindowEvent::Resized(size)); + } + + #[method(drawRect:)] + fn draw_rect(&self, _rect: NSRect) { + trace_scope!("drawRect:"); + + // It's a workaround for https://github.com/rust-windowing/winit/issues/2640, don't replace with `self.window_id()`. + self.ivars() + .app_delegate + .handle_redraw(self.ivars().event_window_id); + + // This is a direct subclass of NSView, no need to call superclass' drawRect: + } + + #[method(acceptsFirstResponder)] + fn accepts_first_responder(&self) -> bool { + trace_scope!("acceptsFirstResponder"); + true + } + + // This is necessary to prevent a beefy terminal error on MacBook Pros: + // IMKInputSession [0x7fc573576ff0 presentFunctionRowItemTextInputViewWithEndpoint:completionHandler:] : [self textInputContext]=0x7fc573558e10 *NO* NSRemoteViewController to client, NSError=Error Domain=NSCocoaErrorDomain Code=4099 "The connection from pid 0 was invalidated from this process." UserInfo={NSDebugDescription=The connection from pid 0 was invalidated from this process.}, com.apple.inputmethod.EmojiFunctionRowItem + // TODO: Add an API extension for using `NSTouchBar` + #[method_id(touchBar)] + fn touch_bar(&self) -> Option> { + trace_scope!("touchBar"); + None + } + + #[method(resetCursorRects)] + fn reset_cursor_rects(&self) { + trace_scope!("resetCursorRects"); + let bounds = self.bounds(); + let cursor_state = self.ivars().cursor_state.borrow(); + // We correctly invoke `addCursorRect` only from inside `resetCursorRects` + if cursor_state.visible { + self.addCursorRect_cursor(bounds, &cursor_state.cursor); + } else { + self.addCursorRect_cursor(bounds, &invisible_cursor()); + } + } + + #[method_id(hitTest:)] + fn hit_test(&self, point: NSPoint) -> Option> { + if self.ivars().cursor_hittest.get() { + unsafe { msg_send_id![super(self), hitTest: point] } + } else { + None + } + } + + #[method(winitForeignWindowDidChangeBackingProperties:)] + fn foreign_backing_changed(&self, _notification: &NSNotification) { + trace_scope!("winitForeignWindowDidChangeBackingProperties:"); + self.capture_foreign_host_metrics(); + self.schedule_host_metrics(false); + } + + #[method(winitForeignWindowDidBecomeKey:)] + fn foreign_became_key(&self, _notification: &NSNotification) { + trace_scope!("winitForeignWindowDidBecomeKey:"); + if self.is_foreign_hosted() { + self.set_focused(true); + } + } + + #[method(winitForeignWindowDidResignKey:)] + fn foreign_resigned_key(&self, _notification: &NSNotification) { + trace_scope!("winitForeignWindowDidResignKey:"); + if self.is_foreign_hosted() { + self.set_focused(false); + } + } + + #[method(winitForeignWindowDidChangeOcclusionState:)] + fn foreign_occlusion_changed(&self, _notification: &NSNotification) { + trace_scope!("winitForeignWindowDidChangeOcclusionState:"); + if let Some(host) = self.actual_host_window() { + self.set_occluded( + host.isMiniaturized() + || !host + .occlusionState() + .contains(NSWindowOcclusionState::Visible), + ); + } + } + + #[method(winitForeignWindowMetricsChanged:)] + fn foreign_metrics_changed(&self, _notification: &NSNotification) { + trace_scope!("winitForeignWindowMetricsChanged:"); + // Root changes can be caused by an mpv command dispatched from an + // Iced update. Snapshot the root position while the notification + // still identifies that host, then defer event delivery so an + // AppKit notification cannot re-enter winit's event handler. The + // snapshot survives a detach before the queued callback runs. + self.capture_foreign_host_metrics(); + self.schedule_host_metrics(false); + } + + #[method(winitForeignWindowWillClose:)] + fn foreign_will_close(&self, _notification: &NSNotification) { + trace_scope!("winitForeignWindowWillClose:"); + if !self.is_foreign_hosted() + || self.ivars().foreign_host_closing.replace(true) + { + return; + } + + self.capture_foreign_host_metrics(); + self.clear_foreign_window_observers(); + self.reset_modifiers(); + self.reset_ime_for_host_transition(); + + // The externally owned root is closing. Move the retained view + // back to its hidden donor immediately so neither AppKit nor the + // renderer can retain an orphan under the closing mpv window. + unsafe { self.removeFromSuperview() }; + if let Some(donor) = self.donor_window() { + donor.setContentView(Some(self)); + } else { + self.ivars().foreign_hosted.set(false); + self.set_focused(false); + } + + // Application code may synchronously react to CloseRequested. + // Publish it only after the renderer view is no longer retained + // beneath the external window that is closing. + self.queue_event(WindowEvent::CloseRequested); + } + } + + unsafe impl NSTextInputClient for WinitView { + #[method(hasMarkedText)] + fn has_marked_text(&self) -> bool { + trace_scope!("hasMarkedText"); + self.ivars().marked_text.borrow().length() > 0 + } + + #[method(markedRange)] + fn marked_range(&self) -> NSRange { + trace_scope!("markedRange"); + let length = self.ivars().marked_text.borrow().length(); + if length > 0 { + NSRange::new(0, length) + } else { + // Documented to return `{NSNotFound, 0}` if there is no marked range. + NSRange::new(NSNotFound as NSUInteger, 0) + } + } + + #[method(selectedRange)] + fn selected_range(&self) -> NSRange { + trace_scope!("selectedRange"); + // Documented to return `{NSNotFound, 0}` if there is no selection. + NSRange::new(NSNotFound as NSUInteger, 0) + } + + #[method(setMarkedText:selectedRange:replacementRange:)] + fn set_marked_text( + &self, + string: &NSObject, + selected_range: NSRange, + _replacement_range: NSRange, + ) { + // TODO: Use _replacement_range, requires changing the event to report surrounding text. + trace_scope!("setMarkedText:selectedRange:replacementRange:"); + + // SAFETY: This method is guaranteed to get either a `NSString` or a `NSAttributedString`. + let (marked_text, string) = if string.is_kind_of::() { + let string: *const NSObject = string; + let string: *const NSAttributedString = string.cast(); + let string = unsafe { &*string }; + ( + NSMutableAttributedString::from_attributed_nsstring(string), + string.string(), + ) + } else { + let string: *const NSObject = string; + let string: *const NSString = string.cast(); + let string = unsafe { &*string }; + ( + NSMutableAttributedString::from_nsstring(string), + string.copy(), + ) + }; + + // Update marked text. + *self.ivars().marked_text.borrow_mut() = marked_text; + + // Notify IME is active if application still doesn't know it. + if self.ivars().ime_state.get() == ImeState::Disabled { + *self.ivars().input_source.borrow_mut() = self.current_input_source(); + self.queue_event(WindowEvent::Ime(Ime::Enabled)); + } + + if unsafe { self.hasMarkedText() } { + self.ivars().ime_state.set(ImeState::Preedit); + } else { + // In case the preedit was cleared, set IME into the Ground state. + self.ivars().ime_state.set(ImeState::Ground); + } + + let cursor_range = if string.is_empty() { + // An empty string basically means that there's no preedit, so indicate that by + // sending a `None` cursor range. + None + } else { + // Clamp to string length to avoid NSRangeException from out-of-bounds + // indices sent by macOS IME (e.g. native Pinyin, see + // https://github.com/alacritty/alacritty/issues/8791). + let len = string.length(); + let location = selected_range.location.min(len); + let end = selected_range.end().min(len); + // Convert the selected range from UTF-16 indices to UTF-8 indices. + let sub_string_a = unsafe { string.substringToIndex(location) }; + let sub_string_b = unsafe { string.substringToIndex(end) }; + let lowerbound_utf8 = sub_string_a.len(); + let upperbound_utf8 = sub_string_b.len(); + Some((lowerbound_utf8, upperbound_utf8)) + }; + + // Send WindowEvent for updating marked text + self.queue_event(WindowEvent::Ime(Ime::Preedit(string.to_string(), cursor_range))); + } + + #[method(unmarkText)] + fn unmark_text(&self) { + trace_scope!("unmarkText"); + *self.ivars().marked_text.borrow_mut() = NSMutableAttributedString::new(); + + let input_context = self.inputContext().expect("input context"); + input_context.discardMarkedText(); + + self.queue_event(WindowEvent::Ime(Ime::Preedit(String::new(), None))); + if self.is_ime_enabled() { + // Leave the Preedit self.ivars() + self.ivars().ime_state.set(ImeState::Ground); + } else { + tracing::warn!("Expected to have IME enabled when receiving unmarkText"); + } + } + + #[method_id(validAttributesForMarkedText)] + fn valid_attributes_for_marked_text(&self) -> Retained> { + trace_scope!("validAttributesForMarkedText"); + NSArray::new() + } + + #[method_id(attributedSubstringForProposedRange:actualRange:)] + fn attributed_substring_for_proposed_range( + &self, + _range: NSRange, + _actual_range: *mut NSRange, + ) -> Option> { + trace_scope!("attributedSubstringForProposedRange:actualRange:"); + None + } + + #[method(characterIndexForPoint:)] + fn character_index_for_point(&self, _point: NSPoint) -> NSUInteger { + trace_scope!("characterIndexForPoint:"); + 0 + } + + #[method(firstRectForCharacterRange:actualRange:)] + fn first_rect_for_character_range( + &self, + _range: NSRange, + _actual_range: *mut NSRange, + ) -> NSRect { + trace_scope!("firstRectForCharacterRange:actualRange:"); + let rect = NSRect::new( + self.ivars().ime_position.get(), + self.ivars().ime_size.get() + ); + // Return value is expected to be in screen coordinates, so we need a conversion here + self.host_window() + .convertRectToScreen(self.convertRect_toView(rect, None)) + } + + #[method(insertText:replacementRange:)] + fn insert_text(&self, string: &NSObject, _replacement_range: NSRange) { + // TODO: Use _replacement_range, requires changing the event to report surrounding text. + trace_scope!("insertText:replacementRange:"); + + // SAFETY: This method is guaranteed to get either a `NSString` or a `NSAttributedString`. + let string = if string.is_kind_of::() { + let string: *const NSObject = string; + let string: *const NSAttributedString = string.cast(); + unsafe { &*string }.string().to_string() + } else { + let string: *const NSObject = string; + let string: *const NSString = string.cast(); + unsafe { &*string }.to_string() + }; + + let is_control = string.chars().next().is_some_and(|c| c.is_control()); + + // Commit only if we have marked text. + if unsafe { self.hasMarkedText() } && self.is_ime_enabled() && !is_control { + self.queue_event(WindowEvent::Ime(Ime::Preedit(String::new(), None))); + self.queue_event(WindowEvent::Ime(Ime::Commit(string))); + self.ivars().ime_state.set(ImeState::Committed); + } + } + + // Basically, we're sent this message whenever a keyboard event that doesn't generate a "human + // readable" character happens, i.e. newlines, tabs, and Ctrl+C. + #[method(doCommandBySelector:)] + fn do_command_by_selector(&self, _command: Sel) { + trace_scope!("doCommandBySelector:"); + // We shouldn't forward any character from just committed text, since we'll end up sending + // it twice with some IMEs like Korean one. We'll also always send `Enter` in that case, + // which is not desired given it was used to confirm IME input. + if self.ivars().ime_state.get() == ImeState::Committed { + return; + } + + self.ivars().forward_key_to_app.set(true); + + if unsafe { self.hasMarkedText() } && self.ivars().ime_state.get() == ImeState::Preedit + { + // Leave preedit so that we also report the key-up for this key. + self.ivars().ime_state.set(ImeState::Ground); + } + } + } + + unsafe impl WinitView { + #[method(keyDown:)] + fn key_down(&self, event: &NSEvent) { + trace_scope!("keyDown:"); + { + let mut prev_input_source = self.ivars().input_source.borrow_mut(); + let current_input_source = self.current_input_source(); + if *prev_input_source != current_input_source && self.is_ime_enabled() { + *prev_input_source = current_input_source; + drop(prev_input_source); + self.ivars().ime_state.set(ImeState::Disabled); + self.queue_event(WindowEvent::Ime(Ime::Disabled)); + } + } + + // Get the characters from the event. + let old_ime_state = self.ivars().ime_state.get(); + self.ivars().forward_key_to_app.set(false); + let event = replace_event(event, self.option_as_alt()); + + // The `interpretKeyEvents` function might call + // `setMarkedText`, `insertText`, and `doCommandBySelector`. + // It's important that we call this before queuing the KeyboardInput, because + // we must send the `KeyboardInput` event during IME if it triggered + // `doCommandBySelector`. (doCommandBySelector means that the keyboard input + // is not handled by IME and should be handled by the application) + if self.ivars().ime_allowed.get() { + let events_for_nsview = NSArray::from_slice(&[&*event]); + unsafe { self.interpretKeyEvents(&events_for_nsview) }; + + // If the text was committed we must treat the next keyboard event as IME related. + if self.ivars().ime_state.get() == ImeState::Committed { + // Remove any marked text, so normal input can continue. + *self.ivars().marked_text.borrow_mut() = NSMutableAttributedString::new(); + } + } + + self.update_modifiers(&event, false); + + let had_ime_input = match self.ivars().ime_state.get() { + ImeState::Committed => { + // Allow normal input after the commit. + self.ivars().ime_state.set(ImeState::Ground); + true + } + ImeState::Preedit => true, + // `key_down` could result in preedit clear, so compare old and current state. + _ => old_ime_state != self.ivars().ime_state.get(), + }; + + if !had_ime_input || self.ivars().forward_key_to_app.get() { + let key_event = create_key_event(&event, true, unsafe { event.isARepeat() }); + self.queue_event(WindowEvent::KeyboardInput { + device_id: DEVICE_ID, + event: key_event, + is_synthetic: false, + }); + } + } + + #[method(keyUp:)] + fn key_up(&self, event: &NSEvent) { + trace_scope!("keyUp:"); + + let event = replace_event(event, self.option_as_alt()); + self.update_modifiers(&event, false); + + // We want to send keyboard input when we are currently in the ground state. + if matches!( + self.ivars().ime_state.get(), + ImeState::Ground | ImeState::Disabled + ) { + self.queue_event(WindowEvent::KeyboardInput { + device_id: DEVICE_ID, + event: create_key_event(&event, false, false), + is_synthetic: false, + }); + } + } + + #[method(flagsChanged:)] + fn flags_changed(&self, event: &NSEvent) { + trace_scope!("flagsChanged:"); + + self.update_modifiers(event, true); + } + + #[method(insertTab:)] + fn insert_tab(&self, _sender: Option<&AnyObject>) { + trace_scope!("insertTab:"); + let window = self.host_window(); + if let Some(first_responder) = window.firstResponder() { + if *first_responder == ***self { + window.selectNextKeyView(Some(self)) + } + } + } + + #[method(insertBackTab:)] + fn insert_back_tab(&self, _sender: Option<&AnyObject>) { + trace_scope!("insertBackTab:"); + let window = self.host_window(); + if let Some(first_responder) = window.firstResponder() { + if *first_responder == ***self { + window.selectPreviousKeyView(Some(self)) + } + } + } + + // Allows us to receive Cmd-. (the shortcut for closing a dialog) + // https://bugs.eclipse.org/bugs/show_bug.cgi?id=300620#c6 + #[method(cancelOperation:)] + fn cancel_operation(&self, _sender: Option<&AnyObject>) { + let mtm = MainThreadMarker::from(self); + trace_scope!("cancelOperation:"); + + let event = NSApplication::sharedApplication(mtm) + .currentEvent() + .expect("could not find current event"); + + self.update_modifiers(&event, false); + let event = create_key_event(&event, true, unsafe { event.isARepeat() }); + + self.queue_event(WindowEvent::KeyboardInput { + device_id: DEVICE_ID, + event, + is_synthetic: false, + }); + } + + // In the past (?), `mouseMoved:` events were not generated when the + // user hovered over a window from a separate window, and as such the + // application might not know the location of the mouse in the event. + // + // To fix this, we emit `mouse_motion` inside of mouse click, mouse + // scroll, magnify and other gesture event handlers, to ensure that + // the application's state of where the mouse click was located is up + // to date. + // + // See https://github.com/rust-windowing/winit/pull/1490 for history. + + #[method(mouseDown:)] + fn mouse_down(&self, event: &NSEvent) { + trace_scope!("mouseDown:"); + self.mouse_motion(event); + self.mouse_click(event, ElementState::Pressed); + } + + #[method(mouseUp:)] + fn mouse_up(&self, event: &NSEvent) { + trace_scope!("mouseUp:"); + self.mouse_motion(event); + self.mouse_click(event, ElementState::Released); + } + + #[method(rightMouseDown:)] + fn right_mouse_down(&self, event: &NSEvent) { + trace_scope!("rightMouseDown:"); + self.mouse_motion(event); + self.mouse_click(event, ElementState::Pressed); + } + + #[method(rightMouseUp:)] + fn right_mouse_up(&self, event: &NSEvent) { + trace_scope!("rightMouseUp:"); + self.mouse_motion(event); + self.mouse_click(event, ElementState::Released); + } + + #[method(otherMouseDown:)] + fn other_mouse_down(&self, event: &NSEvent) { + trace_scope!("otherMouseDown:"); + self.mouse_motion(event); + self.mouse_click(event, ElementState::Pressed); + } + + #[method(otherMouseUp:)] + fn other_mouse_up(&self, event: &NSEvent) { + trace_scope!("otherMouseUp:"); + self.mouse_motion(event); + self.mouse_click(event, ElementState::Released); + } + + // No tracing on these because that would be overly verbose + + #[method(mouseMoved:)] + fn mouse_moved(&self, event: &NSEvent) { + self.mouse_motion(event); + } + + #[method(mouseDragged:)] + fn mouse_dragged(&self, event: &NSEvent) { + self.mouse_motion(event); + } + + #[method(rightMouseDragged:)] + fn right_mouse_dragged(&self, event: &NSEvent) { + self.mouse_motion(event); + } + + #[method(otherMouseDragged:)] + fn other_mouse_dragged(&self, event: &NSEvent) { + self.mouse_motion(event); + } + + #[method(mouseEntered:)] + fn mouse_entered(&self, _event: &NSEvent) { + trace_scope!("mouseEntered:"); + self.queue_event(WindowEvent::CursorEntered { + device_id: DEVICE_ID, + }); + } + + #[method(mouseExited:)] + fn mouse_exited(&self, _event: &NSEvent) { + trace_scope!("mouseExited:"); + + self.queue_event(WindowEvent::CursorLeft { + device_id: DEVICE_ID, + }); + } + + #[method(scrollWheel:)] + fn scroll_wheel(&self, event: &NSEvent) { + trace_scope!("scrollWheel:"); + + self.mouse_motion(event); + + let delta = { + let (x, y) = unsafe { (event.scrollingDeltaX(), event.scrollingDeltaY()) }; + if unsafe { event.hasPreciseScrollingDeltas() } { + let delta = LogicalPosition::new(x, y).to_physical(self.scale_factor()); + MouseScrollDelta::PixelDelta(delta) + } else { + MouseScrollDelta::LineDelta(x as f32, y as f32) + } + }; + + // The "momentum phase," if any, has higher priority than touch phase (the two should + // be mutually exclusive anyhow, which is why the API is rather incoherent). If no momentum + // phase is recorded (or rather, the started/ended cases of the momentum phase) then we + // report the touch phase. + #[allow(non_upper_case_globals)] + let phase = match unsafe { event.momentumPhase() } { + NSEventPhase::MayBegin | NSEventPhase::Began => TouchPhase::Started, + NSEventPhase::Ended | NSEventPhase::Cancelled => TouchPhase::Ended, + _ => match unsafe { event.phase() } { + NSEventPhase::MayBegin | NSEventPhase::Began => TouchPhase::Started, + NSEventPhase::Ended | NSEventPhase::Cancelled => TouchPhase::Ended, + _ => TouchPhase::Moved, + }, + }; + + self.update_modifiers(event, false); + + self.ivars().app_delegate.maybe_queue_device_event(DeviceEvent::MouseWheel { delta }); + self.queue_event(WindowEvent::MouseWheel { + device_id: DEVICE_ID, + delta, + phase, + }); + } + + #[method(magnifyWithEvent:)] + fn magnify_with_event(&self, event: &NSEvent) { + trace_scope!("magnifyWithEvent:"); + + self.mouse_motion(event); + + #[allow(non_upper_case_globals)] + let phase = match unsafe { event.phase() } { + NSEventPhase::Began => TouchPhase::Started, + NSEventPhase::Changed => TouchPhase::Moved, + NSEventPhase::Cancelled => TouchPhase::Cancelled, + NSEventPhase::Ended => TouchPhase::Ended, + _ => return, + }; + + self.queue_event(WindowEvent::PinchGesture { + device_id: DEVICE_ID, + delta: unsafe { event.magnification() }, + phase, + }); + } + + #[method(smartMagnifyWithEvent:)] + fn smart_magnify_with_event(&self, event: &NSEvent) { + trace_scope!("smartMagnifyWithEvent:"); + + self.mouse_motion(event); + + self.queue_event(WindowEvent::DoubleTapGesture { + device_id: DEVICE_ID, + }); + } + + #[method(rotateWithEvent:)] + fn rotate_with_event(&self, event: &NSEvent) { + trace_scope!("rotateWithEvent:"); + + self.mouse_motion(event); + + #[allow(non_upper_case_globals)] + let phase = match unsafe { event.phase() } { + NSEventPhase::Began => TouchPhase::Started, + NSEventPhase::Changed => TouchPhase::Moved, + NSEventPhase::Cancelled => TouchPhase::Cancelled, + NSEventPhase::Ended => TouchPhase::Ended, + _ => return, + }; + + self.queue_event(WindowEvent::RotationGesture { + device_id: DEVICE_ID, + delta: unsafe { event.rotation() }, + phase, + }); + } + + #[method(pressureChangeWithEvent:)] + fn pressure_change_with_event(&self, event: &NSEvent) { + trace_scope!("pressureChangeWithEvent:"); + + self.queue_event(WindowEvent::TouchpadPressure { + device_id: DEVICE_ID, + pressure: unsafe { event.pressure() }, + stage: unsafe { event.stage() } as i64, + }); + } + + // Allows us to receive Ctrl-Tab and Ctrl-Esc. + // Note that this *doesn't* help with any missing Cmd inputs. + // https://github.com/chromium/chromium/blob/a86a8a6bcfa438fa3ac2eba6f02b3ad1f8e0756f/ui/views/cocoa/bridged_content_view.mm#L816 + #[method(_wantsKeyDownForEvent:)] + fn wants_key_down_for_event(&self, _event: &NSEvent) -> bool { + trace_scope!("_wantsKeyDownForEvent:"); + true + } + + #[method(acceptsFirstMouse:)] + fn accepts_first_mouse(&self, _event: &NSEvent) -> bool { + trace_scope!("acceptsFirstMouse:"); + self.ivars().accepts_first_mouse + } + } +); + +impl WinitView { + pub(super) fn new( + app_delegate: &ApplicationDelegate, + window: &WinitWindow, + accepts_first_mouse: bool, + option_as_alt: OptionAsAlt, + ) -> Retained { + let mtm = MainThreadMarker::from(window); + let this = + mtm.alloc().set_ivars(ViewState { + app_delegate: app_delegate.retain(), + cursor_state: Default::default(), + ime_position: Default::default(), + ime_size: Default::default(), + modifiers: Default::default(), + phys_modifiers: Default::default(), + tracking_rect: Default::default(), + ime_state: Default::default(), + input_source: Default::default(), + ime_allowed: Default::default(), + forward_key_to_app: Default::default(), + marked_text: Default::default(), + accepts_first_mouse, + event_window_id: window.id(), + donor_window: WeakId::new(&window.retain()), + foreign_hosted: Cell::new(false), + foreign_host_closing: Cell::new(false), + previous_scale_factor: Cell::new( + window.backingScaleFactor() as f64 + ), + previous_host_position: Cell::new(None), + pending_host_metrics: Cell::new(None), + focused: Cell::new(None), + occluded: Cell::new(None), + cursor_hittest: Cell::new(true), + host_metrics_scheduled: Cell::new(false), + host_metrics_force_scale: Cell::new(false), + option_as_alt: Cell::new(option_as_alt), + }); + let this: Retained = unsafe { msg_send_id![super(this), init] }; + + this.setPostsFrameChangedNotifications(true); + let notification_center = + unsafe { NSNotificationCenter::defaultCenter() }; + unsafe { + notification_center.addObserver_selector_name_object( + &this, + sel!(frameDidChange:), + Some(NSViewFrameDidChangeNotification), + Some(&this), + ) + } + + *this.ivars().input_source.borrow_mut() = this.current_input_source(); + + this + } + + pub(super) fn donor_window(&self) -> Option> { + self.ivars().donor_window.load() + } + + fn actual_host_window(&self) -> Option> { + self.as_super().window() + } + + pub(super) fn host_window(&self) -> Retained { + self.actual_host_window() + .or_else(|| self.donor_window().map(Retained::into_super)) + .expect("WinitView to have an actual host or retained donor window") + } + + pub(super) fn is_foreign_hosted(&self) -> bool { + self.ivars().foreign_hosted.get() + } + + fn queue_event(&self, event: WindowEvent) { + self.ivars() + .app_delegate + .maybe_queue_window_event(self.ivars().event_window_id, event); + } + + pub(super) fn set_focused(&self, focused: bool) { + if self.ivars().focused.replace(Some(focused)) == Some(focused) { + return; + } + if !focused { + self.reset_modifiers(); + } + self.queue_event(WindowEvent::Focused(focused)); + } + + pub(super) fn set_occluded(&self, occluded: bool) { + if self.ivars().occluded.replace(Some(occluded)) == Some(occluded) { + return; + } + self.queue_event(WindowEvent::Occluded(occluded)); + } + + fn scale_factor(&self) -> f64 { + self.host_window().backingScaleFactor() as f64 + } + + fn emit_resized(&self) { + let bounds = self.bounds(); + let logical_size = LogicalSize::new( + bounds.size.width as f64, + bounds.size.height as f64, + ); + self.queue_event(WindowEvent::Resized( + logical_size.to_physical::(self.scale_factor()), + )); + } + + fn foreign_host_metrics(&self) -> Option { + if !self.is_foreign_hosted() { + return None; + } + let host = self.actual_host_window()?; + let scale_factor = host.backingScaleFactor() as f64; + let position = flip_window_screen_coordinates(host.frame()); + let position = LogicalPosition::new(position.x, position.y) + .to_physical(scale_factor); + let bounds = self.bounds(); + let inner_size = LogicalSize::new( + bounds.size.width as f64, + bounds.size.height as f64, + ) + .to_physical(scale_factor); + Some(ForeignHostMetrics { + position, + scale_factor, + inner_size, + }) + } + + fn capture_foreign_host_metrics(&self) { + if let Some(metrics) = self.foreign_host_metrics() { + self.ivars().pending_host_metrics.set(Some(metrics)); + } + } + + fn emit_moved_position(&self, position: PhysicalPosition) { + if self.ivars().previous_host_position.replace(Some(position)) + == Some(position) + { + return; + } + self.queue_event(WindowEvent::Moved(position)); + } + + fn has_detached_pending_host_metrics(&self) -> bool { + self.ivars().pending_host_metrics.get().is_some() + && (!self.is_foreign_hosted() + || self.actual_host_window().is_none()) + } + + fn emit_pending_host_moved(&self) -> bool { + let Some(metrics) = self.ivars().pending_host_metrics.take() else { + return false; + }; + self.emit_moved_position(metrics.position); + true + } + + fn emit_captured_host_scale_factor_changed( + &self, + metrics: ForeignHostMetrics, + ) { + if metrics.scale_factor == self.ivars().previous_scale_factor.get() { + return; + } + self.ivars().previous_scale_factor.set(metrics.scale_factor); + + let captured_inner_size = Arc::new(Mutex::new(metrics.inner_size)); + self.ivars().app_delegate.handle_window_event( + self.ivars().event_window_id, + WindowEvent::ScaleFactorChanged { + scale_factor: metrics.scale_factor, + inner_size_writer: InnerSizeWriter::new(Arc::downgrade( + &captured_inner_size, + )), + }, + ); + // The captured foreign root is already detached and was the sole + // geometry authority. Keep the writer alive through dispatch, but + // never apply its request to the donor. Do not publish a captured + // Resized here: Iced resolves that event against the raw window's + // current (donor) scale, which would undo the captured scale before + // the pending Moved is converted. Normal donor reconciliation below + // publishes the next authoritative resize. + drop(captured_inner_size); + } + + fn emit_detached_pending_host_moved(&self) -> bool { + let Some(metrics) = self.ivars().pending_host_metrics.take() else { + return false; + }; + self.emit_captured_host_scale_factor_changed(metrics); + self.emit_moved_position(metrics.position); + true + } + + fn emit_host_moved(&self) { + if self.emit_pending_host_moved() { + return; + } + let metrics = self.foreign_host_metrics(); + let Some(metrics) = metrics else { + return; + }; + self.emit_moved_position(metrics.position); + } + + pub(super) fn handle_scale_factor_changed(&self, force: bool) -> bool { + let scale_factor = self.scale_factor(); + if !force && scale_factor == self.ivars().previous_scale_factor.get() { + return false; + } + self.ivars().previous_scale_factor.set(scale_factor); + + let bounds = self.bounds(); + let logical_size = LogicalSize::new( + bounds.size.width as f64, + bounds.size.height as f64, + ); + let suggested_size = logical_size.to_physical::(scale_factor); + let new_inner_size = Arc::new(Mutex::new(suggested_size)); + self.ivars().app_delegate.handle_window_event( + self.ivars().event_window_id, + WindowEvent::ScaleFactorChanged { + scale_factor, + inner_size_writer: InnerSizeWriter::new(Arc::downgrade( + &new_inner_size, + )), + }, + ); + let requested_size = *new_inner_size.lock().unwrap(); + drop(new_inner_size); + + let actual_size = if !self.is_foreign_hosted() + && requested_size != suggested_size + { + if let Some(donor) = self.donor_window() { + let logical = requested_size.to_logical::(scale_factor); + donor + .setContentSize(NSSize::new(logical.width, logical.height)); + requested_size + } else { + suggested_size + } + } else { + // The external mpv root is the sole geometry authority. Ignore an + // InnerSizeWriter request while hosted and report actual view size. + suggested_size + }; + self.ivars().app_delegate.handle_window_event( + self.ivars().event_window_id, + WindowEvent::Resized(actual_size), + ); + true + } + + fn schedule_host_metrics(&self, force_scale: bool) { + if force_scale { + self.ivars().host_metrics_force_scale.set(true); + } + if self.ivars().host_metrics_scheduled.replace(true) { + return; + } + + let mtm = MainThreadMarker::from(self); + let this = self.retain(); + RunLoop::main(mtm).queue_closure(move || { + this.ivars().host_metrics_scheduled.set(false); + let force_scale = + this.ivars().host_metrics_force_scale.replace(false); + this.synchronize_host_state(force_scale); + }); + } + + fn synchronize_host_state(&self, force_scale: bool) { + // A detached view must publish the captured foreign-root position + // before any donor ScaleFactorChanged event. Iced converts Moved from + // physical coordinates using its current scale, so the detached + // helper first replays a coalesced captured foreign scale when needed. + if self.has_detached_pending_host_metrics() { + self.emit_detached_pending_host_moved(); + } + let Some(host) = self.actual_host_window() else { + // A pending foreign-root move is independent of the view's + // current host. Deliver it even if detach completed before this + // deferred callback. + self.emit_detached_pending_host_moved(); + return; + }; + self.set_focused(host.isKeyWindow()); + self.set_occluded( + host.isMiniaturized() + || !host + .occlusionState() + .contains(NSWindowOcclusionState::Visible), + ); + if !self.handle_scale_factor_changed(force_scale) { + self.emit_resized(); + } + self.emit_host_moved(); + host.invalidateCursorRectsForView(self); + self.ivars() + .app_delegate + .queue_redraw(self.ivars().event_window_id); + } + + fn observe_foreign_window(&self, host: &NSWindow) { + let center = unsafe { NSNotificationCenter::defaultCenter() }; + unsafe { + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowDidBecomeKey:), + Some(NSWindowDidBecomeKeyNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowDidResignKey:), + Some(NSWindowDidResignKeyNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowDidChangeBackingProperties:), + Some(NSWindowDidChangeBackingPropertiesNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowDidChangeOcclusionState:), + Some(NSWindowDidChangeOcclusionStateNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowMetricsChanged:), + Some(NSWindowDidResizeNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowMetricsChanged:), + Some(NSWindowDidMoveNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowMetricsChanged:), + Some(NSWindowDidChangeScreenNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowMetricsChanged:), + Some(NSWindowDidMiniaturizeNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowMetricsChanged:), + Some(NSWindowDidDeminiaturizeNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowMetricsChanged:), + Some(NSWindowDidEnterFullScreenNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowMetricsChanged:), + Some(NSWindowDidExitFullScreenNotification), + Some(host), + ); + center.addObserver_selector_name_object( + self, + sel!(winitForeignWindowWillClose:), + Some(NSWindowWillCloseNotification), + Some(host), + ); + } + } + + fn clear_foreign_window_observers(&self) { + let center = unsafe { NSNotificationCenter::defaultCenter() }; + unsafe { + center.removeObserver_name_object( + self, + Some(NSWindowDidBecomeKeyNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidResignKeyNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidChangeBackingPropertiesNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidChangeOcclusionStateNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidResizeNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidMoveNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidChangeScreenNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidMiniaturizeNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidDeminiaturizeNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidEnterFullScreenNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowDidExitFullScreenNotification), + None, + ); + center.removeObserver_name_object( + self, + Some(NSWindowWillCloseNotification), + None, + ); + } + } + + fn reset_ime_for_host_transition(&self) { + *self.ivars().marked_text.borrow_mut() = + NSMutableAttributedString::new(); + if let Some(input_context) = self.inputContext() { + input_context.discardMarkedText(); + } + if self.ivars().ime_state.replace(ImeState::Disabled) + != ImeState::Disabled + { + self.queue_event(WindowEvent::Ime(Ime::Disabled)); + } + } + + pub(super) fn set_cursor_hittest(&self, hittest: bool) { + self.ivars().cursor_hittest.set(hittest); + } + + pub(super) fn prepare_for_donor_close(&self) { + self.capture_foreign_host_metrics(); + self.clear_foreign_window_observers(); + if self.is_foreign_hosted() { + unsafe { self.removeFromSuperview() }; + } + self.ivars().foreign_hosted.set(false); + self.ivars().foreign_host_closing.set(false); + self.set_focused(false); + self.reset_ime_for_host_transition(); + } + + fn is_ime_enabled(&self) -> bool { + !matches!(self.ivars().ime_state.get(), ImeState::Disabled) + } + + fn current_input_source(&self) -> String { + self.inputContext() + .expect("input context") + .selectedKeyboardInputSource() + .map(|input_source| input_source.to_string()) + .unwrap_or_default() + } + + pub(super) fn cursor_icon(&self) -> Retained { + self.ivars().cursor_state.borrow().cursor.clone() + } + + pub(super) fn set_cursor_icon(&self, icon: Retained) { + let mut cursor_state = self.ivars().cursor_state.borrow_mut(); + cursor_state.cursor = icon; + } + + /// Set whether the cursor should be visible or not. + /// + /// Returns whether the state changed. + pub(super) fn set_cursor_visible(&self, visible: bool) -> bool { + let mut cursor_state = self.ivars().cursor_state.borrow_mut(); + if visible != cursor_state.visible { + cursor_state.visible = visible; + true + } else { + false + } + } + + pub(super) fn set_ime_allowed(&self, ime_allowed: bool) { + if self.ivars().ime_allowed.get() == ime_allowed { + return; + } + self.ivars().ime_allowed.set(ime_allowed); + if self.ivars().ime_allowed.get() { + return; + } + + // Clear markedText + *self.ivars().marked_text.borrow_mut() = + NSMutableAttributedString::new(); + + if self.ivars().ime_state.get() != ImeState::Disabled { + self.ivars().ime_state.set(ImeState::Disabled); + self.queue_event(WindowEvent::Ime(Ime::Disabled)); + } + } + + pub(super) fn set_ime_cursor_area(&self, position: NSPoint, size: NSSize) { + self.ivars().ime_position.set(position); + self.ivars().ime_size.set(size); + let input_context = self.inputContext().expect("input context"); + input_context.invalidateCharacterCoordinates(); + } + + /// Reset modifiers and emit a synthetic ModifiersChanged event if deemed necessary. + pub(super) fn reset_modifiers(&self) { + if !self.ivars().modifiers.get().state().is_empty() { + self.ivars().modifiers.set(Modifiers::default()); + self.queue_event(WindowEvent::ModifiersChanged( + self.ivars().modifiers.get(), + )); + } + } + + pub(super) fn set_option_as_alt(&self, value: OptionAsAlt) { + self.ivars().option_as_alt.set(value) + } + + pub(super) fn option_as_alt(&self) -> OptionAsAlt { + self.ivars().option_as_alt.get() + } + + /// Update modifiers if `event` has something different + fn update_modifiers( + &self, + ns_event: &NSEvent, + is_flags_changed_event: bool, + ) { + use ElementState::{Pressed, Released}; + + let current_modifiers = event_mods(ns_event); + let prev_modifiers = self.ivars().modifiers.get(); + self.ivars().modifiers.set(current_modifiers); + + // This function was called form the flagsChanged event, which is triggered + // when the user presses/releases a modifier even if the same kind of modifier + // has already been pressed. + // + // When flags changed event has key code of zero it means that event doesn't carry any key + // event, thus we can't generate regular presses based on that. The `ModifiersChanged` + // later will work though, since the flags are attached to the event and contain valid + // information. + 'send_event: { + if is_flags_changed_event && unsafe { ns_event.keyCode() } != 0 { + let scancode = unsafe { ns_event.keyCode() }; + let physical_key = scancode_to_physicalkey(scancode as u32); + + let logical_key = code_to_key(physical_key, scancode); + // Ignore processing of unknown modifiers because we can't determine whether + // it was pressed or release reliably. + // + // Furthermore, sometimes normal keys are reported inside flagsChanged:, such as + // when holding Caps Lock while pressing another key, see: + // https://github.com/alacritty/alacritty/issues/8268 + let Some(event_modifier) = key_to_modifier(&logical_key) else { + break 'send_event; + }; + + let mut event = KeyEvent { + location: code_to_location(physical_key), + logical_key: logical_key.clone(), + physical_key, + repeat: false, + // We'll correct this later. + state: Pressed, + text: None, + platform_specific: KeyEventExtra { + text_with_all_modifiers: None, + key_without_modifiers: logical_key.clone(), + }, + }; + + let location_mask = + ModLocationMask::from_location(event.location); + + let mut phys_mod_state = + self.ivars().phys_modifiers.borrow_mut(); + let phys_mod = phys_mod_state + .entry(logical_key) + .or_insert(ModLocationMask::empty()); + + let is_active = + current_modifiers.state().contains(event_modifier); + let mut events = VecDeque::with_capacity(2); + + // There is no API for getting whether the button was pressed or released + // during this event. For this reason we have to do a bit of magic below + // to come up with a good guess whether this key was pressed or released. + // (This is not trivial because there are multiple buttons that may affect + // the same modifier) + if !is_active { + event.state = Released; + if phys_mod.contains(ModLocationMask::LEFT) { + let mut event = event.clone(); + event.location = KeyLocation::Left; + event.physical_key = + get_left_modifier_code(&event.logical_key).into(); + events.push_back(WindowEvent::KeyboardInput { + device_id: DEVICE_ID, + event, + is_synthetic: false, + }); + } + if phys_mod.contains(ModLocationMask::RIGHT) { + event.location = KeyLocation::Right; + event.physical_key = + get_right_modifier_code(&event.logical_key).into(); + events.push_back(WindowEvent::KeyboardInput { + device_id: DEVICE_ID, + event, + is_synthetic: false, + }); + } + *phys_mod = ModLocationMask::empty(); + } else { + if *phys_mod == location_mask { + // Here we hit a contradiction: + // The modifier state was "changed" to active, + // yet the only pressed modifier key was the one that we + // just got a change event for. + // This seemingly means that the only pressed modifier is now released, + // but at the same time the modifier became active. + // + // But this scenario is possible if we released modifiers + // while the application was not in focus. (Because we don't + // get informed of modifier key events while the application + // is not focused) + + // In this case we prioritize the information + // about the current modifier state which means + // that the button was pressed. + event.state = Pressed; + } else { + phys_mod.toggle(location_mask); + let is_pressed = phys_mod.contains(location_mask); + event.state = + if is_pressed { Pressed } else { Released }; + } + + events.push_back(WindowEvent::KeyboardInput { + device_id: DEVICE_ID, + event, + is_synthetic: false, + }); + } + + drop(phys_mod_state); + + for event in events { + self.queue_event(event); + } + } + } + + if prev_modifiers == current_modifiers { + return; + } + + self.queue_event(WindowEvent::ModifiersChanged( + self.ivars().modifiers.get(), + )); + } + + fn mouse_click(&self, event: &NSEvent, button_state: ElementState) { + let button = mouse_button(event); + + self.update_modifiers(event, false); + + self.queue_event(WindowEvent::MouseInput { + device_id: DEVICE_ID, + state: button_state, + button, + }); + } + + fn mouse_motion(&self, event: &NSEvent) { + let window_point = unsafe { event.locationInWindow() }; + let view_point = self.convertPoint_fromView(window_point, None); + let frame = self.frame(); + + if view_point.x.is_sign_negative() + || view_point.y.is_sign_negative() + || view_point.x > frame.size.width + || view_point.y > frame.size.height + { + let mouse_buttons_down = unsafe { NSEvent::pressedMouseButtons() }; + if mouse_buttons_down == 0 { + // Point is outside of the client area (view) and no buttons are pressed + return; + } + } + + let view_point = LogicalPosition::new(view_point.x, view_point.y); + + self.update_modifiers(event, false); + + self.queue_event(WindowEvent::CursorMoved { + device_id: DEVICE_ID, + position: view_point.to_physical(self.scale_factor()), + }); + } +} + +/// Get the mouse button from the NSEvent. +fn mouse_button(event: &NSEvent) -> MouseButton { + // The buttonNumber property only makes sense for the mouse events: + // NSLeftMouse.../NSRightMouse.../NSOtherMouse... + // For the other events, it's always set to 0. + // MacOS only defines the left, right and middle buttons, 3..=31 are left as generic buttons, + // but 3 and 4 are very commonly used as Back and Forward by hardware vendors and applications. + match unsafe { event.buttonNumber() } { + 0 => MouseButton::Left, + 1 => MouseButton::Right, + 2 => MouseButton::Middle, + 3 => MouseButton::Back, + 4 => MouseButton::Forward, + n => MouseButton::Other(n as u16), + } +} + +// NOTE: to get option as alt working we need to rewrite events +// we're getting from the operating system, which makes it +// impossible to provide such events as extra in `KeyEvent`. +fn replace_event( + event: &NSEvent, + option_as_alt: OptionAsAlt, +) -> Retained { + let ev_mods = event_mods(event).state; + let ignore_alt_characters = match option_as_alt { + OptionAsAlt::OnlyLeft if lalt_pressed(event) => true, + OptionAsAlt::OnlyRight if ralt_pressed(event) => true, + OptionAsAlt::Both if ev_mods.alt_key() => true, + _ => false, + } && !ev_mods.control_key() + && !ev_mods.super_key(); + + if ignore_alt_characters { + let ns_chars = unsafe { + event + .charactersIgnoringModifiers() + .expect("expected characters to be non-null") + }; + + unsafe { + NSEvent::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode( + event.r#type(), + event.locationInWindow(), + event.modifierFlags(), + event.timestamp(), + event.windowNumber(), + None, + &ns_chars, + &ns_chars, + event.isARepeat(), + event.keyCode(), + ) + .unwrap() + } + } else { + event.copy() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/window.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/window.rs new file mode 100644 index 00000000..9435c989 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/window.rs @@ -0,0 +1,134 @@ +#![allow(clippy::unnecessary_cast)] + +use objc2::rc::{autoreleasepool, Retained}; +use objc2::{declare_class, mutability, ClassType, DeclaredClass}; +use objc2_app_kit::{NSResponder, NSWindow}; +use objc2_foundation::{MainThreadBound, MainThreadMarker, NSObject}; + +use super::event_loop::ActiveEventLoop; +use super::window_delegate::WindowDelegate; +use crate::error::OsError as RootOsError; +use crate::window::WindowAttributes; + +pub(crate) struct Window { + window: MainThreadBound>, + /// The window only keeps a weak reference to this, so we must keep it around here. + delegate: MainThreadBound>, +} + +impl Drop for Window { + fn drop(&mut self) { + self.delegate + .get_on_main(|delegate| delegate.prepare_for_donor_close()); + self.window + .get_on_main(|window| autoreleasepool(|_| window.close())) + } +} + +impl Window { + pub(crate) fn new( + window_target: &ActiveEventLoop, + attributes: WindowAttributes, + ) -> Result { + let mtm = window_target.mtm; + let delegate = autoreleasepool(|_| { + WindowDelegate::new(window_target.app_delegate(), attributes, mtm) + })?; + Ok(Window { + window: MainThreadBound::new(delegate.window().retain(), mtm), + delegate: MainThreadBound::new(delegate, mtm), + }) + } + + pub(crate) fn maybe_queue_on_main( + &self, + f: impl FnOnce(&WindowDelegate) + Send + 'static, + ) { + // For now, don't actually do queuing, since it may be less predictable + self.maybe_wait_on_main(f) + } + + pub(crate) fn maybe_wait_on_main( + &self, + f: impl FnOnce(&WindowDelegate) -> R + Send, + ) -> R { + self.delegate.get_on_main(|delegate| f(delegate)) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub(crate) fn raw_window_handle_rwh_06( + &self, + ) -> Result { + if let Some(mtm) = MainThreadMarker::new() { + Ok(self.delegate.get(mtm).raw_window_handle_rwh_06()) + } else { + Err(rwh_06::HandleError::Unavailable) + } + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub(crate) fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::AppKit( + rwh_06::AppKitDisplayHandle::new(), + )) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WindowId(pub usize); + +impl WindowId { + pub const fn dummy() -> Self { + Self(0) + } +} + +impl From for u64 { + fn from(window_id: WindowId) -> Self { + window_id.0 as u64 + } +} + +impl From for WindowId { + fn from(raw_id: u64) -> Self { + Self(raw_id as usize) + } +} + +declare_class!( + #[derive(Debug)] + pub struct WinitWindow; + + unsafe impl ClassType for WinitWindow { + #[inherits(NSResponder, NSObject)] + type Super = NSWindow; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "WinitWindow"; + } + + impl DeclaredClass for WinitWindow {} + + unsafe impl WinitWindow { + #[method(canBecomeMainWindow)] + fn can_become_main_window(&self) -> bool { + trace_scope!("canBecomeMainWindow"); + true + } + + #[method(canBecomeKeyWindow)] + fn can_become_key_window(&self) -> bool { + trace_scope!("canBecomeKeyWindow"); + true + } + } +); + +impl WinitWindow { + pub(super) fn id(&self) -> WindowId { + WindowId(self as *const Self as usize) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/window_delegate.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/window_delegate.rs new file mode 100644 index 00000000..f8fe8781 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/macos/window_delegate.rs @@ -0,0 +1,2187 @@ +#![allow(clippy::unnecessary_cast)] +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::ffi::c_void; +use std::ptr; + +use core_graphics::display::{CGDisplay, CGPoint}; +use monitor::VideoModeHandle; +use objc2::rc::{autoreleasepool, Retained}; +use objc2::runtime::{AnyObject, ProtocolObject}; +use objc2::{ + declare_class, msg_send_id, mutability, sel, ClassType, DeclaredClass, +}; +use objc2_app_kit::{ + NSAppKitVersionNumber, NSAppKitVersionNumber10_12, NSAppearance, + NSAppearanceCustomization, NSAppearanceNameAqua, NSApplication, + NSApplicationPresentationOptions, NSBackingStoreType, NSColor, + NSDraggingDestination, NSFilenamesPboardType, NSPasteboard, + NSRequestUserAttentionType, NSScreen, NSView, NSWindow, NSWindowButton, + NSWindowDelegate, NSWindowFullScreenButton, NSWindowLevel, + NSWindowOcclusionState, NSWindowOrderingMode, NSWindowSharingType, + NSWindowStyleMask, NSWindowTabbingMode, NSWindowTitleVisibility, +}; +use objc2_foundation::{ + ns_string, CGFloat, MainThreadMarker, NSArray, NSCopying, NSDictionary, + NSKeyValueChangeKey, NSKeyValueChangeNewKey, NSKeyValueChangeOldKey, + NSKeyValueObservingOptions, NSObject, NSObjectNSDelayedPerforming, + NSObjectNSKeyValueObserverRegistration, NSObjectProtocol, NSPoint, NSRect, + NSSize, NSString, +}; +use tracing::{trace, warn}; + +use super::app_state::ApplicationDelegate; +use super::cursor::cursor_from_icon; +use super::monitor::{self, flip_window_screen_coordinates, get_display_id}; +use super::observer::RunLoop; +use super::view::WinitView; +use super::window::WinitWindow; +use super::{ffi, Fullscreen, MonitorHandle, OsError, WindowId}; +use crate::dpi::{ + LogicalPosition, LogicalSize, PhysicalPosition, PhysicalSize, Position, + Size, +}; +use crate::error::{ExternalError, NotSupportedError, OsError as RootOsError}; +use crate::event::WindowEvent; +use crate::platform::macos::{OptionAsAlt, WindowExtMacOS}; +use crate::window::{ + Cursor, CursorGrabMode, Icon, ImePurpose, ResizeDirection, Theme, + UserAttentionType, WindowAttributes, WindowButtons, WindowLevel, +}; + +#[derive(Clone, Debug)] +pub struct PlatformSpecificWindowAttributes { + pub movable_by_window_background: bool, + pub titlebar_transparent: bool, + pub title_hidden: bool, + pub titlebar_hidden: bool, + pub titlebar_buttons_hidden: bool, + pub fullsize_content_view: bool, + pub disallow_hidpi: bool, + pub has_shadow: bool, + pub accepts_first_mouse: bool, + pub tabbing_identifier: Option, + pub option_as_alt: OptionAsAlt, + pub borderless_game: bool, +} + +impl Default for PlatformSpecificWindowAttributes { + #[inline] + fn default() -> Self { + Self { + movable_by_window_background: false, + titlebar_transparent: false, + title_hidden: false, + titlebar_hidden: false, + titlebar_buttons_hidden: false, + fullsize_content_view: false, + disallow_hidpi: false, + has_shadow: true, + accepts_first_mouse: true, + tabbing_identifier: None, + option_as_alt: Default::default(), + borderless_game: false, + } + } +} + +#[derive(Debug)] +pub(crate) struct State { + /// Strong reference to the global application state. + app_delegate: Retained, + + window: Retained, + /// Retain the view directly so raw-handle and renderer-surface recovery + /// remains valid while AppKit installs it in a foreign host window. + view: Retained, + + // During `windowDidResize`, we use this to only send Moved if the position changed. + // + // This is expressed in desktop coordinates, and flipped to match Winit's coordinate system. + previous_position: Cell, + + /// The current resize increments for the window content. + resize_increments: Cell, + /// Whether the window is showing decorations. + decorations: Cell, + resizable: Cell, + maximized: Cell, + + /// Presentation options saved before entering `set_simple_fullscreen`, and + /// restored upon exiting it. Also used when transitioning from Borderless to + /// Exclusive fullscreen in `set_fullscreen` because we need to disable the menu + /// bar in exclusive fullscreen but want to restore the original options when + /// transitioning back to borderless fullscreen. + save_presentation_opts: Cell>, + // This is set when WindowAttributes::with_fullscreen was set, + // see comments of `window_did_fail_to_enter_fullscreen` + initial_fullscreen: Cell, + /// This field tracks the current fullscreen state of the window + /// (as seen by `WindowDelegate`). + fullscreen: RefCell>, + // If it is attempted to toggle fullscreen when in_fullscreen_transition is true, + // Set target_fullscreen and do after fullscreen transition is end. + target_fullscreen: RefCell>>, + // This is true between windowWillEnterFullScreen and windowDidEnterFullScreen + // or windowWillExitFullScreen and windowDidExitFullScreen. + // We must not toggle fullscreen when this is true. + in_fullscreen_transition: Cell, + standard_frame: Cell>, + is_simple_fullscreen: Cell, + saved_style: Cell>, + is_borderless_game: Cell, +} + +declare_class!( + pub(crate) struct WindowDelegate; + + unsafe impl ClassType for WindowDelegate { + type Super = NSObject; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "WinitWindowDelegate"; + } + + impl DeclaredClass for WindowDelegate { + type Ivars = State; + } + + unsafe impl NSObjectProtocol for WindowDelegate {} + + unsafe impl NSWindowDelegate for WindowDelegate { + #[method(windowShouldClose:)] + fn window_should_close(&self, _: Option<&AnyObject>) -> bool { + trace_scope!("windowShouldClose:"); + self.queue_event(WindowEvent::CloseRequested); + false + } + + #[method(windowWillClose:)] + fn window_will_close(&self, _: Option<&AnyObject>) { + trace_scope!("windowWillClose:"); + self.view().prepare_for_donor_close(); + // `setDelegate:` retains the previous value and then autoreleases it + autoreleasepool(|_| { + // Since El Capitan, we need to be careful that delegate methods can't + // be called after the window closes. + self.window().setDelegate(None); + }); + self.queue_event(WindowEvent::Destroyed); + } + + #[method(windowDidResize:)] + fn window_did_resize(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidResize:"); + // NOTE: WindowEvent::Resized is reported in frameDidChange. + if !self.view().is_foreign_hosted() { + self.emit_move_event(); + } + } + + #[method(windowWillStartLiveResize:)] + fn window_will_start_live_resize(&self, _: Option<&AnyObject>) { + trace_scope!("windowWillStartLiveResize:"); + + let increments = self.ivars().resize_increments.get(); + self.set_resize_increments_inner(increments); + } + + #[method(windowDidEndLiveResize:)] + fn window_did_end_live_resize(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidEndLiveResize:"); + self.set_resize_increments_inner(NSSize::new(1., 1.)); + } + + // This won't be triggered if the move was part of a resize. + #[method(windowDidMove:)] + fn window_did_move(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidMove:"); + if !self.view().is_foreign_hosted() { + self.emit_move_event(); + } + } + + #[method(windowDidChangeBackingProperties:)] + fn window_did_change_backing_properties(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidChangeBackingProperties:"); + let view = self.view(); + if view.is_foreign_hosted() { + return; + } + + let mtm = MainThreadMarker::from(self); + let view = view.clone(); + RunLoop::main(mtm).queue_closure(move || { + view.handle_scale_factor_changed(false); + }); + } + + #[method(windowDidBecomeKey:)] + fn window_did_become_key(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidBecomeKey:"); + // TODO: center the cursor if the window had mouse grab when it + // lost focus + let view = self.view(); + if !view.is_foreign_hosted() { + view.set_focused(true); + } + } + + #[method(windowDidResignKey:)] + fn window_did_resign_key(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidResignKey:"); + // It happens rather often, e.g. when the user is Cmd+Tabbing, that the + // NSWindowDelegate will receive a didResignKey event despite no event + // being received when the modifiers are released. This is because + // flagsChanged events are received by the NSView instead of the + // NSWindowDelegate, and as a result a tracked modifiers state can quite + // easily fall out of synchrony with reality. This requires us to emit + // a synthetic ModifiersChanged event when we lose focus. + let view = self.view(); + if !view.is_foreign_hosted() { + view.set_focused(false); + } + } + + /// Invoked when before enter fullscreen + #[method(windowWillEnterFullScreen:)] + fn window_will_enter_fullscreen(&self, _: Option<&AnyObject>) { + trace_scope!("windowWillEnterFullScreen:"); + + self.ivars().maximized.set(self.is_zoomed()); + let mut fullscreen = self.ivars().fullscreen.borrow_mut(); + match &*fullscreen { + // Exclusive mode sets the state in `set_fullscreen` as the user + // can't enter exclusive mode by other means (like the + // fullscreen button on the window decorations) + Some(Fullscreen::Exclusive(_)) => (), + // `window_will_enter_fullscreen` was triggered and we're already + // in fullscreen, so we must've reached here by `set_fullscreen` + // as it updates the state + Some(Fullscreen::Borderless(_)) => (), + // Otherwise, we must've reached fullscreen by the user clicking + // on the green fullscreen button. Update state! + None => { + let current_monitor = self.current_monitor_inner(); + *fullscreen = Some(Fullscreen::Borderless(current_monitor)); + }, + } + self.ivars().in_fullscreen_transition.set(true); + } + + /// Invoked when before exit fullscreen + #[method(windowWillExitFullScreen:)] + fn window_will_exit_fullscreen(&self, _: Option<&AnyObject>) { + trace_scope!("windowWillExitFullScreen:"); + + self.ivars().in_fullscreen_transition.set(true); + } + + #[method(window:willUseFullScreenPresentationOptions:)] + fn window_will_use_fullscreen_presentation_options( + &self, + _: Option<&AnyObject>, + proposed_options: NSApplicationPresentationOptions, + ) -> NSApplicationPresentationOptions { + trace_scope!("window:willUseFullScreenPresentationOptions:"); + // Generally, games will want to disable the menu bar and the dock. Ideally, + // this would be configurable by the user. Unfortunately because of our + // `CGShieldingWindowLevel() + 1` hack (see `set_fullscreen`), our window is + // placed on top of the menu bar in exclusive fullscreen mode. This looks + // broken so we always disable the menu bar in exclusive fullscreen. We may + // still want to make this configurable for borderless fullscreen. Right now + // we don't, for consistency. If we do, it should be documented that the + // user-provided options are ignored in exclusive fullscreen. + let mut options = proposed_options; + let fullscreen = self.ivars().fullscreen.borrow(); + if let Some(Fullscreen::Exclusive(_)) = &*fullscreen { + options = NSApplicationPresentationOptions::NSApplicationPresentationFullScreen + | NSApplicationPresentationOptions::NSApplicationPresentationHideDock + | NSApplicationPresentationOptions::NSApplicationPresentationHideMenuBar; + } + + options + } + + /// Invoked when entered fullscreen + #[method(windowDidEnterFullScreen:)] + fn window_did_enter_fullscreen(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidEnterFullScreen:"); + self.ivars().initial_fullscreen.set(false); + self.ivars().in_fullscreen_transition.set(false); + if let Some(target_fullscreen) = self.ivars().target_fullscreen.take() { + self.set_fullscreen(target_fullscreen); + } + } + + /// Invoked when exited fullscreen + #[method(windowDidExitFullScreen:)] + fn window_did_exit_fullscreen(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidExitFullScreen:"); + + self.restore_state_from_fullscreen(); + self.ivars().in_fullscreen_transition.set(false); + if let Some(target_fullscreen) = self.ivars().target_fullscreen.take() { + self.set_fullscreen(target_fullscreen); + } + } + + /// Invoked when fail to enter fullscreen + /// + /// When this window launch from a fullscreen app (e.g. launch from VS Code + /// terminal), it creates a new virtual desktop and a transition animation. + /// This animation takes one second and cannot be disable without + /// elevated privileges. In this animation time, all toggleFullscreen events + /// will be failed. In this implementation, we will try again by using + /// performSelector:withObject:afterDelay: until window_did_enter_fullscreen. + /// It should be fine as we only do this at initialization (i.e with_fullscreen + /// was set). + /// + /// From Apple doc: + /// In some cases, the transition to enter full-screen mode can fail, + /// due to being in the midst of handling some other animation or user gesture. + /// This method indicates that there was an error, and you should clean up any + /// work you may have done to prepare to enter full-screen mode. + #[method(windowDidFailToEnterFullScreen:)] + fn window_did_fail_to_enter_fullscreen(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidFailToEnterFullScreen:"); + self.ivars().in_fullscreen_transition.set(false); + self.ivars().target_fullscreen.replace(None); + if self.ivars().initial_fullscreen.get() { + unsafe { + self.window().performSelector_withObject_afterDelay( + sel!(toggleFullScreen:), + None, + 0.5, + ) + }; + } else { + self.restore_state_from_fullscreen(); + } + } + + // Invoked when the occlusion state of the window changes + #[method(windowDidChangeOcclusionState:)] + fn window_did_change_occlusion_state(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidChangeOcclusionState:"); + let view = self.view(); + if !view.is_foreign_hosted() { + let visible = + self.window().occlusionState().contains(NSWindowOcclusionState::Visible); + view.set_occluded(!visible); + } + } + + #[method(windowDidChangeScreen:)] + fn window_did_change_screen(&self, _: Option<&AnyObject>) { + trace_scope!("windowDidChangeScreen:"); + if self.view().is_foreign_hosted() { + return; + } + let is_simple_fullscreen = self.ivars().is_simple_fullscreen.get(); + if is_simple_fullscreen { + if let Some(screen) = self.window().screen() { + self.window().setFrame_display(screen.frame(), true); + } + } + } + } + + unsafe impl NSDraggingDestination for WindowDelegate { + /// Invoked when the dragged image enters destination bounds or frame + #[method(draggingEntered:)] + fn dragging_entered(&self, sender: &NSObject) -> bool { + trace_scope!("draggingEntered:"); + + use std::path::PathBuf; + + let pb: Retained = unsafe { msg_send_id![sender, draggingPasteboard] }; + let filenames = match pb.propertyListForType(unsafe { NSFilenamesPboardType }) { + Some(filenames) => filenames, + None => return false.into(), + }; + let filenames: Retained> = unsafe { Retained::cast(filenames) }; + + filenames.into_iter().for_each(|file| { + let path = PathBuf::from(file.to_string()); + self.queue_event(WindowEvent::HoveredFile(path)); + }); + + true + } + + /// Invoked when the image is released + #[method(prepareForDragOperation:)] + fn prepare_for_drag_operation(&self, _sender: &NSObject) -> bool { + trace_scope!("prepareForDragOperation:"); + true + } + + /// Invoked after the released image has been removed from the screen + #[method(performDragOperation:)] + fn perform_drag_operation(&self, sender: &NSObject) -> bool { + trace_scope!("performDragOperation:"); + + use std::path::PathBuf; + + let pb: Retained = unsafe { msg_send_id![sender, draggingPasteboard] }; + let filenames = match pb.propertyListForType(unsafe { NSFilenamesPboardType }) { + Some(filenames) => filenames, + None => return false.into(), + }; + let filenames: Retained> = unsafe { Retained::cast(filenames) }; + + filenames.into_iter().for_each(|file| { + let path = PathBuf::from(file.to_string()); + self.queue_event(WindowEvent::DroppedFile(path)); + }); + + true + } + + /// Invoked when the dragging operation is complete + #[method(concludeDragOperation:)] + fn conclude_drag_operation(&self, _sender: Option<&NSObject>) { + trace_scope!("concludeDragOperation:"); + } + + /// Invoked when the dragging operation is cancelled + #[method(draggingExited:)] + fn dragging_exited(&self, _sender: Option<&NSObject>) { + trace_scope!("draggingExited:"); + self.queue_event(WindowEvent::HoveredFileCancelled); + } + } + + // Key-Value Observing + unsafe impl WindowDelegate { + #[method(observeValueForKeyPath:ofObject:change:context:)] + fn observe_value( + &self, + key_path: Option<&NSString>, + _object: Option<&AnyObject>, + change: Option<&NSDictionary>, + _context: *mut c_void, + ) { + trace_scope!("observeValueForKeyPath:ofObject:change:context:"); + if self.view().is_foreign_hosted() { + return; + } + // NOTE: We don't _really_ need to check the key path, as there should only be one, but + // in the future we might want to observe other key paths. + if key_path == Some(ns_string!("effectiveAppearance")) { + let change = change.expect("requested a change dictionary in `addObserver`, but none was provided"); + let old = change.get(unsafe { NSKeyValueChangeOldKey }).expect("requested change dictionary did not contain `NSKeyValueChangeOldKey`"); + let new = change.get(unsafe { NSKeyValueChangeNewKey }).expect("requested change dictionary did not contain `NSKeyValueChangeNewKey`"); + + // SAFETY: The value of `effectiveAppearance` is `NSAppearance` + let old: *const AnyObject = old; + let old: *const NSAppearance = old.cast(); + let old: &NSAppearance = unsafe { &*old }; + let new: *const AnyObject = new; + let new: *const NSAppearance = new.cast(); + let new: &NSAppearance = unsafe { &*new }; + + trace!(old = %unsafe { old.name() }, new = %unsafe { new.name() }, "effectiveAppearance changed"); + + // Ignore the change if the window's theme is customized by the user (since in that + // case the `effectiveAppearance` is only emitted upon said customization, and then + // it's triggered directly by a user action, and we don't want to emit the event). + if unsafe { self.window().appearance() }.is_some() { + return; + } + + let old = appearance_to_theme(old); + let new = appearance_to_theme(new); + // Check that the theme changed in Winit's terms (the theme might have changed on + // other parameters, such as level of contrast, but the event should not be emitted + // in those cases). + if old == new { + return; + } + + self.queue_event(WindowEvent::ThemeChanged(new)); + } else { + panic!("unknown observed keypath {key_path:?}"); + } + } + } +); + +impl Drop for WindowDelegate { + fn drop(&mut self) { + unsafe { + self.window().removeObserver_forKeyPath( + self, + ns_string!("effectiveAppearance"), + ); + } + } +} + +fn new_window( + app_delegate: &ApplicationDelegate, + attrs: &WindowAttributes, + mtm: MainThreadMarker, +) -> Option<(Retained, Retained)> { + autoreleasepool(|_| { + let screen = match attrs.fullscreen.clone().map(Into::into) { + Some(Fullscreen::Borderless(Some(monitor))) + | Some(Fullscreen::Exclusive(VideoModeHandle { + monitor, .. + })) => monitor.ns_screen(mtm).or_else(|| NSScreen::mainScreen(mtm)), + Some(Fullscreen::Borderless(None)) => NSScreen::mainScreen(mtm), + None => None, + }; + let frame = match &screen { + Some(screen) => screen.frame(), + None => { + let scale_factor = NSScreen::mainScreen(mtm) + .map(|screen| screen.backingScaleFactor() as f64) + .unwrap_or(1.0); + let size = match attrs.inner_size { + Some(size) => { + let size = size.to_logical(scale_factor); + NSSize::new(size.width, size.height) + } + None => NSSize::new(800.0, 600.0), + }; + let position = match attrs.position { + Some(position) => { + let position = position.to_logical(scale_factor); + flip_window_screen_coordinates(NSRect::new( + NSPoint::new(position.x, position.y), + size, + )) + } + // This value is ignored by calling win.center() below + None => NSPoint::new(0.0, 0.0), + }; + NSRect::new(position, size) + } + }; + + let mut masks = if (!attrs.decorations && screen.is_none()) + || attrs.platform_specific.titlebar_hidden + { + // Resizable without a titlebar or borders + // if decorations is set to false, ignore pl_attrs + // + // if the titlebar is hidden, ignore other pl_attrs + NSWindowStyleMask::Borderless + | NSWindowStyleMask::Resizable + | NSWindowStyleMask::Miniaturizable + } else { + // default case, resizable window with titlebar and titlebar buttons + NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable + | NSWindowStyleMask::Resizable + | NSWindowStyleMask::Titled + }; + + if !attrs.resizable { + masks &= !NSWindowStyleMask::Resizable; + } + + if !attrs.enabled_buttons.contains(WindowButtons::MINIMIZE) { + masks &= !NSWindowStyleMask::Miniaturizable; + } + + if !attrs.enabled_buttons.contains(WindowButtons::CLOSE) { + masks &= !NSWindowStyleMask::Closable; + } + + if attrs.platform_specific.fullsize_content_view { + masks |= NSWindowStyleMask::FullSizeContentView; + } + + let window: Option> = unsafe { + msg_send_id![ + super(mtm.alloc().set_ivars(())), + initWithContentRect: frame, + styleMask: masks, + backing: NSBackingStoreType::NSBackingStoreBuffered, + defer: false, + ] + }; + let window = window?; + + // It is very important for correct memory management that we + // disable the extra release that would otherwise happen when + // calling `close` on the window. + unsafe { window.setReleasedWhenClosed(false) }; + + window.setTitle(&NSString::from_str(&attrs.title)); + window.setAcceptsMouseMovedEvents(true); + + if let Some(identifier) = &attrs.platform_specific.tabbing_identifier { + window.setTabbingIdentifier(&NSString::from_str(identifier)); + window.setTabbingMode(NSWindowTabbingMode::Preferred); + } + + if attrs.content_protected { + window.setSharingType(NSWindowSharingType::NSWindowSharingNone); + } + + if attrs.platform_specific.titlebar_transparent { + window.setTitlebarAppearsTransparent(true); + } + if attrs.platform_specific.title_hidden { + window.setTitleVisibility( + NSWindowTitleVisibility::NSWindowTitleHidden, + ); + } + if attrs.platform_specific.titlebar_buttons_hidden { + for titlebar_button in &[ + #[allow(deprecated)] + NSWindowFullScreenButton, + NSWindowButton::NSWindowMiniaturizeButton, + NSWindowButton::NSWindowCloseButton, + NSWindowButton::NSWindowZoomButton, + ] { + if let Some(button) = + window.standardWindowButton(*titlebar_button) + { + button.setHidden(true); + } + } + } + if attrs.platform_specific.movable_by_window_background { + window.setMovableByWindowBackground(true); + } + + if !attrs.enabled_buttons.contains(WindowButtons::MAXIMIZE) { + if let Some(button) = + window.standardWindowButton(NSWindowButton::NSWindowZoomButton) + { + button.setEnabled(false); + } + } + + if !attrs.platform_specific.has_shadow { + window.setHasShadow(false); + } + if attrs.position.is_none() { + window.center(); + } + + let view = WinitView::new( + app_delegate, + &window, + attrs.platform_specific.accepts_first_mouse, + attrs.platform_specific.option_as_alt, + ); + + // The default value of `setWantsBestResolutionOpenGLSurface:` was `false` until + // macos 10.14 and `true` after 10.15, we should set it to `YES` or `NO` to avoid + // always the default system value in favour of the user's code + #[allow(deprecated)] + view.setWantsBestResolutionOpenGLSurface( + !attrs.platform_specific.disallow_hidpi, + ); + + // On Mojave, views automatically become layer-backed shortly after being added to + // a window. Changing the layer-backedness of a view breaks the association between + // the view and its associated OpenGL context. To work around this, on Mojave we + // explicitly make the view layer-backed up front so that AppKit doesn't do it + // itself and break the association with its context. + if unsafe { NSAppKitVersionNumber }.floor() > NSAppKitVersionNumber10_12 + { + view.setWantsLayer(true); + } + + // Configure the new view as the "key view" for the window + window.setContentView(Some(&view)); + window.setInitialFirstResponder(Some(&view)); + + if attrs.transparent { + window.setOpaque(false); + // See `set_transparent` for details on why we do this. + window.setBackgroundColor(unsafe { Some(&NSColor::clearColor()) }); + } + + // register for drag and drop operations. + window.registerForDraggedTypes(&NSArray::from_id_slice(&[unsafe { + NSFilenamesPboardType + } + .copy()])); + + Some((window, view)) + }) +} + +impl WindowDelegate { + pub(super) fn new( + app_delegate: &ApplicationDelegate, + attrs: WindowAttributes, + mtm: MainThreadMarker, + ) -> Result, RootOsError> { + let (window, view) = + new_window(app_delegate, &attrs, mtm).ok_or_else(|| { + os_error!(OsError::CreationError("couldn't create `NSWindow`")) + })?; + + #[cfg(feature = "rwh_06")] + match attrs.parent_window.map(|handle| handle.0) { + Some(rwh_06::RawWindowHandle::AppKit(handle)) => { + // SAFETY: Caller ensures the pointer is valid or NULL + // Unwrap is fine, since the pointer comes from `NonNull`. + let parent_view: Retained = + unsafe { Retained::retain(handle.ns_view.as_ptr().cast()) } + .unwrap(); + let parent = parent_view.window().ok_or_else(|| { + os_error!(OsError::CreationError( + "parent view should be installed in a window" + )) + })?; + + // SAFETY: We know that there are no parent -> child -> parent cycles since the only + // place in `winit` where we allow making a window a child window is + // right here, just after it's been created. + unsafe { + parent.addChildWindow_ordered( + &window, + NSWindowOrderingMode::NSWindowAbove, + ) + }; + } + Some(raw) => panic!("invalid raw window handle {raw:?} on macOS"), + None => (), + } + + let resize_increments = match attrs + .resize_increments + .map(|i| i.to_logical(window.backingScaleFactor() as _)) + { + Some(LogicalSize { width, height }) + if width >= 1. && height >= 1. => + { + NSSize::new(width, height) + } + _ => NSSize::new(1., 1.), + }; + + let scale_factor = window.backingScaleFactor() as f64; + + if let Some(appearance) = theme_to_appearance(attrs.preferred_theme) { + unsafe { window.setAppearance(Some(&appearance)) }; + } + + let delegate = mtm.alloc().set_ivars(State { + app_delegate: app_delegate.retain(), + window: window.retain(), + view, + previous_position: Cell::new(flip_window_screen_coordinates( + window.frame(), + )), + resize_increments: Cell::new(resize_increments), + decorations: Cell::new(attrs.decorations), + resizable: Cell::new(attrs.resizable), + maximized: Cell::new(attrs.maximized), + save_presentation_opts: Cell::new(None), + initial_fullscreen: Cell::new(attrs.fullscreen.is_some()), + fullscreen: RefCell::new(None), + target_fullscreen: RefCell::new(None), + in_fullscreen_transition: Cell::new(false), + standard_frame: Cell::new(None), + is_simple_fullscreen: Cell::new(false), + saved_style: Cell::new(None), + is_borderless_game: Cell::new( + attrs.platform_specific.borderless_game, + ), + }); + let delegate: Retained = + unsafe { msg_send_id![super(delegate), init] }; + + if scale_factor != 1.0 { + let view = delegate.view(); + RunLoop::main(mtm).queue_closure(move || { + view.handle_scale_factor_changed(true); + }); + } + window.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + + // Listen for theme change event. + // + // SAFETY: The observer is un-registered in the `Drop` of the delegate. + unsafe { + window.addObserver_forKeyPath_options_context( + &delegate, + ns_string!("effectiveAppearance"), + NSKeyValueObservingOptions::NSKeyValueObservingOptionNew + | NSKeyValueObservingOptions::NSKeyValueObservingOptionOld, + ptr::null_mut(), + ) + }; + + if attrs.blur { + delegate.set_blur(attrs.blur); + } + + if let Some(dim) = attrs.min_inner_size { + delegate.set_min_inner_size(Some(dim)); + } + if let Some(dim) = attrs.max_inner_size { + delegate.set_max_inner_size(Some(dim)); + } + + delegate.set_window_level(attrs.window_level); + + delegate.set_cursor(attrs.cursor); + + // XXX Send `Focused(false)` right after creating the window delegate, so we won't + // obscure the real focused events on the startup. + delegate.view().set_focused(false); + + // Set fullscreen mode after we setup everything + delegate.set_fullscreen(attrs.fullscreen.map(Into::into)); + + // Setting the window as key has to happen *after* we set the fullscreen + // state, since otherwise we'll briefly see the window at normal size + // before it transitions. + if attrs.visible { + if attrs.active { + // Tightly linked with `app_state::window_activation_hack` + window.makeKeyAndOrderFront(None); + } else { + window.orderFront(None); + } + } + + if attrs.maximized { + delegate.set_maximized(attrs.maximized); + } + + Ok(delegate) + } + + #[track_caller] + pub(super) fn view(&self) -> Retained { + self.ivars().view.clone() + } + + #[track_caller] + pub(super) fn window(&self) -> &WinitWindow { + &self.ivars().window + } + + #[track_caller] + fn host_window(&self) -> Retained { + self.view().host_window() + } + + pub(super) fn prepare_for_donor_close(&self) { + self.view().prepare_for_donor_close(); + } + + #[track_caller] + pub(crate) fn id(&self) -> WindowId { + self.window().id() + } + + pub(crate) fn queue_event(&self, event: WindowEvent) { + self.ivars() + .app_delegate + .maybe_queue_window_event(self.window().id(), event); + } + + fn emit_move_event(&self) { + let position = flip_window_screen_coordinates(self.window().frame()); + if self.ivars().previous_position.get() == position { + return; + } + self.ivars().previous_position.set(position); + + let position = LogicalPosition::new(position.x, position.y) + .to_physical(self.scale_factor()); + self.queue_event(WindowEvent::Moved(position)); + } + + fn set_style_mask(&self, mask: NSWindowStyleMask) { + if self.view().is_foreign_hosted() { + return; + } + self.window().setStyleMask(mask); + // If we don't do this, key handling will break + // (at least until the window is clicked again/etc.) + let _ = self.window().makeFirstResponder(Some(&self.view())); + } + + pub fn set_title(&self, title: &str) { + if self.view().is_foreign_hosted() { + return; + } + self.window().setTitle(&NSString::from_str(title)) + } + + pub fn set_transparent(&self, transparent: bool) { + if self.view().is_foreign_hosted() { + return; + } + // This is just a hint for Quartz, it doesn't actually speculate with window alpha. + // Providing a wrong value here could result in visual artifacts, when the window is + // transparent. + self.window().setOpaque(!transparent); + + // AppKit draws the window with a background color by default, which is usually really + // nice, but gets in the way when we want to allow the contents of the window to be + // transparent, as in that case, the transparent contents will just be drawn on top of + // the background color. As such, to allow the window to be transparent, we must also set + // the background color to one with an empty alpha channel. + let color = if transparent { + unsafe { NSColor::clearColor() } + } else { + unsafe { NSColor::windowBackgroundColor() } + }; + + self.window().setBackgroundColor(Some(&color)); + } + + pub fn set_blur(&self, blur: bool) { + if self.view().is_foreign_hosted() { + return; + } + // NOTE: in general we want to specify the blur radius, but the choice of 80 + // should be a reasonable default. + let radius = if blur { 80 } else { 0 }; + let window_number = unsafe { self.window().windowNumber() }; + unsafe { + ffi::CGSSetWindowBackgroundBlurRadius( + ffi::CGSMainConnectionID(), + window_number, + radius, + ); + } + } + + pub fn set_visible(&self, visible: bool) { + let view = self.view(); + if view.is_foreign_hosted() { + view.setHidden(!visible); + } else { + match visible { + true => self.window().makeKeyAndOrderFront(None), + false => self.window().orderOut(None), + } + } + } + + #[inline] + pub fn is_visible(&self) -> Option { + let view = self.view(); + if view.is_foreign_hosted() { + Some(self.host_window().isVisible() && !unsafe { view.isHidden() }) + } else { + Some(self.window().isVisible()) + } + } + + pub fn request_redraw(&self) { + self.ivars().app_delegate.queue_redraw(self.window().id()); + } + + #[inline] + pub fn pre_present_notify(&self) {} + + pub fn outer_position( + &self, + ) -> Result, NotSupportedError> { + let position = + flip_window_screen_coordinates(self.host_window().frame()); + Ok(LogicalPosition::new(position.x, position.y) + .to_physical(self.scale_factor())) + } + + pub fn inner_position( + &self, + ) -> Result, NotSupportedError> { + let view = self.view(); + let window_rect = view.convertRect_toView(view.bounds(), None); + let screen_rect = self.host_window().convertRectToScreen(window_rect); + let position = flip_window_screen_coordinates(screen_rect); + Ok(LogicalPosition::new(position.x, position.y) + .to_physical(self.scale_factor())) + } + + pub fn set_outer_position(&self, position: Position) { + if self.view().is_foreign_hosted() { + return; + } + let position = position.to_logical(self.scale_factor()); + let point = flip_window_screen_coordinates(NSRect::new( + NSPoint::new(position.x, position.y), + self.window().frame().size, + )); + unsafe { self.window().setFrameOrigin(point) }; + } + + #[inline] + pub fn inner_size(&self) -> PhysicalSize { + let bounds = self.view().bounds(); + let logical = LogicalSize::new(bounds.size.width, bounds.size.height); + logical.to_physical(self.scale_factor()) + } + + #[inline] + pub fn outer_size(&self) -> PhysicalSize { + let frame = self.host_window().frame(); + let logical = LogicalSize::new(frame.size.width, frame.size.height); + logical.to_physical(self.scale_factor()) + } + + #[inline] + pub fn request_inner_size(&self, size: Size) -> Option> { + if self.view().is_foreign_hosted() { + return Some(self.inner_size()); + } + let scale_factor = self.scale_factor(); + let size = size.to_logical(scale_factor); + self.window() + .setContentSize(NSSize::new(size.width, size.height)); + None + } + + pub fn set_min_inner_size(&self, dimensions: Option) { + if self.view().is_foreign_hosted() { + return; + } + let dimensions = dimensions.unwrap_or(Size::Logical(LogicalSize { + width: 0.0, + height: 0.0, + })); + let min_size = dimensions.to_logical::(self.scale_factor()); + + let min_size = NSSize::new(min_size.width, min_size.height); + unsafe { self.window().setContentMinSize(min_size) }; + + // If necessary, resize the window to match constraint + let mut current_size = self + .window() + .contentRectForFrameRect(self.window().frame()) + .size; + if current_size.width < min_size.width { + current_size.width = min_size.width; + } + if current_size.height < min_size.height { + current_size.height = min_size.height; + } + self.window().setContentSize(current_size); + } + + pub fn set_max_inner_size(&self, dimensions: Option) { + if self.view().is_foreign_hosted() { + return; + } + let dimensions = dimensions.unwrap_or(Size::Logical(LogicalSize { + width: f32::MAX as f64, + height: f32::MAX as f64, + })); + let scale_factor = self.scale_factor(); + let max_size = dimensions.to_logical::(scale_factor); + + let max_size = NSSize::new(max_size.width, max_size.height); + unsafe { self.window().setContentMaxSize(max_size) }; + + // If necessary, resize the window to match constraint + let mut current_size = self + .window() + .contentRectForFrameRect(self.window().frame()) + .size; + if max_size.width < current_size.width { + current_size.width = max_size.width; + } + if max_size.height < current_size.height { + current_size.height = max_size.height; + } + self.window().setContentSize(current_size); + } + + pub fn resize_increments(&self) -> Option> { + let increments = self.ivars().resize_increments.get(); + let (w, h) = (increments.width, increments.height); + if w > 1.0 || h > 1.0 { + Some(LogicalSize::new(w, h).to_physical(self.scale_factor())) + } else { + None + } + } + + pub fn set_resize_increments(&self, increments: Option) { + // XXX the resize increments are only used during live resizes. + self.ivars().resize_increments.set( + increments + .map(|increments| { + let logical = + increments.to_logical::(self.scale_factor()); + NSSize::new(logical.width.max(1.0), logical.height.max(1.0)) + }) + .unwrap_or_else(|| NSSize::new(1.0, 1.0)), + ); + } + + pub(crate) fn set_resize_increments_inner(&self, size: NSSize) { + if self.view().is_foreign_hosted() { + return; + } + // It was concluded (#2411) that there is never a use-case for + // "outer" resize increments, hence we set "inner" ones here. + // ("outer" in macOS being just resizeIncrements, and "inner" - contentResizeIncrements) + // This is consistent with X11 size hints behavior + self.window().setContentResizeIncrements(size); + } + + #[inline] + pub fn set_resizable(&self, resizable: bool) { + self.ivars().resizable.set(resizable); + if self.view().is_foreign_hosted() { + return; + } + let fullscreen = self.ivars().fullscreen.borrow().is_some(); + if !fullscreen { + let mut mask = self.window().styleMask(); + if resizable { + mask |= NSWindowStyleMask::Resizable; + } else { + mask &= !NSWindowStyleMask::Resizable; + } + self.set_style_mask(mask); + } + // Otherwise, we don't change the mask until we exit fullscreen. + } + + #[inline] + pub fn is_resizable(&self) -> bool { + self.host_window().isResizable() + } + + #[inline] + pub fn set_enabled_buttons(&self, buttons: WindowButtons) { + if self.view().is_foreign_hosted() { + return; + } + let mut mask = self.window().styleMask(); + + if buttons.contains(WindowButtons::CLOSE) { + mask |= NSWindowStyleMask::Closable; + } else { + mask &= !NSWindowStyleMask::Closable; + } + + if buttons.contains(WindowButtons::MINIMIZE) { + mask |= NSWindowStyleMask::Miniaturizable; + } else { + mask &= !NSWindowStyleMask::Miniaturizable; + } + + // This must happen before the button's "enabled" status has been set, + // hence we do it synchronously. + self.set_style_mask(mask); + + // We edit the button directly instead of using `NSResizableWindowMask`, + // since that mask also affect the resizability of the window (which is + // controllable by other means in `winit`). + if let Some(button) = self + .window() + .standardWindowButton(NSWindowButton::NSWindowZoomButton) + { + button.setEnabled(buttons.contains(WindowButtons::MAXIMIZE)); + } + } + + #[inline] + pub fn enabled_buttons(&self) -> WindowButtons { + let window = self.host_window(); + let mut buttons = WindowButtons::empty(); + if window.isMiniaturizable() { + buttons |= WindowButtons::MINIMIZE; + } + if window + .standardWindowButton(NSWindowButton::NSWindowZoomButton) + .map(|b| b.isEnabled()) + .unwrap_or(true) + { + buttons |= WindowButtons::MAXIMIZE; + } + if window.hasCloseBox() { + buttons |= WindowButtons::CLOSE; + } + buttons + } + + pub fn set_cursor(&self, cursor: Cursor) { + let view = self.view(); + + let cursor = match cursor { + Cursor::Icon(icon) => cursor_from_icon(icon), + Cursor::Custom(cursor) => cursor.inner.0, + }; + + if view.cursor_icon() == cursor { + return; + } + + view.set_cursor_icon(cursor); + self.host_window().invalidateCursorRectsForView(&view); + } + + #[inline] + pub fn set_cursor_grab( + &self, + mode: CursorGrabMode, + ) -> Result<(), ExternalError> { + let associate_mouse_cursor = match mode { + CursorGrabMode::Locked => false, + CursorGrabMode::None => true, + CursorGrabMode::Confined => { + return Err(ExternalError::NotSupported( + NotSupportedError::new(), + )) + } + }; + + // TODO: Do this for real https://stackoverflow.com/a/40922095/5435443 + CGDisplay::associate_mouse_and_mouse_cursor_position( + associate_mouse_cursor, + ) + .map_err(|status| { + ExternalError::Os(os_error!(OsError::CGError(status))) + }) + } + + #[inline] + pub fn set_cursor_visible(&self, visible: bool) { + let view = self.view(); + let state_changed = view.set_cursor_visible(visible); + if state_changed { + self.host_window().invalidateCursorRectsForView(&view); + } + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + self.host_window().backingScaleFactor() as _ + } + + #[inline] + pub fn set_cursor_position( + &self, + cursor_position: Position, + ) -> Result<(), ExternalError> { + let physical_window_position = self.inner_position().unwrap(); + let scale_factor = self.scale_factor(); + let window_position = + physical_window_position.to_logical::(scale_factor); + let logical_cursor_position = + cursor_position.to_logical::(scale_factor); + let point = CGPoint { + x: logical_cursor_position.x + window_position.x, + y: logical_cursor_position.y + window_position.y, + }; + CGDisplay::warp_mouse_cursor_position(point) + .map_err(|e| ExternalError::Os(os_error!(OsError::CGError(e))))?; + CGDisplay::associate_mouse_and_mouse_cursor_position(true) + .map_err(|e| ExternalError::Os(os_error!(OsError::CGError(e))))?; + + Ok(()) + } + + #[inline] + pub fn drag_window(&self) -> Result<(), ExternalError> { + let mtm = MainThreadMarker::from(self); + let event = NSApplication::sharedApplication(mtm) + .currentEvent() + .ok_or(ExternalError::Ignored)?; + self.host_window().performWindowDragWithEvent(&event); + Ok(()) + } + + #[inline] + pub fn drag_resize_window( + &self, + _direction: ResizeDirection, + ) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + #[inline] + pub fn show_window_menu(&self, _position: Position) {} + + #[inline] + pub fn set_cursor_hittest( + &self, + hittest: bool, + ) -> Result<(), ExternalError> { + let view = self.view(); + view.set_cursor_hittest(hittest); + if !view.is_foreign_hosted() { + self.window().setIgnoresMouseEvents(!hittest); + } + Ok(()) + } + + pub(crate) fn is_zoomed(&self) -> bool { + if self.view().is_foreign_hosted() { + return self.host_window().isZoomed(); + } + // because `isZoomed` doesn't work if the window's borderless, + // we make it resizable temporarily. + let curr_mask = self.window().styleMask(); + + let required = NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable; + let needs_temp_mask = !curr_mask.contains(required); + if needs_temp_mask { + self.set_style_mask(required); + } + + let is_zoomed = self.window().isZoomed(); + + // Roll back temp styles + if needs_temp_mask { + self.set_style_mask(curr_mask); + } + + is_zoomed + } + + fn saved_style(&self) -> NSWindowStyleMask { + let base_mask = self + .ivars() + .saved_style + .take() + .unwrap_or_else(|| self.window().styleMask()); + if self.ivars().resizable.get() { + base_mask | NSWindowStyleMask::Resizable + } else { + base_mask & !NSWindowStyleMask::Resizable + } + } + + /// This is called when the window is exiting fullscreen, whether by the + /// user clicking on the green fullscreen button or programmatically by + /// `toggleFullScreen:` + pub(crate) fn restore_state_from_fullscreen(&self) { + self.ivars().fullscreen.replace(None); + + let maximized = self.ivars().maximized.get(); + let mask = self.saved_style(); + + self.set_style_mask(mask); + self.set_maximized(maximized); + } + + #[inline] + pub fn set_minimized(&self, minimized: bool) { + if self.view().is_foreign_hosted() { + return; + } + let is_minimized = self.window().isMiniaturized(); + if is_minimized == minimized { + return; + } + + if minimized { + self.window().miniaturize(Some(self)); + } else { + unsafe { self.window().deminiaturize(Some(self)) }; + } + } + + #[inline] + pub fn is_minimized(&self) -> Option { + Some(self.host_window().isMiniaturized()) + } + + #[inline] + pub fn set_maximized(&self, maximized: bool) { + if self.view().is_foreign_hosted() { + return; + } + let mtm = MainThreadMarker::from(self); + let is_zoomed = self.is_zoomed(); + if is_zoomed == maximized { + return; + }; + + // Save the standard frame sized if it is not zoomed + if !is_zoomed { + self.ivars().standard_frame.set(Some(self.window().frame())); + } + + self.ivars().maximized.set(maximized); + + if self.ivars().fullscreen.borrow().is_some() { + // Handle it in window_did_exit_fullscreen + return; + } + + if self + .window() + .styleMask() + .contains(NSWindowStyleMask::Resizable) + { + // Just use the native zoom if resizable + self.window().zoom(None); + } else { + // if it's not resizable, we set the frame directly + let new_rect = if maximized { + let screen = + NSScreen::mainScreen(mtm).expect("no screen found"); + screen.visibleFrame() + } else { + self.ivars() + .standard_frame + .get() + .unwrap_or(DEFAULT_STANDARD_FRAME) + }; + self.window().setFrame_display(new_rect, false); + } + } + + #[inline] + pub(crate) fn fullscreen(&self) -> Option { + if self.view().is_foreign_hosted() + && self + .host_window() + .styleMask() + .contains(NSWindowStyleMask::FullScreen) + { + return Some(Fullscreen::Borderless(self.current_monitor_inner())); + } + self.ivars().fullscreen.borrow().clone() + } + + #[inline] + pub fn is_maximized(&self) -> bool { + self.is_zoomed() + } + + #[inline] + pub(crate) fn set_fullscreen(&self, fullscreen: Option) { + if self.view().is_foreign_hosted() { + return; + } + let mtm = MainThreadMarker::from(self); + let app = NSApplication::sharedApplication(mtm); + + if self.ivars().is_simple_fullscreen.get() { + return; + } + if self.ivars().in_fullscreen_transition.get() { + // We can't set fullscreen here. + // Set fullscreen after transition. + self.ivars().target_fullscreen.replace(Some(fullscreen)); + return; + } + let old_fullscreen = self.ivars().fullscreen.borrow().clone(); + if fullscreen == old_fullscreen { + return; + } + + // If the fullscreen is on a different monitor, we must move the window + // to that monitor before we toggle fullscreen (as `toggleFullScreen` + // does not take a screen parameter, but uses the current screen) + if let Some(ref fullscreen) = fullscreen { + let new_screen = match fullscreen { + Fullscreen::Borderless(Some(monitor)) => monitor.clone(), + Fullscreen::Borderless(None) => { + if let Some(monitor) = self.current_monitor_inner() { + monitor + } else { + return; + } + } + Fullscreen::Exclusive(video_mode) => video_mode.monitor(), + } + .ns_screen(mtm) + .unwrap(); + + let old_screen = self.window().screen().unwrap(); + if old_screen != new_screen { + unsafe { + self.window().setFrameOrigin(new_screen.frame().origin) + }; + } + } + + if let Some(Fullscreen::Exclusive(ref video_mode)) = fullscreen { + // Note: `enterFullScreenMode:withOptions:` seems to do the exact + // same thing as we're doing here (captures the display, sets the + // video mode, and hides the menu bar and dock), with the exception + // of that I couldn't figure out how to set the display mode with + // it. I think `enterFullScreenMode:withOptions:` is still using the + // older display mode API where display modes were of the type + // `CFDictionary`, but this has changed, so we can't obtain the + // correct parameter for this any longer. Apple's code samples for + // this function seem to just pass in "YES" for the display mode + // parameter, which is not consistent with the docs saying that it + // takes a `NSDictionary`.. + + let display_id = video_mode.monitor().native_identifier(); + + let mut fade_token = ffi::kCGDisplayFadeReservationInvalidToken; + + if matches!(old_fullscreen, Some(Fullscreen::Borderless(_))) { + self.ivars() + .save_presentation_opts + .replace(Some(app.presentationOptions())); + } + + unsafe { + // Fade to black (and wait for the fade to complete) to hide the + // flicker from capturing the display and switching display mode + if ffi::CGAcquireDisplayFadeReservation(5.0, &mut fade_token) + == ffi::kCGErrorSuccess + { + ffi::CGDisplayFade( + fade_token, + 0.3, + ffi::kCGDisplayBlendNormal, + ffi::kCGDisplayBlendSolidColor, + 0.0, + 0.0, + 0.0, + ffi::TRUE, + ); + } + + assert_eq!( + ffi::CGDisplayCapture(display_id), + ffi::kCGErrorSuccess + ); + } + + unsafe { + let result = ffi::CGDisplaySetDisplayMode( + display_id, + video_mode.native_mode.0, + std::ptr::null(), + ); + assert!( + result == ffi::kCGErrorSuccess, + "failed to set video mode" + ); + + // After the display has been configured, fade back in + // asynchronously + if fade_token != ffi::kCGDisplayFadeReservationInvalidToken { + ffi::CGDisplayFade( + fade_token, + 0.6, + ffi::kCGDisplayBlendSolidColor, + ffi::kCGDisplayBlendNormal, + 0.0, + 0.0, + 0.0, + ffi::FALSE, + ); + ffi::CGReleaseDisplayFadeReservation(fade_token); + } + } + } + + self.ivars().fullscreen.replace(fullscreen.clone()); + + fn toggle_fullscreen(window: &WinitWindow) { + // Window level must be restored from `CGShieldingWindowLevel() + // + 1` back to normal in order for `toggleFullScreen` to do + // anything + window.setLevel(ffi::kCGNormalWindowLevel as NSWindowLevel); + window.toggleFullScreen(None); + } + + match (old_fullscreen, fullscreen) { + (None, Some(fullscreen)) => { + // `toggleFullScreen` doesn't work if the `StyleMask` is none, so we + // set a normal style temporarily. The previous state will be + // restored in `WindowDelegate::window_did_exit_fullscreen`. + let curr_mask = self.window().styleMask(); + let required = + NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable; + if !curr_mask.contains(required) { + self.set_style_mask(required); + self.ivars().saved_style.set(Some(curr_mask)); + } + + // In borderless games, we want to disable the dock and menu bar + // by setting the presentation options. We do this here rather than in + // `window:willUseFullScreenPresentationOptions` because for some reason + // the menu bar remains interactable despite being hidden. + if self.is_borderless_game() + && matches!(fullscreen, Fullscreen::Borderless(_)) + { + let presentation_options = NSApplicationPresentationOptions::NSApplicationPresentationHideDock + | NSApplicationPresentationOptions::NSApplicationPresentationHideMenuBar; + app.setPresentationOptions(presentation_options); + } + + toggle_fullscreen(self.window()); + } + (Some(Fullscreen::Borderless(_)), None) => { + // State is restored by `window_did_exit_fullscreen` + toggle_fullscreen(self.window()); + } + (Some(Fullscreen::Exclusive(ref video_mode)), None) => { + restore_and_release_display(&video_mode.monitor()); + toggle_fullscreen(self.window()); + } + ( + Some(Fullscreen::Borderless(_)), + Some(Fullscreen::Exclusive(_)), + ) => { + // If we're already in fullscreen mode, calling + // `CGDisplayCapture` will place the shielding window on top of + // our window, which results in a black display and is not what + // we want. So, we must place our window on top of the shielding + // window. Unfortunately, this also makes our window be on top + // of the menu bar, and this looks broken, so we must make sure + // that the menu bar is disabled. This is done in the window + // delegate in `window:willUseFullScreenPresentationOptions:`. + self.ivars() + .save_presentation_opts + .set(Some(app.presentationOptions())); + + let presentation_options = + NSApplicationPresentationOptions::NSApplicationPresentationFullScreen + | NSApplicationPresentationOptions::NSApplicationPresentationHideDock + | NSApplicationPresentationOptions::NSApplicationPresentationHideMenuBar; + app.setPresentationOptions(presentation_options); + + let window_level = unsafe { ffi::CGShieldingWindowLevel() } + as NSWindowLevel + + 1; + self.window().setLevel(window_level); + } + ( + Some(Fullscreen::Exclusive(ref video_mode)), + Some(Fullscreen::Borderless(_)), + ) => { + let presentation_options = self.ivars().save_presentation_opts.get().unwrap_or( + NSApplicationPresentationOptions::NSApplicationPresentationFullScreen + | NSApplicationPresentationOptions::NSApplicationPresentationAutoHideDock + | NSApplicationPresentationOptions::NSApplicationPresentationAutoHideMenuBar + ); + app.setPresentationOptions(presentation_options); + + restore_and_release_display(&video_mode.monitor()); + + // Restore the normal window level following the Borderless fullscreen + // `CGShieldingWindowLevel() + 1` hack. + self.window() + .setLevel(ffi::kCGNormalWindowLevel as NSWindowLevel); + } + _ => {} + }; + } + + #[inline] + pub fn set_decorations(&self, decorations: bool) { + if decorations == self.ivars().decorations.get() { + return; + } + + self.ivars().decorations.set(decorations); + if self.view().is_foreign_hosted() { + return; + } + + let fullscreen = self.ivars().fullscreen.borrow().is_some(); + let resizable = self.ivars().resizable.get(); + + // If we're in fullscreen mode, we wait to apply decoration changes + // until we're in `window_did_exit_fullscreen`. + if fullscreen { + return; + } + + let new_mask = { + let mut new_mask = if decorations { + NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable + | NSWindowStyleMask::Resizable + | NSWindowStyleMask::Titled + } else { + NSWindowStyleMask::Borderless | NSWindowStyleMask::Resizable + }; + if !resizable { + new_mask &= !NSWindowStyleMask::Resizable; + } + new_mask + }; + self.set_style_mask(new_mask); + } + + #[inline] + pub fn is_decorated(&self) -> bool { + if self.view().is_foreign_hosted() { + self.host_window() + .styleMask() + .contains(NSWindowStyleMask::Titled) + } else { + self.ivars().decorations.get() + } + } + + #[inline] + pub fn set_window_level(&self, level: WindowLevel) { + if self.view().is_foreign_hosted() { + return; + } + let level = match level { + WindowLevel::AlwaysOnTop => { + ffi::kCGFloatingWindowLevel as NSWindowLevel + } + WindowLevel::AlwaysOnBottom => { + (ffi::kCGNormalWindowLevel - 1) as NSWindowLevel + } + WindowLevel::Normal => ffi::kCGNormalWindowLevel as NSWindowLevel, + }; + self.window().setLevel(level); + } + + #[inline] + pub fn set_window_icon(&self, _icon: Option) { + // macOS doesn't have window icons. Though, there is + // `setRepresentedFilename`, but that's semantically distinct and should + // only be used when the window is in some way representing a specific + // file/directory. For instance, Terminal.app uses this for the CWD. + // Anyway, that should eventually be implemented as + // `WindowAttributesExt::with_represented_file` or something, and doesn't + // have anything to do with `set_window_icon`. + // https://developer.apple.com/library/content/documentation/Cocoa/Conceptual/WinPanel/Tasks/SettingWindowTitle.html + } + + #[inline] + pub fn set_ime_cursor_area(&self, spot: Position, size: Size) { + let scale_factor = self.scale_factor(); + let logical_spot = spot.to_logical(scale_factor); + let logical_spot = NSPoint::new(logical_spot.x, logical_spot.y); + + let size = size.to_logical(scale_factor); + let size = NSSize::new(size.width, size.height); + + self.view().set_ime_cursor_area(logical_spot, size); + } + + #[inline] + pub fn set_ime_allowed(&self, allowed: bool) { + self.view().set_ime_allowed(allowed); + } + + #[inline] + pub fn set_ime_purpose(&self, _purpose: ImePurpose) {} + + #[inline] + pub fn focus_window(&self) { + let view = self.view(); + if view.is_foreign_hosted() { + let host = self.host_window(); + if !host.isMiniaturized() && host.isVisible() { + host.makeFirstResponder(Some(&view)); + } + return; + } + + let mtm = MainThreadMarker::from(self); + let is_minimized = self.window().isMiniaturized(); + let is_visible = self.window().isVisible(); + + if !is_minimized && is_visible { + #[allow(deprecated)] + NSApplication::sharedApplication(mtm) + .activateIgnoringOtherApps(true); + self.window().makeKeyAndOrderFront(None); + } + } + + #[inline] + pub fn request_user_attention( + &self, + request_type: Option, + ) { + let mtm = MainThreadMarker::from(self); + let ns_request_type = request_type.map(|ty| match ty { + UserAttentionType::Critical => { + NSRequestUserAttentionType::NSCriticalRequest + } + UserAttentionType::Informational => { + NSRequestUserAttentionType::NSInformationalRequest + } + }); + if let Some(ty) = ns_request_type { + NSApplication::sharedApplication(mtm).requestUserAttention(ty); + } + } + + #[inline] + // Allow directly accessing the current monitor internally without unwrapping. + pub(crate) fn current_monitor_inner(&self) -> Option { + let display_id = get_display_id(&*self.host_window().screen()?); + if let Some(monitor) = MonitorHandle::new(display_id) { + Some(monitor) + } else { + // NOTE: Display ID was just fetched from live NSScreen, but can still result in `None` + // with certain Thunderbolt docked monitors. + warn!(display_id, "got screen with invalid display ID"); + None + } + } + + #[inline] + pub fn current_monitor(&self) -> Option { + self.current_monitor_inner() + } + + #[inline] + pub fn available_monitors(&self) -> VecDeque { + monitor::available_monitors() + } + + #[inline] + pub fn primary_monitor(&self) -> Option { + let monitor = monitor::primary_monitor(); + Some(monitor) + } + + #[cfg(feature = "rwh_04")] + #[inline] + pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle { + let mut window_handle = rwh_04::AppKitHandle::empty(); + let host = self.host_window(); + window_handle.ns_window = Retained::as_ptr(&host) as *mut _; + window_handle.ns_view = Retained::as_ptr(&self.view()) as *mut _; + rwh_04::RawWindowHandle::AppKit(window_handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle { + let mut window_handle = rwh_05::AppKitWindowHandle::empty(); + let host = self.host_window(); + window_handle.ns_window = Retained::as_ptr(&host) as *mut _; + window_handle.ns_view = Retained::as_ptr(&self.view()) as *mut _; + rwh_05::RawWindowHandle::AppKit(window_handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::AppKit(rwh_05::AppKitDisplayHandle::empty()) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_window_handle_rwh_06(&self) -> rwh_06::RawWindowHandle { + let window_handle = rwh_06::AppKitWindowHandle::new({ + let ptr = Retained::as_ptr(&self.view()) as *mut _; + std::ptr::NonNull::new(ptr) + .expect("Retained should never be null") + }); + rwh_06::RawWindowHandle::AppKit(window_handle) + } + + fn toggle_style_mask(&self, mask: NSWindowStyleMask, on: bool) { + let current_style_mask = self.window().styleMask(); + if on { + self.set_style_mask(current_style_mask | mask); + } else { + self.set_style_mask(current_style_mask & !mask); + } + } + + #[inline] + pub fn has_focus(&self) -> bool { + self.host_window().isKeyWindow() + } + + pub fn theme(&self) -> Option { + unsafe { self.host_window().appearance() } + .map(|appearance| appearance_to_theme(&appearance)) + .or_else(|| { + let mtm = MainThreadMarker::from(self); + let app = NSApplication::sharedApplication(mtm); + + if app.respondsToSelector(sel!(effectiveAppearance)) { + Some(super::window_delegate::appearance_to_theme( + &app.effectiveAppearance(), + )) + } else { + Some(Theme::Light) + } + }) + } + + pub fn set_theme(&self, theme: Option) { + if self.view().is_foreign_hosted() { + return; + } + unsafe { + self.window() + .setAppearance(theme_to_appearance(theme).as_deref()) + }; + } + + #[inline] + pub fn set_content_protected(&self, protected: bool) { + if self.view().is_foreign_hosted() { + return; + } + self.window().setSharingType(if protected { + NSWindowSharingType::NSWindowSharingNone + } else { + NSWindowSharingType::NSWindowSharingReadOnly + }) + } + + pub fn title(&self) -> String { + self.host_window().title().to_string() + } + + pub fn reset_dead_keys(&self) { + // (Artur) I couldn't find a way to implement this. + } +} + +fn restore_and_release_display(monitor: &MonitorHandle) { + let available_monitors = monitor::available_monitors(); + if available_monitors.contains(monitor) { + unsafe { + ffi::CGRestorePermanentDisplayConfiguration(); + assert_eq!( + ffi::CGDisplayRelease(monitor.native_identifier()), + ffi::kCGErrorSuccess + ); + }; + } else { + warn!( + monitor = monitor.name(), + "Tried to restore exclusive fullscreen on a monitor that is no longer available" + ); + } +} + +impl WindowExtMacOS for WindowDelegate { + #[inline] + fn simple_fullscreen(&self) -> bool { + self.ivars().is_simple_fullscreen.get() + } + + #[inline] + fn set_simple_fullscreen(&self, fullscreen: bool) -> bool { + if self.view().is_foreign_hosted() { + return false; + } + let mtm = MainThreadMarker::from(self); + + let app = NSApplication::sharedApplication(mtm); + let is_native_fullscreen = self.ivars().fullscreen.borrow().is_some(); + let is_simple_fullscreen = self.ivars().is_simple_fullscreen.get(); + + // Do nothing if native fullscreen is active. + if is_native_fullscreen + || (fullscreen && is_simple_fullscreen) + || (!fullscreen && !is_simple_fullscreen) + { + return false; + } + + if fullscreen { + // Remember the original window's settings + // Exclude title bar + self.ivars().standard_frame.set(Some( + self.window().contentRectForFrameRect(self.window().frame()), + )); + self.ivars() + .saved_style + .set(Some(self.window().styleMask())); + self.ivars() + .save_presentation_opts + .set(Some(app.presentationOptions())); + + // Tell our window's state that we're in fullscreen + self.ivars().is_simple_fullscreen.set(true); + + // Simulate pre-Lion fullscreen by hiding the dock and menu bar + let presentation_options = if self.is_borderless_game() { + NSApplicationPresentationOptions::NSApplicationPresentationHideDock + | NSApplicationPresentationOptions::NSApplicationPresentationHideMenuBar + } else { + NSApplicationPresentationOptions::NSApplicationPresentationAutoHideDock + | NSApplicationPresentationOptions::NSApplicationPresentationAutoHideMenuBar + }; + app.setPresentationOptions(presentation_options); + + // Hide the titlebar + self.toggle_style_mask(NSWindowStyleMask::Titled, false); + + // Set the window frame to the screen frame size + let screen = self + .window() + .screen() + .expect("expected screen to be available"); + self.window().setFrame_display(screen.frame(), true); + + // Fullscreen windows can't be resized, minimized, or moved + self.toggle_style_mask(NSWindowStyleMask::Miniaturizable, false); + self.toggle_style_mask(NSWindowStyleMask::Resizable, false); + self.window().setMovable(false); + } else { + let new_mask = self.saved_style(); + self.ivars().is_simple_fullscreen.set(false); + + let save_presentation_opts = + self.ivars().save_presentation_opts.get(); + let frame = self + .ivars() + .standard_frame + .get() + .unwrap_or(DEFAULT_STANDARD_FRAME); + + if let Some(presentation_opts) = save_presentation_opts { + app.setPresentationOptions(presentation_opts); + } + + self.window().setFrame_display(frame, true); + self.window().setMovable(true); + self.set_style_mask(new_mask); + } + + true + } + + #[inline] + fn has_shadow(&self) -> bool { + self.host_window().hasShadow() + } + + #[inline] + fn set_has_shadow(&self, has_shadow: bool) { + if self.view().is_foreign_hosted() { + return; + } + self.window().setHasShadow(has_shadow) + } + + #[inline] + fn set_tabbing_identifier(&self, identifier: &str) { + if self.view().is_foreign_hosted() { + return; + } + self.window() + .setTabbingIdentifier(&NSString::from_str(identifier)) + } + + #[inline] + fn tabbing_identifier(&self) -> String { + self.window().tabbingIdentifier().to_string() + } + + #[inline] + fn select_next_tab(&self) { + if self.view().is_foreign_hosted() { + return; + } + self.window().selectNextTab(None) + } + + #[inline] + fn select_previous_tab(&self) { + if self.view().is_foreign_hosted() { + return; + } + unsafe { self.window().selectPreviousTab(None) } + } + + #[inline] + fn select_tab_at_index(&self, index: usize) { + if self.view().is_foreign_hosted() { + return; + } + if let Some(group) = self.window().tabGroup() { + if let Some(windows) = unsafe { self.window().tabbedWindows() } { + if index < windows.len() { + group.setSelectedWindow(Some(&windows[index])); + } + } + } + } + + #[inline] + fn num_tabs(&self) -> usize { + unsafe { self.window().tabbedWindows() } + .map(|windows| windows.len()) + .unwrap_or(1) + } + + fn is_document_edited(&self) -> bool { + self.window().isDocumentEdited() + } + + fn set_document_edited(&self, edited: bool) { + if self.view().is_foreign_hosted() { + return; + } + self.window().setDocumentEdited(edited) + } + + fn set_option_as_alt(&self, option_as_alt: OptionAsAlt) { + self.view().set_option_as_alt(option_as_alt); + } + + fn option_as_alt(&self) -> OptionAsAlt { + self.view().option_as_alt() + } + + fn set_borderless_game(&self, borderless_game: bool) { + self.ivars().is_borderless_game.set(borderless_game); + } + + fn is_borderless_game(&self) -> bool { + self.ivars().is_borderless_game.get() + } +} + +const DEFAULT_STANDARD_FRAME: NSRect = + NSRect::new(NSPoint::new(50.0, 50.0), NSSize::new(800.0, 600.0)); + +fn dark_appearance_name() -> &'static NSString { + // Don't use the static `NSAppearanceNameDarkAqua` to allow linking on macOS < 10.14 + ns_string!("NSAppearanceNameDarkAqua") +} + +pub fn appearance_to_theme(appearance: &NSAppearance) -> Theme { + let best_match = appearance.bestMatchFromAppearancesWithNames( + &NSArray::from_id_slice(&[ + unsafe { NSAppearanceNameAqua.copy() }, + dark_appearance_name().copy(), + ]), + ); + if let Some(best_match) = best_match { + if *best_match == *dark_appearance_name() { + Theme::Dark + } else { + Theme::Light + } + } else { + warn!( + ?appearance, + "failed to determine the theme of the appearance" + ); + // Default to light in this case + Theme::Light + } +} + +fn theme_to_appearance(theme: Option) -> Option> { + let appearance = match theme? { + Theme::Light => unsafe { + NSAppearance::appearanceNamed(NSAppearanceNameAqua) + }, + Theme::Dark => NSAppearance::appearanceNamed(dark_appearance_name()), + }; + if let Some(appearance) = appearance { + Some(appearance) + } else { + warn!(?theme, "could not find appearance for theme"); + // Assume system appearance in this case + None + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/mod.rs new file mode 100644 index 00000000..3bfce688 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/mod.rs @@ -0,0 +1,78 @@ +use crate::monitor::{MonitorHandle as RootMonitorHandle, VideoModeHandle as RootVideoModeHandle}; +use crate::window::Fullscreen as RootFullscreen; + +#[cfg(android_platform)] +mod android; +#[cfg(ios_platform)] +mod ios; +#[cfg(any(x11_platform, wayland_platform))] +mod linux; +#[cfg(macos_platform)] +mod macos; +#[cfg(orbital_platform)] +mod orbital; +#[cfg(web_platform)] +mod web; +#[cfg(windows_platform)] +mod windows; + +#[cfg(android_platform)] +use android as platform; +#[cfg(ios_platform)] +use ios as platform; +#[cfg(any(x11_platform, wayland_platform))] +use linux as platform; +#[cfg(macos_platform)] +use macos as platform; +#[cfg(orbital_platform)] +use orbital as platform; +#[cfg(web_platform)] +use web as platform; +#[cfg(windows_platform)] +use windows as platform; + +pub use self::platform::*; + +/// Helper for converting between platform-specific and generic +/// [`VideoModeHandle`]/[`MonitorHandle`] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Fullscreen { + Exclusive(VideoModeHandle), + Borderless(Option), +} + +impl From for Fullscreen { + fn from(f: RootFullscreen) -> Self { + match f { + RootFullscreen::Exclusive(mode) => Self::Exclusive(mode.video_mode), + RootFullscreen::Borderless(Some(handle)) => Self::Borderless(Some(handle.inner)), + RootFullscreen::Borderless(None) => Self::Borderless(None), + } + } +} + +impl From for RootFullscreen { + fn from(f: Fullscreen) -> Self { + match f { + Fullscreen::Exclusive(video_mode) => { + Self::Exclusive(RootVideoModeHandle { video_mode }) + }, + Fullscreen::Borderless(Some(inner)) => { + Self::Borderless(Some(RootMonitorHandle { inner })) + }, + Fullscreen::Borderless(None) => Self::Borderless(None), + } + } +} + +#[cfg(all( + not(ios_platform), + not(windows_platform), + not(macos_platform), + not(android_platform), + not(x11_platform), + not(wayland_platform), + not(web_platform), + not(orbital_platform), +))] +compile_error!("The platform you're compiling for is not supported by winit"); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/event_loop.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/event_loop.rs new file mode 100644 index 00000000..91f76e75 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/event_loop.rs @@ -0,0 +1,829 @@ +use std::cell::Cell; +use std::collections::VecDeque; +use std::marker::PhantomData; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Instant; +use std::{mem, slice}; + +use bitflags::bitflags; +use orbclient::{ + ButtonEvent, EventOption, FocusEvent, HoverEvent, KeyEvent, MouseEvent, MouseRelativeEvent, + MoveEvent, QuitEvent, ResizeEvent, ScrollEvent, TextInputEvent, +}; +use smol_str::SmolStr; + +use crate::error::EventLoopError; +use crate::event::{self, Ime, Modifiers, StartCause}; +use crate::event_loop::{self, ControlFlow, DeviceEvents}; +use crate::keyboard::{ + Key, KeyCode, KeyLocation, ModifiersKeys, ModifiersState, NamedKey, NativeKey, NativeKeyCode, + PhysicalKey, +}; +use crate::window::{ + CustomCursor as RootCustomCursor, CustomCursorSource, Theme, WindowId as RootWindowId, +}; + +use super::{ + DeviceId, KeyEventExtra, MonitorHandle, OsError, PlatformSpecificEventLoopAttributes, + RedoxSocket, TimeSocket, WindowId, WindowProperties, +}; + +fn convert_scancode(scancode: u8) -> (PhysicalKey, Option) { + // Key constants from https://docs.rs/orbclient/latest/orbclient/event/index.html + let (key_code, named_key_opt) = match scancode { + orbclient::K_A => (KeyCode::KeyA, None), + orbclient::K_B => (KeyCode::KeyB, None), + orbclient::K_C => (KeyCode::KeyC, None), + orbclient::K_D => (KeyCode::KeyD, None), + orbclient::K_E => (KeyCode::KeyE, None), + orbclient::K_F => (KeyCode::KeyF, None), + orbclient::K_G => (KeyCode::KeyG, None), + orbclient::K_H => (KeyCode::KeyH, None), + orbclient::K_I => (KeyCode::KeyI, None), + orbclient::K_J => (KeyCode::KeyJ, None), + orbclient::K_K => (KeyCode::KeyK, None), + orbclient::K_L => (KeyCode::KeyL, None), + orbclient::K_M => (KeyCode::KeyM, None), + orbclient::K_N => (KeyCode::KeyN, None), + orbclient::K_O => (KeyCode::KeyO, None), + orbclient::K_P => (KeyCode::KeyP, None), + orbclient::K_Q => (KeyCode::KeyQ, None), + orbclient::K_R => (KeyCode::KeyR, None), + orbclient::K_S => (KeyCode::KeyS, None), + orbclient::K_T => (KeyCode::KeyT, None), + orbclient::K_U => (KeyCode::KeyU, None), + orbclient::K_V => (KeyCode::KeyV, None), + orbclient::K_W => (KeyCode::KeyW, None), + orbclient::K_X => (KeyCode::KeyX, None), + orbclient::K_Y => (KeyCode::KeyY, None), + orbclient::K_Z => (KeyCode::KeyZ, None), + orbclient::K_0 => (KeyCode::Digit0, None), + orbclient::K_1 => (KeyCode::Digit1, None), + orbclient::K_2 => (KeyCode::Digit2, None), + orbclient::K_3 => (KeyCode::Digit3, None), + orbclient::K_4 => (KeyCode::Digit4, None), + orbclient::K_5 => (KeyCode::Digit5, None), + orbclient::K_6 => (KeyCode::Digit6, None), + orbclient::K_7 => (KeyCode::Digit7, None), + orbclient::K_8 => (KeyCode::Digit8, None), + orbclient::K_9 => (KeyCode::Digit9, None), + + orbclient::K_ALT => (KeyCode::AltLeft, Some(NamedKey::Alt)), + orbclient::K_ALT_GR => (KeyCode::AltRight, Some(NamedKey::AltGraph)), + orbclient::K_BACKSLASH => (KeyCode::Backslash, None), + orbclient::K_BKSP => (KeyCode::Backspace, Some(NamedKey::Backspace)), + orbclient::K_BRACE_CLOSE => (KeyCode::BracketRight, None), + orbclient::K_BRACE_OPEN => (KeyCode::BracketLeft, None), + orbclient::K_CAPS => (KeyCode::CapsLock, Some(NamedKey::CapsLock)), + orbclient::K_COMMA => (KeyCode::Comma, None), + orbclient::K_CTRL => (KeyCode::ControlLeft, Some(NamedKey::Control)), + orbclient::K_DEL => (KeyCode::Delete, Some(NamedKey::Delete)), + orbclient::K_DOWN => (KeyCode::ArrowDown, Some(NamedKey::ArrowDown)), + orbclient::K_END => (KeyCode::End, Some(NamedKey::End)), + orbclient::K_ENTER => (KeyCode::Enter, Some(NamedKey::Enter)), + orbclient::K_EQUALS => (KeyCode::Equal, None), + orbclient::K_ESC => (KeyCode::Escape, Some(NamedKey::Escape)), + orbclient::K_F1 => (KeyCode::F1, Some(NamedKey::F1)), + orbclient::K_F2 => (KeyCode::F2, Some(NamedKey::F2)), + orbclient::K_F3 => (KeyCode::F3, Some(NamedKey::F3)), + orbclient::K_F4 => (KeyCode::F4, Some(NamedKey::F4)), + orbclient::K_F5 => (KeyCode::F5, Some(NamedKey::F5)), + orbclient::K_F6 => (KeyCode::F6, Some(NamedKey::F6)), + orbclient::K_F7 => (KeyCode::F7, Some(NamedKey::F7)), + orbclient::K_F8 => (KeyCode::F8, Some(NamedKey::F8)), + orbclient::K_F9 => (KeyCode::F9, Some(NamedKey::F9)), + orbclient::K_F10 => (KeyCode::F10, Some(NamedKey::F10)), + orbclient::K_F11 => (KeyCode::F11, Some(NamedKey::F11)), + orbclient::K_F12 => (KeyCode::F12, Some(NamedKey::F12)), + orbclient::K_HOME => (KeyCode::Home, Some(NamedKey::Home)), + orbclient::K_LEFT => (KeyCode::ArrowLeft, Some(NamedKey::ArrowLeft)), + orbclient::K_LEFT_SHIFT => (KeyCode::ShiftLeft, Some(NamedKey::Shift)), + orbclient::K_MINUS => (KeyCode::Minus, None), + orbclient::K_NUM_0 => (KeyCode::Numpad0, None), + orbclient::K_NUM_1 => (KeyCode::Numpad1, None), + orbclient::K_NUM_2 => (KeyCode::Numpad2, None), + orbclient::K_NUM_3 => (KeyCode::Numpad3, None), + orbclient::K_NUM_4 => (KeyCode::Numpad4, None), + orbclient::K_NUM_5 => (KeyCode::Numpad5, None), + orbclient::K_NUM_6 => (KeyCode::Numpad6, None), + orbclient::K_NUM_7 => (KeyCode::Numpad7, None), + orbclient::K_NUM_8 => (KeyCode::Numpad8, None), + orbclient::K_NUM_9 => (KeyCode::Numpad9, None), + orbclient::K_PERIOD => (KeyCode::Period, None), + orbclient::K_PGDN => (KeyCode::PageDown, Some(NamedKey::PageDown)), + orbclient::K_PGUP => (KeyCode::PageUp, Some(NamedKey::PageUp)), + orbclient::K_QUOTE => (KeyCode::Quote, None), + orbclient::K_RIGHT => (KeyCode::ArrowRight, Some(NamedKey::ArrowRight)), + orbclient::K_RIGHT_SHIFT => (KeyCode::ShiftRight, Some(NamedKey::Shift)), + orbclient::K_SEMICOLON => (KeyCode::Semicolon, None), + orbclient::K_SLASH => (KeyCode::Slash, None), + orbclient::K_SPACE => (KeyCode::Space, Some(NamedKey::Space)), + orbclient::K_SUPER => (KeyCode::SuperLeft, Some(NamedKey::Super)), + orbclient::K_TAB => (KeyCode::Tab, Some(NamedKey::Tab)), + orbclient::K_TICK => (KeyCode::Backquote, None), + orbclient::K_UP => (KeyCode::ArrowUp, Some(NamedKey::ArrowUp)), + orbclient::K_VOLUME_DOWN => (KeyCode::AudioVolumeDown, Some(NamedKey::AudioVolumeDown)), + orbclient::K_VOLUME_TOGGLE => (KeyCode::AudioVolumeMute, Some(NamedKey::AudioVolumeMute)), + orbclient::K_VOLUME_UP => (KeyCode::AudioVolumeUp, Some(NamedKey::AudioVolumeUp)), + + _ => return (PhysicalKey::Unidentified(NativeKeyCode::Unidentified), None), + }; + (PhysicalKey::Code(key_code), named_key_opt) +} + +fn element_state(pressed: bool) -> event::ElementState { + if pressed { + event::ElementState::Pressed + } else { + event::ElementState::Released + } +} + +bitflags! { + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + struct KeyboardModifierState: u8 { + const LSHIFT = 1 << 0; + const RSHIFT = 1 << 1; + const LCTRL = 1 << 2; + const RCTRL = 1 << 3; + const LALT = 1 << 4; + const RALT = 1 << 5; + const LSUPER = 1 << 6; + const RSUPER = 1 << 7; + } +} + +bitflags! { + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + struct MouseButtonState: u8 { + const LEFT = 1 << 0; + const MIDDLE = 1 << 1; + const RIGHT = 1 << 2; + } +} + +#[derive(Default)] +struct EventState { + keyboard: KeyboardModifierState, + mouse: MouseButtonState, + resize_opt: Option<(u32, u32)>, +} + +impl EventState { + fn character_all_modifiers(&self, character: char) -> char { + // Modify character if Ctrl is pressed + #[allow(clippy::collapsible_if)] + if self.keyboard.contains(KeyboardModifierState::LCTRL) + || self.keyboard.contains(KeyboardModifierState::RCTRL) + { + if character.is_ascii_lowercase() { + return ((character as u8 - b'a') + 1) as char; + } + // TODO: more control key variants? + } + + // Return character as-is if no special handling required + character + } + + fn key(&mut self, key: PhysicalKey, pressed: bool) { + let code = match key { + PhysicalKey::Code(code) => code, + _ => return, + }; + + match code { + KeyCode::ShiftLeft => self.keyboard.set(KeyboardModifierState::LSHIFT, pressed), + KeyCode::ShiftRight => self.keyboard.set(KeyboardModifierState::RSHIFT, pressed), + KeyCode::ControlLeft => self.keyboard.set(KeyboardModifierState::LCTRL, pressed), + KeyCode::ControlRight => self.keyboard.set(KeyboardModifierState::RCTRL, pressed), + KeyCode::AltLeft => self.keyboard.set(KeyboardModifierState::LALT, pressed), + KeyCode::AltRight => self.keyboard.set(KeyboardModifierState::RALT, pressed), + KeyCode::SuperLeft => self.keyboard.set(KeyboardModifierState::LSUPER, pressed), + KeyCode::SuperRight => self.keyboard.set(KeyboardModifierState::RSUPER, pressed), + _ => (), + } + } + + fn mouse( + &mut self, + left: bool, + middle: bool, + right: bool, + ) -> Option<(event::MouseButton, event::ElementState)> { + if self.mouse.contains(MouseButtonState::LEFT) != left { + self.mouse.set(MouseButtonState::LEFT, left); + return Some((event::MouseButton::Left, element_state(left))); + } + + if self.mouse.contains(MouseButtonState::MIDDLE) != middle { + self.mouse.set(MouseButtonState::MIDDLE, middle); + return Some((event::MouseButton::Middle, element_state(middle))); + } + + if self.mouse.contains(MouseButtonState::RIGHT) != right { + self.mouse.set(MouseButtonState::RIGHT, right); + return Some((event::MouseButton::Right, element_state(right))); + } + + None + } + + fn modifiers(&self) -> Modifiers { + let mut state = ModifiersState::empty(); + let mut pressed_mods = ModifiersKeys::empty(); + + if self.keyboard.intersects(KeyboardModifierState::LSHIFT | KeyboardModifierState::RSHIFT) { + state |= ModifiersState::SHIFT; + } + + pressed_mods + .set(ModifiersKeys::LSHIFT, self.keyboard.contains(KeyboardModifierState::LSHIFT)); + pressed_mods + .set(ModifiersKeys::RSHIFT, self.keyboard.contains(KeyboardModifierState::RSHIFT)); + + if self.keyboard.intersects(KeyboardModifierState::LCTRL | KeyboardModifierState::RCTRL) { + state |= ModifiersState::CONTROL; + } + + pressed_mods + .set(ModifiersKeys::LCONTROL, self.keyboard.contains(KeyboardModifierState::LCTRL)); + pressed_mods + .set(ModifiersKeys::RCONTROL, self.keyboard.contains(KeyboardModifierState::RCTRL)); + + if self.keyboard.intersects(KeyboardModifierState::LALT | KeyboardModifierState::RALT) { + state |= ModifiersState::ALT; + } + + pressed_mods.set(ModifiersKeys::LALT, self.keyboard.contains(KeyboardModifierState::LALT)); + pressed_mods.set(ModifiersKeys::RALT, self.keyboard.contains(KeyboardModifierState::RALT)); + + if self.keyboard.intersects(KeyboardModifierState::LSUPER | KeyboardModifierState::RSUPER) { + state |= ModifiersState::SUPER + } + + pressed_mods + .set(ModifiersKeys::LSUPER, self.keyboard.contains(KeyboardModifierState::LSUPER)); + pressed_mods + .set(ModifiersKeys::RSUPER, self.keyboard.contains(KeyboardModifierState::RSUPER)); + + Modifiers { state, pressed_mods } + } +} + +pub struct EventLoop { + windows: Vec<(Arc, EventState)>, + window_target: event_loop::ActiveEventLoop, + user_events_sender: mpsc::Sender, + user_events_receiver: mpsc::Receiver, +} + +impl EventLoop { + pub(crate) fn new(_: &PlatformSpecificEventLoopAttributes) -> Result { + let (user_events_sender, user_events_receiver) = mpsc::channel(); + + let event_socket = Arc::new( + RedoxSocket::event() + .map_err(OsError::new) + .map_err(|error| EventLoopError::Os(os_error!(error)))?, + ); + + let wake_socket = Arc::new( + TimeSocket::open() + .map_err(OsError::new) + .map_err(|error| EventLoopError::Os(os_error!(error)))?, + ); + + event_socket + .write(&syscall::Event { + id: wake_socket.0.fd, + flags: syscall::EventFlags::EVENT_READ, + data: wake_socket.0.fd, + }) + .map_err(OsError::new) + .map_err(|error| EventLoopError::Os(os_error!(error)))?; + + Ok(Self { + windows: Vec::new(), + window_target: event_loop::ActiveEventLoop { + p: ActiveEventLoop { + control_flow: Cell::new(ControlFlow::default()), + exit: Cell::new(false), + creates: Mutex::new(VecDeque::new()), + redraws: Arc::new(Mutex::new(VecDeque::new())), + destroys: Arc::new(Mutex::new(VecDeque::new())), + event_socket, + wake_socket, + }, + _marker: PhantomData, + }, + user_events_sender, + user_events_receiver, + }) + } + + fn process_event( + window_id: WindowId, + event_option: EventOption, + event_state: &mut EventState, + mut event_handler: F, + ) where + F: FnMut(event::Event), + { + match event_option { + EventOption::Key(KeyEvent { character, scancode, pressed }) => { + // Convert scancode + let (physical_key, named_key_opt) = convert_scancode(scancode); + + // Get previous modifiers and update modifiers based on physical key + let modifiers_before = event_state.keyboard; + event_state.key(physical_key, pressed); + + // Default to unidentified key with no text + let mut logical_key = Key::Unidentified(NativeKey::Unidentified); + let mut key_without_modifiers = logical_key.clone(); + let mut text = None; + let mut text_with_all_modifiers = None; + + // Set key and text based on character + if character != '\0' { + let mut tmp = [0u8; 4]; + let character_str = character.encode_utf8(&mut tmp); + // The key with Shift and Caps Lock applied (but not Ctrl) + logical_key = Key::Character(character_str.into()); + // The key without Shift or Caps Lock applied + key_without_modifiers = + Key::Character(SmolStr::from_iter(character.to_lowercase())); + if pressed { + // The key with Shift and Caps Lock applied (but not Ctrl) + text = Some(character_str.into()); + // The key with Shift, Caps Lock, and Ctrl applied + let character_all_modifiers = + event_state.character_all_modifiers(character); + text_with_all_modifiers = + Some(character_all_modifiers.encode_utf8(&mut tmp).into()) + } + }; + + // Override key if a named key was found (this is to allow Enter to replace '\n') + if let Some(named_key) = named_key_opt { + logical_key = Key::Named(named_key); + key_without_modifiers = logical_key.clone(); + } + + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::KeyboardInput { + device_id: event::DeviceId(DeviceId), + event: event::KeyEvent { + logical_key, + physical_key, + location: KeyLocation::Standard, + state: element_state(pressed), + repeat: false, + text, + platform_specific: KeyEventExtra { + key_without_modifiers, + text_with_all_modifiers, + }, + }, + is_synthetic: false, + }, + }); + + // If the state of the modifiers has changed, send the event. + if modifiers_before != event_state.keyboard { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::ModifiersChanged(event_state.modifiers()), + }) + } + }, + EventOption::TextInput(TextInputEvent { character }) => { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::Ime(Ime::Preedit("".into(), None)), + }); + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::Ime(Ime::Commit(character.into())), + }); + }, + EventOption::Mouse(MouseEvent { x, y }) => { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::CursorMoved { + device_id: event::DeviceId(DeviceId), + position: (x, y).into(), + }, + }); + }, + EventOption::MouseRelative(MouseRelativeEvent { dx, dy }) => { + event_handler(event::Event::DeviceEvent { + device_id: event::DeviceId(DeviceId), + event: event::DeviceEvent::MouseMotion { delta: (dx as f64, dy as f64) }, + }); + }, + EventOption::Button(ButtonEvent { left, middle, right }) => { + while let Some((button, state)) = event_state.mouse(left, middle, right) { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::MouseInput { + device_id: event::DeviceId(DeviceId), + state, + button, + }, + }); + } + }, + EventOption::Scroll(ScrollEvent { x, y }) => { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::MouseWheel { + device_id: event::DeviceId(DeviceId), + delta: event::MouseScrollDelta::LineDelta(x as f32, y as f32), + phase: event::TouchPhase::Moved, + }, + }); + }, + EventOption::Quit(QuitEvent {}) => { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::CloseRequested, + }); + }, + EventOption::Focus(FocusEvent { focused }) => { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::Focused(focused), + }); + }, + EventOption::Move(MoveEvent { x, y }) => { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::Moved((x, y).into()), + }); + }, + EventOption::Resize(ResizeEvent { width, height }) => { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::Resized((width, height).into()), + }); + + // Acknowledge resize after event loop. + event_state.resize_opt = Some((width, height)); + }, + // TODO: Screen, Clipboard, Drop + EventOption::Hover(HoverEvent { entered }) => { + if entered { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::CursorEntered { + device_id: event::DeviceId(DeviceId), + }, + }); + } else { + event_handler(event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::CursorLeft { + device_id: event::DeviceId(DeviceId), + }, + }); + } + }, + other => { + tracing::warn!("unhandled event: {:?}", other); + }, + } + } + + pub fn run(mut self, mut event_handler_inner: F) -> Result<(), EventLoopError> + where + F: FnMut(event::Event, &event_loop::ActiveEventLoop), + { + let mut event_handler = + move |event: event::Event, window_target: &event_loop::ActiveEventLoop| { + event_handler_inner(event, window_target); + }; + + let mut start_cause = StartCause::Init; + + loop { + event_handler(event::Event::NewEvents(start_cause), &self.window_target); + + if start_cause == StartCause::Init { + event_handler(event::Event::Resumed, &self.window_target); + } + + // Handle window creates. + while let Some(window) = { + let mut creates = self.window_target.p.creates.lock().unwrap(); + creates.pop_front() + } { + let window_id = WindowId { fd: window.fd as u64 }; + + let mut buf: [u8; 4096] = [0; 4096]; + let path = window.fpath(&mut buf).expect("failed to read properties"); + let properties = WindowProperties::new(path); + + self.windows.push((window, EventState::default())); + + // Send resize event on create to indicate first size. + event_handler( + event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::Resized((properties.w, properties.h).into()), + }, + &self.window_target, + ); + + // Send resize event on create to indicate first position. + event_handler( + event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::Moved((properties.x, properties.y).into()), + }, + &self.window_target, + ); + } + + // Handle window destroys. + while let Some(destroy_id) = { + let mut destroys = self.window_target.p.destroys.lock().unwrap(); + destroys.pop_front() + } { + event_handler( + event::Event::WindowEvent { + window_id: RootWindowId(destroy_id), + event: event::WindowEvent::Destroyed, + }, + &self.window_target, + ); + + self.windows.retain(|(window, _event_state)| window.fd as u64 != destroy_id.fd); + } + + // Handle window events. + let mut i = 0; + // While loop is used here because the same window may be processed more than once. + while let Some((window, event_state)) = self.windows.get_mut(i) { + let window_id = WindowId { fd: window.fd as u64 }; + + let mut event_buf = [0u8; 16 * mem::size_of::()]; + let count = + syscall::read(window.fd, &mut event_buf).expect("failed to read window events"); + // Safety: orbclient::Event is a packed struct designed to be transferred over a + // socket. + let events = unsafe { + slice::from_raw_parts( + event_buf.as_ptr() as *const orbclient::Event, + count / mem::size_of::(), + ) + }; + + for orbital_event in events { + Self::process_event( + window_id, + orbital_event.to_option(), + event_state, + |event| event_handler(event, &self.window_target), + ); + } + + if count == event_buf.len() { + // If event buf was full, process same window again to ensure all events are + // drained. + continue; + } + + // Acknowledge the latest resize event. + if let Some((w, h)) = event_state.resize_opt.take() { + window + .write(format!("S,{w},{h}").as_bytes()) + .expect("failed to acknowledge resize"); + + // Require redraw after resize. + let mut redraws = self.window_target.p.redraws.lock().unwrap(); + if !redraws.contains(&window_id) { + redraws.push_back(window_id); + } + } + + // Move to next window. + i += 1; + } + + while let Ok(event) = self.user_events_receiver.try_recv() { + event_handler(event::Event::UserEvent(event), &self.window_target); + } + + // To avoid deadlocks the redraws lock is not held during event processing. + while let Some(window_id) = { + let mut redraws = self.window_target.p.redraws.lock().unwrap(); + redraws.pop_front() + } { + event_handler( + event::Event::WindowEvent { + window_id: RootWindowId(window_id), + event: event::WindowEvent::RedrawRequested, + }, + &self.window_target, + ); + } + + event_handler(event::Event::AboutToWait, &self.window_target); + + if self.window_target.p.exiting() { + break; + } + + let requested_resume = match self.window_target.p.control_flow() { + ControlFlow::Poll => { + start_cause = StartCause::Poll; + continue; + }, + ControlFlow::Wait => None, + ControlFlow::WaitUntil(instant) => Some(instant), + }; + + // Re-using wake socket caused extra wake events before because there were leftover + // timeouts, and then new timeouts were added each time a spurious timeout expired. + let timeout_socket = TimeSocket::open().unwrap(); + + self.window_target + .p + .event_socket + .write(&syscall::Event { + id: timeout_socket.0.fd, + flags: syscall::EventFlags::EVENT_READ, + data: 0, + }) + .unwrap(); + + let start = Instant::now(); + if let Some(instant) = requested_resume { + let mut time = timeout_socket.current_time().unwrap(); + + if let Some(duration) = instant.checked_duration_since(start) { + time.tv_sec += duration.as_secs() as i64; + time.tv_nsec += duration.subsec_nanos() as i32; + // Normalize timespec so tv_nsec is not greater than one second. + while time.tv_nsec >= 1_000_000_000 { + time.tv_sec += 1; + time.tv_nsec -= 1_000_000_000; + } + } + + timeout_socket.timeout(&time).unwrap(); + } + + // Wait for event if needed. + let mut event = syscall::Event::default(); + self.window_target.p.event_socket.read(&mut event).unwrap(); + + // TODO: handle spurious wakeups (redraw caused wakeup but redraw already handled) + match requested_resume { + Some(requested_resume) if event.id == timeout_socket.0.fd => { + // If the event is from the special timeout socket, report that resume + // time was reached. + start_cause = StartCause::ResumeTimeReached { start, requested_resume }; + }, + _ => { + // Normal window event or spurious timeout. + start_cause = StartCause::WaitCancelled { start, requested_resume }; + }, + } + } + + event_handler(event::Event::LoopExiting, &self.window_target); + + Ok(()) + } + + pub fn window_target(&self) -> &event_loop::ActiveEventLoop { + &self.window_target + } + + pub fn create_proxy(&self) -> EventLoopProxy { + EventLoopProxy { + user_events_sender: self.user_events_sender.clone(), + wake_socket: self.window_target.p.wake_socket.clone(), + } + } +} + +pub struct EventLoopProxy { + user_events_sender: mpsc::Sender, + wake_socket: Arc, +} + +impl EventLoopProxy { + pub fn send_event(&self, event: T) -> Result<(), event_loop::EventLoopClosed> { + self.user_events_sender + .send(event) + .map_err(|mpsc::SendError(x)| event_loop::EventLoopClosed(x))?; + + self.wake_socket.wake().unwrap(); + + Ok(()) + } +} + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + Self { + user_events_sender: self.user_events_sender.clone(), + wake_socket: self.wake_socket.clone(), + } + } +} + +impl Unpin for EventLoopProxy {} + +pub struct ActiveEventLoop { + control_flow: Cell, + exit: Cell, + pub(super) creates: Mutex>>, + pub(super) redraws: Arc>>, + pub(super) destroys: Arc>>, + pub(super) event_socket: Arc, + pub(super) wake_socket: Arc, +} + +impl ActiveEventLoop { + pub fn create_custom_cursor(&self, source: CustomCursorSource) -> RootCustomCursor { + let _ = source.inner; + RootCustomCursor { inner: super::PlatformCustomCursor } + } + + pub fn primary_monitor(&self) -> Option { + Some(MonitorHandle) + } + + pub fn available_monitors(&self) -> VecDeque { + let mut v = VecDeque::with_capacity(1); + v.push_back(MonitorHandle); + v + } + + #[inline] + pub fn listen_device_events(&self, _allowed: DeviceEvents) {} + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::Orbital(rwh_05::OrbitalDisplayHandle::empty()) + } + + #[inline] + pub fn system_theme(&self) -> Option { + None + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::Orbital(rwh_06::OrbitalDisplayHandle::new())) + } + + pub fn set_control_flow(&self, control_flow: ControlFlow) { + self.control_flow.set(control_flow) + } + + pub fn control_flow(&self) -> ControlFlow { + self.control_flow.get() + } + + pub(crate) fn exit(&self) { + self.exit.set(true); + } + + pub(crate) fn exiting(&self) -> bool { + self.exit.get() + } + + pub(crate) fn owned_display_handle(&self) -> OwnedDisplayHandle { + OwnedDisplayHandle + } +} + +#[derive(Clone)] +pub(crate) struct OwnedDisplayHandle; + +impl OwnedDisplayHandle { + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::OrbitalDisplayHandle::empty().into() + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::OrbitalDisplayHandle::new().into()) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/mod.rs new file mode 100644 index 00000000..2d152513 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/mod.rs @@ -0,0 +1,255 @@ +#![cfg(target_os = "redox")] + +use std::fmt::{self, Display, Formatter}; +use std::str; +use std::sync::Arc; + +use smol_str::SmolStr; + +use crate::dpi::{PhysicalPosition, PhysicalSize}; +use crate::keyboard::Key; + +pub(crate) use self::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy, OwnedDisplayHandle}; +mod event_loop; + +pub use self::window::Window; +mod window; + +struct RedoxSocket { + fd: usize, +} + +impl RedoxSocket { + fn event() -> syscall::Result { + Self::open_raw("event:") + } + + fn orbital(properties: &WindowProperties<'_>) -> syscall::Result { + Self::open_raw(&format!("{properties}")) + } + + // Paths should be checked to ensure they are actually sockets and not normal files. If a + // non-socket path is used, it could cause read and write to not function as expected. For + // example, the seek would change in a potentially unpredictable way if either read or write + // were called at the same time by multiple threads. + fn open_raw(path: &str) -> syscall::Result { + let fd = syscall::open(path, syscall::O_RDWR | syscall::O_CLOEXEC)?; + Ok(Self { fd }) + } + + fn read(&self, buf: &mut [u8]) -> syscall::Result<()> { + let count = syscall::read(self.fd, buf)?; + if count == buf.len() { + Ok(()) + } else { + Err(syscall::Error::new(syscall::EINVAL)) + } + } + + fn write(&self, buf: &[u8]) -> syscall::Result<()> { + let count = syscall::write(self.fd, buf)?; + if count == buf.len() { + Ok(()) + } else { + Err(syscall::Error::new(syscall::EINVAL)) + } + } + + fn fpath<'a>(&self, buf: &'a mut [u8]) -> syscall::Result<&'a str> { + let count = syscall::fpath(self.fd, buf)?; + str::from_utf8(&buf[..count]).map_err(|_err| syscall::Error::new(syscall::EINVAL)) + } +} + +impl Drop for RedoxSocket { + fn drop(&mut self) { + let _ = syscall::close(self.fd); + } +} + +pub struct TimeSocket(RedoxSocket); + +impl TimeSocket { + fn open() -> syscall::Result { + RedoxSocket::open_raw("time:4").map(Self) + } + + // Read current time. + fn current_time(&self) -> syscall::Result { + let mut timespec = syscall::TimeSpec::default(); + self.0.read(&mut timespec)?; + Ok(timespec) + } + + // Write a timeout. + fn timeout(&self, timespec: &syscall::TimeSpec) -> syscall::Result<()> { + self.0.write(timespec) + } + + // Wake immediately. + fn wake(&self) -> syscall::Result<()> { + // Writing a default TimeSpec will always trigger a time event. + self.timeout(&syscall::TimeSpec::default()) + } +} + +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PlatformSpecificEventLoopAttributes {} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct WindowId { + fd: u64, +} + +impl WindowId { + pub const fn dummy() -> Self { + WindowId { fd: u64::MAX } + } +} + +impl From for u64 { + fn from(id: WindowId) -> Self { + id.fd + } +} + +impl From for WindowId { + fn from(fd: u64) -> Self { + Self { fd } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct DeviceId; + +impl DeviceId { + pub const fn dummy() -> Self { + DeviceId + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PlatformSpecificWindowAttributes; + +struct WindowProperties<'a> { + flags: &'a str, + x: i32, + y: i32, + w: u32, + h: u32, + title: &'a str, +} + +impl<'a> WindowProperties<'a> { + fn new(path: &'a str) -> Self { + // orbital:flags/x/y/w/h/t + let mut parts = path.splitn(6, '/'); + let flags = parts.next().unwrap_or(""); + let x = parts.next().map_or(0, |part| part.parse::().unwrap_or(0)); + let y = parts.next().map_or(0, |part| part.parse::().unwrap_or(0)); + let w = parts.next().map_or(0, |part| part.parse::().unwrap_or(0)); + let h = parts.next().map_or(0, |part| part.parse::().unwrap_or(0)); + let title = parts.next().unwrap_or(""); + Self { flags, x, y, w, h, title } + } +} + +impl fmt::Display for WindowProperties<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "orbital:{}/{}/{}/{}/{}/{}", + self.flags, self.x, self.y, self.w, self.h, self.title + ) + } +} + +#[derive(Clone, Debug)] +pub struct OsError(Arc); + +impl OsError { + fn new(error: syscall::Error) -> Self { + Self(Arc::new(error)) + } +} + +impl Display for OsError { + fn fmt(&self, fmt: &mut Formatter<'_>) -> Result<(), fmt::Error> { + self.0.fmt(fmt) + } +} + +pub(crate) use crate::cursor::{ + NoCustomCursor as PlatformCustomCursor, NoCustomCursor as PlatformCustomCursorSource, +}; +pub(crate) use crate::icon::NoIcon as PlatformIcon; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct MonitorHandle; + +impl MonitorHandle { + pub fn name(&self) -> Option { + Some("Redox Device".to_owned()) + } + + pub fn size(&self) -> PhysicalSize { + PhysicalSize::new(0, 0) // TODO + } + + pub fn position(&self) -> PhysicalPosition { + (0, 0).into() + } + + pub fn scale_factor(&self) -> f64 { + 1.0 // TODO + } + + pub fn refresh_rate_millihertz(&self) -> Option { + // FIXME no way to get real refresh rate for now. + None + } + + pub fn video_modes(&self) -> impl Iterator { + let size = self.size().into(); + // FIXME this is not the real refresh rate + // (it is guaranteed to support 32 bit color though) + std::iter::once(VideoModeHandle { + size, + bit_depth: 32, + refresh_rate_millihertz: 60000, + monitor: self.clone(), + }) + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct VideoModeHandle { + size: (u32, u32), + bit_depth: u16, + refresh_rate_millihertz: u32, + monitor: MonitorHandle, +} + +impl VideoModeHandle { + pub fn size(&self) -> PhysicalSize { + self.size.into() + } + + pub fn bit_depth(&self) -> u16 { + self.bit_depth + } + + pub fn refresh_rate_millihertz(&self) -> u32 { + self.refresh_rate_millihertz + } + + pub fn monitor(&self) -> MonitorHandle { + self.monitor.clone() + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct KeyEventExtra { + pub key_without_modifiers: Key, + pub text_with_all_modifiers: Option, +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/window.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/window.rs new file mode 100644 index 00000000..3e676c36 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/orbital/window.rs @@ -0,0 +1,509 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use crate::cursor::Cursor; +use crate::dpi::{PhysicalPosition, PhysicalSize, Position, Size}; +use crate::platform_impl::Fullscreen; +use crate::window::ImePurpose; +use crate::{error, window}; + +use super::{ + ActiveEventLoop, MonitorHandle, OsError, RedoxSocket, TimeSocket, WindowId, WindowProperties, +}; + +// These values match the values uses in the `window_new` function in orbital: +// https://gitlab.redox-os.org/redox-os/orbital/-/blob/master/src/scheme.rs +const ORBITAL_FLAG_ASYNC: char = 'a'; +const ORBITAL_FLAG_BACK: char = 'b'; +const ORBITAL_FLAG_FRONT: char = 'f'; +const ORBITAL_FLAG_HIDDEN: char = 'h'; +const ORBITAL_FLAG_BORDERLESS: char = 'l'; +const ORBITAL_FLAG_MAXIMIZED: char = 'm'; +const ORBITAL_FLAG_RESIZABLE: char = 'r'; +const ORBITAL_FLAG_TRANSPARENT: char = 't'; + +pub struct Window { + window_socket: Arc, + redraws: Arc>>, + destroys: Arc>>, + wake_socket: Arc, +} + +impl Window { + pub(crate) fn new( + el: &ActiveEventLoop, + attrs: window::WindowAttributes, + ) -> Result { + let scale = MonitorHandle.scale_factor(); + + let (x, y) = if let Some(pos) = attrs.position { + pos.to_physical::(scale).into() + } else { + // These coordinates are a special value to center the window. + (-1, -1) + }; + + let (w, h): (u32, u32) = if let Some(size) = attrs.inner_size { + size.to_physical::(scale).into() + } else { + (1024, 768) + }; + + // TODO: min/max inner_size + + // Async by default. + let mut flag_str = ORBITAL_FLAG_ASYNC.to_string(); + + if attrs.maximized { + flag_str.push(ORBITAL_FLAG_MAXIMIZED); + } + + if attrs.resizable { + flag_str.push(ORBITAL_FLAG_RESIZABLE); + } + + // TODO: fullscreen + + if attrs.transparent { + flag_str.push(ORBITAL_FLAG_TRANSPARENT); + } + + if !attrs.decorations { + flag_str.push(ORBITAL_FLAG_BORDERLESS); + } + + if !attrs.visible { + flag_str.push(ORBITAL_FLAG_HIDDEN); + } + + match attrs.window_level { + window::WindowLevel::AlwaysOnBottom => { + flag_str.push(ORBITAL_FLAG_BACK); + }, + window::WindowLevel::Normal => {}, + window::WindowLevel::AlwaysOnTop => { + flag_str.push(ORBITAL_FLAG_FRONT); + }, + } + + // TODO: window_icon + + // Open window. + let window = RedoxSocket::orbital(&WindowProperties { + flags: &flag_str, + x, + y, + w, + h, + title: &attrs.title, + }) + .expect("failed to open window"); + + // Add to event socket. + el.event_socket + .write(&syscall::Event { + id: window.fd, + flags: syscall::EventFlags::EVENT_READ, + data: window.fd, + }) + .unwrap(); + + let window_socket = Arc::new(window); + + // Notify event thread that this window was created, it will send some default events. + { + let mut creates = el.creates.lock().unwrap(); + creates.push_back(window_socket.clone()); + } + + el.wake_socket.wake().unwrap(); + + Ok(Self { + window_socket, + redraws: el.redraws.clone(), + destroys: el.destroys.clone(), + wake_socket: el.wake_socket.clone(), + }) + } + + pub(crate) fn maybe_queue_on_main(&self, f: impl FnOnce(&Self) + Send + 'static) { + f(self) + } + + pub(crate) fn maybe_wait_on_main(&self, f: impl FnOnce(&Self) -> R + Send) -> R { + f(self) + } + + fn get_flag(&self, flag: char) -> Result { + let mut buf: [u8; 4096] = [0; 4096]; + let path = self + .window_socket + .fpath(&mut buf) + .map_err(|err| error::ExternalError::Os(os_error!(OsError::new(err))))?; + let properties = WindowProperties::new(path); + Ok(properties.flags.contains(flag)) + } + + fn set_flag(&self, flag: char, value: bool) -> Result<(), error::ExternalError> { + self.window_socket + .write(format!("F,{flag},{}", if value { 1 } else { 0 }).as_bytes()) + .map_err(|err| error::ExternalError::Os(os_error!(OsError::new(err))))?; + Ok(()) + } + + #[inline] + pub fn id(&self) -> WindowId { + WindowId { fd: self.window_socket.fd as u64 } + } + + #[inline] + pub fn primary_monitor(&self) -> Option { + Some(MonitorHandle) + } + + #[inline] + pub fn available_monitors(&self) -> VecDeque { + let mut v = VecDeque::with_capacity(1); + v.push_back(MonitorHandle); + v + } + + #[inline] + pub fn current_monitor(&self) -> Option { + Some(MonitorHandle) + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + MonitorHandle.scale_factor() + } + + #[inline] + pub fn request_redraw(&self) { + let window_id = self.id(); + let mut redraws = self.redraws.lock().unwrap(); + if !redraws.contains(&window_id) { + redraws.push_back(window_id); + + self.wake_socket.wake().unwrap(); + } + } + + #[inline] + pub fn pre_present_notify(&self) {} + + #[inline] + pub fn reset_dead_keys(&self) { + // TODO? + } + + #[inline] + pub fn inner_position(&self) -> Result, error::NotSupportedError> { + let mut buf: [u8; 4096] = [0; 4096]; + let path = self.window_socket.fpath(&mut buf).expect("failed to read properties"); + let properties = WindowProperties::new(path); + Ok((properties.x, properties.y).into()) + } + + #[inline] + pub fn outer_position(&self) -> Result, error::NotSupportedError> { + // TODO: adjust for window decorations + self.inner_position() + } + + #[inline] + pub fn set_outer_position(&self, position: Position) { + // TODO: adjust for window decorations + let (x, y): (i32, i32) = position.to_physical::(self.scale_factor()).into(); + self.window_socket.write(format!("P,{x},{y}").as_bytes()).expect("failed to set position"); + } + + #[inline] + pub fn inner_size(&self) -> PhysicalSize { + let mut buf: [u8; 4096] = [0; 4096]; + let path = self.window_socket.fpath(&mut buf).expect("failed to read properties"); + let properties = WindowProperties::new(path); + (properties.w, properties.h).into() + } + + #[inline] + pub fn request_inner_size(&self, size: Size) -> Option> { + let (w, h): (u32, u32) = size.to_physical::(self.scale_factor()).into(); + self.window_socket.write(format!("S,{w},{h}").as_bytes()).expect("failed to set size"); + None + } + + #[inline] + pub fn outer_size(&self) -> PhysicalSize { + // TODO: adjust for window decorations + self.inner_size() + } + + #[inline] + pub fn set_min_inner_size(&self, _: Option) {} + + #[inline] + pub fn set_max_inner_size(&self, _: Option) {} + + #[inline] + pub fn title(&self) -> String { + let mut buf: [u8; 4096] = [0; 4096]; + let path = self.window_socket.fpath(&mut buf).expect("failed to read properties"); + let properties = WindowProperties::new(path); + properties.title.to_string() + } + + #[inline] + pub fn set_title(&self, title: &str) { + self.window_socket.write(format!("T,{title}").as_bytes()).expect("failed to set title"); + } + + #[inline] + pub fn set_transparent(&self, transparent: bool) { + let _ = self.set_flag(ORBITAL_FLAG_TRANSPARENT, transparent); + } + + #[inline] + pub fn set_blur(&self, _blur: bool) {} + + #[inline] + pub fn set_visible(&self, visible: bool) { + let _ = self.set_flag(ORBITAL_FLAG_HIDDEN, !visible); + } + + #[inline] + pub fn is_visible(&self) -> Option { + Some(!self.get_flag(ORBITAL_FLAG_HIDDEN).unwrap_or(false)) + } + + #[inline] + pub fn resize_increments(&self) -> Option> { + None + } + + #[inline] + pub fn set_resize_increments(&self, _increments: Option) {} + + #[inline] + pub fn set_resizable(&self, resizeable: bool) { + let _ = self.set_flag(ORBITAL_FLAG_RESIZABLE, resizeable); + } + + #[inline] + pub fn is_resizable(&self) -> bool { + self.get_flag(ORBITAL_FLAG_RESIZABLE).unwrap_or(false) + } + + #[inline] + pub fn set_minimized(&self, _minimized: bool) {} + + #[inline] + pub fn is_minimized(&self) -> Option { + None + } + + #[inline] + pub fn set_maximized(&self, maximized: bool) { + let _ = self.set_flag(ORBITAL_FLAG_MAXIMIZED, maximized); + } + + #[inline] + pub fn is_maximized(&self) -> bool { + self.get_flag(ORBITAL_FLAG_MAXIMIZED).unwrap_or(false) + } + + #[inline] + pub(crate) fn set_fullscreen(&self, _monitor: Option) {} + + #[inline] + pub(crate) fn fullscreen(&self) -> Option { + None + } + + #[inline] + pub fn set_decorations(&self, decorations: bool) { + let _ = self.set_flag(ORBITAL_FLAG_BORDERLESS, !decorations); + } + + #[inline] + pub fn is_decorated(&self) -> bool { + !self.get_flag(ORBITAL_FLAG_BORDERLESS).unwrap_or(false) + } + + #[inline] + pub fn set_window_level(&self, level: window::WindowLevel) { + match level { + window::WindowLevel::AlwaysOnBottom => { + let _ = self.set_flag(ORBITAL_FLAG_BACK, true); + }, + window::WindowLevel::Normal => { + let _ = self.set_flag(ORBITAL_FLAG_BACK, false); + let _ = self.set_flag(ORBITAL_FLAG_FRONT, false); + }, + window::WindowLevel::AlwaysOnTop => { + let _ = self.set_flag(ORBITAL_FLAG_FRONT, true); + }, + } + } + + #[inline] + pub fn set_window_icon(&self, _window_icon: Option) {} + + #[inline] + pub fn set_ime_cursor_area(&self, _position: Position, _size: Size) {} + + #[inline] + pub fn set_ime_allowed(&self, _allowed: bool) {} + + #[inline] + pub fn set_ime_purpose(&self, _purpose: ImePurpose) {} + + #[inline] + pub fn focus_window(&self) {} + + #[inline] + pub fn request_user_attention(&self, _request_type: Option) {} + + #[inline] + pub fn set_cursor(&self, _: Cursor) {} + + #[inline] + pub fn set_cursor_position(&self, _: Position) -> Result<(), error::ExternalError> { + Err(error::ExternalError::NotSupported(error::NotSupportedError::new())) + } + + #[inline] + pub fn set_cursor_grab( + &self, + mode: window::CursorGrabMode, + ) -> Result<(), error::ExternalError> { + let (grab, relative) = match mode { + window::CursorGrabMode::None => (false, false), + window::CursorGrabMode::Confined => (true, false), + window::CursorGrabMode::Locked => (true, true), + }; + self.window_socket + .write(format!("M,G,{}", if grab { 1 } else { 0 }).as_bytes()) + .map_err(|err| error::ExternalError::Os(os_error!(OsError::new(err))))?; + self.window_socket + .write(format!("M,R,{}", if relative { 1 } else { 0 }).as_bytes()) + .map_err(|err| error::ExternalError::Os(os_error!(OsError::new(err))))?; + Ok(()) + } + + #[inline] + pub fn set_cursor_visible(&self, visible: bool) { + let _ = self.window_socket.write(format!("M,C,{}", if visible { 1 } else { 0 }).as_bytes()); + } + + #[inline] + pub fn drag_window(&self) -> Result<(), error::ExternalError> { + self.window_socket + .write(b"D") + .map_err(|err| error::ExternalError::Os(os_error!(OsError::new(err))))?; + Ok(()) + } + + #[inline] + pub fn drag_resize_window( + &self, + direction: window::ResizeDirection, + ) -> Result<(), error::ExternalError> { + let arg = match direction { + window::ResizeDirection::East => "R", + window::ResizeDirection::North => "T", + window::ResizeDirection::NorthEast => "T,R", + window::ResizeDirection::NorthWest => "T,L", + window::ResizeDirection::South => "B", + window::ResizeDirection::SouthEast => "B,R", + window::ResizeDirection::SouthWest => "B,L", + window::ResizeDirection::West => "L", + }; + self.window_socket + .write(format!("D,{arg}").as_bytes()) + .map_err(|err| error::ExternalError::Os(os_error!(OsError::new(err))))?; + Ok(()) + } + + #[inline] + pub fn show_window_menu(&self, _position: Position) {} + + #[inline] + pub fn set_cursor_hittest(&self, _hittest: bool) -> Result<(), error::ExternalError> { + Err(error::ExternalError::NotSupported(error::NotSupportedError::new())) + } + + #[cfg(feature = "rwh_04")] + #[inline] + pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle { + let mut handle = rwh_04::OrbitalHandle::empty(); + handle.window = self.window_socket.fd as *mut _; + rwh_04::RawWindowHandle::Orbital(handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle { + let mut handle = rwh_05::OrbitalWindowHandle::empty(); + handle.window = self.window_socket.fd as *mut _; + rwh_05::RawWindowHandle::Orbital(handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::Orbital(rwh_05::OrbitalDisplayHandle::empty()) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_window_handle_rwh_06(&self) -> Result { + let handle = rwh_06::OrbitalWindowHandle::new({ + let window = self.window_socket.fd as *mut _; + std::ptr::NonNull::new(window).expect("orbital fd should never be null") + }); + Ok(rwh_06::RawWindowHandle::Orbital(handle)) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::Orbital(rwh_06::OrbitalDisplayHandle::new())) + } + + #[inline] + pub fn set_enabled_buttons(&self, _buttons: window::WindowButtons) {} + + #[inline] + pub fn enabled_buttons(&self) -> window::WindowButtons { + window::WindowButtons::all() + } + + #[inline] + pub fn theme(&self) -> Option { + None + } + + #[inline] + pub fn has_focus(&self) -> bool { + false + } + + #[inline] + pub fn set_theme(&self, _theme: Option) {} + + pub fn set_content_protected(&self, _protected: bool) {} +} + +impl Drop for Window { + fn drop(&mut self) { + { + let mut destroys = self.destroys.lock().unwrap(); + destroys.push_back(self.id()); + } + + self.wake_socket.wake().unwrap(); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/abortable.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/abortable.rs new file mode 100644 index 00000000..671ae879 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/abortable.rs @@ -0,0 +1,96 @@ +use std::error::Error; +use std::fmt::{self, Display, Formatter}; +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use pin_project::pin_project; + +use super::AtomicWaker; + +#[pin_project] +pub struct Abortable { + #[pin] + future: F, + shared: Arc, +} + +impl Abortable { + pub fn new(handle: AbortHandle, future: F) -> Self { + Self { future, shared: handle.0 } + } +} + +impl Future for Abortable { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if self.shared.aborted.load(Ordering::Relaxed) { + return Poll::Ready(Err(Aborted)); + } + + if let Poll::Ready(value) = self.as_mut().project().future.poll(cx) { + return Poll::Ready(Ok(value)); + } + + self.shared.waker.register(cx.waker()); + + if self.shared.aborted.load(Ordering::Relaxed) { + return Poll::Ready(Err(Aborted)); + } + + Poll::Pending + } +} + +#[derive(Debug)] +struct Shared { + waker: AtomicWaker, + aborted: AtomicBool, +} + +#[derive(Clone, Debug)] +pub struct AbortHandle(Arc); + +impl AbortHandle { + pub fn new() -> Self { + Self(Arc::new(Shared { waker: AtomicWaker::new(), aborted: AtomicBool::new(false) })) + } + + pub fn abort(&self) { + self.0.aborted.store(true, Ordering::Relaxed); + self.0.waker.wake() + } +} + +#[derive(Debug)] +pub struct DropAbortHandle(AbortHandle); + +impl DropAbortHandle { + pub fn new(handle: AbortHandle) -> Self { + Self(handle) + } + + pub fn handle(&self) -> AbortHandle { + self.0.clone() + } +} + +impl Drop for DropAbortHandle { + fn drop(&mut self) { + self.0.abort() + } +} + +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)] +pub struct Aborted; + +impl Display for Aborted { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "`Abortable` future has been aborted") + } +} + +impl Error for Aborted {} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/atomic_waker.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/atomic_waker.rs new file mode 100644 index 00000000..87b83612 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/atomic_waker.rs @@ -0,0 +1,35 @@ +use std::cell::RefCell; +use std::ops::Deref; +use std::task::Waker; + +#[derive(Debug)] +pub struct AtomicWaker(RefCell>); + +impl AtomicWaker { + pub const fn new() -> Self { + Self(RefCell::new(None)) + } + + pub fn register(&self, waker: &Waker) { + let mut this = self.0.borrow_mut(); + + if let Some(old_waker) = this.deref() { + if old_waker.will_wake(waker) { + return; + } + } + + *this = Some(waker.clone()); + } + + pub fn wake(&self) { + if let Some(waker) = self.0.borrow_mut().take() { + waker.wake(); + } + } +} + +// SAFETY: Wasm without the `atomics` target feature is single-threaded. +unsafe impl Send for AtomicWaker {} +// SAFETY: Wasm without the `atomics` target feature is single-threaded. +unsafe impl Sync for AtomicWaker {} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/channel.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/channel.rs new file mode 100644 index 00000000..11a7a479 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/channel.rs @@ -0,0 +1,113 @@ +use std::future; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, RecvError, SendError, TryRecvError}; +use std::sync::{Arc, Mutex}; +use std::task::Poll; + +use super::AtomicWaker; + +pub fn channel() -> (Sender, Receiver) { + let (sender, receiver) = mpsc::channel(); + let shared = Arc::new(Shared { closed: AtomicBool::new(false), waker: AtomicWaker::new() }); + + let sender = + Sender(Arc::new(SenderInner { sender: Mutex::new(sender), shared: Arc::clone(&shared) })); + let receiver = Receiver { receiver: Rc::new(receiver), shared }; + + (sender, receiver) +} + +pub struct Sender(Arc>); + +struct SenderInner { + // We need to wrap it into a `Mutex` to make it `Sync`. So the sender can't + // be accessed on the main thread, as it could block. Additionally we need + // to wrap `Sender` in an `Arc` to make it cloneable on the main thread without + // having to block. + sender: Mutex>, + shared: Arc, +} + +impl Sender { + pub fn send(&self, event: T) -> Result<(), SendError> { + self.0.sender.lock().unwrap().send(event)?; + self.0.shared.waker.wake(); + + Ok(()) + } +} + +impl SenderInner { + fn close(&self) { + self.shared.closed.store(true, Ordering::Relaxed); + self.shared.waker.wake(); + } +} + +impl Clone for Sender { + fn clone(&self) -> Self { + Self(Arc::clone(&self.0)) + } +} + +impl Drop for SenderInner { + fn drop(&mut self) { + self.close(); + } +} + +pub struct Receiver { + receiver: Rc>, + shared: Arc, +} + +impl Receiver { + pub async fn next(&self) -> Result { + future::poll_fn(|cx| match self.receiver.try_recv() { + Ok(event) => Poll::Ready(Ok(event)), + Err(TryRecvError::Empty) => { + self.shared.waker.register(cx.waker()); + + match self.receiver.try_recv() { + Ok(event) => Poll::Ready(Ok(event)), + Err(TryRecvError::Empty) => { + if self.shared.closed.load(Ordering::Relaxed) { + Poll::Ready(Err(RecvError)) + } else { + Poll::Pending + } + }, + Err(TryRecvError::Disconnected) => Poll::Ready(Err(RecvError)), + } + }, + Err(TryRecvError::Disconnected) => Poll::Ready(Err(RecvError)), + }) + .await + } + + pub fn try_recv(&self) -> Result, RecvError> { + match self.receiver.try_recv() { + Ok(value) => Ok(Some(value)), + Err(TryRecvError::Empty) => Ok(None), + Err(TryRecvError::Disconnected) => Err(RecvError), + } + } +} + +impl Clone for Receiver { + fn clone(&self) -> Self { + Self { receiver: Rc::clone(&self.receiver), shared: Arc::clone(&self.shared) } + } +} + +impl Drop for Receiver { + fn drop(&mut self) { + self.shared.closed.store(true, Ordering::Relaxed); + } +} + +struct Shared { + closed: AtomicBool, + waker: AtomicWaker, +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/concurrent_queue.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/concurrent_queue.rs new file mode 100644 index 00000000..70afb3dd --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/concurrent_queue.rs @@ -0,0 +1,52 @@ +use std::cell::{Cell, RefCell}; + +#[derive(Debug)] +pub struct ConcurrentQueue { + queue: RefCell>, + closed: Cell, +} + +pub enum PushError { + #[allow(dead_code)] + Full(T), + Closed(T), +} + +pub enum PopError { + Empty, + Closed, +} + +impl ConcurrentQueue { + pub fn unbounded() -> Self { + Self { queue: RefCell::new(Vec::new()), closed: Cell::new(false) } + } + + pub fn push(&self, value: T) -> Result<(), PushError> { + if self.closed.get() { + return Err(PushError::Closed(value)); + } + + self.queue.borrow_mut().push(value); + Ok(()) + } + + pub fn pop(&self) -> Result { + self.queue.borrow_mut().pop().ok_or_else(|| { + if self.closed.get() { + PopError::Closed + } else { + PopError::Empty + } + }) + } + + pub fn close(&self) -> bool { + !self.closed.replace(true) + } +} + +// SAFETY: Wasm without the `atomics` target feature is single-threaded. +unsafe impl Send for ConcurrentQueue {} +// SAFETY: Wasm without the `atomics` target feature is single-threaded. +unsafe impl Sync for ConcurrentQueue {} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/dispatcher.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/dispatcher.rs new file mode 100644 index 00000000..10ab345e --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/dispatcher.rs @@ -0,0 +1,106 @@ +use super::super::main_thread::MainThreadMarker; +use super::{channel, Receiver, Sender, Wrapper}; +use std::cell::Ref; +use std::sync::{Arc, Condvar, Mutex}; + +pub struct Dispatcher(Wrapper>, Closure>); + +struct Closure(Box); + +impl Dispatcher { + #[track_caller] + pub fn new(main_thread: MainThreadMarker, value: T) -> Option<(Self, DispatchRunner)> { + let (sender, receiver) = channel::>(); + + Wrapper::new( + main_thread, + value, + |value, Closure(closure)| { + // SAFETY: The given `Closure` here isn't really `'static`, so we shouldn't do + // anything funny with it here. See `Self::queue()`. + closure(value.borrow().as_ref().unwrap()) + }, + { + let receiver = receiver.clone(); + move |value| async move { + while let Ok(Closure(closure)) = receiver.next().await { + // SAFETY: The given `Closure` here isn't really `'static`, so we shouldn't + // do anything funny with it here. See + // `Self::queue()`. + closure(value.borrow().as_ref().unwrap()) + } + } + }, + sender, + |sender, closure| { + // SAFETY: The given `Closure` here isn't really `'static`, so we shouldn't do + // anything funny with it here. See `Self::queue()`. + sender.send(closure).unwrap() + }, + ) + .map(|wrapper| (Self(wrapper.clone()), DispatchRunner { wrapper, receiver })) + } + + pub fn value(&self) -> Option> { + self.0.value() + } + + pub fn dispatch(&self, f: impl 'static + FnOnce(&T) + Send) { + if let Some(value) = self.0.value() { + f(&value) + } else { + self.0.send(Closure(Box::new(f))) + } + } + + pub fn queue(&self, f: impl FnOnce(&T) -> R + Send) -> R { + if let Some(value) = self.0.value() { + f(&value) + } else { + let pair = Arc::new((Mutex::new(None), Condvar::new())); + let closure = Box::new({ + let pair = pair.clone(); + move |value: &T| { + *pair.0.lock().unwrap() = Some(f(value)); + pair.1.notify_one(); + } + }) as Box; + // SAFETY: The `transmute` is necessary because `Closure` requires `'static`. This is + // safe because this function won't return until `f` has finished executing. See + // `Self::new()`. + let closure = Closure(unsafe { + std::mem::transmute::< + Box, + Box, + >(closure) + }); + + self.0.send(closure); + + let mut started = pair.0.lock().unwrap(); + + while started.is_none() { + started = pair.1.wait(started).unwrap(); + } + + started.take().unwrap() + } + } +} + +pub struct DispatchRunner { + wrapper: Wrapper>, Closure>, + receiver: Receiver>, +} + +impl DispatchRunner { + pub fn run(&self) { + while let Some(Closure(closure)) = + self.receiver.try_recv().expect("should only be closed when `Dispatcher` is dropped") + { + // SAFETY: The given `Closure` here isn't really `'static`, so we shouldn't do anything + // funny with it here. See `Self::queue()`. + closure(&self.wrapper.value().expect("don't call this outside the main thread")) + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/mod.rs new file mode 100644 index 00000000..4681cf92 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/mod.rs @@ -0,0 +1,19 @@ +mod abortable; +#[cfg(not(target_feature = "atomics"))] +mod atomic_waker; +mod channel; +#[cfg(not(target_feature = "atomics"))] +mod concurrent_queue; +mod dispatcher; +mod notifier; +mod waker; +mod wrapper; + +pub use self::abortable::{AbortHandle, Abortable, DropAbortHandle}; +pub use self::channel::{channel, Receiver, Sender}; +pub use self::dispatcher::{DispatchRunner, Dispatcher}; +pub use self::notifier::{Notified, Notifier}; +pub use self::waker::{Waker, WakerSpawner}; +use self::wrapper::Wrapper; +use atomic_waker::AtomicWaker; +use concurrent_queue::{ConcurrentQueue, PushError}; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/notifier.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/notifier.rs new file mode 100644 index 00000000..81655d18 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/notifier.rs @@ -0,0 +1,72 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, OnceLock}; +use std::task::{Context, Poll, Waker}; + +use super::{ConcurrentQueue, PushError}; + +#[derive(Debug)] +pub struct Notifier(Arc>); + +impl Notifier { + pub fn new() -> Self { + Self(Arc::new(Inner { queue: ConcurrentQueue::unbounded(), value: OnceLock::new() })) + } + + pub fn notify(self, value: T) { + if self.0.value.set(value).is_err() { + unreachable!("value set before") + } + + self.0.queue.close(); + + while let Ok(waker) = self.0.queue.pop() { + waker.wake() + } + } + + pub fn notified(&self) -> Notified { + Notified(Some(Arc::clone(&self.0))) + } +} + +#[derive(Clone, Debug)] +pub struct Notified(Option>>); + +impl Future for Notified { + type Output = T; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.0.take().expect("`Receiver` polled after completion"); + + if this.value.get().is_none() { + match this.queue.push(cx.waker().clone()) { + Ok(()) => { + if this.value.get().is_none() { + self.0 = Some(this); + return Poll::Pending; + } + }, + Err(PushError::Closed(_)) => (), + Err(PushError::Full(_)) => { + unreachable!("found full queue despite using unbounded queue") + }, + } + } + + let (Ok(Some(value)) | Err(Some(value))) = Arc::try_unwrap(this) + .map(|mut inner| inner.value.take()) + .map_err(|this| this.value.get().cloned()) + else { + unreachable!("found no value despite being ready") + }; + + Poll::Ready(value) + } +} + +#[derive(Debug)] +struct Inner { + queue: ConcurrentQueue, + value: OnceLock, +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/waker.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/waker.rs new file mode 100644 index 00000000..40b316dc --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/waker.rs @@ -0,0 +1,131 @@ +use std::future; +use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::task::Poll; + +use super::super::main_thread::MainThreadMarker; +use super::{AtomicWaker, Wrapper}; + +pub struct WakerSpawner(Wrapper, Sender, NonZeroUsize>); + +pub struct Waker(Wrapper, Sender, NonZeroUsize>); + +struct Handler { + value: T, + handler: fn(&T, NonZeroUsize, bool), +} + +#[derive(Clone)] +struct Sender(Arc); + +impl WakerSpawner { + #[track_caller] + pub fn new( + main_thread: MainThreadMarker, + value: T, + handler: fn(&T, NonZeroUsize, bool), + ) -> Option { + let inner = Arc::new(Inner { + counter: AtomicUsize::new(0), + waker: AtomicWaker::new(), + closed: AtomicBool::new(false), + }); + + let handler = Handler { value, handler }; + + let sender = Sender(Arc::clone(&inner)); + + let wrapper = Wrapper::new( + main_thread, + handler, + |handler, count| { + let handler = handler.borrow(); + let handler = handler.as_ref().unwrap(); + (handler.handler)(&handler.value, count, true); + }, + { + let inner = Arc::clone(&inner); + + move |handler| async move { + while let Some(count) = future::poll_fn(|cx| { + let count = inner.counter.swap(0, Ordering::Relaxed); + + match NonZeroUsize::new(count) { + Some(count) => Poll::Ready(Some(count)), + None => { + inner.waker.register(cx.waker()); + + let count = inner.counter.swap(0, Ordering::Relaxed); + + match NonZeroUsize::new(count) { + Some(count) => Poll::Ready(Some(count)), + None => { + if inner.closed.load(Ordering::Relaxed) { + return Poll::Ready(None); + } + + Poll::Pending + }, + } + }, + } + }) + .await + { + let handler = handler.borrow(); + let handler = handler.as_ref().unwrap(); + (handler.handler)(&handler.value, count, false); + } + } + }, + sender, + |inner, _| { + inner.0.counter.fetch_add(1, Ordering::Relaxed); + inner.0.waker.wake(); + }, + )?; + + Some(Self(wrapper)) + } + + pub fn waker(&self) -> Waker { + Waker(self.0.clone()) + } + + pub fn fetch(&self) -> usize { + debug_assert!( + MainThreadMarker::new().is_some(), + "this should only be called from the main thread" + ); + + self.0.with_sender_data(|inner| inner.0.counter.swap(0, Ordering::Relaxed)) + } +} + +impl Drop for WakerSpawner { + fn drop(&mut self) { + self.0.with_sender_data(|inner| { + inner.0.closed.store(true, Ordering::Relaxed); + inner.0.waker.wake(); + }); + } +} + +impl Waker { + pub fn wake(&self) { + self.0.send(NonZeroUsize::MIN) + } +} + +impl Clone for Waker { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +struct Inner { + counter: AtomicUsize, + waker: AtomicWaker, + closed: AtomicBool, +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/wrapper.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/wrapper.rs new file mode 100644 index 00000000..2c7f6940 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/async/wrapper.rs @@ -0,0 +1,89 @@ +use super::super::main_thread::MainThreadMarker; +use std::cell::{Ref, RefCell}; +use std::future::Future; +use std::marker::PhantomData; +use std::sync::Arc; + +// Unsafe wrapper type that allows us to use `T` when it's not `Send` from other threads. +// `value` **must** only be accessed on the main thread. +pub struct Wrapper { + value: Value, + handler: fn(&RefCell>, E), + sender_data: S, + sender_handler: fn(&S, E), +} + +struct Value { + // SAFETY: + // This value must not be accessed if not on the main thread. + // + // - We wrap this in an `Arc` to allow it to be safely cloned without accessing the value. + // - The `RefCell` lets us mutably access in the main thread but is safe to drop in any thread + // because it has no `Drop` behavior. + // - The `Option` lets us safely drop `T` only in the main thread. + value: Arc>>, + // Prevent's `Send` or `Sync` to be automatically implemented. + local: PhantomData<*const ()>, +} + +// SAFETY: See `Self::value`. +unsafe impl Send for Value {} +// SAFETY: See `Self::value`. +unsafe impl Sync for Value {} + +impl Wrapper { + #[track_caller] + pub fn new>( + _: MainThreadMarker, + value: V, + handler: fn(&RefCell>, E), + receiver: impl 'static + FnOnce(Arc>>) -> R, + sender_data: S, + sender_handler: fn(&S, E), + ) -> Option { + let value = Arc::new(RefCell::new(Some(value))); + + wasm_bindgen_futures::spawn_local({ + let value = Arc::clone(&value); + async move { + receiver(Arc::clone(&value)).await; + drop(value.borrow_mut().take().unwrap()); + } + }); + + Some(Self { + value: Value { value, local: PhantomData }, + handler, + sender_data, + sender_handler, + }) + } + + pub fn send(&self, event: E) { + if MainThreadMarker::new().is_some() { + (self.handler)(&self.value.value, event) + } else { + (self.sender_handler)(&self.sender_data, event) + } + } + + pub fn value(&self) -> Option> { + MainThreadMarker::new() + .map(|_| Ref::map(self.value.value.borrow(), |value| value.as_ref().unwrap())) + } + + pub fn with_sender_data(&self, f: impl FnOnce(&S) -> T) -> T { + f(&self.sender_data) + } +} + +impl Clone for Wrapper { + fn clone(&self) -> Self { + Self { + value: Value { value: self.value.value.clone(), local: PhantomData }, + handler: self.handler, + sender_data: self.sender_data.clone(), + sender_handler: self.sender_handler, + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/cursor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/cursor.rs new file mode 100644 index 00000000..214ff45a --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/cursor.rs @@ -0,0 +1,730 @@ +use std::cell::RefCell; +use std::future::{self, Future}; +use std::hash::{Hash, Hasher}; +use std::mem; +use std::ops::{Deref, DerefMut}; +use std::pin::Pin; +use std::rc::Rc; +use std::sync::Arc; +use std::task::{ready, Context, Poll, Waker}; +use std::time::Duration; + +use cursor_icon::CursorIcon; +use js_sys::{Array, Object}; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + Blob, Document, DomException, HtmlCanvasElement, HtmlImageElement, ImageBitmap, + ImageBitmapOptions, ImageBitmapRenderingContext, ImageData, PremultiplyAlpha, Url, Window, +}; + +use super::backend::Style; +use super::main_thread::{MainThreadMarker, MainThreadSafe}; +use super::r#async::{AbortHandle, Abortable, DropAbortHandle, Notified, Notifier}; +use super::ActiveEventLoop; +use crate::cursor::{BadImage, Cursor, CursorImage, CustomCursor as RootCustomCursor}; +use crate::platform::web::CustomCursorError; + +#[derive(Debug)] +pub(crate) enum CustomCursorSource { + Image(CursorImage), + Url { url: String, hotspot_x: u16, hotspot_y: u16 }, + Animation { duration: Duration, cursors: Vec }, +} + +impl CustomCursorSource { + pub fn from_rgba( + rgba: Vec, + width: u16, + height: u16, + hotspot_x: u16, + hotspot_y: u16, + ) -> Result { + Ok(CustomCursorSource::Image(CursorImage::from_rgba( + rgba, width, height, hotspot_x, hotspot_y, + )?)) + } +} + +#[derive(Clone, Debug)] +pub struct CustomCursor { + pub(crate) animation: bool, + state: Arc>>, +} + +impl Hash for CustomCursor { + fn hash(&self, state: &mut H) { + Arc::as_ptr(&self.state).hash(state); + } +} + +impl PartialEq for CustomCursor { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.state, &other.state) + } +} + +impl Eq for CustomCursor {} + +impl CustomCursor { + pub(crate) fn new(event_loop: &ActiveEventLoop, source: CustomCursorSource) -> Self { + match source { + CustomCursorSource::Image(image) => Self::build_spawn( + event_loop, + from_rgba(event_loop.runner.window(), event_loop.runner.document().clone(), &image), + false, + ), + CustomCursorSource::Url { url, hotspot_x, hotspot_y } => Self::build_spawn( + event_loop, + from_url(UrlType::Plain(url), hotspot_x, hotspot_y), + false, + ), + CustomCursorSource::Animation { duration, cursors } => Self::build_spawn( + event_loop, + from_animation( + event_loop.runner.main_thread(), + duration, + cursors.into_iter().map(|cursor| cursor.inner), + ), + true, + ), + } + } + + fn build_spawn(window_target: &ActiveEventLoop, task: F, animation: bool) -> CustomCursor + where + F: 'static + Future>, + S: Into, + { + let handle = AbortHandle::new(); + let this = CustomCursor { + animation, + state: Arc::new(MainThreadSafe::new( + window_target.runner.main_thread(), + RefCell::new(ImageState::Loading { + notifier: Notifier::new(), + _handle: DropAbortHandle::new(handle.clone()), + }), + )), + }; + let weak = Arc::downgrade(&this.state); + let main_thread = window_target.runner.main_thread(); + + let task = Abortable::new(handle, { + async move { + let result = task.await; + + let this = weak.upgrade().expect("`CursorHandler` invalidated without aborting"); + let mut this = this.get(main_thread).borrow_mut(); + + match result { + Ok(new_state) => { + let ImageState::Loading { notifier, .. } = + mem::replace(this.deref_mut(), new_state.into()) + else { + unreachable!("found invalid state"); + }; + notifier.notify(Ok(())); + }, + Err(error) => { + let ImageState::Loading { notifier, .. } = + mem::replace(this.deref_mut(), ImageState::Failed(error.clone())) + else { + unreachable!("found invalid state"); + }; + notifier.notify(Err(error)); + }, + } + } + }); + + wasm_bindgen_futures::spawn_local(async move { + let _ = task.await; + }); + + this + } + + pub(crate) fn new_async( + event_loop: &ActiveEventLoop, + source: CustomCursorSource, + ) -> CustomCursorFuture { + let CustomCursor { animation, state } = Self::new(event_loop, source); + let binding = state.get(event_loop.runner.main_thread()).borrow(); + let ImageState::Loading { notifier, .. } = binding.deref() else { + unreachable!("found invalid state") + }; + let notified = notifier.notified(); + drop(binding); + + CustomCursorFuture { notified, animation, state: Some(state) } + } +} + +#[derive(Debug)] +pub struct CustomCursorFuture { + notified: Notified>, + animation: bool, + state: Option>>>, +} + +impl Future for CustomCursorFuture { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + if self.state.is_none() { + panic!("`CustomCursorFuture` polled after completion") + } + + let result = ready!(Pin::new(&mut self.notified).poll(cx)); + let state = self.state.take().expect("`CustomCursorFuture` polled after completion"); + + Poll::Ready(result.map(|_| CustomCursor { animation: self.animation, state })) + } +} + +#[derive(Debug)] +pub struct CursorHandler(Rc>); + +#[derive(Debug)] +struct Inner { + main_thread: MainThreadMarker, + canvas: HtmlCanvasElement, + style: Style, + visible: bool, + cursor: SelectedCursor, +} + +impl CursorHandler { + pub(crate) fn new( + main_thread: MainThreadMarker, + canvas: HtmlCanvasElement, + style: Style, + ) -> Self { + Self(Rc::new(RefCell::new(Inner { + main_thread, + canvas, + style, + visible: true, + cursor: SelectedCursor::default(), + }))) + } + + pub fn set_cursor(&self, cursor: Cursor) { + let mut this = self.0.borrow_mut(); + + match cursor { + Cursor::Icon(icon) => { + if let SelectedCursor::Icon(old_icon) + | SelectedCursor::Loading { previous: Previous::Icon(old_icon), .. } = + &this.cursor + { + if *old_icon == icon { + return; + } + } + + this.cursor = SelectedCursor::Icon(icon); + this.set_style(); + }, + Cursor::Custom(cursor) => { + let cursor = cursor.inner; + + if let SelectedCursor::Loading { cursor: old_cursor, .. } + | SelectedCursor::Image(old_cursor) + | SelectedCursor::Animation { cursor: old_cursor, .. } = &this.cursor + { + if *old_cursor == cursor { + return; + } + } + + let state = cursor.state.get(this.main_thread).borrow(); + + match state.deref() { + ImageState::Loading { notifier, .. } => { + let notified = notifier.notified(); + let handle = DropAbortHandle::new(AbortHandle::new()); + let task = Abortable::new(handle.handle(), { + let weak = Rc::downgrade(&self.0); + async move { + let _ = notified.await; + let handler = weak + .upgrade() + .expect("`CursorHandler` invalidated without aborting"); + handler.borrow_mut().notify(); + } + }); + wasm_bindgen_futures::spawn_local(async move { + let _ = task.await; + }); + + drop(state); + this.cursor = SelectedCursor::Loading { + cursor, + previous: mem::take(&mut this.cursor).into(), + _handle: handle, + }; + }, + ImageState::Failed(error) => { + tracing::error!( + "trying to load custom cursor that has failed to load: {error}" + ) + }, + ImageState::Image(_) => { + drop(state); + this.cursor = SelectedCursor::Image(cursor); + this.set_style(); + }, + ImageState::Animation(animation) => { + let canvas: &CanvasAnimateExt = this.canvas.unchecked_ref(); + let animation = canvas.animate_with_keyframe_animation_options( + Some(&animation.keyframes), + &animation.options, + ); + drop(state); + + if !this.visible { + animation.cancel(); + } + + this.cursor = SelectedCursor::Animation { + animation: AnimationDropper(animation), + cursor, + }; + this.set_style(); + }, + }; + }, + } + } + + pub fn set_cursor_visible(&self, visible: bool) { + let mut this = self.0.borrow_mut(); + + if !visible && this.visible { + this.visible = false; + this.style.set("cursor", "none"); + + if let SelectedCursor::Animation { animation, .. } = &this.cursor { + animation.0.cancel(); + } + } else if visible && !this.visible { + this.visible = true; + this.set_style(); + } + } +} + +impl Inner { + fn set_style(&self) { + if self.visible { + match &self.cursor { + SelectedCursor::Icon(icon) + | SelectedCursor::Loading { previous: Previous::Icon(icon), .. } => { + if let CursorIcon::Default = icon { + self.style.remove("cursor") + } else { + self.style.set("cursor", icon.name()) + } + }, + SelectedCursor::Loading { previous: Previous::Image(cursor), .. } + | SelectedCursor::Image(cursor) => { + match cursor.state.get(self.main_thread).borrow().deref() { + ImageState::Image(Image { style, .. }) => self.style.set("cursor", style), + _ => unreachable!("found invalid saved state"), + } + }, + SelectedCursor::Loading { + previous: Previous::Animation { animation, .. }, .. + } + | SelectedCursor::Animation { animation, .. } => { + self.style.remove("cursor"); + animation.0.play() + }, + } + } + } + + fn notify(&mut self) { + let SelectedCursor::Loading { cursor, previous, .. } = mem::take(&mut self.cursor) else { + unreachable!("found wrong state") + }; + + let state = cursor.state.get(self.main_thread).borrow(); + match state.deref() { + ImageState::Image(_) => { + drop(state); + self.cursor = SelectedCursor::Image(cursor); + self.set_style(); + }, + ImageState::Animation(animation) => { + let canvas: &CanvasAnimateExt = self.canvas.unchecked_ref(); + let animation = canvas.animate_with_keyframe_animation_options( + Some(&animation.keyframes), + &animation.options, + ); + drop(state); + + if !self.visible { + animation.cancel(); + } + + self.cursor = + SelectedCursor::Animation { animation: AnimationDropper(animation), cursor }; + self.set_style(); + }, + ImageState::Failed(error) => { + tracing::error!("custom cursor failed to load: {error}"); + self.cursor = previous.into() + }, + ImageState::Loading { .. } => unreachable!("notified without being ready"), + } + } +} + +#[derive(Debug)] +enum SelectedCursor { + Icon(CursorIcon), + Loading { cursor: CustomCursor, previous: Previous, _handle: DropAbortHandle }, + Image(CustomCursor), + Animation { cursor: CustomCursor, animation: AnimationDropper }, +} + +impl Default for SelectedCursor { + fn default() -> Self { + Self::Icon(Default::default()) + } +} + +impl From for SelectedCursor { + fn from(previous: Previous) -> Self { + match previous { + Previous::Icon(icon) => Self::Icon(icon), + Previous::Image(cursor) => Self::Image(cursor), + Previous::Animation { cursor, animation } => Self::Animation { cursor, animation }, + } + } +} + +#[derive(Debug)] +enum Previous { + Icon(CursorIcon), + Image(CustomCursor), + Animation { cursor: CustomCursor, animation: AnimationDropper }, +} + +impl From for Previous { + fn from(value: SelectedCursor) -> Self { + match value { + SelectedCursor::Icon(icon) => Self::Icon(icon), + SelectedCursor::Loading { previous, .. } => previous, + SelectedCursor::Image(image) => Self::Image(image), + SelectedCursor::Animation { cursor, animation } => { + Self::Animation { cursor, animation } + }, + } + } +} + +#[derive(Debug)] +enum ImageState { + Loading { notifier: Notifier>, _handle: DropAbortHandle }, + Failed(CustomCursorError), + Image(Image), + Animation(Animation), +} + +#[derive(Debug)] +struct Image { + style: String, + _object_url: Option, + _image: HtmlImageElement, +} + +impl From for ImageState { + fn from(image: Image) -> Self { + Self::Image(image) + } +} + +#[derive(Debug)] +struct Animation { + keyframes: Array, + options: KeyframeAnimationOptions, + _images: Vec, +} + +impl From for ImageState { + fn from(animation: Animation) -> Self { + Self::Animation(animation) + } +} + +#[derive(Debug)] +enum UrlType { + Plain(String), + Object(ObjectUrl), +} + +impl UrlType { + fn url(&self) -> &str { + match &self { + UrlType::Plain(url) => url, + UrlType::Object(object_url) => &object_url.0, + } + } +} + +#[derive(Debug)] +struct ObjectUrl(String); + +impl Drop for ObjectUrl { + fn drop(&mut self) { + Url::revoke_object_url(&self.0).expect("unexpected exception in `URL.revokeObjectURL()`"); + } +} + +#[derive(Debug)] +struct AnimationDropper(WebAnimation); + +impl Drop for AnimationDropper { + fn drop(&mut self) { + self.0.cancel() + } +} + +fn from_rgba( + window: &Window, + document: Document, + image: &CursorImage, +) -> impl Future> { + // 1. Create an `ImageData` from the RGBA data. + // 2. Create an `ImageBitmap` from the `ImageData`. + // 3. Draw `ImageBitmap` on an `HTMLCanvasElement`. + // 4. Create a `Blob` from the `HTMLCanvasElement`. + // 5. Create an object URL from the `Blob`. + // 6. Decode the image on an `HTMLImageElement` from the URL. + + // 1. Create an `ImageData` from the RGBA data. + // Adapted from https://github.com/rust-windowing/softbuffer/blob/ab7688e2ed2e2eca51b3c4e1863a5bd7fe85800e/src/web.rs#L196-L223 + #[cfg(target_feature = "atomics")] + // Can't share `SharedArrayBuffer` with `ImageData`. + let result = { + use js_sys::{Uint8Array, Uint8ClampedArray}; + use wasm_bindgen::JsValue; + + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(js_namespace = ImageData)] + type ImageDataExt; + #[wasm_bindgen(catch, constructor, js_class = ImageData)] + fn new(array: Uint8ClampedArray, sw: u32) -> Result; + } + + let array = Uint8Array::new_with_length(image.rgba.len() as u32); + array.copy_from(&image.rgba); + let array = Uint8ClampedArray::new(&array); + ImageDataExt::new(array, image.width as u32) + .map(JsValue::from) + .map(ImageData::unchecked_from_js) + }; + #[cfg(not(target_feature = "atomics"))] + let result = ImageData::new_with_u8_clamped_array( + wasm_bindgen::Clamped(&image.rgba), + image.width as u32, + ); + let image_data = result.expect("found wrong image size"); + + // 2. Create an `ImageBitmap` from the `ImageData`. + // + // We call `createImageBitmap()` before spawning the future, + // to not have to clone the image buffer. + let options = ImageBitmapOptions::new(); + options.set_premultiply_alpha(PremultiplyAlpha::None); + let bitmap = JsFuture::from( + window + .create_image_bitmap_with_image_data_and_image_bitmap_options(&image_data, &options) + .expect("unexpected exception in `createImageBitmap()`"), + ); + + let CursorImage { width, height, hotspot_x, hotspot_y, .. } = *image; + async move { + let bitmap: ImageBitmap = + bitmap.await.expect("found invalid state in `ImageData`").unchecked_into(); + + let canvas: HtmlCanvasElement = + document.create_element("canvas").expect("invalid tag name").unchecked_into(); + #[allow(clippy::disallowed_methods)] + canvas.set_width(width as u32); + #[allow(clippy::disallowed_methods)] + canvas.set_height(height as u32); + + // 3. Draw `ImageBitmap` on an `HTMLCanvasElement`. + let context: ImageBitmapRenderingContext = canvas + .get_context("bitmaprenderer") + .expect("unexpected exception in `HTMLCanvasElement.getContext()`") + .expect("`bitmaprenderer` context unsupported") + .unchecked_into(); + context.transfer_from_image_bitmap(&bitmap); + drop(bitmap); + drop(context); + + // 4. Create a `Blob` from the `HTMLCanvasElement`. + // + // To keep the `Closure` alive until `HTMLCanvasElement.toBlob()` is done, + // we do the whole `Waker` strategy. Commonly on `Drop` the callback is aborted, + // but it would increase complexity and isn't possible in this case. + // Keep in mind that `HTMLCanvasElement.toBlob()` can call the callback immediately. + let value = Rc::new(RefCell::new(None)); + let waker = Rc::new(RefCell::>::new(None)); + let callback = Closure::once({ + let value = value.clone(); + let waker = waker.clone(); + move |blob: Option| { + *value.borrow_mut() = Some(blob); + if let Some(waker) = waker.borrow_mut().take() { + waker.wake(); + } + } + }); + canvas + .to_blob(callback.as_ref().unchecked_ref()) + .expect("failed with `SecurityError` despite only source coming from memory"); + let blob = future::poll_fn(|cx| { + if let Some(blob) = value.borrow_mut().take() { + Poll::Ready(blob) + } else { + *waker.borrow_mut() = Some(cx.waker().clone()); + Poll::Pending + } + }) + .await; + drop(canvas); + + let Some(blob) = blob else { + return Err(CustomCursorError::Blob); + }; + + // 5. Create an object URL from the `Blob`. + let url = Url::create_object_url_with_blob(&blob) + .expect("unexpected exception in `URL.createObjectURL()`"); + let url = UrlType::Object(ObjectUrl(url)); + + from_url(url, hotspot_x, hotspot_y).await + } +} + +async fn from_url( + url: UrlType, + hotspot_x: u16, + hotspot_y: u16, +) -> Result { + // 6. Decode the image on an `HTMLImageElement` from the URL. + let image = HtmlImageElement::new().expect("unexpected exception in `new HtmlImageElement`"); + image.set_src(url.url()); + let result = JsFuture::from(image.decode()).await; + + if let Err(error) = result { + debug_assert!(error.has_type::()); + let error: DomException = error.unchecked_into(); + debug_assert_eq!(error.name(), "EncodingError"); + let error = error.message(); + + return Err(CustomCursorError::Decode(error)); + } + + Ok(Image { + style: format!("url({}) {hotspot_x} {hotspot_y}, auto", url.url()), + _object_url: match url { + UrlType::Plain(_) => None, + UrlType::Object(object_url) => Some(object_url), + }, + _image: image, + }) +} + +#[allow(clippy::await_holding_refcell_ref)] // false-positive +async fn from_animation( + main_thread: MainThreadMarker, + duration: Duration, + cursors: impl ExactSizeIterator, +) -> Result { + let keyframes = Array::new(); + let mut images = Vec::with_capacity(cursors.len()); + + for cursor in cursors { + let state = cursor.state.get(main_thread).borrow(); + + match state.deref() { + ImageState::Loading { notifier, .. } => { + let notified = notifier.notified(); + drop(state); + notified.await?; + }, + ImageState::Failed(error) => return Err(error.clone()), + ImageState::Image(_) => drop(state), + ImageState::Animation(_) => unreachable!("check in `CustomCursorSource` failed"), + } + + let state = cursor.state.get(main_thread).borrow(); + let style = match state.deref() { + ImageState::Image(Image { style, .. }) => style, + _ => unreachable!("found invalid state"), + }; + + let keyframe: Keyframe = Object::new().unchecked_into(); + keyframe.set_cursor(style); + keyframes.push(&keyframe); + drop(state); + + images.push(cursor); + } + + keyframes.push(&keyframes.get(0)); + + let options: KeyframeAnimationOptions = Object::new().unchecked_into(); + options.set_duration(duration.as_millis() as f64); + options.set_iterations(f64::INFINITY); + + Ok(Animation { keyframes, options, _images: images }) +} + +#[wasm_bindgen] +extern "C" { + type CanvasAnimateExt; + + #[wasm_bindgen(method, js_name = animate)] + fn animate_with_keyframe_animation_options( + this: &CanvasAnimateExt, + keyframes: Option<&Object>, + options: &KeyframeAnimationOptions, + ) -> WebAnimation; + + #[derive(Debug)] + type WebAnimation; + + #[wasm_bindgen(method)] + fn cancel(this: &WebAnimation); + + #[wasm_bindgen(method)] + fn play(this: &WebAnimation); + + #[wasm_bindgen(extends = Object)] + type Keyframe; + + #[wasm_bindgen(method, setter, js_name = cursor)] + fn set_cursor(this: &Keyframe, value: &str); + + #[derive(Debug)] + #[wasm_bindgen(extends = Object)] + type KeyframeAnimationOptions; + + #[wasm_bindgen(method, setter, js_name = duration)] + fn set_duration(this: &KeyframeAnimationOptions, value: f64); + + #[wasm_bindgen(method, setter, js_name = iterations)] + fn set_iterations(this: &KeyframeAnimationOptions, value: f64); +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/device.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/device.rs new file mode 100644 index 00000000..91d08586 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/device.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeviceId(pub i32); + +impl DeviceId { + pub const fn dummy() -> Self { + Self(0) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/error.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/error.rs new file mode 100644 index 00000000..6995f2bc --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/error.rs @@ -0,0 +1,10 @@ +use std::fmt; + +#[derive(Debug)] +pub struct OsError(pub String); + +impl fmt::Display for OsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/mod.rs new file mode 100644 index 00000000..23fe2e00 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/mod.rs @@ -0,0 +1,119 @@ +use std::marker::PhantomData; +use std::sync::mpsc::{self, Receiver, Sender}; + +use crate::error::EventLoopError; +use crate::event::Event; +use crate::event_loop::ActiveEventLoop as RootActiveEventLoop; +use crate::platform::web::{ActiveEventLoopExtWebSys, PollStrategy, WaitUntilStrategy}; + +use super::{backend, device, window}; + +mod proxy; +pub(crate) mod runner; +mod state; +mod window_target; + +pub(crate) use proxy::EventLoopProxy; +pub(crate) use window_target::{ActiveEventLoop, OwnedDisplayHandle}; + +pub struct EventLoop { + elw: RootActiveEventLoop, + user_event_sender: Sender, + user_event_receiver: Receiver, +} + +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PlatformSpecificEventLoopAttributes {} + +impl EventLoop { + pub(crate) fn new(_: &PlatformSpecificEventLoopAttributes) -> Result { + let (user_event_sender, user_event_receiver) = mpsc::channel(); + let elw = RootActiveEventLoop { p: ActiveEventLoop::new(), _marker: PhantomData }; + Ok(EventLoop { elw, user_event_sender, user_event_receiver }) + } + + pub fn run(self, mut event_handler: F) -> ! + where + F: FnMut(Event, &RootActiveEventLoop), + { + let target = RootActiveEventLoop { p: self.elw.p.clone(), _marker: PhantomData }; + + // SAFETY: Don't use `move` to make sure we leak the `event_handler` and `target`. + let handler: Box)> = Box::new(|event| { + let event = match event.map_nonuser_event() { + Ok(event) => event, + Err(Event::UserEvent(())) => Event::UserEvent( + self.user_event_receiver + .try_recv() + .expect("handler woken up without user event"), + ), + Err(_) => unreachable!(), + }; + event_handler(event, &target) + }); + // SAFETY: The `transmute` is necessary because `run()` requires `'static`. This is safe + // because this function will never return and all resources not cleaned up by the point we + // `throw` will leak, making this actually `'static`. + let handler = unsafe { + std::mem::transmute::)>, Box) + 'static>>( + handler, + ) + }; + self.elw.p.run(handler, false); + + // Throw an exception to break out of Rust execution and use unreachable to tell the + // compiler this function won't return, giving it a return type of '!' + backend::throw( + "Using exceptions for control flow, don't mind me. This isn't actually an error!", + ); + + unreachable!(); + } + + pub fn spawn(self, mut event_handler: F) + where + F: 'static + FnMut(Event, &RootActiveEventLoop), + { + let target = RootActiveEventLoop { p: self.elw.p.clone(), _marker: PhantomData }; + + self.elw.p.run( + Box::new(move |event| { + let event = match event.map_nonuser_event() { + Ok(event) => event, + Err(Event::UserEvent(())) => Event::UserEvent( + self.user_event_receiver + .try_recv() + .expect("handler woken up without user event"), + ), + Err(_) => unreachable!(), + }; + event_handler(event, &target) + }), + true, + ); + } + + pub fn create_proxy(&self) -> EventLoopProxy { + EventLoopProxy::new(self.elw.p.waker(), self.user_event_sender.clone()) + } + + pub fn window_target(&self) -> &RootActiveEventLoop { + &self.elw + } + + pub fn set_poll_strategy(&self, strategy: PollStrategy) { + self.elw.set_poll_strategy(strategy); + } + + pub fn poll_strategy(&self) -> PollStrategy { + self.elw.poll_strategy() + } + + pub fn set_wait_until_strategy(&self, strategy: WaitUntilStrategy) { + self.elw.set_wait_until_strategy(strategy); + } + + pub fn wait_until_strategy(&self) -> WaitUntilStrategy { + self.elw.wait_until_strategy() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/proxy.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/proxy.rs new file mode 100644 index 00000000..bd8e7146 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/proxy.rs @@ -0,0 +1,29 @@ +use std::rc::Weak; +use std::sync::mpsc::{SendError, Sender}; + +use super::runner::Execution; +use crate::event_loop::EventLoopClosed; +use crate::platform_impl::platform::r#async::Waker; + +pub struct EventLoopProxy { + runner: Waker>, + sender: Sender, +} + +impl EventLoopProxy { + pub fn new(runner: Waker>, sender: Sender) -> Self { + Self { runner, sender } + } + + pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed> { + self.sender.send(event).map_err(|SendError(event)| EventLoopClosed(event))?; + self.runner.wake(); + Ok(()) + } +} + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + Self { runner: self.runner.clone(), sender: self.sender.clone() } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/runner.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/runner.rs new file mode 100644 index 00000000..d9ba3d37 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/runner.rs @@ -0,0 +1,808 @@ +use std::cell::{Cell, RefCell}; +use std::collections::{HashSet, VecDeque}; +use std::iter; +use std::num::NonZeroUsize; +use std::ops::Deref; +use std::rc::{Rc, Weak}; + +use wasm_bindgen::prelude::Closure; +use wasm_bindgen::JsCast; +use web_sys::{Document, KeyboardEvent, PageTransitionEvent, PointerEvent, WheelEvent}; +use web_time::{Duration, Instant}; + +use super::super::main_thread::MainThreadMarker; +use super::super::DeviceId; +use super::backend; +use super::state::State; +use crate::dpi::PhysicalSize; +use crate::event::{ + DeviceEvent, DeviceId as RootDeviceId, ElementState, Event, RawKeyEvent, StartCause, + WindowEvent, +}; +use crate::event_loop::{ControlFlow, DeviceEvents}; +use crate::platform::web::{PollStrategy, WaitUntilStrategy}; +use crate::platform_impl::platform::backend::EventListenerHandle; +use crate::platform_impl::platform::r#async::{DispatchRunner, Waker, WakerSpawner}; +use crate::platform_impl::platform::window::Inner; +use crate::window::WindowId; + +pub struct Shared(Rc); + +pub(super) type EventHandler = dyn FnMut(Event<()>); + +impl Clone for Shared { + fn clone(&self) -> Self { + Shared(self.0.clone()) + } +} + +type OnEventHandle = RefCell>>; + +pub struct Execution { + main_thread: MainThreadMarker, + proxy_spawner: WakerSpawner>, + control_flow: Cell, + poll_strategy: Cell, + wait_until_strategy: Cell, + exit: Cell, + runner: RefCell, + suspended: Cell, + event_loop_recreation: Cell, + events: RefCell>, + id: RefCell, + window: web_sys::Window, + document: Document, + #[allow(clippy::type_complexity)] + all_canvases: RefCell>, DispatchRunner)>>, + redraw_pending: RefCell>, + destroy_pending: RefCell>, + page_transition_event_handle: RefCell>, + device_events: Cell, + on_mouse_move: OnEventHandle, + on_wheel: OnEventHandle, + on_mouse_press: OnEventHandle, + on_mouse_release: OnEventHandle, + on_key_press: OnEventHandle, + on_key_release: OnEventHandle, + on_visibility_change: OnEventHandle, +} + +enum RunnerEnum { + /// The `EventLoop` is created but not being run. + Pending, + /// The `EventLoop` is being run. + Running(Runner), + /// The `EventLoop` is exited after being started with `EventLoop::run_app`. Since + /// `EventLoop::run_app` takes ownership of the `EventLoop`, we can be certain + /// that this event loop will never be run again. + Destroyed, +} + +impl RunnerEnum { + fn maybe_runner(&self) -> Option<&Runner> { + match self { + RunnerEnum::Running(runner) => Some(runner), + _ => None, + } + } +} + +struct Runner { + state: State, + event_handler: Box, +} + +impl Runner { + pub fn new(event_handler: Box) -> Self { + Runner { state: State::Init, event_handler } + } + + /// Returns the corresponding `StartCause` for the current `state`, or `None` + /// when in `Exit` state. + fn maybe_start_cause(&self) -> Option { + Some(match self.state { + State::Init => StartCause::Init, + State::Poll { .. } => StartCause::Poll, + State::Wait { start } => StartCause::WaitCancelled { start, requested_resume: None }, + State::WaitUntil { start, end, .. } => { + StartCause::WaitCancelled { start, requested_resume: Some(end) } + }, + State::Exit => return None, + }) + } + + fn handle_single_event(&mut self, runner: &Shared, event: impl Into) { + match event.into() { + EventWrapper::Event(event) => (self.event_handler)(event), + EventWrapper::ScaleChange { canvas, size, scale } => { + if let Some(canvas) = canvas.upgrade() { + canvas.borrow().handle_scale_change( + runner, + |event| (self.event_handler)(event), + size, + scale, + ) + } + }, + } + } +} + +impl Shared { + pub fn new() -> Self { + let main_thread = MainThreadMarker::new().expect("only callable from inside the `Window`"); + #[allow(clippy::disallowed_methods)] + let window = web_sys::window().expect("only callable from inside the `Window`"); + #[allow(clippy::disallowed_methods)] + let document = window.document().expect("Failed to obtain document"); + + Shared(Rc::::new_cyclic(|weak| { + let proxy_spawner = + WakerSpawner::new(main_thread, weak.clone(), |runner, count, local| { + if let Some(runner) = runner.upgrade() { + Shared(runner).send_user_events(count, local) + } + }) + .expect("`EventLoop` has to be created in the main thread"); + + Execution { + main_thread, + proxy_spawner, + control_flow: Cell::new(ControlFlow::default()), + poll_strategy: Cell::new(PollStrategy::default()), + wait_until_strategy: Cell::new(WaitUntilStrategy::default()), + exit: Cell::new(false), + runner: RefCell::new(RunnerEnum::Pending), + suspended: Cell::new(false), + event_loop_recreation: Cell::new(false), + events: RefCell::new(VecDeque::new()), + window, + document, + id: RefCell::new(0), + all_canvases: RefCell::new(Vec::new()), + redraw_pending: RefCell::new(HashSet::new()), + destroy_pending: RefCell::new(VecDeque::new()), + page_transition_event_handle: RefCell::new(None), + device_events: Cell::default(), + on_mouse_move: RefCell::new(None), + on_wheel: RefCell::new(None), + on_mouse_press: RefCell::new(None), + on_mouse_release: RefCell::new(None), + on_key_press: RefCell::new(None), + on_key_release: RefCell::new(None), + on_visibility_change: RefCell::new(None), + } + })) + } + + pub fn main_thread(&self) -> MainThreadMarker { + self.0.main_thread + } + + pub fn window(&self) -> &web_sys::Window { + &self.0.window + } + + pub fn document(&self) -> &Document { + &self.0.document + } + + pub fn add_canvas( + &self, + id: WindowId, + canvas: Weak>, + runner: DispatchRunner, + ) { + self.0.all_canvases.borrow_mut().push((id, canvas, runner)); + } + + pub fn notify_destroy_window(&self, id: WindowId) { + self.0.destroy_pending.borrow_mut().push_back(id); + } + + // Set the event callback to use for the event loop runner + // This the event callback is a fairly thin layer over the user-provided callback that closes + // over a RootActiveEventLoop reference + pub fn set_listener(&self, event_handler: Box) { + { + let mut runner = self.0.runner.borrow_mut(); + assert!(matches!(*runner, RunnerEnum::Pending)); + *runner = RunnerEnum::Running(Runner::new(event_handler)); + } + self.init(); + + *self.0.page_transition_event_handle.borrow_mut() = Some(backend::on_page_transition( + self.window().clone(), + { + let runner = self.clone(); + move |event: PageTransitionEvent| { + if event.persisted() { + runner.0.suspended.set(false); + runner.send_event(Event::Resumed); + } + } + }, + { + let runner = self.clone(); + move |event: PageTransitionEvent| { + runner.0.suspended.set(true); + if event.persisted() { + runner.send_event(Event::Suspended); + } else { + runner.handle_unload(); + } + } + }, + )); + + let runner = self.clone(); + let window = self.window().clone(); + *self.0.on_mouse_move.borrow_mut() = Some(EventListenerHandle::new( + self.window().clone(), + "pointermove", + Closure::new(move |event: PointerEvent| { + if !runner.device_events() { + return; + } + + // chorded button event + let device_id = RootDeviceId(DeviceId(event.pointer_id())); + + if let Some(button) = backend::event::mouse_button(&event) { + let state = if backend::event::mouse_buttons(&event).contains(button.into()) { + ElementState::Pressed + } else { + ElementState::Released + }; + + runner.send_event(Event::DeviceEvent { + device_id, + event: DeviceEvent::Button { button: button.to_id(), state }, + }); + + return; + } + + // pointer move event + let mut delta = backend::event::MouseDelta::init(&window, &event); + runner.send_events(backend::event::pointer_move_event(event).flat_map(|event| { + let delta = delta.delta(&event).to_physical(backend::scale_factor(&window)); + + let x_motion = (delta.x != 0.0).then_some(Event::DeviceEvent { + device_id, + event: DeviceEvent::Motion { axis: 0, value: delta.x }, + }); + + let y_motion = (delta.y != 0.0).then_some(Event::DeviceEvent { + device_id, + event: DeviceEvent::Motion { axis: 1, value: delta.y }, + }); + + x_motion.into_iter().chain(y_motion).chain(iter::once(Event::DeviceEvent { + device_id, + event: DeviceEvent::MouseMotion { delta: (delta.x, delta.y) }, + })) + })); + }), + )); + let runner = self.clone(); + let window = self.window().clone(); + *self.0.on_wheel.borrow_mut() = Some(EventListenerHandle::new( + self.window().clone(), + "wheel", + Closure::new(move |event: WheelEvent| { + if !runner.device_events() { + return; + } + + if let Some(delta) = backend::event::mouse_scroll_delta(&window, &event) { + runner.send_event(Event::DeviceEvent { + device_id: RootDeviceId(DeviceId(0)), + event: DeviceEvent::MouseWheel { delta }, + }); + } + }), + )); + let runner = self.clone(); + *self.0.on_mouse_press.borrow_mut() = Some(EventListenerHandle::new( + self.window().clone(), + "pointerdown", + Closure::new(move |event: PointerEvent| { + if !runner.device_events() { + return; + } + + let button = backend::event::mouse_button(&event).expect("no mouse button pressed"); + runner.send_event(Event::DeviceEvent { + device_id: RootDeviceId(DeviceId(event.pointer_id())), + event: DeviceEvent::Button { + button: button.to_id(), + state: ElementState::Pressed, + }, + }); + }), + )); + let runner = self.clone(); + *self.0.on_mouse_release.borrow_mut() = Some(EventListenerHandle::new( + self.window().clone(), + "pointerup", + Closure::new(move |event: PointerEvent| { + if !runner.device_events() { + return; + } + + let button = backend::event::mouse_button(&event).expect("no mouse button pressed"); + runner.send_event(Event::DeviceEvent { + device_id: RootDeviceId(DeviceId(event.pointer_id())), + event: DeviceEvent::Button { + button: button.to_id(), + state: ElementState::Released, + }, + }); + }), + )); + let runner = self.clone(); + *self.0.on_key_press.borrow_mut() = Some(EventListenerHandle::new( + self.window().clone(), + "keydown", + Closure::new(move |event: KeyboardEvent| { + if !runner.device_events() { + return; + } + + runner.send_event(Event::DeviceEvent { + device_id: RootDeviceId(DeviceId::dummy()), + event: DeviceEvent::Key(RawKeyEvent { + physical_key: backend::event::key_code(&event), + state: ElementState::Pressed, + }), + }); + }), + )); + let runner = self.clone(); + *self.0.on_key_release.borrow_mut() = Some(EventListenerHandle::new( + self.window().clone(), + "keyup", + Closure::new(move |event: KeyboardEvent| { + if !runner.device_events() { + return; + } + + runner.send_event(Event::DeviceEvent { + device_id: RootDeviceId(DeviceId::dummy()), + event: DeviceEvent::Key(RawKeyEvent { + physical_key: backend::event::key_code(&event), + state: ElementState::Released, + }), + }); + }), + )); + let runner = self.clone(); + *self.0.on_visibility_change.borrow_mut() = Some(EventListenerHandle::new( + // Safari <14 doesn't support the `visibilitychange` event on `Window`. + self.document().clone(), + "visibilitychange", + Closure::new(move |_| { + if !runner.0.suspended.get() { + for (id, canvas, _) in &*runner.0.all_canvases.borrow() { + if let Some(canvas) = canvas.upgrade() { + let is_visible = backend::is_visible(runner.document()); + // only fire if: + // - not visible and intersects + // - not visible and we don't know if it intersects yet + // - visible and intersects + if let (false, Some(true) | None) | (true, Some(true)) = + (is_visible, canvas.borrow().is_intersecting) + { + runner.send_event(Event::WindowEvent { + window_id: *id, + event: WindowEvent::Occluded(!is_visible), + }); + } + } + } + } + }), + )); + } + + // Generate a strictly increasing ID + // This is used to differentiate windows when handling events + pub fn generate_id(&self) -> u32 { + let mut id = self.0.id.borrow_mut(); + *id += 1; + + *id + } + + pub fn request_redraw(&self, id: WindowId) { + self.0.redraw_pending.borrow_mut().insert(id); + self.send_events::(iter::empty()); + } + + pub fn init(&self) { + // NB: For consistency all platforms must emit a 'resumed' event even though web + // applications don't themselves have a formal suspend/resume lifecycle. + self.run_until_cleared([Event::NewEvents(StartCause::Init), Event::Resumed].into_iter()); + } + + // Run the polling logic for the Poll ControlFlow, which involves clearing the queue + pub fn poll(&self) { + let start_cause = Event::NewEvents(StartCause::Poll); + self.run_until_cleared(iter::once(start_cause)); + } + + // Run the logic for waking from a WaitUntil, which involves clearing the queue + // Generally there shouldn't be events built up when this is called + pub fn resume_time_reached(&self, start: Instant, requested_resume: Instant) { + let start_cause = + Event::NewEvents(StartCause::ResumeTimeReached { start, requested_resume }); + self.run_until_cleared(iter::once(start_cause)); + } + + // Add an event to the event loop runner, from the user or an event handler + // + // It will determine if the event should be immediately sent to the user or buffered for later + pub(crate) fn send_event>(&self, event: E) { + self.send_events(iter::once(event)); + } + + // Add a series of user events to the event loop runner + // + // This will schedule the event loop to wake up instead of waking it up immediately if its not + // running. + pub(crate) fn send_user_events(&self, count: NonZeroUsize, local: bool) { + // If the event loop is closed, it should discard any new events + if self.is_closed() { + return; + } + + if local { + // If the loop is not running and triggered locally, queue on next microtick. + if let Ok(RunnerEnum::Running(_)) = + self.0.runner.try_borrow().as_ref().map(Deref::deref) + { + self.window().queue_microtask( + &Closure::once_into_js({ + let this = Rc::downgrade(&self.0); + move || { + if let Some(shared) = this.upgrade() { + Shared(shared).send_events( + iter::repeat(Event::UserEvent(())).take(count.get()), + ) + } + } + }) + .unchecked_into(), + ); + + return; + } + } + + self.send_events(iter::repeat(Event::UserEvent(())).take(count.get())) + } + + // Add a series of events to the event loop runner + // + // It will determine if the event should be immediately sent to the user or buffered for later + pub(crate) fn send_events>(&self, events: impl IntoIterator) { + // If the event loop is closed, it should discard any new events + if self.is_closed() { + return; + } + // If we can run the event processing right now, or need to queue this and wait for later + let mut process_immediately = true; + match self.0.runner.try_borrow().as_ref().map(Deref::deref) { + Ok(RunnerEnum::Running(ref runner)) => { + // If we're currently polling, queue this and wait for the poll() method to be + // called. + if let State::Poll { .. } = runner.state { + process_immediately = false; + } + }, + Ok(RunnerEnum::Pending) => { + // The runner still hasn't been attached: queue this event and wait for it to be + process_immediately = false; + }, + // Some other code is mutating the runner, which most likely means + // the event loop is running and busy. So we queue this event for + // it to be processed later. + Err(_) => { + process_immediately = false; + }, + // This is unreachable since `self.is_closed() == true`. + Ok(RunnerEnum::Destroyed) => unreachable!(), + } + if !process_immediately { + // Queue these events to look at later + self.0.events.borrow_mut().extend(events.into_iter().map(Into::into)); + return; + } + // At this point, we know this is a fresh set of events + // Now we determine why new events are incoming, and handle the events + let start_cause = match (self.0.runner.borrow().maybe_runner()) + .unwrap_or_else(|| { + unreachable!("The runner cannot process events when it is not attached") + }) + .maybe_start_cause() + { + Some(c) => c, + // If we're in the exit state, don't do event processing + None => return, + }; + // Take the start event, then the events provided to this function, and run an iteration of + // the event loop + let start_event = Event::NewEvents(start_cause); + let events = + iter::once(EventWrapper::from(start_event)).chain(events.into_iter().map(Into::into)); + self.run_until_cleared(events); + } + + // Process the destroy-pending windows. This should only be called from + // `run_until_cleared`, somewhere between emitting `NewEvents` and `AboutToWait`. + fn process_destroy_pending_windows(&self) { + while let Some(id) = self.0.destroy_pending.borrow_mut().pop_front() { + self.0.all_canvases.borrow_mut().retain(|&(item_id, ..)| item_id != id); + self.handle_event(Event::WindowEvent { + window_id: id, + event: crate::event::WindowEvent::Destroyed, + }); + self.0.redraw_pending.borrow_mut().remove(&id); + } + } + + // Given the set of new events, run the event loop until the main events and redraw events are + // cleared + // + // This will also process any events that have been queued or that are queued during processing + fn run_until_cleared>(&self, events: impl Iterator) { + for event in events { + self.handle_event(event.into()); + } + self.process_destroy_pending_windows(); + + // Collect all of the redraw events to avoid double-locking the RefCell + let redraw_events: Vec = self.0.redraw_pending.borrow_mut().drain().collect(); + for window_id in redraw_events { + self.handle_event(Event::WindowEvent { + window_id, + event: WindowEvent::RedrawRequested, + }); + } + + self.handle_event(Event::AboutToWait); + + self.apply_control_flow(); + // If the event loop is closed, it has been closed this iteration and now the closing + // event should be emitted + if self.is_closed() { + self.handle_loop_destroyed(); + } + } + + fn handle_unload(&self) { + self.exit(); + self.apply_control_flow(); + // We don't call `handle_loop_destroyed` here because we don't need to + // perform cleanup when the web browser is going to destroy the page. + self.handle_event(Event::LoopExiting); + } + + // handle_event takes in events and either queues them or applies a callback + // + // It should only ever be called from `run_until_cleared`. + fn handle_event(&self, event: impl Into) { + if self.is_closed() { + self.exit(); + } + match *self.0.runner.borrow_mut() { + RunnerEnum::Running(ref mut runner) => { + runner.handle_single_event(self, event); + }, + // If an event is being handled without a runner somehow, add it to the event queue so + // it will eventually be processed + RunnerEnum::Pending => self.0.events.borrow_mut().push_back(event.into()), + // If the Runner has been destroyed, there is nothing to do. + RunnerEnum::Destroyed => return, + } + + let is_closed = self.exiting(); + + // Don't take events out of the queue if the loop is closed or the runner doesn't exist + // If the runner doesn't exist and this method recurses, it will recurse infinitely + if !is_closed && self.0.runner.borrow().maybe_runner().is_some() { + // Pre-fetch window commands to avoid having to wait until the next event loop cycle + // and potentially block other threads in the meantime. + for (_, window, runner) in self.0.all_canvases.borrow().iter() { + if let Some(window) = window.upgrade() { + runner.run(); + drop(window) + } + } + + // Take an event out of the queue and handle it + // Make sure not to let the borrow_mut live during the next handle_event + let event = { + let mut events = self.0.events.borrow_mut(); + + // Pre-fetch `UserEvent`s to avoid having to wait until the next event loop cycle. + events.extend( + iter::repeat(Event::UserEvent(())) + .take(self.0.proxy_spawner.fetch()) + .map(EventWrapper::from), + ); + + events.pop_front() + }; + if let Some(event) = event { + self.handle_event(event); + } + } + } + + // Apply the new ControlFlow that has been selected by the user + // Start any necessary timeouts etc + fn apply_control_flow(&self) { + let new_state = if self.exiting() { + State::Exit + } else { + match self.control_flow() { + ControlFlow::Poll => { + let cloned = self.clone(); + State::Poll { + _request: backend::Schedule::new( + self.poll_strategy(), + self.window(), + move || cloned.poll(), + ), + } + }, + ControlFlow::Wait => State::Wait { start: Instant::now() }, + ControlFlow::WaitUntil(end) => { + let start = Instant::now(); + + let delay = if end <= start { Duration::from_millis(0) } else { end - start }; + + let cloned = self.clone(); + + State::WaitUntil { + start, + end, + _timeout: backend::Schedule::new_with_duration( + self.wait_until_strategy(), + self.window(), + move || cloned.resume_time_reached(start, end), + delay, + ), + } + }, + } + }; + + if let RunnerEnum::Running(ref mut runner) = *self.0.runner.borrow_mut() { + runner.state = new_state; + } + } + + fn handle_loop_destroyed(&self) { + self.handle_event(Event::LoopExiting); + let all_canvases = std::mem::take(&mut *self.0.all_canvases.borrow_mut()); + *self.0.page_transition_event_handle.borrow_mut() = None; + *self.0.on_mouse_move.borrow_mut() = None; + *self.0.on_wheel.borrow_mut() = None; + *self.0.on_mouse_press.borrow_mut() = None; + *self.0.on_mouse_release.borrow_mut() = None; + *self.0.on_key_press.borrow_mut() = None; + *self.0.on_key_release.borrow_mut() = None; + *self.0.on_visibility_change.borrow_mut() = None; + // Dropping the `Runner` drops the event handler closure, which will in + // turn drop all `Window`s moved into the closure. + *self.0.runner.borrow_mut() = RunnerEnum::Destroyed; + for (_, canvas, _) in all_canvases { + // In case any remaining `Window`s are still not dropped, we will need + // to explicitly remove the event handlers associated with their canvases. + if let Some(canvas) = canvas.upgrade() { + let mut canvas = canvas.borrow_mut(); + canvas.remove_listeners(); + } + } + // At this point, the `self.0` `Rc` should only be strongly referenced + // by the following: + // * `self`, i.e. the item which triggered this event loop wakeup, which is usually a + // `wasm-bindgen` `Closure`, which will be dropped after returning to the JS glue code. + // * The `ActiveEventLoop` leaked inside `EventLoop::run_app` due to the JS exception thrown + // at the end. + // * For each undropped `Window`: + // * The `register_redraw_request` closure. + // * The `destroy_fn` closure. + if self.0.event_loop_recreation.get() { + crate::event_loop::EventLoopBuilder::<()>::allow_event_loop_recreation(); + } + } + + // Check if the event loop is currently closed + fn is_closed(&self) -> bool { + match self.0.runner.try_borrow().as_ref().map(Deref::deref) { + Ok(RunnerEnum::Running(runner)) => runner.state.exiting(), + // The event loop is not closed since it is not initialized. + Ok(RunnerEnum::Pending) => false, + // The event loop is closed since it has been destroyed. + Ok(RunnerEnum::Destroyed) => true, + // Some other code is mutating the runner, which most likely means + // the event loop is running and busy. + Err(_) => false, + } + } + + pub fn listen_device_events(&self, allowed: DeviceEvents) { + self.0.device_events.set(allowed) + } + + fn device_events(&self) -> bool { + match self.0.device_events.get() { + DeviceEvents::Always => true, + DeviceEvents::WhenFocused => { + self.0.all_canvases.borrow().iter().any(|(_, canvas, _)| { + if let Some(canvas) = canvas.upgrade() { + canvas.borrow().has_focus.get() + } else { + false + } + }) + }, + DeviceEvents::Never => false, + } + } + + pub fn event_loop_recreation(&self, allow: bool) { + self.0.event_loop_recreation.set(allow) + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + self.0.control_flow.get() + } + + pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) { + self.0.control_flow.set(control_flow) + } + + pub(crate) fn exit(&self) { + self.0.exit.set(true) + } + + pub(crate) fn exiting(&self) -> bool { + self.0.exit.get() + } + + pub(crate) fn set_poll_strategy(&self, strategy: PollStrategy) { + self.0.poll_strategy.set(strategy) + } + + pub(crate) fn poll_strategy(&self) -> PollStrategy { + self.0.poll_strategy.get() + } + + pub(crate) fn set_wait_until_strategy(&self, strategy: WaitUntilStrategy) { + self.0.wait_until_strategy.set(strategy) + } + + pub(crate) fn wait_until_strategy(&self) -> WaitUntilStrategy { + self.0.wait_until_strategy.get() + } + + pub(crate) fn waker(&self) -> Waker> { + self.0.proxy_spawner.waker() + } +} + +pub(crate) enum EventWrapper { + Event(Event<()>), + ScaleChange { canvas: Weak>, size: PhysicalSize, scale: f64 }, +} + +impl From> for EventWrapper { + fn from(value: Event<()>) -> Self { + Self::Event(value) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/state.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/state.rs new file mode 100644 index 00000000..d06e30ed --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/state.rs @@ -0,0 +1,18 @@ +use super::backend; + +use web_time::Instant; + +#[derive(Debug)] +pub enum State { + Init, + WaitUntil { _timeout: backend::Schedule, start: Instant, end: Instant }, + Wait { start: Instant }, + Poll { _request: backend::Schedule }, + Exit, +} + +impl State { + pub fn exiting(&self) -> bool { + matches!(self, State::Exit) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/window_target.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/window_target.rs new file mode 100644 index 00000000..d741f098 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/event_loop/window_target.rs @@ -0,0 +1,685 @@ +use std::cell::{Cell, RefCell}; +use std::clone::Clone; +use std::collections::vec_deque::IntoIter as VecDequeIter; +use std::collections::VecDeque; +use std::iter; +use std::rc::{Rc, Weak}; + +use web_sys::Element; + +use super::super::monitor::MonitorHandle; +use super::super::KeyEventExtra; +use super::device::DeviceId; +use super::runner::{EventWrapper, Execution}; +use super::window::WindowId; +use super::{backend, runner}; +use crate::event::{ + DeviceId as RootDeviceId, ElementState, Event, KeyEvent, Touch, TouchPhase, WindowEvent, +}; +use crate::event_loop::{ControlFlow, DeviceEvents}; +use crate::keyboard::ModifiersState; +use crate::platform::web::{CustomCursorFuture, PollStrategy, WaitUntilStrategy}; +use crate::platform_impl::platform::cursor::CustomCursor; +use crate::platform_impl::platform::r#async::Waker; +use crate::window::{ + CustomCursor as RootCustomCursor, CustomCursorSource, Theme, WindowId as RootWindowId, +}; + +#[derive(Default)] +struct ModifiersShared(Rc>); + +impl ModifiersShared { + fn set(&self, new: ModifiersState) { + self.0.set(new) + } + + fn get(&self) -> ModifiersState { + self.0.get() + } +} + +impl Clone for ModifiersShared { + fn clone(&self) -> Self { + Self(Rc::clone(&self.0)) + } +} + +#[derive(Clone)] +pub struct ActiveEventLoop { + pub(crate) runner: runner::Shared, + modifiers: ModifiersShared, +} + +impl ActiveEventLoop { + pub fn new() -> Self { + Self { runner: runner::Shared::new(), modifiers: ModifiersShared::default() } + } + + pub fn run(&self, event_handler: Box, event_loop_recreation: bool) { + self.runner.event_loop_recreation(event_loop_recreation); + self.runner.set_listener(event_handler); + } + + pub fn generate_id(&self) -> WindowId { + WindowId(self.runner.generate_id()) + } + + pub fn create_custom_cursor(&self, source: CustomCursorSource) -> RootCustomCursor { + RootCustomCursor { inner: CustomCursor::new(self, source.inner) } + } + + pub fn create_custom_cursor_async(&self, source: CustomCursorSource) -> CustomCursorFuture { + CustomCursorFuture(CustomCursor::new_async(self, source.inner)) + } + + pub fn register(&self, canvas: &Rc>, id: WindowId) { + let canvas_clone = canvas.clone(); + let mut canvas = canvas.borrow_mut(); + #[cfg(any(feature = "rwh_04", feature = "rwh_05"))] + canvas.set_attribute("data-raw-handle", &id.0.to_string()); + + canvas.on_touch_start(); + + let runner = self.runner.clone(); + let has_focus = canvas.has_focus.clone(); + let modifiers = self.modifiers.clone(); + canvas.on_blur(move || { + has_focus.set(false); + + let clear_modifiers = (!modifiers.get().is_empty()).then(|| { + modifiers.set(ModifiersState::empty()); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(ModifiersState::empty().into()), + } + }); + + runner.send_events(clear_modifiers.into_iter().chain(iter::once(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::Focused(false), + }))); + }); + + let runner = self.runner.clone(); + let has_focus = canvas.has_focus.clone(); + canvas.on_focus(move || { + if !has_focus.replace(true) { + runner.send_event(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::Focused(true), + }); + } + }); + + // It is possible that at this point the canvas has + // been focused before the callback can be called. + let focused = canvas + .document() + .active_element() + .filter(|element| { + let canvas: &Element = canvas.raw(); + element == canvas + }) + .is_some(); + + if focused { + canvas.has_focus.set(true); + self.runner.send_event(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::Focused(true), + }) + } + + let runner = self.runner.clone(); + let modifiers = self.modifiers.clone(); + canvas.on_keyboard_press( + move |physical_key, logical_key, text, location, repeat, active_modifiers| { + let modifiers_changed = (modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + let device_id = RootDeviceId(DeviceId::dummy()); + + runner.send_events( + iter::once(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::KeyboardInput { + device_id, + event: KeyEvent { + physical_key, + logical_key, + text, + location, + state: ElementState::Pressed, + repeat, + platform_specific: KeyEventExtra, + }, + is_synthetic: false, + }, + }) + .chain(modifiers_changed), + ); + }, + ); + + let runner = self.runner.clone(); + let modifiers = self.modifiers.clone(); + canvas.on_keyboard_release( + move |physical_key, logical_key, text, location, repeat, active_modifiers| { + let modifiers_changed = (modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + let device_id = RootDeviceId(DeviceId::dummy()); + + runner.send_events( + iter::once(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::KeyboardInput { + device_id, + event: KeyEvent { + physical_key, + logical_key, + text, + location, + state: ElementState::Released, + repeat, + platform_specific: KeyEventExtra, + }, + is_synthetic: false, + }, + }) + .chain(modifiers_changed), + ) + }, + ); + + let has_focus = canvas.has_focus.clone(); + canvas.on_cursor_leave({ + let runner = self.runner.clone(); + let has_focus = has_focus.clone(); + let modifiers = self.modifiers.clone(); + + move |active_modifiers, pointer_id| { + let focus = (has_focus.get() && modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + let pointer = pointer_id.map(|pointer_id| Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::CursorLeft { + device_id: RootDeviceId(DeviceId(pointer_id)), + }, + }); + + if focus.is_some() || pointer.is_some() { + runner.send_events(focus.into_iter().chain(pointer)) + } + } + }); + + canvas.on_cursor_enter({ + let runner = self.runner.clone(); + let has_focus = has_focus.clone(); + let modifiers = self.modifiers.clone(); + + move |active_modifiers, pointer_id| { + let focus = (has_focus.get() && modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + let pointer = pointer_id.map(|pointer_id| Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::CursorEntered { + device_id: RootDeviceId(DeviceId(pointer_id)), + }, + }); + + if focus.is_some() || pointer.is_some() { + runner.send_events(focus.into_iter().chain(pointer)) + } + } + }); + + canvas.on_cursor_move( + { + let runner = self.runner.clone(); + let has_focus = has_focus.clone(); + let modifiers = self.modifiers.clone(); + + move |active_modifiers, pointer_id, events| { + let modifiers = + (has_focus.get() && modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + runner.send_events(modifiers.into_iter().chain(events.flat_map(|position| { + let device_id = RootDeviceId(DeviceId(pointer_id)); + + iter::once(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::CursorMoved { device_id, position }, + }) + }))); + } + }, + { + let runner = self.runner.clone(); + let has_focus = has_focus.clone(); + let modifiers = self.modifiers.clone(); + + move |active_modifiers, device_id, events| { + let modifiers = + (has_focus.get() && modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + runner.send_events(modifiers.into_iter().chain(events.map( + |(location, force)| Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::Touch(Touch { + id: device_id as u64, + device_id: RootDeviceId(DeviceId(device_id)), + phase: TouchPhase::Moved, + force: Some(force), + location, + }), + }, + ))); + } + }, + { + let runner = self.runner.clone(); + let has_focus = has_focus.clone(); + let modifiers = self.modifiers.clone(); + + move |active_modifiers, + pointer_id, + position: crate::dpi::PhysicalPosition, + buttons, + button| { + let modifiers = + (has_focus.get() && modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + let device_id = RootDeviceId(DeviceId(pointer_id)); + + let state = if buttons.contains(button.into()) { + ElementState::Pressed + } else { + ElementState::Released + }; + + // A chorded button event may come in without any prior CursorMoved events, + // therefore we should send a CursorMoved event to make sure that the + // user code has the correct cursor position. + runner.send_events(modifiers.into_iter().chain([ + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::CursorMoved { device_id, position }, + }, + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::MouseInput { device_id, state, button }, + }, + ])); + } + }, + ); + + canvas.on_mouse_press( + { + let runner = self.runner.clone(); + let modifiers = self.modifiers.clone(); + + move |active_modifiers, pointer_id, position, button| { + let modifiers = (modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + let device_id: RootDeviceId = RootDeviceId(DeviceId(pointer_id)); + + // A mouse down event may come in without any prior CursorMoved events, + // therefore we should send a CursorMoved event to make sure that the + // user code has the correct cursor position. + runner.send_events(modifiers.into_iter().chain([ + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::CursorMoved { device_id, position }, + }, + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::MouseInput { + device_id, + state: ElementState::Pressed, + button, + }, + }, + ])); + } + }, + { + let runner = self.runner.clone(); + let modifiers = self.modifiers.clone(); + + move |active_modifiers, device_id, location, force| { + let modifiers = (modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + runner.send_events(modifiers.into_iter().chain(iter::once( + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::Touch(Touch { + id: device_id as u64, + device_id: RootDeviceId(DeviceId(device_id)), + phase: TouchPhase::Started, + force: Some(force), + location, + }), + }, + ))) + } + }, + ); + + canvas.on_mouse_release( + { + let runner = self.runner.clone(); + let has_focus = has_focus.clone(); + let modifiers = self.modifiers.clone(); + + move |active_modifiers, pointer_id, position, button| { + let modifiers = + (has_focus.get() && modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + let device_id: RootDeviceId = RootDeviceId(DeviceId(pointer_id)); + + // A mouse up event may come in without any prior CursorMoved events, + // therefore we should send a CursorMoved event to make sure that the + // user code has the correct cursor position. + runner.send_events(modifiers.into_iter().chain([ + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::CursorMoved { device_id, position }, + }, + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::MouseInput { + device_id, + state: ElementState::Released, + button, + }, + }, + ])); + } + }, + { + let runner_touch = self.runner.clone(); + let has_focus = has_focus.clone(); + let modifiers = self.modifiers.clone(); + + move |active_modifiers, device_id, location, force| { + let modifiers = + (has_focus.get() && modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + runner_touch.send_events(modifiers.into_iter().chain(iter::once( + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::Touch(Touch { + id: device_id as u64, + device_id: RootDeviceId(DeviceId(device_id)), + phase: TouchPhase::Ended, + force: Some(force), + location, + }), + }, + ))); + } + }, + ); + + let runner = self.runner.clone(); + let modifiers = self.modifiers.clone(); + canvas.on_mouse_wheel(move |pointer_id, delta, active_modifiers| { + let modifiers_changed = + (has_focus.get() && modifiers.get() != active_modifiers).then(|| { + modifiers.set(active_modifiers); + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ModifiersChanged(active_modifiers.into()), + } + }); + + runner.send_events(modifiers_changed.into_iter().chain(iter::once( + Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::MouseWheel { + device_id: RootDeviceId(DeviceId(pointer_id)), + delta, + phase: TouchPhase::Moved, + }, + }, + ))); + }); + + let runner = self.runner.clone(); + canvas.on_touch_cancel(move |device_id, location, force| { + runner.send_event(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::Touch(Touch { + id: device_id as u64, + device_id: RootDeviceId(DeviceId(device_id)), + phase: TouchPhase::Cancelled, + force: Some(force), + location, + }), + }); + }); + + let runner = self.runner.clone(); + canvas.on_dark_mode(move |is_dark_mode| { + let theme = if is_dark_mode { Theme::Dark } else { Theme::Light }; + runner.send_event(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::ThemeChanged(theme), + }); + }); + + canvas.on_resize_scale( + { + let runner = self.runner.clone(); + let canvas = canvas_clone.clone(); + + move |size, scale| { + runner.send_event(EventWrapper::ScaleChange { + canvas: Rc::downgrade(&canvas), + size, + scale, + }) + } + }, + { + let runner = self.runner.clone(); + let canvas = canvas_clone.clone(); + + move |new_size| { + let canvas = canvas.borrow(); + canvas.set_current_size(new_size); + if canvas.old_size() != new_size { + canvas.set_old_size(new_size); + runner.send_event(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::Resized(new_size), + }); + canvas.request_animation_frame(); + } + } + }, + ); + + let runner = self.runner.clone(); + canvas.on_intersection(move |is_intersecting| { + // only fire if visible while skipping the first event if it's intersecting + if backend::is_visible(runner.document()) + && !(is_intersecting && canvas_clone.borrow().is_intersecting.is_none()) + { + runner.send_event(Event::WindowEvent { + window_id: RootWindowId(id), + event: WindowEvent::Occluded(!is_intersecting), + }); + } + + canvas_clone.borrow_mut().is_intersecting = Some(is_intersecting); + }); + + let runner = self.runner.clone(); + canvas.on_animation_frame(move || runner.request_redraw(RootWindowId(id))); + + canvas.on_context_menu(); + } + + pub fn available_monitors(&self) -> VecDequeIter { + VecDeque::new().into_iter() + } + + pub fn primary_monitor(&self) -> Option { + None + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::Web(rwh_05::WebDisplayHandle::empty()) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::Web(rwh_06::WebDisplayHandle::new())) + } + + pub fn listen_device_events(&self, allowed: DeviceEvents) { + self.runner.listen_device_events(allowed) + } + + pub fn system_theme(&self) -> Option { + backend::is_dark_mode(self.runner.window()).map(|is_dark_mode| { + if is_dark_mode { + Theme::Dark + } else { + Theme::Light + } + }) + } + + pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) { + self.runner.set_control_flow(control_flow) + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + self.runner.control_flow() + } + + pub(crate) fn exit(&self) { + self.runner.exit() + } + + pub(crate) fn exiting(&self) -> bool { + self.runner.exiting() + } + + pub(crate) fn set_poll_strategy(&self, strategy: PollStrategy) { + self.runner.set_poll_strategy(strategy) + } + + pub(crate) fn poll_strategy(&self) -> PollStrategy { + self.runner.poll_strategy() + } + + pub(crate) fn set_wait_until_strategy(&self, strategy: WaitUntilStrategy) { + self.runner.set_wait_until_strategy(strategy) + } + + pub(crate) fn wait_until_strategy(&self) -> WaitUntilStrategy { + self.runner.wait_until_strategy() + } + + pub(crate) fn waker(&self) -> Waker> { + self.runner.waker() + } + + pub(crate) fn owned_display_handle(&self) -> OwnedDisplayHandle { + OwnedDisplayHandle + } +} + +#[derive(Clone)] +pub(crate) struct OwnedDisplayHandle; + +impl OwnedDisplayHandle { + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::WebDisplayHandle::empty().into() + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::WebDisplayHandle::new().into()) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/keyboard.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/keyboard.rs new file mode 100644 index 00000000..6f8d69c7 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/keyboard.rs @@ -0,0 +1,522 @@ +use smol_str::SmolStr; + +use crate::keyboard::{Key, KeyCode, NamedKey, NativeKey, NativeKeyCode, PhysicalKey}; + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub(crate) struct KeyEventExtra; + +impl Key { + pub(crate) fn from_key_attribute_value(kav: &str) -> Self { + Key::Named(match kav { + "Unidentified" => return Key::Unidentified(NativeKey::Web(SmolStr::new(kav))), + "Dead" => return Key::Dead(None), + "Alt" => NamedKey::Alt, + "AltGraph" => NamedKey::AltGraph, + "CapsLock" => NamedKey::CapsLock, + "Control" => NamedKey::Control, + "Fn" => NamedKey::Fn, + "FnLock" => NamedKey::FnLock, + "NumLock" => NamedKey::NumLock, + "ScrollLock" => NamedKey::ScrollLock, + "Shift" => NamedKey::Shift, + "Symbol" => NamedKey::Symbol, + "SymbolLock" => NamedKey::SymbolLock, + "Hyper" => NamedKey::Hyper, + "Meta" => NamedKey::Super, + "Enter" => NamedKey::Enter, + "Tab" => NamedKey::Tab, + " " => NamedKey::Space, + "ArrowDown" => NamedKey::ArrowDown, + "ArrowLeft" => NamedKey::ArrowLeft, + "ArrowRight" => NamedKey::ArrowRight, + "ArrowUp" => NamedKey::ArrowUp, + "End" => NamedKey::End, + "Home" => NamedKey::Home, + "PageDown" => NamedKey::PageDown, + "PageUp" => NamedKey::PageUp, + "Backspace" => NamedKey::Backspace, + "Clear" => NamedKey::Clear, + "Copy" => NamedKey::Copy, + "CrSel" => NamedKey::CrSel, + "Cut" => NamedKey::Cut, + "Delete" => NamedKey::Delete, + "EraseEof" => NamedKey::EraseEof, + "ExSel" => NamedKey::ExSel, + "Insert" => NamedKey::Insert, + "Paste" => NamedKey::Paste, + "Redo" => NamedKey::Redo, + "Undo" => NamedKey::Undo, + "Accept" => NamedKey::Accept, + "Again" => NamedKey::Again, + "Attn" => NamedKey::Attn, + "Cancel" => NamedKey::Cancel, + "ContextMenu" => NamedKey::ContextMenu, + "Escape" => NamedKey::Escape, + "Execute" => NamedKey::Execute, + "Find" => NamedKey::Find, + "Help" => NamedKey::Help, + "Pause" => NamedKey::Pause, + "Play" => NamedKey::Play, + "Props" => NamedKey::Props, + "Select" => NamedKey::Select, + "ZoomIn" => NamedKey::ZoomIn, + "ZoomOut" => NamedKey::ZoomOut, + "BrightnessDown" => NamedKey::BrightnessDown, + "BrightnessUp" => NamedKey::BrightnessUp, + "Eject" => NamedKey::Eject, + "LogOff" => NamedKey::LogOff, + "Power" => NamedKey::Power, + "PowerOff" => NamedKey::PowerOff, + "PrintScreen" => NamedKey::PrintScreen, + "Hibernate" => NamedKey::Hibernate, + "Standby" => NamedKey::Standby, + "WakeUp" => NamedKey::WakeUp, + "AllCandidates" => NamedKey::AllCandidates, + "Alphanumeric" => NamedKey::Alphanumeric, + "CodeInput" => NamedKey::CodeInput, + "Compose" => NamedKey::Compose, + "Convert" => NamedKey::Convert, + "FinalMode" => NamedKey::FinalMode, + "GroupFirst" => NamedKey::GroupFirst, + "GroupLast" => NamedKey::GroupLast, + "GroupNext" => NamedKey::GroupNext, + "GroupPrevious" => NamedKey::GroupPrevious, + "ModeChange" => NamedKey::ModeChange, + "NextCandidate" => NamedKey::NextCandidate, + "NonConvert" => NamedKey::NonConvert, + "PreviousCandidate" => NamedKey::PreviousCandidate, + "Process" => NamedKey::Process, + "SingleCandidate" => NamedKey::SingleCandidate, + "HangulMode" => NamedKey::HangulMode, + "HanjaMode" => NamedKey::HanjaMode, + "JunjaMode" => NamedKey::JunjaMode, + "Eisu" => NamedKey::Eisu, + "Hankaku" => NamedKey::Hankaku, + "Hiragana" => NamedKey::Hiragana, + "HiraganaKatakana" => NamedKey::HiraganaKatakana, + "KanaMode" => NamedKey::KanaMode, + "KanjiMode" => NamedKey::KanjiMode, + "Katakana" => NamedKey::Katakana, + "Romaji" => NamedKey::Romaji, + "Zenkaku" => NamedKey::Zenkaku, + "ZenkakuHankaku" => NamedKey::ZenkakuHankaku, + "Soft1" => NamedKey::Soft1, + "Soft2" => NamedKey::Soft2, + "Soft3" => NamedKey::Soft3, + "Soft4" => NamedKey::Soft4, + "ChannelDown" => NamedKey::ChannelDown, + "ChannelUp" => NamedKey::ChannelUp, + "Close" => NamedKey::Close, + "MailForward" => NamedKey::MailForward, + "MailReply" => NamedKey::MailReply, + "MailSend" => NamedKey::MailSend, + "MediaClose" => NamedKey::MediaClose, + "MediaFastForward" => NamedKey::MediaFastForward, + "MediaPause" => NamedKey::MediaPause, + "MediaPlay" => NamedKey::MediaPlay, + "MediaPlayPause" => NamedKey::MediaPlayPause, + "MediaRecord" => NamedKey::MediaRecord, + "MediaRewind" => NamedKey::MediaRewind, + "MediaStop" => NamedKey::MediaStop, + "MediaTrackNext" => NamedKey::MediaTrackNext, + "MediaTrackPrevious" => NamedKey::MediaTrackPrevious, + "New" => NamedKey::New, + "Open" => NamedKey::Open, + "Print" => NamedKey::Print, + "Save" => NamedKey::Save, + "SpellCheck" => NamedKey::SpellCheck, + "Key11" => NamedKey::Key11, + "Key12" => NamedKey::Key12, + "AudioBalanceLeft" => NamedKey::AudioBalanceLeft, + "AudioBalanceRight" => NamedKey::AudioBalanceRight, + "AudioBassBoostDown" => NamedKey::AudioBassBoostDown, + "AudioBassBoostToggle" => NamedKey::AudioBassBoostToggle, + "AudioBassBoostUp" => NamedKey::AudioBassBoostUp, + "AudioFaderFront" => NamedKey::AudioFaderFront, + "AudioFaderRear" => NamedKey::AudioFaderRear, + "AudioSurroundModeNext" => NamedKey::AudioSurroundModeNext, + "AudioTrebleDown" => NamedKey::AudioTrebleDown, + "AudioTrebleUp" => NamedKey::AudioTrebleUp, + "AudioVolumeDown" => NamedKey::AudioVolumeDown, + "AudioVolumeUp" => NamedKey::AudioVolumeUp, + "AudioVolumeMute" => NamedKey::AudioVolumeMute, + "MicrophoneToggle" => NamedKey::MicrophoneToggle, + "MicrophoneVolumeDown" => NamedKey::MicrophoneVolumeDown, + "MicrophoneVolumeUp" => NamedKey::MicrophoneVolumeUp, + "MicrophoneVolumeMute" => NamedKey::MicrophoneVolumeMute, + "SpeechCorrectionList" => NamedKey::SpeechCorrectionList, + "SpeechInputToggle" => NamedKey::SpeechInputToggle, + "LaunchApplication1" => NamedKey::LaunchApplication1, + "LaunchApplication2" => NamedKey::LaunchApplication2, + "LaunchCalendar" => NamedKey::LaunchCalendar, + "LaunchContacts" => NamedKey::LaunchContacts, + "LaunchMail" => NamedKey::LaunchMail, + "LaunchMediaPlayer" => NamedKey::LaunchMediaPlayer, + "LaunchMusicPlayer" => NamedKey::LaunchMusicPlayer, + "LaunchPhone" => NamedKey::LaunchPhone, + "LaunchScreenSaver" => NamedKey::LaunchScreenSaver, + "LaunchSpreadsheet" => NamedKey::LaunchSpreadsheet, + "LaunchWebBrowser" => NamedKey::LaunchWebBrowser, + "LaunchWebCam" => NamedKey::LaunchWebCam, + "LaunchWordProcessor" => NamedKey::LaunchWordProcessor, + "BrowserBack" => NamedKey::BrowserBack, + "BrowserFavorites" => NamedKey::BrowserFavorites, + "BrowserForward" => NamedKey::BrowserForward, + "BrowserHome" => NamedKey::BrowserHome, + "BrowserRefresh" => NamedKey::BrowserRefresh, + "BrowserSearch" => NamedKey::BrowserSearch, + "BrowserStop" => NamedKey::BrowserStop, + "AppSwitch" => NamedKey::AppSwitch, + "Call" => NamedKey::Call, + "Camera" => NamedKey::Camera, + "CameraFocus" => NamedKey::CameraFocus, + "EndCall" => NamedKey::EndCall, + "GoBack" => NamedKey::GoBack, + "GoHome" => NamedKey::GoHome, + "HeadsetHook" => NamedKey::HeadsetHook, + "LastNumberRedial" => NamedKey::LastNumberRedial, + "Notification" => NamedKey::Notification, + "MannerMode" => NamedKey::MannerMode, + "VoiceDial" => NamedKey::VoiceDial, + "TV" => NamedKey::TV, + "TV3DMode" => NamedKey::TV3DMode, + "TVAntennaCable" => NamedKey::TVAntennaCable, + "TVAudioDescription" => NamedKey::TVAudioDescription, + "TVAudioDescriptionMixDown" => NamedKey::TVAudioDescriptionMixDown, + "TVAudioDescriptionMixUp" => NamedKey::TVAudioDescriptionMixUp, + "TVContentsMenu" => NamedKey::TVContentsMenu, + "TVDataService" => NamedKey::TVDataService, + "TVInput" => NamedKey::TVInput, + "TVInputComponent1" => NamedKey::TVInputComponent1, + "TVInputComponent2" => NamedKey::TVInputComponent2, + "TVInputComposite1" => NamedKey::TVInputComposite1, + "TVInputComposite2" => NamedKey::TVInputComposite2, + "TVInputHDMI1" => NamedKey::TVInputHDMI1, + "TVInputHDMI2" => NamedKey::TVInputHDMI2, + "TVInputHDMI3" => NamedKey::TVInputHDMI3, + "TVInputHDMI4" => NamedKey::TVInputHDMI4, + "TVInputVGA1" => NamedKey::TVInputVGA1, + "TVMediaContext" => NamedKey::TVMediaContext, + "TVNetwork" => NamedKey::TVNetwork, + "TVNumberEntry" => NamedKey::TVNumberEntry, + "TVPower" => NamedKey::TVPower, + "TVRadioService" => NamedKey::TVRadioService, + "TVSatellite" => NamedKey::TVSatellite, + "TVSatelliteBS" => NamedKey::TVSatelliteBS, + "TVSatelliteCS" => NamedKey::TVSatelliteCS, + "TVSatelliteToggle" => NamedKey::TVSatelliteToggle, + "TVTerrestrialAnalog" => NamedKey::TVTerrestrialAnalog, + "TVTerrestrialDigital" => NamedKey::TVTerrestrialDigital, + "TVTimer" => NamedKey::TVTimer, + "AVRInput" => NamedKey::AVRInput, + "AVRPower" => NamedKey::AVRPower, + "ColorF0Red" => NamedKey::ColorF0Red, + "ColorF1Green" => NamedKey::ColorF1Green, + "ColorF2Yellow" => NamedKey::ColorF2Yellow, + "ColorF3Blue" => NamedKey::ColorF3Blue, + "ColorF4Grey" => NamedKey::ColorF4Grey, + "ColorF5Brown" => NamedKey::ColorF5Brown, + "ClosedCaptionToggle" => NamedKey::ClosedCaptionToggle, + "Dimmer" => NamedKey::Dimmer, + "DisplaySwap" => NamedKey::DisplaySwap, + "DVR" => NamedKey::DVR, + "Exit" => NamedKey::Exit, + "FavoriteClear0" => NamedKey::FavoriteClear0, + "FavoriteClear1" => NamedKey::FavoriteClear1, + "FavoriteClear2" => NamedKey::FavoriteClear2, + "FavoriteClear3" => NamedKey::FavoriteClear3, + "FavoriteRecall0" => NamedKey::FavoriteRecall0, + "FavoriteRecall1" => NamedKey::FavoriteRecall1, + "FavoriteRecall2" => NamedKey::FavoriteRecall2, + "FavoriteRecall3" => NamedKey::FavoriteRecall3, + "FavoriteStore0" => NamedKey::FavoriteStore0, + "FavoriteStore1" => NamedKey::FavoriteStore1, + "FavoriteStore2" => NamedKey::FavoriteStore2, + "FavoriteStore3" => NamedKey::FavoriteStore3, + "Guide" => NamedKey::Guide, + "GuideNextDay" => NamedKey::GuideNextDay, + "GuidePreviousDay" => NamedKey::GuidePreviousDay, + "Info" => NamedKey::Info, + "InstantReplay" => NamedKey::InstantReplay, + "Link" => NamedKey::Link, + "ListProgram" => NamedKey::ListProgram, + "LiveContent" => NamedKey::LiveContent, + "Lock" => NamedKey::Lock, + "MediaApps" => NamedKey::MediaApps, + "MediaAudioTrack" => NamedKey::MediaAudioTrack, + "MediaLast" => NamedKey::MediaLast, + "MediaSkipBackward" => NamedKey::MediaSkipBackward, + "MediaSkipForward" => NamedKey::MediaSkipForward, + "MediaStepBackward" => NamedKey::MediaStepBackward, + "MediaStepForward" => NamedKey::MediaStepForward, + "MediaTopMenu" => NamedKey::MediaTopMenu, + "NavigateIn" => NamedKey::NavigateIn, + "NavigateNext" => NamedKey::NavigateNext, + "NavigateOut" => NamedKey::NavigateOut, + "NavigatePrevious" => NamedKey::NavigatePrevious, + "NextFavoriteChannel" => NamedKey::NextFavoriteChannel, + "NextUserProfile" => NamedKey::NextUserProfile, + "OnDemand" => NamedKey::OnDemand, + "Pairing" => NamedKey::Pairing, + "PinPDown" => NamedKey::PinPDown, + "PinPMove" => NamedKey::PinPMove, + "PinPToggle" => NamedKey::PinPToggle, + "PinPUp" => NamedKey::PinPUp, + "PlaySpeedDown" => NamedKey::PlaySpeedDown, + "PlaySpeedReset" => NamedKey::PlaySpeedReset, + "PlaySpeedUp" => NamedKey::PlaySpeedUp, + "RandomToggle" => NamedKey::RandomToggle, + "RcLowBattery" => NamedKey::RcLowBattery, + "RecordSpeedNext" => NamedKey::RecordSpeedNext, + "RfBypass" => NamedKey::RfBypass, + "ScanChannelsToggle" => NamedKey::ScanChannelsToggle, + "ScreenModeNext" => NamedKey::ScreenModeNext, + "Settings" => NamedKey::Settings, + "SplitScreenToggle" => NamedKey::SplitScreenToggle, + "STBInput" => NamedKey::STBInput, + "STBPower" => NamedKey::STBPower, + "Subtitle" => NamedKey::Subtitle, + "Teletext" => NamedKey::Teletext, + "VideoModeNext" => NamedKey::VideoModeNext, + "Wink" => NamedKey::Wink, + "ZoomToggle" => NamedKey::ZoomToggle, + "F1" => NamedKey::F1, + "F2" => NamedKey::F2, + "F3" => NamedKey::F3, + "F4" => NamedKey::F4, + "F5" => NamedKey::F5, + "F6" => NamedKey::F6, + "F7" => NamedKey::F7, + "F8" => NamedKey::F8, + "F9" => NamedKey::F9, + "F10" => NamedKey::F10, + "F11" => NamedKey::F11, + "F12" => NamedKey::F12, + "F13" => NamedKey::F13, + "F14" => NamedKey::F14, + "F15" => NamedKey::F15, + "F16" => NamedKey::F16, + "F17" => NamedKey::F17, + "F18" => NamedKey::F18, + "F19" => NamedKey::F19, + "F20" => NamedKey::F20, + "F21" => NamedKey::F21, + "F22" => NamedKey::F22, + "F23" => NamedKey::F23, + "F24" => NamedKey::F24, + "F25" => NamedKey::F25, + "F26" => NamedKey::F26, + "F27" => NamedKey::F27, + "F28" => NamedKey::F28, + "F29" => NamedKey::F29, + "F30" => NamedKey::F30, + "F31" => NamedKey::F31, + "F32" => NamedKey::F32, + "F33" => NamedKey::F33, + "F34" => NamedKey::F34, + "F35" => NamedKey::F35, + string => return Key::Character(SmolStr::new(string)), + }) + } +} + +impl PhysicalKey { + pub fn from_key_code_attribute_value(kcav: &str) -> Self { + PhysicalKey::Code(match kcav { + "Backquote" => KeyCode::Backquote, + "Backslash" => KeyCode::Backslash, + "BracketLeft" => KeyCode::BracketLeft, + "BracketRight" => KeyCode::BracketRight, + "Comma" => KeyCode::Comma, + "Digit0" => KeyCode::Digit0, + "Digit1" => KeyCode::Digit1, + "Digit2" => KeyCode::Digit2, + "Digit3" => KeyCode::Digit3, + "Digit4" => KeyCode::Digit4, + "Digit5" => KeyCode::Digit5, + "Digit6" => KeyCode::Digit6, + "Digit7" => KeyCode::Digit7, + "Digit8" => KeyCode::Digit8, + "Digit9" => KeyCode::Digit9, + "Equal" => KeyCode::Equal, + "IntlBackslash" => KeyCode::IntlBackslash, + "IntlRo" => KeyCode::IntlRo, + "IntlYen" => KeyCode::IntlYen, + "KeyA" => KeyCode::KeyA, + "KeyB" => KeyCode::KeyB, + "KeyC" => KeyCode::KeyC, + "KeyD" => KeyCode::KeyD, + "KeyE" => KeyCode::KeyE, + "KeyF" => KeyCode::KeyF, + "KeyG" => KeyCode::KeyG, + "KeyH" => KeyCode::KeyH, + "KeyI" => KeyCode::KeyI, + "KeyJ" => KeyCode::KeyJ, + "KeyK" => KeyCode::KeyK, + "KeyL" => KeyCode::KeyL, + "KeyM" => KeyCode::KeyM, + "KeyN" => KeyCode::KeyN, + "KeyO" => KeyCode::KeyO, + "KeyP" => KeyCode::KeyP, + "KeyQ" => KeyCode::KeyQ, + "KeyR" => KeyCode::KeyR, + "KeyS" => KeyCode::KeyS, + "KeyT" => KeyCode::KeyT, + "KeyU" => KeyCode::KeyU, + "KeyV" => KeyCode::KeyV, + "KeyW" => KeyCode::KeyW, + "KeyX" => KeyCode::KeyX, + "KeyY" => KeyCode::KeyY, + "KeyZ" => KeyCode::KeyZ, + "Minus" => KeyCode::Minus, + "Period" => KeyCode::Period, + "Quote" => KeyCode::Quote, + "Semicolon" => KeyCode::Semicolon, + "Slash" => KeyCode::Slash, + "AltLeft" => KeyCode::AltLeft, + "AltRight" => KeyCode::AltRight, + "Backspace" => KeyCode::Backspace, + "CapsLock" => KeyCode::CapsLock, + "ContextMenu" => KeyCode::ContextMenu, + "ControlLeft" => KeyCode::ControlLeft, + "ControlRight" => KeyCode::ControlRight, + "Enter" => KeyCode::Enter, + "MetaLeft" => KeyCode::SuperLeft, + "MetaRight" => KeyCode::SuperRight, + "ShiftLeft" => KeyCode::ShiftLeft, + "ShiftRight" => KeyCode::ShiftRight, + "Space" => KeyCode::Space, + "Tab" => KeyCode::Tab, + "Convert" => KeyCode::Convert, + "KanaMode" => KeyCode::KanaMode, + "Lang1" => KeyCode::Lang1, + "Lang2" => KeyCode::Lang2, + "Lang3" => KeyCode::Lang3, + "Lang4" => KeyCode::Lang4, + "Lang5" => KeyCode::Lang5, + "NonConvert" => KeyCode::NonConvert, + "Delete" => KeyCode::Delete, + "End" => KeyCode::End, + "Help" => KeyCode::Help, + "Home" => KeyCode::Home, + "Insert" => KeyCode::Insert, + "PageDown" => KeyCode::PageDown, + "PageUp" => KeyCode::PageUp, + "ArrowDown" => KeyCode::ArrowDown, + "ArrowLeft" => KeyCode::ArrowLeft, + "ArrowRight" => KeyCode::ArrowRight, + "ArrowUp" => KeyCode::ArrowUp, + "NumLock" => KeyCode::NumLock, + "Numpad0" => KeyCode::Numpad0, + "Numpad1" => KeyCode::Numpad1, + "Numpad2" => KeyCode::Numpad2, + "Numpad3" => KeyCode::Numpad3, + "Numpad4" => KeyCode::Numpad4, + "Numpad5" => KeyCode::Numpad5, + "Numpad6" => KeyCode::Numpad6, + "Numpad7" => KeyCode::Numpad7, + "Numpad8" => KeyCode::Numpad8, + "Numpad9" => KeyCode::Numpad9, + "NumpadAdd" => KeyCode::NumpadAdd, + "NumpadBackspace" => KeyCode::NumpadBackspace, + "NumpadClear" => KeyCode::NumpadClear, + "NumpadClearEntry" => KeyCode::NumpadClearEntry, + "NumpadComma" => KeyCode::NumpadComma, + "NumpadDecimal" => KeyCode::NumpadDecimal, + "NumpadDivide" => KeyCode::NumpadDivide, + "NumpadEnter" => KeyCode::NumpadEnter, + "NumpadEqual" => KeyCode::NumpadEqual, + "NumpadHash" => KeyCode::NumpadHash, + "NumpadMemoryAdd" => KeyCode::NumpadMemoryAdd, + "NumpadMemoryClear" => KeyCode::NumpadMemoryClear, + "NumpadMemoryRecall" => KeyCode::NumpadMemoryRecall, + "NumpadMemoryStore" => KeyCode::NumpadMemoryStore, + "NumpadMemorySubtract" => KeyCode::NumpadMemorySubtract, + "NumpadMultiply" => KeyCode::NumpadMultiply, + "NumpadParenLeft" => KeyCode::NumpadParenLeft, + "NumpadParenRight" => KeyCode::NumpadParenRight, + "NumpadStar" => KeyCode::NumpadStar, + "NumpadSubtract" => KeyCode::NumpadSubtract, + "Escape" => KeyCode::Escape, + "Fn" => KeyCode::Fn, + "FnLock" => KeyCode::FnLock, + "PrintScreen" => KeyCode::PrintScreen, + "ScrollLock" => KeyCode::ScrollLock, + "Pause" => KeyCode::Pause, + "BrowserBack" => KeyCode::BrowserBack, + "BrowserFavorites" => KeyCode::BrowserFavorites, + "BrowserForward" => KeyCode::BrowserForward, + "BrowserHome" => KeyCode::BrowserHome, + "BrowserRefresh" => KeyCode::BrowserRefresh, + "BrowserSearch" => KeyCode::BrowserSearch, + "BrowserStop" => KeyCode::BrowserStop, + "Eject" => KeyCode::Eject, + "LaunchApp1" => KeyCode::LaunchApp1, + "LaunchApp2" => KeyCode::LaunchApp2, + "LaunchMail" => KeyCode::LaunchMail, + "MediaPlayPause" => KeyCode::MediaPlayPause, + "MediaSelect" => KeyCode::MediaSelect, + "MediaStop" => KeyCode::MediaStop, + "MediaTrackNext" => KeyCode::MediaTrackNext, + "MediaTrackPrevious" => KeyCode::MediaTrackPrevious, + "Power" => KeyCode::Power, + "Sleep" => KeyCode::Sleep, + "AudioVolumeDown" => KeyCode::AudioVolumeDown, + "AudioVolumeMute" => KeyCode::AudioVolumeMute, + "AudioVolumeUp" => KeyCode::AudioVolumeUp, + "WakeUp" => KeyCode::WakeUp, + "Hyper" => KeyCode::Hyper, + "Turbo" => KeyCode::Turbo, + "Abort" => KeyCode::Abort, + "Resume" => KeyCode::Resume, + "Suspend" => KeyCode::Suspend, + "Again" => KeyCode::Again, + "Copy" => KeyCode::Copy, + "Cut" => KeyCode::Cut, + "Find" => KeyCode::Find, + "Open" => KeyCode::Open, + "Paste" => KeyCode::Paste, + "Props" => KeyCode::Props, + "Select" => KeyCode::Select, + "Undo" => KeyCode::Undo, + "Hiragana" => KeyCode::Hiragana, + "Katakana" => KeyCode::Katakana, + "F1" => KeyCode::F1, + "F2" => KeyCode::F2, + "F3" => KeyCode::F3, + "F4" => KeyCode::F4, + "F5" => KeyCode::F5, + "F6" => KeyCode::F6, + "F7" => KeyCode::F7, + "F8" => KeyCode::F8, + "F9" => KeyCode::F9, + "F10" => KeyCode::F10, + "F11" => KeyCode::F11, + "F12" => KeyCode::F12, + "F13" => KeyCode::F13, + "F14" => KeyCode::F14, + "F15" => KeyCode::F15, + "F16" => KeyCode::F16, + "F17" => KeyCode::F17, + "F18" => KeyCode::F18, + "F19" => KeyCode::F19, + "F20" => KeyCode::F20, + "F21" => KeyCode::F21, + "F22" => KeyCode::F22, + "F23" => KeyCode::F23, + "F24" => KeyCode::F24, + "F25" => KeyCode::F25, + "F26" => KeyCode::F26, + "F27" => KeyCode::F27, + "F28" => KeyCode::F28, + "F29" => KeyCode::F29, + "F30" => KeyCode::F30, + "F31" => KeyCode::F31, + "F32" => KeyCode::F32, + "F33" => KeyCode::F33, + "F34" => KeyCode::F34, + "F35" => KeyCode::F35, + _ => return PhysicalKey::Unidentified(NativeKeyCode::Unidentified), + }) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/main_thread.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/main_thread.rs new file mode 100644 index 00000000..59a2ac59 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/main_thread.rs @@ -0,0 +1,96 @@ +use std::fmt::{self, Debug, Formatter}; +use std::marker::PhantomData; +use std::mem; +use std::sync::OnceLock; + +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; + +use super::r#async::{self, Sender}; + +thread_local! { + static MAIN_THREAD: bool = { + #[wasm_bindgen] + extern "C" { + #[derive(Clone)] + type Global; + + #[wasm_bindgen(method, getter, js_name = Window)] + fn window(this: &Global) -> JsValue; + } + + let global: Global = js_sys::global().unchecked_into(); + !global.window().is_undefined() + }; +} + +#[derive(Clone, Copy, Debug)] +pub struct MainThreadMarker(PhantomData<*const ()>); + +impl MainThreadMarker { + pub fn new() -> Option { + MAIN_THREAD.with(|is| is.then_some(Self(PhantomData))) + } +} + +pub struct MainThreadSafe(Option); + +impl MainThreadSafe { + pub fn new(_: MainThreadMarker, value: T) -> Self { + DROP_HANDLER.get_or_init(|| { + let (sender, receiver) = r#async::channel(); + wasm_bindgen_futures::spawn_local( + async move { while receiver.next().await.is_ok() {} }, + ); + + sender + }); + + Self(Some(value)) + } + + pub fn into_inner(mut self, _: MainThreadMarker) -> T { + self.0.take().expect("already taken or dropped") + } + + pub fn get(&self, _: MainThreadMarker) -> &T { + self.0.as_ref().expect("already taken or dropped") + } +} + +impl Debug for MainThreadSafe { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if MainThreadMarker::new().is_some() { + f.debug_tuple("MainThreadSafe").field(&self.0).finish() + } else { + f.debug_struct("MainThreadSafe").finish_non_exhaustive() + } + } +} + +impl Drop for MainThreadSafe { + fn drop(&mut self) { + if let Some(value) = self.0.take() { + if mem::needs_drop::() && MainThreadMarker::new().is_none() { + DROP_HANDLER + .get() + .expect("drop handler not initialized when setting canvas") + .send(DropBox(Box::new(value))) + .expect("sender dropped in main thread") + } + } + } +} + +unsafe impl Send for MainThreadSafe {} +unsafe impl Sync for MainThreadSafe {} + +static DROP_HANDLER: OnceLock> = OnceLock::new(); + +struct DropBox(#[allow(dead_code)] Box); + +unsafe impl Send for DropBox {} +unsafe impl Sync for DropBox {} + +trait Any {} +impl Any for T {} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/mod.rs new file mode 100644 index 00000000..969d8bb1 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/mod.rs @@ -0,0 +1,50 @@ +// Brief introduction to the internals of the web backend: +// The web backend used to support both wasm-bindgen and stdweb as methods of binding to the +// environment. Because they are both supporting the same underlying APIs, the actual web bindings +// are cordoned off into backend abstractions, which present the thinnest unifying layer possible. +// +// When adding support for new events or interactions with the browser, first consult trusted +// documentation (such as MDN) to ensure it is well-standardised and supported across many browsers. +// Once you have decided on the relevant web APIs, add support to both backends. +// +// The backend is used by the rest of the module to implement Winit's business logic, which forms +// the rest of the code. 'device', 'error', 'monitor', and 'window' define web-specific structures +// for winit's cross-platform structures. They are all relatively simple translations. +// +// The event_loop module handles listening for and processing events. 'Proxy' implements +// EventLoopProxy and 'WindowTarget' implements ActiveEventLoop. WindowTarget also handles +// registering the event handlers. The 'Execution' struct in the 'runner' module handles taking +// incoming events (from the registered handlers) and ensuring they are passed to the user in a +// compliant way. + +// TODO: FP, remove when is fixed. +#![allow(clippy::empty_docs)] + +mod r#async; +mod cursor; +mod device; +mod error; +mod event_loop; +mod keyboard; +mod main_thread; +mod monitor; +mod web_sys; +mod window; + +pub use self::device::DeviceId; +pub use self::error::OsError; +pub(crate) use self::event_loop::{ + ActiveEventLoop, EventLoop, EventLoopProxy, OwnedDisplayHandle, + PlatformSpecificEventLoopAttributes, +}; +pub use self::monitor::{MonitorHandle, VideoModeHandle}; +pub use self::window::{PlatformSpecificWindowAttributes, Window, WindowId}; + +pub(crate) use self::keyboard::KeyEventExtra; +use self::web_sys as backend; +pub(crate) use crate::icon::NoIcon as PlatformIcon; +pub(crate) use crate::platform_impl::Fullscreen; +pub(crate) use cursor::{ + CustomCursor as PlatformCustomCursor, CustomCursorFuture, + CustomCursorSource as PlatformCustomCursorSource, +}; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/monitor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/monitor.rs new file mode 100644 index 00000000..1870284a --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/monitor.rs @@ -0,0 +1,53 @@ +use std::iter::Empty; + +use crate::dpi::{PhysicalPosition, PhysicalSize}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MonitorHandle; + +impl MonitorHandle { + pub fn scale_factor(&self) -> f64 { + unreachable!() + } + + pub fn position(&self) -> PhysicalPosition { + unreachable!() + } + + pub fn name(&self) -> Option { + unreachable!() + } + + pub fn refresh_rate_millihertz(&self) -> Option { + unreachable!() + } + + pub fn size(&self) -> PhysicalSize { + unreachable!() + } + + pub fn video_modes(&self) -> Empty { + unreachable!() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct VideoModeHandle; + +impl VideoModeHandle { + pub fn size(&self) -> PhysicalSize { + unreachable!(); + } + + pub fn bit_depth(&self) -> u16 { + unreachable!(); + } + + pub fn refresh_rate_millihertz(&self) -> u32 { + unreachable!(); + } + + pub fn monitor(&self) -> MonitorHandle { + unreachable!(); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/animation_frame.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/animation_frame.rs new file mode 100644 index 00000000..17aa8f68 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/animation_frame.rs @@ -0,0 +1,60 @@ +use std::cell::Cell; +use std::rc::Rc; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; + +pub struct AnimationFrameHandler { + window: web_sys::Window, + closure: Closure, + handle: Rc>>, +} + +impl AnimationFrameHandler { + pub fn new(window: web_sys::Window) -> Self { + let handle = Rc::new(Cell::new(None)); + let closure = Closure::new({ + let handle = handle.clone(); + move || handle.set(None) + }); + + Self { window, closure, handle } + } + + pub fn on_animation_frame(&mut self, mut f: F) + where + F: 'static + FnMut(), + { + let handle = self.handle.clone(); + self.closure = Closure::new(move || { + handle.set(None); + f(); + }) + } + + pub fn request(&self) { + if let Some(handle) = self.handle.take() { + self.window.cancel_animation_frame(handle).expect("Failed to cancel animation frame"); + } + + let handle = self + .window + .request_animation_frame(self.closure.as_ref().unchecked_ref()) + .expect("Failed to request animation frame"); + + self.handle.set(Some(handle)); + } + + pub fn cancel(&mut self) { + if let Some(handle) = self.handle.take() { + self.window.cancel_animation_frame(handle).expect("Failed to cancel animation frame"); + } + } +} + +impl Drop for AnimationFrameHandler { + fn drop(&mut self) { + if let Some(handle) = self.handle.take() { + self.window.cancel_animation_frame(handle).expect("Failed to cancel animation frame"); + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/canvas.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/canvas.rs new file mode 100644 index 00000000..84dde9f7 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/canvas.rs @@ -0,0 +1,565 @@ +use std::cell::Cell; +use std::ops::Deref; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +use smol_str::SmolStr; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; +use web_sys::{ + CssStyleDeclaration, Document, Event, FocusEvent, HtmlCanvasElement, KeyboardEvent, + PointerEvent, WheelEvent, +}; + +use crate::dpi::{LogicalPosition, PhysicalPosition, PhysicalSize}; +use crate::error::OsError as RootOE; +use crate::event::{Force, InnerSizeWriter, MouseButton, MouseScrollDelta}; +use crate::keyboard::{Key, KeyLocation, ModifiersState, PhysicalKey}; +use crate::platform_impl::OsError; +use crate::window::{WindowAttributes, WindowId as RootWindowId}; + +use super::super::cursor::CursorHandler; +use super::super::main_thread::MainThreadMarker; +use super::super::WindowId; +use super::animation_frame::AnimationFrameHandler; +use super::event_handle::EventListenerHandle; +use super::intersection_handle::IntersectionObserverHandle; +use super::media_query_handle::MediaQueryListHandle; +use super::pointer::PointerHandler; +use super::{event, fullscreen, ButtonsState, ResizeScaleHandle}; + +#[allow(dead_code)] +pub struct Canvas { + common: Common, + id: WindowId, + pub has_focus: Rc>, + pub prevent_default: Rc>, + pub is_intersecting: Option, + on_touch_start: Option>, + on_focus: Option>, + on_blur: Option>, + on_keyboard_release: Option>, + on_keyboard_press: Option>, + on_mouse_wheel: Option>, + on_dark_mode: Option, + pointer_handler: PointerHandler, + on_resize_scale: Option, + on_intersect: Option, + animation_frame_handler: AnimationFrameHandler, + on_touch_end: Option>, + on_context_menu: Option>, + pub cursor: CursorHandler, +} + +pub struct Common { + pub window: web_sys::Window, + pub document: Document, + /// Note: resizing the HTMLCanvasElement should go through `backend::set_canvas_size` to ensure + /// the DPI factor is maintained. Note: this is read-only because we use a pointer to this + /// for [`WindowHandle`][rwh_06::WindowHandle]. + raw: Rc, + style: Style, + old_size: Rc>>, + current_size: Rc>>, +} + +#[derive(Clone, Debug)] +pub struct Style { + read: CssStyleDeclaration, + write: CssStyleDeclaration, +} + +impl Canvas { + pub(crate) fn create( + main_thread: MainThreadMarker, + id: WindowId, + window: web_sys::Window, + document: Document, + attr: &mut WindowAttributes, + ) -> Result { + let canvas = match attr.platform_specific.canvas.take().map(|canvas| { + Arc::try_unwrap(canvas) + .map(|canvas| canvas.into_inner(main_thread)) + .unwrap_or_else(|canvas| canvas.get(main_thread).clone()) + }) { + Some(canvas) => canvas, + None => document + .create_element("canvas") + .map_err(|_| os_error!(OsError("Failed to create canvas element".to_owned())))? + .unchecked_into(), + }; + + if attr.platform_specific.append && !document.contains(Some(&canvas)) { + document + .body() + .expect("Failed to get body from document") + .append_child(&canvas) + .expect("Failed to append canvas to body"); + } + + // A tabindex is needed in order to capture local keyboard events. + // A "0" value means that the element should be focusable in + // sequential keyboard navigation, but its order is defined by the + // document's source order. + // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex + if attr.platform_specific.focusable { + canvas + .set_attribute("tabindex", "0") + .map_err(|_| os_error!(OsError("Failed to set a tabindex".to_owned())))?; + } + + let style = Style::new(&window, &canvas); + + let cursor = CursorHandler::new(main_thread, canvas.clone(), style.clone()); + + let common = Common { + window: window.clone(), + document: document.clone(), + raw: Rc::new(canvas.clone()), + style, + old_size: Rc::default(), + current_size: Rc::default(), + }; + + if let Some(size) = attr.inner_size { + let size = size.to_logical(super::scale_factor(&common.window)); + super::set_canvas_size(&common.document, &common.raw, &common.style, size); + } + + if let Some(size) = attr.min_inner_size { + let size = size.to_logical(super::scale_factor(&common.window)); + super::set_canvas_min_size(&common.document, &common.raw, &common.style, Some(size)); + } + + if let Some(size) = attr.max_inner_size { + let size = size.to_logical(super::scale_factor(&common.window)); + super::set_canvas_max_size(&common.document, &common.raw, &common.style, Some(size)); + } + + if let Some(position) = attr.position { + let position = position.to_logical(super::scale_factor(&common.window)); + super::set_canvas_position(&common.document, &common.raw, &common.style, position); + } + + if attr.fullscreen.is_some() { + fullscreen::request_fullscreen(&document, &canvas); + } + + if attr.active { + let _ = common.raw.focus(); + } + + Ok(Canvas { + common, + id, + has_focus: Rc::new(Cell::new(false)), + prevent_default: Rc::new(Cell::new(attr.platform_specific.prevent_default)), + is_intersecting: None, + on_touch_start: None, + on_blur: None, + on_focus: None, + on_keyboard_release: None, + on_keyboard_press: None, + on_mouse_wheel: None, + on_dark_mode: None, + pointer_handler: PointerHandler::new(), + on_resize_scale: None, + on_intersect: None, + animation_frame_handler: AnimationFrameHandler::new(window), + on_touch_end: None, + on_context_menu: None, + cursor, + }) + } + + pub fn set_cursor_lock(&self, lock: bool) -> Result<(), RootOE> { + if lock { + self.raw().request_pointer_lock(); + } else { + self.common.document.exit_pointer_lock(); + } + Ok(()) + } + + pub fn set_attribute(&self, attribute: &str, value: &str) { + self.common + .raw + .set_attribute(attribute, value) + .unwrap_or_else(|err| panic!("error: {err:?}\nSet attribute: {attribute}")) + } + + pub fn position(&self) -> LogicalPosition { + let bounds = self.common.raw.get_bounding_client_rect(); + let mut position = LogicalPosition { x: bounds.x(), y: bounds.y() }; + + if self.document().contains(Some(self.raw())) && self.style().get("display") != "none" { + position.x += super::style_size_property(self.style(), "border-left-width") + + super::style_size_property(self.style(), "padding-left"); + position.y += super::style_size_property(self.style(), "border-top-width") + + super::style_size_property(self.style(), "padding-top"); + } + + position + } + + #[inline] + pub fn old_size(&self) -> PhysicalSize { + self.common.old_size.get() + } + + #[inline] + pub fn inner_size(&self) -> PhysicalSize { + self.common.current_size.get() + } + + #[inline] + pub fn set_old_size(&self, size: PhysicalSize) { + self.common.old_size.set(size) + } + + #[inline] + pub fn set_current_size(&self, size: PhysicalSize) { + self.common.current_size.set(size) + } + + #[inline] + pub fn window(&self) -> &web_sys::Window { + &self.common.window + } + + #[inline] + pub fn document(&self) -> &Document { + &self.common.document + } + + #[inline] + pub fn raw(&self) -> &HtmlCanvasElement { + &self.common.raw + } + + #[inline] + pub fn style(&self) -> &Style { + &self.common.style + } + + pub fn on_touch_start(&mut self) { + let prevent_default = Rc::clone(&self.prevent_default); + self.on_touch_start = Some(self.common.add_event("touchstart", move |event: Event| { + if prevent_default.get() { + event.prevent_default(); + } + })); + } + + pub fn on_blur(&mut self, mut handler: F) + where + F: 'static + FnMut(), + { + self.on_blur = Some(self.common.add_event("blur", move |_: FocusEvent| { + handler(); + })); + } + + pub fn on_focus(&mut self, mut handler: F) + where + F: 'static + FnMut(), + { + self.on_focus = Some(self.common.add_event("focus", move |_: FocusEvent| { + handler(); + })); + } + + pub fn on_keyboard_release(&mut self, mut handler: F) + where + F: 'static + FnMut(PhysicalKey, Key, Option, KeyLocation, bool, ModifiersState), + { + let prevent_default = Rc::clone(&self.prevent_default); + self.on_keyboard_release = + Some(self.common.add_event("keyup", move |event: KeyboardEvent| { + if prevent_default.get() { + event.prevent_default(); + } + let key = event::key(&event); + let modifiers = event::keyboard_modifiers(&event); + handler( + event::key_code(&event), + key, + event::key_text(&event), + event::key_location(&event), + event.repeat(), + modifiers, + ); + })); + } + + pub fn on_keyboard_press(&mut self, mut handler: F) + where + F: 'static + FnMut(PhysicalKey, Key, Option, KeyLocation, bool, ModifiersState), + { + let prevent_default = Rc::clone(&self.prevent_default); + self.on_keyboard_press = + Some(self.common.add_event("keydown", move |event: KeyboardEvent| { + if prevent_default.get() { + event.prevent_default(); + } + let key = event::key(&event); + let modifiers = event::keyboard_modifiers(&event); + handler( + event::key_code(&event), + key, + event::key_text(&event), + event::key_location(&event), + event.repeat(), + modifiers, + ); + })); + } + + pub fn on_cursor_leave(&mut self, handler: F) + where + F: 'static + FnMut(ModifiersState, Option), + { + self.pointer_handler.on_cursor_leave(&self.common, handler) + } + + pub fn on_cursor_enter(&mut self, handler: F) + where + F: 'static + FnMut(ModifiersState, Option), + { + self.pointer_handler.on_cursor_enter(&self.common, handler) + } + + pub fn on_mouse_release(&mut self, mouse_handler: M, touch_handler: T) + where + M: 'static + FnMut(ModifiersState, i32, PhysicalPosition, MouseButton), + T: 'static + FnMut(ModifiersState, i32, PhysicalPosition, Force), + { + self.pointer_handler.on_mouse_release(&self.common, mouse_handler, touch_handler) + } + + pub fn on_mouse_press(&mut self, mouse_handler: M, touch_handler: T) + where + M: 'static + FnMut(ModifiersState, i32, PhysicalPosition, MouseButton), + T: 'static + FnMut(ModifiersState, i32, PhysicalPosition, Force), + { + self.pointer_handler.on_mouse_press( + &self.common, + mouse_handler, + touch_handler, + Rc::clone(&self.prevent_default), + ) + } + + pub fn on_cursor_move(&mut self, mouse_handler: M, touch_handler: T, button_handler: B) + where + M: 'static + FnMut(ModifiersState, i32, &mut dyn Iterator>), + T: 'static + + FnMut(ModifiersState, i32, &mut dyn Iterator, Force)>), + B: 'static + FnMut(ModifiersState, i32, PhysicalPosition, ButtonsState, MouseButton), + { + self.pointer_handler.on_cursor_move( + &self.common, + mouse_handler, + touch_handler, + button_handler, + Rc::clone(&self.prevent_default), + ) + } + + pub fn on_touch_cancel(&mut self, handler: F) + where + F: 'static + FnMut(i32, PhysicalPosition, Force), + { + self.pointer_handler.on_touch_cancel(&self.common, handler) + } + + pub fn on_mouse_wheel(&mut self, mut handler: F) + where + F: 'static + FnMut(i32, MouseScrollDelta, ModifiersState), + { + let window = self.common.window.clone(); + let prevent_default = Rc::clone(&self.prevent_default); + self.on_mouse_wheel = Some(self.common.add_event("wheel", move |event: WheelEvent| { + if prevent_default.get() { + event.prevent_default(); + } + + if let Some(delta) = event::mouse_scroll_delta(&window, &event) { + let modifiers = event::mouse_modifiers(&event); + handler(0, delta, modifiers); + } + })); + } + + pub fn on_dark_mode(&mut self, mut handler: F) + where + F: 'static + FnMut(bool), + { + self.on_dark_mode = Some(MediaQueryListHandle::new( + &self.common.window, + "(prefers-color-scheme: dark)", + move |mql| handler(mql.matches()), + )); + } + + pub(crate) fn on_resize_scale(&mut self, scale_handler: S, size_handler: R) + where + S: 'static + Fn(PhysicalSize, f64), + R: 'static + Fn(PhysicalSize), + { + self.on_resize_scale = Some(ResizeScaleHandle::new( + self.window().clone(), + self.document().clone(), + self.raw().clone(), + self.style().clone(), + scale_handler, + size_handler, + )); + } + + pub(crate) fn on_intersection(&mut self, handler: F) + where + F: 'static + FnMut(bool), + { + self.on_intersect = Some(IntersectionObserverHandle::new(self.raw(), handler)); + } + + pub(crate) fn on_animation_frame(&mut self, f: F) + where + F: 'static + FnMut(), + { + self.animation_frame_handler.on_animation_frame(f) + } + + pub(crate) fn on_context_menu(&mut self) { + let prevent_default = Rc::clone(&self.prevent_default); + self.on_context_menu = + Some(self.common.add_event("contextmenu", move |event: PointerEvent| { + if prevent_default.get() { + event.prevent_default(); + } + })); + } + + pub fn request_fullscreen(&self) { + fullscreen::request_fullscreen(self.document(), self.raw()); + } + + pub fn exit_fullscreen(&self) { + fullscreen::exit_fullscreen(self.document(), self.raw()); + } + + pub fn is_fullscreen(&self) -> bool { + fullscreen::is_fullscreen(self.document(), self.raw()) + } + + pub fn request_animation_frame(&self) { + self.animation_frame_handler.request(); + } + + pub(crate) fn handle_scale_change( + &self, + runner: &super::super::event_loop::runner::Shared, + event_handler: impl FnOnce(crate::event::Event<()>), + current_size: PhysicalSize, + scale: f64, + ) { + // First, we send the `ScaleFactorChanged` event: + self.set_current_size(current_size); + let new_size = { + let new_size = Arc::new(Mutex::new(current_size)); + event_handler(crate::event::Event::WindowEvent { + window_id: RootWindowId(self.id), + event: crate::event::WindowEvent::ScaleFactorChanged { + scale_factor: scale, + inner_size_writer: InnerSizeWriter::new(Arc::downgrade(&new_size)), + }, + }); + + let new_size = *new_size.lock().unwrap(); + new_size + }; + + if current_size != new_size { + // Then we resize the canvas to the new size, a new + // `Resized` event will be sent by the `ResizeObserver`: + let new_size = new_size.to_logical(scale); + super::set_canvas_size(self.document(), self.raw(), self.style(), new_size); + + // Set the size might not trigger the event because the calculation is inaccurate. + self.on_resize_scale + .as_ref() + .expect("expected Window to still be active") + .notify_resize(); + } else if self.old_size() != new_size { + // Then we at least send a resized event. + self.set_old_size(new_size); + runner.send_event(crate::event::Event::WindowEvent { + window_id: RootWindowId(self.id), + event: crate::event::WindowEvent::Resized(new_size), + }) + } + } + + pub fn remove_listeners(&mut self) { + self.on_touch_start = None; + self.on_focus = None; + self.on_blur = None; + self.on_keyboard_release = None; + self.on_keyboard_press = None; + self.on_mouse_wheel = None; + self.on_dark_mode = None; + self.pointer_handler.remove_listeners(); + self.on_resize_scale = None; + self.on_intersect = None; + self.animation_frame_handler.cancel(); + self.on_touch_end = None; + self.on_context_menu = None; + } +} + +impl Common { + pub fn add_event( + &self, + event_name: &'static str, + handler: F, + ) -> EventListenerHandle + where + E: 'static + AsRef + wasm_bindgen::convert::FromWasmAbi, + F: 'static + FnMut(E), + { + EventListenerHandle::new(self.raw.deref().clone(), event_name, Closure::new(handler)) + } + + pub fn raw(&self) -> &HtmlCanvasElement { + &self.raw + } +} + +impl Style { + fn new(window: &web_sys::Window, canvas: &HtmlCanvasElement) -> Self { + #[allow(clippy::disallowed_methods)] + let read = window + .get_computed_style(canvas) + .expect("Failed to obtain computed style") + // this can't fail: we aren't using a pseudo-element + .expect("Invalid pseudo-element"); + + #[allow(clippy::disallowed_methods)] + let write = canvas.style(); + + Self { read, write } + } + + pub(crate) fn get(&self, property: &str) -> String { + self.read.get_property_value(property).expect("Invalid property") + } + + pub(crate) fn remove(&self, property: &str) { + self.write.remove_property(property).expect("Property is read only"); + } + + pub(crate) fn set(&self, property: &str, value: &str) { + self.write.set_property(property, value).expect("Property is read only"); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/event.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/event.rs new file mode 100644 index 00000000..ddd12576 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/event.rs @@ -0,0 +1,270 @@ +use crate::event::{MouseButton, MouseScrollDelta}; +use crate::keyboard::{Key, KeyLocation, ModifiersState, NamedKey, PhysicalKey}; + +use dpi::{LogicalPosition, PhysicalPosition, Position}; +use smol_str::SmolStr; +use std::cell::OnceCell; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::{KeyboardEvent, MouseEvent, PointerEvent, WheelEvent}; + +use super::Engine; + +bitflags::bitflags! { + // https://www.w3.org/TR/pointerevents3/#the-buttons-property + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct ButtonsState: u16 { + const LEFT = 0b00001; + const RIGHT = 0b00010; + const MIDDLE = 0b00100; + const BACK = 0b01000; + const FORWARD = 0b10000; + } +} + +impl From for MouseButton { + fn from(value: ButtonsState) -> Self { + match value { + ButtonsState::LEFT => MouseButton::Left, + ButtonsState::RIGHT => MouseButton::Right, + ButtonsState::MIDDLE => MouseButton::Middle, + ButtonsState::BACK => MouseButton::Back, + ButtonsState::FORWARD => MouseButton::Forward, + _ => MouseButton::Other(value.bits()), + } + } +} + +impl From for ButtonsState { + fn from(value: MouseButton) -> Self { + match value { + MouseButton::Left => ButtonsState::LEFT, + MouseButton::Right => ButtonsState::RIGHT, + MouseButton::Middle => ButtonsState::MIDDLE, + MouseButton::Back => ButtonsState::BACK, + MouseButton::Forward => ButtonsState::FORWARD, + MouseButton::Other(value) => ButtonsState::from_bits_retain(value), + } + } +} + +pub fn mouse_buttons(event: &MouseEvent) -> ButtonsState { + ButtonsState::from_bits_retain(event.buttons()) +} + +pub fn mouse_button(event: &MouseEvent) -> Option { + // https://www.w3.org/TR/pointerevents3/#the-button-property + match event.button() { + -1 => None, + 0 => Some(MouseButton::Left), + 1 => Some(MouseButton::Middle), + 2 => Some(MouseButton::Right), + 3 => Some(MouseButton::Back), + 4 => Some(MouseButton::Forward), + i => { + Some(MouseButton::Other(i.try_into().expect("unexpected negative mouse button value"))) + }, + } +} + +impl MouseButton { + pub fn to_id(self) -> u32 { + match self { + MouseButton::Left => 0, + MouseButton::Right => 1, + MouseButton::Middle => 2, + MouseButton::Back => 3, + MouseButton::Forward => 4, + MouseButton::Other(value) => value.into(), + } + } +} + +pub fn mouse_position(event: &MouseEvent) -> LogicalPosition { + #[wasm_bindgen] + extern "C" { + type MouseEventExt; + + #[wasm_bindgen(method, getter, js_name = offsetX)] + fn offset_x(this: &MouseEventExt) -> f64; + + #[wasm_bindgen(method, getter, js_name = offsetY)] + fn offset_y(this: &MouseEventExt) -> f64; + } + + let event: &MouseEventExt = event.unchecked_ref(); + + LogicalPosition { x: event.offset_x(), y: event.offset_y() } +} + +// TODO: Remove this when Firefox supports correct movement values in coalesced events and browsers +// have agreed on what coordinate space `movementX/Y` is using. +// See . +// See . +pub enum MouseDelta { + Chromium, + Gecko { old_position: LogicalPosition, old_delta: LogicalPosition }, + Other, +} + +impl MouseDelta { + pub fn init(window: &web_sys::Window, event: &PointerEvent) -> Self { + match super::engine(window) { + Some(Engine::Chromium) => Self::Chromium, + // Firefox has wrong movement values in coalesced events. + Some(Engine::Gecko) if has_coalesced_events_support(event) => Self::Gecko { + old_position: mouse_position(event), + old_delta: LogicalPosition::new( + event.movement_x() as f64, + event.movement_y() as f64, + ), + }, + _ => Self::Other, + } + } + + pub fn delta(&mut self, event: &MouseEvent) -> Position { + match self { + MouseDelta::Chromium => { + PhysicalPosition::new(event.movement_x(), event.movement_y()).into() + }, + MouseDelta::Gecko { old_position, old_delta } => { + let new_position = mouse_position(event); + let x = new_position.x - old_position.x + old_delta.x; + let y = new_position.y - old_position.y + old_delta.y; + *old_position = new_position; + *old_delta = LogicalPosition::new(0., 0.); + LogicalPosition::new(x, y).into() + }, + MouseDelta::Other => { + LogicalPosition::new(event.movement_x(), event.movement_y()).into() + }, + } + } +} + +pub fn mouse_scroll_delta( + window: &web_sys::Window, + event: &WheelEvent, +) -> Option { + let x = -event.delta_x(); + let y = -event.delta_y(); + + match event.delta_mode() { + WheelEvent::DOM_DELTA_LINE => Some(MouseScrollDelta::LineDelta(x as f32, y as f32)), + WheelEvent::DOM_DELTA_PIXEL => { + let delta = LogicalPosition::new(x, y).to_physical(super::scale_factor(window)); + Some(MouseScrollDelta::PixelDelta(delta)) + }, + _ => None, + } +} + +pub fn key_code(event: &KeyboardEvent) -> PhysicalKey { + let code = event.code(); + PhysicalKey::from_key_code_attribute_value(&code) +} + +pub fn key(event: &KeyboardEvent) -> Key { + Key::from_key_attribute_value(&event.key()) +} + +pub fn key_text(event: &KeyboardEvent) -> Option { + let key = event.key(); + let key = Key::from_key_attribute_value(&key); + match &key { + Key::Character(text) => Some(text.clone()), + Key::Named(NamedKey::Tab) => Some(SmolStr::new("\t")), + Key::Named(NamedKey::Enter) => Some(SmolStr::new("\r")), + Key::Named(NamedKey::Space) => Some(SmolStr::new(" ")), + _ => None, + } + .map(SmolStr::new) +} + +pub fn key_location(event: &KeyboardEvent) -> KeyLocation { + match event.location() { + KeyboardEvent::DOM_KEY_LOCATION_LEFT => KeyLocation::Left, + KeyboardEvent::DOM_KEY_LOCATION_RIGHT => KeyLocation::Right, + KeyboardEvent::DOM_KEY_LOCATION_NUMPAD => KeyLocation::Numpad, + KeyboardEvent::DOM_KEY_LOCATION_STANDARD => KeyLocation::Standard, + location => { + tracing::warn!("Unexpected key location: {location}"); + KeyLocation::Standard + }, + } +} + +pub fn keyboard_modifiers(event: &KeyboardEvent) -> ModifiersState { + let mut state = ModifiersState::empty(); + + if event.shift_key() { + state |= ModifiersState::SHIFT; + } + if event.ctrl_key() { + state |= ModifiersState::CONTROL; + } + if event.alt_key() { + state |= ModifiersState::ALT; + } + if event.meta_key() { + state |= ModifiersState::SUPER; + } + + state +} + +pub fn mouse_modifiers(event: &MouseEvent) -> ModifiersState { + let mut state = ModifiersState::empty(); + + if event.shift_key() { + state |= ModifiersState::SHIFT; + } + if event.ctrl_key() { + state |= ModifiersState::CONTROL; + } + if event.alt_key() { + state |= ModifiersState::ALT; + } + if event.meta_key() { + state |= ModifiersState::SUPER; + } + + state +} + +pub fn pointer_move_event(event: PointerEvent) -> impl Iterator { + // make a single iterator depending on the availability of coalesced events + if has_coalesced_events_support(&event) { + None.into_iter().chain( + Some(event.get_coalesced_events().into_iter().map(PointerEvent::unchecked_from_js)) + .into_iter() + .flatten(), + ) + } else { + Some(event).into_iter().chain(None.into_iter().flatten()) + } +} + +// TODO: Remove when Safari supports `getCoalescedEvents`. +// See . +pub fn has_coalesced_events_support(event: &PointerEvent) -> bool { + thread_local! { + static COALESCED_EVENTS_SUPPORT: OnceCell = const { OnceCell::new() }; + } + + COALESCED_EVENTS_SUPPORT.with(|support| { + *support.get_or_init(|| { + #[wasm_bindgen] + extern "C" { + type PointerCoalescedEventsSupport; + + #[wasm_bindgen(method, getter, js_name = getCoalescedEvents)] + fn has_get_coalesced_events(this: &PointerCoalescedEventsSupport) -> JsValue; + } + + let support: &PointerCoalescedEventsSupport = event.unchecked_ref(); + !support.has_get_coalesced_events().is_undefined() + }) + }) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/event_handle.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/event_handle.rs new file mode 100644 index 00000000..4474af3c --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/event_handle.rs @@ -0,0 +1,38 @@ +use wasm_bindgen::prelude::Closure; +use wasm_bindgen::JsCast; +use web_sys::EventTarget; + +pub struct EventListenerHandle { + target: EventTarget, + event_type: &'static str, + listener: Closure, +} + +impl EventListenerHandle { + pub fn new(target: U, event_type: &'static str, listener: Closure) -> Self + where + U: Into, + { + let target = target.into(); + target + .add_event_listener_with_callback(event_type, listener.as_ref().unchecked_ref()) + .expect("Failed to add event listener"); + EventListenerHandle { target, event_type, listener } + } +} + +impl Drop for EventListenerHandle { + fn drop(&mut self) { + self.target + .remove_event_listener_with_callback( + self.event_type, + self.listener.as_ref().unchecked_ref(), + ) + .unwrap_or_else(|e| { + web_sys::console::error_2( + &format!("Error removing event listener {}", self.event_type).into(), + &e, + ) + }); + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/fullscreen.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/fullscreen.rs new file mode 100644 index 00000000..867d34a6 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/fullscreen.rs @@ -0,0 +1,103 @@ +use std::cell::OnceCell; + +use js_sys::Promise; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::{Document, Element, HtmlCanvasElement}; + +pub fn request_fullscreen(document: &Document, canvas: &HtmlCanvasElement) { + if is_fullscreen(document, canvas) { + return; + } + + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(extends = HtmlCanvasElement)] + type RequestFullscreen; + + #[wasm_bindgen(method, js_name = requestFullscreen)] + fn request_fullscreen(this: &RequestFullscreen) -> Promise; + + #[wasm_bindgen(method, js_name = webkitRequestFullscreen)] + fn webkit_request_fullscreen(this: &RequestFullscreen); + } + + let canvas: &RequestFullscreen = canvas.unchecked_ref(); + + if has_fullscreen_api_support(canvas) { + thread_local! { + static REJECT_HANDLER: Closure = Closure::new(|_| ()); + } + REJECT_HANDLER.with(|handler| { + let _ = canvas.request_fullscreen().catch(handler); + }); + } else { + canvas.webkit_request_fullscreen(); + } +} + +pub fn is_fullscreen(document: &Document, canvas: &HtmlCanvasElement) -> bool { + #[wasm_bindgen] + extern "C" { + type FullscreenElement; + + #[wasm_bindgen(method, getter, js_name = webkitFullscreenElement)] + fn webkit_fullscreen_element(this: &FullscreenElement) -> Option; + } + + let element = if has_fullscreen_api_support(canvas) { + #[allow(clippy::disallowed_methods)] + document.fullscreen_element() + } else { + let document: &FullscreenElement = document.unchecked_ref(); + document.webkit_fullscreen_element() + }; + + match element { + Some(element) => { + let canvas: &Element = canvas; + canvas == &element + }, + None => false, + } +} + +pub fn exit_fullscreen(document: &Document, canvas: &HtmlCanvasElement) { + #[wasm_bindgen] + extern "C" { + type ExitFullscreen; + + #[wasm_bindgen(method, js_name = webkitExitFullscreen)] + fn webkit_exit_fullscreen(this: &ExitFullscreen); + } + + if has_fullscreen_api_support(canvas) { + #[allow(clippy::disallowed_methods)] + document.exit_fullscreen() + } else { + let document: &ExitFullscreen = document.unchecked_ref(); + document.webkit_exit_fullscreen() + } +} + +fn has_fullscreen_api_support(canvas: &HtmlCanvasElement) -> bool { + thread_local! { + static FULLSCREEN_API_SUPPORT: OnceCell = const { OnceCell::new() }; + } + + FULLSCREEN_API_SUPPORT.with(|support| { + *support.get_or_init(|| { + #[wasm_bindgen] + extern "C" { + type CanvasFullScreenApiSupport; + + #[wasm_bindgen(method, getter, js_name = requestFullscreen)] + fn has_request_fullscreen(this: &CanvasFullScreenApiSupport) -> JsValue; + } + + let support: &CanvasFullScreenApiSupport = canvas.unchecked_ref(); + !support.has_request_fullscreen().is_undefined() + }) + }) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/intersection_handle.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/intersection_handle.rs new file mode 100644 index 00000000..9f63e84c --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/intersection_handle.rs @@ -0,0 +1,33 @@ +use js_sys::Array; +use wasm_bindgen::prelude::Closure; +use wasm_bindgen::JsCast; +use web_sys::{Element, IntersectionObserver, IntersectionObserverEntry}; + +pub(super) struct IntersectionObserverHandle { + observer: IntersectionObserver, + _closure: Closure, +} + +impl IntersectionObserverHandle { + pub fn new(element: &Element, mut callback: F) -> Self + where + F: 'static + FnMut(bool), + { + let closure = Closure::new(move |entries: Array| { + let entry: IntersectionObserverEntry = entries.get(0).unchecked_into(); + callback(entry.is_intersecting()); + }); + let observer = IntersectionObserver::new(closure.as_ref().unchecked_ref()) + // we don't provide any `options` + .expect("Invalid `options`"); + observer.observe(element); + + Self { observer, _closure: closure } + } +} + +impl Drop for IntersectionObserverHandle { + fn drop(&mut self) { + self.observer.disconnect() + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/media_query_handle.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/media_query_handle.rs new file mode 100644 index 00000000..766157a6 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/media_query_handle.rs @@ -0,0 +1,48 @@ +use wasm_bindgen::prelude::Closure; +use wasm_bindgen::JsCast; +use web_sys::MediaQueryList; + +pub(super) struct MediaQueryListHandle { + mql: MediaQueryList, + closure: Closure, +} + +impl MediaQueryListHandle { + pub fn new(window: &web_sys::Window, media_query: &str, mut listener: F) -> Self + where + F: 'static + FnMut(&MediaQueryList), + { + let mql = window + .match_media(media_query) + .expect("Failed to parse media query") + .expect("Found empty media query"); + + let closure = Closure::new({ + let mql = mql.clone(); + move || listener(&mql) + }); + // TODO: Replace obsolete `addListener()` with `addEventListener()` and use + // `MediaQueryListEvent` instead of cloning the `MediaQueryList`. + // Requires Safari v14. + mql.add_listener_with_opt_callback(Some(closure.as_ref().unchecked_ref())) + .expect("Invalid listener"); + + Self { mql, closure } + } + + pub fn mql(&self) -> &MediaQueryList { + &self.mql + } +} + +impl Drop for MediaQueryListHandle { + fn drop(&mut self) { + remove_listener(&self.mql, &self.closure); + } +} + +fn remove_listener(mql: &MediaQueryList, listener: &Closure) { + mql.remove_listener_with_opt_callback(Some(listener.as_ref().unchecked_ref())).unwrap_or_else( + |e| web_sys::console::error_2(&"Error removing media query listener".into(), &e), + ); +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/mod.rs new file mode 100644 index 00000000..08962b49 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/mod.rs @@ -0,0 +1,233 @@ +mod animation_frame; +mod canvas; +pub mod event; +mod event_handle; +mod fullscreen; +mod intersection_handle; +mod media_query_handle; +mod pointer; +mod resize_scaling; +mod schedule; + +use std::sync::OnceLock; + +pub use self::canvas::{Canvas, Style}; +pub use self::event::ButtonsState; +pub use self::event_handle::EventListenerHandle; +pub use self::resize_scaling::ResizeScaleHandle; +pub use self::schedule::Schedule; + +use crate::dpi::{LogicalPosition, LogicalSize}; +use js_sys::Array; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsCast; +use web_sys::{ + Document, HtmlCanvasElement, Navigator, PageTransitionEvent, VisibilityState, Window, +}; + +pub fn throw(msg: &str) { + wasm_bindgen::throw_str(msg); +} + +pub struct PageTransitionEventHandle { + _show_listener: event_handle::EventListenerHandle, + _hide_listener: event_handle::EventListenerHandle, +} + +pub fn on_page_transition( + window: web_sys::Window, + show_handler: impl FnMut(PageTransitionEvent) + 'static, + hide_handler: impl FnMut(PageTransitionEvent) + 'static, +) -> PageTransitionEventHandle { + let show_closure = Closure::new(show_handler); + let hide_closure = Closure::new(hide_handler); + + let show_listener = + event_handle::EventListenerHandle::new(window.clone(), "pageshow", show_closure); + let hide_listener = event_handle::EventListenerHandle::new(window, "pagehide", hide_closure); + PageTransitionEventHandle { _show_listener: show_listener, _hide_listener: hide_listener } +} + +pub fn scale_factor(window: &web_sys::Window) -> f64 { + window.device_pixel_ratio() +} + +fn fix_canvas_size(style: &Style, mut size: LogicalSize) -> LogicalSize { + if style.get("box-sizing") == "border-box" { + size.width += style_size_property(style, "border-left-width") + + style_size_property(style, "border-right-width") + + style_size_property(style, "padding-left") + + style_size_property(style, "padding-right"); + size.height += style_size_property(style, "border-top-width") + + style_size_property(style, "border-bottom-width") + + style_size_property(style, "padding-top") + + style_size_property(style, "padding-bottom"); + } + + size +} + +pub fn set_canvas_size( + document: &Document, + raw: &HtmlCanvasElement, + style: &Style, + new_size: LogicalSize, +) { + if !document.contains(Some(raw)) || style.get("display") == "none" { + return; + } + + let new_size = fix_canvas_size(style, new_size); + + style.set("width", &format!("{}px", new_size.width)); + style.set("height", &format!("{}px", new_size.height)); +} + +pub fn set_canvas_min_size( + document: &Document, + raw: &HtmlCanvasElement, + style: &Style, + dimensions: Option>, +) { + if let Some(dimensions) = dimensions { + if !document.contains(Some(raw)) || style.get("display") == "none" { + return; + } + + let new_size = fix_canvas_size(style, dimensions); + + style.set("min-width", &format!("{}px", new_size.width)); + style.set("min-height", &format!("{}px", new_size.height)); + } else { + style.remove("min-width"); + style.remove("min-height"); + } +} + +pub fn set_canvas_max_size( + document: &Document, + raw: &HtmlCanvasElement, + style: &Style, + dimensions: Option>, +) { + if let Some(dimensions) = dimensions { + if !document.contains(Some(raw)) || style.get("display") == "none" { + return; + } + + let new_size = fix_canvas_size(style, dimensions); + + style.set("max-width", &format!("{}px", new_size.width)); + style.set("max-height", &format!("{}px", new_size.height)); + } else { + style.remove("max-width"); + style.remove("max-height"); + } +} + +pub fn set_canvas_position( + document: &Document, + raw: &HtmlCanvasElement, + style: &Style, + mut position: LogicalPosition, +) { + if document.contains(Some(raw)) && style.get("display") != "none" { + position.x -= style_size_property(style, "margin-left") + + style_size_property(style, "border-left-width") + + style_size_property(style, "padding-left"); + position.y -= style_size_property(style, "margin-top") + + style_size_property(style, "border-top-width") + + style_size_property(style, "padding-top"); + } + + style.set("position", "fixed"); + style.set("left", &format!("{}px", position.x)); + style.set("top", &format!("{}px", position.y)); +} + +/// This function will panic if the element is not inserted in the DOM +/// or is not a CSS property that represents a size in pixel. +pub fn style_size_property(style: &Style, property: &str) -> f64 { + let prop = style.get(property); + prop.strip_suffix("px") + .expect("Element was not inserted into the DOM or is not a size in pixel") + .parse() + .expect("CSS property is not a size in pixel") +} + +pub fn is_dark_mode(window: &web_sys::Window) -> Option { + window.match_media("(prefers-color-scheme: dark)").ok().flatten().map(|media| media.matches()) +} + +pub fn is_visible(document: &Document) -> bool { + document.visibility_state() == VisibilityState::Visible +} + +pub type RawCanvasType = HtmlCanvasElement; + +#[derive(Clone, Copy)] +pub enum Engine { + Chromium, + Gecko, + WebKit, +} + +pub fn engine(window: &Window) -> Option { + static ENGINE: OnceLock> = OnceLock::new(); + + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(extends = Navigator)] + type NavigatorExt; + + #[wasm_bindgen(method, getter, js_name = userAgentData)] + fn user_agent_data(this: &NavigatorExt) -> Option; + + type NavigatorUaData; + + #[wasm_bindgen(method, getter)] + fn brands(this: &NavigatorUaData) -> Array; + + type NavigatorUaBrandVersion; + + #[wasm_bindgen(method, getter)] + fn brand(this: &NavigatorUaBrandVersion) -> String; + } + + *ENGINE.get_or_init(|| { + let navigator: NavigatorExt = window.navigator().unchecked_into(); + + if let Some(data) = navigator.user_agent_data() { + for brand in data + .brands() + .iter() + .map(NavigatorUaBrandVersion::unchecked_from_js) + .map(|brand| brand.brand()) + { + match brand.as_str() { + "Chromium" => return Some(Engine::Chromium), + // TODO: verify when Firefox actually implements it. + "Gecko" => return Some(Engine::Gecko), + // TODO: verify when Safari actually implements it. + "WebKit" => return Some(Engine::WebKit), + _ => (), + } + } + + None + } else { + let data = navigator.user_agent().ok()?; + + if data.contains("Chrome/") { + Some(Engine::Chromium) + } else if data.contains("Gecko/") { + Some(Engine::Gecko) + } else if data.contains("AppleWebKit/") { + Some(Engine::WebKit) + } else { + None + } + } + }) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/pointer.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/pointer.rs new file mode 100644 index 00000000..3ee168bb --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/pointer.rs @@ -0,0 +1,244 @@ +use std::cell::Cell; +use std::rc::Rc; + +use super::canvas::Common; +use super::event; +use super::event_handle::EventListenerHandle; +use crate::dpi::PhysicalPosition; +use crate::event::{Force, MouseButton}; +use crate::keyboard::ModifiersState; + +use event::ButtonsState; +use web_sys::PointerEvent; + +#[allow(dead_code)] +pub(super) struct PointerHandler { + on_cursor_leave: Option>, + on_cursor_enter: Option>, + on_cursor_move: Option>, + on_pointer_press: Option>, + on_pointer_release: Option>, + on_touch_cancel: Option>, +} + +impl PointerHandler { + pub fn new() -> Self { + Self { + on_cursor_leave: None, + on_cursor_enter: None, + on_cursor_move: None, + on_pointer_press: None, + on_pointer_release: None, + on_touch_cancel: None, + } + } + + pub fn on_cursor_leave(&mut self, canvas_common: &Common, mut handler: F) + where + F: 'static + FnMut(ModifiersState, Option), + { + self.on_cursor_leave = + Some(canvas_common.add_event("pointerout", move |event: PointerEvent| { + let modifiers = event::mouse_modifiers(&event); + + // touch events are handled separately + // handling them here would produce duplicate mouse events, inconsistent with + // other platforms. + let pointer_id = (event.pointer_type() != "touch").then(|| event.pointer_id()); + + handler(modifiers, pointer_id); + })); + } + + pub fn on_cursor_enter(&mut self, canvas_common: &Common, mut handler: F) + where + F: 'static + FnMut(ModifiersState, Option), + { + self.on_cursor_enter = + Some(canvas_common.add_event("pointerover", move |event: PointerEvent| { + let modifiers = event::mouse_modifiers(&event); + + // touch events are handled separately + // handling them here would produce duplicate mouse events, inconsistent with + // other platforms. + let pointer_id = (event.pointer_type() != "touch").then(|| event.pointer_id()); + + handler(modifiers, pointer_id); + })); + } + + pub fn on_mouse_release( + &mut self, + canvas_common: &Common, + mut mouse_handler: M, + mut touch_handler: T, + ) where + M: 'static + FnMut(ModifiersState, i32, PhysicalPosition, MouseButton), + T: 'static + FnMut(ModifiersState, i32, PhysicalPosition, Force), + { + let window = canvas_common.window.clone(); + self.on_pointer_release = + Some(canvas_common.add_event("pointerup", move |event: PointerEvent| { + let modifiers = event::mouse_modifiers(&event); + + match event.pointer_type().as_str() { + "touch" => touch_handler( + modifiers, + event.pointer_id(), + event::mouse_position(&event).to_physical(super::scale_factor(&window)), + Force::Normalized(event.pressure() as f64), + ), + _ => mouse_handler( + modifiers, + event.pointer_id(), + event::mouse_position(&event).to_physical(super::scale_factor(&window)), + event::mouse_button(&event).expect("no mouse button released"), + ), + } + })); + } + + pub fn on_mouse_press( + &mut self, + canvas_common: &Common, + mut mouse_handler: M, + mut touch_handler: T, + prevent_default: Rc>, + ) where + M: 'static + FnMut(ModifiersState, i32, PhysicalPosition, MouseButton), + T: 'static + FnMut(ModifiersState, i32, PhysicalPosition, Force), + { + let window = canvas_common.window.clone(); + let canvas = canvas_common.raw().clone(); + self.on_pointer_press = + Some(canvas_common.add_event("pointerdown", move |event: PointerEvent| { + if prevent_default.get() { + // prevent text selection + event.prevent_default(); + // but still focus element + let _ = canvas.focus(); + } + + let modifiers = event::mouse_modifiers(&event); + let pointer_type = &event.pointer_type(); + + match pointer_type.as_str() { + "touch" => { + touch_handler( + modifiers, + event.pointer_id(), + event::mouse_position(&event).to_physical(super::scale_factor(&window)), + Force::Normalized(event.pressure() as f64), + ); + }, + _ => { + mouse_handler( + modifiers, + event.pointer_id(), + event::mouse_position(&event).to_physical(super::scale_factor(&window)), + event::mouse_button(&event).expect("no mouse button pressed"), + ); + + if pointer_type == "mouse" { + // Error is swallowed here since the error would occur every time the + // mouse is clicked when the cursor is + // grabbed, and there is probably not a + // situation where this could fail, that we + // care if it fails. + let _e = canvas.set_pointer_capture(event.pointer_id()); + } + }, + } + })); + } + + pub fn on_cursor_move( + &mut self, + canvas_common: &Common, + mut mouse_handler: M, + mut touch_handler: T, + mut button_handler: B, + prevent_default: Rc>, + ) where + M: 'static + FnMut(ModifiersState, i32, &mut dyn Iterator>), + T: 'static + + FnMut(ModifiersState, i32, &mut dyn Iterator, Force)>), + B: 'static + FnMut(ModifiersState, i32, PhysicalPosition, ButtonsState, MouseButton), + { + let window = canvas_common.window.clone(); + let canvas = canvas_common.raw().clone(); + self.on_cursor_move = + Some(canvas_common.add_event("pointermove", move |event: PointerEvent| { + let modifiers = event::mouse_modifiers(&event); + + let id = event.pointer_id(); + + // chorded button event + if let Some(button) = event::mouse_button(&event) { + if prevent_default.get() { + // prevent text selection + event.prevent_default(); + // but still focus element + let _ = canvas.focus(); + } + + button_handler( + modifiers, + id, + event::mouse_position(&event).to_physical(super::scale_factor(&window)), + event::mouse_buttons(&event), + button, + ); + + return; + } + + // pointer move event + let scale = super::scale_factor(&window); + match event.pointer_type().as_str() { + "touch" => touch_handler( + modifiers, + id, + &mut event::pointer_move_event(event).map(|event| { + ( + event::mouse_position(&event).to_physical(scale), + Force::Normalized(event.pressure() as f64), + ) + }), + ), + _ => mouse_handler( + modifiers, + id, + &mut event::pointer_move_event(event) + .map(|event| event::mouse_position(&event).to_physical(scale)), + ), + }; + })); + } + + pub fn on_touch_cancel(&mut self, canvas_common: &Common, mut handler: F) + where + F: 'static + FnMut(i32, PhysicalPosition, Force), + { + let window = canvas_common.window.clone(); + self.on_touch_cancel = + Some(canvas_common.add_event("pointercancel", move |event: PointerEvent| { + if event.pointer_type() == "touch" { + handler( + event.pointer_id(), + event::mouse_position(&event).to_physical(super::scale_factor(&window)), + Force::Normalized(event.pressure() as f64), + ); + } + })); + } + + pub fn remove_listeners(&mut self) { + self.on_cursor_leave = None; + self.on_cursor_enter = None; + self.on_cursor_move = None; + self.on_pointer_press = None; + self.on_pointer_release = None; + self.on_touch_cancel = None; + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/resize_scaling.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/resize_scaling.rs new file mode 100644 index 00000000..4d10b3ac --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/resize_scaling.rs @@ -0,0 +1,302 @@ +use js_sys::{Array, Object}; +use tracing::warn; +use wasm_bindgen::prelude::{wasm_bindgen, Closure}; +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::{ + Document, HtmlCanvasElement, MediaQueryList, ResizeObserver, ResizeObserverBoxOptions, + ResizeObserverEntry, ResizeObserverOptions, ResizeObserverSize, Window, +}; + +use crate::dpi::{LogicalSize, PhysicalSize}; + +use super::super::backend; +use super::canvas::Style; +use super::media_query_handle::MediaQueryListHandle; + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +pub struct ResizeScaleHandle(Rc); + +impl ResizeScaleHandle { + pub(crate) fn new( + window: Window, + document: Document, + canvas: HtmlCanvasElement, + style: Style, + scale_handler: S, + resize_handler: R, + ) -> Self + where + S: 'static + Fn(PhysicalSize, f64), + R: 'static + Fn(PhysicalSize), + { + Self(ResizeScaleInternal::new( + window, + document, + canvas, + style, + scale_handler, + resize_handler, + )) + } + + pub(crate) fn notify_resize(&self) { + self.0.notify() + } +} + +/// This is a helper type to help manage the `MediaQueryList` used for detecting +/// changes of the `devicePixelRatio`. +struct ResizeScaleInternal { + window: Window, + document: Document, + canvas: HtmlCanvasElement, + style: Style, + mql: RefCell, + observer: ResizeObserver, + _observer_closure: Closure, + scale_handler: Box, f64)>, + resize_handler: Box)>, + notify_scale: Cell, +} + +impl ResizeScaleInternal { + fn new( + window: Window, + document: Document, + canvas: HtmlCanvasElement, + style: Style, + scale_handler: S, + resize_handler: R, + ) -> Rc + where + S: 'static + Fn(PhysicalSize, f64), + R: 'static + Fn(PhysicalSize), + { + Rc::::new_cyclic(|weak_self| { + let mql = Self::create_mql(&window, { + let weak_self = weak_self.clone(); + move |mql| { + if let Some(rc_self) = weak_self.upgrade() { + Self::handle_scale(rc_self, mql); + } + } + }); + + let weak_self = weak_self.clone(); + let observer_closure = Closure::new(move |entries: Array, _| { + if let Some(this) = weak_self.upgrade() { + let size = this.process_entry(entries); + + if this.notify_scale.replace(false) { + let scale = backend::scale_factor(&this.window); + (this.scale_handler)(size, scale) + } else { + (this.resize_handler)(size) + } + } + }); + let observer = Self::create_observer(&canvas, observer_closure.as_ref()); + + Self { + window, + document, + canvas, + style, + mql: RefCell::new(mql), + observer, + _observer_closure: observer_closure, + scale_handler: Box::new(scale_handler), + resize_handler: Box::new(resize_handler), + notify_scale: Cell::new(false), + } + }) + } + + fn create_mql(window: &Window, closure: F) -> MediaQueryListHandle + where + F: 'static + FnMut(&MediaQueryList), + { + let current_scale = super::scale_factor(window); + // TODO: Remove `-webkit-device-pixel-ratio`. Requires Safari v16. + let media_query = format!( + "(resolution: {current_scale}dppx), + (-webkit-device-pixel-ratio: {current_scale})", + ); + let mql = MediaQueryListHandle::new(window, &media_query, closure); + debug_assert!( + mql.mql().matches(), + "created media query doesn't match, {current_scale} != {}", + super::scale_factor(window) + ); + mql + } + + fn create_observer(canvas: &HtmlCanvasElement, closure: &JsValue) -> ResizeObserver { + let observer = ResizeObserver::new(closure.as_ref().unchecked_ref()) + .expect("Failed to create `ResizeObserver`"); + + // Safari doesn't support `devicePixelContentBoxSize` + if has_device_pixel_support() { + let options = ResizeObserverOptions::new(); + options.set_box(ResizeObserverBoxOptions::DevicePixelContentBox); + observer.observe_with_options(canvas, &options); + } else { + observer.observe(canvas); + } + + observer + } + + fn notify(&self) { + if !self.document.contains(Some(&self.canvas)) || self.style.get("display") == "none" { + let size = PhysicalSize::new(0, 0); + + if self.notify_scale.replace(false) { + let scale = backend::scale_factor(&self.window); + (self.scale_handler)(size, scale) + } else { + (self.resize_handler)(size) + } + + return; + } + + // Safari doesn't support `devicePixelContentBoxSize` + if has_device_pixel_support() { + self.observer.unobserve(&self.canvas); + self.observer.observe(&self.canvas); + + return; + } + + let mut size = LogicalSize::new( + backend::style_size_property(&self.style, "width"), + backend::style_size_property(&self.style, "height"), + ); + + if self.style.get("box-sizing") == "border-box" { + size.width -= backend::style_size_property(&self.style, "border-left-width") + + backend::style_size_property(&self.style, "border-right-width") + + backend::style_size_property(&self.style, "padding-left") + + backend::style_size_property(&self.style, "padding-right"); + size.height -= backend::style_size_property(&self.style, "border-top-width") + + backend::style_size_property(&self.style, "border-bottom-width") + + backend::style_size_property(&self.style, "padding-top") + + backend::style_size_property(&self.style, "padding-bottom"); + } + + let size = size.to_physical(backend::scale_factor(&self.window)); + + if self.notify_scale.replace(false) { + let scale = backend::scale_factor(&self.window); + (self.scale_handler)(size, scale) + } else { + (self.resize_handler)(size) + } + } + + fn handle_scale(self: Rc, mql: &MediaQueryList) { + let weak_self = Rc::downgrade(&self); + let scale = super::scale_factor(&self.window); + + // TODO: confirm/reproduce this problem, see: + // . + // This should never happen, but if it does then apparently the scale factor didn't change. + if mql.matches() { + warn!( + "media query tracking scale factor was triggered without a change:\nMedia Query: \ + {}\nCurrent Scale: {scale}", + mql.media(), + ); + return; + } + + let new_mql = Self::create_mql(&self.window, move |mql| { + if let Some(rc_self) = weak_self.upgrade() { + Self::handle_scale(rc_self, mql); + } + }); + self.mql.replace(new_mql); + + self.notify_scale.set(true); + self.notify(); + } + + fn process_entry(&self, entries: Array) -> PhysicalSize { + let entry: ResizeObserverEntry = entries.get(0).unchecked_into(); + + // Safari doesn't support `devicePixelContentBoxSize` + if !has_device_pixel_support() { + let rect = entry.content_rect(); + + return LogicalSize::new(rect.width(), rect.height()) + .to_physical(backend::scale_factor(&self.window)); + } + + let entry: ResizeObserverSize = + entry.device_pixel_content_box_size().get(0).unchecked_into(); + + let writing_mode = self.style.get("writing-mode"); + + // means the canvas is not inserted into the DOM + if writing_mode.is_empty() { + debug_assert_eq!(entry.inline_size(), 0.); + debug_assert_eq!(entry.block_size(), 0.); + + return PhysicalSize::new(0, 0); + } + + let horizontal = match writing_mode.as_str() { + _ if writing_mode.starts_with("horizontal") => true, + _ if writing_mode.starts_with("vertical") | writing_mode.starts_with("sideways") => { + false + }, + // deprecated values + "lr" | "lr-tb" | "rl" => true, + "tb" | "tb-lr" | "tb-rl" => false, + _ => { + warn!("unrecognized `writing-mode`, assuming horizontal"); + true + }, + }; + + if horizontal { + PhysicalSize::new(entry.inline_size() as u32, entry.block_size() as u32) + } else { + PhysicalSize::new(entry.block_size() as u32, entry.inline_size() as u32) + } + } +} + +impl Drop for ResizeScaleInternal { + fn drop(&mut self) { + self.observer.disconnect(); + } +} + +// TODO: Remove when Safari supports `devicePixelContentBoxSize`. +// See . +pub fn has_device_pixel_support() -> bool { + thread_local! { + static DEVICE_PIXEL_SUPPORT: bool = { + #[wasm_bindgen] + extern "C" { + type ResizeObserverEntryExt; + + #[wasm_bindgen(js_class = ResizeObserverEntry, static_method_of = ResizeObserverEntryExt, getter)] + fn prototype() -> Object; + } + + let prototype = ResizeObserverEntryExt::prototype(); + let descriptor = Object::get_own_property_descriptor( + &prototype, + &JsValue::from_str("devicePixelContentBoxSize"), + ); + !descriptor.is_undefined() + }; + } + + DEVICE_PIXEL_SUPPORT.with(|support| *support) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/schedule.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/schedule.rs new file mode 100644 index 00000000..eb970684 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/schedule.rs @@ -0,0 +1,342 @@ +use js_sys::{Array, Function, Object, Promise, Reflect}; +use std::cell::OnceCell; +use std::time::Duration; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::{ + AbortController, AbortSignal, Blob, BlobPropertyBag, MessageChannel, MessagePort, Url, Worker, +}; + +use crate::platform::web::{PollStrategy, WaitUntilStrategy}; + +#[derive(Debug)] +pub struct Schedule { + _closure: Closure, + inner: Inner, +} + +#[derive(Debug)] +enum Inner { + Scheduler { + controller: AbortController, + }, + IdleCallback { + window: web_sys::Window, + handle: u32, + }, + Timeout { + window: web_sys::Window, + handle: i32, + port: MessagePort, + _timeout_closure: Closure, + }, + Worker(MessagePort), +} + +impl Schedule { + pub fn new(strategy: PollStrategy, window: &web_sys::Window, f: F) -> Schedule + where + F: 'static + FnMut(), + { + if strategy == PollStrategy::Scheduler && has_scheduler_support(window) { + Self::new_scheduler(window, f, None) + } else if strategy == PollStrategy::IdleCallback && has_idle_callback_support(window) { + Self::new_idle_callback(window.clone(), f) + } else { + Self::new_timeout(window.clone(), f, None) + } + } + + pub fn new_with_duration( + strategy: WaitUntilStrategy, + window: &web_sys::Window, + f: F, + duration: Duration, + ) -> Schedule + where + F: 'static + FnMut(), + { + match strategy { + WaitUntilStrategy::Scheduler => { + if has_scheduler_support(window) { + Self::new_scheduler(window, f, Some(duration)) + } else { + Self::new_timeout(window.clone(), f, Some(duration)) + } + }, + WaitUntilStrategy::Worker => Self::new_worker(f, duration), + } + } + + fn new_scheduler(window: &web_sys::Window, f: F, duration: Option) -> Schedule + where + F: 'static + FnMut(), + { + let window: &WindowSupportExt = window.unchecked_ref(); + let scheduler = window.scheduler(); + + let closure = Closure::new(f); + let mut options = SchedulerPostTaskOptions::new(); + let controller = AbortController::new().expect("Failed to create `AbortController`"); + options.signal(&controller.signal()); + + if let Some(duration) = duration { + // `Duration::as_millis()` always rounds down (because of truncation), we want to round + // up instead. This makes sure that the we never wake up **before** the given time. + let duration = duration + .as_secs() + .checked_mul(1000) + .and_then(|secs| secs.checked_add(duration_millis_ceil(duration).into())) + .unwrap_or(u64::MAX); + + options.delay(duration as f64); + } + + thread_local! { + static REJECT_HANDLER: Closure = Closure::new(|_| ()); + } + REJECT_HANDLER.with(|handler| { + let _ = scheduler + .post_task_with_options(closure.as_ref().unchecked_ref(), &options) + .catch(handler); + }); + + Schedule { _closure: closure, inner: Inner::Scheduler { controller } } + } + + fn new_idle_callback(window: web_sys::Window, f: F) -> Schedule + where + F: 'static + FnMut(), + { + let closure = Closure::new(f); + let handle = window + .request_idle_callback(closure.as_ref().unchecked_ref()) + .expect("Failed to request idle callback"); + + Schedule { _closure: closure, inner: Inner::IdleCallback { window, handle } } + } + + fn new_timeout(window: web_sys::Window, f: F, duration: Option) -> Schedule + where + F: 'static + FnMut(), + { + let channel = MessageChannel::new().unwrap(); + let closure = Closure::new(f); + let port_1 = channel.port1(); + port_1.set_onmessage(Some(closure.as_ref().unchecked_ref())); + port_1.start(); + + let port_2 = channel.port2(); + let timeout_closure = Closure::new(move || { + port_2.post_message(&JsValue::UNDEFINED).expect("Failed to send message") + }); + let handle = if let Some(duration) = duration { + // `Duration::as_millis()` always rounds down (because of truncation), we want to round + // up instead. This makes sure that the we never wake up **before** the given time. + let duration = duration + .as_secs() + .try_into() + .ok() + .and_then(|secs: i32| secs.checked_mul(1000)) + .and_then(|secs: i32| { + let millis: i32 = duration_millis_ceil(duration) + .try_into() + .expect("millis are somehow bigger then 1K"); + secs.checked_add(millis) + }) + .unwrap_or(i32::MAX); + + window.set_timeout_with_callback_and_timeout_and_arguments_0( + timeout_closure.as_ref().unchecked_ref(), + duration, + ) + } else { + window.set_timeout_with_callback(timeout_closure.as_ref().unchecked_ref()) + } + .expect("Failed to set timeout"); + + Schedule { + _closure: closure, + inner: Inner::Timeout { + window, + handle, + port: port_1, + _timeout_closure: timeout_closure, + }, + } + } + + fn new_worker(f: F, duration: Duration) -> Schedule + where + F: 'static + FnMut(), + { + thread_local! { + static URL: ScriptUrl = ScriptUrl::new(include_str!("worker.min.js")); + static WORKER: Worker = URL.with(|url| Worker::new(&url.0)).expect("`new Worker()` is not expected to fail with a local script"); + } + + let channel = MessageChannel::new().unwrap(); + let closure = Closure::new(f); + let port_1 = channel.port1(); + port_1.set_onmessage(Some(closure.as_ref().unchecked_ref())); + port_1.start(); + + // `Duration::as_millis()` always rounds down (because of truncation), we want to round + // up instead. This makes sure that the we never wake up **before** the given time. + let duration = duration + .as_secs() + .try_into() + .ok() + .and_then(|secs: u32| secs.checked_mul(1000)) + .and_then(|secs| secs.checked_add(duration_millis_ceil(duration))) + .unwrap_or(u32::MAX); + + WORKER + .with(|worker| { + let port_2 = channel.port2(); + worker.post_message_with_transfer( + &Array::of2(&port_2, &duration.into()), + &Array::of1(&port_2).into(), + ) + }) + .expect("`Worker.postMessage()` is not expected to fail"); + + Schedule { _closure: closure, inner: Inner::Worker(port_1) } + } +} + +impl Drop for Schedule { + fn drop(&mut self) { + match &self.inner { + Inner::Scheduler { controller, .. } => controller.abort(), + Inner::IdleCallback { window, handle, .. } => window.cancel_idle_callback(*handle), + Inner::Timeout { window, handle, port, .. } => { + window.clear_timeout_with_handle(*handle); + port.close(); + port.set_onmessage(None); + }, + Inner::Worker(port) => { + port.close(); + port.set_onmessage(None); + }, + } + } +} + +// TODO: Replace with `u32::div_ceil()` when we hit Rust v1.73. +fn duration_millis_ceil(duration: Duration) -> u32 { + let micros = duration.subsec_micros(); + + // From . + let d = micros / 1000; + let r = micros % 1000; + if r > 0 && 1000 > 0 { + d + 1 + } else { + d + } +} + +fn has_scheduler_support(window: &web_sys::Window) -> bool { + thread_local! { + static SCHEDULER_SUPPORT: OnceCell = const { OnceCell::new() }; + } + + SCHEDULER_SUPPORT.with(|support| { + *support.get_or_init(|| { + #[wasm_bindgen] + extern "C" { + type SchedulerSupport; + + #[wasm_bindgen(method, getter, js_name = scheduler)] + fn has_scheduler(this: &SchedulerSupport) -> JsValue; + } + + let support: &SchedulerSupport = window.unchecked_ref(); + + !support.has_scheduler().is_undefined() + }) + }) +} + +fn has_idle_callback_support(window: &web_sys::Window) -> bool { + thread_local! { + static IDLE_CALLBACK_SUPPORT: OnceCell = const { OnceCell::new() }; + } + + IDLE_CALLBACK_SUPPORT.with(|support| { + *support.get_or_init(|| { + #[wasm_bindgen] + extern "C" { + type IdleCallbackSupport; + + #[wasm_bindgen(method, getter, js_name = requestIdleCallback)] + fn has_request_idle_callback(this: &IdleCallbackSupport) -> JsValue; + } + + let support: &IdleCallbackSupport = window.unchecked_ref(); + !support.has_request_idle_callback().is_undefined() + }) + }) +} + +struct ScriptUrl(String); + +impl ScriptUrl { + fn new(script: &str) -> Self { + let sequence = Array::of1(&script.into()); + let property = BlobPropertyBag::new(); + property.set_type("text/javascript"); + let blob = Blob::new_with_str_sequence_and_options(&sequence, &property) + .expect("`new Blob()` should never throw"); + + let url = Url::create_object_url_with_blob(&blob) + .expect("`URL.createObjectURL()` should never throw"); + + Self(url) + } +} + +impl Drop for ScriptUrl { + fn drop(&mut self) { + Url::revoke_object_url(&self.0).expect("`URL.revokeObjectURL()` should never throw"); + } +} + +#[wasm_bindgen] +extern "C" { + type WindowSupportExt; + + #[wasm_bindgen(method, getter)] + fn scheduler(this: &WindowSupportExt) -> Scheduler; + + type Scheduler; + + #[wasm_bindgen(method, js_name = postTask)] + fn post_task_with_options( + this: &Scheduler, + callback: &Function, + options: &SchedulerPostTaskOptions, + ) -> Promise; + + type SchedulerPostTaskOptions; +} + +impl SchedulerPostTaskOptions { + fn new() -> Self { + Object::new().unchecked_into() + } + + fn delay(&mut self, val: f64) -> &mut Self { + let r = Reflect::set(self, &JsValue::from("delay"), &val.into()); + debug_assert!(r.is_ok(), "Failed to set `delay` property"); + self + } + + fn signal(&mut self, val: &AbortSignal) -> &mut Self { + let r = Reflect::set(self, &JsValue::from("signal"), &val.into()); + debug_assert!(r.is_ok(), "Failed to set `signal` property"); + self + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/worker.js b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/worker.js new file mode 100644 index 00000000..5a8411ef --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/worker.js @@ -0,0 +1,10 @@ +onmessage = event => { + const [port, timeout] = event.data + const f = () => port.postMessage(undefined) + + if ('scheduler' in this) { + scheduler.postTask(f, { delay: timeout }) + } else { + setTimeout(f, timeout) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/worker.min.js b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/worker.min.js new file mode 100644 index 00000000..fd394a73 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/web_sys/worker.min.js @@ -0,0 +1 @@ +onmessage=e=>{let[s,t]=e.data,a=()=>s.postMessage(void 0);"scheduler"in this?scheduler.postTask(a,{delay:t}):setTimeout(a,t)}; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/web/window.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/window.rs new file mode 100644 index 00000000..29ca7302 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/web/window.rs @@ -0,0 +1,477 @@ +use crate::dpi::{PhysicalPosition, PhysicalSize, Position, Size}; +use crate::error::{ExternalError, NotSupportedError, OsError as RootOE}; +use crate::icon::Icon; +use crate::window::{ + Cursor, CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, + WindowAttributes, WindowButtons, WindowId as RootWI, WindowLevel, +}; + +use super::main_thread::{MainThreadMarker, MainThreadSafe}; +use super::monitor::MonitorHandle; +use super::r#async::Dispatcher; +use super::{backend, ActiveEventLoop, Fullscreen}; +use web_sys::HtmlCanvasElement; + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; +use std::sync::Arc; + +pub struct Window { + inner: Dispatcher, +} + +pub struct Inner { + id: WindowId, + pub window: web_sys::Window, + canvas: Rc>, + destroy_fn: Option>, +} + +impl Window { + pub(crate) fn new( + target: &ActiveEventLoop, + mut attr: WindowAttributes, + ) -> Result { + let id = target.generate_id(); + + let window = target.runner.window(); + let document = target.runner.document(); + let canvas = backend::Canvas::create( + target.runner.main_thread(), + id, + window.clone(), + document.clone(), + &mut attr, + )?; + let canvas = Rc::new(RefCell::new(canvas)); + + target.register(&canvas, id); + + let runner = target.runner.clone(); + let destroy_fn = Box::new(move || runner.notify_destroy_window(RootWI(id))); + + let inner = Inner { id, window: window.clone(), canvas, destroy_fn: Some(destroy_fn) }; + + inner.set_title(&attr.title); + inner.set_maximized(attr.maximized); + inner.set_visible(attr.visible); + inner.set_window_icon(attr.window_icon); + inner.set_cursor(attr.cursor); + + let canvas = Rc::downgrade(&inner.canvas); + let (dispatcher, runner) = Dispatcher::new(target.runner.main_thread(), inner).unwrap(); + target.runner.add_canvas(RootWI(id), canvas, runner); + + Ok(Window { inner: dispatcher }) + } + + pub(crate) fn maybe_queue_on_main(&self, f: impl FnOnce(&Inner) + Send + 'static) { + self.inner.dispatch(f) + } + + pub(crate) fn maybe_wait_on_main(&self, f: impl FnOnce(&Inner) -> R + Send) -> R { + self.inner.queue(f) + } + + pub fn canvas(&self) -> Option { + self.inner.value().map(|inner| inner.canvas.borrow().raw().clone()) + } + + pub(crate) fn prevent_default(&self) -> bool { + self.inner.queue(|inner| inner.canvas.borrow().prevent_default.get()) + } + + pub(crate) fn set_prevent_default(&self, prevent_default: bool) { + self.inner.dispatch(move |inner| inner.canvas.borrow().prevent_default.set(prevent_default)) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_window_handle_rwh_06(&self) -> Result { + self.inner + .value() + .map(|inner| { + let canvas = inner.canvas.borrow(); + // SAFETY: This will only work if the reference to `HtmlCanvasElement` stays valid. + let canvas: &wasm_bindgen::JsValue = canvas.raw(); + let window_handle = + rwh_06::WebCanvasWindowHandle::new(std::ptr::NonNull::from(canvas).cast()); + rwh_06::RawWindowHandle::WebCanvas(window_handle) + }) + .ok_or(rwh_06::HandleError::Unavailable) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub(crate) fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::Web(rwh_06::WebDisplayHandle::new())) + } +} + +impl Inner { + pub fn set_title(&self, title: &str) { + self.canvas.borrow().set_attribute("alt", title) + } + + pub fn set_transparent(&self, _transparent: bool) {} + + pub fn set_blur(&self, _blur: bool) {} + + pub fn set_visible(&self, _visible: bool) { + // Intentionally a no-op + } + + #[inline] + pub fn is_visible(&self) -> Option { + None + } + + pub fn request_redraw(&self) { + self.canvas.borrow().request_animation_frame(); + } + + pub fn pre_present_notify(&self) {} + + pub fn outer_position(&self) -> Result, NotSupportedError> { + Ok(self.canvas.borrow().position().to_physical(self.scale_factor())) + } + + pub fn inner_position(&self) -> Result, NotSupportedError> { + // Note: the canvas element has no window decorations, so this is equal to `outer_position`. + self.outer_position() + } + + pub fn set_outer_position(&self, position: Position) { + let canvas = self.canvas.borrow(); + let position = position.to_logical::(self.scale_factor()); + + backend::set_canvas_position(canvas.document(), canvas.raw(), canvas.style(), position) + } + + #[inline] + pub fn inner_size(&self) -> PhysicalSize { + self.canvas.borrow().inner_size() + } + + #[inline] + pub fn outer_size(&self) -> PhysicalSize { + // Note: the canvas element has no window decorations, so this is equal to `inner_size`. + self.inner_size() + } + + #[inline] + pub fn request_inner_size(&self, size: Size) -> Option> { + let size = size.to_logical(self.scale_factor()); + let canvas = self.canvas.borrow(); + backend::set_canvas_size(canvas.document(), canvas.raw(), canvas.style(), size); + None + } + + #[inline] + pub fn set_min_inner_size(&self, dimensions: Option) { + let dimensions = dimensions.map(|dimensions| dimensions.to_logical(self.scale_factor())); + let canvas = self.canvas.borrow(); + backend::set_canvas_min_size(canvas.document(), canvas.raw(), canvas.style(), dimensions) + } + + #[inline] + pub fn set_max_inner_size(&self, dimensions: Option) { + let dimensions = dimensions.map(|dimensions| dimensions.to_logical(self.scale_factor())); + let canvas = self.canvas.borrow(); + backend::set_canvas_max_size(canvas.document(), canvas.raw(), canvas.style(), dimensions) + } + + #[inline] + pub fn resize_increments(&self) -> Option> { + None + } + + #[inline] + pub fn set_resize_increments(&self, _increments: Option) { + // Intentionally a no-op: users can't resize canvas elements + } + + #[inline] + pub fn set_resizable(&self, _resizable: bool) { + // Intentionally a no-op: users can't resize canvas elements + } + + pub fn is_resizable(&self) -> bool { + true + } + + #[inline] + pub fn set_enabled_buttons(&self, _buttons: WindowButtons) {} + + #[inline] + pub fn enabled_buttons(&self) -> WindowButtons { + WindowButtons::all() + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + super::backend::scale_factor(&self.window) + } + + #[inline] + pub fn set_cursor(&self, cursor: Cursor) { + self.canvas.borrow_mut().cursor.set_cursor(cursor) + } + + #[inline] + pub fn set_cursor_position(&self, _position: Position) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + #[inline] + pub fn set_cursor_grab(&self, mode: CursorGrabMode) -> Result<(), ExternalError> { + let lock = match mode { + CursorGrabMode::None => false, + CursorGrabMode::Locked => true, + CursorGrabMode::Confined => { + return Err(ExternalError::NotSupported(NotSupportedError::new())) + }, + }; + + self.canvas.borrow().set_cursor_lock(lock).map_err(ExternalError::Os) + } + + #[inline] + pub fn set_cursor_visible(&self, visible: bool) { + self.canvas.borrow_mut().cursor.set_cursor_visible(visible) + } + + #[inline] + pub fn drag_window(&self) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + #[inline] + pub fn drag_resize_window(&self, _direction: ResizeDirection) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + #[inline] + pub fn show_window_menu(&self, _position: Position) {} + + #[inline] + pub fn set_cursor_hittest(&self, _hittest: bool) -> Result<(), ExternalError> { + Err(ExternalError::NotSupported(NotSupportedError::new())) + } + + #[inline] + pub fn set_minimized(&self, _minimized: bool) { + // Intentionally a no-op, as canvases cannot be 'minimized' + } + + #[inline] + pub fn is_minimized(&self) -> Option { + // Canvas cannot be 'minimized' + Some(false) + } + + #[inline] + pub fn set_maximized(&self, _maximized: bool) { + // Intentionally a no-op, as canvases cannot be 'maximized' + } + + #[inline] + pub fn is_maximized(&self) -> bool { + // Canvas cannot be 'maximized' + false + } + + #[inline] + pub(crate) fn fullscreen(&self) -> Option { + if self.canvas.borrow().is_fullscreen() { + Some(Fullscreen::Borderless(None)) + } else { + None + } + } + + #[inline] + pub(crate) fn set_fullscreen(&self, fullscreen: Option) { + let canvas = &self.canvas.borrow(); + + if fullscreen.is_some() { + canvas.request_fullscreen(); + } else { + canvas.exit_fullscreen() + } + } + + #[inline] + pub fn set_decorations(&self, _decorations: bool) { + // Intentionally a no-op, no canvas decorations + } + + pub fn is_decorated(&self) -> bool { + true + } + + #[inline] + pub fn set_window_level(&self, _level: WindowLevel) { + // Intentionally a no-op, no window ordering + } + + #[inline] + pub fn set_window_icon(&self, _window_icon: Option) { + // Currently an intentional no-op + } + + #[inline] + pub fn set_ime_cursor_area(&self, _position: Position, _size: Size) { + // Currently a no-op as it does not seem there is good support for this on web + } + + #[inline] + pub fn set_ime_allowed(&self, _allowed: bool) { + // Currently not implemented + } + + #[inline] + pub fn set_ime_purpose(&self, _purpose: ImePurpose) { + // Currently not implemented + } + + #[inline] + pub fn focus_window(&self) { + let _ = self.canvas.borrow().raw().focus(); + } + + #[inline] + pub fn request_user_attention(&self, _request_type: Option) { + // Currently an intentional no-op + } + + #[inline] + pub fn current_monitor(&self) -> Option { + None + } + + #[inline] + pub fn available_monitors(&self) -> VecDeque { + VecDeque::new() + } + + #[inline] + pub fn primary_monitor(&self) -> Option { + None + } + + #[inline] + pub fn id(&self) -> WindowId { + self.id + } + + #[cfg(feature = "rwh_04")] + #[inline] + pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle { + let mut window_handle = rwh_04::WebHandle::empty(); + window_handle.id = self.id.0; + rwh_04::RawWindowHandle::Web(window_handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle { + let mut window_handle = rwh_05::WebWindowHandle::empty(); + window_handle.id = self.id.0; + rwh_05::RawWindowHandle::Web(window_handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::Web(rwh_05::WebDisplayHandle::empty()) + } + + #[inline] + pub fn set_theme(&self, _theme: Option) {} + + #[inline] + pub fn theme(&self) -> Option { + backend::is_dark_mode(&self.window).map(|is_dark_mode| { + if is_dark_mode { + Theme::Dark + } else { + Theme::Light + } + }) + } + + pub fn set_content_protected(&self, _protected: bool) {} + + #[inline] + pub fn has_focus(&self) -> bool { + self.canvas.borrow().has_focus.get() + } + + pub fn title(&self) -> String { + String::new() + } + + pub fn reset_dead_keys(&self) { + // Not supported + } +} + +impl Drop for Inner { + fn drop(&mut self) { + if let Some(destroy_fn) = self.destroy_fn.take() { + destroy_fn(); + } + } +} +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WindowId(pub(crate) u32); + +impl WindowId { + pub const fn dummy() -> Self { + Self(0) + } +} + +impl From for u64 { + fn from(window_id: WindowId) -> Self { + window_id.0 as u64 + } +} + +impl From for WindowId { + fn from(raw_id: u64) -> Self { + Self(raw_id as u32) + } +} + +#[derive(Clone, Debug)] +pub struct PlatformSpecificWindowAttributes { + pub(crate) canvas: Option>>, + pub(crate) prevent_default: bool, + pub(crate) focusable: bool, + pub(crate) append: bool, +} + +impl PlatformSpecificWindowAttributes { + pub(crate) fn set_canvas(&mut self, canvas: Option) { + let Some(canvas) = canvas else { + self.canvas = None; + return; + }; + + let main_thread = MainThreadMarker::new() + .expect("received a `HtmlCanvasElement` outside the window context"); + + self.canvas = Some(Arc::new(MainThreadSafe::new(main_thread, canvas))); + } +} + +impl Default for PlatformSpecificWindowAttributes { + fn default() -> Self { + Self { canvas: None, prevent_default: true, focusable: true, append: false } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/dark_mode.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/dark_mode.rs new file mode 100644 index 00000000..366e44c6 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/dark_mode.rs @@ -0,0 +1,170 @@ +/// This is a simple implementation of support for Windows Dark Mode, +/// which is inspired by the solution in https://github.com/ysc3839/win32-darkmode +use std::{ffi::c_void, ptr}; + +use crate::utils::Lazy; +use windows_sys::core::PCSTR; +use windows_sys::Win32::Foundation::{BOOL, HWND, NTSTATUS, S_OK}; +use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA}; +use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW; +use windows_sys::Win32::UI::Accessibility::{HCF_HIGHCONTRASTON, HIGHCONTRASTA}; +use windows_sys::Win32::UI::Controls::SetWindowTheme; +use windows_sys::Win32::UI::WindowsAndMessaging::{SystemParametersInfoA, SPI_GETHIGHCONTRAST}; + +use crate::window::Theme; + +use super::util; + +static WIN10_BUILD_VERSION: Lazy> = Lazy::new(|| { + type RtlGetVersion = unsafe extern "system" fn(*mut OSVERSIONINFOW) -> NTSTATUS; + let handle = get_function!("ntdll.dll", RtlGetVersion); + + if let Some(rtl_get_version) = handle { + unsafe { + let mut vi = OSVERSIONINFOW { + dwOSVersionInfoSize: 0, + dwMajorVersion: 0, + dwMinorVersion: 0, + dwBuildNumber: 0, + dwPlatformId: 0, + szCSDVersion: [0; 128], + }; + + let status = (rtl_get_version)(&mut vi); + + if status >= 0 && vi.dwMajorVersion == 10 && vi.dwMinorVersion == 0 { + Some(vi.dwBuildNumber) + } else { + None + } + } + } else { + None + } +}); + +static DARK_MODE_SUPPORTED: Lazy = Lazy::new(|| { + // We won't try to do anything for windows versions < 17763 + // (Windows 10 October 2018 update) + match *WIN10_BUILD_VERSION { + Some(v) => v >= 17763, + None => false, + } +}); + +static DARK_THEME_NAME: Lazy> = Lazy::new(|| util::encode_wide("DarkMode_Explorer")); +static LIGHT_THEME_NAME: Lazy> = Lazy::new(|| util::encode_wide("")); + +/// Attempt to set a theme on a window, if necessary. +/// Returns the theme that was picked +pub fn try_theme(hwnd: HWND, preferred_theme: Option) -> Theme { + if *DARK_MODE_SUPPORTED { + let is_dark_mode = match preferred_theme { + Some(theme) => theme == Theme::Dark, + None => should_use_dark_mode(), + }; + + let theme = if is_dark_mode { Theme::Dark } else { Theme::Light }; + let theme_name = match theme { + Theme::Dark => DARK_THEME_NAME.as_ptr(), + Theme::Light => LIGHT_THEME_NAME.as_ptr(), + }; + + let status = unsafe { SetWindowTheme(hwnd, theme_name, ptr::null()) }; + + if status == S_OK && set_dark_mode_for_window(hwnd, is_dark_mode) { + return theme; + } + } + + Theme::Light +} + +fn set_dark_mode_for_window(hwnd: HWND, is_dark_mode: bool) -> bool { + // Uses Windows undocumented API SetWindowCompositionAttribute, + // as seen in win32-darkmode example linked at top of file. + + type SetWindowCompositionAttribute = + unsafe extern "system" fn(HWND, *mut WINDOWCOMPOSITIONATTRIBDATA) -> BOOL; + + #[allow(clippy::upper_case_acronyms)] + type WINDOWCOMPOSITIONATTRIB = u32; + const WCA_USEDARKMODECOLORS: WINDOWCOMPOSITIONATTRIB = 26; + + #[allow(non_snake_case)] + #[allow(clippy::upper_case_acronyms)] + #[repr(C)] + struct WINDOWCOMPOSITIONATTRIBDATA { + Attrib: WINDOWCOMPOSITIONATTRIB, + pvData: *mut c_void, + cbData: usize, + } + + static SET_WINDOW_COMPOSITION_ATTRIBUTE: Lazy> = + Lazy::new(|| get_function!("user32.dll", SetWindowCompositionAttribute)); + + if let Some(set_window_composition_attribute) = *SET_WINDOW_COMPOSITION_ATTRIBUTE { + unsafe { + // SetWindowCompositionAttribute needs a bigbool (i32), not bool. + let mut is_dark_mode_bigbool = BOOL::from(is_dark_mode); + + let mut data = WINDOWCOMPOSITIONATTRIBDATA { + Attrib: WCA_USEDARKMODECOLORS, + pvData: &mut is_dark_mode_bigbool as *mut _ as _, + cbData: std::mem::size_of_val(&is_dark_mode_bigbool) as _, + }; + + let status = set_window_composition_attribute(hwnd, &mut data); + + status != false.into() + } + } else { + false + } +} + +pub fn should_use_dark_mode() -> bool { + should_apps_use_dark_mode() && !is_high_contrast() +} + +fn should_apps_use_dark_mode() -> bool { + type ShouldAppsUseDarkMode = unsafe extern "system" fn() -> bool; + static SHOULD_APPS_USE_DARK_MODE: Lazy> = Lazy::new(|| unsafe { + const UXTHEME_SHOULDAPPSUSEDARKMODE_ORDINAL: PCSTR = 132 as PCSTR; + + // We won't try to do anything for windows versions < 17763 + // (Windows 10 October 2018 update) + if !*DARK_MODE_SUPPORTED { + return None; + } + + let module = LoadLibraryA("uxtheme.dll\0".as_ptr().cast()); + + if module == 0 { + return None; + } + + let handle = GetProcAddress(module, UXTHEME_SHOULDAPPSUSEDARKMODE_ORDINAL); + + handle.map(|handle| std::mem::transmute(handle)) + }); + + SHOULD_APPS_USE_DARK_MODE + .map(|should_apps_use_dark_mode| unsafe { (should_apps_use_dark_mode)() }) + .unwrap_or(false) +} + +fn is_high_contrast() -> bool { + let mut hc = HIGHCONTRASTA { cbSize: 0, dwFlags: 0, lpszDefaultScheme: ptr::null_mut() }; + + let ok = unsafe { + SystemParametersInfoA( + SPI_GETHIGHCONTRAST, + std::mem::size_of_val(&hc) as _, + &mut hc as *mut _ as _, + 0, + ) + }; + + ok != false.into() && util::has_flag(hc.dwFlags, HCF_HIGHCONTRASTON) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/definitions.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/definitions.rs new file mode 100644 index 00000000..c015ffc4 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/definitions.rs @@ -0,0 +1,148 @@ +#![allow(non_snake_case)] +#![allow(non_upper_case_globals)] + +use std::ffi::c_void; + +use windows_sys::core::{IUnknown, GUID, HRESULT}; +use windows_sys::Win32::Foundation::{BOOL, HWND, POINTL}; +use windows_sys::Win32::System::Com::{ + IAdviseSink, IDataObject, IEnumFORMATETC, IEnumSTATDATA, FORMATETC, STGMEDIUM, +}; + +#[repr(C)] +pub struct IUnknownVtbl { + pub QueryInterface: unsafe extern "system" fn( + This: *mut IUnknown, + riid: *const GUID, + ppvObject: *mut *mut c_void, + ) -> HRESULT, + pub AddRef: unsafe extern "system" fn(This: *mut IUnknown) -> u32, + pub Release: unsafe extern "system" fn(This: *mut IUnknown) -> u32, +} + +#[repr(C)] +pub struct IDataObjectVtbl { + pub parent: IUnknownVtbl, + pub GetData: unsafe extern "system" fn( + This: *mut IDataObject, + pformatetcIn: *const FORMATETC, + pmedium: *mut STGMEDIUM, + ) -> HRESULT, + pub GetDataHere: unsafe extern "system" fn( + This: *mut IDataObject, + pformatetc: *const FORMATETC, + pmedium: *mut STGMEDIUM, + ) -> HRESULT, + QueryGetData: + unsafe extern "system" fn(This: *mut IDataObject, pformatetc: *const FORMATETC) -> HRESULT, + pub GetCanonicalFormatEtc: unsafe extern "system" fn( + This: *mut IDataObject, + pformatetcIn: *const FORMATETC, + pformatetcOut: *mut FORMATETC, + ) -> HRESULT, + pub SetData: unsafe extern "system" fn( + This: *mut IDataObject, + pformatetc: *const FORMATETC, + pformatetcOut: *const FORMATETC, + fRelease: BOOL, + ) -> HRESULT, + pub EnumFormatEtc: unsafe extern "system" fn( + This: *mut IDataObject, + dwDirection: u32, + ppenumFormatEtc: *mut *mut IEnumFORMATETC, + ) -> HRESULT, + pub DAdvise: unsafe extern "system" fn( + This: *mut IDataObject, + pformatetc: *const FORMATETC, + advf: u32, + pAdvSInk: *const IAdviseSink, + pdwConnection: *mut u32, + ) -> HRESULT, + pub DUnadvise: unsafe extern "system" fn(This: *mut IDataObject, dwConnection: u32) -> HRESULT, + pub EnumDAdvise: unsafe extern "system" fn( + This: *mut IDataObject, + ppenumAdvise: *const *const IEnumSTATDATA, + ) -> HRESULT, +} + +#[repr(C)] +pub struct IDropTargetVtbl { + pub parent: IUnknownVtbl, + pub DragEnter: unsafe extern "system" fn( + This: *mut IDropTarget, + pDataObj: *const IDataObject, + grfKeyState: u32, + pt: *const POINTL, + pdwEffect: *mut u32, + ) -> HRESULT, + pub DragOver: unsafe extern "system" fn( + This: *mut IDropTarget, + grfKeyState: u32, + pt: *const POINTL, + pdwEffect: *mut u32, + ) -> HRESULT, + pub DragLeave: unsafe extern "system" fn(This: *mut IDropTarget) -> HRESULT, + pub Drop: unsafe extern "system" fn( + This: *mut IDropTarget, + pDataObj: *const IDataObject, + grfKeyState: u32, + pt: *const POINTL, + pdwEffect: *mut u32, + ) -> HRESULT, +} + +#[repr(C)] +pub struct IDropTarget { + pub lpVtbl: *const IDropTargetVtbl, +} + +#[repr(C)] +pub struct ITaskbarListVtbl { + pub parent: IUnknownVtbl, + pub HrInit: unsafe extern "system" fn(This: *mut ITaskbarList) -> HRESULT, + pub AddTab: unsafe extern "system" fn(This: *mut ITaskbarList, hwnd: HWND) -> HRESULT, + pub DeleteTab: unsafe extern "system" fn(This: *mut ITaskbarList, hwnd: HWND) -> HRESULT, + pub ActivateTab: unsafe extern "system" fn(This: *mut ITaskbarList, hwnd: HWND) -> HRESULT, + pub SetActiveAlt: unsafe extern "system" fn(This: *mut ITaskbarList, hwnd: HWND) -> HRESULT, +} + +#[repr(C)] +pub struct ITaskbarList { + pub lpVtbl: *const ITaskbarListVtbl, +} + +#[repr(C)] +pub struct ITaskbarList2Vtbl { + pub parent: ITaskbarListVtbl, + pub MarkFullscreenWindow: unsafe extern "system" fn( + This: *mut ITaskbarList2, + hwnd: HWND, + fFullscreen: BOOL, + ) -> HRESULT, +} + +#[repr(C)] +pub struct ITaskbarList2 { + pub lpVtbl: *const ITaskbarList2Vtbl, +} + +pub const CLSID_TaskbarList: GUID = GUID { + data1: 0x56fdf344, + data2: 0xfd6d, + data3: 0x11d0, + data4: [0x95, 0x8a, 0x00, 0x60, 0x97, 0xc9, 0xa0, 0x90], +}; + +pub const IID_ITaskbarList: GUID = GUID { + data1: 0x56fdf342, + data2: 0xfd6d, + data3: 0x11d0, + data4: [0x95, 0x8a, 0x00, 0x60, 0x97, 0xc9, 0xa0, 0x90], +}; + +pub const IID_ITaskbarList2: GUID = GUID { + data1: 0x602d4995, + data2: 0xb13a, + data3: 0x429b, + data4: [0xa6, 0x6e, 0x19, 0x35, 0xe4, 0x4f, 0x43, 0x17], +}; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/dpi.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/dpi.rs new file mode 100644 index 00000000..5880069c --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/dpi.rs @@ -0,0 +1,112 @@ +#![allow(non_snake_case, unused_unsafe)] + +use std::sync::Once; + +use windows_sys::Win32::Foundation::{HWND, S_OK}; +use windows_sys::Win32::Graphics::Gdi::{ + GetDC, GetDeviceCaps, MonitorFromWindow, HMONITOR, LOGPIXELSX, MONITOR_DEFAULTTONEAREST, +}; +use windows_sys::Win32::UI::HiDpi::{ + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + MDT_EFFECTIVE_DPI, PROCESS_PER_MONITOR_DPI_AWARE, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::IsProcessDPIAware; + +use crate::platform_impl::platform::util::{ + ENABLE_NON_CLIENT_DPI_SCALING, GET_DPI_FOR_MONITOR, GET_DPI_FOR_WINDOW, SET_PROCESS_DPI_AWARE, + SET_PROCESS_DPI_AWARENESS, SET_PROCESS_DPI_AWARENESS_CONTEXT, +}; + +pub fn become_dpi_aware() { + static ENABLE_DPI_AWARENESS: Once = Once::new(); + ENABLE_DPI_AWARENESS.call_once(|| { + unsafe { + if let Some(SetProcessDpiAwarenessContext) = *SET_PROCESS_DPI_AWARENESS_CONTEXT { + // We are on Windows 10 Anniversary Update (1607) or later. + if SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) + == false.into() + { + // V2 only works with Windows 10 Creators Update (1703). Try using the older + // V1 if we can't set V2. + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE); + } + } else if let Some(SetProcessDpiAwareness) = *SET_PROCESS_DPI_AWARENESS { + // We are on Windows 8.1 or later. + SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE); + } else if let Some(SetProcessDPIAware) = *SET_PROCESS_DPI_AWARE { + // We are on Vista or later. + SetProcessDPIAware(); + } + } + }); +} + +pub fn enable_non_client_dpi_scaling(hwnd: HWND) { + unsafe { + if let Some(EnableNonClientDpiScaling) = *ENABLE_NON_CLIENT_DPI_SCALING { + EnableNonClientDpiScaling(hwnd); + } + } +} + +pub fn get_monitor_dpi(hmonitor: HMONITOR) -> Option { + unsafe { + if let Some(GetDpiForMonitor) = *GET_DPI_FOR_MONITOR { + // We are on Windows 8.1 or later. + let mut dpi_x = 0; + let mut dpi_y = 0; + if GetDpiForMonitor(hmonitor, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y) == S_OK { + // MSDN says that "the values of *dpiX and *dpiY are identical. You only need to + // record one of the values to determine the DPI and respond appropriately". + // https://msdn.microsoft.com/en-us/library/windows/desktop/dn280510(v=vs.85).aspx + return Some(dpi_x); + } + } + } + None +} + +pub const BASE_DPI: u32 = 96; +pub fn dpi_to_scale_factor(dpi: u32) -> f64 { + dpi as f64 / BASE_DPI as f64 +} + +pub unsafe fn hwnd_dpi(hwnd: HWND) -> u32 { + let hdc = unsafe { GetDC(hwnd) }; + if hdc == 0 { + panic!("[winit] `GetDC` returned null!"); + } + if let Some(GetDpiForWindow) = *GET_DPI_FOR_WINDOW { + // We are on Windows 10 Anniversary Update (1607) or later. + match unsafe { GetDpiForWindow(hwnd) } { + 0 => BASE_DPI, // 0 is returned if hwnd is invalid + dpi => dpi, + } + } else if let Some(GetDpiForMonitor) = *GET_DPI_FOR_MONITOR { + // We are on Windows 8.1 or later. + let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) }; + if monitor == 0 { + return BASE_DPI; + } + + let mut dpi_x = 0; + let mut dpi_y = 0; + if unsafe { GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y) } == S_OK { + dpi_x + } else { + BASE_DPI + } + } else { + // We are on Vista or later. + if unsafe { IsProcessDPIAware() } != false.into() { + // If the process is DPI aware, then scaling must be handled by the application using + // this DPI value. + unsafe { GetDeviceCaps(hdc, LOGPIXELSX as i32) as u32 } + } else { + // If the process is DPI unaware, then scaling is performed by the OS; we thus return + // 96 (scale factor 1.0) to prevent the window from being re-scaled by both the + // application and the WM. + BASE_DPI + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/drop_handler.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/drop_handler.rs new file mode 100644 index 00000000..38cb5c54 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/drop_handler.rs @@ -0,0 +1,237 @@ +use std::ffi::{c_void, OsString}; +use std::os::windows::ffi::OsStringExt; +use std::path::PathBuf; +use std::ptr; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use windows_sys::core::{IUnknown, GUID, HRESULT}; +use windows_sys::Win32::Foundation::{DV_E_FORMATETC, HWND, POINTL, S_OK}; +use windows_sys::Win32::System::Com::{IDataObject, DVASPECT_CONTENT, FORMATETC, TYMED_HGLOBAL}; +use windows_sys::Win32::System::Ole::{CF_HDROP, DROPEFFECT_COPY, DROPEFFECT_NONE}; +use windows_sys::Win32::UI::Shell::{DragFinish, DragQueryFileW, HDROP}; + +use tracing::debug; + +use crate::platform_impl::platform::definitions::{ + IDataObjectVtbl, IDropTarget, IDropTargetVtbl, IUnknownVtbl, +}; +use crate::platform_impl::platform::WindowId; + +use crate::event::Event; +use crate::window::WindowId as RootWindowId; + +#[repr(C)] +pub struct FileDropHandlerData { + pub interface: IDropTarget, + refcount: AtomicUsize, + window: HWND, + send_event: Box)>, + cursor_effect: u32, + hovered_is_valid: bool, /* If the currently hovered item is not valid there must not be any + * `HoveredFileCancelled` emitted */ +} + +pub struct FileDropHandler { + pub data: *mut FileDropHandlerData, +} + +#[allow(non_snake_case)] +impl FileDropHandler { + pub fn new(window: HWND, send_event: Box)>) -> FileDropHandler { + let data = Box::new(FileDropHandlerData { + interface: IDropTarget { lpVtbl: &DROP_TARGET_VTBL as *const IDropTargetVtbl }, + refcount: AtomicUsize::new(1), + window, + send_event, + cursor_effect: DROPEFFECT_NONE, + hovered_is_valid: false, + }); + FileDropHandler { data: Box::into_raw(data) } + } + + // Implement IUnknown + pub unsafe extern "system" fn QueryInterface( + _this: *mut IUnknown, + _riid: *const GUID, + _ppvObject: *mut *mut c_void, + ) -> HRESULT { + // This function doesn't appear to be required for an `IDropTarget`. + // An implementation would be nice however. + unimplemented!(); + } + + pub unsafe extern "system" fn AddRef(this: *mut IUnknown) -> u32 { + let drop_handler_data = unsafe { Self::from_interface(this) }; + let count = drop_handler_data.refcount.fetch_add(1, Ordering::Release) + 1; + count as u32 + } + + pub unsafe extern "system" fn Release(this: *mut IUnknown) -> u32 { + let drop_handler = unsafe { Self::from_interface(this) }; + let count = drop_handler.refcount.fetch_sub(1, Ordering::Release) - 1; + if count == 0 { + // Destroy the underlying data + drop(unsafe { Box::from_raw(drop_handler as *mut FileDropHandlerData) }); + } + count as u32 + } + + pub unsafe extern "system" fn DragEnter( + this: *mut IDropTarget, + pDataObj: *const IDataObject, + _grfKeyState: u32, + _pt: *const POINTL, + pdwEffect: *mut u32, + ) -> HRESULT { + use crate::event::WindowEvent::HoveredFile; + let drop_handler = unsafe { Self::from_interface(this) }; + let hdrop = unsafe { + Self::iterate_filenames(pDataObj, |filename| { + drop_handler.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(drop_handler.window)), + event: HoveredFile(filename), + }); + }) + }; + drop_handler.hovered_is_valid = hdrop.is_some(); + drop_handler.cursor_effect = + if drop_handler.hovered_is_valid { DROPEFFECT_COPY } else { DROPEFFECT_NONE }; + unsafe { + *pdwEffect = drop_handler.cursor_effect; + } + + S_OK + } + + pub unsafe extern "system" fn DragOver( + this: *mut IDropTarget, + _grfKeyState: u32, + _pt: *const POINTL, + pdwEffect: *mut u32, + ) -> HRESULT { + let drop_handler = unsafe { Self::from_interface(this) }; + unsafe { + *pdwEffect = drop_handler.cursor_effect; + } + + S_OK + } + + pub unsafe extern "system" fn DragLeave(this: *mut IDropTarget) -> HRESULT { + use crate::event::WindowEvent::HoveredFileCancelled; + let drop_handler = unsafe { Self::from_interface(this) }; + if drop_handler.hovered_is_valid { + drop_handler.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(drop_handler.window)), + event: HoveredFileCancelled, + }); + } + + S_OK + } + + pub unsafe extern "system" fn Drop( + this: *mut IDropTarget, + pDataObj: *const IDataObject, + _grfKeyState: u32, + _pt: *const POINTL, + _pdwEffect: *mut u32, + ) -> HRESULT { + use crate::event::WindowEvent::DroppedFile; + let drop_handler = unsafe { Self::from_interface(this) }; + let hdrop = unsafe { + Self::iterate_filenames(pDataObj, |filename| { + drop_handler.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(drop_handler.window)), + event: DroppedFile(filename), + }); + }) + }; + if let Some(hdrop) = hdrop { + unsafe { DragFinish(hdrop) }; + } + + S_OK + } + + unsafe fn from_interface<'a, InterfaceT>(this: *mut InterfaceT) -> &'a mut FileDropHandlerData { + unsafe { &mut *(this as *mut _) } + } + + unsafe fn iterate_filenames(data_obj: *const IDataObject, callback: F) -> Option + where + F: Fn(PathBuf), + { + let drop_format = FORMATETC { + cfFormat: CF_HDROP, + ptd: ptr::null_mut(), + dwAspect: DVASPECT_CONTENT, + lindex: -1, + tymed: TYMED_HGLOBAL as u32, + }; + + let mut medium = unsafe { std::mem::zeroed() }; + let get_data_fn = unsafe { (*(*data_obj).cast::()).GetData }; + let get_data_result = unsafe { get_data_fn(data_obj as *mut _, &drop_format, &mut medium) }; + if get_data_result >= 0 { + let hdrop = unsafe { medium.u.hGlobal as HDROP }; + + // The second parameter (0xFFFFFFFF) instructs the function to return the item count + let item_count = unsafe { DragQueryFileW(hdrop, 0xffffffff, ptr::null_mut(), 0) }; + + for i in 0..item_count { + // Get the length of the path string NOT including the terminating null character. + // Previously, this was using a fixed size array of MAX_PATH length, but the + // Windows API allows longer paths under certain circumstances. + let character_count = + unsafe { DragQueryFileW(hdrop, i, ptr::null_mut(), 0) as usize }; + let str_len = character_count + 1; + + // Fill path_buf with the null-terminated file name + let mut path_buf = Vec::with_capacity(str_len); + unsafe { + DragQueryFileW(hdrop, i, path_buf.as_mut_ptr(), str_len as u32); + path_buf.set_len(str_len); + } + + callback(OsString::from_wide(&path_buf[0..character_count]).into()); + } + + Some(hdrop) + } else if get_data_result == DV_E_FORMATETC { + // If the dropped item is not a file this error will occur. + // In this case it is OK to return without taking further action. + debug!("Error occurred while processing dropped/hovered item: item is not a file."); + None + } else { + debug!("Unexpected error occurred while processing dropped/hovered item."); + None + } + } +} + +impl FileDropHandlerData { + fn send_event(&self, event: Event<()>) { + (self.send_event)(event); + } +} + +impl Drop for FileDropHandler { + fn drop(&mut self) { + unsafe { + FileDropHandler::Release(self.data as *mut IUnknown); + } + } +} + +static DROP_TARGET_VTBL: IDropTargetVtbl = IDropTargetVtbl { + parent: IUnknownVtbl { + QueryInterface: FileDropHandler::QueryInterface, + AddRef: FileDropHandler::AddRef, + Release: FileDropHandler::Release, + }, + DragEnter: FileDropHandler::DragEnter, + DragOver: FileDropHandler::DragOver, + DragLeave: FileDropHandler::DragLeave, + Drop: FileDropHandler::Drop, +}; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/event_loop.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/event_loop.rs new file mode 100644 index 00000000..6d60c1c5 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/event_loop.rs @@ -0,0 +1,2676 @@ +#![allow(non_snake_case)] + +mod runner; + +use std::cell::Cell; +use std::collections::VecDeque; +use std::ffi::c_void; +use std::marker::PhantomData; +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle, RawHandle}; +use std::rc::Rc; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; +use std::{mem, panic, ptr}; + +use crate::utils::Lazy; + +use windows_sys::Win32::Devices::HumanInterfaceDevice::MOUSE_MOVE_RELATIVE; +use windows_sys::Win32::Foundation::{ + GetLastError, FALSE, HANDLE, HWND, LPARAM, LRESULT, POINT, RECT, WAIT_FAILED, WPARAM, +}; +use windows_sys::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromRect, MonitorFromWindow, RedrawWindow, ScreenToClient, + ValidateRect, MONITORINFO, MONITOR_DEFAULTTONULL, RDW_INTERNALPAINT, SC_SCREENSAVE, +}; +use windows_sys::Win32::System::Ole::RevokeDragDrop; +use windows_sys::Win32::System::Threading::{ + CreateWaitableTimerExW, GetCurrentThreadId, SetWaitableTimer, + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, INFINITE, TIMER_ALL_ACCESS, +}; +use windows_sys::Win32::UI::Controls::{HOVER_DEFAULT, WM_MOUSELEAVE}; +use windows_sys::Win32::UI::Input::Ime::{GCS_COMPSTR, GCS_RESULTSTR, ISC_SHOWUICOMPOSITIONWINDOW}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + ReleaseCapture, SetCapture, TrackMouseEvent, TME_LEAVE, TRACKMOUSEEVENT, +}; +use windows_sys::Win32::UI::Input::Pointer::{ + POINTER_FLAG_DOWN, POINTER_FLAG_UP, POINTER_FLAG_UPDATE, +}; +use windows_sys::Win32::UI::Input::Touch::{ + CloseTouchInputHandle, GetTouchInputInfo, TOUCHEVENTF_DOWN, TOUCHEVENTF_MOVE, TOUCHEVENTF_UP, + TOUCHINPUT, +}; +use windows_sys::Win32::UI::Input::{RAWINPUT, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetClientRect, GetCursorPos, + GetMenu, LoadCursorW, MsgWaitForMultipleObjectsEx, PeekMessageW, PostMessageW, + RegisterClassExW, RegisterWindowMessageA, SetCursor, SetWindowPos, TranslateMessage, + CREATESTRUCTW, GIDC_ARRIVAL, GIDC_REMOVAL, GWL_STYLE, GWL_USERDATA, HTCAPTION, HTCLIENT, + MINMAXINFO, MNC_CLOSE, MSG, MWMO_INPUTAVAILABLE, NCCALCSIZE_PARAMS, PM_REMOVE, PT_PEN, + PT_TOUCH, QS_ALLINPUT, RI_MOUSE_HWHEEL, RI_MOUSE_WHEEL, SC_MINIMIZE, SC_RESTORE, + SIZE_MAXIMIZED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WHEEL_DELTA, WINDOWPOS, + WMSZ_BOTTOM, WMSZ_BOTTOMLEFT, WMSZ_BOTTOMRIGHT, WMSZ_LEFT, WMSZ_RIGHT, WMSZ_TOP, WMSZ_TOPLEFT, + WMSZ_TOPRIGHT, WM_CAPTURECHANGED, WM_CLOSE, WM_CREATE, WM_DESTROY, WM_DPICHANGED, + WM_ENTERSIZEMOVE, WM_EXITSIZEMOVE, WM_GETMINMAXINFO, WM_IME_COMPOSITION, WM_IME_ENDCOMPOSITION, + WM_IME_SETCONTEXT, WM_IME_STARTCOMPOSITION, WM_INPUT, WM_INPUT_DEVICE_CHANGE, WM_KEYDOWN, + WM_KEYUP, WM_KILLFOCUS, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, + WM_MENUCHAR, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_NCACTIVATE, WM_NCCALCSIZE, + WM_NCCREATE, WM_NCDESTROY, WM_NCLBUTTONDOWN, WM_PAINT, WM_POINTERDOWN, WM_POINTERUP, + WM_POINTERUPDATE, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SETCURSOR, WM_SETFOCUS, WM_SETTINGCHANGE, + WM_SIZE, WM_SIZING, WM_SYSCOMMAND, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_TOUCH, WM_WINDOWPOSCHANGED, + WM_WINDOWPOSCHANGING, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, WS_EX_LAYERED, + WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_OVERLAPPED, WS_POPUP, WS_VISIBLE, +}; + +use crate::dpi::{PhysicalPosition, PhysicalSize}; +use crate::error::EventLoopError; +use crate::event::{ + DeviceEvent, Event, Force, Ime, InnerSizeWriter, RawKeyEvent, Touch, TouchPhase, WindowEvent, +}; +use crate::event_loop::{ActiveEventLoop as RootAEL, ControlFlow, DeviceEvents, EventLoopClosed}; +use crate::keyboard::ModifiersState; +use crate::platform::pump_events::PumpStatus; +use crate::platform_impl::platform::dark_mode::try_theme; +use crate::platform_impl::platform::dpi::{become_dpi_aware, dpi_to_scale_factor}; +use crate::platform_impl::platform::drop_handler::FileDropHandler; +use crate::platform_impl::platform::icon::WinCursor; +use crate::platform_impl::platform::ime::ImeContext; +use crate::platform_impl::platform::keyboard::KeyEventBuilder; +use crate::platform_impl::platform::keyboard_layout::LAYOUT_CACHE; +use crate::platform_impl::platform::monitor::{self, MonitorHandle}; +use crate::platform_impl::platform::window::InitData; +use crate::platform_impl::platform::window_state::{ + CursorFlags, ImeState, WindowFlags, WindowState, +}; +use crate::platform_impl::platform::{ + raw_input, util, wrap_device_id, Fullscreen, WindowId, DEVICE_ID, +}; +use crate::window::{ + CustomCursor as RootCustomCursor, CustomCursorSource, Theme, WindowId as RootWindowId, +}; +use runner::{EventLoopRunner, EventLoopRunnerShared}; + +use super::window::set_skip_taskbar; +use super::SelectedCursor; + +/// some backends like macos uses an uninhabited `Never` type, +/// on windows, `UserEvent`s are also dispatched through the +/// WNDPROC callback, and due to the re-entrant nature of the +/// callback, recursively delivered events must be queued in a +/// buffer, the current implementation put this queue in +/// `EventLoopRunner`, which is shared between the event pumping +/// loop and the callback. because it's hard to decide from the +/// outside whether a event needs to be buffered, I decided not +/// use `Event` for the shared runner state, but use unit +/// as a placeholder so user events can be buffered as usual, +/// the real `UserEvent` is pulled from the mpsc channel directly +/// when the placeholder event is delivered to the event handler +pub(crate) struct UserEventPlaceholder; + +// here below, the generic `EventLoopRunnerShared` is replaced with +// `EventLoopRunnerShared` so we can get rid +// of the generic parameter T in types which don't depend on T. +// this is the approach which requires minimum changes to current +// backend implementation. it should be considered transitional +// and should be refactored and cleaned up eventually, I hope. + +pub(crate) struct WindowData { + pub window_state: Arc>, + pub event_loop_runner: EventLoopRunnerShared, + pub key_event_builder: KeyEventBuilder, + pub _file_drop_handler: Option, + pub userdata_removed: Cell, + pub recurse_depth: Cell, +} + +impl WindowData { + fn send_event(&self, event: Event) { + self.event_loop_runner.send_event(event); + } + + fn window_state_lock(&self) -> MutexGuard<'_, WindowState> { + self.window_state.lock().unwrap() + } +} + +struct ThreadMsgTargetData { + event_loop_runner: EventLoopRunnerShared, +} + +impl ThreadMsgTargetData { + fn send_event(&self, event: Event) { + self.event_loop_runner.send_event(event); + } +} + +/// The result of a subclass procedure (the message handling callback) +#[derive(Clone, Copy)] +pub(crate) enum ProcResult { + DefWindowProc(WPARAM), + Value(isize), +} + +pub struct EventLoop { + user_event_sender: Sender, + user_event_receiver: Receiver, + window_target: RootAEL, + msg_hook: Option bool + 'static>>, + // It is a timer used on timed waits. + // It is created lazily in case if we have `ControlFlow::WaitUntil`. + // Keep it as a field to avoid recreating it on every `ControlFlow::WaitUntil`. + high_resolution_timer: Option, +} + +pub(crate) struct PlatformSpecificEventLoopAttributes { + pub(crate) any_thread: bool, + pub(crate) dpi_aware: bool, + pub(crate) msg_hook: Option bool + 'static>>, +} + +impl Default for PlatformSpecificEventLoopAttributes { + fn default() -> Self { + Self { any_thread: false, dpi_aware: true, msg_hook: None } + } +} + +pub struct ActiveEventLoop { + thread_id: u32, + thread_msg_target: HWND, + pub(crate) runner_shared: EventLoopRunnerShared, +} + +impl EventLoop { + pub(crate) fn new( + attributes: &mut PlatformSpecificEventLoopAttributes, + ) -> Result { + let thread_id = unsafe { GetCurrentThreadId() }; + + if !attributes.any_thread && thread_id != main_thread_id() { + panic!( + "Initializing the event loop outside of the main thread is a significant \ + cross-platform compatibility hazard. If you absolutely need to create an \ + EventLoop on a different thread, you can use the \ + `EventLoopBuilderExtWindows::any_thread` function." + ); + } + + if attributes.dpi_aware { + become_dpi_aware(); + } + + let thread_msg_target = create_event_target_window(); + + let runner_shared = Rc::new(EventLoopRunner::new(thread_msg_target)); + + let (user_event_sender, user_event_receiver) = mpsc::channel(); + insert_event_target_window_data(thread_msg_target, runner_shared.clone()); + raw_input::register_all_mice_and_keyboards_for_raw_input( + thread_msg_target, + Default::default(), + ); + + Ok(EventLoop { + user_event_sender, + user_event_receiver, + window_target: RootAEL { + p: ActiveEventLoop { thread_id, thread_msg_target, runner_shared }, + _marker: PhantomData, + }, + msg_hook: attributes.msg_hook.take(), + high_resolution_timer: None, + }) + } + + pub fn window_target(&self) -> &RootAEL { + &self.window_target + } + + pub fn run(mut self, event_handler: F) -> Result<(), EventLoopError> + where + F: FnMut(Event, &RootAEL), + { + self.run_on_demand(event_handler) + } + + pub fn run_on_demand(&mut self, mut event_handler: F) -> Result<(), EventLoopError> + where + F: FnMut(Event, &RootAEL), + { + { + let runner = &self.window_target.p.runner_shared; + + let event_loop_windows_ref = &self.window_target; + let user_event_receiver = &self.user_event_receiver; + // # Safety + // We make sure to call runner.clear_event_handler() before + // returning + unsafe { + runner.set_event_handler(move |event| { + // the shared `EventLoopRunner` is not parameterized + // `EventLoopProxy::send_event()` calls `PostMessage` + // to wakeup and dispatch a placeholder `UserEvent`, + // when we received the placeholder event here, the + // real UserEvent(T) should already be put in the + // mpsc channel and ready to be pulled + let event = match event.map_nonuser_event() { + Ok(non_user_event) => non_user_event, + Err(_user_event_placeholder) => Event::UserEvent( + user_event_receiver + .try_recv() + .expect("user event signaled but not received"), + ), + }; + event_handler(event, event_loop_windows_ref) + }); + } + } + + let exit_code = loop { + self.wait_for_messages(None); + // wait_for_messages calls user application before and after waiting + // so it may have decided to exit. + if let Some(code) = self.exit_code() { + break code; + } + + self.dispatch_peeked_messages(); + + if let Some(code) = self.exit_code() { + break code; + } + }; + + let runner = &self.window_target.p.runner_shared; + runner.loop_destroyed(); + + // # Safety + // We assume that this will effectively call `runner.clear_event_handler()` + // to meet the safety requirements for calling `runner.set_event_handler()` above. + runner.reset_runner(); + + if exit_code == 0 { + Ok(()) + } else { + Err(EventLoopError::ExitFailure(exit_code)) + } + } + + pub fn pump_events(&mut self, timeout: Option, mut event_handler: F) -> PumpStatus + where + F: FnMut(Event, &RootAEL), + { + { + let runner = &self.window_target.p.runner_shared; + let event_loop_windows_ref = &self.window_target; + let user_event_receiver = &self.user_event_receiver; + + // # Safety + // We make sure to call runner.clear_event_handler() before + // returning + // + // Note: we're currently assuming nothing can panic and unwind + // to leave the runner in an unsound state with an associated + // event handler. + unsafe { + runner.set_event_handler(move |event| { + let event = match event.map_nonuser_event() { + Ok(non_user_event) => non_user_event, + Err(_user_event_placeholder) => Event::UserEvent( + user_event_receiver + .recv() + .expect("user event signaled but not received"), + ), + }; + event_handler(event, event_loop_windows_ref) + }); + runner.wakeup(); + } + } + + if self.exit_code().is_none() { + self.wait_for_messages(timeout); + } + // wait_for_messages calls user application before and after waiting + // so it may have decided to exit. + if self.exit_code().is_none() { + self.dispatch_peeked_messages(); + } + + let runner = &self.window_target.p.runner_shared; + + let status = if let Some(code) = runner.exit_code() { + runner.loop_destroyed(); + + // Immediately reset the internal state for the loop to allow + // the loop to be run more than once. + runner.reset_runner(); + PumpStatus::Exit(code) + } else { + runner.prepare_wait(); + PumpStatus::Continue + }; + + // We wait until we've checked for an exit status before clearing the + // application callback, in case we need to dispatch a LoopExiting event + // + // # Safety + // This pairs up with our call to `runner.set_event_handler` and ensures + // the application's callback can't be held beyond its lifetime. + runner.clear_event_handler(); + + status + } + + /// Waits until new event messages arrive to be peeked. + /// Doesn't peek messages itself. + /// + /// Parameter timeout is optional. This method would wait for the smaller timeout + /// between the argument and a timeout from control flow. + fn wait_for_messages(&mut self, timeout: Option) { + let runner = &self.window_target.p.runner_shared; + + // We aim to be consistent with the MacOS backend which has a RunLoop + // observer that will dispatch AboutToWait when about to wait for + // events, and NewEvents after the RunLoop wakes up. + // + // We emulate similar behaviour by treating `MsgWaitForMultipleObjectsEx` as our wait + // point and wake up point (when it returns) and we drain all other + // pending messages via `PeekMessage` until we come back to "wait" via + // `MsgWaitForMultipleObjectsEx`. + // + runner.prepare_wait(); + wait_for_messages_impl(&mut self.high_resolution_timer, runner.control_flow(), timeout); + // Before we potentially exit, make sure to consistently emit an event for the wake up + runner.wakeup(); + } + + /// Dispatch all queued messages via `PeekMessageW` + fn dispatch_peeked_messages(&mut self) { + let runner = &self.window_target.p.runner_shared; + + // We generally want to continue dispatching all pending messages + // but we also allow dispatching to be interrupted as a means to + // ensure the `pump_events` won't indefinitely block an external + // event loop if there are too many pending events. This interrupt + // flag will be set after dispatching `RedrawRequested` events. + runner.interrupt_msg_dispatch.set(false); + + // # Safety + // The Windows API has no documented requirement for bitwise + // initializing a `MSG` struct (it can be uninitialized memory for the C + // API) and there's no API to construct or initialize a `MSG`. This + // is the simplest way avoid uninitialized memory in Rust + let mut msg: MSG = unsafe { mem::zeroed() }; + + loop { + unsafe { + if PeekMessageW(&mut msg, 0, 0, 0, PM_REMOVE) == false.into() { + break; + } + + let handled = if let Some(callback) = self.msg_hook.as_deref_mut() { + callback(&mut msg as *mut _ as *mut _) + } else { + false + }; + if !handled { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + + if let Err(payload) = runner.take_panic_error() { + runner.reset_runner(); + panic::resume_unwind(payload); + } + + if let Some(_code) = runner.exit_code() { + break; + } + + if runner.interrupt_msg_dispatch.get() { + break; + } + } + } + + pub fn create_proxy(&self) -> EventLoopProxy { + EventLoopProxy { + target_window: self.window_target.p.thread_msg_target, + event_send: self.user_event_sender.clone(), + } + } + + fn exit_code(&self) -> Option { + self.window_target.p.exit_code() + } +} + +impl ActiveEventLoop { + #[inline(always)] + pub(crate) fn create_thread_executor(&self) -> EventLoopThreadExecutor { + EventLoopThreadExecutor { thread_id: self.thread_id, target_window: self.thread_msg_target } + } + + pub fn create_custom_cursor(&self, source: CustomCursorSource) -> RootCustomCursor { + let inner = match WinCursor::new(&source.inner.0) { + Ok(cursor) => cursor, + Err(err) => { + tracing::warn!("Failed to create custom cursor: {err}"); + WinCursor::Failed + }, + }; + + RootCustomCursor { inner } + } + + // TODO: Investigate opportunities for caching + pub fn available_monitors(&self) -> VecDeque { + monitor::available_monitors() + } + + pub fn primary_monitor(&self) -> Option { + let monitor = monitor::primary_monitor(); + Some(monitor) + } + + #[cfg(feature = "rwh_05")] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::Windows(rwh_05::WindowsDisplayHandle::empty()) + } + + #[cfg(feature = "rwh_06")] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::Windows(rwh_06::WindowsDisplayHandle::new())) + } + + pub fn listen_device_events(&self, allowed: DeviceEvents) { + raw_input::register_all_mice_and_keyboards_for_raw_input(self.thread_msg_target, allowed); + } + + pub fn system_theme(&self) -> Option { + Some(if super::dark_mode::should_use_dark_mode() { Theme::Dark } else { Theme::Light }) + } + + pub(crate) fn set_control_flow(&self, control_flow: ControlFlow) { + self.runner_shared.set_control_flow(control_flow) + } + + pub(crate) fn control_flow(&self) -> ControlFlow { + self.runner_shared.control_flow() + } + + pub(crate) fn exit(&self) { + self.runner_shared.set_exit_code(0) + } + + pub(crate) fn exiting(&self) -> bool { + self.runner_shared.exit_code().is_some() + } + + pub(crate) fn clear_exit(&self) { + self.runner_shared.clear_exit(); + } + + pub(crate) fn owned_display_handle(&self) -> OwnedDisplayHandle { + OwnedDisplayHandle + } + + fn exit_code(&self) -> Option { + self.runner_shared.exit_code() + } +} + +#[derive(Clone)] +pub(crate) struct OwnedDisplayHandle; + +impl OwnedDisplayHandle { + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::WindowsDisplayHandle::empty().into() + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::WindowsDisplayHandle::new().into()) + } +} + +/// Returns the id of the main thread. +/// +/// Windows has no real API to check if the current executing thread is the "main thread", unlike +/// macOS. +/// +/// Windows will let us look up the current thread's id, but there's no API that lets us check what +/// the id of the main thread is. We would somehow need to get the main thread's id before a +/// developer could spin off any other threads inside of the main entrypoint in order to emulate the +/// capabilities of other platforms. +/// +/// We can get the id of the main thread by using CRT initialization. CRT initialization can be used +/// to setup global state within a program. The OS will call a list of function pointers which +/// assign values to a static variable. To have get a hold of the main thread id, we need to place +/// our function pointer inside of the `.CRT$XCU` section so it is called before the main +/// entrypoint. +/// +/// Full details of CRT initialization can be found here: +/// +fn main_thread_id() -> u32 { + static mut MAIN_THREAD_ID: u32 = 0; + + // Function pointer used in CRT initialization section to set the above static field's value. + + // Mark as used so this is not removable. + #[used] + #[allow(non_upper_case_globals)] + // Place the function pointer inside of CRT initialization section so it is loaded before + // main entrypoint. + // + // See: https://doc.rust-lang.org/stable/reference/abi.html#the-link_section-attribute + #[link_section = ".CRT$XCU"] + static INIT_MAIN_THREAD_ID: unsafe extern "C" fn() = { + unsafe extern "C" fn initer() { + unsafe { + MAIN_THREAD_ID = GetCurrentThreadId(); + } + } + initer + }; + + unsafe { MAIN_THREAD_ID } +} + +/// Returns the minimum `Option`, taking into account that `None` +/// equates to an infinite timeout, not a zero timeout (so can't just use +/// `Option::min`) +fn min_timeout(a: Option, b: Option) -> Option { + a.map_or(b, |a_timeout| b.map_or(Some(a_timeout), |b_timeout| Some(a_timeout.min(b_timeout)))) +} + +// Implementation taken from https://github.com/rust-lang/rust/blob/db5476571d9b27c862b95c1e64764b0ac8980e23/src/libstd/sys/windows/mod.rs +fn dur2timeout(dur: Duration) -> u32 { + // Note that a duration is a (u64, u32) (seconds, nanoseconds) pair, and the + // timeouts in windows APIs are typically u32 milliseconds. To translate, we + // have two pieces to take care of: + // + // * Nanosecond precision is rounded up + // * Greater than u32::MAX milliseconds (50 days) is rounded up to INFINITE (never time out). + dur.as_secs() + .checked_mul(1000) + .and_then(|ms| ms.checked_add((dur.subsec_nanos() as u64) / 1_000_000)) + .and_then( + |ms| { + if dur.subsec_nanos() % 1_000_000 > 0 { + ms.checked_add(1) + } else { + Some(ms) + } + }, + ) + .map(|ms| if ms > u32::MAX as u64 { INFINITE } else { ms as u32 }) + .unwrap_or(INFINITE) +} + +impl Drop for EventLoop { + fn drop(&mut self) { + unsafe { + DestroyWindow(self.window_target.p.thread_msg_target); + } + } +} + +/// Set upper limit for waiting time to avoid overflows. +/// I chose 50 days as a limit because it is used in dur2timeout. +const FIFTY_DAYS: Duration = Duration::from_secs(50_u64 * 24 * 60 * 60); +/// Waitable timers use 100 ns intervals to indicate due time. +/// +/// And there is no point waiting using other ways for such small timings +/// because they are even less precise (can overshoot by few ms). +const MIN_WAIT: Duration = Duration::from_nanos(100); + +fn create_high_resolution_timer() -> Option { + unsafe { + let handle: HANDLE = CreateWaitableTimerExW( + ptr::null(), + ptr::null(), + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, + TIMER_ALL_ACCESS, + ); + // CREATE_WAITABLE_TIMER_HIGH_RESOLUTION is supported only after + // Win10 1803 but it is already default option for rustc + // (std uses it to implement `std::thread::sleep`). + if handle == 0 { + None + } else { + Some(OwnedHandle::from_raw_handle(handle as *mut c_void)) + } + } +} + +/// This function should not return error if parameters are valid +/// but there is no guarantee about that at MSDN docs +/// so we return result of GetLastError if fail. +/// +/// ## Safety +/// +/// timer must be a valid timer handle created by [create_high_resolution_timer]. +/// timeout divided by 100 nanoseconds must be more than 0 and less than i64::MAX. +unsafe fn set_high_resolution_timer(timer: RawHandle, timeout: Duration) -> Result<(), u32> { + const INTERVAL_NS: u32 = MIN_WAIT.subsec_nanos(); + const INTERVALS_IN_SEC: u64 = (Duration::from_secs(1).as_nanos() / INTERVAL_NS as u128) as u64; + let intervals_to_wait: u64 = + timeout.as_secs() * INTERVALS_IN_SEC + u64::from(timeout.subsec_nanos() / INTERVAL_NS); + debug_assert!(intervals_to_wait < i64::MAX as u64, "Must be called with smaller duration",); + // Use negative time to indicate relative time. + let due_time: i64 = -(intervals_to_wait as i64); + unsafe { + let set_result = SetWaitableTimer(timer as HANDLE, &due_time, 0, None, ptr::null(), FALSE); + if set_result != FALSE { + Ok(()) + } else { + Err(GetLastError()) + } + } +} + +/// Implementation detail of [EventLoop::wait_for_messages]. +/// +/// Does actual system-level waiting and doesn't process any messages itself, +/// including winits internal notifications about waiting and new messages arrival. +fn wait_for_messages_impl( + high_resolution_timer: &mut Option, + control_flow: ControlFlow, + timeout: Option, +) { + let timeout = { + let control_flow_timeout = match control_flow { + ControlFlow::Wait => None, + ControlFlow::Poll => Some(Duration::ZERO), + ControlFlow::WaitUntil(wait_deadline) => { + let start = Instant::now(); + Some(wait_deadline.saturating_duration_since(start)) + }, + }; + let timeout = min_timeout(timeout, control_flow_timeout); + if timeout == Some(Duration::ZERO) { + // Do not wait if we don't have time. + return; + } + // Now we decided to wait so need to do some clamping + // to avoid problems with overflow and calling WinAPI with invalid parameters. + timeout + .map(|t| t.min(FIFTY_DAYS)) + // If timeout is less than minimally supported by Windows, + // increase it to that minimum. Who want less than microsecond delays anyway? + .map(|t| t.max(MIN_WAIT)) + }; + + if timeout.is_some() && high_resolution_timer.is_none() { + *high_resolution_timer = create_high_resolution_timer(); + } + + let high_resolution_timer: Option = + high_resolution_timer.as_ref().map(OwnedHandle::as_raw_handle); + + let use_timer: bool; + if let (Some(handle), Some(timeout)) = (high_resolution_timer, timeout) { + let res = unsafe { + // Safety: handle can be Some only if we succeeded in creating high resolution + // timer. We properly clamped timeout so it can be used as argument + // to timer. + set_high_resolution_timer(handle, timeout) + }; + if let Err(error_code) = res { + // We successfully got timer but failed to set it? + // Should be some bug in our code. + tracing::trace!("Failed to set high resolution timer: last error {}", error_code); + use_timer = false; + } else { + use_timer = true; + } + } else { + use_timer = false; + } + + unsafe { + // Either: + // 1. User wants to wait indefinitely if timeout is not set. + // 2. We failed to get and set high resolution timer and we need something instead of it. + let wait_duration_ms = timeout.map(dur2timeout).unwrap_or(INFINITE); + + let (num_handles, raw_handles) = + if use_timer { (1, [high_resolution_timer.unwrap()]) } else { (0, [ptr::null_mut()]) }; + + // We must use `QS_ALLINPUT` to wake on accessibility messages. + let result = MsgWaitForMultipleObjectsEx( + num_handles, + raw_handles.as_ptr() as *const _, + wait_duration_ms, + QS_ALLINPUT, + MWMO_INPUTAVAILABLE, + ); + if result == WAIT_FAILED { + // Well, nothing smart to do in such case. + // Treat it as spurious wake up. + tracing::warn!("Failed to MsgWaitForMultipleObjectsEx: error code {}", GetLastError(),); + } + } +} + +pub(crate) struct EventLoopThreadExecutor { + thread_id: u32, + target_window: HWND, +} + +unsafe impl Send for EventLoopThreadExecutor {} +unsafe impl Sync for EventLoopThreadExecutor {} + +impl EventLoopThreadExecutor { + /// Check to see if we're in the parent event loop's thread. + pub(super) fn in_event_loop_thread(&self) -> bool { + let cur_thread_id = unsafe { GetCurrentThreadId() }; + self.thread_id == cur_thread_id + } + + /// Executes a function in the event loop thread. If we're already in the event loop thread, + /// we just call the function directly. + /// + /// The `Inserted` can be used to inject a `WindowState` for the callback to use. The state is + /// removed automatically if the callback receives a `WM_CLOSE` message for the window. + /// + /// Note that if you are using this to change some property of a window and updating + /// `WindowState` then you should call this within the lock of `WindowState`. Otherwise the + /// events may be sent to the other thread in different order to the one in which you set + /// `WindowState`, leaving them out of sync. + /// + /// Note that we use a FnMut instead of a FnOnce because we're too lazy to create an equivalent + /// to the unstable FnBox. + pub(super) fn execute_in_thread(&self, mut function: F) + where + F: FnMut() + Send + 'static, + { + unsafe { + if self.in_event_loop_thread() { + function(); + } else { + // We double-box because the first box is a fat pointer. + let boxed2: ThreadExecFn = Box::new(Box::new(function)); + + let raw = Box::into_raw(boxed2); + + let res = PostMessageW(self.target_window, EXEC_MSG_ID.get(), raw as usize, 0); + assert!(res != false.into(), "PostMessage failed; is the messages queue full?"); + } + } + } +} + +type ThreadExecFn = Box>; + +pub struct EventLoopProxy { + target_window: HWND, + event_send: Sender, +} +unsafe impl Send for EventLoopProxy {} + +impl Clone for EventLoopProxy { + fn clone(&self) -> Self { + Self { target_window: self.target_window, event_send: self.event_send.clone() } + } +} + +impl EventLoopProxy { + pub fn send_event(&self, event: T) -> Result<(), EventLoopClosed> { + self.event_send + .send(event) + .map(|result| { + unsafe { PostMessageW(self.target_window, USER_EVENT_MSG_ID.get(), 0, 0) }; + result + }) + .map_err(|e| EventLoopClosed(e.0)) + } +} + +/// A lazily-initialized window message ID. +pub struct LazyMessageId { + /// The ID. + id: AtomicU32, + + /// The name of the message. + name: &'static str, +} + +/// An invalid custom window ID. +const INVALID_ID: u32 = 0x0; + +impl LazyMessageId { + /// Create a new `LazyId`. + const fn new(name: &'static str) -> Self { + Self { id: AtomicU32::new(INVALID_ID), name } + } + + /// Get the message ID. + pub fn get(&self) -> u32 { + // Load the ID. + let id = self.id.load(Ordering::Relaxed); + + if id != INVALID_ID { + return id; + } + + // Register the message. + // SAFETY: We are sure that the pointer is a valid C string ending with '\0'. + assert!(self.name.ends_with('\0')); + let new_id = unsafe { RegisterWindowMessageA(self.name.as_ptr()) }; + + assert_ne!( + new_id, + 0, + "RegisterWindowMessageA returned zero for '{}': {}", + self.name, + std::io::Error::last_os_error() + ); + + // Store the new ID. Since `RegisterWindowMessageA` returns the same value for any given + // string, the target value will always either be a). `INVALID_ID` or b). the + // correct ID. Therefore a compare-and-swap operation here (or really any + // consideration) is never necessary. + self.id.store(new_id, Ordering::Relaxed); + + new_id + } +} + +// Message sent by the `EventLoopProxy` when we want to wake up the thread. +// WPARAM and LPARAM are unused. +static USER_EVENT_MSG_ID: LazyMessageId = LazyMessageId::new("Winit::WakeupMsg\0"); +// Message sent when we want to execute a closure in the thread. +// WPARAM contains a Box> that must be retrieved with `Box::from_raw`, +// and LPARAM is unused. +static EXEC_MSG_ID: LazyMessageId = LazyMessageId::new("Winit::ExecMsg\0"); +// Message sent by a `Window` when it wants to be destroyed by the main thread. +// WPARAM and LPARAM are unused. +pub(crate) static DESTROY_MSG_ID: LazyMessageId = LazyMessageId::new("Winit::DestroyMsg\0"); +// WPARAM is a bool specifying the `WindowFlags::MARKER_RETAIN_STATE_ON_SIZE` flag. See the +// documentation in the `window_state` module for more information. +pub(crate) static SET_RETAIN_STATE_ON_SIZE_MSG_ID: LazyMessageId = + LazyMessageId::new("Winit::SetRetainMaximized\0"); +static THREAD_EVENT_TARGET_WINDOW_CLASS: Lazy> = + Lazy::new(|| util::encode_wide("Winit Thread Event Target")); +/// When the taskbar is created, it registers a message with the "TaskbarCreated" string and then +/// broadcasts this message to all top-level windows +pub(crate) static TASKBAR_CREATED: LazyMessageId = LazyMessageId::new("TaskbarCreated\0"); + +fn create_event_target_window() -> HWND { + use windows_sys::Win32::UI::WindowsAndMessaging::{CS_HREDRAW, CS_VREDRAW}; + unsafe { + let class = WNDCLASSEXW { + cbSize: mem::size_of::() as u32, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(thread_event_target_callback), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: util::get_instance_handle(), + hIcon: 0, + hCursor: 0, // must be null in order for cursor state to work properly + hbrBackground: 0, + lpszMenuName: ptr::null(), + lpszClassName: THREAD_EVENT_TARGET_WINDOW_CLASS.as_ptr(), + hIconSm: 0, + }; + + RegisterClassExW(&class); + } + + unsafe { + // WS_EX_TOOLWINDOW prevents this window from ever showing up in the taskbar, which + // we want to avoid. If you remove this style, this window won't show up in the + // taskbar *initially*, but it can show up at some later point. This can sometimes + // happen on its own after several hours have passed, although this has proven + // difficult to reproduce. Alternatively, it can be manually triggered by killing + // `explorer.exe` and then starting the process back up. + // It is unclear why the bug is triggered by waiting for several hours. + let window = CreateWindowExW( + WS_EX_NOACTIVATE | WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW, + THREAD_EVENT_TARGET_WINDOW_CLASS.as_ptr(), + ptr::null(), + WS_OVERLAPPED, + 0, + 0, + 0, + 0, + 0, + 0, + util::get_instance_handle(), + ptr::null(), + ); + + super::set_window_long( + window, + GWL_STYLE, + // The window technically has to be visible to receive WM_PAINT messages (which are + // used for delivering events during resizes), but it isn't displayed to + // the user because of the LAYERED style. + (WS_VISIBLE | WS_POPUP) as isize, + ); + window + } +} + +fn insert_event_target_window_data( + thread_msg_target: HWND, + event_loop_runner: EventLoopRunnerShared, +) { + let userdata = ThreadMsgTargetData { event_loop_runner }; + let input_ptr = Box::into_raw(Box::new(userdata)); + + unsafe { super::set_window_long(thread_msg_target, GWL_USERDATA, input_ptr as isize) }; +} + +/// Capture mouse input, allowing `window` to receive mouse events when the cursor is outside of +/// the window. +unsafe fn capture_mouse(window: HWND, window_state: &mut WindowState) { + window_state.mouse.capture_count += 1; + unsafe { SetCapture(window) }; +} + +/// Release mouse input, stopping windows on this thread from receiving mouse input when the cursor +/// is outside the window. +unsafe fn release_mouse(mut window_state: MutexGuard<'_, WindowState>) { + window_state.mouse.capture_count = window_state.mouse.capture_count.saturating_sub(1); + if window_state.mouse.capture_count == 0 { + // ReleaseCapture() causes a WM_CAPTURECHANGED where we lock the window_state. + drop(window_state); + unsafe { ReleaseCapture() }; + } +} + +fn normalize_pointer_pressure(pressure: u32) -> Option { + match pressure { + 1..=1024 => Some(Force::Normalized(pressure as f64 / 1024.0)), + _ => None, + } +} + +/// Emit a `ModifiersChanged` event whenever modifiers have changed. +/// Returns the current modifier state +fn update_modifiers(window: HWND, userdata: &WindowData) { + use crate::event::WindowEvent::ModifiersChanged; + + let modifiers = { + let mut layouts = LAYOUT_CACHE.lock().unwrap(); + layouts.get_agnostic_mods() + }; + + let mut window_state = userdata.window_state.lock().unwrap(); + if window_state.modifiers_state != modifiers { + window_state.modifiers_state = modifiers; + + // Drop lock + drop(window_state); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: ModifiersChanged(modifiers.into()), + }); + } +} + +unsafe fn gain_active_focus(window: HWND, userdata: &WindowData) { + use crate::event::WindowEvent::Focused; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: Focused(true), + }); +} + +unsafe fn lose_active_focus(window: HWND, userdata: &WindowData) { + use crate::event::WindowEvent::{Focused, ModifiersChanged}; + + userdata.window_state_lock().modifiers_state = ModifiersState::empty(); + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: ModifiersChanged(ModifiersState::empty().into()), + }); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: Focused(false), + }); +} + +/// Any window whose callback is configured to this function will have its events propagated +/// through the events loop of the thread the window was created in. +// This is the callback that is called by `DispatchMessage` in the events loop. +// +// Returning 0 tells the Win32 API that the message has been processed. +// FIXME: detect WM_DWMCOMPOSITIONCHANGED and call DwmEnableBlurBehindWindow if necessary +pub(super) unsafe extern "system" fn public_window_callback( + window: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + let userdata = unsafe { super::get_window_long(window, GWL_USERDATA) }; + + let userdata_ptr = match (userdata, msg) { + (0, WM_NCCREATE) => { + let createstruct = unsafe { &mut *(lparam as *mut CREATESTRUCTW) }; + let initdata = unsafe { &mut *(createstruct.lpCreateParams as *mut InitData<'_>) }; + + let result = match unsafe { initdata.on_nccreate(window) } { + Some(userdata) => unsafe { + super::set_window_long(window, GWL_USERDATA, userdata as _); + DefWindowProcW(window, msg, wparam, lparam) + }, + None => -1, // failed to create the window + }; + + return result; + }, + // Getting here should quite frankly be impossible, + // but we'll make window creation fail here just in case. + (0, WM_CREATE) => return -1, + (_, WM_CREATE) => unsafe { + let createstruct = &mut *(lparam as *mut CREATESTRUCTW); + let initdata = createstruct.lpCreateParams; + let initdata = &mut *(initdata as *mut InitData<'_>); + + initdata.on_create(); + return DefWindowProcW(window, msg, wparam, lparam); + }, + (0, _) => return unsafe { DefWindowProcW(window, msg, wparam, lparam) }, + _ => userdata as *mut WindowData, + }; + + let (result, userdata_removed, recurse_depth) = { + let userdata = unsafe { &*(userdata_ptr) }; + + userdata.recurse_depth.set(userdata.recurse_depth.get() + 1); + + let result = unsafe { public_window_callback_inner(window, msg, wparam, lparam, userdata) }; + + let userdata_removed = userdata.userdata_removed.get(); + let recurse_depth = userdata.recurse_depth.get() - 1; + userdata.recurse_depth.set(recurse_depth); + + (result, userdata_removed, recurse_depth) + }; + + if userdata_removed && recurse_depth == 0 { + drop(unsafe { Box::from_raw(userdata_ptr) }); + } + + result +} + +unsafe fn public_window_callback_inner( + window: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + userdata: &WindowData, +) -> LRESULT { + let mut result = ProcResult::DefWindowProc(wparam); + + // Send new modifiers before sending key events. + let mods_changed_callback = || match msg { + WM_KEYDOWN | WM_SYSKEYDOWN | WM_KEYUP | WM_SYSKEYUP => { + update_modifiers(window, userdata); + result = ProcResult::Value(0); + }, + _ => (), + }; + userdata + .event_loop_runner + .catch_unwind(mods_changed_callback) + .unwrap_or_else(|| result = ProcResult::Value(-1)); + + let keyboard_callback = || { + use crate::event::WindowEvent::KeyboardInput; + let events = + userdata.key_event_builder.process_message(window, msg, wparam, lparam, &mut result); + for event in events { + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: KeyboardInput { + device_id: DEVICE_ID, + event: event.event, + is_synthetic: event.is_synthetic, + }, + }); + } + }; + userdata + .event_loop_runner + .catch_unwind(keyboard_callback) + .unwrap_or_else(|| result = ProcResult::Value(-1)); + + // I decided to bind the closure to `callback` and pass it to catch_unwind rather than passing + // the closure to catch_unwind directly so that the match body indentation wouldn't change and + // the git blame and history would be preserved. + let callback = || match msg { + WM_NCCALCSIZE => { + let window_flags = userdata.window_state_lock().window_flags; + if wparam == 0 || window_flags.contains(WindowFlags::MARKER_DECORATIONS) { + result = ProcResult::DefWindowProc(wparam); + return; + } + + let params = unsafe { &mut *(lparam as *mut NCCALCSIZE_PARAMS) }; + + if util::is_maximized(window) { + // Limit the window size when maximized to the current monitor. + // Otherwise it would include the non-existent decorations. + // + // Use `MonitorFromRect` instead of `MonitorFromWindow` to select + // the correct monitor here. + // See https://github.com/MicrosoftEdge/WebView2Feedback/issues/2549 + let monitor = unsafe { MonitorFromRect(¶ms.rgrc[0], MONITOR_DEFAULTTONULL) }; + if let Ok(monitor_info) = monitor::get_monitor_info(monitor) { + params.rgrc[0] = monitor_info.monitorInfo.rcWork; + } + } else if window_flags.contains(WindowFlags::MARKER_UNDECORATED_SHADOW) { + // Extend the client area to cover the whole non-client area. + // https://docs.microsoft.com/en-us/windows/win32/winmsg/wm-nccalcsize#remarks + // + // HACK(msiglreith): To add the drop shadow we slightly tweak the non-client area. + // This leads to a small black 1px border on the top. Adding a margin manually + // on all 4 borders would result in the caption getting drawn by the DWM. + // + // Another option would be to allow the DWM to paint inside the client area. + // Unfortunately this results in janky resize behavior, where the compositor is + // ahead of the window surface. Currently, there seems no option to achieve this + // with the Windows API. + params.rgrc[0].top += 1; + params.rgrc[0].bottom += 1; + } + + result = ProcResult::Value(0); + }, + + WM_ENTERSIZEMOVE => { + userdata + .window_state_lock() + .set_window_flags_in_place(|f| f.insert(WindowFlags::MARKER_IN_SIZE_MOVE)); + result = ProcResult::Value(0); + }, + + WM_EXITSIZEMOVE => { + let mut state = userdata.window_state_lock(); + if state.dragging { + state.dragging = false; + unsafe { PostMessageW(window, WM_LBUTTONUP, 0, lparam) }; + } + + state.set_window_flags_in_place(|f| f.remove(WindowFlags::MARKER_IN_SIZE_MOVE)); + result = ProcResult::Value(0); + }, + + WM_NCLBUTTONDOWN => { + if wparam == HTCAPTION as _ { + // Prevent the user event loop from pausing when left clicking the title bar. + // + // When the user interacts with the title bar, Windows enters the modal event + // loop. Currently, a left click causes a pause for about 500ms. Sending a dummy + // mouse-move event seems to cancel the modal loop early, preventing the pause. + // The application will never see this dummy event. + // + // The mouse coordinates are encoded into the lparam value, however the WM_MOUSEMOVE + // event is not using the same coordinate system of the WM_NCLBUTTONDOWN event. + // One uses client-area coordinates and the other is screen-coordinates. In any + // case, passing the lparam as-is with the dummy event does not seem the cancel + // the modal loop. + // + // However, passing in a value of 0 has been observed to always cancel the pause. + // + // Other notes: + // + // For some unknown reason, the cursor will blink when clicking the title bar. + // Cancelling the modal loop early causes the blink to happen *immediately*. + // Otherwise, the blank happens *after* the pause. + // + // When right-click the title bar, the system window menu is presented to the user, + // and the modal event loop begins. This dummy event does *not* prevent the freeze + // in the main event loop caused by that popup menu. + let lparam = 0; + unsafe { PostMessageW(window, WM_MOUSEMOVE, 0, lparam) }; + } + result = ProcResult::DefWindowProc(wparam); + }, + + WM_CLOSE => { + use crate::event::WindowEvent::CloseRequested; + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: CloseRequested, + }); + result = ProcResult::Value(0); + }, + + WM_DESTROY => { + use crate::event::WindowEvent::Destroyed; + unsafe { RevokeDragDrop(window) }; + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: Destroyed, + }); + result = ProcResult::Value(0); + }, + + WM_NCDESTROY => { + unsafe { super::set_window_long(window, GWL_USERDATA, 0) }; + userdata.userdata_removed.set(true); + result = ProcResult::Value(0); + }, + + WM_PAINT => { + userdata.window_state_lock().redraw_requested = + userdata.event_loop_runner.should_buffer(); + + // We'll buffer only in response to `UpdateWindow`, if win32 decides to redraw the + // window outside the normal flow of the event loop. This way mark event as handled + // and request a normal redraw with `RedrawWindow`. + if !userdata.event_loop_runner.should_buffer() { + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::RedrawRequested, + }); + } + + // NOTE: calling `RedrawWindow` during `WM_PAINT` does nothing, since to mark + // `WM_PAINT` as handled we should call the `DefWindowProcW`. Call it and check whether + // user asked for redraw during `RedrawRequested` event handling and request it again + // after marking `WM_PAINT` as handled. + result = ProcResult::Value(unsafe { DefWindowProcW(window, msg, wparam, lparam) }); + if std::mem::take(&mut userdata.window_state_lock().redraw_requested) { + unsafe { RedrawWindow(window, ptr::null(), 0, RDW_INTERNALPAINT) }; + } + }, + WM_WINDOWPOSCHANGING => { + let mut window_state = userdata.window_state_lock(); + if let Some(ref mut fullscreen) = window_state.fullscreen { + let window_pos = unsafe { &mut *(lparam as *mut WINDOWPOS) }; + let new_rect = RECT { + left: window_pos.x, + top: window_pos.y, + right: window_pos.x + window_pos.cx, + bottom: window_pos.y + window_pos.cy, + }; + + const NOMOVE_OR_NOSIZE: u32 = SWP_NOMOVE | SWP_NOSIZE; + + let new_rect = if window_pos.flags & NOMOVE_OR_NOSIZE != 0 { + let cur_rect = util::WindowArea::Outer.get_rect(window).expect( + "Unexpected GetWindowRect failure; please report this error to \ + rust-windowing/winit", + ); + + match window_pos.flags & NOMOVE_OR_NOSIZE { + NOMOVE_OR_NOSIZE => None, + + SWP_NOMOVE => Some(RECT { + left: cur_rect.left, + top: cur_rect.top, + right: cur_rect.left + window_pos.cx, + bottom: cur_rect.top + window_pos.cy, + }), + + SWP_NOSIZE => Some(RECT { + left: window_pos.x, + top: window_pos.y, + right: window_pos.x - cur_rect.left + cur_rect.right, + bottom: window_pos.y - cur_rect.top + cur_rect.bottom, + }), + + _ => unreachable!(), + } + } else { + Some(new_rect) + }; + + if let Some(new_rect) = new_rect { + let new_monitor = unsafe { MonitorFromRect(&new_rect, MONITOR_DEFAULTTONULL) }; + match fullscreen { + Fullscreen::Borderless(ref mut fullscreen_monitor) => { + if new_monitor != 0 + && fullscreen_monitor + .as_ref() + .map(|monitor| new_monitor != monitor.hmonitor()) + .unwrap_or(true) + { + if let Ok(new_monitor_info) = monitor::get_monitor_info(new_monitor) + { + let new_monitor_rect = new_monitor_info.monitorInfo.rcMonitor; + window_pos.x = new_monitor_rect.left; + window_pos.y = new_monitor_rect.top; + window_pos.cx = new_monitor_rect.right - new_monitor_rect.left; + window_pos.cy = new_monitor_rect.bottom - new_monitor_rect.top; + } + *fullscreen_monitor = Some(MonitorHandle::new(new_monitor)); + } + }, + Fullscreen::Exclusive(ref video_mode) => { + let old_monitor = video_mode.monitor.hmonitor(); + if let Ok(old_monitor_info) = monitor::get_monitor_info(old_monitor) { + let old_monitor_rect = old_monitor_info.monitorInfo.rcMonitor; + window_pos.x = old_monitor_rect.left; + window_pos.y = old_monitor_rect.top; + window_pos.cx = old_monitor_rect.right - old_monitor_rect.left; + window_pos.cy = old_monitor_rect.bottom - old_monitor_rect.top; + } + }, + } + } + } + + result = ProcResult::Value(0); + }, + + // WM_MOVE supplies client area positions, so we send Moved here instead. + WM_WINDOWPOSCHANGED => { + use crate::event::WindowEvent::Moved; + + let windowpos = lparam as *const WINDOWPOS; + if unsafe { (*windowpos).flags & SWP_NOMOVE != SWP_NOMOVE } { + let physical_position = + unsafe { PhysicalPosition::new((*windowpos).x, (*windowpos).y) }; + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: Moved(physical_position), + }); + } + + // This is necessary for us to still get sent WM_SIZE. + result = ProcResult::DefWindowProc(wparam); + }, + + WM_SIZE => { + use crate::event::WindowEvent::Resized; + let w = super::loword(lparam as u32) as u32; + let h = super::hiword(lparam as u32) as u32; + + let physical_size = PhysicalSize::new(w, h); + let event = Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: Resized(physical_size), + }; + + { + let mut w = userdata.window_state_lock(); + // See WindowFlags::MARKER_RETAIN_STATE_ON_SIZE docs for info on why this `if` check + // exists. + if !w.window_flags().contains(WindowFlags::MARKER_RETAIN_STATE_ON_SIZE) { + let maximized = wparam == SIZE_MAXIMIZED as usize; + w.set_window_flags_in_place(|f| f.set(WindowFlags::MAXIMIZED, maximized)); + } + } + userdata.send_event(event); + result = ProcResult::Value(0); + }, + + WM_SIZING => { + /// Calculate the amount to add to round `value` to the nearest multiple of `increment`. + fn snap_to_nearest_increment_delta(value: i32, increment: i32) -> i32 { + let half_one = increment / 2; + let half_two = increment - half_one; + half_one - (value - half_two) % increment + } + + let scale_factor = userdata.window_state_lock().scale_factor; + let Some(inc) = userdata + .window_state_lock() + .resize_increments + .map(|inc| inc.to_physical(scale_factor)) + .filter(|inc| inc.width > 0 && inc.height > 0) + else { + result = ProcResult::Value(0); + return; + }; + + let side = wparam as u32; + // The desired new size of the window, decorations included. + let rect = unsafe { &mut *(lparam as *mut RECT) }; + + // We need to calculate the dimensions of the window decorations to get the true + // size of the window's contents + let adj_rect = userdata + .window_state_lock() + .window_flags + .adjust_rect(window, *rect) + .unwrap_or(*rect); + let deco_width = rect.left - adj_rect.left + adj_rect.right - rect.right; + let deco_height = rect.top - adj_rect.top + adj_rect.bottom - rect.bottom; + + let width = rect.right - rect.left - deco_width; + let height = rect.bottom - rect.top - deco_height; + + let mut width_delta = snap_to_nearest_increment_delta(width, inc.width); + let mut height_delta = snap_to_nearest_increment_delta(height, inc.height); + + // Windows won't bound check the value of `rect` after we're done here, so we + // have to check manually. If the width/height we snap to would go out of bounds, just + // set it equal to the min/max bound. + let min_size = + userdata.window_state_lock().min_size.map(|size| size.to_physical(scale_factor)); + let max_size = + userdata.window_state_lock().max_size.map(|size| size.to_physical(scale_factor)); + let final_width = width + width_delta; + let final_height = height + height_delta; + if let Some(min_size) = min_size { + if final_width < min_size.width { + width_delta += min_size.width - final_width; + } + if final_height < min_size.height { + height_delta += min_size.height - final_height; + } + } + if let Some(max_size) = max_size { + if final_width > max_size.width { + width_delta -= final_width - max_size.width; + } + if final_height > max_size.height { + height_delta -= final_height - max_size.height; + } + } + + match side { + WMSZ_LEFT | WMSZ_BOTTOMLEFT | WMSZ_TOPLEFT => { + rect.left -= width_delta; + }, + WMSZ_RIGHT | WMSZ_BOTTOMRIGHT | WMSZ_TOPRIGHT => { + rect.right += width_delta; + }, + _ => {}, + } + + match side { + WMSZ_TOP | WMSZ_TOPLEFT | WMSZ_TOPRIGHT => { + rect.top -= height_delta; + }, + WMSZ_BOTTOM | WMSZ_BOTTOMLEFT | WMSZ_BOTTOMRIGHT => { + rect.bottom += height_delta; + }, + _ => {}, + } + + result = ProcResult::DefWindowProc(wparam); + }, + + WM_MENUCHAR => { + result = ProcResult::Value((MNC_CLOSE << 16) as isize); + }, + + WM_IME_STARTCOMPOSITION => { + let ime_allowed = userdata.window_state_lock().ime_allowed; + if ime_allowed { + userdata.window_state_lock().ime_state = ImeState::Enabled; + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Ime(Ime::Enabled), + }); + } + + result = ProcResult::DefWindowProc(wparam); + }, + + WM_IME_COMPOSITION => { + let ime_allowed_and_composing = { + let w = userdata.window_state_lock(); + w.ime_allowed && w.ime_state != ImeState::Disabled + }; + // Windows Hangul IME sends WM_IME_COMPOSITION after WM_IME_ENDCOMPOSITION, so + // check whether composing. + if ime_allowed_and_composing { + let ime_context = unsafe { ImeContext::current(window) }; + + if lparam == 0 { + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Ime(Ime::Preedit(String::new(), None)), + }); + } + + // Google Japanese Input and ATOK have both flags, so + // first, receive composing result if exist. + if (lparam as u32 & GCS_RESULTSTR) != 0 { + if let Some(text) = unsafe { ime_context.get_composed_text() } { + userdata.window_state_lock().ime_state = ImeState::Enabled; + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Ime(Ime::Preedit(String::new(), None)), + }); + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Ime(Ime::Commit(text)), + }); + } + } + + // Next, receive preedit range for next composing if exist. + if (lparam as u32 & GCS_COMPSTR) != 0 { + if let Some((text, first, last)) = + unsafe { ime_context.get_composing_text_and_cursor() } + { + userdata.window_state_lock().ime_state = ImeState::Preedit; + let cursor_range = first.map(|f| (f, last.unwrap_or(f))); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Ime(Ime::Preedit(text, cursor_range)), + }); + } + } + } + + // Not calling DefWindowProc to hide composing text drawn by IME. + result = ProcResult::Value(0); + }, + + WM_IME_ENDCOMPOSITION => { + let ime_allowed_or_composing = { + let w = userdata.window_state_lock(); + w.ime_allowed || w.ime_state != ImeState::Disabled + }; + if ime_allowed_or_composing { + if userdata.window_state_lock().ime_state == ImeState::Preedit { + // Windows Hangul IME sends WM_IME_COMPOSITION after WM_IME_ENDCOMPOSITION, so + // trying receiving composing result and commit if exists. + let ime_context = unsafe { ImeContext::current(window) }; + if let Some(text) = unsafe { ime_context.get_composed_text() } { + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Ime(Ime::Preedit(String::new(), None)), + }); + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Ime(Ime::Commit(text)), + }); + } + } + + userdata.window_state_lock().ime_state = ImeState::Disabled; + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Ime(Ime::Disabled), + }); + } + + result = ProcResult::DefWindowProc(wparam); + }, + + WM_IME_SETCONTEXT => { + // IME UI visibility flags are in lparam. + let lparam = lparam & !(ISC_SHOWUICOMPOSITIONWINDOW as isize); + result = ProcResult::Value(unsafe { DefWindowProcW(window, msg, wparam, lparam) }); + }, + + // this is necessary for us to maintain minimize/restore state + WM_SYSCOMMAND => { + if wparam == SC_RESTORE as usize { + let mut w = userdata.window_state_lock(); + w.set_window_flags_in_place(|f| f.set(WindowFlags::MINIMIZED, false)); + } + if wparam == SC_MINIMIZE as usize { + let mut w = userdata.window_state_lock(); + w.set_window_flags_in_place(|f| f.set(WindowFlags::MINIMIZED, true)); + } + // Send `WindowEvent::Minimized` here if we decide to implement one + + if wparam == SC_SCREENSAVE as usize { + let window_state = userdata.window_state_lock(); + if window_state.fullscreen.is_some() { + result = ProcResult::Value(0); + return; + } + } + + result = ProcResult::DefWindowProc(wparam); + }, + + WM_MOUSEMOVE => { + use crate::event::WindowEvent::{CursorEntered, CursorLeft, CursorMoved}; + + let x = super::get_x_lparam(lparam as u32) as i32; + let y = super::get_y_lparam(lparam as u32) as i32; + let position = PhysicalPosition::new(x as f64, y as f64); + + let cursor_moved; + { + let mut w = userdata.window_state_lock(); + let mouse_was_inside_window = + w.mouse.cursor_flags().contains(CursorFlags::IN_WINDOW); + + match get_pointer_move_kind(window, mouse_was_inside_window, x, y) { + PointerMoveKind::Enter => { + w.mouse + .set_cursor_flags(window, |f| f.set(CursorFlags::IN_WINDOW, true)) + .ok(); + + drop(w); + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: CursorEntered { device_id: DEVICE_ID }, + }); + + // Calling TrackMouseEvent in order to receive mouse leave events. + unsafe { + TrackMouseEvent(&mut TRACKMOUSEEVENT { + cbSize: mem::size_of::() as u32, + dwFlags: TME_LEAVE, + hwndTrack: window, + dwHoverTime: HOVER_DEFAULT, + }) + }; + }, + PointerMoveKind::Leave => { + w.mouse + .set_cursor_flags(window, |f| f.set(CursorFlags::IN_WINDOW, false)) + .ok(); + + drop(w); + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: CursorLeft { device_id: DEVICE_ID }, + }); + }, + PointerMoveKind::None => drop(w), + } + + // handle spurious WM_MOUSEMOVE messages + // see https://devblogs.microsoft.com/oldnewthing/20031001-00/?p=42343 + // and http://debugandconquer.blogspot.com/2015/08/the-cause-of-spurious-mouse-move.html + let mut w = userdata.window_state_lock(); + cursor_moved = w.mouse.last_position != Some(position); + w.mouse.last_position = Some(position); + } + + if cursor_moved { + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: CursorMoved { device_id: DEVICE_ID, position }, + }); + } + + result = ProcResult::Value(0); + }, + + WM_MOUSELEAVE => { + use crate::event::WindowEvent::CursorLeft; + { + let mut w = userdata.window_state_lock(); + w.mouse.set_cursor_flags(window, |f| f.set(CursorFlags::IN_WINDOW, false)).ok(); + } + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: CursorLeft { device_id: DEVICE_ID }, + }); + + result = ProcResult::Value(0); + }, + + WM_MOUSEWHEEL => { + use crate::event::MouseScrollDelta::LineDelta; + + let value = (wparam >> 16) as i16; + let value = value as f32 / WHEEL_DELTA as f32; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::MouseWheel { + device_id: DEVICE_ID, + delta: LineDelta(0.0, value), + phase: TouchPhase::Moved, + }, + }); + + result = ProcResult::Value(0); + }, + + WM_MOUSEHWHEEL => { + use crate::event::MouseScrollDelta::LineDelta; + + let value = (wparam >> 16) as i16; + let value = -value as f32 / WHEEL_DELTA as f32; // NOTE: inverted! See https://github.com/rust-windowing/winit/pull/2105/ + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::MouseWheel { + device_id: DEVICE_ID, + delta: LineDelta(value, 0.0), + phase: TouchPhase::Moved, + }, + }); + + result = ProcResult::Value(0); + }, + + WM_KEYDOWN | WM_SYSKEYDOWN => { + if msg == WM_SYSKEYDOWN { + result = ProcResult::DefWindowProc(wparam); + } + }, + + WM_KEYUP | WM_SYSKEYUP => { + if msg == WM_SYSKEYUP && unsafe { GetMenu(window) != 0 } { + // let Windows handle event if the window has a native menu, a modal event loop + // is started here on Alt key up. + result = ProcResult::DefWindowProc(wparam); + } + }, + + WM_LBUTTONDOWN => { + use crate::event::ElementState::Pressed; + use crate::event::MouseButton::Left; + use crate::event::WindowEvent::MouseInput; + + unsafe { capture_mouse(window, &mut userdata.window_state_lock()) }; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: MouseInput { device_id: DEVICE_ID, state: Pressed, button: Left }, + }); + result = ProcResult::Value(0); + }, + + WM_LBUTTONUP => { + use crate::event::ElementState::Released; + use crate::event::MouseButton::Left; + use crate::event::WindowEvent::MouseInput; + + unsafe { release_mouse(userdata.window_state_lock()) }; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: MouseInput { device_id: DEVICE_ID, state: Released, button: Left }, + }); + result = ProcResult::Value(0); + }, + + WM_RBUTTONDOWN => { + use crate::event::ElementState::Pressed; + use crate::event::MouseButton::Right; + use crate::event::WindowEvent::MouseInput; + + unsafe { capture_mouse(window, &mut userdata.window_state_lock()) }; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: MouseInput { device_id: DEVICE_ID, state: Pressed, button: Right }, + }); + result = ProcResult::Value(0); + }, + + WM_RBUTTONUP => { + use crate::event::ElementState::Released; + use crate::event::MouseButton::Right; + use crate::event::WindowEvent::MouseInput; + + unsafe { release_mouse(userdata.window_state_lock()) }; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: MouseInput { device_id: DEVICE_ID, state: Released, button: Right }, + }); + result = ProcResult::Value(0); + }, + + WM_MBUTTONDOWN => { + use crate::event::ElementState::Pressed; + use crate::event::MouseButton::Middle; + use crate::event::WindowEvent::MouseInput; + + unsafe { capture_mouse(window, &mut userdata.window_state_lock()) }; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: MouseInput { device_id: DEVICE_ID, state: Pressed, button: Middle }, + }); + result = ProcResult::Value(0); + }, + + WM_MBUTTONUP => { + use crate::event::ElementState::Released; + use crate::event::MouseButton::Middle; + use crate::event::WindowEvent::MouseInput; + + unsafe { release_mouse(userdata.window_state_lock()) }; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: MouseInput { device_id: DEVICE_ID, state: Released, button: Middle }, + }); + result = ProcResult::Value(0); + }, + + WM_XBUTTONDOWN => { + use crate::event::ElementState::Pressed; + use crate::event::MouseButton::{Back, Forward, Other}; + use crate::event::WindowEvent::MouseInput; + let xbutton = super::get_xbutton_wparam(wparam as u32); + + unsafe { capture_mouse(window, &mut userdata.window_state_lock()) }; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: MouseInput { + device_id: DEVICE_ID, + state: Pressed, + button: match xbutton { + 1 => Back, + 2 => Forward, + _ => Other(xbutton), + }, + }, + }); + result = ProcResult::Value(0); + }, + + WM_XBUTTONUP => { + use crate::event::ElementState::Released; + use crate::event::MouseButton::{Back, Forward, Other}; + use crate::event::WindowEvent::MouseInput; + let xbutton = super::get_xbutton_wparam(wparam as u32); + + unsafe { release_mouse(userdata.window_state_lock()) }; + + update_modifiers(window, userdata); + + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: MouseInput { + device_id: DEVICE_ID, + state: Released, + button: match xbutton { + 1 => Back, + 2 => Forward, + _ => Other(xbutton), + }, + }, + }); + result = ProcResult::Value(0); + }, + + WM_CAPTURECHANGED => { + // lparam here is a handle to the window which is gaining mouse capture. + // If it is the same as our window, then we're essentially retaining the capture. This + // can happen if `SetCapture` is called on our window when it already has the mouse + // capture. + if lparam != window { + userdata.window_state_lock().mouse.capture_count = 0; + } + result = ProcResult::Value(0); + }, + + WM_TOUCH => { + let pcount = super::loword(wparam as u32) as usize; + let mut inputs = Vec::with_capacity(pcount); + let htouch = lparam; + if unsafe { + GetTouchInputInfo( + htouch, + pcount as u32, + inputs.as_mut_ptr(), + mem::size_of::() as i32, + ) > 0 + } { + unsafe { inputs.set_len(pcount) }; + for input in &inputs { + let mut location = POINT { x: input.x / 100, y: input.y / 100 }; + + if unsafe { ScreenToClient(window, &mut location) } == false.into() { + continue; + } + + let x = location.x as f64 + (input.x % 100) as f64 / 100f64; + let y = location.y as f64 + (input.y % 100) as f64 / 100f64; + let location = PhysicalPosition::new(x, y); + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Touch(Touch { + phase: if util::has_flag(input.dwFlags, TOUCHEVENTF_DOWN) { + TouchPhase::Started + } else if util::has_flag(input.dwFlags, TOUCHEVENTF_UP) { + TouchPhase::Ended + } else if util::has_flag(input.dwFlags, TOUCHEVENTF_MOVE) { + TouchPhase::Moved + } else { + continue; + }, + location, + force: None, // WM_TOUCH doesn't support pressure information + id: input.dwID as u64, + device_id: DEVICE_ID, + }), + }); + } + } + unsafe { CloseTouchInputHandle(htouch) }; + result = ProcResult::Value(0); + }, + + WM_POINTERDOWN | WM_POINTERUPDATE | WM_POINTERUP => { + if let ( + Some(GetPointerFrameInfoHistory), + Some(SkipPointerFrameMessages), + Some(GetPointerDeviceRects), + ) = ( + *util::GET_POINTER_FRAME_INFO_HISTORY, + *util::SKIP_POINTER_FRAME_MESSAGES, + *util::GET_POINTER_DEVICE_RECTS, + ) { + let pointer_id = super::loword(wparam as u32) as u32; + let mut entries_count = 0u32; + let mut pointers_count = 0u32; + if unsafe { + GetPointerFrameInfoHistory( + pointer_id, + &mut entries_count, + &mut pointers_count, + ptr::null_mut(), + ) + } == false.into() + { + result = ProcResult::Value(0); + return; + } + + let pointer_info_count = (entries_count * pointers_count) as usize; + let mut pointer_infos = Vec::with_capacity(pointer_info_count); + if unsafe { + GetPointerFrameInfoHistory( + pointer_id, + &mut entries_count, + &mut pointers_count, + pointer_infos.as_mut_ptr(), + ) + } == false.into() + { + result = ProcResult::Value(0); + return; + } + unsafe { pointer_infos.set_len(pointer_info_count) }; + + // https://docs.microsoft.com/en-us/windows/desktop/api/winuser/nf-winuser-getpointerframeinfohistory + // The information retrieved appears in reverse chronological order, with the most + // recent entry in the first row of the returned array + for pointer_info in pointer_infos.iter().rev() { + let mut device_rect = mem::MaybeUninit::uninit(); + let mut display_rect = mem::MaybeUninit::uninit(); + + if unsafe { + GetPointerDeviceRects( + pointer_info.sourceDevice, + device_rect.as_mut_ptr(), + display_rect.as_mut_ptr(), + ) + } == false.into() + { + continue; + } + + let device_rect = unsafe { device_rect.assume_init() }; + let display_rect = unsafe { display_rect.assume_init() }; + + // For the most precise himetric to pixel conversion we calculate the ratio + // between the resolution of the display device (pixel) and + // the touch device (himetric). + let himetric_to_pixel_ratio_x = (display_rect.right - display_rect.left) as f64 + / (device_rect.right - device_rect.left) as f64; + let himetric_to_pixel_ratio_y = (display_rect.bottom - display_rect.top) as f64 + / (device_rect.bottom - device_rect.top) as f64; + + // ptHimetricLocation's origin is 0,0 even on multi-monitor setups. + // On multi-monitor setups we need to translate the himetric location to the + // rect of the display device it's attached to. + let x = display_rect.left as f64 + + pointer_info.ptHimetricLocation.x as f64 * himetric_to_pixel_ratio_x; + let y = display_rect.top as f64 + + pointer_info.ptHimetricLocation.y as f64 * himetric_to_pixel_ratio_y; + + let mut location = POINT { x: x.floor() as i32, y: y.floor() as i32 }; + + if unsafe { ScreenToClient(window, &mut location) } == false.into() { + continue; + } + + let force = match pointer_info.pointerType { + PT_TOUCH => { + let mut touch_info = mem::MaybeUninit::uninit(); + util::GET_POINTER_TOUCH_INFO.and_then(|GetPointerTouchInfo| { + match unsafe { + GetPointerTouchInfo( + pointer_info.pointerId, + touch_info.as_mut_ptr(), + ) + } { + 0 => None, + _ => normalize_pointer_pressure(unsafe { + touch_info.assume_init().pressure + }), + } + }) + }, + PT_PEN => { + let mut pen_info = mem::MaybeUninit::uninit(); + util::GET_POINTER_PEN_INFO.and_then(|GetPointerPenInfo| { + match unsafe { + GetPointerPenInfo(pointer_info.pointerId, pen_info.as_mut_ptr()) + } { + 0 => None, + _ => normalize_pointer_pressure(unsafe { + pen_info.assume_init().pressure + }), + } + }) + }, + _ => None, + }; + + let x = location.x as f64 + x.fract(); + let y = location.y as f64 + y.fract(); + let location = PhysicalPosition::new(x, y); + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: WindowEvent::Touch(Touch { + phase: if util::has_flag(pointer_info.pointerFlags, POINTER_FLAG_DOWN) { + TouchPhase::Started + } else if util::has_flag(pointer_info.pointerFlags, POINTER_FLAG_UP) { + TouchPhase::Ended + } else if util::has_flag(pointer_info.pointerFlags, POINTER_FLAG_UPDATE) + { + TouchPhase::Moved + } else { + continue; + }, + location, + force, + id: pointer_info.pointerId as u64, + device_id: DEVICE_ID, + }), + }); + } + + unsafe { SkipPointerFrameMessages(pointer_id) }; + } + result = ProcResult::Value(0); + }, + + WM_NCACTIVATE => { + let is_active = wparam != false.into(); + let active_focus_changed = userdata.window_state_lock().set_active(is_active); + if active_focus_changed { + if is_active { + unsafe { gain_active_focus(window, userdata) }; + } else { + unsafe { lose_active_focus(window, userdata) }; + } + } + result = ProcResult::DefWindowProc(wparam); + }, + + WM_SETFOCUS => { + let active_focus_changed = userdata.window_state_lock().set_focused(true); + if active_focus_changed { + unsafe { gain_active_focus(window, userdata) }; + } + result = ProcResult::Value(0); + }, + + WM_KILLFOCUS => { + let active_focus_changed = userdata.window_state_lock().set_focused(false); + if active_focus_changed { + unsafe { lose_active_focus(window, userdata) }; + } + result = ProcResult::Value(0); + }, + + WM_SETCURSOR => { + let set_cursor_to = { + let window_state = userdata.window_state_lock(); + // The return value for the preceding `WM_NCHITTEST` message is conveniently + // provided through the low-order word of lParam. We use that here since + // `WM_MOUSEMOVE` seems to come after `WM_SETCURSOR` for a given cursor movement. + let in_client_area = super::loword(lparam as u32) as u32 == HTCLIENT; + if in_client_area { + Some(window_state.mouse.selected_cursor.clone()) + } else { + None + } + }; + + match set_cursor_to { + Some(selected_cursor) => { + let hcursor = match selected_cursor { + SelectedCursor::Named(cursor_icon) => unsafe { + LoadCursorW(0, util::to_windows_cursor(cursor_icon)) + }, + SelectedCursor::Custom(cursor) => cursor.as_raw_handle(), + }; + unsafe { SetCursor(hcursor) }; + result = ProcResult::Value(0); + }, + None => result = ProcResult::DefWindowProc(wparam), + } + }, + + WM_GETMINMAXINFO => { + let mmi = lparam as *mut MINMAXINFO; + + let window_state = userdata.window_state_lock(); + let window_flags = window_state.window_flags; + + if window_state.min_size.is_some() || window_state.max_size.is_some() { + if let Some(min_size) = window_state.min_size { + let min_size = min_size.to_physical(window_state.scale_factor); + let (width, height): (u32, u32) = + window_flags.adjust_size(window, min_size).into(); + unsafe { (*mmi).ptMinTrackSize = POINT { x: width as i32, y: height as i32 } }; + } + if let Some(max_size) = window_state.max_size { + let max_size = max_size.to_physical(window_state.scale_factor); + let (width, height): (u32, u32) = + window_flags.adjust_size(window, max_size).into(); + unsafe { (*mmi).ptMaxTrackSize = POINT { x: width as i32, y: height as i32 } }; + } + } + + result = ProcResult::Value(0); + }, + + // Only sent on Windows 8.1 or newer. On Windows 7 and older user has to log out to change + // DPI, therefore all applications are closed while DPI is changing. + WM_DPICHANGED => { + use crate::event::WindowEvent::ScaleFactorChanged; + + // This message actually provides two DPI values - x and y. However MSDN says that + // "you only need to use either the X-axis or the Y-axis value when scaling your + // application since they are the same". + // https://msdn.microsoft.com/en-us/library/windows/desktop/dn312083(v=vs.85).aspx + let new_dpi_x = super::loword(wparam as u32) as u32; + let new_scale_factor = dpi_to_scale_factor(new_dpi_x); + let old_scale_factor: f64; + + let (allow_resize, window_flags) = { + let mut window_state = userdata.window_state_lock(); + old_scale_factor = window_state.scale_factor; + window_state.scale_factor = new_scale_factor; + + if new_scale_factor == old_scale_factor { + result = ProcResult::Value(0); + return; + } + + let allow_resize = window_state.fullscreen.is_none() + && !window_state.window_flags().contains(WindowFlags::MAXIMIZED); + + (allow_resize, window_state.window_flags) + }; + + // New size as suggested by Windows. + let suggested_rect = unsafe { *(lparam as *const RECT) }; + + // The window rect provided is the window's outer size, not it's inner size. However, + // win32 doesn't provide an `UnadjustWindowRectEx` function to get the client rect from + // the outer rect, so we instead adjust the window rect to get the decoration margins + // and remove them from the outer size. + let margin_left: i32; + let margin_top: i32; + // let margin_right: i32; + // let margin_bottom: i32; + { + let adjusted_rect = + window_flags.adjust_rect(window, suggested_rect).unwrap_or(suggested_rect); + margin_left = suggested_rect.left - adjusted_rect.left; + margin_top = suggested_rect.top - adjusted_rect.top; + // margin_right = adjusted_rect.right - suggested_rect.right; + // margin_bottom = adjusted_rect.bottom - suggested_rect.bottom; + } + + let old_physical_inner_rect = util::WindowArea::Inner + .get_rect(window) + .expect("failed to query (old) inner window area"); + let old_physical_inner_size = PhysicalSize::new( + (old_physical_inner_rect.right - old_physical_inner_rect.left) as u32, + (old_physical_inner_rect.bottom - old_physical_inner_rect.top) as u32, + ); + + // `allow_resize` prevents us from re-applying DPI adjustment to the restored size after + // exiting fullscreen (the restored size is already DPI adjusted). + let new_physical_inner_size = match allow_resize { + // We calculate our own size because the default suggested rect doesn't do a great + // job of preserving the window's logical size. + true => old_physical_inner_size + .to_logical::(old_scale_factor) + .to_physical::(new_scale_factor), + false => old_physical_inner_size, + }; + + let new_inner_size = Arc::new(Mutex::new(new_physical_inner_size)); + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: ScaleFactorChanged { + scale_factor: new_scale_factor, + inner_size_writer: InnerSizeWriter::new(Arc::downgrade(&new_inner_size)), + }, + }); + + let new_physical_inner_size = *new_inner_size.lock().unwrap(); + drop(new_inner_size); + + let dragging_window: bool; + + { + let window_state = userdata.window_state_lock(); + dragging_window = + window_state.window_flags().contains(WindowFlags::MARKER_IN_SIZE_MOVE); + // Unset maximized if we're changing the window's size. + if new_physical_inner_size != old_physical_inner_size { + WindowState::set_window_flags(window_state, window, |f| { + f.set(WindowFlags::MAXIMIZED, false) + }); + } + } + + let new_outer_rect: RECT; + { + let suggested_ul = + (suggested_rect.left + margin_left, suggested_rect.top + margin_top); + + let mut conservative_rect = RECT { + left: suggested_ul.0, + top: suggested_ul.1, + right: suggested_ul.0 + new_physical_inner_size.width as i32, + bottom: suggested_ul.1 + new_physical_inner_size.height as i32, + }; + + conservative_rect = window_flags + .adjust_rect(window, conservative_rect) + .unwrap_or(conservative_rect); + + // If we're dragging the window, offset the window so that the cursor's + // relative horizontal position in the title bar is preserved. + if dragging_window { + let bias = { + let cursor_pos = { + let mut pos = unsafe { mem::zeroed() }; + unsafe { GetCursorPos(&mut pos) }; + pos + }; + let suggested_cursor_horizontal_ratio = (cursor_pos.x - suggested_rect.left) + as f64 + / (suggested_rect.right - suggested_rect.left) as f64; + + (cursor_pos.x + - (suggested_cursor_horizontal_ratio + * (conservative_rect.right - conservative_rect.left) as f64) + as i32) + - conservative_rect.left + }; + conservative_rect.left += bias; + conservative_rect.right += bias; + } + + // Check to see if the new window rect is on the monitor with the new DPI factor. + // If it isn't, offset the window so that it is. + let new_dpi_monitor = unsafe { MonitorFromWindow(window, MONITOR_DEFAULTTONULL) }; + let conservative_rect_monitor = + unsafe { MonitorFromRect(&conservative_rect, MONITOR_DEFAULTTONULL) }; + new_outer_rect = if conservative_rect_monitor == new_dpi_monitor { + conservative_rect + } else { + let get_monitor_rect = |monitor| { + let mut monitor_info = MONITORINFO { + cbSize: mem::size_of::() as _, + ..unsafe { mem::zeroed() } + }; + unsafe { GetMonitorInfoW(monitor, &mut monitor_info) }; + monitor_info.rcMonitor + }; + let wrong_monitor = conservative_rect_monitor; + let wrong_monitor_rect = get_monitor_rect(wrong_monitor); + let new_monitor_rect = get_monitor_rect(new_dpi_monitor); + + // The direction to nudge the window in to get the window onto the monitor with + // the new DPI factor. We calculate this by seeing which monitor edges are + // shared and nudging away from the wrong monitor based on those. + #[allow(clippy::bool_to_int_with_if)] + let delta_nudge_to_dpi_monitor = ( + if wrong_monitor_rect.left == new_monitor_rect.right { + -1 + } else if wrong_monitor_rect.right == new_monitor_rect.left { + 1 + } else { + 0 + }, + if wrong_monitor_rect.bottom == new_monitor_rect.top { + 1 + } else if wrong_monitor_rect.top == new_monitor_rect.bottom { + -1 + } else { + 0 + }, + ); + + let abort_after_iterations = new_monitor_rect.right - new_monitor_rect.left + + new_monitor_rect.bottom + - new_monitor_rect.top; + for _ in 0..abort_after_iterations { + conservative_rect.left += delta_nudge_to_dpi_monitor.0; + conservative_rect.right += delta_nudge_to_dpi_monitor.0; + conservative_rect.top += delta_nudge_to_dpi_monitor.1; + conservative_rect.bottom += delta_nudge_to_dpi_monitor.1; + + if unsafe { MonitorFromRect(&conservative_rect, MONITOR_DEFAULTTONULL) } + == new_dpi_monitor + { + break; + } + } + + conservative_rect + }; + } + + unsafe { + SetWindowPos( + window, + 0, + new_outer_rect.left, + new_outer_rect.top, + new_outer_rect.right - new_outer_rect.left, + new_outer_rect.bottom - new_outer_rect.top, + SWP_NOZORDER | SWP_NOACTIVATE, + ) + }; + + result = ProcResult::Value(0); + }, + + WM_SETTINGCHANGE => { + use crate::event::WindowEvent::ThemeChanged; + + let preferred_theme = userdata.window_state_lock().preferred_theme; + + if preferred_theme.is_none() { + let new_theme = try_theme(window, preferred_theme); + let mut window_state = userdata.window_state_lock(); + + if window_state.current_theme != new_theme { + window_state.current_theme = new_theme; + drop(window_state); + userdata.send_event(Event::WindowEvent { + window_id: RootWindowId(WindowId(window)), + event: ThemeChanged(new_theme), + }); + } + } + result = ProcResult::DefWindowProc(wparam); + }, + + _ => { + if msg == DESTROY_MSG_ID.get() { + unsafe { DestroyWindow(window) }; + result = ProcResult::Value(0); + } else if msg == SET_RETAIN_STATE_ON_SIZE_MSG_ID.get() { + let mut window_state = userdata.window_state_lock(); + window_state.set_window_flags_in_place(|f| { + f.set(WindowFlags::MARKER_RETAIN_STATE_ON_SIZE, wparam != 0) + }); + result = ProcResult::Value(0); + } else if msg == TASKBAR_CREATED.get() { + let window_state = userdata.window_state_lock(); + unsafe { set_skip_taskbar(window, window_state.skip_taskbar) }; + result = ProcResult::DefWindowProc(wparam); + } else { + result = ProcResult::DefWindowProc(wparam); + } + }, + }; + + userdata + .event_loop_runner + .catch_unwind(callback) + .unwrap_or_else(|| result = ProcResult::Value(-1)); + + match result { + ProcResult::DefWindowProc(wparam) => unsafe { DefWindowProcW(window, msg, wparam, lparam) }, + ProcResult::Value(val) => val, + } +} + +unsafe extern "system" fn thread_event_target_callback( + window: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + let userdata_ptr = + unsafe { super::get_window_long(window, GWL_USERDATA) } as *mut ThreadMsgTargetData; + if userdata_ptr.is_null() { + // `userdata_ptr` will always be null for the first `WM_GETMINMAXINFO`, as well as + // `WM_NCCREATE` and `WM_CREATE`. + return unsafe { DefWindowProcW(window, msg, wparam, lparam) }; + } + let userdata = unsafe { Box::from_raw(userdata_ptr) }; + + if msg != WM_PAINT { + unsafe { RedrawWindow(window, ptr::null(), 0, RDW_INTERNALPAINT) }; + } + + let mut userdata_removed = false; + + // I decided to bind the closure to `callback` and pass it to catch_unwind rather than passing + // the closure to catch_unwind directly so that the match body indentation wouldn't change and + // the git blame and history would be preserved. + let callback = || match msg { + WM_NCDESTROY => { + unsafe { super::set_window_long(window, GWL_USERDATA, 0) }; + userdata_removed = true; + 0 + }, + WM_PAINT => unsafe { + ValidateRect(window, ptr::null()); + // Default WM_PAINT behaviour. This makes sure modals and popups are shown immediately + // when opening them. + DefWindowProcW(window, msg, wparam, lparam) + }, + + WM_INPUT_DEVICE_CHANGE => { + let event = match wparam as u32 { + GIDC_ARRIVAL => DeviceEvent::Added, + GIDC_REMOVAL => DeviceEvent::Removed, + _ => unreachable!(), + }; + + userdata + .send_event(Event::DeviceEvent { device_id: wrap_device_id(lparam as u32), event }); + + 0 + }, + + WM_INPUT => { + if let Some(data) = raw_input::get_raw_input_data(lparam as _) { + unsafe { handle_raw_input(&userdata, data) }; + } + + unsafe { DefWindowProcW(window, msg, wparam, lparam) } + }, + + _ if msg == USER_EVENT_MSG_ID.get() => { + // synthesis a placeholder UserEvent, so that if the callback is + // re-entered it can be buffered for later delivery. the real + // user event is still in the mpsc channel and will be pulled + // once the placeholder event is delivered to the wrapper + // `event_handler` + userdata.send_event(Event::UserEvent(UserEventPlaceholder)); + 0 + }, + _ if msg == EXEC_MSG_ID.get() => { + let mut function: ThreadExecFn = unsafe { Box::from_raw(wparam as *mut _) }; + function(); + 0 + }, + _ => unsafe { DefWindowProcW(window, msg, wparam, lparam) }, + }; + + let result = userdata.event_loop_runner.catch_unwind(callback).unwrap_or(-1); + if userdata_removed { + drop(userdata); + } else { + Box::leak(userdata); + } + result +} + +unsafe fn handle_raw_input(userdata: &ThreadMsgTargetData, data: RAWINPUT) { + use crate::event::DeviceEvent::{Button, Key, Motion, MouseMotion, MouseWheel}; + use crate::event::ElementState::{Pressed, Released}; + use crate::event::MouseScrollDelta::LineDelta; + + let device_id = wrap_device_id(data.header.hDevice as _); + + if data.header.dwType == RIM_TYPEMOUSE { + let mouse = unsafe { data.data.mouse }; + + if util::has_flag(mouse.usFlags as u32, MOUSE_MOVE_RELATIVE) { + let x = mouse.lLastX as f64; + let y = mouse.lLastY as f64; + + if x != 0.0 { + userdata.send_event(Event::DeviceEvent { + device_id, + event: Motion { axis: 0, value: x }, + }); + } + + if y != 0.0 { + userdata.send_event(Event::DeviceEvent { + device_id, + event: Motion { axis: 1, value: y }, + }); + } + + if x != 0.0 || y != 0.0 { + userdata.send_event(Event::DeviceEvent { + device_id, + event: MouseMotion { delta: (x, y) }, + }); + } + } + + let button_flags = unsafe { mouse.Anonymous.Anonymous.usButtonFlags }; + if util::has_flag(button_flags as u32, RI_MOUSE_WHEEL) { + let button_data = unsafe { mouse.Anonymous.Anonymous.usButtonData } as i16; + let delta = button_data as f32 / WHEEL_DELTA as f32; + userdata.send_event(Event::DeviceEvent { + device_id, + event: MouseWheel { delta: LineDelta(0.0, delta) }, + }); + } + if util::has_flag(button_flags as u32, RI_MOUSE_HWHEEL) { + let button_data = unsafe { mouse.Anonymous.Anonymous.usButtonData } as i16; + let delta = -button_data as f32 / WHEEL_DELTA as f32; + userdata.send_event(Event::DeviceEvent { + device_id, + event: MouseWheel { delta: LineDelta(delta, 0.0) }, + }); + } + + let button_state = raw_input::get_raw_mouse_button_state(button_flags as u32); + for (button, state) in button_state.iter().enumerate() { + if let Some(state) = *state { + userdata.send_event(Event::DeviceEvent { + device_id, + event: Button { button: button as _, state }, + }); + } + } + } else if data.header.dwType == RIM_TYPEKEYBOARD { + let keyboard = unsafe { data.data.keyboard }; + + let pressed = keyboard.Message == WM_KEYDOWN || keyboard.Message == WM_SYSKEYDOWN; + let released = keyboard.Message == WM_KEYUP || keyboard.Message == WM_SYSKEYUP; + + if !pressed && !released { + return; + } + + if let Some(physical_key) = raw_input::get_keyboard_physical_key(keyboard) { + let state = if pressed { Pressed } else { Released }; + + userdata.send_event(Event::DeviceEvent { + device_id, + event: Key(RawKeyEvent { physical_key, state }), + }); + } + } +} + +enum PointerMoveKind { + /// Pointer entered to the window. + Enter, + /// Pointer leaved the window client area. + Leave, + /// Pointer is inside the window or `GetClientRect` failed. + None, +} + +fn get_pointer_move_kind( + window: HWND, + mouse_was_inside_window: bool, + x: i32, + y: i32, +) -> PointerMoveKind { + let rect: RECT = unsafe { + let mut rect: RECT = mem::zeroed(); + if GetClientRect(window, &mut rect) == false.into() { + return PointerMoveKind::None; // exit early if GetClientRect failed + } + rect + }; + + let x = (rect.left..rect.right).contains(&x); + let y = (rect.top..rect.bottom).contains(&y); + + if !mouse_was_inside_window && x && y { + PointerMoveKind::Enter + } else if mouse_was_inside_window && !(x && y) { + PointerMoveKind::Leave + } else { + PointerMoveKind::None + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/event_loop/runner.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/event_loop/runner.rs new file mode 100644 index 00000000..3243ec47 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/event_loop/runner.rs @@ -0,0 +1,406 @@ +use std::any::Any; +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use std::{mem, panic}; + +use windows_sys::Win32::Foundation::HWND; + +use crate::dpi::PhysicalSize; +use crate::event::{Event, InnerSizeWriter, StartCause, WindowEvent}; +use crate::platform_impl::platform::event_loop::{WindowData, GWL_USERDATA}; +use crate::platform_impl::platform::get_window_long; +use crate::window::WindowId; + +use super::ControlFlow; + +pub(crate) type EventLoopRunnerShared = Rc>; + +type EventHandler = Cell)>>>; + +pub(crate) struct EventLoopRunner { + // The event loop's win32 handles + pub(super) thread_msg_target: HWND, + + // Setting this will ensure pump_events will return to the external + // loop asap. E.g. set after each RedrawRequested to ensure pump_events + // can't stall an external loop beyond a frame + pub(super) interrupt_msg_dispatch: Cell, + + control_flow: Cell, + exit: Cell>, + runner_state: Cell, + last_events_cleared: Cell, + event_handler: EventHandler, + event_buffer: RefCell>>, + + panic_error: Cell>, +} + +pub type PanicError = Box; + +/// See `move_state_to` function for details on how the state loop works. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum RunnerState { + /// The event loop has just been created, and an `Init` event must be sent. + Uninitialized, + /// The event loop is idling. + Idle, + /// The event loop is handling the OS's events and sending them to the user's callback. + /// `NewEvents` has been sent, and `AboutToWait` hasn't. + HandlingMainEvents, + /// The event loop has been destroyed. No other events will be emitted. + Destroyed, +} + +enum BufferedEvent { + Event(Event), + ScaleFactorChanged(WindowId, f64, PhysicalSize), +} + +impl EventLoopRunner { + pub(crate) fn new(thread_msg_target: HWND) -> EventLoopRunner { + EventLoopRunner { + thread_msg_target, + interrupt_msg_dispatch: Cell::new(false), + runner_state: Cell::new(RunnerState::Uninitialized), + control_flow: Cell::new(ControlFlow::default()), + exit: Cell::new(None), + panic_error: Cell::new(None), + last_events_cleared: Cell::new(Instant::now()), + event_handler: Cell::new(None), + event_buffer: RefCell::new(VecDeque::new()), + } + } + + /// Associate the application's event handler with the runner + /// + /// # Safety + /// This is ignoring the lifetime of the application handler (which may not + /// outlive the EventLoopRunner) and can lead to undefined behaviour if + /// the handler is not cleared before the end of real lifetime. + /// + /// All public APIs that take an event handler (`run`, `run_on_demand`, + /// `pump_events`) _must_ pair a call to `set_event_handler` with + /// a call to `clear_event_handler` before returning to avoid + /// undefined behaviour. + pub(crate) unsafe fn set_event_handler(&self, f: F) + where + F: FnMut(Event), + { + // Erase closure lifetime. + // SAFETY: Caller upholds that the lifetime of the closure is upheld. + let f = unsafe { + mem::transmute::)>, Box)>>(Box::new(f)) + }; + let old_event_handler = self.event_handler.replace(Some(f)); + assert!(old_event_handler.is_none()); + } + + pub(crate) fn clear_event_handler(&self) { + self.event_handler.set(None); + } + + pub(crate) fn reset_runner(&self) { + let EventLoopRunner { + thread_msg_target: _, + interrupt_msg_dispatch, + runner_state, + panic_error, + control_flow: _, + exit, + last_events_cleared: _, + event_handler, + event_buffer: _, + } = self; + interrupt_msg_dispatch.set(false); + runner_state.set(RunnerState::Uninitialized); + panic_error.set(None); + exit.set(None); + event_handler.set(None); + } +} + +/// State retrieval functions. +impl EventLoopRunner { + #[allow(unused)] + pub fn thread_msg_target(&self) -> HWND { + self.thread_msg_target + } + + pub fn take_panic_error(&self) -> Result<(), PanicError> { + match self.panic_error.take() { + Some(err) => Err(err), + None => Ok(()), + } + } + + pub fn set_control_flow(&self, control_flow: ControlFlow) { + self.control_flow.set(control_flow) + } + + pub fn control_flow(&self) -> ControlFlow { + self.control_flow.get() + } + + pub fn set_exit_code(&self, code: i32) { + self.exit.set(Some(code)) + } + + pub fn exit_code(&self) -> Option { + self.exit.get() + } + + pub fn clear_exit(&self) { + self.exit.set(None); + } + + pub fn should_buffer(&self) -> bool { + let handler = self.event_handler.take(); + let should_buffer = handler.is_none(); + self.event_handler.set(handler); + should_buffer + } +} + +/// Misc. functions +impl EventLoopRunner { + pub fn catch_unwind(&self, f: impl FnOnce() -> R) -> Option { + let panic_error = self.panic_error.take(); + if panic_error.is_none() { + let result = panic::catch_unwind(panic::AssertUnwindSafe(f)); + + // Check to see if the panic error was set in a re-entrant call to catch_unwind inside + // of `f`. If it was, that error takes priority. If it wasn't, check if our call to + // catch_unwind caught any panics and set panic_error appropriately. + match self.panic_error.take() { + None => match result { + Ok(r) => Some(r), + Err(e) => { + self.panic_error.set(Some(e)); + None + }, + }, + Some(e) => { + self.panic_error.set(Some(e)); + None + }, + } + } else { + self.panic_error.set(panic_error); + None + } + } +} + +/// Event dispatch functions. +impl EventLoopRunner { + pub(crate) fn prepare_wait(&self) { + self.move_state_to(RunnerState::Idle); + } + + pub(crate) fn wakeup(&self) { + self.move_state_to(RunnerState::HandlingMainEvents); + } + + pub(crate) fn send_event(&self, event: Event) { + if let Event::WindowEvent { event: WindowEvent::RedrawRequested, .. } = event { + self.call_event_handler(event); + // As a rule, to ensure that `pump_events` can't block an external event loop + // for too long, we always guarantee that `pump_events` will return control to + // the external loop asap after a `RedrawRequested` event is dispatched. + self.interrupt_msg_dispatch.set(true); + } else if self.should_buffer() { + // If the runner is already borrowed, we're in the middle of an event loop invocation. + // Add the event to a buffer to be processed later. + self.event_buffer.borrow_mut().push_back(BufferedEvent::from_event(event)) + } else { + self.call_event_handler(event); + self.dispatch_buffered_events(); + } + } + + pub(crate) fn loop_destroyed(&self) { + self.move_state_to(RunnerState::Destroyed); + } + + fn call_event_handler(&self, event: Event) { + self.catch_unwind(|| { + let mut event_handler = self.event_handler.take().expect( + "either event handler is re-entrant (likely), or no event handler is registered \ + (very unlikely)", + ); + + event_handler(event); + + assert!(self.event_handler.replace(Some(event_handler)).is_none()); + }); + } + + fn dispatch_buffered_events(&self) { + loop { + // We do this instead of using a `while let` loop because if we use a `while let` + // loop the reference returned `borrow_mut()` doesn't get dropped until the end + // of the loop's body and attempts to add events to the event buffer while in + // `process_event` will fail. + let buffered_event_opt = self.event_buffer.borrow_mut().pop_front(); + match buffered_event_opt { + Some(e) => e.dispatch_event(|e| self.call_event_handler(e)), + None => break, + } + } + } + + /// Dispatch control flow events (`NewEvents`, `AboutToWait`, and + /// `LoopExiting`) as necessary to bring the internal `RunnerState` to the + /// new runner state. + /// + /// The state transitions are defined as follows: + /// + /// ```text + /// Uninitialized + /// | + /// V + /// Idle + /// ^ | + /// | V + /// HandlingMainEvents + /// | + /// V + /// Destroyed + /// ``` + /// + /// Attempting to transition back to `Uninitialized` will result in a panic. Attempting to + /// transition *from* `Destroyed` will also result in a panic. Transitioning to the current + /// state is a no-op. Even if the `new_runner_state` isn't the immediate next state in the + /// runner state machine (e.g. `self.runner_state == HandlingMainEvents` and + /// `new_runner_state == Idle`), the intermediate state transitions will still be executed. + fn move_state_to(&self, new_runner_state: RunnerState) { + use RunnerState::{Destroyed, HandlingMainEvents, Idle, Uninitialized}; + + match (self.runner_state.replace(new_runner_state), new_runner_state) { + (Uninitialized, Uninitialized) + | (Idle, Idle) + | (HandlingMainEvents, HandlingMainEvents) + | (Destroyed, Destroyed) => (), + + // State transitions that initialize the event loop. + (Uninitialized, HandlingMainEvents) => { + self.call_new_events(true); + }, + (Uninitialized, Idle) => { + self.call_new_events(true); + self.call_event_handler(Event::AboutToWait); + self.last_events_cleared.set(Instant::now()); + }, + (Uninitialized, Destroyed) => { + self.call_new_events(true); + self.call_event_handler(Event::AboutToWait); + self.last_events_cleared.set(Instant::now()); + self.call_event_handler(Event::LoopExiting); + }, + (_, Uninitialized) => panic!("cannot move state to Uninitialized"), + + // State transitions that start the event handling process. + (Idle, HandlingMainEvents) => { + self.call_new_events(false); + }, + (Idle, Destroyed) => { + self.call_event_handler(Event::LoopExiting); + }, + + (HandlingMainEvents, Idle) => { + // This is always the last event we dispatch before waiting for new events + self.call_event_handler(Event::AboutToWait); + self.last_events_cleared.set(Instant::now()); + }, + (HandlingMainEvents, Destroyed) => { + self.call_event_handler(Event::AboutToWait); + self.last_events_cleared.set(Instant::now()); + self.call_event_handler(Event::LoopExiting); + }, + + (Destroyed, _) => panic!("cannot move state from Destroyed"), + } + } + + fn call_new_events(&self, init: bool) { + let start_cause = match (init, self.control_flow(), self.exit.get()) { + (true, ..) => StartCause::Init, + (false, ControlFlow::Poll, None) => StartCause::Poll, + (false, _, Some(_)) | (false, ControlFlow::Wait, None) => StartCause::WaitCancelled { + requested_resume: None, + start: self.last_events_cleared.get(), + }, + (false, ControlFlow::WaitUntil(requested_resume), None) => { + if Instant::now() < requested_resume { + StartCause::WaitCancelled { + requested_resume: Some(requested_resume), + start: self.last_events_cleared.get(), + } + } else { + StartCause::ResumeTimeReached { + requested_resume, + start: self.last_events_cleared.get(), + } + } + }, + }; + self.call_event_handler(Event::NewEvents(start_cause)); + // NB: For consistency all platforms must emit a 'resumed' event even though Windows + // applications don't themselves have a formal suspend/resume lifecycle. + if init { + self.call_event_handler(Event::Resumed); + } + self.dispatch_buffered_events(); + } +} + +impl BufferedEvent { + pub fn from_event(event: Event) -> BufferedEvent { + match event { + Event::WindowEvent { + event: WindowEvent::ScaleFactorChanged { scale_factor, inner_size_writer }, + window_id, + } => BufferedEvent::ScaleFactorChanged( + window_id, + scale_factor, + *inner_size_writer.new_inner_size.upgrade().unwrap().lock().unwrap(), + ), + event => BufferedEvent::Event(event), + } + } + + pub fn dispatch_event(self, dispatch: impl FnOnce(Event)) { + match self { + Self::Event(event) => dispatch(event), + Self::ScaleFactorChanged(window_id, scale_factor, new_inner_size) => { + let user_new_inner_size = Arc::new(Mutex::new(new_inner_size)); + dispatch(Event::WindowEvent { + window_id, + event: WindowEvent::ScaleFactorChanged { + scale_factor, + inner_size_writer: InnerSizeWriter::new(Arc::downgrade( + &user_new_inner_size, + )), + }, + }); + let inner_size = *user_new_inner_size.lock().unwrap(); + + drop(user_new_inner_size); + + if inner_size != new_inner_size { + let window_flags = unsafe { + let userdata = + get_window_long(window_id.0.into(), GWL_USERDATA) as *mut WindowData; + (*userdata).window_state_lock().window_flags + }; + + window_flags.set_size((window_id.0).0, inner_size); + } + }, + } + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/icon.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/icon.rs new file mode 100644 index 00000000..4e5fe69e --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/icon.rs @@ -0,0 +1,265 @@ +use std::ffi::c_void; +use std::path::Path; +use std::sync::Arc; +use std::{fmt, io, mem}; + +use cursor_icon::CursorIcon; +use windows_sys::core::PCWSTR; +use windows_sys::Win32::Foundation::HWND; +use windows_sys::Win32::Graphics::Gdi::{ + CreateBitmap, CreateCompatibleBitmap, DeleteObject, GetDC, ReleaseDC, SetBitmapBits, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateIcon, CreateIconIndirect, DestroyCursor, DestroyIcon, LoadImageW, SendMessageW, HCURSOR, + HICON, ICONINFO, ICON_BIG, ICON_SMALL, IMAGE_ICON, LR_DEFAULTSIZE, LR_LOADFROMFILE, WM_SETICON, +}; + +use crate::cursor::CursorImage; +use crate::dpi::PhysicalSize; +use crate::icon::*; + +use super::util; + +impl Pixel { + fn convert_to_bgra(&mut self) { + mem::swap(&mut self.r, &mut self.b); + } +} + +impl RgbaIcon { + fn into_windows_icon(self) -> Result { + let rgba = self.rgba; + let pixel_count = rgba.len() / PIXEL_SIZE; + let mut and_mask = Vec::with_capacity(pixel_count); + let pixels = + unsafe { std::slice::from_raw_parts_mut(rgba.as_ptr() as *mut Pixel, pixel_count) }; + for pixel in pixels { + and_mask.push(pixel.a.wrapping_sub(u8::MAX)); // invert alpha channel + pixel.convert_to_bgra(); + } + assert_eq!(and_mask.len(), pixel_count); + let handle = unsafe { + CreateIcon( + 0, + self.width as i32, + self.height as i32, + 1, + (PIXEL_SIZE * 8) as u8, + and_mask.as_ptr(), + rgba.as_ptr(), + ) + }; + if handle != 0 { + Ok(WinIcon::from_handle(handle)) + } else { + Err(BadIcon::OsError(io::Error::last_os_error())) + } + } +} + +#[derive(Debug)] +pub enum IconType { + Small = ICON_SMALL as isize, + Big = ICON_BIG as isize, +} + +#[derive(Debug)] +struct RaiiIcon { + handle: HICON, +} + +#[derive(Clone)] +pub struct WinIcon { + inner: Arc, +} + +unsafe impl Send for WinIcon {} + +impl WinIcon { + pub fn as_raw_handle(&self) -> HICON { + self.inner.handle + } + + pub fn from_path>( + path: P, + size: Option>, + ) -> Result { + // width / height of 0 along with LR_DEFAULTSIZE tells windows to load the default icon size + let (width, height) = size.map(Into::into).unwrap_or((0, 0)); + + let wide_path = util::encode_wide(path.as_ref()); + + let handle = unsafe { + LoadImageW( + 0, + wide_path.as_ptr(), + IMAGE_ICON, + width, + height, + LR_DEFAULTSIZE | LR_LOADFROMFILE, + ) + }; + if handle != 0 { + Ok(WinIcon::from_handle(handle as HICON)) + } else { + Err(BadIcon::OsError(io::Error::last_os_error())) + } + } + + pub fn from_resource( + resource_id: u16, + size: Option>, + ) -> Result { + Self::from_resource_ptr(resource_id as PCWSTR, size) + } + + pub fn from_resource_name( + resource_name: &str, + size: Option>, + ) -> Result { + let wide_name = util::encode_wide(resource_name); + Self::from_resource_ptr(wide_name.as_ptr(), size) + } + + fn from_resource_ptr( + resource: PCWSTR, + size: Option>, + ) -> Result { + // width / height of 0 along with LR_DEFAULTSIZE tells windows to load the default icon size + let (width, height) = size.map(Into::into).unwrap_or((0, 0)); + let handle = unsafe { + LoadImageW( + util::get_instance_handle(), + resource, + IMAGE_ICON, + width, + height, + LR_DEFAULTSIZE, + ) + }; + if handle != 0 { + Ok(WinIcon::from_handle(handle as HICON)) + } else { + Err(BadIcon::OsError(io::Error::last_os_error())) + } + } + + pub fn from_rgba(rgba: Vec, width: u32, height: u32) -> Result { + let rgba_icon = RgbaIcon::from_rgba(rgba, width, height)?; + rgba_icon.into_windows_icon() + } + + pub fn set_for_window(&self, hwnd: HWND, icon_type: IconType) { + unsafe { + SendMessageW(hwnd, WM_SETICON, icon_type as usize, self.as_raw_handle()); + } + } + + fn from_handle(handle: HICON) -> Self { + Self { inner: Arc::new(RaiiIcon { handle }) } + } +} + +impl Drop for RaiiIcon { + fn drop(&mut self) { + unsafe { DestroyIcon(self.handle) }; + } +} + +impl fmt::Debug for WinIcon { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + (*self.inner).fmt(formatter) + } +} + +pub fn unset_for_window(hwnd: HWND, icon_type: IconType) { + unsafe { + SendMessageW(hwnd, WM_SETICON, icon_type as usize, 0); + } +} + +#[derive(Debug, Clone)] +pub enum SelectedCursor { + Named(CursorIcon), + Custom(Arc), +} + +impl Default for SelectedCursor { + fn default() -> Self { + Self::Named(Default::default()) + } +} + +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub enum WinCursor { + Cursor(Arc), + Failed, +} + +impl WinCursor { + pub(crate) fn new(image: &CursorImage) -> Result { + let mut bgra = image.rgba.clone(); + bgra.chunks_exact_mut(4).for_each(|chunk| chunk.swap(0, 2)); + + let w = image.width as i32; + let h = image.height as i32; + + unsafe { + let hdc_screen = GetDC(0); + if hdc_screen == 0 { + return Err(io::Error::last_os_error()); + } + let hbm_color = CreateCompatibleBitmap(hdc_screen, w, h); + ReleaseDC(0, hdc_screen); + if hbm_color == 0 { + return Err(io::Error::last_os_error()); + } + if SetBitmapBits(hbm_color, bgra.len() as u32, bgra.as_ptr() as *const c_void) == 0 { + DeleteObject(hbm_color); + return Err(io::Error::last_os_error()); + }; + + // Mask created according to https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-createbitmap#parameters + let mask_bits: Vec = vec![0xff; ((((w + 15) >> 4) << 1) * h) as usize]; + let hbm_mask = CreateBitmap(w, h, 1, 1, mask_bits.as_ptr() as *const _); + if hbm_mask == 0 { + DeleteObject(hbm_color); + return Err(io::Error::last_os_error()); + } + + let icon_info = ICONINFO { + fIcon: 0, + xHotspot: image.hotspot_x as u32, + yHotspot: image.hotspot_y as u32, + hbmMask: hbm_mask, + hbmColor: hbm_color, + }; + + let handle = CreateIconIndirect(&icon_info as *const _); + DeleteObject(hbm_color); + DeleteObject(hbm_mask); + if handle == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Self::Cursor(Arc::new(RaiiCursor { handle }))) + } + } +} + +#[derive(Debug, Hash, Eq, PartialEq)] +pub struct RaiiCursor { + handle: HCURSOR, +} + +impl Drop for RaiiCursor { + fn drop(&mut self) { + unsafe { DestroyCursor(self.handle) }; + } +} + +impl RaiiCursor { + pub fn as_raw_handle(&self) -> HICON { + self.handle + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/ime.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/ime.rs new file mode 100644 index 00000000..eb65abf3 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/ime.rs @@ -0,0 +1,162 @@ +use std::ffi::{c_void, OsString}; +use std::os::windows::prelude::OsStringExt; +use std::ptr::null_mut; + +use windows_sys::Win32::Foundation::{POINT, RECT}; +use windows_sys::Win32::Globalization::HIMC; +use windows_sys::Win32::UI::Input::Ime::{ + ImmAssociateContextEx, ImmGetCompositionStringW, ImmGetContext, ImmReleaseContext, + ImmSetCandidateWindow, ImmSetCompositionWindow, ATTR_TARGET_CONVERTED, + ATTR_TARGET_NOTCONVERTED, CANDIDATEFORM, CFS_EXCLUDE, CFS_POINT, COMPOSITIONFORM, GCS_COMPATTR, + GCS_COMPSTR, GCS_CURSORPOS, GCS_RESULTSTR, IACE_CHILDREN, IACE_DEFAULT, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_IMMENABLED}; + +use crate::dpi::{Position, Size}; +use crate::platform::windows::HWND; + +pub struct ImeContext { + hwnd: HWND, + himc: HIMC, +} + +impl ImeContext { + pub unsafe fn current(hwnd: HWND) -> Self { + let himc = unsafe { ImmGetContext(hwnd) }; + ImeContext { hwnd, himc } + } + + pub unsafe fn get_composing_text_and_cursor( + &self, + ) -> Option<(String, Option, Option)> { + let text = unsafe { self.get_composition_string(GCS_COMPSTR) }?; + let attrs = unsafe { self.get_composition_data(GCS_COMPATTR) }.unwrap_or_default(); + + let mut first = None; + let mut last = None; + let mut boundary_before_char = 0; + let mut attr_idx = 0; + + for chr in text.chars() { + let Some(attr) = attrs.get(attr_idx).copied() else { + break; + }; + + let char_is_targeted = + attr as u32 == ATTR_TARGET_CONVERTED || attr as u32 == ATTR_TARGET_NOTCONVERTED; + + if first.is_none() && char_is_targeted { + first = Some(boundary_before_char); + } else if first.is_some() && last.is_none() && !char_is_targeted { + last = Some(boundary_before_char); + } + + boundary_before_char += chr.len_utf8(); + attr_idx += chr.len_utf16(); + } + + if first.is_some() && last.is_none() { + last = Some(text.len()); + } else if first.is_none() { + // IME haven't split words and select any clause yet, so trying to retrieve normal + // cursor. + let cursor = unsafe { self.get_composition_cursor(&text) }; + first = cursor; + last = cursor; + } + + Some((text, first, last)) + } + + pub unsafe fn get_composed_text(&self) -> Option { + unsafe { self.get_composition_string(GCS_RESULTSTR) } + } + + unsafe fn get_composition_cursor(&self, text: &str) -> Option { + let cursor = unsafe { ImmGetCompositionStringW(self.himc, GCS_CURSORPOS, null_mut(), 0) }; + (cursor >= 0).then(|| text.chars().take(cursor as _).map(|c| c.len_utf8()).sum()) + } + + unsafe fn get_composition_string(&self, gcs_mode: u32) -> Option { + let data = unsafe { self.get_composition_data(gcs_mode) }?; + let (prefix, shorts, suffix) = unsafe { data.align_to::() }; + if prefix.is_empty() && suffix.is_empty() { + OsString::from_wide(shorts).into_string().ok() + } else { + None + } + } + + unsafe fn get_composition_data(&self, gcs_mode: u32) -> Option> { + let size = match unsafe { ImmGetCompositionStringW(self.himc, gcs_mode, null_mut(), 0) } { + 0 => return Some(Vec::new()), + size if size < 0 => return None, + size => size, + }; + + let mut buf = Vec::::with_capacity(size as _); + let size = unsafe { + ImmGetCompositionStringW( + self.himc, + gcs_mode, + buf.as_mut_ptr() as *mut c_void, + size as _, + ) + }; + + if size < 0 { + None + } else { + unsafe { buf.set_len(size as _) }; + Some(buf) + } + } + + pub unsafe fn set_ime_cursor_area(&self, spot: Position, size: Size, scale_factor: f64) { + if !unsafe { ImeContext::system_has_ime() } { + return; + } + + let (x, y) = spot.to_physical::(scale_factor).into(); + let (width, height): (i32, i32) = size.to_physical::(scale_factor).into(); + let rc_area = RECT { left: x, top: y, right: x + width, bottom: y + height }; + let candidate_form = CANDIDATEFORM { + dwIndex: 0, + dwStyle: CFS_EXCLUDE, + ptCurrentPos: POINT { x, y }, + rcArea: rc_area, + }; + let composition_form = COMPOSITIONFORM { + dwStyle: CFS_POINT, + ptCurrentPos: POINT { x, y: y + height }, + rcArea: rc_area, + }; + + unsafe { + ImmSetCompositionWindow(self.himc, &composition_form); + ImmSetCandidateWindow(self.himc, &candidate_form); + } + } + + pub unsafe fn set_ime_allowed(hwnd: HWND, allowed: bool) { + if !unsafe { ImeContext::system_has_ime() } { + return; + } + + if allowed { + unsafe { ImmAssociateContextEx(hwnd, 0, IACE_DEFAULT) }; + } else { + unsafe { ImmAssociateContextEx(hwnd, 0, IACE_CHILDREN) }; + } + } + + unsafe fn system_has_ime() -> bool { + unsafe { GetSystemMetrics(SM_IMMENABLED) != 0 } + } +} + +impl Drop for ImeContext { + fn drop(&mut self) { + unsafe { ImmReleaseContext(self.hwnd, self.himc) }; + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/keyboard.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/keyboard.rs new file mode 100644 index 00000000..9c109490 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/keyboard.rs @@ -0,0 +1,1243 @@ +use std::char; +use std::ffi::OsString; +use std::mem::MaybeUninit; +use std::os::windows::ffi::OsStringExt; +use std::sync::atomic::AtomicU32; +use std::sync::atomic::Ordering::Relaxed; +use std::sync::{Mutex, MutexGuard}; + +use windows_sys::Win32::Foundation::{HWND, LPARAM, WPARAM}; +use windows_sys::Win32::System::SystemServices::LANG_KOREAN; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + GetAsyncKeyState, GetKeyState, GetKeyboardLayout, GetKeyboardState, MapVirtualKeyExW, + MAPVK_VK_TO_VSC_EX, MAPVK_VSC_TO_VK_EX, VIRTUAL_KEY, VK_ABNT_C2, VK_ADD, VK_CAPITAL, VK_CLEAR, + VK_CONTROL, VK_DECIMAL, VK_DELETE, VK_DIVIDE, VK_DOWN, VK_END, VK_F4, VK_HOME, VK_INSERT, + VK_LCONTROL, VK_LEFT, VK_LMENU, VK_LSHIFT, VK_LWIN, VK_MENU, VK_MULTIPLY, VK_NEXT, VK_NUMLOCK, + VK_NUMPAD0, VK_NUMPAD1, VK_NUMPAD2, VK_NUMPAD3, VK_NUMPAD4, VK_NUMPAD5, VK_NUMPAD6, VK_NUMPAD7, + VK_NUMPAD8, VK_NUMPAD9, VK_PRIOR, VK_RCONTROL, VK_RETURN, VK_RIGHT, VK_RMENU, VK_RSHIFT, + VK_RWIN, VK_SCROLL, VK_SHIFT, VK_SUBTRACT, VK_UP, +}; +use windows_sys::Win32::UI::TextServices::HKL; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + PeekMessageW, MSG, PM_NOREMOVE, WM_CHAR, WM_DEADCHAR, WM_KEYDOWN, WM_KEYFIRST, WM_KEYLAST, + WM_KEYUP, WM_KILLFOCUS, WM_SETFOCUS, WM_SYSCHAR, WM_SYSDEADCHAR, WM_SYSKEYDOWN, WM_SYSKEYUP, +}; + +use smol_str::SmolStr; +use tracing::{trace, warn}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::event::{ElementState, KeyEvent}; +use crate::keyboard::{Key, KeyCode, KeyLocation, NamedKey, NativeKey, NativeKeyCode, PhysicalKey}; +use crate::platform_impl::platform::event_loop::ProcResult; +use crate::platform_impl::platform::keyboard_layout::{ + Layout, LayoutCache, WindowsModifiers, LAYOUT_CACHE, +}; +use crate::platform_impl::platform::{loword, primarylangid, KeyEventExtra}; + +pub type ExScancode = u16; + +pub struct MessageAsKeyEvent { + pub event: KeyEvent, + pub is_synthetic: bool, +} + +/// Stores information required to make `KeyEvent`s. +/// +/// A single Winit `KeyEvent` contains information which the Windows API passes to the application +/// in multiple window messages. In other words: a Winit `KeyEvent` cannot be built from a single +/// window message. Therefore, this type keeps track of certain information from previous events so +/// that a `KeyEvent` can be constructed when the last event related to a keypress is received. +/// +/// `PeekMessage` is sometimes used to determine whether the next window message still belongs to +/// the current keypress. If it doesn't and the current state represents a key event waiting to be +/// dispatched, then said event is considered complete and is dispatched. +/// +/// The sequence of window messages for a key press event is the following: +/// - Exactly one WM_KEYDOWN / WM_SYSKEYDOWN +/// - Zero or one WM_DEADCHAR / WM_SYSDEADCHAR +/// - Zero or more WM_CHAR / WM_SYSCHAR. These messages each come with a UTF-16 code unit which when +/// put together in the sequence they arrived in, forms the text which is the result of pressing +/// the key. +/// +/// Key release messages are a bit different due to the fact that they don't contribute to +/// text input. The "sequence" only consists of one WM_KEYUP / WM_SYSKEYUP event. +pub struct KeyEventBuilder { + event_info: Mutex>, + pending: PendingEventQueue, +} +impl Default for KeyEventBuilder { + fn default() -> Self { + KeyEventBuilder { event_info: Mutex::new(None), pending: Default::default() } + } +} +impl KeyEventBuilder { + /// Call this function for every window message. + /// Returns Some() if this window message completes a KeyEvent. + /// Returns None otherwise. + pub(crate) fn process_message( + &self, + hwnd: HWND, + msg_kind: u32, + wparam: WPARAM, + lparam: LPARAM, + result: &mut ProcResult, + ) -> Vec { + enum MatchResult { + Nothing, + TokenToRemove(PendingMessageToken), + MessagesToDispatch(Vec), + } + + let mut matcher = || -> MatchResult { + match msg_kind { + WM_SETFOCUS => { + // synthesize keydown events + let kbd_state = get_async_kbd_state(); + let key_events = Self::synthesize_kbd_state(ElementState::Pressed, &kbd_state); + MatchResult::MessagesToDispatch(self.pending.complete_multi(key_events)) + }, + WM_KILLFOCUS => { + // synthesize keyup events + let kbd_state = get_kbd_state(); + let key_events = Self::synthesize_kbd_state(ElementState::Released, &kbd_state); + MatchResult::MessagesToDispatch(self.pending.complete_multi(key_events)) + }, + WM_KEYDOWN | WM_SYSKEYDOWN => { + if msg_kind == WM_SYSKEYDOWN && wparam as VIRTUAL_KEY == VK_F4 { + // Don't dispatch Alt+F4 to the application. + // This is handled in `event_loop.rs` + return MatchResult::Nothing; + } + let pending_token = self.pending.add_pending(); + *result = ProcResult::Value(0); + + let next_msg = next_kbd_msg(hwnd); + + let mut layouts = LAYOUT_CACHE.lock().unwrap(); + let mut finished_event_info = Some(PartialKeyEventInfo::from_message( + wparam, + lparam, + ElementState::Pressed, + &mut layouts, + )); + let mut event_info = self.event_info.lock().unwrap(); + *event_info = None; + if let Some(next_msg) = next_msg { + let next_msg_kind = next_msg.message; + let next_belongs_to_this = !matches!( + next_msg_kind, + WM_KEYDOWN | WM_SYSKEYDOWN | WM_KEYUP | WM_SYSKEYUP + ); + if next_belongs_to_this { + // The next OS event belongs to this Winit event, so let's just + // store the partial information, and add to it in the upcoming events + *event_info = finished_event_info.take(); + } else { + let (_, layout) = layouts.get_current_layout(); + let is_fake = { + let curr_event = finished_event_info.as_ref().unwrap(); + is_current_fake(curr_event, next_msg, layout) + }; + if is_fake { + finished_event_info = None; + } + } + } + if let Some(event_info) = finished_event_info { + let ev = event_info.finalize(); + return MatchResult::MessagesToDispatch(self.pending.complete_pending( + pending_token, + MessageAsKeyEvent { event: ev, is_synthetic: false }, + )); + } + MatchResult::TokenToRemove(pending_token) + }, + WM_DEADCHAR | WM_SYSDEADCHAR => { + let pending_token = self.pending.add_pending(); + *result = ProcResult::Value(0); + // At this point, we know that there isn't going to be any more events related + // to this key press + let event_info = self.event_info.lock().unwrap().take().unwrap(); + let ev = event_info.finalize(); + MatchResult::MessagesToDispatch(self.pending.complete_pending( + pending_token, + MessageAsKeyEvent { event: ev, is_synthetic: false }, + )) + }, + WM_CHAR | WM_SYSCHAR => { + let mut event_info = self.event_info.lock().unwrap(); + if event_info.is_none() { + trace!( + "Received a CHAR message but no `event_info` was available. The \ + message is probably IME, returning." + ); + return MatchResult::Nothing; + } + let pending_token = self.pending.add_pending(); + *result = ProcResult::Value(0); + let is_high_surrogate = (0xd800..=0xdbff).contains(&wparam); + let is_low_surrogate = (0xdc00..=0xdfff).contains(&wparam); + + let is_utf16 = is_high_surrogate || is_low_surrogate; + + if is_utf16 { + if let Some(ev_info) = event_info.as_mut() { + ev_info.utf16parts.push(wparam as u16); + } + } else { + // In this case, wparam holds a UTF-32 character. + // Let's encode it as UTF-16 and append it to the end of `utf16parts` + let utf16parts = match event_info.as_mut() { + Some(ev_info) => &mut ev_info.utf16parts, + None => { + warn!("The event_info was None when it was expected to be some"); + return MatchResult::TokenToRemove(pending_token); + }, + }; + let start_offset = utf16parts.len(); + let new_size = utf16parts.len() + 2; + utf16parts.resize(new_size, 0); + if let Some(ch) = char::from_u32(wparam as u32) { + let encode_len = ch.encode_utf16(&mut utf16parts[start_offset..]).len(); + let new_size = start_offset + encode_len; + utf16parts.resize(new_size, 0); + } + } + // It's important that we unlock the mutex, and create the pending event token + // before calling `next_msg` + std::mem::drop(event_info); + let next_msg = next_kbd_msg(hwnd); + let more_char_coming = next_msg + .map(|m| matches!(m.message, WM_CHAR | WM_SYSCHAR)) + .unwrap_or(false); + if more_char_coming { + // No need to produce an event just yet, because there are still more + // characters that need to be appended to this keyboard event + MatchResult::TokenToRemove(pending_token) + } else { + let mut event_info = self.event_info.lock().unwrap(); + let mut event_info = match event_info.take() { + Some(ev_info) => ev_info, + None => { + warn!("The event_info was None when it was expected to be some"); + return MatchResult::TokenToRemove(pending_token); + }, + }; + let mut layouts = LAYOUT_CACHE.lock().unwrap(); + // It's okay to call `ToUnicode` here, because at this point the dead key + // is already consumed by the character. + let kbd_state = get_kbd_state(); + let mod_state = WindowsModifiers::active_modifiers(&kbd_state); + + let (_, layout) = layouts.get_current_layout(); + let ctrl_on = if layout.has_alt_graph { + let alt_on = mod_state.contains(WindowsModifiers::ALT); + !alt_on && mod_state.contains(WindowsModifiers::CONTROL) + } else { + mod_state.contains(WindowsModifiers::CONTROL) + }; + + // If Ctrl is not pressed, just use the text with all + // modifiers because that already consumed the dead key. Otherwise, + // we would interpret the character incorrectly, missing the dead key. + if !ctrl_on { + event_info.text = PartialText::System(event_info.utf16parts.clone()); + } else { + let mod_no_ctrl = mod_state.remove_only_ctrl(); + let num_lock_on = kbd_state[VK_NUMLOCK as usize] & 1 != 0; + let vkey = event_info.vkey; + let physical_key = &event_info.physical_key; + let key = layout.get_key(mod_no_ctrl, num_lock_on, vkey, physical_key); + event_info.text = PartialText::Text(key.to_text().map(SmolStr::new)); + } + let ev = event_info.finalize(); + MatchResult::MessagesToDispatch(self.pending.complete_pending( + pending_token, + MessageAsKeyEvent { event: ev, is_synthetic: false }, + )) + } + }, + WM_KEYUP | WM_SYSKEYUP => { + let pending_token = self.pending.add_pending(); + *result = ProcResult::Value(0); + + let mut layouts = LAYOUT_CACHE.lock().unwrap(); + let event_info = PartialKeyEventInfo::from_message( + wparam, + lparam, + ElementState::Released, + &mut layouts, + ); + // We MUST release the layout lock before calling `next_kbd_msg`, otherwise it + // may deadlock + drop(layouts); + // It's important that we create the pending token before reading the next + // message. + let next_msg = next_kbd_msg(hwnd); + let mut valid_event_info = Some(event_info); + if let Some(next_msg) = next_msg { + let mut layouts = LAYOUT_CACHE.lock().unwrap(); + let (_, layout) = layouts.get_current_layout(); + let is_fake = { + let event_info = valid_event_info.as_ref().unwrap(); + is_current_fake(event_info, next_msg, layout) + }; + if is_fake { + valid_event_info = None; + } + } + if let Some(event_info) = valid_event_info { + let event = event_info.finalize(); + return MatchResult::MessagesToDispatch(self.pending.complete_pending( + pending_token, + MessageAsKeyEvent { event, is_synthetic: false }, + )); + } + MatchResult::TokenToRemove(pending_token) + }, + _ => MatchResult::Nothing, + } + }; + let matcher_result = matcher(); + match matcher_result { + MatchResult::TokenToRemove(t) => self.pending.remove_pending(t), + MatchResult::MessagesToDispatch(m) => m, + MatchResult::Nothing => Vec::new(), + } + } + + // Allowing nominimal_bool lint because the `is_key_pressed` macro triggers this warning + // and I don't know of another way to resolve it and also keeping the macro + #[allow(clippy::nonminimal_bool)] + fn synthesize_kbd_state( + key_state: ElementState, + kbd_state: &[u8; 256], + ) -> Vec { + let mut key_events = Vec::new(); + + let mut layouts = LAYOUT_CACHE.lock().unwrap(); + let (locale_id, _) = layouts.get_current_layout(); + + macro_rules! is_key_pressed { + ($vk:expr) => { + kbd_state[$vk as usize] & 0x80 != 0 + }; + } + + // Is caps-lock active? Note that this is different from caps-lock + // being held down. + let caps_lock_on = kbd_state[VK_CAPITAL as usize] & 1 != 0; + let num_lock_on = kbd_state[VK_NUMLOCK as usize] & 1 != 0; + + // We are synthesizing the press event for caps-lock first for the following reasons: + // 1. If caps-lock is *not* held down but *is* active, then we have to synthesize all + // printable keys, respecting the caps-lock state. + // 2. If caps-lock is held down, we could choose to synthesize its keypress after every + // other key, in which case all other keys *must* be synthesized as if the caps-lock + // state was be the opposite of what it currently is. + // -- + // For the sake of simplicity we are choosing to always synthesize + // caps-lock first, and always use the current caps-lock state + // to determine the produced text + if is_key_pressed!(VK_CAPITAL) { + let event = Self::create_synthetic( + VK_CAPITAL, + key_state, + caps_lock_on, + num_lock_on, + locale_id as HKL, + &mut layouts, + ); + if let Some(event) = event { + key_events.push(event); + } + } + let do_non_modifier = |key_events: &mut Vec<_>, layouts: &mut _| { + for vk in 0..256 { + match vk { + VK_CONTROL | VK_LCONTROL | VK_RCONTROL | VK_SHIFT | VK_LSHIFT | VK_RSHIFT + | VK_MENU | VK_LMENU | VK_RMENU | VK_CAPITAL => continue, + _ => (), + } + if !is_key_pressed!(vk) { + continue; + } + let event = Self::create_synthetic( + vk, + key_state, + caps_lock_on, + num_lock_on, + locale_id as HKL, + layouts, + ); + if let Some(event) = event { + key_events.push(event); + } + } + }; + let do_modifier = |key_events: &mut Vec<_>, layouts: &mut _| { + const CLEAR_MODIFIER_VKS: [VIRTUAL_KEY; 6] = + [VK_LCONTROL, VK_LSHIFT, VK_LMENU, VK_RCONTROL, VK_RSHIFT, VK_RMENU]; + for vk in CLEAR_MODIFIER_VKS.iter() { + if is_key_pressed!(*vk) { + let event = Self::create_synthetic( + *vk, + key_state, + caps_lock_on, + num_lock_on, + locale_id as HKL, + layouts, + ); + if let Some(event) = event { + key_events.push(event); + } + } + } + }; + + // Be cheeky and sequence modifier and non-modifier + // key events such that non-modifier keys are not affected + // by modifiers (except for caps-lock) + match key_state { + ElementState::Pressed => { + do_non_modifier(&mut key_events, &mut layouts); + do_modifier(&mut key_events, &mut layouts); + }, + ElementState::Released => { + do_modifier(&mut key_events, &mut layouts); + do_non_modifier(&mut key_events, &mut layouts); + }, + } + + key_events + } + + fn create_synthetic( + vk: VIRTUAL_KEY, + key_state: ElementState, + caps_lock_on: bool, + num_lock_on: bool, + locale_id: HKL, + layouts: &mut MutexGuard<'_, LayoutCache>, + ) -> Option { + let scancode = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, locale_id) }; + if scancode == 0 { + return None; + } + let scancode = scancode as ExScancode; + let physical_key = scancode_to_physicalkey(scancode as u32); + let mods = + if caps_lock_on { WindowsModifiers::CAPS_LOCK } else { WindowsModifiers::empty() }; + let layout = layouts.layouts.get(&(locale_id as u64)).unwrap(); + let logical_key = layout.get_key(mods, num_lock_on, vk, &physical_key); + let key_without_modifiers = + layout.get_key(WindowsModifiers::empty(), false, vk, &physical_key); + let text = if key_state == ElementState::Pressed { + logical_key.to_text().map(SmolStr::new) + } else { + None + }; + let event_info = PartialKeyEventInfo { + vkey: vk, + logical_key: PartialLogicalKey::This(logical_key.clone()), + key_without_modifiers, + key_state, + is_repeat: false, + physical_key, + location: get_location(scancode, locale_id), + utf16parts: Vec::with_capacity(8), + text: PartialText::Text(text.clone()), + }; + + let mut event = event_info.finalize(); + event.logical_key = logical_key; + event.platform_specific.text_with_all_modifiers = text; + Some(MessageAsKeyEvent { event, is_synthetic: true }) + } +} + +enum PartialText { + // Unicode + System(Vec), + Text(Option), +} + +enum PartialLogicalKey { + /// Use the text provided by the WM_CHAR messages and report that as a `Character` variant. If + /// the text consists of multiple grapheme clusters (user-perceived characters) that means that + /// dead key could not be combined with the second input, and in that case we should fall back + /// to using what would have without a dead-key input. + TextOr(Key), + + /// Use the value directly provided by this variant + This(Key), +} + +struct PartialKeyEventInfo { + vkey: VIRTUAL_KEY, + key_state: ElementState, + is_repeat: bool, + physical_key: PhysicalKey, + location: KeyLocation, + logical_key: PartialLogicalKey, + + key_without_modifiers: Key, + + /// The UTF-16 code units of the text that was produced by the keypress event. + /// This take all modifiers into account. Including CTRL + utf16parts: Vec, + + text: PartialText, +} + +impl PartialKeyEventInfo { + fn from_message( + wparam: WPARAM, + lparam: LPARAM, + state: ElementState, + layouts: &mut MutexGuard<'_, LayoutCache>, + ) -> Self { + const NO_MODS: WindowsModifiers = WindowsModifiers::empty(); + + let (_, layout) = layouts.get_current_layout(); + let lparam_struct = destructure_key_lparam(lparam); + let vkey = wparam as VIRTUAL_KEY; + let scancode = if lparam_struct.scancode == 0 { + // In some cases (often with media keys) the device reports a scancode of 0 but a + // valid virtual key. In these cases we obtain the scancode from the virtual key. + unsafe { MapVirtualKeyExW(vkey as u32, MAPVK_VK_TO_VSC_EX, layout.hkl as HKL) as u16 } + } else { + new_ex_scancode(lparam_struct.scancode, lparam_struct.extended) + }; + let physical_key = scancode_to_physicalkey(scancode as u32); + let location = get_location(scancode, layout.hkl as HKL); + + let kbd_state = get_kbd_state(); + let mods = WindowsModifiers::active_modifiers(&kbd_state); + let mods_without_ctrl = mods.remove_only_ctrl(); + let num_lock_on = kbd_state[VK_NUMLOCK as usize] & 1 != 0; + + // On Windows Ctrl+NumLock = Pause (and apparently Ctrl+Pause -> NumLock). In these cases + // the KeyCode still stores the real key, so in the name of consistency across platforms, we + // circumvent this mapping and force the key values to match the keycode. + // For more on this, read the article by Raymond Chen, titled: + // "Why does Ctrl+ScrollLock cancel dialogs?" + // https://devblogs.microsoft.com/oldnewthing/20080211-00/?p=23503 + let code_as_key = if mods.contains(WindowsModifiers::CONTROL) { + match physical_key { + PhysicalKey::Code(KeyCode::NumLock) => Some(Key::Named(NamedKey::NumLock)), + PhysicalKey::Code(KeyCode::Pause) => Some(Key::Named(NamedKey::Pause)), + _ => None, + } + } else { + None + }; + + let preliminary_logical_key = + layout.get_key(mods_without_ctrl, num_lock_on, vkey, &physical_key); + let key_is_char = matches!(preliminary_logical_key, Key::Character(_)); + let is_pressed = state == ElementState::Pressed; + + let logical_key = if let Some(key) = code_as_key.clone() { + PartialLogicalKey::This(key) + } else if is_pressed && key_is_char && !mods.contains(WindowsModifiers::CONTROL) { + // In some cases we want to use the UNICHAR text for logical_key in order to allow + // dead keys to have an effect on the character reported by `logical_key`. + PartialLogicalKey::TextOr(preliminary_logical_key) + } else { + PartialLogicalKey::This(preliminary_logical_key) + }; + let key_without_modifiers = if let Some(key) = code_as_key { + key + } else { + match layout.get_key(NO_MODS, false, vkey, &physical_key) { + // We convert dead keys into their character. + // The reason for this is that `key_without_modifiers` is designed for key-bindings, + // but the US International layout treats `'` (apostrophe) as a dead key and the + // regular US layout treats it a character. In order for a single binding + // configuration to work with both layouts, we forward each dead key as a character. + Key::Dead(k) => { + if let Some(ch) = k { + // I'm avoiding the heap allocation. I don't want to talk about it :( + let mut utf8 = [0; 4]; + let s = ch.encode_utf8(&mut utf8); + Key::Character(SmolStr::new(s)) + } else { + Key::Unidentified(NativeKey::Unidentified) + } + }, + key => key, + } + }; + + PartialKeyEventInfo { + vkey, + key_state: state, + logical_key, + key_without_modifiers, + is_repeat: lparam_struct.is_repeat, + physical_key, + location, + utf16parts: Vec::with_capacity(8), + text: PartialText::System(Vec::new()), + } + } + + fn finalize(self) -> KeyEvent { + let mut char_with_all_modifiers = None; + if !self.utf16parts.is_empty() { + let os_string = OsString::from_wide(&self.utf16parts); + if let Ok(string) = os_string.into_string() { + char_with_all_modifiers = Some(SmolStr::new(string)); + } + } + + // The text without Ctrl + let mut text = None; + match self.text { + PartialText::System(wide) => { + if !wide.is_empty() { + let os_string = OsString::from_wide(&wide); + if let Ok(string) = os_string.into_string() { + text = Some(SmolStr::new(string)); + } + } + }, + PartialText::Text(s) => { + text = s.map(SmolStr::new); + }, + } + + let logical_key = match self.logical_key { + PartialLogicalKey::TextOr(fallback) => match text.as_ref() { + Some(s) => { + if s.grapheme_indices(true).count() > 1 { + fallback + } else { + Key::Character(s.clone()) + } + }, + None => Key::Unidentified(NativeKey::Windows(self.vkey)), + }, + PartialLogicalKey::This(v) => v, + }; + + KeyEvent { + physical_key: self.physical_key, + logical_key, + text, + location: self.location, + state: self.key_state, + repeat: self.is_repeat, + platform_specific: KeyEventExtra { + text_with_all_modifiers: char_with_all_modifiers, + key_without_modifiers: self.key_without_modifiers, + }, + } + } +} + +#[derive(Debug, Copy, Clone)] +struct KeyLParam { + pub scancode: u8, + pub extended: bool, + + /// This is `previous_state XOR transition_state`. See the lParam for WM_KEYDOWN and WM_KEYUP + /// for further details. + pub is_repeat: bool, +} + +fn destructure_key_lparam(lparam: LPARAM) -> KeyLParam { + let previous_state = (lparam >> 30) & 0x01; + let transition_state = (lparam >> 31) & 0x01; + KeyLParam { + scancode: ((lparam >> 16) & 0xff) as u8, + extended: ((lparam >> 24) & 0x01) != 0, + is_repeat: (previous_state ^ transition_state) != 0, + } +} + +#[inline] +fn new_ex_scancode(scancode: u8, extended: bool) -> ExScancode { + (scancode as u16) | (if extended { 0xe000 } else { 0 }) +} + +#[inline] +fn ex_scancode_from_lparam(lparam: LPARAM) -> ExScancode { + let lparam = destructure_key_lparam(lparam); + new_ex_scancode(lparam.scancode, lparam.extended) +} + +/// Gets the keyboard state as reported by messages that have been removed from the event queue. +/// See also: get_async_kbd_state +fn get_kbd_state() -> [u8; 256] { + unsafe { + let mut kbd_state: MaybeUninit<[u8; 256]> = MaybeUninit::uninit(); + GetKeyboardState(kbd_state.as_mut_ptr() as *mut u8); + kbd_state.assume_init() + } +} + +/// Gets the current keyboard state regardless of whether the corresponding keyboard events have +/// been removed from the event queue. See also: get_kbd_state +#[allow(clippy::uninit_assumed_init)] +fn get_async_kbd_state() -> [u8; 256] { + unsafe { + let mut kbd_state: [u8; 256] = [0; 256]; + for (vk, state) in kbd_state.iter_mut().enumerate() { + let vk = vk as VIRTUAL_KEY; + let async_state = GetAsyncKeyState(vk as i32); + let is_down = (async_state & (1 << 15)) != 0; + *state = if is_down { 0x80 } else { 0 }; + + if matches!(vk, VK_CAPITAL | VK_NUMLOCK | VK_SCROLL) { + // Toggle states aren't reported by `GetAsyncKeyState` + let toggle_state = GetKeyState(vk as i32); + let is_active = (toggle_state & 1) != 0; + *state |= u8::from(is_active); + } + } + kbd_state + } +} + +/// On windows, AltGr == Ctrl + Alt +/// +/// Due to this equivalence, the system generates a fake Ctrl key-press (and key-release) preceding +/// every AltGr key-press (and key-release). We check if the current event is a Ctrl event and if +/// the next event is a right Alt (AltGr) event. If this is the case, the current event must be the +/// fake Ctrl event. +fn is_current_fake(curr_info: &PartialKeyEventInfo, next_msg: MSG, layout: &Layout) -> bool { + let curr_is_ctrl = + matches!(curr_info.logical_key, PartialLogicalKey::This(Key::Named(NamedKey::Control))); + if layout.has_alt_graph { + let next_code = ex_scancode_from_lparam(next_msg.lParam); + let next_is_altgr = next_code == 0xe038; // 0xE038 is right alt + if curr_is_ctrl && next_is_altgr { + return true; + } + } + false +} + +enum PendingMessage { + Incomplete, + Complete(T), +} +struct IdentifiedPendingMessage { + token: PendingMessageToken, + msg: PendingMessage, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PendingMessageToken(u32); + +/// While processing keyboard events, we sometimes need +/// to call `PeekMessageW` (`next_msg`). But `PeekMessageW` +/// can also call the event handler, which means that the new event +/// gets processed before finishing to process the one that came before. +/// +/// This would mean that the application receives events in the wrong order. +/// To avoid this, we keep track whether we are in the middle of processing +/// an event. Such an event is an "incomplete pending event". A +/// "complete pending event" is one that has already finished processing, but +/// hasn't been dispatched to the application because there still are incomplete +/// pending events that came before it. +/// +/// When we finish processing an event, we call `complete_pending`, +/// which returns an empty array if there are incomplete pending events, but +/// if all pending events are complete, then it returns all pending events in +/// the order they were encountered. These can then be dispatched to the application +pub struct PendingEventQueue { + pending: Mutex>>, + next_id: AtomicU32, +} +impl PendingEventQueue { + /// Add a new pending event to the "pending queue" + pub fn add_pending(&self) -> PendingMessageToken { + let token = self.next_token(); + let mut pending = self.pending.lock().unwrap(); + pending.push(IdentifiedPendingMessage { token, msg: PendingMessage::Incomplete }); + token + } + + /// Returns all finished pending events + /// + /// If the return value is non empty, it's guaranteed to contain `msg` + /// + /// See also: `add_pending` + pub fn complete_pending(&self, token: PendingMessageToken, msg: T) -> Vec { + let mut pending = self.pending.lock().unwrap(); + let mut target_is_first = false; + for (i, pending_msg) in pending.iter_mut().enumerate() { + if pending_msg.token == token { + pending_msg.msg = PendingMessage::Complete(msg); + if i == 0 { + target_is_first = true; + } + break; + } + } + if target_is_first { + // If the message that we just finished was the first one in the pending queue, + // then we can empty the queue, and dispatch all of the messages. + Self::drain_pending(&mut *pending) + } else { + Vec::new() + } + } + + pub fn complete_multi(&self, msgs: Vec) -> Vec { + let mut pending = self.pending.lock().unwrap(); + if pending.is_empty() { + return msgs; + } + pending.reserve(msgs.len()); + for msg in msgs { + pending.push(IdentifiedPendingMessage { + token: self.next_token(), + msg: PendingMessage::Complete(msg), + }); + } + Vec::new() + } + + /// Returns all finished pending events + /// + /// It's safe to call this even if the element isn't in the list anymore + /// + /// See also: `add_pending` + pub fn remove_pending(&self, token: PendingMessageToken) -> Vec { + let mut pending = self.pending.lock().unwrap(); + let mut was_first = false; + if let Some(m) = pending.first() { + if m.token == token { + was_first = true; + } + } + pending.retain(|m| m.token != token); + if was_first { + Self::drain_pending(&mut *pending) + } else { + Vec::new() + } + } + + fn drain_pending(pending: &mut Vec>) -> Vec { + pending + .drain(..) + .map(|m| match m.msg { + PendingMessage::Complete(msg) => msg, + PendingMessage::Incomplete => { + panic!( + "Found an incomplete pending message when collecting messages. This \ + indicates a bug in winit." + ) + }, + }) + .collect() + } + + fn next_token(&self) -> PendingMessageToken { + // It's okay for the u32 to overflow here. Yes, that could mean + // that two different messages have the same token, + // but that would only happen after having about 4 billion + // messages sitting in the pending queue. + // + // In that case, having two identical tokens is the least of your concerns. + let id = self.next_id.fetch_add(1, Relaxed); + PendingMessageToken(id) + } +} +impl Default for PendingEventQueue { + fn default() -> Self { + PendingEventQueue { pending: Mutex::new(Vec::new()), next_id: AtomicU32::new(0) } + } +} + +/// WARNING: Due to using PeekMessage, the event handler +/// function may get called during this function. +/// (Re-entrance to the event handler) +/// +/// This can cause a deadlock if calling this function +/// while having a mutex locked. +/// +/// It can also cause code to get executed in a surprising order. +pub fn next_kbd_msg(hwnd: HWND) -> Option { + unsafe { + let mut next_msg = MaybeUninit::uninit(); + let peek_retval = + PeekMessageW(next_msg.as_mut_ptr(), hwnd, WM_KEYFIRST, WM_KEYLAST, PM_NOREMOVE); + (peek_retval != 0).then(|| next_msg.assume_init()) + } +} + +fn get_location(scancode: ExScancode, hkl: HKL) -> KeyLocation { + const ABNT_C2: VIRTUAL_KEY = VK_ABNT_C2 as VIRTUAL_KEY; + + let extension = 0xe000; + let extended = (scancode & extension) == extension; + let vkey = unsafe { MapVirtualKeyExW(scancode as u32, MAPVK_VSC_TO_VK_EX, hkl) as VIRTUAL_KEY }; + + // Use the native VKEY and the extended flag to cover most cases + // This is taken from the `druid` GUI library, specifically + // druid-shell/src/platform/windows/keyboard.rs + match vkey { + VK_LSHIFT | VK_LCONTROL | VK_LMENU | VK_LWIN => KeyLocation::Left, + VK_RSHIFT | VK_RCONTROL | VK_RMENU | VK_RWIN => KeyLocation::Right, + VK_RETURN if extended => KeyLocation::Numpad, + VK_INSERT | VK_DELETE | VK_END | VK_DOWN | VK_NEXT | VK_LEFT | VK_CLEAR | VK_RIGHT + | VK_HOME | VK_UP | VK_PRIOR => { + if extended { + KeyLocation::Standard + } else { + KeyLocation::Numpad + } + }, + VK_NUMPAD0 | VK_NUMPAD1 | VK_NUMPAD2 | VK_NUMPAD3 | VK_NUMPAD4 | VK_NUMPAD5 + | VK_NUMPAD6 | VK_NUMPAD7 | VK_NUMPAD8 | VK_NUMPAD9 | VK_DECIMAL | VK_DIVIDE + | VK_MULTIPLY | VK_SUBTRACT | VK_ADD | ABNT_C2 => KeyLocation::Numpad, + _ => KeyLocation::Standard, + } +} + +pub(crate) fn physicalkey_to_scancode(physical_key: PhysicalKey) -> Option { + // See `scancode_to_physicalkey` for more info + + let hkl = unsafe { GetKeyboardLayout(0) }; + + let primary_lang_id = primarylangid(loword(hkl as u32)); + let is_korean = primary_lang_id as u32 == LANG_KOREAN; + + let code = match physical_key { + PhysicalKey::Code(code) => code, + PhysicalKey::Unidentified(code) => { + return match code { + NativeKeyCode::Windows(scancode) => Some(scancode as u32), + _ => None, + }; + }, + }; + + match code { + KeyCode::Backquote => Some(0x0029), + KeyCode::Backslash => Some(0x002b), + KeyCode::Backspace => Some(0x000e), + KeyCode::BracketLeft => Some(0x001a), + KeyCode::BracketRight => Some(0x001b), + KeyCode::Comma => Some(0x0033), + KeyCode::Digit0 => Some(0x000b), + KeyCode::Digit1 => Some(0x0002), + KeyCode::Digit2 => Some(0x0003), + KeyCode::Digit3 => Some(0x0004), + KeyCode::Digit4 => Some(0x0005), + KeyCode::Digit5 => Some(0x0006), + KeyCode::Digit6 => Some(0x0007), + KeyCode::Digit7 => Some(0x0008), + KeyCode::Digit8 => Some(0x0009), + KeyCode::Digit9 => Some(0x000a), + KeyCode::Equal => Some(0x000d), + KeyCode::IntlBackslash => Some(0x0056), + KeyCode::IntlRo => Some(0x0073), + KeyCode::IntlYen => Some(0x007d), + KeyCode::KeyA => Some(0x001e), + KeyCode::KeyB => Some(0x0030), + KeyCode::KeyC => Some(0x002e), + KeyCode::KeyD => Some(0x0020), + KeyCode::KeyE => Some(0x0012), + KeyCode::KeyF => Some(0x0021), + KeyCode::KeyG => Some(0x0022), + KeyCode::KeyH => Some(0x0023), + KeyCode::KeyI => Some(0x0017), + KeyCode::KeyJ => Some(0x0024), + KeyCode::KeyK => Some(0x0025), + KeyCode::KeyL => Some(0x0026), + KeyCode::KeyM => Some(0x0032), + KeyCode::KeyN => Some(0x0031), + KeyCode::KeyO => Some(0x0018), + KeyCode::KeyP => Some(0x0019), + KeyCode::KeyQ => Some(0x0010), + KeyCode::KeyR => Some(0x0013), + KeyCode::KeyS => Some(0x001f), + KeyCode::KeyT => Some(0x0014), + KeyCode::KeyU => Some(0x0016), + KeyCode::KeyV => Some(0x002f), + KeyCode::KeyW => Some(0x0011), + KeyCode::KeyX => Some(0x002d), + KeyCode::KeyY => Some(0x0015), + KeyCode::KeyZ => Some(0x002c), + KeyCode::Minus => Some(0x000c), + KeyCode::Period => Some(0x0034), + KeyCode::Quote => Some(0x0028), + KeyCode::Semicolon => Some(0x0027), + KeyCode::Slash => Some(0x0035), + KeyCode::AltLeft => Some(0x0038), + KeyCode::AltRight => Some(0xe038), + KeyCode::CapsLock => Some(0x003a), + KeyCode::ContextMenu => Some(0xe05d), + KeyCode::ControlLeft => Some(0x001d), + KeyCode::ControlRight => Some(0xe01d), + KeyCode::Enter => Some(0x001c), + KeyCode::SuperLeft => Some(0xe05b), + KeyCode::SuperRight => Some(0xe05c), + KeyCode::ShiftLeft => Some(0x002a), + KeyCode::ShiftRight => Some(0x0036), + KeyCode::Space => Some(0x0039), + KeyCode::Tab => Some(0x000f), + KeyCode::Convert => Some(0x0079), + KeyCode::Lang1 => { + if is_korean { + Some(0xe0f2) + } else { + Some(0x0072) + } + }, + KeyCode::Lang2 => { + if is_korean { + Some(0xe0f1) + } else { + Some(0x0071) + } + }, + KeyCode::KanaMode => Some(0x0070), + KeyCode::NonConvert => Some(0x007b), + KeyCode::Delete => Some(0xe053), + KeyCode::End => Some(0xe04f), + KeyCode::Home => Some(0xe047), + KeyCode::Insert => Some(0xe052), + KeyCode::PageDown => Some(0xe051), + KeyCode::PageUp => Some(0xe049), + KeyCode::ArrowDown => Some(0xe050), + KeyCode::ArrowLeft => Some(0xe04b), + KeyCode::ArrowRight => Some(0xe04d), + KeyCode::ArrowUp => Some(0xe048), + KeyCode::NumLock => Some(0xe045), + KeyCode::Numpad0 => Some(0x0052), + KeyCode::Numpad1 => Some(0x004f), + KeyCode::Numpad2 => Some(0x0050), + KeyCode::Numpad3 => Some(0x0051), + KeyCode::Numpad4 => Some(0x004b), + KeyCode::Numpad5 => Some(0x004c), + KeyCode::Numpad6 => Some(0x004d), + KeyCode::Numpad7 => Some(0x0047), + KeyCode::Numpad8 => Some(0x0048), + KeyCode::Numpad9 => Some(0x0049), + KeyCode::NumpadAdd => Some(0x004e), + KeyCode::NumpadComma => Some(0x007e), + KeyCode::NumpadDecimal => Some(0x0053), + KeyCode::NumpadDivide => Some(0xe035), + KeyCode::NumpadEnter => Some(0xe01c), + KeyCode::NumpadEqual => Some(0x0059), + KeyCode::NumpadMultiply => Some(0x0037), + KeyCode::NumpadSubtract => Some(0x004a), + KeyCode::Escape => Some(0x0001), + KeyCode::F1 => Some(0x003b), + KeyCode::F2 => Some(0x003c), + KeyCode::F3 => Some(0x003d), + KeyCode::F4 => Some(0x003e), + KeyCode::F5 => Some(0x003f), + KeyCode::F6 => Some(0x0040), + KeyCode::F7 => Some(0x0041), + KeyCode::F8 => Some(0x0042), + KeyCode::F9 => Some(0x0043), + KeyCode::F10 => Some(0x0044), + KeyCode::F11 => Some(0x0057), + KeyCode::F12 => Some(0x0058), + KeyCode::F13 => Some(0x0064), + KeyCode::F14 => Some(0x0065), + KeyCode::F15 => Some(0x0066), + KeyCode::F16 => Some(0x0067), + KeyCode::F17 => Some(0x0068), + KeyCode::F18 => Some(0x0069), + KeyCode::F19 => Some(0x006a), + KeyCode::F20 => Some(0x006b), + KeyCode::F21 => Some(0x006c), + KeyCode::F22 => Some(0x006d), + KeyCode::F23 => Some(0x006e), + KeyCode::F24 => Some(0x0076), + KeyCode::PrintScreen => Some(0xe037), + // KeyCode::PrintScreen => Some(0x0054), // Alt + PrintScreen + KeyCode::ScrollLock => Some(0x0046), + KeyCode::Pause => Some(0x0045), + // KeyCode::Pause => Some(0xE046), // Ctrl + Pause + KeyCode::BrowserBack => Some(0xe06a), + KeyCode::BrowserFavorites => Some(0xe066), + KeyCode::BrowserForward => Some(0xe069), + KeyCode::BrowserHome => Some(0xe032), + KeyCode::BrowserRefresh => Some(0xe067), + KeyCode::BrowserSearch => Some(0xe065), + KeyCode::BrowserStop => Some(0xe068), + KeyCode::LaunchApp1 => Some(0xe06b), + KeyCode::LaunchApp2 => Some(0xe021), + KeyCode::LaunchMail => Some(0xe06c), + KeyCode::MediaPlayPause => Some(0xe022), + KeyCode::MediaSelect => Some(0xe06d), + KeyCode::MediaStop => Some(0xe024), + KeyCode::MediaTrackNext => Some(0xe019), + KeyCode::MediaTrackPrevious => Some(0xe010), + KeyCode::Power => Some(0xe05e), + KeyCode::AudioVolumeDown => Some(0xe02e), + KeyCode::AudioVolumeMute => Some(0xe020), + KeyCode::AudioVolumeUp => Some(0xe030), + _ => None, + } +} + +pub(crate) fn scancode_to_physicalkey(scancode: u32) -> PhysicalKey { + // See: https://www.win.tue.nl/~aeb/linux/kbd/scancodes-1.html + // and: https://www.w3.org/TR/uievents-code/ + // and: The widget/NativeKeyToDOMCodeName.h file in the firefox source + + PhysicalKey::Code(match scancode { + 0x0029 => KeyCode::Backquote, + 0x002b => KeyCode::Backslash, + 0x000e => KeyCode::Backspace, + 0x001a => KeyCode::BracketLeft, + 0x001b => KeyCode::BracketRight, + 0x0033 => KeyCode::Comma, + 0x000b => KeyCode::Digit0, + 0x0002 => KeyCode::Digit1, + 0x0003 => KeyCode::Digit2, + 0x0004 => KeyCode::Digit3, + 0x0005 => KeyCode::Digit4, + 0x0006 => KeyCode::Digit5, + 0x0007 => KeyCode::Digit6, + 0x0008 => KeyCode::Digit7, + 0x0009 => KeyCode::Digit8, + 0x000a => KeyCode::Digit9, + 0x000d => KeyCode::Equal, + 0x0056 => KeyCode::IntlBackslash, + 0x0073 => KeyCode::IntlRo, + 0x007d => KeyCode::IntlYen, + 0x001e => KeyCode::KeyA, + 0x0030 => KeyCode::KeyB, + 0x002e => KeyCode::KeyC, + 0x0020 => KeyCode::KeyD, + 0x0012 => KeyCode::KeyE, + 0x0021 => KeyCode::KeyF, + 0x0022 => KeyCode::KeyG, + 0x0023 => KeyCode::KeyH, + 0x0017 => KeyCode::KeyI, + 0x0024 => KeyCode::KeyJ, + 0x0025 => KeyCode::KeyK, + 0x0026 => KeyCode::KeyL, + 0x0032 => KeyCode::KeyM, + 0x0031 => KeyCode::KeyN, + 0x0018 => KeyCode::KeyO, + 0x0019 => KeyCode::KeyP, + 0x0010 => KeyCode::KeyQ, + 0x0013 => KeyCode::KeyR, + 0x001f => KeyCode::KeyS, + 0x0014 => KeyCode::KeyT, + 0x0016 => KeyCode::KeyU, + 0x002f => KeyCode::KeyV, + 0x0011 => KeyCode::KeyW, + 0x002d => KeyCode::KeyX, + 0x0015 => KeyCode::KeyY, + 0x002c => KeyCode::KeyZ, + 0x000c => KeyCode::Minus, + 0x0034 => KeyCode::Period, + 0x0028 => KeyCode::Quote, + 0x0027 => KeyCode::Semicolon, + 0x0035 => KeyCode::Slash, + 0x0038 => KeyCode::AltLeft, + 0xe038 => KeyCode::AltRight, + 0x003a => KeyCode::CapsLock, + 0xe05d => KeyCode::ContextMenu, + 0x001d => KeyCode::ControlLeft, + 0xe01d => KeyCode::ControlRight, + 0x001c => KeyCode::Enter, + 0xe05b => KeyCode::SuperLeft, + 0xe05c => KeyCode::SuperRight, + 0x002a => KeyCode::ShiftLeft, + 0x0036 => KeyCode::ShiftRight, + 0x0039 => KeyCode::Space, + 0x000f => KeyCode::Tab, + 0x0079 => KeyCode::Convert, + 0x0072 => KeyCode::Lang1, // for non-Korean layout + 0xe0f2 => KeyCode::Lang1, // for Korean layout + 0x0071 => KeyCode::Lang2, // for non-Korean layout + 0xe0f1 => KeyCode::Lang2, // for Korean layout + 0x0070 => KeyCode::KanaMode, + 0x007b => KeyCode::NonConvert, + 0xe053 => KeyCode::Delete, + 0xe04f => KeyCode::End, + 0xe047 => KeyCode::Home, + 0xe052 => KeyCode::Insert, + 0xe051 => KeyCode::PageDown, + 0xe049 => KeyCode::PageUp, + 0xe050 => KeyCode::ArrowDown, + 0xe04b => KeyCode::ArrowLeft, + 0xe04d => KeyCode::ArrowRight, + 0xe048 => KeyCode::ArrowUp, + 0xe045 => KeyCode::NumLock, + 0x0052 => KeyCode::Numpad0, + 0x004f => KeyCode::Numpad1, + 0x0050 => KeyCode::Numpad2, + 0x0051 => KeyCode::Numpad3, + 0x004b => KeyCode::Numpad4, + 0x004c => KeyCode::Numpad5, + 0x004d => KeyCode::Numpad6, + 0x0047 => KeyCode::Numpad7, + 0x0048 => KeyCode::Numpad8, + 0x0049 => KeyCode::Numpad9, + 0x004e => KeyCode::NumpadAdd, + 0x007e => KeyCode::NumpadComma, + 0x0053 => KeyCode::NumpadDecimal, + 0xe035 => KeyCode::NumpadDivide, + 0xe01c => KeyCode::NumpadEnter, + 0x0059 => KeyCode::NumpadEqual, + 0x0037 => KeyCode::NumpadMultiply, + 0x004a => KeyCode::NumpadSubtract, + 0x0001 => KeyCode::Escape, + 0x003b => KeyCode::F1, + 0x003c => KeyCode::F2, + 0x003d => KeyCode::F3, + 0x003e => KeyCode::F4, + 0x003f => KeyCode::F5, + 0x0040 => KeyCode::F6, + 0x0041 => KeyCode::F7, + 0x0042 => KeyCode::F8, + 0x0043 => KeyCode::F9, + 0x0044 => KeyCode::F10, + 0x0057 => KeyCode::F11, + 0x0058 => KeyCode::F12, + 0x0064 => KeyCode::F13, + 0x0065 => KeyCode::F14, + 0x0066 => KeyCode::F15, + 0x0067 => KeyCode::F16, + 0x0068 => KeyCode::F17, + 0x0069 => KeyCode::F18, + 0x006a => KeyCode::F19, + 0x006b => KeyCode::F20, + 0x006c => KeyCode::F21, + 0x006d => KeyCode::F22, + 0x006e => KeyCode::F23, + 0x0076 => KeyCode::F24, + 0xe037 => KeyCode::PrintScreen, + 0x0054 => KeyCode::PrintScreen, // Alt + PrintScreen + 0x0046 => KeyCode::ScrollLock, + 0x0045 => KeyCode::Pause, + 0xe046 => KeyCode::Pause, // Ctrl + Pause + 0xe06a => KeyCode::BrowserBack, + 0xe066 => KeyCode::BrowserFavorites, + 0xe069 => KeyCode::BrowserForward, + 0xe032 => KeyCode::BrowserHome, + 0xe067 => KeyCode::BrowserRefresh, + 0xe065 => KeyCode::BrowserSearch, + 0xe068 => KeyCode::BrowserStop, + 0xe06b => KeyCode::LaunchApp1, + 0xe021 => KeyCode::LaunchApp2, + 0xe06c => KeyCode::LaunchMail, + 0xe022 => KeyCode::MediaPlayPause, + 0xe06d => KeyCode::MediaSelect, + 0xe024 => KeyCode::MediaStop, + 0xe019 => KeyCode::MediaTrackNext, + 0xe010 => KeyCode::MediaTrackPrevious, + 0xe05e => KeyCode::Power, + 0xe02e => KeyCode::AudioVolumeDown, + 0xe020 => KeyCode::AudioVolumeMute, + 0xe030 => KeyCode::AudioVolumeUp, + _ => return PhysicalKey::Unidentified(NativeKeyCode::Windows(scancode as u16)), + }) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/keyboard_layout.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/keyboard_layout.rs new file mode 100644 index 00000000..5340e43d --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/keyboard_layout.rs @@ -0,0 +1,986 @@ +use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::os::windows::ffi::OsStringExt; +use std::sync::Mutex; + +use crate::utils::Lazy; +use smol_str::SmolStr; +use windows_sys::Win32::System::SystemServices::{LANG_JAPANESE, LANG_KOREAN}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + GetKeyState, GetKeyboardLayout, MapVirtualKeyExW, ToUnicodeEx, MAPVK_VK_TO_VSC_EX, VIRTUAL_KEY, + VK_ACCEPT, VK_ADD, VK_APPS, VK_ATTN, VK_BACK, VK_BROWSER_BACK, VK_BROWSER_FAVORITES, + VK_BROWSER_FORWARD, VK_BROWSER_HOME, VK_BROWSER_REFRESH, VK_BROWSER_SEARCH, VK_BROWSER_STOP, + VK_CANCEL, VK_CAPITAL, VK_CLEAR, VK_CONTROL, VK_CONVERT, VK_CRSEL, VK_DECIMAL, VK_DELETE, + VK_DIVIDE, VK_DOWN, VK_END, VK_EREOF, VK_ESCAPE, VK_EXECUTE, VK_EXSEL, VK_F1, VK_F10, VK_F11, + VK_F12, VK_F13, VK_F14, VK_F15, VK_F16, VK_F17, VK_F18, VK_F19, VK_F2, VK_F20, VK_F21, VK_F22, + VK_F23, VK_F24, VK_F3, VK_F4, VK_F5, VK_F6, VK_F7, VK_F8, VK_F9, VK_FINAL, VK_GAMEPAD_A, + VK_GAMEPAD_B, VK_GAMEPAD_DPAD_DOWN, VK_GAMEPAD_DPAD_LEFT, VK_GAMEPAD_DPAD_RIGHT, + VK_GAMEPAD_DPAD_UP, VK_GAMEPAD_LEFT_SHOULDER, VK_GAMEPAD_LEFT_THUMBSTICK_BUTTON, + VK_GAMEPAD_LEFT_THUMBSTICK_DOWN, VK_GAMEPAD_LEFT_THUMBSTICK_LEFT, + VK_GAMEPAD_LEFT_THUMBSTICK_RIGHT, VK_GAMEPAD_LEFT_THUMBSTICK_UP, VK_GAMEPAD_LEFT_TRIGGER, + VK_GAMEPAD_MENU, VK_GAMEPAD_RIGHT_SHOULDER, VK_GAMEPAD_RIGHT_THUMBSTICK_BUTTON, + VK_GAMEPAD_RIGHT_THUMBSTICK_DOWN, VK_GAMEPAD_RIGHT_THUMBSTICK_LEFT, + VK_GAMEPAD_RIGHT_THUMBSTICK_RIGHT, VK_GAMEPAD_RIGHT_THUMBSTICK_UP, VK_GAMEPAD_RIGHT_TRIGGER, + VK_GAMEPAD_VIEW, VK_GAMEPAD_X, VK_GAMEPAD_Y, VK_HANGUL, VK_HANJA, VK_HELP, VK_HOME, VK_ICO_00, + VK_ICO_CLEAR, VK_ICO_HELP, VK_INSERT, VK_JUNJA, VK_KANA, VK_KANJI, VK_LAUNCH_APP1, + VK_LAUNCH_APP2, VK_LAUNCH_MAIL, VK_LAUNCH_MEDIA_SELECT, VK_LBUTTON, VK_LCONTROL, VK_LEFT, + VK_LMENU, VK_LSHIFT, VK_LWIN, VK_MBUTTON, VK_MEDIA_NEXT_TRACK, VK_MEDIA_PLAY_PAUSE, + VK_MEDIA_PREV_TRACK, VK_MEDIA_STOP, VK_MENU, VK_MODECHANGE, VK_MULTIPLY, VK_NAVIGATION_ACCEPT, + VK_NAVIGATION_CANCEL, VK_NAVIGATION_DOWN, VK_NAVIGATION_LEFT, VK_NAVIGATION_MENU, + VK_NAVIGATION_RIGHT, VK_NAVIGATION_UP, VK_NAVIGATION_VIEW, VK_NEXT, VK_NONAME, VK_NONCONVERT, + VK_NUMLOCK, VK_NUMPAD0, VK_NUMPAD1, VK_NUMPAD2, VK_NUMPAD3, VK_NUMPAD4, VK_NUMPAD5, VK_NUMPAD6, + VK_NUMPAD7, VK_NUMPAD8, VK_NUMPAD9, VK_OEM_1, VK_OEM_102, VK_OEM_2, VK_OEM_3, VK_OEM_4, + VK_OEM_5, VK_OEM_6, VK_OEM_7, VK_OEM_8, VK_OEM_ATTN, VK_OEM_AUTO, VK_OEM_AX, VK_OEM_BACKTAB, + VK_OEM_CLEAR, VK_OEM_COMMA, VK_OEM_COPY, VK_OEM_CUSEL, VK_OEM_ENLW, VK_OEM_FINISH, + VK_OEM_FJ_LOYA, VK_OEM_FJ_MASSHOU, VK_OEM_FJ_ROYA, VK_OEM_FJ_TOUROKU, VK_OEM_JUMP, + VK_OEM_MINUS, VK_OEM_NEC_EQUAL, VK_OEM_PA1, VK_OEM_PA2, VK_OEM_PA3, VK_OEM_PERIOD, VK_OEM_PLUS, + VK_OEM_RESET, VK_OEM_WSCTRL, VK_PA1, VK_PACKET, VK_PAUSE, VK_PLAY, VK_PRINT, VK_PRIOR, + VK_PROCESSKEY, VK_RBUTTON, VK_RCONTROL, VK_RETURN, VK_RIGHT, VK_RMENU, VK_RSHIFT, VK_RWIN, + VK_SCROLL, VK_SELECT, VK_SEPARATOR, VK_SHIFT, VK_SLEEP, VK_SNAPSHOT, VK_SPACE, VK_SUBTRACT, + VK_TAB, VK_UP, VK_VOLUME_DOWN, VK_VOLUME_MUTE, VK_VOLUME_UP, VK_XBUTTON1, VK_XBUTTON2, VK_ZOOM, +}; +use windows_sys::Win32::UI::TextServices::HKL; + +use crate::keyboard::{Key, KeyCode, ModifiersState, NamedKey, NativeKey, PhysicalKey}; +use crate::platform_impl::{loword, primarylangid, scancode_to_physicalkey}; + +pub(crate) static LAYOUT_CACHE: Lazy> = + Lazy::new(|| Mutex::new(LayoutCache::default())); + +fn key_pressed(vkey: VIRTUAL_KEY) -> bool { + unsafe { (GetKeyState(vkey as i32) & (1 << 15)) == (1 << 15) } +} + +const NUMPAD_VKEYS: [VIRTUAL_KEY; 16] = [ + VK_NUMPAD0, + VK_NUMPAD1, + VK_NUMPAD2, + VK_NUMPAD3, + VK_NUMPAD4, + VK_NUMPAD5, + VK_NUMPAD6, + VK_NUMPAD7, + VK_NUMPAD8, + VK_NUMPAD9, + VK_MULTIPLY, + VK_ADD, + VK_SEPARATOR, + VK_SUBTRACT, + VK_DECIMAL, + VK_DIVIDE, +]; + +static NUMPAD_KEYCODES: Lazy> = Lazy::new(|| { + let mut keycodes = HashSet::new(); + keycodes.insert(KeyCode::Numpad0); + keycodes.insert(KeyCode::Numpad1); + keycodes.insert(KeyCode::Numpad2); + keycodes.insert(KeyCode::Numpad3); + keycodes.insert(KeyCode::Numpad4); + keycodes.insert(KeyCode::Numpad5); + keycodes.insert(KeyCode::Numpad6); + keycodes.insert(KeyCode::Numpad7); + keycodes.insert(KeyCode::Numpad8); + keycodes.insert(KeyCode::Numpad9); + keycodes.insert(KeyCode::NumpadMultiply); + keycodes.insert(KeyCode::NumpadAdd); + keycodes.insert(KeyCode::NumpadComma); + keycodes.insert(KeyCode::NumpadSubtract); + keycodes.insert(KeyCode::NumpadDecimal); + keycodes.insert(KeyCode::NumpadDivide); + keycodes +}); + +bitflags::bitflags! { + #[derive(Clone, Copy, PartialEq, Eq, Hash)] + pub struct WindowsModifiers : u8 { + const SHIFT = 1 << 0; + const CONTROL = 1 << 1; + const ALT = 1 << 2; + const CAPS_LOCK = 1 << 3; + const FLAGS_END = 1 << 4; + } +} + +impl WindowsModifiers { + pub fn active_modifiers(key_state: &[u8; 256]) -> WindowsModifiers { + let shift = key_state[VK_SHIFT as usize] & 0x80 != 0; + let lshift = key_state[VK_LSHIFT as usize] & 0x80 != 0; + let rshift = key_state[VK_RSHIFT as usize] & 0x80 != 0; + + let control = key_state[VK_CONTROL as usize] & 0x80 != 0; + let lcontrol = key_state[VK_LCONTROL as usize] & 0x80 != 0; + let rcontrol = key_state[VK_RCONTROL as usize] & 0x80 != 0; + + let alt = key_state[VK_MENU as usize] & 0x80 != 0; + let lalt = key_state[VK_LMENU as usize] & 0x80 != 0; + let ralt = key_state[VK_RMENU as usize] & 0x80 != 0; + + let caps = key_state[VK_CAPITAL as usize] & 0x01 != 0; + + let mut result = WindowsModifiers::empty(); + if shift || lshift || rshift { + result.insert(WindowsModifiers::SHIFT); + } + if control || lcontrol || rcontrol { + result.insert(WindowsModifiers::CONTROL); + } + if alt || lalt || ralt { + result.insert(WindowsModifiers::ALT); + } + if caps { + result.insert(WindowsModifiers::CAPS_LOCK); + } + + result + } + + pub fn apply_to_kbd_state(self, key_state: &mut [u8; 256]) { + if self.intersects(Self::SHIFT) { + key_state[VK_SHIFT as usize] |= 0x80; + } else { + key_state[VK_SHIFT as usize] &= !0x80; + key_state[VK_LSHIFT as usize] &= !0x80; + key_state[VK_RSHIFT as usize] &= !0x80; + } + if self.intersects(Self::CONTROL) { + key_state[VK_CONTROL as usize] |= 0x80; + } else { + key_state[VK_CONTROL as usize] &= !0x80; + key_state[VK_LCONTROL as usize] &= !0x80; + key_state[VK_RCONTROL as usize] &= !0x80; + } + if self.intersects(Self::ALT) { + key_state[VK_MENU as usize] |= 0x80; + } else { + key_state[VK_MENU as usize] &= !0x80; + key_state[VK_LMENU as usize] &= !0x80; + key_state[VK_RMENU as usize] &= !0x80; + } + if self.intersects(Self::CAPS_LOCK) { + key_state[VK_CAPITAL as usize] |= 0x01; + } else { + key_state[VK_CAPITAL as usize] &= !0x01; + } + } + + /// Removes the control modifier if the alt modifier is not present. + /// This is useful because on Windows: (Control + Alt) == AltGr + /// but we don't want to interfere with the AltGr state. + pub fn remove_only_ctrl(mut self) -> WindowsModifiers { + if !self.contains(WindowsModifiers::ALT) { + self.remove(WindowsModifiers::CONTROL); + } + self + } +} + +pub(crate) struct Layout { + pub hkl: u64, + + /// Maps numpad keys from Windows virtual key to a `Key`. + /// + /// This is useful because some numpad keys generate different characters based on the locale. + /// For example `VK_DECIMAL` is sometimes "." and sometimes ",". Note: numpad-specific virtual + /// keys are only produced by Windows when the NumLock is active. + /// + /// Making this field separate from the `keys` field saves having to add NumLock as a modifier + /// to `WindowsModifiers`, which would double the number of items in keys. + pub numlock_on_keys: HashMap, + /// Like `numlock_on_keys` but this will map to the key that would be produced if numlock was + /// off. The keys of this map are identical to the keys of `numlock_on_keys`. + pub numlock_off_keys: HashMap, + + /// Maps a modifier state to group of key strings + /// We're not using `ModifiersState` here because that object cannot express caps lock, + /// but we need to handle caps lock too. + /// + /// This map shouldn't need to exist. + /// However currently this seems to be the only good way + /// of getting the label for the pressed key. Note that calling `ToUnicode` + /// just when the key is pressed/released would be enough if `ToUnicode` wouldn't + /// change the keyboard state (it clears the dead key). There is a flag to prevent + /// changing the state, but that flag requires Windows 10, version 1607 or newer) + pub keys: HashMap>, + pub has_alt_graph: bool, +} + +impl Layout { + pub fn get_key( + &self, + mods: WindowsModifiers, + num_lock_on: bool, + vkey: VIRTUAL_KEY, + physical_key: &PhysicalKey, + ) -> Key { + let native_code = NativeKey::Windows(vkey); + + let unknown_alt = vkey == VK_MENU; + if !unknown_alt { + // Here we try using the virtual key directly but if the virtual key doesn't distinguish + // between left and right alt, we can't report AltGr. Therefore, we only do this if the + // key is not the "unknown alt" key. + // + // The reason for using the virtual key directly is that `MapVirtualKeyExW` (used when + // building the keys map) sometimes maps virtual keys to odd scancodes that don't match + // the scancode coming from the KEYDOWN message for the same key. For example: `VK_LEFT` + // is mapped to `0x004B`, but the scancode for the left arrow is `0xE04B`. + let key_from_vkey = + vkey_to_non_char_key(vkey, native_code.clone(), self.hkl, self.has_alt_graph); + + if !matches!(key_from_vkey, Key::Unidentified(_)) { + return key_from_vkey; + } + } + if num_lock_on { + if let Some(key) = self.numlock_on_keys.get(&vkey) { + return key.clone(); + } + } else if let Some(key) = self.numlock_off_keys.get(&vkey) { + return key.clone(); + } + if let PhysicalKey::Code(code) = physical_key { + if let Some(keys) = self.keys.get(&mods) { + if let Some(key) = keys.get(code) { + return key.clone(); + } + } + } + Key::Unidentified(native_code) + } +} + +#[derive(Default)] +pub(crate) struct LayoutCache { + /// Maps locale identifiers (HKL) to layouts + pub layouts: HashMap, +} + +impl LayoutCache { + /// Checks whether the current layout is already known and + /// prepares the layout if it isn't known. + /// The current layout is then returned. + pub fn get_current_layout(&mut self) -> (u64, &Layout) { + let locale_id = unsafe { GetKeyboardLayout(0) } as u64; + match self.layouts.entry(locale_id) { + Entry::Occupied(entry) => (locale_id, entry.into_mut()), + Entry::Vacant(entry) => { + let layout = Self::prepare_layout(locale_id); + (locale_id, entry.insert(layout)) + }, + } + } + + pub fn get_agnostic_mods(&mut self) -> ModifiersState { + let (_, layout) = self.get_current_layout(); + let filter_out_altgr = layout.has_alt_graph && key_pressed(VK_RMENU); + let mut mods = ModifiersState::empty(); + mods.set(ModifiersState::SHIFT, key_pressed(VK_SHIFT)); + mods.set(ModifiersState::CONTROL, key_pressed(VK_CONTROL) && !filter_out_altgr); + mods.set(ModifiersState::ALT, key_pressed(VK_MENU) && !filter_out_altgr); + mods.set(ModifiersState::SUPER, key_pressed(VK_LWIN) || key_pressed(VK_RWIN)); + mods + } + + fn prepare_layout(locale_id: u64) -> Layout { + let mut layout = Layout { + hkl: locale_id, + numlock_on_keys: Default::default(), + numlock_off_keys: Default::default(), + keys: Default::default(), + has_alt_graph: false, + }; + + // We initialize the keyboard state with all zeros to + // simulate a scenario when no modifier is active. + let mut key_state = [0u8; 256]; + + // `MapVirtualKeyExW` maps (non-numpad-specific) virtual keys to scancodes as if numlock + // was off. We rely on this behavior to find all virtual keys which are not numpad-specific + // but map to the numpad. + // + // src_vkey: VK ==> scancode: u16 (on the numpad) + // + // Then we convert the source virtual key into a `Key` and the scancode into a virtual key + // to get the reverse mapping. + // + // src_vkey: VK ==> scancode: u16 (on the numpad) + // || || + // \/ \/ + // map_value: Key <- map_vkey: VK + layout.numlock_off_keys.reserve(NUMPAD_KEYCODES.len()); + for vk in 0..256 { + let scancode = unsafe { MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, locale_id as HKL) }; + if scancode == 0 { + continue; + } + let keycode = match scancode_to_physicalkey(scancode) { + PhysicalKey::Code(code) => code, + // TODO: validate that we can skip on unidentified keys (probably never occurs?) + _ => continue, + }; + if !is_numpad_specific(vk as VIRTUAL_KEY) && NUMPAD_KEYCODES.contains(&keycode) { + let native_code = NativeKey::Windows(vk as VIRTUAL_KEY); + let map_vkey = keycode_to_vkey(keycode, locale_id); + if map_vkey == 0 { + continue; + } + let map_value = + vkey_to_non_char_key(vk as VIRTUAL_KEY, native_code, locale_id, false); + if matches!(map_value, Key::Unidentified(_)) { + continue; + } + layout.numlock_off_keys.insert(map_vkey, map_value); + } + } + + layout.numlock_on_keys.reserve(NUMPAD_VKEYS.len()); + for vk in NUMPAD_VKEYS.iter() { + let vk = (*vk) as u32; + let scancode = unsafe { MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, locale_id as HKL) }; + let unicode = Self::to_unicode_string(&key_state, vk, scancode, locale_id); + if let ToUnicodeResult::Str(s) = unicode { + layout.numlock_on_keys.insert(vk as VIRTUAL_KEY, Key::Character(SmolStr::new(s))); + } + } + + // Iterate through every combination of modifiers + let mods_end = WindowsModifiers::FLAGS_END.bits(); + for mod_state in 0..mods_end { + let mut keys_for_this_mod = HashMap::with_capacity(256); + + let mod_state = WindowsModifiers::from_bits_retain(mod_state); + mod_state.apply_to_kbd_state(&mut key_state); + + // Virtual key values are in the domain [0, 255]. + // This is reinforced by the fact that the keyboard state array has 256 + // elements. This array is allowed to be indexed by virtual key values + // giving the key state for the virtual key used for indexing. + for vk in 0..256 { + let scancode = + unsafe { MapVirtualKeyExW(vk, MAPVK_VK_TO_VSC_EX, locale_id as HKL) }; + if scancode == 0 { + continue; + } + + let native_code = NativeKey::Windows(vk as VIRTUAL_KEY); + let key_code = match scancode_to_physicalkey(scancode) { + PhysicalKey::Code(code) => code, + // TODO: validate that we can skip on unidentified keys (probably never occurs?) + _ => continue, + }; + // Let's try to get the key from just the scancode and vk + // We don't necessarily know yet if AltGraph is present on this layout so we'll + // assume it isn't. Then we'll do a second pass where we set the "AltRight" keys to + // "AltGr" in case we find out that there's an AltGraph. + let preliminary_key = + vkey_to_non_char_key(vk as VIRTUAL_KEY, native_code, locale_id, false); + match preliminary_key { + Key::Unidentified(_) => (), + _ => { + keys_for_this_mod.insert(key_code, preliminary_key); + continue; + }, + } + + let unicode = Self::to_unicode_string(&key_state, vk, scancode, locale_id); + let key = match unicode { + ToUnicodeResult::Str(str) => Key::Character(SmolStr::new(str)), + ToUnicodeResult::Dead(dead_char) => { + // println!("{:?} - {:?} produced dead {:?}", key_code, mod_state, + // dead_char); + Key::Dead(dead_char) + }, + ToUnicodeResult::None => { + let has_alt = mod_state.contains(WindowsModifiers::ALT); + let has_ctrl = mod_state.contains(WindowsModifiers::CONTROL); + // HACK: `ToUnicodeEx` seems to fail getting the string for the numpad + // divide key, so we handle that explicitly here + if !has_alt && !has_ctrl && key_code == KeyCode::NumpadDivide { + Key::Character(SmolStr::new("/")) + } else { + // Just use the unidentified key, we got earlier + preliminary_key + } + }, + }; + + // Check for alt graph. + // The logic is that if a key pressed with no modifier produces + // a different `Character` from when it's pressed with CTRL+ALT then the layout + // has AltGr. + let ctrl_alt: WindowsModifiers = WindowsModifiers::CONTROL | WindowsModifiers::ALT; + let is_in_ctrl_alt = mod_state == ctrl_alt; + if !layout.has_alt_graph && is_in_ctrl_alt { + // Unwrapping here because if we are in the ctrl+alt modifier state + // then the alt modifier state must have come before. + let simple_keys = layout.keys.get(&WindowsModifiers::empty()).unwrap(); + if let Some(Key::Character(key_no_altgr)) = simple_keys.get(&key_code) { + if let Key::Character(key) = &key { + layout.has_alt_graph = key != key_no_altgr; + } + } + } + + keys_for_this_mod.insert(key_code, key); + } + layout.keys.insert(mod_state, keys_for_this_mod); + } + + // Second pass: replace right alt keys with AltGr if the layout has alt graph + if layout.has_alt_graph { + for mod_state in 0..mods_end { + let mod_state = WindowsModifiers::from_bits_retain(mod_state); + if let Some(keys) = layout.keys.get_mut(&mod_state) { + if let Some(key) = keys.get_mut(&KeyCode::AltRight) { + *key = Key::Named(NamedKey::AltGraph); + } + } + } + } + + layout + } + + fn to_unicode_string( + key_state: &[u8; 256], + vkey: u32, + scancode: u32, + locale_id: u64, + ) -> ToUnicodeResult { + unsafe { + let mut label_wide = [0u16; 8]; + let mut wide_len = ToUnicodeEx( + vkey, + scancode, + (&key_state[0]) as *const _, + (&mut label_wide[0]) as *mut _, + label_wide.len() as i32, + 0, + locale_id as HKL, + ); + if wide_len < 0 { + // If it's dead, we run `ToUnicode` again to consume the dead-key + wide_len = ToUnicodeEx( + vkey, + scancode, + (&key_state[0]) as *const _, + (&mut label_wide[0]) as *mut _, + label_wide.len() as i32, + 0, + locale_id as HKL, + ); + if wide_len > 0 { + let os_string = OsString::from_wide(&label_wide[0..wide_len as usize]); + if let Ok(label_str) = os_string.into_string() { + if let Some(ch) = label_str.chars().next() { + return ToUnicodeResult::Dead(Some(ch)); + } + } + } + return ToUnicodeResult::Dead(None); + } + if wide_len > 0 { + let os_string = OsString::from_wide(&label_wide[0..wide_len as usize]); + if let Ok(label_str) = os_string.into_string() { + return ToUnicodeResult::Str(label_str); + } + } + } + ToUnicodeResult::None + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +enum ToUnicodeResult { + Str(String), + Dead(Option), + None, +} + +fn is_numpad_specific(vk: VIRTUAL_KEY) -> bool { + matches!( + vk, + VK_NUMPAD0 + | VK_NUMPAD1 + | VK_NUMPAD2 + | VK_NUMPAD3 + | VK_NUMPAD4 + | VK_NUMPAD5 + | VK_NUMPAD6 + | VK_NUMPAD7 + | VK_NUMPAD8 + | VK_NUMPAD9 + | VK_ADD + | VK_SUBTRACT + | VK_DIVIDE + | VK_DECIMAL + | VK_SEPARATOR + ) +} + +fn keycode_to_vkey(keycode: KeyCode, hkl: u64) -> VIRTUAL_KEY { + let primary_lang_id = primarylangid(loword(hkl as u32)); + let is_korean = primary_lang_id as u32 == LANG_KOREAN; + let is_japanese = primary_lang_id as u32 == LANG_JAPANESE; + + match keycode { + KeyCode::Backquote => 0, + KeyCode::Backslash => 0, + KeyCode::BracketLeft => 0, + KeyCode::BracketRight => 0, + KeyCode::Comma => 0, + KeyCode::Digit0 => 0, + KeyCode::Digit1 => 0, + KeyCode::Digit2 => 0, + KeyCode::Digit3 => 0, + KeyCode::Digit4 => 0, + KeyCode::Digit5 => 0, + KeyCode::Digit6 => 0, + KeyCode::Digit7 => 0, + KeyCode::Digit8 => 0, + KeyCode::Digit9 => 0, + KeyCode::Equal => 0, + KeyCode::IntlBackslash => 0, + KeyCode::IntlRo => 0, + KeyCode::IntlYen => 0, + KeyCode::KeyA => 0, + KeyCode::KeyB => 0, + KeyCode::KeyC => 0, + KeyCode::KeyD => 0, + KeyCode::KeyE => 0, + KeyCode::KeyF => 0, + KeyCode::KeyG => 0, + KeyCode::KeyH => 0, + KeyCode::KeyI => 0, + KeyCode::KeyJ => 0, + KeyCode::KeyK => 0, + KeyCode::KeyL => 0, + KeyCode::KeyM => 0, + KeyCode::KeyN => 0, + KeyCode::KeyO => 0, + KeyCode::KeyP => 0, + KeyCode::KeyQ => 0, + KeyCode::KeyR => 0, + KeyCode::KeyS => 0, + KeyCode::KeyT => 0, + KeyCode::KeyU => 0, + KeyCode::KeyV => 0, + KeyCode::KeyW => 0, + KeyCode::KeyX => 0, + KeyCode::KeyY => 0, + KeyCode::KeyZ => 0, + KeyCode::Minus => 0, + KeyCode::Period => 0, + KeyCode::Quote => 0, + KeyCode::Semicolon => 0, + KeyCode::Slash => 0, + KeyCode::AltLeft => VK_LMENU, + KeyCode::AltRight => VK_RMENU, + KeyCode::Backspace => VK_BACK, + KeyCode::CapsLock => VK_CAPITAL, + KeyCode::ContextMenu => VK_APPS, + KeyCode::ControlLeft => VK_LCONTROL, + KeyCode::ControlRight => VK_RCONTROL, + KeyCode::Enter => VK_RETURN, + KeyCode::SuperLeft => VK_LWIN, + KeyCode::SuperRight => VK_RWIN, + KeyCode::ShiftLeft => VK_RSHIFT, + KeyCode::ShiftRight => VK_LSHIFT, + KeyCode::Space => VK_SPACE, + KeyCode::Tab => VK_TAB, + KeyCode::Convert => VK_CONVERT, + KeyCode::KanaMode => VK_KANA, + KeyCode::Lang1 if is_korean => VK_HANGUL, + KeyCode::Lang1 if is_japanese => VK_KANA, + KeyCode::Lang2 if is_korean => VK_HANJA, + KeyCode::Lang2 if is_japanese => 0, + KeyCode::Lang3 if is_japanese => VK_OEM_FINISH, + KeyCode::Lang4 if is_japanese => 0, + KeyCode::Lang5 if is_japanese => 0, + KeyCode::NonConvert => VK_NONCONVERT, + KeyCode::Delete => VK_DELETE, + KeyCode::End => VK_END, + KeyCode::Help => VK_HELP, + KeyCode::Home => VK_HOME, + KeyCode::Insert => VK_INSERT, + KeyCode::PageDown => VK_NEXT, + KeyCode::PageUp => VK_PRIOR, + KeyCode::ArrowDown => VK_DOWN, + KeyCode::ArrowLeft => VK_LEFT, + KeyCode::ArrowRight => VK_RIGHT, + KeyCode::ArrowUp => VK_UP, + KeyCode::NumLock => VK_NUMLOCK, + KeyCode::Numpad0 => VK_NUMPAD0, + KeyCode::Numpad1 => VK_NUMPAD1, + KeyCode::Numpad2 => VK_NUMPAD2, + KeyCode::Numpad3 => VK_NUMPAD3, + KeyCode::Numpad4 => VK_NUMPAD4, + KeyCode::Numpad5 => VK_NUMPAD5, + KeyCode::Numpad6 => VK_NUMPAD6, + KeyCode::Numpad7 => VK_NUMPAD7, + KeyCode::Numpad8 => VK_NUMPAD8, + KeyCode::Numpad9 => VK_NUMPAD9, + KeyCode::NumpadAdd => VK_ADD, + KeyCode::NumpadBackspace => VK_BACK, + KeyCode::NumpadClear => VK_CLEAR, + KeyCode::NumpadClearEntry => 0, + KeyCode::NumpadComma => VK_SEPARATOR, + KeyCode::NumpadDecimal => VK_DECIMAL, + KeyCode::NumpadDivide => VK_DIVIDE, + KeyCode::NumpadEnter => VK_RETURN, + KeyCode::NumpadEqual => 0, + KeyCode::NumpadHash => 0, + KeyCode::NumpadMemoryAdd => 0, + KeyCode::NumpadMemoryClear => 0, + KeyCode::NumpadMemoryRecall => 0, + KeyCode::NumpadMemoryStore => 0, + KeyCode::NumpadMemorySubtract => 0, + KeyCode::NumpadMultiply => VK_MULTIPLY, + KeyCode::NumpadParenLeft => 0, + KeyCode::NumpadParenRight => 0, + KeyCode::NumpadStar => 0, + KeyCode::NumpadSubtract => VK_SUBTRACT, + KeyCode::Escape => VK_ESCAPE, + KeyCode::Fn => 0, + KeyCode::FnLock => 0, + KeyCode::PrintScreen => VK_SNAPSHOT, + KeyCode::ScrollLock => VK_SCROLL, + KeyCode::Pause => VK_PAUSE, + KeyCode::BrowserBack => VK_BROWSER_BACK, + KeyCode::BrowserFavorites => VK_BROWSER_FAVORITES, + KeyCode::BrowserForward => VK_BROWSER_FORWARD, + KeyCode::BrowserHome => VK_BROWSER_HOME, + KeyCode::BrowserRefresh => VK_BROWSER_REFRESH, + KeyCode::BrowserSearch => VK_BROWSER_SEARCH, + KeyCode::BrowserStop => VK_BROWSER_STOP, + KeyCode::Eject => 0, + KeyCode::LaunchApp1 => VK_LAUNCH_APP1, + KeyCode::LaunchApp2 => VK_LAUNCH_APP2, + KeyCode::LaunchMail => VK_LAUNCH_MAIL, + KeyCode::MediaPlayPause => VK_MEDIA_PLAY_PAUSE, + KeyCode::MediaSelect => VK_LAUNCH_MEDIA_SELECT, + KeyCode::MediaStop => VK_MEDIA_STOP, + KeyCode::MediaTrackNext => VK_MEDIA_NEXT_TRACK, + KeyCode::MediaTrackPrevious => VK_MEDIA_PREV_TRACK, + KeyCode::Power => 0, + KeyCode::Sleep => 0, + KeyCode::AudioVolumeDown => VK_VOLUME_DOWN, + KeyCode::AudioVolumeMute => VK_VOLUME_MUTE, + KeyCode::AudioVolumeUp => VK_VOLUME_UP, + KeyCode::WakeUp => 0, + KeyCode::Hyper => 0, + KeyCode::Turbo => 0, + KeyCode::Abort => 0, + KeyCode::Resume => 0, + KeyCode::Suspend => 0, + KeyCode::Again => 0, + KeyCode::Copy => 0, + KeyCode::Cut => 0, + KeyCode::Find => 0, + KeyCode::Open => 0, + KeyCode::Paste => 0, + KeyCode::Props => 0, + KeyCode::Select => VK_SELECT, + KeyCode::Undo => 0, + KeyCode::Hiragana => 0, + KeyCode::Katakana => 0, + KeyCode::F1 => VK_F1, + KeyCode::F2 => VK_F2, + KeyCode::F3 => VK_F3, + KeyCode::F4 => VK_F4, + KeyCode::F5 => VK_F5, + KeyCode::F6 => VK_F6, + KeyCode::F7 => VK_F7, + KeyCode::F8 => VK_F8, + KeyCode::F9 => VK_F9, + KeyCode::F10 => VK_F10, + KeyCode::F11 => VK_F11, + KeyCode::F12 => VK_F12, + KeyCode::F13 => VK_F13, + KeyCode::F14 => VK_F14, + KeyCode::F15 => VK_F15, + KeyCode::F16 => VK_F16, + KeyCode::F17 => VK_F17, + KeyCode::F18 => VK_F18, + KeyCode::F19 => VK_F19, + KeyCode::F20 => VK_F20, + KeyCode::F21 => VK_F21, + KeyCode::F22 => VK_F22, + KeyCode::F23 => VK_F23, + KeyCode::F24 => VK_F24, + KeyCode::F25 => 0, + KeyCode::F26 => 0, + KeyCode::F27 => 0, + KeyCode::F28 => 0, + KeyCode::F29 => 0, + KeyCode::F30 => 0, + KeyCode::F31 => 0, + KeyCode::F32 => 0, + KeyCode::F33 => 0, + KeyCode::F34 => 0, + KeyCode::F35 => 0, + _ => 0, + } +} + +/// This converts virtual keys to `Key`s. Only virtual keys which can be unambiguously converted to +/// a `Key`, with only the information passed in as arguments, are converted. +/// +/// In other words: this function does not need to "prepare" the current layout in order to do +/// the conversion, but as such it cannot convert certain keys, like language-specific character +/// keys. +/// +/// The result includes all non-character keys defined within `Key` plus characters from numpad +/// keys. For example, backspace and tab are included. +fn vkey_to_non_char_key( + vkey: VIRTUAL_KEY, + native_code: NativeKey, + hkl: u64, + has_alt_graph: bool, +) -> Key { + // List of the Web key names and their corresponding platform-native key names: + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values + + let primary_lang_id = primarylangid(loword(hkl as u32)); + let is_korean = primary_lang_id as u32 == LANG_KOREAN; + let is_japanese = primary_lang_id as u32 == LANG_JAPANESE; + + match vkey { + VK_LBUTTON => Key::Unidentified(NativeKey::Unidentified), // Mouse + VK_RBUTTON => Key::Unidentified(NativeKey::Unidentified), // Mouse + + // I don't think this can be represented with a Key + VK_CANCEL => Key::Unidentified(native_code), + + VK_MBUTTON => Key::Unidentified(NativeKey::Unidentified), // Mouse + VK_XBUTTON1 => Key::Unidentified(NativeKey::Unidentified), // Mouse + VK_XBUTTON2 => Key::Unidentified(NativeKey::Unidentified), // Mouse + VK_BACK => Key::Named(NamedKey::Backspace), + VK_TAB => Key::Named(NamedKey::Tab), + VK_CLEAR => Key::Named(NamedKey::Clear), + VK_RETURN => Key::Named(NamedKey::Enter), + VK_SHIFT => Key::Named(NamedKey::Shift), + VK_CONTROL => Key::Named(NamedKey::Control), + VK_MENU => Key::Named(NamedKey::Alt), + VK_PAUSE => Key::Named(NamedKey::Pause), + VK_CAPITAL => Key::Named(NamedKey::CapsLock), + + // VK_HANGEUL => Key::Named(NamedKey::HangulMode), // Deprecated in favour of VK_HANGUL + + // VK_HANGUL and VK_KANA are defined as the same constant, therefore + // we use appropriate conditions to differentiate between them + VK_HANGUL if is_korean => Key::Named(NamedKey::HangulMode), + VK_KANA if is_japanese => Key::Named(NamedKey::KanaMode), + + VK_JUNJA => Key::Named(NamedKey::JunjaMode), + VK_FINAL => Key::Named(NamedKey::FinalMode), + + // VK_HANJA and VK_KANJI are defined as the same constant, therefore + // we use appropriate conditions to differentiate between them + VK_HANJA if is_korean => Key::Named(NamedKey::HanjaMode), + VK_KANJI if is_japanese => Key::Named(NamedKey::KanjiMode), + + VK_ESCAPE => Key::Named(NamedKey::Escape), + VK_CONVERT => Key::Named(NamedKey::Convert), + VK_NONCONVERT => Key::Named(NamedKey::NonConvert), + VK_ACCEPT => Key::Named(NamedKey::Accept), + VK_MODECHANGE => Key::Named(NamedKey::ModeChange), + VK_SPACE => Key::Named(NamedKey::Space), + VK_PRIOR => Key::Named(NamedKey::PageUp), + VK_NEXT => Key::Named(NamedKey::PageDown), + VK_END => Key::Named(NamedKey::End), + VK_HOME => Key::Named(NamedKey::Home), + VK_LEFT => Key::Named(NamedKey::ArrowLeft), + VK_UP => Key::Named(NamedKey::ArrowUp), + VK_RIGHT => Key::Named(NamedKey::ArrowRight), + VK_DOWN => Key::Named(NamedKey::ArrowDown), + VK_SELECT => Key::Named(NamedKey::Select), + VK_PRINT => Key::Named(NamedKey::Print), + VK_EXECUTE => Key::Named(NamedKey::Execute), + VK_SNAPSHOT => Key::Named(NamedKey::PrintScreen), + VK_INSERT => Key::Named(NamedKey::Insert), + VK_DELETE => Key::Named(NamedKey::Delete), + VK_HELP => Key::Named(NamedKey::Help), + VK_LWIN => Key::Named(NamedKey::Super), + VK_RWIN => Key::Named(NamedKey::Super), + VK_APPS => Key::Named(NamedKey::ContextMenu), + VK_SLEEP => Key::Named(NamedKey::Standby), + + // Numpad keys produce characters + VK_NUMPAD0 => Key::Unidentified(native_code), + VK_NUMPAD1 => Key::Unidentified(native_code), + VK_NUMPAD2 => Key::Unidentified(native_code), + VK_NUMPAD3 => Key::Unidentified(native_code), + VK_NUMPAD4 => Key::Unidentified(native_code), + VK_NUMPAD5 => Key::Unidentified(native_code), + VK_NUMPAD6 => Key::Unidentified(native_code), + VK_NUMPAD7 => Key::Unidentified(native_code), + VK_NUMPAD8 => Key::Unidentified(native_code), + VK_NUMPAD9 => Key::Unidentified(native_code), + VK_MULTIPLY => Key::Unidentified(native_code), + VK_ADD => Key::Unidentified(native_code), + VK_SEPARATOR => Key::Unidentified(native_code), + VK_SUBTRACT => Key::Unidentified(native_code), + VK_DECIMAL => Key::Unidentified(native_code), + VK_DIVIDE => Key::Unidentified(native_code), + + VK_F1 => Key::Named(NamedKey::F1), + VK_F2 => Key::Named(NamedKey::F2), + VK_F3 => Key::Named(NamedKey::F3), + VK_F4 => Key::Named(NamedKey::F4), + VK_F5 => Key::Named(NamedKey::F5), + VK_F6 => Key::Named(NamedKey::F6), + VK_F7 => Key::Named(NamedKey::F7), + VK_F8 => Key::Named(NamedKey::F8), + VK_F9 => Key::Named(NamedKey::F9), + VK_F10 => Key::Named(NamedKey::F10), + VK_F11 => Key::Named(NamedKey::F11), + VK_F12 => Key::Named(NamedKey::F12), + VK_F13 => Key::Named(NamedKey::F13), + VK_F14 => Key::Named(NamedKey::F14), + VK_F15 => Key::Named(NamedKey::F15), + VK_F16 => Key::Named(NamedKey::F16), + VK_F17 => Key::Named(NamedKey::F17), + VK_F18 => Key::Named(NamedKey::F18), + VK_F19 => Key::Named(NamedKey::F19), + VK_F20 => Key::Named(NamedKey::F20), + VK_F21 => Key::Named(NamedKey::F21), + VK_F22 => Key::Named(NamedKey::F22), + VK_F23 => Key::Named(NamedKey::F23), + VK_F24 => Key::Named(NamedKey::F24), + VK_NAVIGATION_VIEW => Key::Unidentified(native_code), + VK_NAVIGATION_MENU => Key::Unidentified(native_code), + VK_NAVIGATION_UP => Key::Unidentified(native_code), + VK_NAVIGATION_DOWN => Key::Unidentified(native_code), + VK_NAVIGATION_LEFT => Key::Unidentified(native_code), + VK_NAVIGATION_RIGHT => Key::Unidentified(native_code), + VK_NAVIGATION_ACCEPT => Key::Unidentified(native_code), + VK_NAVIGATION_CANCEL => Key::Unidentified(native_code), + VK_NUMLOCK => Key::Named(NamedKey::NumLock), + VK_SCROLL => Key::Named(NamedKey::ScrollLock), + VK_OEM_NEC_EQUAL => Key::Unidentified(native_code), + // VK_OEM_FJ_JISHO => Key::Unidentified(native_code), // Conflicts with `VK_OEM_NEC_EQUAL` + VK_OEM_FJ_MASSHOU => Key::Unidentified(native_code), + VK_OEM_FJ_TOUROKU => Key::Unidentified(native_code), + VK_OEM_FJ_LOYA => Key::Unidentified(native_code), + VK_OEM_FJ_ROYA => Key::Unidentified(native_code), + VK_LSHIFT => Key::Named(NamedKey::Shift), + VK_RSHIFT => Key::Named(NamedKey::Shift), + VK_LCONTROL => Key::Named(NamedKey::Control), + VK_RCONTROL => Key::Named(NamedKey::Control), + VK_LMENU => Key::Named(NamedKey::Alt), + VK_RMENU => { + if has_alt_graph { + Key::Named(NamedKey::AltGraph) + } else { + Key::Named(NamedKey::Alt) + } + }, + VK_BROWSER_BACK => Key::Named(NamedKey::BrowserBack), + VK_BROWSER_FORWARD => Key::Named(NamedKey::BrowserForward), + VK_BROWSER_REFRESH => Key::Named(NamedKey::BrowserRefresh), + VK_BROWSER_STOP => Key::Named(NamedKey::BrowserStop), + VK_BROWSER_SEARCH => Key::Named(NamedKey::BrowserSearch), + VK_BROWSER_FAVORITES => Key::Named(NamedKey::BrowserFavorites), + VK_BROWSER_HOME => Key::Named(NamedKey::BrowserHome), + VK_VOLUME_MUTE => Key::Named(NamedKey::AudioVolumeMute), + VK_VOLUME_DOWN => Key::Named(NamedKey::AudioVolumeDown), + VK_VOLUME_UP => Key::Named(NamedKey::AudioVolumeUp), + VK_MEDIA_NEXT_TRACK => Key::Named(NamedKey::MediaTrackNext), + VK_MEDIA_PREV_TRACK => Key::Named(NamedKey::MediaTrackPrevious), + VK_MEDIA_STOP => Key::Named(NamedKey::MediaStop), + VK_MEDIA_PLAY_PAUSE => Key::Named(NamedKey::MediaPlayPause), + VK_LAUNCH_MAIL => Key::Named(NamedKey::LaunchMail), + VK_LAUNCH_MEDIA_SELECT => Key::Named(NamedKey::LaunchMediaPlayer), + VK_LAUNCH_APP1 => Key::Named(NamedKey::LaunchApplication1), + VK_LAUNCH_APP2 => Key::Named(NamedKey::LaunchApplication2), + + // This function only converts "non-printable" + VK_OEM_1 => Key::Unidentified(native_code), + VK_OEM_PLUS => Key::Unidentified(native_code), + VK_OEM_COMMA => Key::Unidentified(native_code), + VK_OEM_MINUS => Key::Unidentified(native_code), + VK_OEM_PERIOD => Key::Unidentified(native_code), + VK_OEM_2 => Key::Unidentified(native_code), + VK_OEM_3 => Key::Unidentified(native_code), + + VK_GAMEPAD_A => Key::Unidentified(native_code), + VK_GAMEPAD_B => Key::Unidentified(native_code), + VK_GAMEPAD_X => Key::Unidentified(native_code), + VK_GAMEPAD_Y => Key::Unidentified(native_code), + VK_GAMEPAD_RIGHT_SHOULDER => Key::Unidentified(native_code), + VK_GAMEPAD_LEFT_SHOULDER => Key::Unidentified(native_code), + VK_GAMEPAD_LEFT_TRIGGER => Key::Unidentified(native_code), + VK_GAMEPAD_RIGHT_TRIGGER => Key::Unidentified(native_code), + VK_GAMEPAD_DPAD_UP => Key::Unidentified(native_code), + VK_GAMEPAD_DPAD_DOWN => Key::Unidentified(native_code), + VK_GAMEPAD_DPAD_LEFT => Key::Unidentified(native_code), + VK_GAMEPAD_DPAD_RIGHT => Key::Unidentified(native_code), + VK_GAMEPAD_MENU => Key::Unidentified(native_code), + VK_GAMEPAD_VIEW => Key::Unidentified(native_code), + VK_GAMEPAD_LEFT_THUMBSTICK_BUTTON => Key::Unidentified(native_code), + VK_GAMEPAD_RIGHT_THUMBSTICK_BUTTON => Key::Unidentified(native_code), + VK_GAMEPAD_LEFT_THUMBSTICK_UP => Key::Unidentified(native_code), + VK_GAMEPAD_LEFT_THUMBSTICK_DOWN => Key::Unidentified(native_code), + VK_GAMEPAD_LEFT_THUMBSTICK_RIGHT => Key::Unidentified(native_code), + VK_GAMEPAD_LEFT_THUMBSTICK_LEFT => Key::Unidentified(native_code), + VK_GAMEPAD_RIGHT_THUMBSTICK_UP => Key::Unidentified(native_code), + VK_GAMEPAD_RIGHT_THUMBSTICK_DOWN => Key::Unidentified(native_code), + VK_GAMEPAD_RIGHT_THUMBSTICK_RIGHT => Key::Unidentified(native_code), + VK_GAMEPAD_RIGHT_THUMBSTICK_LEFT => Key::Unidentified(native_code), + + // This function only converts "non-printable" + VK_OEM_4 => Key::Unidentified(native_code), + VK_OEM_5 => Key::Unidentified(native_code), + VK_OEM_6 => Key::Unidentified(native_code), + VK_OEM_7 => Key::Unidentified(native_code), + VK_OEM_8 => Key::Unidentified(native_code), + VK_OEM_AX => Key::Unidentified(native_code), + VK_OEM_102 => Key::Unidentified(native_code), + + VK_ICO_HELP => Key::Unidentified(native_code), + VK_ICO_00 => Key::Unidentified(native_code), + + VK_PROCESSKEY => Key::Named(NamedKey::Process), + + VK_ICO_CLEAR => Key::Unidentified(native_code), + VK_PACKET => Key::Unidentified(native_code), + VK_OEM_RESET => Key::Unidentified(native_code), + VK_OEM_JUMP => Key::Unidentified(native_code), + VK_OEM_PA1 => Key::Unidentified(native_code), + VK_OEM_PA2 => Key::Unidentified(native_code), + VK_OEM_PA3 => Key::Unidentified(native_code), + VK_OEM_WSCTRL => Key::Unidentified(native_code), + VK_OEM_CUSEL => Key::Unidentified(native_code), + + VK_OEM_ATTN => Key::Named(NamedKey::Attn), + VK_OEM_FINISH => { + if is_japanese { + Key::Named(NamedKey::Katakana) + } else { + // This matches IE and Firefox behaviour according to + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values + // At the time of writing, there is no `NamedKey::Finish` variant as + // Finish is not mentioned at https://w3c.github.io/uievents-key/ + // Also see: https://github.com/pyfisch/keyboard-types/issues/9 + Key::Unidentified(native_code) + } + }, + VK_OEM_COPY => Key::Named(NamedKey::Copy), + VK_OEM_AUTO => Key::Named(NamedKey::Hankaku), + VK_OEM_ENLW => Key::Named(NamedKey::Zenkaku), + VK_OEM_BACKTAB => Key::Named(NamedKey::Romaji), + VK_ATTN => Key::Named(NamedKey::KanaMode), + VK_CRSEL => Key::Named(NamedKey::CrSel), + VK_EXSEL => Key::Named(NamedKey::ExSel), + VK_EREOF => Key::Named(NamedKey::EraseEof), + VK_PLAY => Key::Named(NamedKey::Play), + VK_ZOOM => Key::Named(NamedKey::ZoomToggle), + VK_NONAME => Key::Unidentified(native_code), + VK_PA1 => Key::Unidentified(native_code), + VK_OEM_CLEAR => Key::Named(NamedKey::Clear), + _ => Key::Unidentified(native_code), + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/minimal_ime.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/minimal_ime.rs new file mode 100644 index 00000000..71600abb --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/minimal_ime.rs @@ -0,0 +1,67 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering::Relaxed}, + Mutex, +}; + +use winapi::{ + shared::{ + minwindef::{LPARAM, WPARAM}, + windef::HWND, + }, + um::winuser, +}; + +use crate::platform_impl::platform::{event_loop::ProcResult, keyboard::next_kbd_msg}; + +pub struct MinimalIme { + // True if we're currently receiving messages belonging to a finished IME session. + getting_ime_text: AtomicBool, + + utf16parts: Mutex>, +} +impl Default for MinimalIme { + fn default() -> Self { + MinimalIme { + getting_ime_text: AtomicBool::new(false), + utf16parts: Mutex::new(Vec::with_capacity(16)), + } + } +} +impl MinimalIme { + pub(crate) fn process_message( + &self, + hwnd: HWND, + msg_kind: u32, + wparam: WPARAM, + _lparam: LPARAM, + result: &mut ProcResult, + ) -> Option { + match msg_kind { + winuser::WM_IME_ENDCOMPOSITION => { + self.getting_ime_text.store(true, Relaxed); + } + winuser::WM_CHAR | winuser::WM_SYSCHAR => { + if self.getting_ime_text.load(Relaxed) { + *result = ProcResult::Value(0); + self.utf16parts.lock().unwrap().push(wparam as u16); + // It's important that we push the new character and release the lock + // before getting the next message + let next_msg = next_kbd_msg(hwnd); + let more_char_coming = next_msg + .map(|m| matches!(m.message, winuser::WM_CHAR | winuser::WM_SYSCHAR)) + .unwrap_or(false); + if !more_char_coming { + let mut utf16parts = self.utf16parts.lock().unwrap(); + let result = String::from_utf16(&utf16parts).ok(); + utf16parts.clear(); + self.getting_ime_text.store(false, Relaxed); + return result; + } + } + } + _ => (), + } + + None + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/mod.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/mod.rs new file mode 100644 index 00000000..9bb02fd4 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/mod.rs @@ -0,0 +1,196 @@ +use smol_str::SmolStr; +use windows_sys::Win32::Foundation::{HANDLE, HWND}; +use windows_sys::Win32::UI::WindowsAndMessaging::{HMENU, WINDOW_LONG_PTR_INDEX}; + +pub(crate) use self::event_loop::{ + ActiveEventLoop, EventLoop, EventLoopProxy, OwnedDisplayHandle, + PlatformSpecificEventLoopAttributes, +}; +pub(crate) use self::icon::{SelectedCursor, WinIcon}; +pub(crate) use self::keyboard::{physicalkey_to_scancode, scancode_to_physicalkey}; +pub(crate) use self::monitor::{MonitorHandle, VideoModeHandle}; +pub(crate) use self::window::Window; + +pub(crate) use self::icon::WinCursor as PlatformCustomCursor; +pub use self::icon::WinIcon as PlatformIcon; +pub(crate) use crate::cursor::OnlyCursorImageSource as PlatformCustomCursorSource; +use crate::platform_impl::Fullscreen; + +use crate::event::DeviceId as RootDeviceId; +use crate::icon::Icon; +use crate::keyboard::Key; +use crate::platform::windows::{BackdropType, Color, CornerPreference}; + +#[derive(Clone, Debug)] +pub struct PlatformSpecificWindowAttributes { + pub owner: Option, + pub menu: Option, + pub taskbar_icon: Option, + pub no_redirection_bitmap: bool, + pub drag_and_drop: bool, + pub skip_taskbar: bool, + pub class_name: String, + pub decoration_shadow: bool, + pub backdrop_type: BackdropType, + pub clip_children: bool, + pub border_color: Option, + pub title_background_color: Option, + pub title_text_color: Option, + pub corner_preference: Option, +} + +impl Default for PlatformSpecificWindowAttributes { + fn default() -> Self { + Self { + owner: None, + menu: None, + taskbar_icon: None, + no_redirection_bitmap: false, + drag_and_drop: true, + skip_taskbar: false, + class_name: "Window Class".to_string(), + decoration_shadow: false, + backdrop_type: BackdropType::default(), + clip_children: true, + border_color: None, + title_background_color: None, + title_text_color: None, + corner_preference: None, + } + } +} + +unsafe impl Send for PlatformSpecificWindowAttributes {} +unsafe impl Sync for PlatformSpecificWindowAttributes {} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DeviceId(u32); + +impl DeviceId { + pub const fn dummy() -> Self { + DeviceId(0) + } +} + +impl DeviceId { + pub fn persistent_identifier(&self) -> Option { + if self.0 != 0 { + raw_input::get_raw_input_device_name(self.0 as HANDLE) + } else { + None + } + } +} + +// Constant device ID, to be removed when this backend is updated to report real device IDs. +const DEVICE_ID: RootDeviceId = RootDeviceId(DeviceId(0)); + +fn wrap_device_id(id: u32) -> RootDeviceId { + RootDeviceId(DeviceId(id)) +} + +pub type OsError = std::io::Error; + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct KeyEventExtra { + pub text_with_all_modifiers: Option, + pub key_without_modifiers: Key, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WindowId(HWND); +unsafe impl Send for WindowId {} +unsafe impl Sync for WindowId {} + +impl WindowId { + pub const fn dummy() -> Self { + WindowId(0) + } +} + +impl From for u64 { + fn from(window_id: WindowId) -> Self { + window_id.0 as u64 + } +} + +impl From for HWND { + fn from(window_id: WindowId) -> Self { + window_id.0 + } +} + +impl From for WindowId { + fn from(raw_id: u64) -> Self { + Self(raw_id as HWND) + } +} + +#[inline(always)] +const fn get_xbutton_wparam(x: u32) -> u16 { + hiword(x) +} + +#[inline(always)] +const fn get_x_lparam(x: u32) -> i16 { + loword(x) as _ +} + +#[inline(always)] +const fn get_y_lparam(x: u32) -> i16 { + hiword(x) as _ +} + +#[inline(always)] +pub(crate) const fn primarylangid(lgid: u16) -> u16 { + lgid & 0x3ff +} + +#[inline(always)] +pub(crate) const fn loword(x: u32) -> u16 { + (x & 0xffff) as u16 +} + +#[inline(always)] +const fn hiword(x: u32) -> u16 { + ((x >> 16) & 0xffff) as u16 +} + +#[inline(always)] +unsafe fn get_window_long(hwnd: HWND, nindex: WINDOW_LONG_PTR_INDEX) -> isize { + #[cfg(target_pointer_width = "64")] + return unsafe { windows_sys::Win32::UI::WindowsAndMessaging::GetWindowLongPtrW(hwnd, nindex) }; + #[cfg(target_pointer_width = "32")] + return unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::GetWindowLongW(hwnd, nindex) as isize + }; +} + +#[inline(always)] +unsafe fn set_window_long(hwnd: HWND, nindex: WINDOW_LONG_PTR_INDEX, dwnewlong: isize) -> isize { + #[cfg(target_pointer_width = "64")] + return unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::SetWindowLongPtrW(hwnd, nindex, dwnewlong) + }; + #[cfg(target_pointer_width = "32")] + return unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::SetWindowLongW(hwnd, nindex, dwnewlong as i32) + as isize + }; +} + +#[macro_use] +mod util; +mod dark_mode; +mod definitions; +mod dpi; +mod drop_handler; +mod event_loop; +mod icon; +mod ime; +mod keyboard; +mod keyboard_layout; +mod monitor; +mod raw_input; +mod window; +mod window_state; diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/monitor.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/monitor.rs new file mode 100644 index 00000000..9a880db4 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/monitor.rs @@ -0,0 +1,256 @@ +use std::collections::{BTreeSet, VecDeque}; +use std::hash::Hash; +use std::{io, mem, ptr}; + +use windows_sys::Win32::Foundation::{BOOL, HWND, LPARAM, POINT, RECT}; +use windows_sys::Win32::Graphics::Gdi::{ + EnumDisplayMonitors, EnumDisplaySettingsExW, GetMonitorInfoW, MonitorFromPoint, + MonitorFromWindow, DEVMODEW, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH, + ENUM_CURRENT_SETTINGS, HDC, HMONITOR, MONITORINFO, MONITORINFOEXW, MONITOR_DEFAULTTONEAREST, + MONITOR_DEFAULTTOPRIMARY, +}; + +use super::util::decode_wide; +use crate::dpi::{PhysicalPosition, PhysicalSize}; +use crate::monitor::VideoModeHandle as RootVideoModeHandle; +use crate::platform_impl::platform::dpi::{dpi_to_scale_factor, get_monitor_dpi}; +use crate::platform_impl::platform::util::has_flag; +use crate::platform_impl::platform::window::Window; + +#[derive(Clone)] +pub struct VideoModeHandle { + pub(crate) size: (u32, u32), + pub(crate) bit_depth: u16, + pub(crate) refresh_rate_millihertz: u32, + pub(crate) monitor: MonitorHandle, + // DEVMODEW is huge so we box it to avoid blowing up the size of winit::window::Fullscreen + pub(crate) native_video_mode: Box, +} + +impl PartialEq for VideoModeHandle { + fn eq(&self, other: &Self) -> bool { + self.size == other.size + && self.bit_depth == other.bit_depth + && self.refresh_rate_millihertz == other.refresh_rate_millihertz + && self.monitor == other.monitor + } +} + +impl Eq for VideoModeHandle {} + +impl std::hash::Hash for VideoModeHandle { + fn hash(&self, state: &mut H) { + self.size.hash(state); + self.bit_depth.hash(state); + self.refresh_rate_millihertz.hash(state); + self.monitor.hash(state); + } +} + +impl std::fmt::Debug for VideoModeHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VideoModeHandle") + .field("size", &self.size) + .field("bit_depth", &self.bit_depth) + .field("refresh_rate_millihertz", &self.refresh_rate_millihertz) + .field("monitor", &self.monitor) + .finish() + } +} + +impl VideoModeHandle { + pub fn size(&self) -> PhysicalSize { + self.size.into() + } + + pub fn bit_depth(&self) -> u16 { + self.bit_depth + } + + pub fn refresh_rate_millihertz(&self) -> u32 { + self.refresh_rate_millihertz + } + + pub fn monitor(&self) -> MonitorHandle { + self.monitor.clone() + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash, PartialOrd, Ord)] +pub struct MonitorHandle(HMONITOR); + +// Send is not implemented for HMONITOR, we have to wrap it and implement it manually. +// For more info see: +// https://github.com/retep998/winapi-rs/issues/360 +// https://github.com/retep998/winapi-rs/issues/396 + +unsafe impl Send for MonitorHandle {} + +unsafe extern "system" fn monitor_enum_proc( + hmonitor: HMONITOR, + _hdc: HDC, + _place: *mut RECT, + data: LPARAM, +) -> BOOL { + let monitors = data as *mut VecDeque; + unsafe { (*monitors).push_back(MonitorHandle::new(hmonitor)) }; + true.into() // continue enumeration +} + +pub fn available_monitors() -> VecDeque { + let mut monitors: VecDeque = VecDeque::new(); + unsafe { + EnumDisplayMonitors( + 0, + ptr::null(), + Some(monitor_enum_proc), + &mut monitors as *mut _ as LPARAM, + ); + } + monitors +} + +pub fn primary_monitor() -> MonitorHandle { + const ORIGIN: POINT = POINT { x: 0, y: 0 }; + let hmonitor = unsafe { MonitorFromPoint(ORIGIN, MONITOR_DEFAULTTOPRIMARY) }; + MonitorHandle::new(hmonitor) +} + +pub fn current_monitor(hwnd: HWND) -> MonitorHandle { + let hmonitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) }; + MonitorHandle::new(hmonitor) +} + +impl Window { + pub fn available_monitors(&self) -> VecDeque { + available_monitors() + } + + pub fn primary_monitor(&self) -> Option { + let monitor = primary_monitor(); + Some(monitor) + } +} + +pub(crate) fn get_monitor_info(hmonitor: HMONITOR) -> Result { + let mut monitor_info: MONITORINFOEXW = unsafe { mem::zeroed() }; + monitor_info.monitorInfo.cbSize = mem::size_of::() as u32; + let status = unsafe { + GetMonitorInfoW(hmonitor, &mut monitor_info as *mut MONITORINFOEXW as *mut MONITORINFO) + }; + if status == false.into() { + Err(io::Error::last_os_error()) + } else { + Ok(monitor_info) + } +} + +impl MonitorHandle { + pub(crate) fn new(hmonitor: HMONITOR) -> Self { + MonitorHandle(hmonitor) + } + + #[inline] + pub fn name(&self) -> Option { + let monitor_info = get_monitor_info(self.0).unwrap(); + Some(decode_wide(&monitor_info.szDevice).to_string_lossy().to_string()) + } + + #[inline] + pub fn native_identifier(&self) -> String { + self.name().unwrap() + } + + #[inline] + pub fn hmonitor(&self) -> HMONITOR { + self.0 + } + + #[inline] + pub fn size(&self) -> PhysicalSize { + let rc_monitor = get_monitor_info(self.0).unwrap().monitorInfo.rcMonitor; + PhysicalSize { + width: (rc_monitor.right - rc_monitor.left) as u32, + height: (rc_monitor.bottom - rc_monitor.top) as u32, + } + } + + #[inline] + pub fn refresh_rate_millihertz(&self) -> Option { + let monitor_info = get_monitor_info(self.0).ok()?; + let device_name = monitor_info.szDevice.as_ptr(); + unsafe { + let mut mode: DEVMODEW = mem::zeroed(); + mode.dmSize = mem::size_of_val(&mode) as u16; + if EnumDisplaySettingsExW(device_name, ENUM_CURRENT_SETTINGS, &mut mode, 0) + == false.into() + { + None + } else { + Some(mode.dmDisplayFrequency * 1000) + } + } + } + + #[inline] + pub fn position(&self) -> PhysicalPosition { + get_monitor_info(self.0) + .map(|info| { + let rc_monitor = info.monitorInfo.rcMonitor; + PhysicalPosition { x: rc_monitor.left, y: rc_monitor.top } + }) + .unwrap_or(PhysicalPosition { x: 0, y: 0 }) + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + dpi_to_scale_factor(get_monitor_dpi(self.0).unwrap_or(96)) + } + + #[inline] + pub fn video_modes(&self) -> impl Iterator { + // EnumDisplaySettingsExW can return duplicate values (or some of the + // fields are probably changing, but we aren't looking at those fields + // anyway), so we're using a BTreeSet deduplicate + let mut modes = BTreeSet::::new(); + let mod_map = |mode: RootVideoModeHandle| mode.video_mode; + + let monitor_info = match get_monitor_info(self.0) { + Ok(monitor_info) => monitor_info, + Err(error) => { + tracing::warn!("Error from get_monitor_info: {error}"); + return modes.into_iter().map(mod_map); + }, + }; + + let device_name = monitor_info.szDevice.as_ptr(); + + let mut i = 0; + loop { + let mut mode: DEVMODEW = unsafe { mem::zeroed() }; + mode.dmSize = mem::size_of_val(&mode) as u16; + if unsafe { EnumDisplaySettingsExW(device_name, i, &mut mode, 0) } == false.into() { + break; + } + + const REQUIRED_FIELDS: u32 = + DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY; + assert!(has_flag(mode.dmFields, REQUIRED_FIELDS)); + + // Use Ord impl of RootVideoModeHandle + modes.insert(RootVideoModeHandle { + video_mode: VideoModeHandle { + size: (mode.dmPelsWidth, mode.dmPelsHeight), + bit_depth: mode.dmBitsPerPel as u16, + refresh_rate_millihertz: mode.dmDisplayFrequency * 1000, + monitor: self.clone(), + native_video_mode: Box::new(mode), + }, + }); + + i += 1; + } + + modes.into_iter().map(mod_map) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/raw_input.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/raw_input.rs new file mode 100644 index 00000000..f78a7bb0 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/raw_input.rs @@ -0,0 +1,301 @@ +use std::mem::{self, size_of}; +use std::ptr; + +use windows_sys::Win32::Devices::HumanInterfaceDevice::{ + HID_USAGE_GENERIC_KEYBOARD, HID_USAGE_GENERIC_MOUSE, HID_USAGE_PAGE_GENERIC, +}; +use windows_sys::Win32::Foundation::{HANDLE, HWND}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + MapVirtualKeyW, MAPVK_VK_TO_VSC_EX, VK_NUMLOCK, VK_SHIFT, +}; +use windows_sys::Win32::UI::Input::{ + GetRawInputData, GetRawInputDeviceInfoW, GetRawInputDeviceList, RegisterRawInputDevices, + HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTDEVICELIST, RAWINPUTHEADER, RAWKEYBOARD, + RIDEV_DEVNOTIFY, RIDEV_INPUTSINK, RIDEV_REMOVE, RIDI_DEVICEINFO, RIDI_DEVICENAME, + RID_DEVICE_INFO, RID_DEVICE_INFO_HID, RID_DEVICE_INFO_KEYBOARD, RID_DEVICE_INFO_MOUSE, + RID_INPUT, RIM_TYPEHID, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + RI_KEY_E0, RI_KEY_E1, RI_MOUSE_BUTTON_1_DOWN, RI_MOUSE_BUTTON_1_UP, RI_MOUSE_BUTTON_2_DOWN, + RI_MOUSE_BUTTON_2_UP, RI_MOUSE_BUTTON_3_DOWN, RI_MOUSE_BUTTON_3_UP, RI_MOUSE_BUTTON_4_DOWN, + RI_MOUSE_BUTTON_4_UP, RI_MOUSE_BUTTON_5_DOWN, RI_MOUSE_BUTTON_5_UP, +}; + +use super::scancode_to_physicalkey; +use crate::event::ElementState; +use crate::event_loop::DeviceEvents; +use crate::keyboard::{KeyCode, PhysicalKey}; +use crate::platform_impl::platform::util; + +#[allow(dead_code)] +pub fn get_raw_input_device_list() -> Option> { + let list_size = size_of::() as u32; + + let mut num_devices = 0; + let status = unsafe { GetRawInputDeviceList(ptr::null_mut(), &mut num_devices, list_size) }; + + if status == u32::MAX { + return None; + } + + let mut buffer = Vec::with_capacity(num_devices as _); + + let num_stored = + unsafe { GetRawInputDeviceList(buffer.as_mut_ptr(), &mut num_devices, list_size) }; + + if num_stored == u32::MAX { + return None; + } + + debug_assert_eq!(num_devices, num_stored); + + unsafe { buffer.set_len(num_devices as _) }; + + Some(buffer) +} + +#[allow(dead_code)] +pub enum RawDeviceInfo { + Mouse(RID_DEVICE_INFO_MOUSE), + Keyboard(RID_DEVICE_INFO_KEYBOARD), + Hid(RID_DEVICE_INFO_HID), +} + +impl From for RawDeviceInfo { + fn from(info: RID_DEVICE_INFO) -> Self { + unsafe { + match info.dwType { + RIM_TYPEMOUSE => RawDeviceInfo::Mouse(info.Anonymous.mouse), + RIM_TYPEKEYBOARD => RawDeviceInfo::Keyboard(info.Anonymous.keyboard), + RIM_TYPEHID => RawDeviceInfo::Hid(info.Anonymous.hid), + _ => unreachable!(), + } + } + } +} + +#[allow(dead_code)] +pub fn get_raw_input_device_info(handle: HANDLE) -> Option { + let mut info: RID_DEVICE_INFO = unsafe { mem::zeroed() }; + let info_size = size_of::() as u32; + + info.cbSize = info_size; + + let mut minimum_size = 0; + let status = unsafe { + GetRawInputDeviceInfoW(handle, RIDI_DEVICEINFO, &mut info as *mut _ as _, &mut minimum_size) + }; + + if status == u32::MAX || status == 0 { + return None; + } + + debug_assert_eq!(info_size, status); + + Some(info.into()) +} + +pub fn get_raw_input_device_name(handle: HANDLE) -> Option { + let mut minimum_size = 0; + let status = unsafe { + GetRawInputDeviceInfoW(handle, RIDI_DEVICENAME, ptr::null_mut(), &mut minimum_size) + }; + + if status != 0 { + return None; + } + + let mut name: Vec = Vec::with_capacity(minimum_size as _); + + let status = unsafe { + GetRawInputDeviceInfoW(handle, RIDI_DEVICENAME, name.as_ptr() as _, &mut minimum_size) + }; + + if status == u32::MAX || status == 0 { + return None; + } + + debug_assert_eq!(minimum_size, status); + + unsafe { name.set_len(minimum_size as _) }; + + util::decode_wide(&name).into_string().ok() +} + +pub fn register_raw_input_devices(devices: &[RAWINPUTDEVICE]) -> bool { + let device_size = size_of::() as u32; + + unsafe { + RegisterRawInputDevices(devices.as_ptr(), devices.len() as u32, device_size) == true.into() + } +} + +pub fn register_all_mice_and_keyboards_for_raw_input( + mut window_handle: HWND, + filter: DeviceEvents, +) -> bool { + // RIDEV_DEVNOTIFY: receive hotplug events + // RIDEV_INPUTSINK: receive events even if we're not in the foreground + // RIDEV_REMOVE: don't receive device events (requires NULL hwndTarget) + let flags = match filter { + DeviceEvents::Never => { + window_handle = 0; + RIDEV_REMOVE + }, + DeviceEvents::WhenFocused => RIDEV_DEVNOTIFY, + DeviceEvents::Always => RIDEV_DEVNOTIFY | RIDEV_INPUTSINK, + }; + + let devices: [RAWINPUTDEVICE; 2] = [ + RAWINPUTDEVICE { + usUsagePage: HID_USAGE_PAGE_GENERIC, + usUsage: HID_USAGE_GENERIC_MOUSE, + dwFlags: flags, + hwndTarget: window_handle, + }, + RAWINPUTDEVICE { + usUsagePage: HID_USAGE_PAGE_GENERIC, + usUsage: HID_USAGE_GENERIC_KEYBOARD, + dwFlags: flags, + hwndTarget: window_handle, + }, + ]; + + register_raw_input_devices(&devices) +} + +pub fn get_raw_input_data(handle: HRAWINPUT) -> Option { + let mut data: RAWINPUT = unsafe { mem::zeroed() }; + let mut data_size = size_of::() as u32; + let header_size = size_of::() as u32; + + let status = unsafe { + GetRawInputData(handle, RID_INPUT, &mut data as *mut _ as _, &mut data_size, header_size) + }; + + if status == u32::MAX || status == 0 { + return None; + } + + Some(data) +} + +fn button_flags_to_element_state( + button_flags: u32, + down_flag: u32, + up_flag: u32, +) -> Option { + // We assume the same button won't be simultaneously pressed and released. + if util::has_flag(button_flags, down_flag) { + Some(ElementState::Pressed) + } else if util::has_flag(button_flags, up_flag) { + Some(ElementState::Released) + } else { + None + } +} + +pub fn get_raw_mouse_button_state(button_flags: u32) -> [Option; 5] { + [ + button_flags_to_element_state(button_flags, RI_MOUSE_BUTTON_1_DOWN, RI_MOUSE_BUTTON_1_UP), + button_flags_to_element_state(button_flags, RI_MOUSE_BUTTON_2_DOWN, RI_MOUSE_BUTTON_2_UP), + button_flags_to_element_state(button_flags, RI_MOUSE_BUTTON_3_DOWN, RI_MOUSE_BUTTON_3_UP), + button_flags_to_element_state(button_flags, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_4_UP), + button_flags_to_element_state(button_flags, RI_MOUSE_BUTTON_5_DOWN, RI_MOUSE_BUTTON_5_UP), + ] +} + +pub fn get_keyboard_physical_key(keyboard: RAWKEYBOARD) -> Option { + let extension = { + if util::has_flag(keyboard.Flags, RI_KEY_E0 as _) { + 0xe000 + } else if util::has_flag(keyboard.Flags, RI_KEY_E1 as _) { + 0xe100 + } else { + 0x0000 + } + }; + let scancode = if keyboard.MakeCode == 0 { + // In some cases (often with media keys) the device reports a scancode of 0 but a + // valid virtual key. In these cases we obtain the scancode from the virtual key. + unsafe { MapVirtualKeyW(keyboard.VKey as u32, MAPVK_VK_TO_VSC_EX) as u16 } + } else { + keyboard.MakeCode | extension + }; + if scancode == 0xe11d || scancode == 0xe02a { + // At the hardware (or driver?) level, pressing the Pause key is equivalent to pressing + // Ctrl+NumLock. + // This equivalence means that if the user presses Pause, the keyboard will emit two + // subsequent keypresses: + // 1, 0xE11D - Which is a left Ctrl (0x1D) with an extension flag (0xE100) + // 2, 0x0045 - Which on its own can be interpreted as Pause + // + // There's another combination which isn't quite an equivalence: + // PrtSc used to be Shift+Asterisk. This means that on some keyboards, pressing + // PrtSc (print screen) produces the following sequence: + // 1, 0xE02A - Which is a left shift (0x2A) with an extension flag (0xE000) + // 2, 0xE037 - Which is a numpad multiply (0x37) with an extension flag (0xE000). This on + // its own it can be interpreted as PrtSc + // + // For this reason, if we encounter the first keypress, we simply ignore it, trusting + // that there's going to be another event coming, from which we can extract the + // appropriate key. + // For more on this, read the article by Raymond Chen, titled: + // "Why does Ctrl+ScrollLock cancel dialogs?" + // https://devblogs.microsoft.com/oldnewthing/20080211-00/?p=23503 + return None; + } + let physical_key = if keyboard.VKey == VK_NUMLOCK { + // Historically, the NumLock and the Pause key were one and the same physical key. + // The user could trigger Pause by pressing Ctrl+NumLock. + // Now these are often physically separate and the two keys can be differentiated by + // checking the extension flag of the scancode. NumLock is 0xE045, Pause is 0x0045. + // + // However in this event, both keys are reported as 0x0045 even on modern hardware. + // Therefore we use the virtual key instead to determine whether it's a NumLock and + // set the KeyCode accordingly. + // + // For more on this, read the article by Raymond Chen, titled: + // "Why does Ctrl+ScrollLock cancel dialogs?" + // https://devblogs.microsoft.com/oldnewthing/20080211-00/?p=23503 + PhysicalKey::Code(KeyCode::NumLock) + } else { + scancode_to_physicalkey(scancode as u32) + }; + if keyboard.VKey == VK_SHIFT { + if let PhysicalKey::Code( + KeyCode::NumpadDecimal + | KeyCode::Numpad0 + | KeyCode::Numpad1 + | KeyCode::Numpad2 + | KeyCode::Numpad3 + | KeyCode::Numpad4 + | KeyCode::Numpad5 + | KeyCode::Numpad6 + | KeyCode::Numpad7 + | KeyCode::Numpad8 + | KeyCode::Numpad9, + ) = physical_key + { + // On Windows, holding the Shift key makes numpad keys behave as if NumLock + // wasn't active. The way this is exposed to applications by the system is that + // the application receives a fake key release event for the shift key at the + // moment when the numpad key is pressed, just before receiving the numpad key + // as well. + // + // The issue is that in the raw device event (here), the fake shift release + // event reports the numpad key as the scancode. Unfortunately, the event + // doesn't have any information to tell whether it's the + // left shift or the right shift that needs to get the fake + // release (or press) event so we don't forward this + // event to the application at all. + // + // For more on this, read the article by Raymond Chen, titled: + // "The shift key overrides NumLock" + // https://devblogs.microsoft.com/oldnewthing/20040906-00/?p=37953 + return None; + } + } + + Some(physical_key) +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/util.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/util.rs new file mode 100644 index 00000000..a3a7705d --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/util.rs @@ -0,0 +1,280 @@ +use std::ffi::{c_void, OsStr, OsString}; +use std::iter::once; +use std::ops::BitAnd; +use std::os::windows::prelude::{OsStrExt, OsStringExt}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{io, mem, ptr}; + +use crate::utils::Lazy; +use windows_sys::core::{HRESULT, PCWSTR}; +use windows_sys::Win32::Foundation::{BOOL, HANDLE, HMODULE, HWND, POINT, RECT}; +use windows_sys::Win32::Graphics::Gdi::{ClientToScreen, HMONITOR}; +use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA}; +use windows_sys::Win32::System::SystemServices::IMAGE_DOS_HEADER; +use windows_sys::Win32::UI::HiDpi::{ + DPI_AWARENESS_CONTEXT, MONITOR_DPI_TYPE, PROCESS_DPI_AWARENESS, +}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetActiveWindow; +use windows_sys::Win32::UI::Input::Pointer::{POINTER_INFO, POINTER_PEN_INFO, POINTER_TOUCH_INFO}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + ClipCursor, GetClientRect, GetClipCursor, GetCursorPos, GetSystemMetrics, GetWindowPlacement, + GetWindowRect, IsIconic, ShowCursor, IDC_APPSTARTING, IDC_ARROW, IDC_CROSS, IDC_HAND, IDC_HELP, + IDC_IBEAM, IDC_NO, IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, IDC_SIZENWSE, IDC_SIZEWE, IDC_WAIT, + SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_MAXIMIZE, + WINDOWPLACEMENT, +}; + +use crate::window::CursorIcon; + +pub fn encode_wide(string: impl AsRef) -> Vec { + string.as_ref().encode_wide().chain(once(0)).collect() +} + +pub fn decode_wide(mut wide_c_string: &[u16]) -> OsString { + if let Some(null_pos) = wide_c_string.iter().position(|c| *c == 0) { + wide_c_string = &wide_c_string[..null_pos]; + } + + OsString::from_wide(wide_c_string) +} + +pub fn has_flag(bitset: T, flag: T) -> bool +where + T: Copy + PartialEq + BitAnd, +{ + bitset & flag == flag +} + +pub(crate) fn win_to_err(result: BOOL) -> Result<(), io::Error> { + if result != false.into() { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +pub enum WindowArea { + Outer, + Inner, +} + +impl WindowArea { + pub fn get_rect(self, hwnd: HWND) -> Result { + let mut rect = unsafe { mem::zeroed() }; + + match self { + WindowArea::Outer => { + win_to_err(unsafe { GetWindowRect(hwnd, &mut rect) })?; + }, + WindowArea::Inner => unsafe { + let mut top_left = mem::zeroed(); + + win_to_err(ClientToScreen(hwnd, &mut top_left))?; + win_to_err(GetClientRect(hwnd, &mut rect))?; + rect.left += top_left.x; + rect.top += top_left.y; + rect.right += top_left.x; + rect.bottom += top_left.y; + }, + } + + Ok(rect) + } +} + +pub fn is_maximized(window: HWND) -> bool { + unsafe { + let mut placement: WINDOWPLACEMENT = mem::zeroed(); + placement.length = mem::size_of::() as u32; + GetWindowPlacement(window, &mut placement); + placement.showCmd == SW_MAXIMIZE as u32 + } +} + +pub fn set_cursor_hidden(hidden: bool) { + static HIDDEN: AtomicBool = AtomicBool::new(false); + let changed = HIDDEN.swap(hidden, Ordering::SeqCst) ^ hidden; + if changed { + unsafe { ShowCursor(BOOL::from(!hidden)) }; + } +} + +pub fn get_cursor_position() -> Result { + unsafe { + let mut point: POINT = mem::zeroed(); + win_to_err(GetCursorPos(&mut point)).map(|_| point) + } +} + +pub fn get_cursor_clip() -> Result { + unsafe { + let mut rect: RECT = mem::zeroed(); + win_to_err(GetClipCursor(&mut rect)).map(|_| rect) + } +} + +/// Sets the cursor's clip rect. +/// +/// Note that calling this will automatically dispatch a `WM_MOUSEMOVE` event. +pub fn set_cursor_clip(rect: Option) -> Result<(), io::Error> { + unsafe { + let rect_ptr = rect.as_ref().map(|r| r as *const RECT).unwrap_or(ptr::null()); + win_to_err(ClipCursor(rect_ptr)) + } +} + +pub fn get_desktop_rect() -> RECT { + unsafe { + let left = GetSystemMetrics(SM_XVIRTUALSCREEN); + let top = GetSystemMetrics(SM_YVIRTUALSCREEN); + RECT { + left, + top, + right: left + GetSystemMetrics(SM_CXVIRTUALSCREEN), + bottom: top + GetSystemMetrics(SM_CYVIRTUALSCREEN), + } + } +} + +pub fn is_focused(window: HWND) -> bool { + window == unsafe { GetActiveWindow() } +} + +pub fn is_minimized(window: HWND) -> bool { + unsafe { IsIconic(window) != false.into() } +} + +pub fn get_instance_handle() -> HMODULE { + // Gets the instance handle by taking the address of the + // pseudo-variable created by the microsoft linker: + // https://devblogs.microsoft.com/oldnewthing/20041025-00/?p=37483 + + // This is preferred over GetModuleHandle(NULL) because it also works in DLLs: + // https://stackoverflow.com/questions/21718027/getmodulehandlenull-vs-hinstance + + extern "C" { + static __ImageBase: IMAGE_DOS_HEADER; + } + + unsafe { &__ImageBase as *const _ as _ } +} + +pub(crate) fn to_windows_cursor(cursor: CursorIcon) -> PCWSTR { + match cursor { + CursorIcon::Default => IDC_ARROW, + CursorIcon::Pointer => IDC_HAND, + CursorIcon::Crosshair => IDC_CROSS, + CursorIcon::Text | CursorIcon::VerticalText => IDC_IBEAM, + CursorIcon::NotAllowed | CursorIcon::NoDrop => IDC_NO, + CursorIcon::Grab | CursorIcon::Grabbing | CursorIcon::Move | CursorIcon::AllScroll => { + IDC_SIZEALL + }, + CursorIcon::EResize + | CursorIcon::WResize + | CursorIcon::EwResize + | CursorIcon::ColResize => IDC_SIZEWE, + CursorIcon::NResize + | CursorIcon::SResize + | CursorIcon::NsResize + | CursorIcon::RowResize => IDC_SIZENS, + CursorIcon::NeResize | CursorIcon::SwResize | CursorIcon::NeswResize => IDC_SIZENESW, + CursorIcon::NwResize | CursorIcon::SeResize | CursorIcon::NwseResize => IDC_SIZENWSE, + CursorIcon::Wait => IDC_WAIT, + CursorIcon::Progress => IDC_APPSTARTING, + CursorIcon::Help => IDC_HELP, + _ => IDC_ARROW, // use arrow for the missing cases. + } +} + +// Helper function to dynamically load function pointer as some functions +// may not be available on all Windows platforms supported by winit. +// +// `library` and `function` must be zero-terminated. +pub(super) fn get_function_impl(library: &str, function: &str) -> Option<*const c_void> { + assert_eq!(library.chars().last(), Some('\0')); + assert_eq!(function.chars().last(), Some('\0')); + + // Library names we will use are ASCII so we can use the A version to avoid string conversion. + let module = unsafe { LoadLibraryA(library.as_ptr()) }; + if module == 0 { + return None; + } + + unsafe { GetProcAddress(module, function.as_ptr()) }.map(|function_ptr| function_ptr as _) +} + +macro_rules! get_function { + ($lib:expr, $func:ident) => { + crate::platform_impl::platform::util::get_function_impl( + concat!($lib, '\0'), + concat!(stringify!($func), '\0'), + ) + .map(|f| unsafe { std::mem::transmute::<*const _, $func>(f) }) + }; +} + +pub type SetProcessDPIAware = unsafe extern "system" fn() -> BOOL; +pub type SetProcessDpiAwareness = + unsafe extern "system" fn(value: PROCESS_DPI_AWARENESS) -> HRESULT; +pub type SetProcessDpiAwarenessContext = + unsafe extern "system" fn(value: DPI_AWARENESS_CONTEXT) -> BOOL; +pub type GetDpiForWindow = unsafe extern "system" fn(hwnd: HWND) -> u32; +pub type GetDpiForMonitor = unsafe extern "system" fn( + hmonitor: HMONITOR, + dpi_type: MONITOR_DPI_TYPE, + dpi_x: *mut u32, + dpi_y: *mut u32, +) -> HRESULT; +pub type EnableNonClientDpiScaling = unsafe extern "system" fn(hwnd: HWND) -> BOOL; +pub type AdjustWindowRectExForDpi = unsafe extern "system" fn( + rect: *mut RECT, + dw_style: u32, + b_menu: BOOL, + dw_ex_style: u32, + dpi: u32, +) -> BOOL; + +pub type GetPointerFrameInfoHistory = unsafe extern "system" fn( + pointer_id: u32, + entries_count: *mut u32, + pointer_count: *mut u32, + pointer_info: *mut POINTER_INFO, +) -> BOOL; + +pub type SkipPointerFrameMessages = unsafe extern "system" fn(pointer_id: u32) -> BOOL; +pub type GetPointerDeviceRects = unsafe extern "system" fn( + device: HANDLE, + pointer_device_rect: *mut RECT, + display_rect: *mut RECT, +) -> BOOL; + +pub type GetPointerTouchInfo = + unsafe extern "system" fn(pointer_id: u32, touch_info: *mut POINTER_TOUCH_INFO) -> BOOL; + +pub type GetPointerPenInfo = + unsafe extern "system" fn(point_id: u32, pen_info: *mut POINTER_PEN_INFO) -> BOOL; + +pub(crate) static GET_DPI_FOR_WINDOW: Lazy> = + Lazy::new(|| get_function!("user32.dll", GetDpiForWindow)); +pub(crate) static ADJUST_WINDOW_RECT_EX_FOR_DPI: Lazy> = + Lazy::new(|| get_function!("user32.dll", AdjustWindowRectExForDpi)); +pub(crate) static GET_DPI_FOR_MONITOR: Lazy> = + Lazy::new(|| get_function!("shcore.dll", GetDpiForMonitor)); +pub(crate) static ENABLE_NON_CLIENT_DPI_SCALING: Lazy> = + Lazy::new(|| get_function!("user32.dll", EnableNonClientDpiScaling)); +pub(crate) static SET_PROCESS_DPI_AWARENESS_CONTEXT: Lazy> = + Lazy::new(|| get_function!("user32.dll", SetProcessDpiAwarenessContext)); +pub(crate) static SET_PROCESS_DPI_AWARENESS: Lazy> = + Lazy::new(|| get_function!("shcore.dll", SetProcessDpiAwareness)); +pub(crate) static SET_PROCESS_DPI_AWARE: Lazy> = + Lazy::new(|| get_function!("user32.dll", SetProcessDPIAware)); +pub(crate) static GET_POINTER_FRAME_INFO_HISTORY: Lazy> = + Lazy::new(|| get_function!("user32.dll", GetPointerFrameInfoHistory)); +pub(crate) static SKIP_POINTER_FRAME_MESSAGES: Lazy> = + Lazy::new(|| get_function!("user32.dll", SkipPointerFrameMessages)); +pub(crate) static GET_POINTER_DEVICE_RECTS: Lazy> = + Lazy::new(|| get_function!("user32.dll", GetPointerDeviceRects)); +pub(crate) static GET_POINTER_TOUCH_INFO: Lazy> = + Lazy::new(|| get_function!("user32.dll", GetPointerTouchInfo)); +pub(crate) static GET_POINTER_PEN_INFO: Lazy> = + Lazy::new(|| get_function!("user32.dll", GetPointerPenInfo)); diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/window.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/window.rs new file mode 100644 index 00000000..228c6f03 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/window.rs @@ -0,0 +1,1575 @@ +#![cfg(windows_platform)] + +use std::cell::Cell; +use std::ffi::c_void; +use std::mem::{self, MaybeUninit}; +use std::sync::mpsc::channel; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::{io, panic, ptr}; + +use windows_sys::Win32::Foundation::{ + HWND, LPARAM, OLE_E_WRONGCOMPOBJ, POINT, POINTS, RECT, RPC_E_CHANGED_MODE, S_OK, WPARAM, +}; +use windows_sys::Win32::Graphics::Dwm::{ + DwmEnableBlurBehindWindow, DwmSetWindowAttribute, DWMWA_BORDER_COLOR, DWMWA_CAPTION_COLOR, + DWMWA_SYSTEMBACKDROP_TYPE, DWMWA_TEXT_COLOR, DWMWA_WINDOW_CORNER_PREFERENCE, DWM_BB_BLURREGION, + DWM_BB_ENABLE, DWM_BLURBEHIND, DWM_SYSTEMBACKDROP_TYPE, DWM_WINDOW_CORNER_PREFERENCE, +}; +use windows_sys::Win32::Graphics::Gdi::{ + ChangeDisplaySettingsExW, ClientToScreen, CreateRectRgn, DeleteObject, InvalidateRgn, + RedrawWindow, CDS_FULLSCREEN, DISP_CHANGE_BADFLAGS, DISP_CHANGE_BADMODE, DISP_CHANGE_BADPARAM, + DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, RDW_INTERNALPAINT, +}; +use windows_sys::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, +}; +use windows_sys::Win32::System::Ole::{OleInitialize, RegisterDragDrop}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + EnableWindow, GetActiveWindow, MapVirtualKeyW, ReleaseCapture, SendInput, ToUnicode, INPUT, + INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, MAPVK_VK_TO_VSC, + VIRTUAL_KEY, VK_LMENU, VK_MENU, VK_SPACE, +}; +use windows_sys::Win32::UI::Input::Touch::{RegisterTouchWindow, TWF_WANTPALM}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, EnableMenuItem, FlashWindowEx, GetClientRect, GetCursorPos, + GetForegroundWindow, GetSystemMenu, GetSystemMetrics, GetWindowPlacement, GetWindowTextLengthW, + GetWindowTextW, IsWindowVisible, LoadCursorW, PeekMessageW, PostMessageW, RegisterClassExW, + SetCursor, SetCursorPos, SetForegroundWindow, SetMenuDefaultItem, SetWindowDisplayAffinity, + SetWindowPlacement, SetWindowPos, SetWindowTextW, TrackPopupMenu, CS_HREDRAW, CS_VREDRAW, + CW_USEDEFAULT, FLASHWINFO, FLASHW_ALL, FLASHW_STOP, FLASHW_TIMERNOFG, FLASHW_TRAY, + GWLP_HINSTANCE, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTCAPTION, HTLEFT, HTRIGHT, HTTOP, + HTTOPLEFT, HTTOPRIGHT, MENU_ITEM_STATE, MFS_DISABLED, MFS_ENABLED, MF_BYCOMMAND, NID_READY, + PM_NOREMOVE, SC_CLOSE, SC_MAXIMIZE, SC_MINIMIZE, SC_MOVE, SC_RESTORE, SC_SIZE, SM_DIGITIZER, + SWP_ASYNCWINDOWPOS, SWP_NOACTIVATE, SWP_NOSIZE, SWP_NOZORDER, TPM_LEFTALIGN, TPM_RETURNCMD, + WDA_EXCLUDEFROMCAPTURE, WDA_NONE, WM_NCLBUTTONDOWN, WM_SYSCOMMAND, WNDCLASSEXW, +}; + +use tracing::warn; + +use crate::cursor::Cursor; +use crate::dpi::{PhysicalPosition, PhysicalSize, Position, Size}; +use crate::error::{ExternalError, NotSupportedError, OsError as RootOsError}; +use crate::icon::Icon; +use crate::platform::windows::{BackdropType, Color, CornerPreference}; +use crate::platform_impl::platform::dark_mode::try_theme; +use crate::platform_impl::platform::definitions::{ + CLSID_TaskbarList, IID_ITaskbarList, IID_ITaskbarList2, ITaskbarList, ITaskbarList2, +}; +use crate::platform_impl::platform::dpi::{ + dpi_to_scale_factor, enable_non_client_dpi_scaling, hwnd_dpi, +}; +use crate::platform_impl::platform::drop_handler::FileDropHandler; +use crate::platform_impl::platform::event_loop::{self, ActiveEventLoop, DESTROY_MSG_ID}; +use crate::platform_impl::platform::icon::{self, IconType, WinCursor}; +use crate::platform_impl::platform::ime::ImeContext; +use crate::platform_impl::platform::keyboard::KeyEventBuilder; +use crate::platform_impl::platform::monitor::{self, MonitorHandle}; +use crate::platform_impl::platform::window_state::{ + CursorFlags, SavedWindow, WindowFlags, WindowState, +}; +use crate::platform_impl::platform::{util, Fullscreen, SelectedCursor, WindowId}; +use crate::window::{ + CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, WindowAttributes, + WindowButtons, WindowLevel, +}; + +/// The Win32 implementation of the main `Window` object. +pub(crate) struct Window { + /// Main handle for the window. + window: HWND, + + /// The current window state. + window_state: Arc>, + + // The events loop proxy. + thread_executor: event_loop::EventLoopThreadExecutor, +} + +impl Window { + pub(crate) fn new( + event_loop: &ActiveEventLoop, + w_attr: WindowAttributes, + ) -> Result { + // We dispatch an `init` function because of code style. + // First person to remove the need for cloning here gets a cookie! + // + // done. you owe me -- ossi + unsafe { init(w_attr, event_loop) } + } + + pub(crate) fn maybe_queue_on_main(&self, f: impl FnOnce(&Self) + Send + 'static) { + // TODO: Use `thread_executor` here + f(self) + } + + pub(crate) fn maybe_wait_on_main(&self, f: impl FnOnce(&Self) -> R + Send) -> R { + // TODO: Use `thread_executor` here + f(self) + } + + fn window_state_lock(&self) -> MutexGuard<'_, WindowState> { + self.window_state.lock().unwrap() + } + + pub fn set_title(&self, text: &str) { + let wide_text = util::encode_wide(text); + unsafe { + SetWindowTextW(self.hwnd(), wide_text.as_ptr()); + } + } + + pub fn set_transparent(&self, transparent: bool) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::TRANSPARENT, transparent) + }); + }); + } + + pub fn set_blur(&self, _blur: bool) {} + + #[inline] + pub fn set_visible(&self, visible: bool) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::VISIBLE, visible) + }); + }); + } + + #[inline] + pub fn is_visible(&self) -> Option { + Some(unsafe { IsWindowVisible(self.window) == 1 }) + } + + #[inline] + pub fn request_redraw(&self) { + // NOTE: mark that we requested a redraw to handle requests during `WM_PAINT` handling. + self.window_state.lock().unwrap().redraw_requested = true; + unsafe { + RedrawWindow(self.hwnd(), ptr::null(), 0, RDW_INTERNALPAINT); + } + } + + #[inline] + pub fn pre_present_notify(&self) {} + + #[inline] + pub fn outer_position(&self) -> Result, NotSupportedError> { + util::WindowArea::Outer + .get_rect(self.hwnd()) + .map(|rect| Ok(PhysicalPosition::new(rect.left, rect.top))) + .expect( + "Unexpected GetWindowRect failure; please report this error to \ + rust-windowing/winit", + ) + } + + #[inline] + pub fn inner_position(&self) -> Result, NotSupportedError> { + let mut position: POINT = unsafe { mem::zeroed() }; + if unsafe { ClientToScreen(self.hwnd(), &mut position) } == false.into() { + panic!( + "Unexpected ClientToScreen failure: please report this error to \ + rust-windowing/winit" + ) + } + Ok(PhysicalPosition::new(position.x, position.y)) + } + + #[inline] + pub fn set_outer_position(&self, position: Position) { + let (x, y): (i32, i32) = position.to_physical::(self.scale_factor()).into(); + + let window_state = Arc::clone(&self.window_state); + let window = self.window; + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::MAXIMIZED, false) + }); + }); + + unsafe { + SetWindowPos( + self.hwnd(), + 0, + x, + y, + 0, + 0, + SWP_ASYNCWINDOWPOS | SWP_NOZORDER | SWP_NOSIZE | SWP_NOACTIVATE, + ); + InvalidateRgn(self.hwnd(), 0, false.into()); + } + } + + #[inline] + pub fn inner_size(&self) -> PhysicalSize { + let mut rect: RECT = unsafe { mem::zeroed() }; + if unsafe { GetClientRect(self.hwnd(), &mut rect) } == false.into() { + panic!( + "Unexpected GetClientRect failure: please report this error to \ + rust-windowing/winit" + ) + } + PhysicalSize::new((rect.right - rect.left) as u32, (rect.bottom - rect.top) as u32) + } + + #[inline] + pub fn outer_size(&self) -> PhysicalSize { + util::WindowArea::Outer + .get_rect(self.hwnd()) + .map(|rect| { + PhysicalSize::new((rect.right - rect.left) as u32, (rect.bottom - rect.top) as u32) + }) + .unwrap() + } + + #[inline] + pub fn request_inner_size(&self, size: Size) -> Option> { + let scale_factor = self.scale_factor(); + let physical_size = size.to_physical::(scale_factor); + + let window_flags = self.window_state_lock().window_flags; + window_flags.set_size(self.hwnd(), physical_size); + + if physical_size != self.inner_size() { + let window_state = Arc::clone(&self.window_state); + let window = self.window; + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::MAXIMIZED, false) + }); + }); + } + + None + } + + #[inline] + pub fn set_min_inner_size(&self, size: Option) { + self.window_state_lock().min_size = size; + // Make windows re-check the window size bounds. + let size = self.inner_size(); + self.request_inner_size(size.into()); + } + + #[inline] + pub fn set_max_inner_size(&self, size: Option) { + self.window_state_lock().max_size = size; + // Make windows re-check the window size bounds. + let size = self.inner_size(); + self.request_inner_size(size.into()); + } + + #[inline] + pub fn resize_increments(&self) -> Option> { + let w = self.window_state_lock(); + let scale_factor = w.scale_factor; + w.resize_increments.map(|size| size.to_physical(scale_factor)) + } + + #[inline] + pub fn set_resize_increments(&self, increments: Option) { + self.window_state_lock().resize_increments = increments; + } + + #[inline] + pub fn set_resizable(&self, resizable: bool) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::RESIZABLE, resizable) + }); + }); + } + + #[inline] + pub fn is_resizable(&self) -> bool { + let window_state = self.window_state_lock(); + window_state.window_flags.contains(WindowFlags::RESIZABLE) + } + + #[inline] + pub fn set_enabled_buttons(&self, buttons: WindowButtons) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::MINIMIZABLE, buttons.contains(WindowButtons::MINIMIZE)); + f.set(WindowFlags::MAXIMIZABLE, buttons.contains(WindowButtons::MAXIMIZE)); + f.set(WindowFlags::CLOSABLE, buttons.contains(WindowButtons::CLOSE)) + }); + }); + } + + pub fn enabled_buttons(&self) -> WindowButtons { + let mut buttons = WindowButtons::empty(); + let window_state = self.window_state_lock(); + if window_state.window_flags.contains(WindowFlags::MINIMIZABLE) { + buttons |= WindowButtons::MINIMIZE; + } + if window_state.window_flags.contains(WindowFlags::MAXIMIZABLE) { + buttons |= WindowButtons::MAXIMIZE; + } + if window_state.window_flags.contains(WindowFlags::CLOSABLE) { + buttons |= WindowButtons::CLOSE; + } + buttons + } + + /// Returns the `hwnd` of this window. + #[inline] + pub fn hwnd(&self) -> HWND { + self.window + } + + #[cfg(feature = "rwh_04")] + #[inline] + pub fn raw_window_handle_rwh_04(&self) -> rwh_04::RawWindowHandle { + let mut window_handle = rwh_04::Win32Handle::empty(); + window_handle.hwnd = self.window as *mut _; + let hinstance = unsafe { super::get_window_long(self.hwnd(), GWLP_HINSTANCE) }; + window_handle.hinstance = hinstance as *mut _; + rwh_04::RawWindowHandle::Win32(window_handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_window_handle_rwh_05(&self) -> rwh_05::RawWindowHandle { + let mut window_handle = rwh_05::Win32WindowHandle::empty(); + window_handle.hwnd = self.window as *mut _; + let hinstance = unsafe { super::get_window_long(self.hwnd(), GWLP_HINSTANCE) }; + window_handle.hinstance = hinstance as *mut _; + rwh_05::RawWindowHandle::Win32(window_handle) + } + + #[cfg(feature = "rwh_05")] + #[inline] + pub fn raw_display_handle_rwh_05(&self) -> rwh_05::RawDisplayHandle { + rwh_05::RawDisplayHandle::Windows(rwh_05::WindowsDisplayHandle::empty()) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub unsafe fn rwh_06_no_thread_check( + &self, + ) -> Result { + let mut window_handle = rwh_06::Win32WindowHandle::new(unsafe { + // SAFETY: Handle will never be zero. + std::num::NonZeroIsize::new_unchecked(self.window) + }); + let hinstance = unsafe { super::get_window_long(self.hwnd(), GWLP_HINSTANCE) }; + window_handle.hinstance = std::num::NonZeroIsize::new(hinstance); + Ok(rwh_06::RawWindowHandle::Win32(window_handle)) + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_window_handle_rwh_06(&self) -> Result { + // TODO: Write a test once integration framework is ready to ensure that it holds. + // If we aren't in the GUI thread, we can't return the window. + if !self.thread_executor.in_event_loop_thread() { + tracing::error!("tried to access window handle outside of the main thread"); + return Err(rwh_06::HandleError::Unavailable); + } + + // SAFETY: We are on the correct thread. + unsafe { self.rwh_06_no_thread_check() } + } + + #[cfg(feature = "rwh_06")] + #[inline] + pub fn raw_display_handle_rwh_06( + &self, + ) -> Result { + Ok(rwh_06::RawDisplayHandle::Windows(rwh_06::WindowsDisplayHandle::new())) + } + + #[inline] + pub fn set_cursor(&self, cursor: Cursor) { + match cursor { + Cursor::Icon(icon) => { + self.window_state_lock().mouse.selected_cursor = SelectedCursor::Named(icon); + self.thread_executor.execute_in_thread(move || unsafe { + let cursor = LoadCursorW(0, util::to_windows_cursor(icon)); + SetCursor(cursor); + }); + }, + Cursor::Custom(cursor) => { + let new_cursor = match cursor.inner { + WinCursor::Cursor(cursor) => cursor, + WinCursor::Failed => { + warn!("Requested to apply failed cursor"); + return; + }, + }; + self.window_state_lock().mouse.selected_cursor = + SelectedCursor::Custom(new_cursor.clone()); + self.thread_executor.execute_in_thread(move || unsafe { + SetCursor(new_cursor.as_raw_handle()); + }); + }, + } + } + + #[inline] + pub fn set_cursor_grab(&self, mode: CursorGrabMode) -> Result<(), ExternalError> { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + let (tx, rx) = channel(); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + let result = window_state + .lock() + .unwrap() + .mouse + .set_cursor_flags(window, |f| { + f.set(CursorFlags::GRABBED, mode != CursorGrabMode::None); + f.set(CursorFlags::LOCKED, mode == CursorGrabMode::Locked); + }) + .map_err(|e| ExternalError::Os(os_error!(e))); + let _ = tx.send(result); + }); + rx.recv().unwrap() + } + + #[inline] + pub fn set_cursor_visible(&self, visible: bool) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + let (tx, rx) = channel(); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + let result = window_state + .lock() + .unwrap() + .mouse + .set_cursor_flags(window, |f| f.set(CursorFlags::HIDDEN, !visible)) + .map_err(|e| e.to_string()); + let _ = tx.send(result); + }); + rx.recv().unwrap().ok(); + } + + #[inline] + pub fn scale_factor(&self) -> f64 { + self.window_state_lock().scale_factor + } + + #[inline] + pub fn set_cursor_position(&self, position: Position) -> Result<(), ExternalError> { + let scale_factor = self.scale_factor(); + let (x, y) = position.to_physical::(scale_factor).into(); + + let mut point = POINT { x, y }; + unsafe { + if ClientToScreen(self.hwnd(), &mut point) == false.into() { + return Err(ExternalError::Os(os_error!(io::Error::last_os_error()))); + } + if SetCursorPos(point.x, point.y) == false.into() { + return Err(ExternalError::Os(os_error!(io::Error::last_os_error()))); + } + } + Ok(()) + } + + unsafe fn handle_os_dragging(&self, wparam: WPARAM) { + let window = self.window; + let window_state = self.window_state.clone(); + + self.thread_executor.execute_in_thread(move || { + { + let mut guard = window_state.lock().unwrap(); + if !guard.dragging { + guard.dragging = true; + } else { + return; + } + } + + let points = { + let mut pos = unsafe { mem::zeroed() }; + unsafe { GetCursorPos(&mut pos) }; + pos + }; + let points = POINTS { x: points.x as i16, y: points.y as i16 }; + + // ReleaseCapture needs to execute on the main thread + unsafe { ReleaseCapture() }; + + unsafe { + PostMessageW(window, WM_NCLBUTTONDOWN, wparam, &points as *const _ as LPARAM) + }; + }); + } + + #[inline] + pub fn drag_window(&self) -> Result<(), ExternalError> { + unsafe { + self.handle_os_dragging(HTCAPTION as WPARAM); + } + + Ok(()) + } + + #[inline] + pub fn drag_resize_window(&self, direction: ResizeDirection) -> Result<(), ExternalError> { + unsafe { + self.handle_os_dragging(match direction { + ResizeDirection::East => HTRIGHT, + ResizeDirection::North => HTTOP, + ResizeDirection::NorthEast => HTTOPRIGHT, + ResizeDirection::NorthWest => HTTOPLEFT, + ResizeDirection::South => HTBOTTOM, + ResizeDirection::SouthEast => HTBOTTOMRIGHT, + ResizeDirection::SouthWest => HTBOTTOMLEFT, + ResizeDirection::West => HTLEFT, + } as WPARAM); + } + + Ok(()) + } + + unsafe fn handle_showing_window_menu(&self, position: Position) { + unsafe { + let point = { + let mut point = POINT { x: 0, y: 0 }; + let scale_factor = self.scale_factor(); + let (x, y) = position.to_physical::(scale_factor).into(); + point.x = x; + point.y = y; + if ClientToScreen(self.hwnd(), &mut point) == false.into() { + warn!( + "Can't convert client-area coordinates to screen coordinates when showing \ + window menu." + ); + return; + } + point + }; + + // get the current system menu + let h_menu = GetSystemMenu(self.hwnd(), 0); + if h_menu == 0 { + warn!("The corresponding window doesn't have a system menu"); + // This situation should not be treated as an error so just return without showing + // menu. + return; + } + + fn enable(b: bool) -> MENU_ITEM_STATE { + if b { + MFS_ENABLED + } else { + MFS_DISABLED + } + } + + // Change the menu items according to the current window status. + + let restore_btn = enable(self.is_maximized() && self.is_resizable()); + let size_btn = enable(!self.is_maximized() && self.is_resizable()); + let maximize_btn = enable(!self.is_maximized() && self.is_resizable()); + + EnableMenuItem(h_menu, SC_RESTORE, MF_BYCOMMAND | restore_btn); + EnableMenuItem(h_menu, SC_MOVE, MF_BYCOMMAND | enable(!self.is_maximized())); + EnableMenuItem(h_menu, SC_SIZE, MF_BYCOMMAND | size_btn); + EnableMenuItem(h_menu, SC_MINIMIZE, MF_BYCOMMAND | MFS_ENABLED); + EnableMenuItem(h_menu, SC_MAXIMIZE, MF_BYCOMMAND | maximize_btn); + EnableMenuItem(h_menu, SC_CLOSE, MF_BYCOMMAND | MFS_ENABLED); + + // Set the default menu item. + SetMenuDefaultItem(h_menu, SC_CLOSE, 0); + + // Popup the system menu at the position. + let result = TrackPopupMenu( + h_menu, + TPM_RETURNCMD | TPM_LEFTALIGN, /* for now im using LTR, but we have to use user + * layout direction */ + point.x, + point.y, + 0, + self.hwnd(), + std::ptr::null_mut(), + ); + + if result == 0 { + // User canceled the menu, no need to continue. + return; + } + + // Send the command that the user select to the corresponding window. + if PostMessageW(self.hwnd(), WM_SYSCOMMAND, result as _, 0) == 0 { + warn!("Can't post the system menu message to the window."); + } + } + } + + #[inline] + pub fn show_window_menu(&self, position: Position) { + unsafe { + self.handle_showing_window_menu(position); + } + } + + #[inline] + pub fn set_cursor_hittest(&self, hittest: bool) -> Result<(), ExternalError> { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + self.thread_executor.execute_in_thread(move || { + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::IGNORE_CURSOR_EVENT, !hittest) + }); + }); + + Ok(()) + } + + #[inline] + pub fn id(&self) -> WindowId { + WindowId(self.hwnd()) + } + + #[inline] + pub fn set_minimized(&self, minimized: bool) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + + let is_minimized = util::is_minimized(self.hwnd()); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags_in_place(&mut window_state.lock().unwrap(), |f| { + f.set(WindowFlags::MINIMIZED, is_minimized) + }); + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::MINIMIZED, minimized) + }); + }); + } + + #[inline] + pub fn is_minimized(&self) -> Option { + Some(util::is_minimized(self.hwnd())) + } + + #[inline] + pub fn set_maximized(&self, maximized: bool) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::MAXIMIZED, maximized) + }); + }); + } + + #[inline] + pub fn is_maximized(&self) -> bool { + let window_state = self.window_state_lock(); + window_state.window_flags.contains(WindowFlags::MAXIMIZED) + } + + #[inline] + pub fn fullscreen(&self) -> Option { + let window_state = self.window_state_lock(); + window_state.fullscreen.clone() + } + + #[inline] + pub fn set_fullscreen(&self, fullscreen: Option) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + + let mut window_state_lock = window_state.lock().unwrap(); + let old_fullscreen = window_state_lock.fullscreen.clone(); + + match (&old_fullscreen, &fullscreen) { + // Return if we already are in the same fullscreen mode + _ if old_fullscreen == fullscreen => return, + // Return if saved Borderless(monitor) is the same as current monitor when requested + // fullscreen is Borderless(None) + (Some(Fullscreen::Borderless(Some(monitor))), Some(Fullscreen::Borderless(None))) + if *monitor == monitor::current_monitor(window) => + { + return + }, + _ => {}, + } + + window_state_lock.fullscreen.clone_from(&fullscreen); + drop(window_state_lock); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + // Change video mode if we're transitioning to or from exclusive + // fullscreen + match (&old_fullscreen, &fullscreen) { + (_, Some(Fullscreen::Exclusive(video_mode))) => { + let monitor = video_mode.monitor(); + let monitor_info = monitor::get_monitor_info(monitor.hmonitor()).unwrap(); + + let res = unsafe { + ChangeDisplaySettingsExW( + monitor_info.szDevice.as_ptr(), + &*video_mode.native_video_mode, + 0, + CDS_FULLSCREEN, + ptr::null(), + ) + }; + + debug_assert!(res != DISP_CHANGE_BADFLAGS); + debug_assert!(res != DISP_CHANGE_BADMODE); + debug_assert!(res != DISP_CHANGE_BADPARAM); + debug_assert!(res != DISP_CHANGE_FAILED); + assert_eq!(res, DISP_CHANGE_SUCCESSFUL); + }, + (Some(Fullscreen::Exclusive(_)), _) => { + let res = unsafe { + ChangeDisplaySettingsExW( + ptr::null(), + ptr::null(), + 0, + CDS_FULLSCREEN, + ptr::null(), + ) + }; + + debug_assert!(res != DISP_CHANGE_BADFLAGS); + debug_assert!(res != DISP_CHANGE_BADMODE); + debug_assert!(res != DISP_CHANGE_BADPARAM); + debug_assert!(res != DISP_CHANGE_FAILED); + assert_eq!(res, DISP_CHANGE_SUCCESSFUL); + }, + _ => (), + } + + unsafe { + // There are some scenarios where calling `ChangeDisplaySettingsExW` takes long + // enough to execute that the DWM thinks our program has frozen and takes over + // our program's window. When that happens, the `SetWindowPos` call below gets + // eaten and the window doesn't get set to the proper fullscreen position. + // + // Calling `PeekMessageW` here notifies Windows that our process is still running + // fine, taking control back from the DWM and ensuring that the `SetWindowPos` call + // below goes through. + let mut msg = mem::zeroed(); + PeekMessageW(&mut msg, 0, 0, 0, PM_NOREMOVE); + } + + // Update window style + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set( + WindowFlags::MARKER_EXCLUSIVE_FULLSCREEN, + matches!(fullscreen, Some(Fullscreen::Exclusive(_))), + ); + f.set( + WindowFlags::MARKER_BORDERLESS_FULLSCREEN, + matches!(fullscreen, Some(Fullscreen::Borderless(_))), + ); + }); + + // Mark as fullscreen window wrt to z-order + // + // this needs to be called before the below fullscreen SetWindowPos as this itself + // will generate WM_SIZE messages of the old window size that can race with what we set + // below + unsafe { + taskbar_mark_fullscreen(window, fullscreen.is_some()); + } + + // Update window bounds + match &fullscreen { + Some(fullscreen) => { + // Save window bounds before entering fullscreen + let placement = unsafe { + let mut placement = mem::zeroed(); + GetWindowPlacement(window, &mut placement); + placement + }; + + window_state.lock().unwrap().saved_window = Some(SavedWindow { placement }); + + let monitor = match &fullscreen { + Fullscreen::Exclusive(video_mode) => video_mode.monitor(), + Fullscreen::Borderless(Some(monitor)) => monitor.clone(), + Fullscreen::Borderless(None) => monitor::current_monitor(window), + }; + + let position: (i32, i32) = monitor.position().into(); + let size: (u32, u32) = monitor.size().into(); + + unsafe { + SetWindowPos( + window, + 0, + position.0, + position.1, + size.0 as i32, + size.1 as i32, + SWP_ASYNCWINDOWPOS | SWP_NOZORDER, + ); + InvalidateRgn(window, 0, false.into()); + } + }, + None => { + let mut window_state_lock = window_state.lock().unwrap(); + if let Some(SavedWindow { placement }) = window_state_lock.saved_window.take() { + drop(window_state_lock); + unsafe { + SetWindowPlacement(window, &placement); + InvalidateRgn(window, 0, false.into()); + } + } + }, + } + }); + } + + #[inline] + pub fn set_decorations(&self, decorations: bool) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::MARKER_DECORATIONS, decorations) + }); + }); + } + + #[inline] + pub fn is_decorated(&self) -> bool { + let window_state = self.window_state_lock(); + window_state.window_flags.contains(WindowFlags::MARKER_DECORATIONS) + } + + #[inline] + pub fn set_window_level(&self, level: WindowLevel) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::ALWAYS_ON_TOP, level == WindowLevel::AlwaysOnTop); + f.set(WindowFlags::ALWAYS_ON_BOTTOM, level == WindowLevel::AlwaysOnBottom); + }); + }); + } + + #[inline] + pub fn current_monitor(&self) -> Option { + Some(monitor::current_monitor(self.hwnd())) + } + + #[inline] + pub fn set_window_icon(&self, window_icon: Option) { + if let Some(ref window_icon) = window_icon { + window_icon.inner.set_for_window(self.hwnd(), IconType::Small); + } else { + icon::unset_for_window(self.hwnd(), IconType::Small); + } + self.window_state_lock().window_icon = window_icon; + } + + #[inline] + pub fn set_enable(&self, enabled: bool) { + unsafe { EnableWindow(self.hwnd(), enabled.into()) }; + } + + #[inline] + pub fn set_taskbar_icon(&self, taskbar_icon: Option) { + if let Some(ref taskbar_icon) = taskbar_icon { + taskbar_icon.inner.set_for_window(self.hwnd(), IconType::Big); + } else { + icon::unset_for_window(self.hwnd(), IconType::Big); + } + self.window_state_lock().taskbar_icon = taskbar_icon; + } + + #[inline] + pub fn set_ime_cursor_area(&self, spot: Position, size: Size) { + let window = self.window; + let state = self.window_state.clone(); + self.thread_executor.execute_in_thread(move || unsafe { + let scale_factor = state.lock().unwrap().scale_factor; + ImeContext::current(window).set_ime_cursor_area(spot, size, scale_factor); + }); + } + + #[inline] + pub fn set_ime_allowed(&self, allowed: bool) { + let window = self.window; + let state = self.window_state.clone(); + self.thread_executor.execute_in_thread(move || unsafe { + state.lock().unwrap().ime_allowed = allowed; + ImeContext::set_ime_allowed(window, allowed); + }) + } + + #[inline] + pub fn set_ime_purpose(&self, _purpose: ImePurpose) {} + + #[inline] + pub fn request_user_attention(&self, request_type: Option) { + let window = self.window; + let active_window_handle = unsafe { GetActiveWindow() }; + if window == active_window_handle { + return; + } + + self.thread_executor.execute_in_thread(move || unsafe { + let (flags, count) = request_type + .map(|ty| match ty { + UserAttentionType::Critical => (FLASHW_ALL | FLASHW_TIMERNOFG, u32::MAX), + UserAttentionType::Informational => (FLASHW_TRAY | FLASHW_TIMERNOFG, 0), + }) + .unwrap_or((FLASHW_STOP, 0)); + + let flash_info = FLASHWINFO { + cbSize: mem::size_of::() as u32, + hwnd: window, + dwFlags: flags, + uCount: count, + dwTimeout: 0, + }; + FlashWindowEx(&flash_info); + }); + } + + #[inline] + pub fn set_theme(&self, theme: Option) { + try_theme(self.window, theme); + } + + #[inline] + pub fn theme(&self) -> Option { + Some(self.window_state_lock().current_theme) + } + + #[inline] + pub fn has_focus(&self) -> bool { + let window_state = self.window_state.lock().unwrap(); + window_state.has_active_focus() + } + + pub fn title(&self) -> String { + let len = unsafe { GetWindowTextLengthW(self.window) } + 1; + let mut buf = vec![0; len as usize]; + unsafe { GetWindowTextW(self.window, buf.as_mut_ptr(), len) }; + util::decode_wide(&buf).to_string_lossy().to_string() + } + + #[inline] + pub fn set_skip_taskbar(&self, skip: bool) { + self.window_state_lock().skip_taskbar = skip; + unsafe { set_skip_taskbar(self.hwnd(), skip) }; + } + + #[inline] + pub fn set_undecorated_shadow(&self, shadow: bool) { + let window = self.window; + let window_state = Arc::clone(&self.window_state); + + self.thread_executor.execute_in_thread(move || { + let _ = &window; + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + f.set(WindowFlags::MARKER_UNDECORATED_SHADOW, shadow) + }); + }); + } + + #[inline] + pub fn set_system_backdrop(&self, backdrop_type: BackdropType) { + unsafe { + DwmSetWindowAttribute( + self.hwnd(), + DWMWA_SYSTEMBACKDROP_TYPE as u32, + &(backdrop_type as i32) as *const _ as _, + mem::size_of::() as _, + ); + } + } + + #[inline] + pub fn focus_window(&self) { + let window_flags = self.window_state_lock().window_flags(); + + let is_visible = window_flags.contains(WindowFlags::VISIBLE); + let is_minimized = util::is_minimized(self.hwnd()); + let is_foreground = self.window == unsafe { GetForegroundWindow() }; + + if is_visible && !is_minimized && !is_foreground { + unsafe { force_window_active(self.window) }; + } + } + + #[inline] + pub fn set_content_protected(&self, protected: bool) { + unsafe { + SetWindowDisplayAffinity( + self.hwnd(), + if protected { WDA_EXCLUDEFROMCAPTURE } else { WDA_NONE }, + ) + }; + } + + #[inline] + pub fn reset_dead_keys(&self) { + // `ToUnicode` consumes the dead-key by default, so we are constructing a fake (but valid) + // key input which we can call `ToUnicode` with. + unsafe { + let vk = VK_SPACE as VIRTUAL_KEY; + let scancode = MapVirtualKeyW(vk as u32, MAPVK_VK_TO_VSC); + let kbd_state = [0; 256]; + let mut char_buff = [MaybeUninit::uninit(); 8]; + ToUnicode( + vk as u32, + scancode, + kbd_state.as_ptr(), + char_buff[0].as_mut_ptr(), + char_buff.len() as i32, + 0, + ); + } + } + + #[inline] + pub fn set_border_color(&self, color: Color) { + unsafe { + DwmSetWindowAttribute( + self.hwnd(), + DWMWA_BORDER_COLOR as u32, + &color as *const _ as _, + mem::size_of::() as _, + ); + } + } + + #[inline] + pub fn set_title_background_color(&self, color: Color) { + unsafe { + DwmSetWindowAttribute( + self.hwnd(), + DWMWA_CAPTION_COLOR as u32, + &color as *const _ as _, + mem::size_of::() as _, + ); + } + } + + #[inline] + pub fn set_title_text_color(&self, color: Color) { + unsafe { + DwmSetWindowAttribute( + self.hwnd(), + DWMWA_TEXT_COLOR as u32, + &color as *const _ as _, + mem::size_of::() as _, + ); + } + } + + #[inline] + pub fn set_corner_preference(&self, preference: CornerPreference) { + unsafe { + DwmSetWindowAttribute( + self.hwnd(), + DWMWA_WINDOW_CORNER_PREFERENCE as u32, + &(preference as DWM_WINDOW_CORNER_PREFERENCE) as *const _ as _, + mem::size_of::() as _, + ); + } + } +} + +impl Drop for Window { + #[inline] + fn drop(&mut self) { + unsafe { + // The window must be destroyed from the same thread that created it, so we send a + // custom message to be handled by our callback to do the actual work. + PostMessageW(self.hwnd(), DESTROY_MSG_ID.get(), 0, 0); + } + } +} + +pub(super) struct InitData<'a> { + // inputs + pub event_loop: &'a ActiveEventLoop, + pub attributes: WindowAttributes, + pub window_flags: WindowFlags, + // outputs + pub window: Option, +} + +impl InitData<'_> { + unsafe fn create_window(&self, window: HWND) -> Window { + // Register for touch events if applicable + { + let digitizer = unsafe { GetSystemMetrics(SM_DIGITIZER) as u32 }; + if digitizer & NID_READY != 0 { + unsafe { RegisterTouchWindow(window, TWF_WANTPALM) }; + } + } + + let dpi = unsafe { hwnd_dpi(window) }; + let scale_factor = dpi_to_scale_factor(dpi); + + // If the system theme is dark, we need to set the window theme now + // before we update the window flags (and possibly show the + // window for the first time). + let current_theme = try_theme(window, self.attributes.preferred_theme); + + let window_state = { + let window_state = WindowState::new( + &self.attributes, + scale_factor, + current_theme, + self.attributes.preferred_theme, + ); + let window_state = Arc::new(Mutex::new(window_state)); + WindowState::set_window_flags(window_state.lock().unwrap(), window, |f| { + *f = self.window_flags + }); + window_state + }; + + enable_non_client_dpi_scaling(window); + + unsafe { ImeContext::set_ime_allowed(window, false) }; + + Window { window, window_state, thread_executor: self.event_loop.create_thread_executor() } + } + + unsafe fn create_window_data(&self, win: &Window) -> event_loop::WindowData { + let file_drop_handler = if self.attributes.platform_specific.drag_and_drop { + let ole_init_result = unsafe { OleInitialize(ptr::null_mut()) }; + // It is ok if the initialize result is `S_FALSE` because it might happen that + // multiple windows are created on the same thread. + if ole_init_result == OLE_E_WRONGCOMPOBJ { + panic!("OleInitialize failed! Result was: `OLE_E_WRONGCOMPOBJ`"); + } else if ole_init_result == RPC_E_CHANGED_MODE { + panic!( + "OleInitialize failed! Result was: `RPC_E_CHANGED_MODE`. Make sure other \ + crates are not using multithreaded COM library on the same thread or disable \ + drag and drop support." + ); + } + + let file_drop_runner = self.event_loop.runner_shared.clone(); + let file_drop_handler = FileDropHandler::new( + win.window, + Box::new(move |event| { + if let Ok(e) = event.map_nonuser_event() { + file_drop_runner.send_event(e) + } + }), + ); + + let handler_interface_ptr = + unsafe { &mut (*file_drop_handler.data).interface as *mut _ as *mut c_void }; + + assert_eq!(unsafe { RegisterDragDrop(win.window, handler_interface_ptr) }, S_OK); + Some(file_drop_handler) + } else { + None + }; + + event_loop::WindowData { + window_state: win.window_state.clone(), + event_loop_runner: self.event_loop.runner_shared.clone(), + key_event_builder: KeyEventBuilder::default(), + _file_drop_handler: file_drop_handler, + userdata_removed: Cell::new(false), + recurse_depth: Cell::new(0), + } + } + + // Returns a pointer to window user data on success. + // The user data will be registered for the window and can be accessed within the window event + // callback. + pub unsafe fn on_nccreate(&mut self, window: HWND) -> Option { + let runner = self.event_loop.runner_shared.clone(); + let result = runner.catch_unwind(|| { + let window = unsafe { self.create_window(window) }; + let window_data = unsafe { self.create_window_data(&window) }; + (window, window_data) + }); + + result.map(|(win, userdata)| { + self.window = Some(win); + let userdata = Box::into_raw(Box::new(userdata)); + userdata as _ + }) + } + + pub unsafe fn on_create(&mut self) { + let win = self.window.as_mut().expect("failed window creation"); + + // making the window transparent + if self.attributes.transparent && !self.attributes.platform_specific.no_redirection_bitmap { + // Empty region for the blur effect, so the window is fully transparent + let region = unsafe { CreateRectRgn(0, 0, -1, -1) }; + + let bb = DWM_BLURBEHIND { + dwFlags: DWM_BB_ENABLE | DWM_BB_BLURREGION, + fEnable: true.into(), + hRgnBlur: region, + fTransitionOnMaximized: false.into(), + }; + let hr = unsafe { DwmEnableBlurBehindWindow(win.hwnd(), &bb) }; + if hr < 0 { + warn!("Setting transparent window is failed. HRESULT Code: 0x{:X}", hr); + } + unsafe { DeleteObject(region) }; + } + + win.set_skip_taskbar(self.attributes.platform_specific.skip_taskbar); + win.set_window_icon(self.attributes.window_icon.clone()); + win.set_taskbar_icon(self.attributes.platform_specific.taskbar_icon.clone()); + + let attributes = self.attributes.clone(); + + if attributes.content_protected { + win.set_content_protected(true); + } + + win.set_cursor(attributes.cursor); + + // Set visible before setting the size to ensure the + // attribute is correctly applied. + win.set_visible(attributes.visible); + + win.set_enabled_buttons(attributes.enabled_buttons); + + let size = attributes.inner_size.unwrap_or_else(|| PhysicalSize::new(800, 600).into()); + let max_size = attributes + .max_inner_size + .unwrap_or_else(|| PhysicalSize::new(f64::MAX, f64::MAX).into()); + let min_size = attributes.min_inner_size.unwrap_or_else(|| PhysicalSize::new(0, 0).into()); + let clamped_size = Size::clamp(size, min_size, max_size, win.scale_factor()); + win.request_inner_size(clamped_size); + + // let margins = MARGINS { + // cxLeftWidth: 1, + // cxRightWidth: 1, + // cyTopHeight: 1, + // cyBottomHeight: 1, + // }; + // dbg!(DwmExtendFrameIntoClientArea(win.hwnd(), &margins as *const _)); + + if let Some(position) = attributes.position { + win.set_outer_position(position); + } + + win.set_system_backdrop(self.attributes.platform_specific.backdrop_type); + + if let Some(color) = self.attributes.platform_specific.border_color { + win.set_border_color(color); + } + if let Some(color) = self.attributes.platform_specific.title_background_color { + win.set_title_background_color(color); + } + if let Some(color) = self.attributes.platform_specific.title_text_color { + win.set_title_text_color(color); + } + if let Some(corner) = self.attributes.platform_specific.corner_preference { + win.set_corner_preference(corner); + } + } +} +unsafe fn init( + attributes: WindowAttributes, + event_loop: &ActiveEventLoop, +) -> Result { + let title = util::encode_wide(&attributes.title); + + let class_name = util::encode_wide(&attributes.platform_specific.class_name); + unsafe { register_window_class(&class_name) }; + + let mut window_flags = WindowFlags::empty(); + window_flags.set(WindowFlags::MARKER_DECORATIONS, attributes.decorations); + window_flags.set( + WindowFlags::MARKER_UNDECORATED_SHADOW, + attributes.platform_specific.decoration_shadow, + ); + window_flags + .set(WindowFlags::ALWAYS_ON_TOP, attributes.window_level == WindowLevel::AlwaysOnTop); + window_flags + .set(WindowFlags::ALWAYS_ON_BOTTOM, attributes.window_level == WindowLevel::AlwaysOnBottom); + window_flags + .set(WindowFlags::NO_BACK_BUFFER, attributes.platform_specific.no_redirection_bitmap); + window_flags.set(WindowFlags::MARKER_ACTIVATE, attributes.active); + window_flags.set(WindowFlags::TRANSPARENT, attributes.transparent); + // WindowFlags::VISIBLE and MAXIMIZED are set down below after the window has been configured. + window_flags.set(WindowFlags::RESIZABLE, attributes.resizable); + // Will be changed later using `window.set_enabled_buttons` but we need to set a default here + // so the diffing later can work. + window_flags.set(WindowFlags::CLOSABLE, true); + window_flags.set(WindowFlags::CLIP_CHILDREN, attributes.platform_specific.clip_children); + + let mut fallback_parent = || match attributes.platform_specific.owner { + Some(parent) => { + window_flags.set(WindowFlags::POPUP, true); + Some(parent) + }, + None => { + window_flags.set(WindowFlags::ON_TASKBAR, true); + None + }, + }; + + #[cfg(feature = "rwh_06")] + let parent = match attributes.parent_window.as_ref().map(|handle| handle.0) { + Some(rwh_06::RawWindowHandle::Win32(handle)) => { + window_flags.set(WindowFlags::CHILD, true); + if attributes.platform_specific.menu.is_some() { + warn!("Setting a menu on a child window is unsupported"); + } + Some(handle.hwnd.get() as HWND) + }, + Some(raw) => unreachable!("Invalid raw window handle {raw:?} on Windows"), + None => fallback_parent(), + }; + + #[cfg(not(feature = "rwh_06"))] + let parent = fallback_parent(); + + let menu = attributes.platform_specific.menu; + let fullscreen = attributes.fullscreen.clone(); + let maximized = attributes.maximized; + let mut initdata = InitData { event_loop, attributes, window_flags, window: None }; + + let (style, ex_style) = window_flags.to_window_styles(); + let handle = unsafe { + CreateWindowExW( + ex_style, + class_name.as_ptr(), + title.as_ptr(), + style, + CW_USEDEFAULT, + CW_USEDEFAULT, + CW_USEDEFAULT, + CW_USEDEFAULT, + parent.unwrap_or(0), + menu.unwrap_or(0), + util::get_instance_handle(), + &mut initdata as *mut _ as *mut _, + ) + }; + + // If the window creation in `InitData` panicked, then should resume panicking here + if let Err(panic_error) = event_loop.runner_shared.take_panic_error() { + panic::resume_unwind(panic_error) + } + + if handle == 0 { + return Err(os_error!(io::Error::last_os_error())); + } + + // If the handle is non-null, then window creation must have succeeded, which means + // that we *must* have populated the `InitData.window` field. + let win = initdata.window.unwrap(); + + // Need to set FULLSCREEN or MAXIMIZED after CreateWindowEx + // This is because if the size is changed in WM_CREATE, the restored size will be stored in that + // size. + if fullscreen.is_some() { + win.set_fullscreen(fullscreen.map(Into::into)); + unsafe { force_window_active(win.window) }; + } else if maximized { + win.set_maximized(true); + } + + Ok(win) +} + +unsafe fn register_window_class(class_name: &[u16]) { + let class = WNDCLASSEXW { + cbSize: mem::size_of::() as u32, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(super::event_loop::public_window_callback), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: util::get_instance_handle(), + hIcon: 0, + hCursor: 0, // must be null in order for cursor state to work properly + hbrBackground: 0, + lpszMenuName: ptr::null(), + lpszClassName: class_name.as_ptr(), + hIconSm: 0, + }; + + // We ignore errors because registering the same window class twice would trigger + // an error, and because errors here are detected during CreateWindowEx anyway. + // Also since there is no weird element in the struct, there is no reason for this + // call to fail. + unsafe { RegisterClassExW(&class) }; +} + +struct ComInitialized(#[allow(dead_code)] *mut ()); +impl Drop for ComInitialized { + fn drop(&mut self) { + unsafe { CoUninitialize() }; + } +} + +thread_local! { + static COM_INITIALIZED: ComInitialized = { + unsafe { + CoInitializeEx(ptr::null(), COINIT_APARTMENTTHREADED as u32); + ComInitialized(ptr::null_mut()) + } + }; + + static TASKBAR_LIST: Cell<*mut ITaskbarList> = const { Cell::new(ptr::null_mut()) }; + static TASKBAR_LIST2: Cell<*mut ITaskbarList2> = const { Cell::new(ptr::null_mut()) }; +} + +pub fn com_initialized() { + COM_INITIALIZED.with(|_| {}); +} + +// Reference Implementation: +// https://github.com/chromium/chromium/blob/f18e79d901f56154f80eea1e2218544285e62623/ui/views/win/fullscreen_handler.cc +// +// As per MSDN marking the window as fullscreen should ensure that the +// taskbar is moved to the bottom of the Z-order when the fullscreen window +// is activated. If the window is not fullscreen, the Shell falls back to +// heuristics to determine how the window should be treated, which means +// that it could still consider the window as fullscreen. :( +unsafe fn taskbar_mark_fullscreen(handle: HWND, fullscreen: bool) { + com_initialized(); + + TASKBAR_LIST2.with(|task_bar_list2_ptr| { + let mut task_bar_list2 = task_bar_list2_ptr.get(); + + if task_bar_list2.is_null() { + let hr = unsafe { + CoCreateInstance( + &CLSID_TaskbarList, + ptr::null_mut(), + CLSCTX_ALL, + &IID_ITaskbarList2, + &mut task_bar_list2 as *mut _ as *mut _, + ) + }; + if hr != S_OK { + // In visual studio retrieving the taskbar list fails + return; + } + + let hr_init = unsafe { (*(*task_bar_list2).lpVtbl).parent.HrInit }; + if unsafe { hr_init(task_bar_list2.cast()) } != S_OK { + // In some old windows, the taskbar object could not be created, we just ignore it + return; + } + task_bar_list2_ptr.set(task_bar_list2) + } + + task_bar_list2 = task_bar_list2_ptr.get(); + let mark_fullscreen_window = unsafe { (*(*task_bar_list2).lpVtbl).MarkFullscreenWindow }; + unsafe { mark_fullscreen_window(task_bar_list2, handle, fullscreen.into()) }; + }) +} + +pub(crate) unsafe fn set_skip_taskbar(hwnd: HWND, skip: bool) { + com_initialized(); + TASKBAR_LIST.with(|task_bar_list_ptr| { + let mut task_bar_list = task_bar_list_ptr.get(); + + if task_bar_list.is_null() { + let hr = unsafe { + CoCreateInstance( + &CLSID_TaskbarList, + ptr::null_mut(), + CLSCTX_ALL, + &IID_ITaskbarList, + &mut task_bar_list as *mut _ as *mut _, + ) + }; + if hr != S_OK { + // In visual studio retrieving the taskbar list fails + return; + } + + let hr_init = unsafe { (*(*task_bar_list).lpVtbl).HrInit }; + if unsafe { hr_init(task_bar_list.cast()) } != S_OK { + // In some old windows, the taskbar object could not be created, we just ignore it + return; + } + task_bar_list_ptr.set(task_bar_list) + } + + task_bar_list = task_bar_list_ptr.get(); + if skip { + let delete_tab = unsafe { (*(*task_bar_list).lpVtbl).DeleteTab }; + unsafe { delete_tab(task_bar_list, hwnd) }; + } else { + let add_tab = unsafe { (*(*task_bar_list).lpVtbl).AddTab }; + unsafe { add_tab(task_bar_list, hwnd) }; + } + }); +} + +unsafe fn force_window_active(handle: HWND) { + // In some situation, calling SetForegroundWindow could not bring up the window, + // This is a little hack which can "steal" the foreground window permission + // We only call this function in the window creation, so it should be fine. + // See : https://stackoverflow.com/questions/10740346/setforegroundwindow-only-working-while-visual-studio-is-open + let alt_sc = unsafe { MapVirtualKeyW(VK_MENU as u32, MAPVK_VK_TO_VSC) }; + + let inputs = [ + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VK_LMENU, + wScan: alt_sc as u16, + dwFlags: KEYEVENTF_EXTENDEDKEY, + dwExtraInfo: 0, + time: 0, + }, + }, + }, + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VK_LMENU, + wScan: alt_sc as u16, + dwFlags: KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP, + dwExtraInfo: 0, + time: 0, + }, + }, + }, + ]; + + // Simulate a key press and release + unsafe { SendInput(inputs.len() as u32, inputs.as_ptr(), mem::size_of::() as i32) }; + + unsafe { SetForegroundWindow(handle) }; +} diff --git a/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/window_state.rs b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/window_state.rs new file mode 100644 index 00000000..8e24a4f2 --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/platform_impl/windows/window_state.rs @@ -0,0 +1,545 @@ +use crate::dpi::{PhysicalPosition, PhysicalSize, Size}; +use crate::icon::Icon; +use crate::keyboard::ModifiersState; +use crate::platform_impl::platform::{event_loop, util, Fullscreen, SelectedCursor}; +use crate::window::{Theme, WindowAttributes}; +use bitflags::bitflags; +use std::io; +use std::sync::MutexGuard; +use windows_sys::Win32::Foundation::{HWND, RECT}; +use windows_sys::Win32::Graphics::Gdi::InvalidateRgn; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + AdjustWindowRectEx, EnableMenuItem, GetMenu, GetSystemMenu, GetWindowLongW, SendMessageW, + SetWindowLongW, SetWindowPos, ShowWindow, GWL_EXSTYLE, GWL_STYLE, HWND_BOTTOM, HWND_NOTOPMOST, + HWND_TOPMOST, MF_BYCOMMAND, MF_DISABLED, MF_ENABLED, SC_CLOSE, SWP_ASYNCWINDOWPOS, + SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOREPOSITION, SWP_NOSIZE, SWP_NOZORDER, + SW_HIDE, SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE, SW_SHOW, SW_SHOWNOACTIVATE, WINDOWPLACEMENT, + WINDOW_EX_STYLE, WINDOW_STYLE, WS_BORDER, WS_CAPTION, WS_CHILD, WS_CLIPCHILDREN, + WS_CLIPSIBLINGS, WS_EX_ACCEPTFILES, WS_EX_APPWINDOW, WS_EX_LAYERED, WS_EX_NOREDIRECTIONBITMAP, + WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_EX_WINDOWEDGE, WS_MAXIMIZE, WS_MAXIMIZEBOX, WS_MINIMIZE, + WS_MINIMIZEBOX, WS_OVERLAPPEDWINDOW, WS_POPUP, WS_SIZEBOX, WS_SYSMENU, WS_VISIBLE, +}; + +/// Contains information about states and the window that the callback is going to use. +pub(crate) struct WindowState { + pub mouse: MouseProperties, + + /// Used by `WM_GETMINMAXINFO`. + pub min_size: Option, + pub max_size: Option, + + pub resize_increments: Option, + + pub window_icon: Option, + pub taskbar_icon: Option, + + pub saved_window: Option, + pub scale_factor: f64, + + pub modifiers_state: ModifiersState, + pub fullscreen: Option, + pub current_theme: Theme, + pub preferred_theme: Option, + + pub window_flags: WindowFlags, + + pub ime_state: ImeState, + pub ime_allowed: bool, + + // Used by WM_NCACTIVATE, WM_SETFOCUS and WM_KILLFOCUS + pub is_active: bool, + pub is_focused: bool, + + // Flag whether redraw was requested. + pub redraw_requested: bool, + + pub dragging: bool, + + pub skip_taskbar: bool, +} + +#[derive(Clone)] +pub struct SavedWindow { + pub placement: WINDOWPLACEMENT, +} + +#[derive(Clone)] +pub struct MouseProperties { + pub(crate) selected_cursor: SelectedCursor, + pub capture_count: u32, + cursor_flags: CursorFlags, + pub last_position: Option>, +} + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct CursorFlags: u8 { + const GRABBED = 1 << 0; + const HIDDEN = 1 << 1; + const IN_WINDOW = 1 << 2; + const LOCKED = 1 << 3; + } +} +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct WindowFlags: u32 { + const RESIZABLE = 1 << 0; + const MINIMIZABLE = 1 << 1; + const MAXIMIZABLE = 1 << 2; + const CLOSABLE = 1 << 3; + const VISIBLE = 1 << 4; + const ON_TASKBAR = 1 << 5; + const ALWAYS_ON_TOP = 1 << 6; + const ALWAYS_ON_BOTTOM = 1 << 7; + const NO_BACK_BUFFER = 1 << 8; + const TRANSPARENT = 1 << 9; + const CHILD = 1 << 10; + const MAXIMIZED = 1 << 11; + const POPUP = 1 << 12; + + /// Marker flag for fullscreen. Should always match `WindowState::fullscreen`, but is + /// included here to make masking easier. + const MARKER_EXCLUSIVE_FULLSCREEN = 1 << 13; + const MARKER_BORDERLESS_FULLSCREEN = 1 << 14; + + /// The `WM_SIZE` event contains some parameters that can effect the state of `WindowFlags`. + /// In most cases, it's okay to let those parameters change the state. However, when we're + /// running the `WindowFlags::apply_diff` function, we *don't* want those parameters to + /// effect our stored state, because the purpose of `apply_diff` is to update the actual + /// window's state to match our stored state. This controls whether to accept those changes. + const MARKER_RETAIN_STATE_ON_SIZE = 1 << 15; + + const MARKER_IN_SIZE_MOVE = 1 << 16; + + const MINIMIZED = 1 << 17; + + const IGNORE_CURSOR_EVENT = 1 << 18; + + /// Fully decorated window (incl. caption, border and drop shadow). + const MARKER_DECORATIONS = 1 << 19; + /// Drop shadow for undecorated windows. + const MARKER_UNDECORATED_SHADOW = 1 << 20; + + const MARKER_ACTIVATE = 1 << 21; + + const CLIP_CHILDREN = 1 << 22; + + const EXCLUSIVE_FULLSCREEN_OR_MASK = WindowFlags::ALWAYS_ON_TOP.bits(); + } +} + +#[derive(Eq, PartialEq)] +pub enum ImeState { + Disabled, + Enabled, + Preedit, +} + +impl WindowState { + pub(crate) fn new( + attributes: &WindowAttributes, + scale_factor: f64, + current_theme: Theme, + preferred_theme: Option, + ) -> WindowState { + WindowState { + mouse: MouseProperties { + selected_cursor: SelectedCursor::default(), + capture_count: 0, + cursor_flags: CursorFlags::empty(), + last_position: None, + }, + + min_size: attributes.min_inner_size, + max_size: attributes.max_inner_size, + + resize_increments: attributes.resize_increments, + + window_icon: attributes.window_icon.clone(), + taskbar_icon: None, + + saved_window: None, + scale_factor, + + modifiers_state: ModifiersState::default(), + fullscreen: None, + current_theme, + preferred_theme, + window_flags: WindowFlags::empty(), + + ime_state: ImeState::Disabled, + ime_allowed: false, + + is_active: false, + is_focused: false, + redraw_requested: false, + + dragging: false, + + skip_taskbar: false, + } + } + + pub fn window_flags(&self) -> WindowFlags { + self.window_flags + } + + pub fn set_window_flags(mut this: MutexGuard<'_, Self>, window: HWND, f: F) + where + F: FnOnce(&mut WindowFlags), + { + let old_flags = this.window_flags; + f(&mut this.window_flags); + let new_flags = this.window_flags; + + drop(this); + old_flags.apply_diff(window, new_flags); + } + + pub fn set_window_flags_in_place(&mut self, f: F) + where + F: FnOnce(&mut WindowFlags), + { + f(&mut self.window_flags); + } + + pub fn has_active_focus(&self) -> bool { + self.is_active && self.is_focused + } + + // Updates is_active and returns whether active-focus state has changed + pub fn set_active(&mut self, is_active: bool) -> bool { + let old = self.has_active_focus(); + self.is_active = is_active; + old != self.has_active_focus() + } + + // Updates is_focused and returns whether active-focus state has changed + pub fn set_focused(&mut self, is_focused: bool) -> bool { + let old = self.has_active_focus(); + self.is_focused = is_focused; + old != self.has_active_focus() + } +} + +impl MouseProperties { + pub fn cursor_flags(&self) -> CursorFlags { + self.cursor_flags + } + + pub fn set_cursor_flags(&mut self, window: HWND, f: F) -> Result<(), io::Error> + where + F: FnOnce(&mut CursorFlags), + { + let old_flags = self.cursor_flags; + f(&mut self.cursor_flags); + match self.cursor_flags.refresh_os_cursor(window) { + Ok(()) => (), + Err(e) => { + self.cursor_flags = old_flags; + return Err(e); + }, + } + + Ok(()) + } +} + +impl WindowFlags { + fn mask(mut self) -> WindowFlags { + if self.contains(WindowFlags::MARKER_EXCLUSIVE_FULLSCREEN) { + self |= WindowFlags::EXCLUSIVE_FULLSCREEN_OR_MASK; + } + self + } + + pub fn to_window_styles(self) -> (WINDOW_STYLE, WINDOW_EX_STYLE) { + // Required styles to properly support common window functionality like aero snap. + let mut style = WS_CAPTION | WS_BORDER | WS_CLIPSIBLINGS | WS_SYSMENU; + let mut style_ex = WS_EX_WINDOWEDGE | WS_EX_ACCEPTFILES; + + if self.contains(WindowFlags::RESIZABLE) { + style |= WS_SIZEBOX; + } + if self.contains(WindowFlags::MAXIMIZABLE) { + style |= WS_MAXIMIZEBOX; + } + if self.contains(WindowFlags::MINIMIZABLE) { + style |= WS_MINIMIZEBOX; + } + if self.contains(WindowFlags::VISIBLE) { + style |= WS_VISIBLE; + } + if self.contains(WindowFlags::ON_TASKBAR) { + style_ex |= WS_EX_APPWINDOW; + } + if self.contains(WindowFlags::ALWAYS_ON_TOP) { + style_ex |= WS_EX_TOPMOST; + } + if self.contains(WindowFlags::NO_BACK_BUFFER) { + style_ex |= WS_EX_NOREDIRECTIONBITMAP; + } + if self.contains(WindowFlags::CHILD) { + style |= WS_CHILD; // This is incompatible with WS_POPUP if that gets added eventually. + + // Remove decorations window styles for child + if !self.contains(WindowFlags::MARKER_DECORATIONS) { + style &= !(WS_CAPTION | WS_BORDER); + style_ex &= !WS_EX_WINDOWEDGE; + } + } + if self.contains(WindowFlags::POPUP) { + style |= WS_POPUP; + } + if self.contains(WindowFlags::MINIMIZED) { + style |= WS_MINIMIZE; + } + if self.contains(WindowFlags::MAXIMIZED) { + style |= WS_MAXIMIZE; + } + if self.contains(WindowFlags::IGNORE_CURSOR_EVENT) { + style_ex |= WS_EX_TRANSPARENT | WS_EX_LAYERED; + } + if self.contains(WindowFlags::CLIP_CHILDREN) { + style |= WS_CLIPCHILDREN; + } + + if self.intersects( + WindowFlags::MARKER_EXCLUSIVE_FULLSCREEN | WindowFlags::MARKER_BORDERLESS_FULLSCREEN, + ) { + style &= !WS_OVERLAPPEDWINDOW; + } + + (style, style_ex) + } + + /// Adjust the window client rectangle to the return value, if present. + fn apply_diff(mut self, window: HWND, mut new: WindowFlags) { + self = self.mask(); + new = new.mask(); + + let mut diff = self ^ new; + + if diff == WindowFlags::empty() { + return; + } + + if new.contains(WindowFlags::VISIBLE) { + let flag = if !self.contains(WindowFlags::MARKER_ACTIVATE) { + self.set(WindowFlags::MARKER_ACTIVATE, true); + SW_SHOWNOACTIVATE + } else { + SW_SHOW + }; + unsafe { + ShowWindow(window, flag); + } + } + + if diff.intersects(WindowFlags::ALWAYS_ON_TOP | WindowFlags::ALWAYS_ON_BOTTOM) { + unsafe { + SetWindowPos( + window, + match ( + new.contains(WindowFlags::ALWAYS_ON_TOP), + new.contains(WindowFlags::ALWAYS_ON_BOTTOM), + ) { + (true, false) => HWND_TOPMOST, + (false, false) => HWND_NOTOPMOST, + (false, true) => HWND_BOTTOM, + (true, true) => unreachable!(), + }, + 0, + 0, + 0, + 0, + SWP_ASYNCWINDOWPOS | SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + InvalidateRgn(window, 0, false.into()); + } + } + + if diff.contains(WindowFlags::MAXIMIZED) || new.contains(WindowFlags::MAXIMIZED) { + unsafe { + ShowWindow(window, match new.contains(WindowFlags::MAXIMIZED) { + true => SW_MAXIMIZE, + false => SW_RESTORE, + }); + } + } + + // Minimize operations should execute after maximize for proper window animations + if diff.contains(WindowFlags::MINIMIZED) { + unsafe { + ShowWindow(window, match new.contains(WindowFlags::MINIMIZED) { + true => SW_MINIMIZE, + false => SW_RESTORE, + }); + } + + diff.remove(WindowFlags::MINIMIZED); + } + + if diff.contains(WindowFlags::CLOSABLE) || new.contains(WindowFlags::CLOSABLE) { + let flags = MF_BYCOMMAND + | if new.contains(WindowFlags::CLOSABLE) { MF_ENABLED } else { MF_DISABLED }; + + unsafe { + EnableMenuItem(GetSystemMenu(window, 0), SC_CLOSE, flags); + } + } + + if !new.contains(WindowFlags::VISIBLE) { + unsafe { + ShowWindow(window, SW_HIDE); + } + } + + if diff != WindowFlags::empty() { + let (style, style_ex) = new.to_window_styles(); + + unsafe { + SendMessageW(window, event_loop::SET_RETAIN_STATE_ON_SIZE_MSG_ID.get(), 1, 0); + + // This condition is necessary to avoid having an unrestorable window + if !new.contains(WindowFlags::MINIMIZED) { + SetWindowLongW(window, GWL_STYLE, style as i32); + SetWindowLongW(window, GWL_EXSTYLE, style_ex as i32); + } + + let mut flags = SWP_NOZORDER | SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED; + + // We generally don't want style changes here to affect window + // focus, but for fullscreen windows they must be activated + // (i.e. focused) so that they appear on top of the taskbar + if !new.contains(WindowFlags::MARKER_EXCLUSIVE_FULLSCREEN) + && !new.contains(WindowFlags::MARKER_BORDERLESS_FULLSCREEN) + { + flags |= SWP_NOACTIVATE; + } + + // Refresh the window frame + SetWindowPos(window, 0, 0, 0, 0, 0, flags); + SendMessageW(window, event_loop::SET_RETAIN_STATE_ON_SIZE_MSG_ID.get(), 0, 0); + } + } + } + + pub fn adjust_rect(self, hwnd: HWND, mut rect: RECT) -> Result { + unsafe { + let mut style = GetWindowLongW(hwnd, GWL_STYLE) as u32; + let style_ex = GetWindowLongW(hwnd, GWL_EXSTYLE) as u32; + + // Frameless style implemented by manually overriding the non-client area in + // `WM_NCCALCSIZE`. + if !self.contains(WindowFlags::MARKER_DECORATIONS) { + style &= !(WS_CAPTION | WS_SIZEBOX); + } + + util::win_to_err({ + let b_menu = GetMenu(hwnd) != 0; + if let (Some(get_dpi_for_window), Some(adjust_window_rect_ex_for_dpi)) = + (*util::GET_DPI_FOR_WINDOW, *util::ADJUST_WINDOW_RECT_EX_FOR_DPI) + { + let dpi = get_dpi_for_window(hwnd); + adjust_window_rect_ex_for_dpi(&mut rect, style, b_menu.into(), style_ex, dpi) + } else { + AdjustWindowRectEx(&mut rect, style, b_menu.into(), style_ex) + } + })?; + Ok(rect) + } + } + + pub fn adjust_size(self, hwnd: HWND, size: PhysicalSize) -> PhysicalSize { + let (width, height): (u32, u32) = size.into(); + let rect = RECT { left: 0, right: width as i32, top: 0, bottom: height as i32 }; + let rect = self.adjust_rect(hwnd, rect).unwrap_or(rect); + + let outer_x = (rect.right - rect.left).abs(); + let outer_y = (rect.top - rect.bottom).abs(); + + PhysicalSize::new(outer_x as _, outer_y as _) + } + + pub fn set_size(self, hwnd: HWND, size: PhysicalSize) { + unsafe { + let (width, height): (u32, u32) = self.adjust_size(hwnd, size).into(); + SetWindowPos( + hwnd, + 0, + 0, + 0, + width as _, + height as _, + SWP_ASYNCWINDOWPOS | SWP_NOZORDER | SWP_NOREPOSITION | SWP_NOMOVE | SWP_NOACTIVATE, + ); + InvalidateRgn(hwnd, 0, false.into()); + } + } +} + +impl CursorFlags { + fn refresh_os_cursor(self, window: HWND) -> Result<(), io::Error> { + let client_rect = util::WindowArea::Inner.get_rect(window)?; + + if util::is_focused(window) { + let cursor_clip = match self.contains(CursorFlags::GRABBED) { + true => { + if self.contains(CursorFlags::LOCKED) { + if let Ok(pos) = util::get_cursor_position() { + Some(RECT { + left: pos.x, + right: pos.x + 1, + top: pos.y, + bottom: pos.y + 1, + }) + } else { + // If lock is applied while the cursor is not available, lock it to the + // middle of the window. + let cx = (client_rect.left + client_rect.right) / 2; + let cy = (client_rect.top + client_rect.bottom) / 2; + Some(RECT { left: cx, right: cx + 1, top: cy, bottom: cy + 1 }) + } + } else if self.contains(CursorFlags::HIDDEN) { + // Confine the cursor to the center of the window if the cursor is hidden. + // This avoids problems with the cursor activating + // the taskbar if the window borders or overlaps that. + let cx = (client_rect.left + client_rect.right) / 2; + let cy = (client_rect.top + client_rect.bottom) / 2; + Some(RECT { left: cx, right: cx + 1, top: cy, bottom: cy + 1 }) + } else { + Some(client_rect) + } + }, + false => None, + }; + + let rect_to_tuple = |rect: RECT| (rect.left, rect.top, rect.right, rect.bottom); + let active_cursor_clip = rect_to_tuple(util::get_cursor_clip()?); + let desktop_rect = rect_to_tuple(util::get_desktop_rect()); + + let active_cursor_clip = match desktop_rect == active_cursor_clip { + true => None, + false => Some(active_cursor_clip), + }; + + // We do this check because calling `set_cursor_clip` incessantly will flood the event + // loop with `WM_MOUSEMOVE` events, and `refresh_os_cursor` is called by + // `set_cursor_flags` which at times gets called once every iteration of the + // eventloop. + if active_cursor_clip != cursor_clip.map(rect_to_tuple) { + util::set_cursor_clip(cursor_clip)?; + } + } + + let cursor_in_client = self.contains(CursorFlags::IN_WINDOW); + if cursor_in_client { + util::set_cursor_hidden(self.contains(CursorFlags::HIDDEN)); + } else { + util::set_cursor_hidden(false); + } + + Ok(()) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/utils.rs b/third-party/winit-0.30.13-ferrex/src/utils.rs new file mode 100644 index 00000000..d9631edb --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/utils.rs @@ -0,0 +1,28 @@ +// A poly-fill for `lazy_cell` +// Replace with std::sync::LazyLock when https://github.com/rust-lang/rust/issues/109736 is stabilized. + +// This isn't used on every platform, which can come up as dead code warnings. +#![allow(dead_code)] + +use std::ops::Deref; +use std::sync::OnceLock; + +pub(crate) struct Lazy { + cell: OnceLock, + init: fn() -> T, +} + +impl Lazy { + pub const fn new(f: fn() -> T) -> Self { + Self { cell: OnceLock::new(), init: f } + } +} + +impl Deref for Lazy { + type Target = T; + + #[inline] + fn deref(&self) -> &'_ T { + self.cell.get_or_init(self.init) + } +} diff --git a/third-party/winit-0.30.13-ferrex/src/window.rs b/third-party/winit-0.30.13-ferrex/src/window.rs new file mode 100644 index 00000000..e0158eff --- /dev/null +++ b/third-party/winit-0.30.13-ferrex/src/window.rs @@ -0,0 +1,1876 @@ +//! The [`Window`] struct and associated types. +use std::fmt; + +use crate::dpi::{PhysicalPosition, PhysicalSize, Position, Size}; +use crate::error::{ExternalError, NotSupportedError}; +use crate::monitor::{MonitorHandle, VideoModeHandle}; +use crate::platform_impl::{self, PlatformSpecificWindowAttributes}; + +pub use crate::cursor::{BadImage, Cursor, CustomCursor, CustomCursorSource, MAX_CURSOR_SIZE}; +pub use crate::icon::{BadIcon, Icon}; + +#[doc(inline)] +pub use cursor_icon::{CursorIcon, ParseError as CursorIconParseError}; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Represents a window. +/// +/// The window is closed when dropped. +/// +/// ## Threading +/// +/// This is `Send + Sync`, meaning that it can be freely used from other +/// threads. +/// +/// However, some platforms (macOS, Web and iOS) only allow user interface +/// interactions on the main thread, so on those platforms, if you use the +/// window from a thread other than the main, the code is scheduled to run on +/// the main thread, and your thread may be blocked until that completes. +/// +/// ## Platform-specific +/// +/// **Web:** The [`Window`], which is represented by a `HTMLElementCanvas`, can +/// not be closed by dropping the [`Window`]. +pub struct Window { + pub(crate) window: platform_impl::Window, +} + +impl fmt::Debug for Window { + fn fmt(&self, fmtr: &mut fmt::Formatter<'_>) -> fmt::Result { + fmtr.pad("Window { .. }") + } +} + +impl Drop for Window { + /// This will close the [`Window`]. + /// + /// See [`Window`] for more details. + fn drop(&mut self) { + self.window.maybe_wait_on_main(|w| { + // If the window is in exclusive fullscreen, we must restore the desktop + // video mode (generally this would be done on application exit, but + // closing the window doesn't necessarily always mean application exit, + // such as when there are multiple windows) + if let Some(Fullscreen::Exclusive(_)) = w.fullscreen().map(|f| f.into()) { + w.set_fullscreen(None); + } + }) + } +} + +/// Identifier of a window. Unique for each window. +/// +/// Can be obtained with [`window.id()`][`Window::id`]. +/// +/// Whenever you receive an event specific to a window, this event contains a `WindowId` which you +/// can then compare to the ids of your windows. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WindowId(pub(crate) platform_impl::WindowId); + +impl WindowId { + /// Returns a dummy id, useful for unit testing. + /// + /// # Notes + /// + /// The only guarantee made about the return value of this function is that + /// it will always be equal to itself and to future values returned by this function. + /// No other guarantees are made. This may be equal to a real [`WindowId`]. + pub const fn dummy() -> Self { + WindowId(platform_impl::WindowId::dummy()) + } +} + +impl fmt::Debug for WindowId { + fn fmt(&self, fmtr: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(fmtr) + } +} + +impl From for u64 { + fn from(window_id: WindowId) -> Self { + window_id.0.into() + } +} + +impl From for WindowId { + fn from(raw_id: u64) -> Self { + Self(raw_id.into()) + } +} + +/// Attributes used when creating a window. +#[derive(Debug, Clone)] +pub struct WindowAttributes { + pub inner_size: Option, + pub min_inner_size: Option, + pub max_inner_size: Option, + pub position: Option, + pub resizable: bool, + pub enabled_buttons: WindowButtons, + pub title: String, + pub maximized: bool, + pub visible: bool, + pub transparent: bool, + pub blur: bool, + pub decorations: bool, + pub window_icon: Option, + pub preferred_theme: Option, + pub resize_increments: Option, + pub content_protected: bool, + pub window_level: WindowLevel, + pub active: bool, + pub cursor: Cursor, + #[cfg(feature = "rwh_06")] + pub(crate) parent_window: Option, + pub fullscreen: Option, + // Platform-specific configuration. + #[allow(dead_code)] + pub(crate) platform_specific: PlatformSpecificWindowAttributes, +} + +impl Default for WindowAttributes { + #[inline] + fn default() -> WindowAttributes { + WindowAttributes { + inner_size: None, + min_inner_size: None, + max_inner_size: None, + position: None, + resizable: true, + enabled_buttons: WindowButtons::all(), + title: "winit window".to_owned(), + maximized: false, + fullscreen: None, + visible: true, + transparent: false, + blur: false, + decorations: true, + window_level: Default::default(), + window_icon: None, + preferred_theme: None, + resize_increments: None, + content_protected: false, + cursor: Cursor::default(), + #[cfg(feature = "rwh_06")] + parent_window: None, + active: true, + platform_specific: Default::default(), + } + } +} + +/// Wrapper for [`rwh_06::RawWindowHandle`] for [`WindowAttributes::parent_window`]. +/// +/// # Safety +/// +/// The user has to account for that when using [`WindowAttributes::with_parent_window()`], +/// which is `unsafe`. +#[derive(Debug, Clone)] +#[cfg(feature = "rwh_06")] +pub(crate) struct SendSyncRawWindowHandle(pub(crate) rwh_06::RawWindowHandle); + +#[cfg(feature = "rwh_06")] +unsafe impl Send for SendSyncRawWindowHandle {} +#[cfg(feature = "rwh_06")] +unsafe impl Sync for SendSyncRawWindowHandle {} + +impl WindowAttributes { + /// Initializes new attributes with default values. + #[inline] + #[deprecated = "use `Window::default_attributes` instead"] + pub fn new() -> Self { + Default::default() + } +} + +impl WindowAttributes { + /// Get the parent window stored on the attributes. + #[cfg(feature = "rwh_06")] + pub fn parent_window(&self) -> Option<&rwh_06::RawWindowHandle> { + self.parent_window.as_ref().map(|handle| &handle.0) + } + + /// Requests the window to be of specific dimensions. + /// + /// If this is not set, some platform-specific dimensions will be used. + /// + /// See [`Window::request_inner_size`] for details. + #[inline] + pub fn with_inner_size>(mut self, size: S) -> Self { + self.inner_size = Some(size.into()); + self + } + + /// Sets the minimum dimensions a window can have. + /// + /// If this is not set, the window will have no minimum dimensions (aside + /// from reserved). + /// + /// See [`Window::set_min_inner_size`] for details. + #[inline] + pub fn with_min_inner_size>(mut self, min_size: S) -> Self { + self.min_inner_size = Some(min_size.into()); + self + } + + /// Sets the maximum dimensions a window can have. + /// + /// If this is not set, the window will have no maximum or will be set to + /// the primary monitor's dimensions by the platform. + /// + /// See [`Window::set_max_inner_size`] for details. + #[inline] + pub fn with_max_inner_size>(mut self, max_size: S) -> Self { + self.max_inner_size = Some(max_size.into()); + self + } + + /// Sets a desired initial position for the window. + /// + /// If this is not set, some platform-specific position will be chosen. + /// + /// See [`Window::set_outer_position`] for details. + /// + /// ## Platform-specific + /// + /// - **macOS:** The top left corner position of the window content, the window's "inner" + /// position. The window title bar will be placed above it. The window will be positioned such + /// that it fits on screen, maintaining set `inner_size` if any. If you need to precisely + /// position the top left corner of the whole window you have to use + /// [`Window::set_outer_position`] after creating the window. + /// - **Windows:** The top left corner position of the window title bar, the window's "outer" + /// position. There may be a small gap between this position and the window due to the + /// specifics of the Window Manager. + /// - **X11:** The top left corner of the window, the window's "outer" position. + /// - **Others:** Ignored. + #[inline] + pub fn with_position>(mut self, position: P) -> Self { + self.position = Some(position.into()); + self + } + + /// Sets whether the window is resizable or not. + /// + /// The default is `true`. + /// + /// See [`Window::set_resizable`] for details. + #[inline] + pub fn with_resizable(mut self, resizable: bool) -> Self { + self.resizable = resizable; + self + } + + /// Sets the enabled window buttons. + /// + /// The default is [`WindowButtons::all`] + /// + /// See [`Window::set_enabled_buttons`] for details. + #[inline] + pub fn with_enabled_buttons(mut self, buttons: WindowButtons) -> Self { + self.enabled_buttons = buttons; + self + } + + /// Sets the initial title of the window in the title bar. + /// + /// The default is `"winit window"`. + /// + /// See [`Window::set_title`] for details. + #[inline] + pub fn with_title>(mut self, title: T) -> Self { + self.title = title.into(); + self + } + + /// Sets whether the window should be put into fullscreen upon creation. + /// + /// The default is `None`. + /// + /// See [`Window::set_fullscreen`] for details. + #[inline] + pub fn with_fullscreen(mut self, fullscreen: Option) -> Self { + self.fullscreen = fullscreen; + self + } + + /// Request that the window is maximized upon creation. + /// + /// The default is `false`. + /// + /// See [`Window::set_maximized`] for details. + #[inline] + pub fn with_maximized(mut self, maximized: bool) -> Self { + self.maximized = maximized; + self + } + + /// Sets whether the window will be initially visible or hidden. + /// + /// The default is to show the window. + /// + /// See [`Window::set_visible`] for details. + #[inline] + pub fn with_visible(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Sets whether the background of the window should be transparent. + /// + /// If this is `true`, writing colors with alpha values different than + /// `1.0` will produce a transparent window. On some platforms this + /// is more of a hint for the system and you'd still have the alpha + /// buffer. To control it see [`Window::set_transparent`]. + /// + /// The default is `false`. + #[inline] + pub fn with_transparent(mut self, transparent: bool) -> Self { + self.transparent = transparent; + self + } + + /// Sets whether the background of the window should be blurred by the system. + /// + /// The default is `false`. + /// + /// See [`Window::set_blur`] for details. + #[inline] + pub fn with_blur(mut self, blur: bool) -> Self { + self.blur = blur; + self + } + + /// Get whether the window will support transparency. + #[inline] + pub fn transparent(&self) -> bool { + self.transparent + } + + /// Sets whether the window should have a border, a title bar, etc. + /// + /// The default is `true`. + /// + /// See [`Window::set_decorations`] for details. + #[inline] + pub fn with_decorations(mut self, decorations: bool) -> Self { + self.decorations = decorations; + self + } + + /// Sets the window level. + /// + /// This is just a hint to the OS, and the system could ignore it. + /// + /// The default is [`WindowLevel::Normal`]. + /// + /// See [`WindowLevel`] for details. + #[inline] + pub fn with_window_level(mut self, level: WindowLevel) -> Self { + self.window_level = level; + self + } + + /// Sets the window icon. + /// + /// The default is `None`. + /// + /// See [`Window::set_window_icon`] for details. + #[inline] + pub fn with_window_icon(mut self, window_icon: Option) -> Self { + self.window_icon = window_icon; + self + } + + /// Sets a specific theme for the window. + /// + /// If `None` is provided, the window will use the system theme. + /// + /// The default is `None`. + /// + /// ## Platform-specific + /// + /// - **Wayland:** This controls only CSD. When using `None` it'll try to use dbus to get the + /// system preference. When explicit theme is used, this will avoid dbus all together. + /// - **x11:** Build window with `_GTK_THEME_VARIANT` hint set to `dark` or `light`. + /// - **iOS / Android / Web / x11 / Orbital:** Ignored. + #[inline] + pub fn with_theme(mut self, theme: Option) -> Self { + self.preferred_theme = theme; + self + } + + /// Build window with resize increments hint. + /// + /// The default is `None`. + /// + /// See [`Window::set_resize_increments`] for details. + #[inline] + pub fn with_resize_increments>(mut self, resize_increments: S) -> Self { + self.resize_increments = Some(resize_increments.into()); + self + } + + /// Prevents the window contents from being captured by other apps. + /// + /// The default is `false`. + /// + /// ## Platform-specific + /// + /// - **macOS**: if `false`, [`NSWindowSharingNone`] is used but doesn't completely prevent all + /// apps from reading the window content, for instance, QuickTime. + /// - **iOS / Android / Web / x11 / Orbital:** Ignored. + /// + /// [`NSWindowSharingNone`]: https://developer.apple.com/documentation/appkit/nswindowsharingtype/nswindowsharingnone + #[inline] + pub fn with_content_protected(mut self, protected: bool) -> Self { + self.content_protected = protected; + self + } + + /// Whether the window will be initially focused or not. + /// + /// The window should be assumed as not focused by default + /// following by the [`WindowEvent::Focused`]. + /// + /// ## Platform-specific: + /// + /// **Android / iOS / X11 / Wayland / Orbital:** Unsupported. + /// + /// [`WindowEvent::Focused`]: crate::event::WindowEvent::Focused. + #[inline] + pub fn with_active(mut self, active: bool) -> Self { + self.active = active; + self + } + + /// Modifies the cursor icon of the window. + /// + /// The default is [`CursorIcon::Default`]. + /// + /// See [`Window::set_cursor()`] for more details. + #[inline] + pub fn with_cursor(mut self, cursor: impl Into) -> Self { + self.cursor = cursor.into(); + self + } + + /// Build window with parent window. + /// + /// The default is `None`. + /// + /// ## Safety + /// + /// `parent_window` must be a valid window handle. + /// + /// ## Platform-specific + /// + /// - **Windows** : A child window has the WS_CHILD style and is confined + /// to the client area of its parent window. For more information, see + /// + /// - **X11**: A child window is confined to the client area of its parent window. + /// - **Android / iOS / Wayland / Web:** Unsupported. + #[cfg(feature = "rwh_06")] + #[inline] + pub unsafe fn with_parent_window( + mut self, + parent_window: Option, + ) -> Self { + self.parent_window = parent_window.map(SendSyncRawWindowHandle); + self + } +} + +/// Base Window functions. +impl Window { + /// Create a new [`WindowAttributes`] which allows modifying the window's attributes before + /// creation. + #[inline] + pub fn default_attributes() -> WindowAttributes { + WindowAttributes::default() + } + + /// Returns an identifier unique to the window. + #[inline] + pub fn id(&self) -> WindowId { + let _span = tracing::debug_span!("winit::Window::id",).entered(); + + self.window.maybe_wait_on_main(|w| WindowId(w.id())) + } + + /// Returns the scale factor that can be used to map logical pixels to physical pixels, and + /// vice versa. + /// + /// Note that this value can change depending on user action (for example if the window is + /// moved to another screen); as such, tracking [`WindowEvent::ScaleFactorChanged`] events is + /// the most robust way to track the DPI you need to use to draw. + /// + /// This value may differ from [`MonitorHandle::scale_factor`]. + /// + /// See the [`dpi`] crate for more information. + /// + /// ## Platform-specific + /// + /// The scale factor is calculated differently on different platforms: + /// + /// - **Windows:** On Windows 8 and 10, per-monitor scaling is readily configured by users from + /// the display settings. While users are free to select any option they want, they're only + /// given a selection of "nice" scale factors, i.e. 1.0, 1.25, 1.5... on Windows 7. The scale + /// factor is global and changing it requires logging out. See [this article][windows_1] for + /// technical details. + /// - **macOS:** Recent macOS versions allow the user to change the scaling factor for specific + /// displays. When available, the user may pick a per-monitor scaling factor from a set of + /// pre-defined settings. All "retina displays" have a scaling factor above 1.0 by default, + /// but the specific value varies across devices. + /// - **X11:** Many man-hours have been spent trying to figure out how to handle DPI in X11. + /// Winit currently uses a three-pronged approach: + /// + Use the value in the `WINIT_X11_SCALE_FACTOR` environment variable if present. + /// + If not present, use the value set in `Xft.dpi` in Xresources. + /// + Otherwise, calculate the scale factor based on the millimeter monitor dimensions + /// provided by XRandR. + /// + /// If `WINIT_X11_SCALE_FACTOR` is set to `randr`, it'll ignore the `Xft.dpi` field and use + /// the XRandR scaling method. Generally speaking, you should try to configure the + /// standard system variables to do what you want before resorting to + /// `WINIT_X11_SCALE_FACTOR`. + /// - **Wayland:** The scale factor is suggested by the compositor for each window individually + /// by using the wp-fractional-scale protocol if available. Falls back to integer-scale + /// factors otherwise. + /// + /// The monitor scale factor may differ from the window scale factor. + /// - **iOS:** Scale factors are set by Apple to the value that best suits the device, and range + /// from `1.0` to `3.0`. See [this article][apple_1] and [this article][apple_2] for more + /// information. + /// + /// This uses the underlying `UIView`'s [`contentScaleFactor`]. + /// - **Android:** Scale factors are set by the manufacturer to the value that best suits the + /// device, and range from `1.0` to `4.0`. See [this article][android_1] for more information. + /// + /// This is currently unimplemented, and this function always returns 1.0. + /// - **Web:** The scale factor is the ratio between CSS pixels and the physical device pixels. + /// In other words, it is the value of [`window.devicePixelRatio`][web_1]. It is affected by + /// both the screen scaling and the browser zoom level and can go below `1.0`. + /// - **Orbital:** This is currently unimplemented, and this function always returns 1.0. + /// + /// [`WindowEvent::ScaleFactorChanged`]: crate::event::WindowEvent::ScaleFactorChanged + /// [windows_1]: https://docs.microsoft.com/en-us/windows/win32/hidpi/high-dpi-desktop-application-development-on-windows + /// [apple_1]: https://developer.apple.com/library/archive/documentation/DeviceInformation/Reference/iOSDeviceCompatibility/Displays/Displays.html + /// [apple_2]: https://developer.apple.com/design/human-interface-guidelines/macos/icons-and-images/image-size-and-resolution/ + /// [android_1]: https://developer.android.com/training/multiscreen/screendensities + /// [web_1]: https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio + /// [`contentScaleFactor`]: https://developer.apple.com/documentation/uikit/uiview/1622657-contentscalefactor?language=objc + #[inline] + pub fn scale_factor(&self) -> f64 { + let _span = tracing::debug_span!("winit::Window::scale_factor",).entered(); + + self.window.maybe_wait_on_main(|w| w.scale_factor()) + } + + /// Queues a [`WindowEvent::RedrawRequested`] event to be emitted that aligns with the windowing + /// system drawing loop. + /// + /// This is the **strongly encouraged** method of redrawing windows, as it can integrate with + /// OS-requested redraws (e.g. when a window gets resized). To improve the event delivery + /// consider using [`Window::pre_present_notify`] as described in docs. + /// + /// Applications should always aim to redraw whenever they receive a `RedrawRequested` event. + /// + /// There are no strong guarantees about when exactly a `RedrawRequest` event will be emitted + /// with respect to other events, since the requirements can vary significantly between + /// windowing systems. + /// + /// However as the event aligns with the windowing system drawing loop, it may not arrive in + /// same or even next event loop iteration. + /// + /// ## Platform-specific + /// + /// - **Windows** This API uses `RedrawWindow` to request a `WM_PAINT` message and + /// `RedrawRequested` is emitted in sync with any `WM_PAINT` messages. + /// - **iOS:** Can only be called on the main thread. + /// - **Wayland:** The events are aligned with the frame callbacks when + /// [`Window::pre_present_notify`] is used. + /// - **Web:** [`WindowEvent::RedrawRequested`] will be aligned with the + /// `requestAnimationFrame`. + /// + /// [`WindowEvent::RedrawRequested`]: crate::event::WindowEvent::RedrawRequested + #[inline] + pub fn request_redraw(&self) { + let _span = tracing::debug_span!("winit::Window::request_redraw",).entered(); + + self.window.maybe_queue_on_main(|w| w.request_redraw()) + } + + /// Notify the windowing system before presenting to the window. + /// + /// You should call this event after your drawing operations, but before you submit + /// the buffer to the display or commit your drawings. Doing so will help winit to properly + /// schedule and make assumptions about its internal state. For example, it could properly + /// throttle [`WindowEvent::RedrawRequested`]. + /// + /// ## Example + /// + /// This example illustrates how it looks with OpenGL, but it applies to other graphics + /// APIs and software rendering. + /// + /// ```no_run + /// # use winit::window::Window; + /// # fn swap_buffers() {} + /// # fn scope(window: &Window) { + /// // Do the actual drawing with OpenGL. + /// + /// // Notify winit that we're about to submit buffer to the windowing system. + /// window.pre_present_notify(); + /// + /// // Submit buffer to the windowing system. + /// swap_buffers(); + /// # } + /// ``` + /// + /// ## Platform-specific + /// + /// - **Android / iOS / X11 / Web / Windows / macOS / Orbital:** Unsupported. + /// - **Wayland:** Schedules a frame callback to throttle [`WindowEvent::RedrawRequested`]. + /// + /// [`WindowEvent::RedrawRequested`]: crate::event::WindowEvent::RedrawRequested + #[inline] + pub fn pre_present_notify(&self) { + let _span = tracing::debug_span!("winit::Window::pre_present_notify",).entered(); + + self.window.maybe_queue_on_main(|w| w.pre_present_notify()); + } + + /// Reset the dead key state of the keyboard. + /// + /// This is useful when a dead key is bound to trigger an action. Then + /// this function can be called to reset the dead key state so that + /// follow-up text input won't be affected by the dead key. + /// + /// ## Platform-specific + /// - **Web, macOS:** Does nothing + // --------------------------- + // Developers' Note: If this cannot be implemented on every desktop platform + // at least, then this function should be provided through a platform specific + // extension trait + pub fn reset_dead_keys(&self) { + let _span = tracing::debug_span!("winit::Window::reset_dead_keys",).entered(); + + self.window.maybe_queue_on_main(|w| w.reset_dead_keys()) + } +} + +/// Position and size functions. +impl Window { + /// Returns the position of the top-left hand corner of the window's client area relative to the + /// top-left hand corner of the desktop. + /// + /// The same conditions that apply to [`Window::outer_position`] apply to this method. + /// + /// ## Platform-specific + /// + /// - **iOS:** Can only be called on the main thread. Returns the top left coordinates of the + /// window's [safe area] in the screen space coordinate system. + /// - **Web:** Returns the top-left coordinates relative to the viewport. _Note: this returns + /// the same value as [`Window::outer_position`]._ + /// - **Android / Wayland:** Always returns [`NotSupportedError`]. + /// + /// [safe area]: https://developer.apple.com/documentation/uikit/uiview/2891103-safeareainsets?language=objc + #[inline] + pub fn inner_position(&self) -> Result, NotSupportedError> { + let _span = tracing::debug_span!("winit::Window::inner_position",).entered(); + + self.window.maybe_wait_on_main(|w| w.inner_position()) + } + + /// Returns the position of the top-left hand corner of the window relative to the + /// top-left hand corner of the desktop. + /// + /// Note that the top-left hand corner of the desktop is not necessarily the same as + /// the screen. If the user uses a desktop with multiple monitors, the top-left hand corner + /// of the desktop is the top-left hand corner of the monitor at the top-left of the desktop. + /// + /// The coordinates can be negative if the top-left hand corner of the window is outside + /// of the visible screen region. + /// + /// ## Platform-specific + /// + /// - **iOS:** Can only be called on the main thread. Returns the top left coordinates of the + /// window in the screen space coordinate system. + /// - **Web:** Returns the top-left coordinates relative to the viewport. + /// - **Android / Wayland:** Always returns [`NotSupportedError`]. + #[inline] + pub fn outer_position(&self) -> Result, NotSupportedError> { + let _span = tracing::debug_span!("winit::Window::outer_position",).entered(); + + self.window.maybe_wait_on_main(|w| w.outer_position()) + } + + /// Modifies the position of the window. + /// + /// See [`Window::outer_position`] for more information about the coordinates. + /// This automatically un-maximizes the window if it's maximized. + /// + /// ```no_run + /// # use winit::dpi::{LogicalPosition, PhysicalPosition}; + /// # use winit::window::Window; + /// # fn scope(window: &Window) { + /// // Specify the position in logical dimensions like this: + /// window.set_outer_position(LogicalPosition::new(400.0, 200.0)); + /// + /// // Or specify the position in physical dimensions like this: + /// window.set_outer_position(PhysicalPosition::new(400, 200)); + /// # } + /// ``` + /// + /// ## Platform-specific + /// + /// - **iOS:** Can only be called on the main thread. Sets the top left coordinates of the + /// window in the screen space coordinate system. + /// - **Web:** Sets the top-left coordinates relative to the viewport. Doesn't account for CSS + /// [`transform`]. + /// - **Android / Wayland:** Unsupported. + /// + /// [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform + #[inline] + pub fn set_outer_position>(&self, position: P) { + let position = position.into(); + let _span = tracing::debug_span!( + "winit::Window::set_outer_position", + position = ?position + ) + .entered(); + + self.window.maybe_queue_on_main(move |w| w.set_outer_position(position)) + } + + /// Returns the physical size of the window's client area. + /// + /// The client area is the content of the window, excluding the title bar and borders. + /// + /// ## Platform-specific + /// + /// - **iOS:** Can only be called on the main thread. Returns the `PhysicalSize` of the window's + /// [safe area] in screen space coordinates. + /// - **Web:** Returns the size of the canvas element. Doesn't account for CSS [`transform`]. + /// + /// [safe area]: https://developer.apple.com/documentation/uikit/uiview/2891103-safeareainsets?language=objc + /// [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform + #[inline] + pub fn inner_size(&self) -> PhysicalSize { + let _span = tracing::debug_span!("winit::Window::inner_size",).entered(); + + self.window.maybe_wait_on_main(|w| w.inner_size()) + } + + /// Request the new size for the window. + /// + /// On platforms where the size is entirely controlled by the user the + /// applied size will be returned immediately, resize event in such case + /// may not be generated. + /// + /// On platforms where resizing is disallowed by the windowing system, the current + /// inner size is returned immediately, and the user one is ignored. + /// + /// When `None` is returned, it means that the request went to the display system, + /// and the actual size will be delivered later with the [`WindowEvent::Resized`]. + /// + /// See [`Window::inner_size`] for more information about the values. + /// + /// The request could automatically un-maximize the window if it's maximized. + /// + /// ```no_run + /// # use winit::dpi::{LogicalSize, PhysicalSize}; + /// # use winit::window::Window; + /// # fn scope(window: &Window) { + /// // Specify the size in logical dimensions like this: + /// let _ = window.request_inner_size(LogicalSize::new(400.0, 200.0)); + /// + /// // Or specify the size in physical dimensions like this: + /// let _ = window.request_inner_size(PhysicalSize::new(400, 200)); + /// # } + /// ``` + /// + /// ## Platform-specific + /// + /// - **Web:** Sets the size of the canvas element. Doesn't account for CSS [`transform`]. + /// + /// [`WindowEvent::Resized`]: crate::event::WindowEvent::Resized + /// [`transform`]: https://developer.mozilla.org/en-US/docs/Web/CSS/transform + #[inline] + #[must_use] + pub fn request_inner_size>(&self, size: S) -> Option> { + let size = size.into(); + let _span = tracing::debug_span!( + "winit::Window::request_inner_size", + size = ?size + ) + .entered(); + self.window.maybe_wait_on_main(|w| w.request_inner_size(size)) + } + + /// Returns the physical size of the entire window. + /// + /// These dimensions include the title bar and borders. If you don't want that (and you usually + /// don't), use [`Window::inner_size`] instead. + /// + /// ## Platform-specific + /// + /// - **iOS:** Can only be called on the main thread. Returns the [`PhysicalSize`] of the window + /// in screen space coordinates. + /// - **Web:** Returns the size of the canvas element. _Note: this returns the same value as + /// [`Window::inner_size`]._ + #[inline] + pub fn outer_size(&self) -> PhysicalSize { + let _span = tracing::debug_span!("winit::Window::outer_size",).entered(); + self.window.maybe_wait_on_main(|w| w.outer_size()) + } + + /// Sets a minimum dimension size for the window. + /// + /// ```no_run + /// # use winit::dpi::{LogicalSize, PhysicalSize}; + /// # use winit::window::Window; + /// # fn scope(window: &Window) { + /// // Specify the size in logical dimensions like this: + /// window.set_min_inner_size(Some(LogicalSize::new(400.0, 200.0))); + /// + /// // Or specify the size in physical dimensions like this: + /// window.set_min_inner_size(Some(PhysicalSize::new(400, 200))); + /// # } + /// ``` + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Orbital:** Unsupported. + #[inline] + pub fn set_min_inner_size>(&self, min_size: Option) { + let min_size = min_size.map(|s| s.into()); + let _span = tracing::debug_span!( + "winit::Window::set_min_inner_size", + min_size = ?min_size + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.set_min_inner_size(min_size)) + } + + /// Sets a maximum dimension size for the window. + /// + /// ```no_run + /// # use winit::dpi::{LogicalSize, PhysicalSize}; + /// # use winit::window::Window; + /// # fn scope(window: &Window) { + /// // Specify the size in logical dimensions like this: + /// window.set_max_inner_size(Some(LogicalSize::new(400.0, 200.0))); + /// + /// // Or specify the size in physical dimensions like this: + /// window.set_max_inner_size(Some(PhysicalSize::new(400, 200))); + /// # } + /// ``` + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Orbital:** Unsupported. + #[inline] + pub fn set_max_inner_size>(&self, max_size: Option) { + let max_size = max_size.map(|s| s.into()); + let _span = tracing::debug_span!( + "winit::Window::max_size", + max_size = ?max_size + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.set_max_inner_size(max_size)) + } + + /// Returns window resize increments if any were set. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web / Orbital:** Always returns [`None`]. + #[inline] + pub fn resize_increments(&self) -> Option> { + let _span = tracing::debug_span!("winit::Window::resize_increments",).entered(); + self.window.maybe_wait_on_main(|w| w.resize_increments()) + } + + /// Sets window resize increments. + /// + /// This is a niche constraint hint usually employed by terminal emulators + /// and other apps that need "blocky" resizes. + /// + /// ## Platform-specific + /// + /// - **macOS:** Increments are converted to logical size and then macOS rounds them to whole + /// numbers. + /// - **iOS / Android / Web / Orbital:** Unsupported. + #[inline] + pub fn set_resize_increments>(&self, increments: Option) { + let increments = increments.map(Into::into); + let _span = tracing::debug_span!( + "winit::Window::set_resize_increments", + increments = ?increments + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.set_resize_increments(increments)) + } +} + +/// Misc. attribute functions. +impl Window { + /// Modifies the title of the window. + /// + /// ## Platform-specific + /// + /// - **iOS / Android:** Unsupported. + #[inline] + pub fn set_title(&self, title: &str) { + let _span = tracing::debug_span!("winit::Window::set_title", title).entered(); + self.window.maybe_wait_on_main(|w| w.set_title(title)) + } + + /// Change the window transparency state. + /// + /// This is just a hint that may not change anything about + /// the window transparency, however doing a mismatch between + /// the content of your window and this hint may result in + /// visual artifacts. + /// + /// The default value follows the [`WindowAttributes::with_transparent`]. + /// + /// ## Platform-specific + /// + /// - **macOS:** This will reset the window's background color. + /// - **Web / iOS / Android:** Unsupported. + /// - **X11:** Can only be set while building the window, with + /// [`WindowAttributes::with_transparent`]. + #[inline] + pub fn set_transparent(&self, transparent: bool) { + let _span = tracing::debug_span!("winit::Window::set_transparent", transparent).entered(); + self.window.maybe_queue_on_main(move |w| w.set_transparent(transparent)) + } + + /// Change the window blur state. + /// + /// If `true`, this will make the transparent window background blurry. + /// + /// ## Platform-specific + /// + /// - **Android / iOS / X11 / Web / Windows:** Unsupported. + /// - **Wayland:** Only works with org_kde_kwin_blur_manager protocol. + #[inline] + pub fn set_blur(&self, blur: bool) { + let _span = tracing::debug_span!("winit::Window::set_blur", blur).entered(); + self.window.maybe_queue_on_main(move |w| w.set_blur(blur)) + } + + /// Modifies the window's visibility. + /// + /// If `false`, this will hide the window. If `true`, this will show the window. + /// + /// ## Platform-specific + /// + /// - **Android / Wayland / Web:** Unsupported. + /// - **iOS:** Can only be called on the main thread. + #[inline] + pub fn set_visible(&self, visible: bool) { + let _span = tracing::debug_span!("winit::Window::set_visible", visible).entered(); + self.window.maybe_queue_on_main(move |w| w.set_visible(visible)) + } + + /// Gets the window's current visibility state. + /// + /// `None` means it couldn't be determined, so it is not recommended to use this to drive your + /// rendering backend. + /// + /// ## Platform-specific + /// + /// - **X11:** Not implemented. + /// - **Wayland / iOS / Android / Web:** Unsupported. + #[inline] + pub fn is_visible(&self) -> Option { + let _span = tracing::debug_span!("winit::Window::is_visible",).entered(); + self.window.maybe_wait_on_main(|w| w.is_visible()) + } + + /// Sets whether the window is resizable or not. + /// + /// Note that making the window unresizable doesn't exempt you from handling + /// [`WindowEvent::Resized`], as that event can still be triggered by DPI scaling, entering + /// fullscreen mode, etc. Also, the window could still be resized by calling + /// [`Window::request_inner_size`]. + /// + /// ## Platform-specific + /// + /// This only has an effect on desktop platforms. + /// + /// - **X11:** Due to a bug in XFCE, this has no effect on Xfwm. + /// - **iOS / Android / Web:** Unsupported. + /// + /// [`WindowEvent::Resized`]: crate::event::WindowEvent::Resized + #[inline] + pub fn set_resizable(&self, resizable: bool) { + let _span = tracing::debug_span!("winit::Window::set_resizable", resizable).entered(); + self.window.maybe_queue_on_main(move |w| w.set_resizable(resizable)) + } + + /// Gets the window's current resizable state. + /// + /// ## Platform-specific + /// + /// - **X11:** Not implemented. + /// - **iOS / Android / Web:** Unsupported. + #[inline] + pub fn is_resizable(&self) -> bool { + let _span = tracing::debug_span!("winit::Window::is_resizable",).entered(); + self.window.maybe_wait_on_main(|w| w.is_resizable()) + } + + /// Sets the enabled window buttons. + /// + /// ## Platform-specific + /// + /// - **Wayland / X11 / Orbital:** Not implemented. + /// - **Web / iOS / Android:** Unsupported. + pub fn set_enabled_buttons(&self, buttons: WindowButtons) { + let _span = tracing::debug_span!( + "winit::Window::set_enabled_buttons", + buttons = ?buttons + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.set_enabled_buttons(buttons)) + } + + /// Gets the enabled window buttons. + /// + /// ## Platform-specific + /// + /// - **Wayland / X11 / Orbital:** Not implemented. Always returns [`WindowButtons::all`]. + /// - **Web / iOS / Android:** Unsupported. Always returns [`WindowButtons::all`]. + pub fn enabled_buttons(&self) -> WindowButtons { + let _span = tracing::debug_span!("winit::Window::enabled_buttons",).entered(); + self.window.maybe_wait_on_main(|w| w.enabled_buttons()) + } + + /// Sets the window to minimized or back + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web / Orbital:** Unsupported. + /// - **Wayland:** Un-minimize is unsupported. + #[inline] + pub fn set_minimized(&self, minimized: bool) { + let _span = tracing::debug_span!("winit::Window::set_minimized", minimized).entered(); + self.window.maybe_queue_on_main(move |w| w.set_minimized(minimized)) + } + + /// Gets the window's current minimized state. + /// + /// `None` will be returned, if the minimized state couldn't be determined. + /// + /// ## Note + /// + /// - You shouldn't stop rendering for minimized windows, however you could lower the fps. + /// + /// ## Platform-specific + /// + /// - **Wayland**: always `None`. + /// - **iOS / Android / Web / Orbital:** Unsupported. + #[inline] + pub fn is_minimized(&self) -> Option { + let _span = tracing::debug_span!("winit::Window::is_minimized",).entered(); + self.window.maybe_wait_on_main(|w| w.is_minimized()) + } + + /// Sets the window to maximized or back. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web:** Unsupported. + #[inline] + pub fn set_maximized(&self, maximized: bool) { + let _span = tracing::debug_span!("winit::Window::set_maximized", maximized).entered(); + self.window.maybe_queue_on_main(move |w| w.set_maximized(maximized)) + } + + /// Gets the window's current maximized state. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web:** Unsupported. + #[inline] + pub fn is_maximized(&self) -> bool { + let _span = tracing::debug_span!("winit::Window::is_maximized",).entered(); + self.window.maybe_wait_on_main(|w| w.is_maximized()) + } + + /// Sets the window to fullscreen or back. + /// + /// ## Platform-specific + /// + /// - **macOS:** [`Fullscreen::Exclusive`] provides true exclusive mode with a video mode + /// change. *Caveat!* macOS doesn't provide task switching (or spaces!) while in exclusive + /// fullscreen mode. This mode should be used when a video mode change is desired, but for a + /// better user experience, borderless fullscreen might be preferred. + /// + /// [`Fullscreen::Borderless`] provides a borderless fullscreen window on a + /// separate space. This is the idiomatic way for fullscreen games to work + /// on macOS. See `WindowExtMacOs::set_simple_fullscreen` if + /// separate spaces are not preferred. + /// + /// The dock and the menu bar are disabled in exclusive fullscreen mode. + /// - **iOS:** Can only be called on the main thread. + /// - **Wayland:** Does not support exclusive fullscreen mode and will no-op a request. + /// - **Windows:** Screen saver is disabled in fullscreen mode. + /// - **Android / Orbital:** Unsupported. + /// - **Web:** Does nothing without a [transient activation]. + /// + /// [transient activation]: https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation + #[inline] + pub fn set_fullscreen(&self, fullscreen: Option) { + let _span = tracing::debug_span!( + "winit::Window::set_fullscreen", + fullscreen = ?fullscreen + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.set_fullscreen(fullscreen.map(|f| f.into()))) + } + + /// Gets the window's current fullscreen state. + /// + /// ## Platform-specific + /// + /// - **iOS:** Can only be called on the main thread. + /// - **Android / Orbital:** Will always return `None`. + /// - **Wayland:** Can return `Borderless(None)` when there are no monitors. + /// - **Web:** Can only return `None` or `Borderless(None)`. + #[inline] + pub fn fullscreen(&self) -> Option { + let _span = tracing::debug_span!("winit::Window::fullscreen",).entered(); + self.window.maybe_wait_on_main(|w| w.fullscreen().map(|f| f.into())) + } + + /// Turn window decorations on or off. + /// + /// Enable/disable window decorations provided by the server or Winit. + /// By default this is enabled. Note that fullscreen windows and windows on + /// mobile and web platforms naturally do not have decorations. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web:** No effect. + #[inline] + pub fn set_decorations(&self, decorations: bool) { + let _span = tracing::debug_span!("winit::Window::set_decorations", decorations).entered(); + self.window.maybe_queue_on_main(move |w| w.set_decorations(decorations)) + } + + /// Gets the window's current decorations state. + /// + /// Returns `true` when windows are decorated (server-side or by Winit). + /// Also returns `true` when no decorations are required (mobile, web). + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web:** Always returns `true`. + #[inline] + pub fn is_decorated(&self) -> bool { + let _span = tracing::debug_span!("winit::Window::is_decorated",).entered(); + self.window.maybe_wait_on_main(|w| w.is_decorated()) + } + + /// Change the window level. + /// + /// This is just a hint to the OS, and the system could ignore it. + /// + /// See [`WindowLevel`] for details. + pub fn set_window_level(&self, level: WindowLevel) { + let _span = tracing::debug_span!( + "winit::Window::set_window_level", + level = ?level + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.set_window_level(level)) + } + + /// Sets the window icon. + /// + /// On Windows and X11, this is typically the small icon in the top-left + /// corner of the titlebar. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web / Wayland / macOS / Orbital:** Unsupported. + /// + /// - **Windows:** Sets `ICON_SMALL`. The base size for a window icon is 16x16, but it's + /// recommended to account for screen scaling and pick a multiple of that, i.e. 32x32. + /// + /// - **X11:** Has no universal guidelines for icon sizes, so you're at the whims of the WM. + /// That said, it's usually in the same ballpark as on Windows. + #[inline] + pub fn set_window_icon(&self, window_icon: Option) { + let _span = tracing::debug_span!("winit::Window::set_window_icon",).entered(); + self.window.maybe_queue_on_main(move |w| w.set_window_icon(window_icon)) + } + + /// Set the IME cursor editing area, where the `position` is the top left corner of that area + /// and `size` is the size of this area starting from the position. An example of such area + /// could be a input field in the UI or line in the editor. + /// + /// The windowing system could place a candidate box close to that area, but try to not obscure + /// the specified area, so the user input to it stays visible. + /// + /// The candidate box is the window / popup / overlay that allows you to select the desired + /// characters. The look of this box may differ between input devices, even on the same + /// platform. + /// + /// (Apple's official term is "candidate window", see their [chinese] and [japanese] guides). + /// + /// ## Example + /// + /// ```no_run + /// # use winit::dpi::{LogicalPosition, PhysicalPosition, LogicalSize, PhysicalSize}; + /// # use winit::window::Window; + /// # fn scope(window: &Window) { + /// // Specify the position in logical dimensions like this: + /// window.set_ime_cursor_area(LogicalPosition::new(400.0, 200.0), LogicalSize::new(100, 100)); + /// + /// // Or specify the position in physical dimensions like this: + /// window.set_ime_cursor_area(PhysicalPosition::new(400, 200), PhysicalSize::new(100, 100)); + /// # } + /// ``` + /// + /// ## Platform-specific + /// + /// - **X11:** - area is not supported, only position. + /// - **iOS / Android / Web / Orbital:** Unsupported. + /// + /// [chinese]: https://support.apple.com/guide/chinese-input-method/use-the-candidate-window-cim12992/104/mac/12.0 + /// [japanese]: https://support.apple.com/guide/japanese-input-method/use-the-candidate-window-jpim10262/6.3/mac/12.0 + #[inline] + pub fn set_ime_cursor_area, S: Into>(&self, position: P, size: S) { + let position = position.into(); + let size = size.into(); + let _span = tracing::debug_span!( + "winit::Window::set_ime_cursor_area", + position = ?position, + size = ?size, + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.set_ime_cursor_area(position, size)) + } + + /// Sets whether the window should get IME events + /// + /// When IME is allowed, the window will receive [`Ime`] events, and during the + /// preedit phase the window will NOT get [`KeyboardInput`] events. The window + /// should allow IME while it is expecting text input. + /// + /// When IME is not allowed, the window won't receive [`Ime`] events, and will + /// receive [`KeyboardInput`] events for every keypress instead. Not allowing + /// IME is useful for games for example. + /// + /// IME is **not** allowed by default. + /// + /// ## Platform-specific + /// + /// - **macOS:** IME must be enabled to receive text-input where dead-key sequences are + /// combined. + /// - **iOS / Android:** This will show / hide the soft keyboard. + /// - **Web / Orbital:** Unsupported. + /// - **X11**: Enabling IME will disable dead keys reporting during compose. + /// + /// [`Ime`]: crate::event::WindowEvent::Ime + /// [`KeyboardInput`]: crate::event::WindowEvent::KeyboardInput + #[inline] + pub fn set_ime_allowed(&self, allowed: bool) { + let _span = tracing::debug_span!("winit::Window::set_ime_allowed", allowed).entered(); + self.window.maybe_queue_on_main(move |w| w.set_ime_allowed(allowed)) + } + + /// Sets the IME purpose for the window using [`ImePurpose`]. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web / Windows / X11 / macOS / Orbital:** Unsupported. + #[inline] + pub fn set_ime_purpose(&self, purpose: ImePurpose) { + let _span = tracing::debug_span!( + "winit::Window::set_ime_purpose", + purpose = ?purpose + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.set_ime_purpose(purpose)) + } + + /// Brings the window to the front and sets input focus. Has no effect if the window is + /// already in focus, minimized, or not visible. + /// + /// This method steals input focus from other applications. Do not use this method unless + /// you are certain that's what the user wants. Focus stealing can cause an extremely disruptive + /// user experience. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Wayland / Orbital:** Unsupported. + #[inline] + pub fn focus_window(&self) { + let _span = tracing::debug_span!("winit::Window::focus_window",).entered(); + self.window.maybe_queue_on_main(|w| w.focus_window()) + } + + /// Gets whether the window has keyboard focus. + /// + /// This queries the same state information as [`WindowEvent::Focused`]. + /// + /// [`WindowEvent::Focused`]: crate::event::WindowEvent::Focused + #[inline] + pub fn has_focus(&self) -> bool { + let _span = tracing::debug_span!("winit::Window::has_focus",).entered(); + self.window.maybe_wait_on_main(|w| w.has_focus()) + } + + /// Requests user attention to the window, this has no effect if the application + /// is already focused. How requesting for user attention manifests is platform dependent, + /// see [`UserAttentionType`] for details. + /// + /// Providing `None` will unset the request for user attention. Unsetting the request for + /// user attention might not be done automatically by the WM when the window receives input. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web / Orbital:** Unsupported. + /// - **macOS:** `None` has no effect. + /// - **X11:** Requests for user attention must be manually cleared. + /// - **Wayland:** Requires `xdg_activation_v1` protocol, `None` has no effect. + #[inline] + pub fn request_user_attention(&self, request_type: Option) { + let _span = tracing::debug_span!( + "winit::Window::request_user_attention", + request_type = ?request_type + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.request_user_attention(request_type)) + } + + /// Set or override the window theme. + /// + /// Specify `None` to reset the theme to the system default. + /// + /// ## Platform-specific + /// + /// - **Wayland:** Sets the theme for the client side decorations. Using `None` will use dbus to + /// get the system preference. + /// - **X11:** Sets `_GTK_THEME_VARIANT` hint to `dark` or `light` and if `None` is used, it + /// will default to [`Theme::Dark`]. + /// - **iOS / Android / Web / Orbital:** Unsupported. + #[inline] + pub fn set_theme(&self, theme: Option) { + let _span = tracing::debug_span!( + "winit::Window::set_theme", + theme = ?theme + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.set_theme(theme)) + } + + /// Returns the current window theme. + /// + /// Returns `None` if it cannot be determined on the current platform. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / x11 / Orbital:** Unsupported. + /// - **Wayland:** Only returns theme overrides. + #[inline] + pub fn theme(&self) -> Option { + let _span = tracing::debug_span!("winit::Window::theme",).entered(); + self.window.maybe_wait_on_main(|w| w.theme()) + } + + /// Prevents the window contents from being captured by other apps. + /// + /// ## Platform-specific + /// + /// - **macOS**: if `false`, [`NSWindowSharingNone`] is used but doesn't completely prevent all + /// apps from reading the window content, for instance, QuickTime. + /// - **iOS / Android / x11 / Wayland / Web / Orbital:** Unsupported. + /// + /// [`NSWindowSharingNone`]: https://developer.apple.com/documentation/appkit/nswindowsharingtype/nswindowsharingnone + pub fn set_content_protected(&self, protected: bool) { + let _span = + tracing::debug_span!("winit::Window::set_content_protected", protected).entered(); + self.window.maybe_queue_on_main(move |w| w.set_content_protected(protected)) + } + + /// Gets the current title of the window. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / x11 / Wayland / Web:** Unsupported. Always returns an empty string. + #[inline] + pub fn title(&self) -> String { + let _span = tracing::debug_span!("winit::Window::title",).entered(); + self.window.maybe_wait_on_main(|w| w.title()) + } +} + +/// Cursor functions. +impl Window { + /// Modifies the cursor icon of the window. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Orbital:** Unsupported. + /// - **Web:** Custom cursors have to be loaded and decoded first, until then the previous + /// cursor is shown. + #[inline] + pub fn set_cursor(&self, cursor: impl Into) { + let cursor = cursor.into(); + let _span = tracing::debug_span!("winit::Window::set_cursor",).entered(); + self.window.maybe_queue_on_main(move |w| w.set_cursor(cursor)) + } + + /// Deprecated! Use [`Window::set_cursor()`] instead. + #[deprecated = "Renamed to `set_cursor`"] + #[inline] + pub fn set_cursor_icon(&self, icon: CursorIcon) { + self.set_cursor(icon) + } + + /// Changes the position of the cursor in window coordinates. + /// + /// ```no_run + /// # use winit::dpi::{LogicalPosition, PhysicalPosition}; + /// # use winit::window::Window; + /// # fn scope(window: &Window) { + /// // Specify the position in logical dimensions like this: + /// window.set_cursor_position(LogicalPosition::new(400.0, 200.0)); + /// + /// // Or specify the position in physical dimensions like this: + /// window.set_cursor_position(PhysicalPosition::new(400, 200)); + /// # } + /// ``` + /// + /// ## Platform-specific + /// + /// - **Wayland**: Cursor must be in [`CursorGrabMode::Locked`]. + /// - **iOS / Android / Web / Orbital:** Always returns an [`ExternalError::NotSupported`]. + #[inline] + pub fn set_cursor_position>(&self, position: P) -> Result<(), ExternalError> { + let position = position.into(); + let _span = tracing::debug_span!( + "winit::Window::set_cursor_position", + position = ?position + ) + .entered(); + self.window.maybe_wait_on_main(|w| w.set_cursor_position(position)) + } + + /// Set grabbing [mode][CursorGrabMode] on the cursor preventing it from leaving the window. + /// + /// # Example + /// + /// First try confining the cursor, and if that fails, try locking it instead. + /// + /// ```no_run + /// # use winit::window::{CursorGrabMode, Window}; + /// # fn scope(window: &Window) { + /// window + /// .set_cursor_grab(CursorGrabMode::Confined) + /// .or_else(|_e| window.set_cursor_grab(CursorGrabMode::Locked)) + /// .unwrap(); + /// # } + /// ``` + #[inline] + pub fn set_cursor_grab(&self, mode: CursorGrabMode) -> Result<(), ExternalError> { + let _span = tracing::debug_span!( + "winit::Window::set_cursor_grab", + mode = ?mode + ) + .entered(); + self.window.maybe_wait_on_main(|w| w.set_cursor_grab(mode)) + } + + /// Modifies the cursor's visibility. + /// + /// If `false`, this will hide the cursor. If `true`, this will show the cursor. + /// + /// ## Platform-specific + /// + /// - **Windows:** The cursor is only hidden within the confines of the window. + /// - **X11:** The cursor is only hidden within the confines of the window. + /// - **Wayland:** The cursor is only hidden within the confines of the window. + /// - **macOS:** The cursor is hidden as long as the window has input focus, even if the cursor + /// is outside of the window. + /// - **iOS / Android:** Unsupported. + #[inline] + pub fn set_cursor_visible(&self, visible: bool) { + let _span = tracing::debug_span!("winit::Window::set_cursor_visible", visible).entered(); + self.window.maybe_queue_on_main(move |w| w.set_cursor_visible(visible)) + } + + /// Moves the window with the left mouse button until the button is released. + /// + /// There's no guarantee that this will work unless the left mouse button was pressed + /// immediately before this function is called. + /// + /// ## Platform-specific + /// + /// - **X11:** Un-grabs the cursor. + /// - **Wayland:** Requires the cursor to be inside the window to be dragged. + /// - **macOS:** May prevent the button release event to be triggered. + /// - **iOS / Android / Web:** Always returns an [`ExternalError::NotSupported`]. + #[inline] + pub fn drag_window(&self) -> Result<(), ExternalError> { + let _span = tracing::debug_span!("winit::Window::drag_window",).entered(); + self.window.maybe_wait_on_main(|w| w.drag_window()) + } + + /// Resizes the window with the left mouse button until the button is released. + /// + /// There's no guarantee that this will work unless the left mouse button was pressed + /// immediately before this function is called. + /// + /// ## Platform-specific + /// + /// - **macOS:** Always returns an [`ExternalError::NotSupported`] + /// - **iOS / Android / Web:** Always returns an [`ExternalError::NotSupported`]. + #[inline] + pub fn drag_resize_window(&self, direction: ResizeDirection) -> Result<(), ExternalError> { + let _span = tracing::debug_span!( + "winit::Window::drag_resize_window", + direction = ?direction + ) + .entered(); + self.window.maybe_wait_on_main(|w| w.drag_resize_window(direction)) + } + + /// Show [window menu] at a specified position . + /// + /// This is the context menu that is normally shown when interacting with + /// the title bar. This is useful when implementing custom decorations. + /// + /// ## Platform-specific + /// **Android / iOS / macOS / Orbital / Wayland / Web / X11:** Unsupported. + /// + /// [window menu]: https://en.wikipedia.org/wiki/Common_menus_in_Microsoft_Windows#System_menu + pub fn show_window_menu(&self, position: impl Into) { + let position = position.into(); + let _span = tracing::debug_span!( + "winit::Window::show_window_menu", + position = ?position + ) + .entered(); + self.window.maybe_queue_on_main(move |w| w.show_window_menu(position)) + } + + /// Modifies whether the window catches cursor events. + /// + /// If `true`, the window will catch the cursor events. If `false`, events are passed through + /// the window such that any other window behind it receives them. By default hittest is + /// enabled. + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web / Orbital:** Always returns an [`ExternalError::NotSupported`]. + #[inline] + pub fn set_cursor_hittest(&self, hittest: bool) -> Result<(), ExternalError> { + let _span = tracing::debug_span!("winit::Window::set_cursor_hittest", hittest).entered(); + self.window.maybe_wait_on_main(|w| w.set_cursor_hittest(hittest)) + } +} + +/// Monitor info functions. +impl Window { + /// Returns the monitor on which the window currently resides. + /// + /// Returns `None` if current monitor can't be detected. + #[inline] + pub fn current_monitor(&self) -> Option { + let _span = tracing::debug_span!("winit::Window::current_monitor",).entered(); + self.window.maybe_wait_on_main(|w| w.current_monitor().map(|inner| MonitorHandle { inner })) + } + + /// Returns the list of all the monitors available on the system. + /// + /// This is the same as [`ActiveEventLoop::available_monitors`], and is provided for + /// convenience. + /// + /// [`ActiveEventLoop::available_monitors`]: crate::event_loop::ActiveEventLoop::available_monitors + #[inline] + pub fn available_monitors(&self) -> impl Iterator { + let _span = tracing::debug_span!("winit::Window::available_monitors",).entered(); + self.window.maybe_wait_on_main(|w| { + w.available_monitors().into_iter().map(|inner| MonitorHandle { inner }) + }) + } + + /// Returns the primary monitor of the system. + /// + /// Returns `None` if it can't identify any monitor as a primary one. + /// + /// This is the same as [`ActiveEventLoop::primary_monitor`], and is provided for convenience. + /// + /// ## Platform-specific + /// + /// **Wayland / Web:** Always returns `None`. + /// + /// [`ActiveEventLoop::primary_monitor`]: crate::event_loop::ActiveEventLoop::primary_monitor + #[inline] + pub fn primary_monitor(&self) -> Option { + let _span = tracing::debug_span!("winit::Window::primary_monitor",).entered(); + self.window.maybe_wait_on_main(|w| w.primary_monitor().map(|inner| MonitorHandle { inner })) + } +} + +#[cfg(feature = "rwh_06")] +impl rwh_06::HasWindowHandle for Window { + fn window_handle(&self) -> Result, rwh_06::HandleError> { + let raw = self.window.raw_window_handle_rwh_06()?; + + // SAFETY: The window handle will never be deallocated while the window is alive, + // and the main thread safety requirements are upheld internally by each platform. + Ok(unsafe { rwh_06::WindowHandle::borrow_raw(raw) }) + } +} + +#[cfg(feature = "rwh_06")] +impl rwh_06::HasDisplayHandle for Window { + fn display_handle(&self) -> Result, rwh_06::HandleError> { + let raw = self.window.raw_display_handle_rwh_06()?; + + // SAFETY: The window handle will never be deallocated while the window is alive, + // and the main thread safety requirements are upheld internally by each platform. + Ok(unsafe { rwh_06::DisplayHandle::borrow_raw(raw) }) + } +} + +/// Wrapper to make objects `Send`. +/// +/// # Safety +/// +/// This is not safe! This is only used for `RawWindowHandle`, which only has unsafe getters. +#[cfg(any(feature = "rwh_05", feature = "rwh_04"))] +struct UnsafeSendWrapper(T); + +#[cfg(any(feature = "rwh_05", feature = "rwh_04"))] +unsafe impl Send for UnsafeSendWrapper {} + +#[cfg(feature = "rwh_05")] +unsafe impl rwh_05::HasRawWindowHandle for Window { + fn raw_window_handle(&self) -> rwh_05::RawWindowHandle { + self.window.maybe_wait_on_main(|w| UnsafeSendWrapper(w.raw_window_handle_rwh_05())).0 + } +} + +#[cfg(feature = "rwh_05")] +unsafe impl rwh_05::HasRawDisplayHandle for Window { + /// Returns a [`rwh_05::RawDisplayHandle`] used by the [`EventLoop`] that + /// created a window. + /// + /// [`EventLoop`]: crate::event_loop::EventLoop + fn raw_display_handle(&self) -> rwh_05::RawDisplayHandle { + self.window.maybe_wait_on_main(|w| UnsafeSendWrapper(w.raw_display_handle_rwh_05())).0 + } +} + +#[cfg(feature = "rwh_04")] +unsafe impl rwh_04::HasRawWindowHandle for Window { + fn raw_window_handle(&self) -> rwh_04::RawWindowHandle { + self.window.maybe_wait_on_main(|w| UnsafeSendWrapper(w.raw_window_handle_rwh_04())).0 + } +} + +/// The behavior of cursor grabbing. +/// +/// Use this enum with [`Window::set_cursor_grab`] to grab the cursor. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum CursorGrabMode { + /// No grabbing of the cursor is performed. + None, + + /// The cursor is confined to the window area. + /// + /// There's no guarantee that the cursor will be hidden. You should hide it by yourself if you + /// want to do so. + /// + /// ## Platform-specific + /// + /// - **macOS:** Not implemented. Always returns [`ExternalError::NotSupported`] for now. + /// - **iOS / Android / Web:** Always returns an [`ExternalError::NotSupported`]. + Confined, + + /// The cursor is locked inside the window area to the certain position. + /// + /// There's no guarantee that the cursor will be hidden. You should hide it by yourself if you + /// want to do so. + /// + /// ## Platform-specific + /// + /// - **X11:** Not implemented. Always returns [`ExternalError::NotSupported`] for now. + /// - **iOS / Android:** Always returns an [`ExternalError::NotSupported`]. + Locked, +} + +/// Defines the orientation that a window resize will be performed. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum ResizeDirection { + East, + North, + NorthEast, + NorthWest, + South, + SouthEast, + SouthWest, + West, +} + +impl From for CursorIcon { + fn from(direction: ResizeDirection) -> Self { + use ResizeDirection::*; + match direction { + East => CursorIcon::EResize, + North => CursorIcon::NResize, + NorthEast => CursorIcon::NeResize, + NorthWest => CursorIcon::NwResize, + South => CursorIcon::SResize, + SouthEast => CursorIcon::SeResize, + SouthWest => CursorIcon::SwResize, + West => CursorIcon::WResize, + } + } +} + +/// Fullscreen modes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Fullscreen { + Exclusive(VideoModeHandle), + + /// Providing `None` to `Borderless` will fullscreen on the current monitor. + Borderless(Option), +} + +/// The theme variant to use. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Theme { + /// Use the light variant. + Light, + + /// Use the dark variant. + Dark, +} + +/// ## Platform-specific +/// +/// - **X11:** Sets the WM's `XUrgencyHint`. No distinction between [`Critical`] and +/// [`Informational`]. +/// +/// [`Critical`]: Self::Critical +/// [`Informational`]: Self::Informational +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum UserAttentionType { + /// ## Platform-specific + /// + /// - **macOS:** Bounces the dock icon until the application is in focus. + /// - **Windows:** Flashes both the window and the taskbar button until the application is in + /// focus. + Critical, + + /// ## Platform-specific + /// + /// - **macOS:** Bounces the dock icon once. + /// - **Windows:** Flashes the taskbar button until the application is in focus. + #[default] + Informational, +} + +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct WindowButtons: u32 { + const CLOSE = 1 << 0; + const MINIMIZE = 1 << 1; + const MAXIMIZE = 1 << 2; + } +} + +/// A window level groups windows with respect to their z-position. +/// +/// The relative ordering between windows in different window levels is fixed. +/// The z-order of a window within the same window level may change dynamically on user interaction. +/// +/// ## Platform-specific +/// +/// - **iOS / Android / Web / Wayland:** Unsupported. +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +pub enum WindowLevel { + /// The window will always be below normal windows. + /// + /// This is useful for a widget-based app. + AlwaysOnBottom, + + /// The default. + #[default] + Normal, + + /// The window will always be on top of normal windows. + AlwaysOnTop, +} + +/// Generic IME purposes for use in [`Window::set_ime_purpose`]. +/// +/// The purpose may improve UX by optimizing the IME for the specific use case, +/// if winit can express the purpose to the platform and the platform reacts accordingly. +/// +/// ## Platform-specific +/// +/// - **iOS / Android / Web / Windows / X11 / macOS / Orbital:** Unsupported. +#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] +#[non_exhaustive] +pub enum ImePurpose { + /// No special hints for the IME (default). + #[default] + Normal, + /// The IME is used for password input. + Password, + /// The IME is used to input into a terminal. + /// + /// For example, that could alter OSK on Wayland to show extra buttons. + Terminal, +} + +/// An opaque token used to activate the [`Window`]. +/// +/// [`Window`]: crate::window::Window +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ActivationToken { + pub(crate) token: String, +} + +impl ActivationToken { + /// Make an [`ActivationToken`] from a string. + /// + /// This method should be used to wrap tokens passed by side channels to your application, like + /// dbus. + /// + /// The validity of the token is ensured by the windowing system. Using the invalid token will + /// only result in the side effect of the operation involving it being ignored (e.g. window + /// won't get focused automatically), but won't yield any errors. + /// + /// To obtain a valid token, use + #[cfg_attr( + any(x11_platform, wayland_platform, docsrs), + doc = " [`request_activation_token`](crate::platform::startup_notify::WindowExtStartupNotify::request_activation_token)." + )] + #[cfg_attr( + not(any(x11_platform, wayland_platform, docsrs)), + doc = " `request_activation_token`." + )] + pub fn from_raw(token: String) -> Self { + Self { token } + } + + /// Convert the token to its string representation to later pass via IPC. + pub fn into_raw(self) -> String { + self.token + } +} From 1d86f9c0a29c89d78f17e23047a0af5d0acca57a Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 10:56:02 -0600 Subject: [PATCH 16/56] feat(player): embed macOS controls in the mpv window --- Cargo.lock | 87 +- Cargo.toml | 1 + crates/ferrex-player-playback/Cargo.toml | 3 + crates/ferrex-player-playback/src/controls.rs | 157 +- .../src/external_mpv.rs | 90 +- .../src/macos_presenter.rs | 1908 +++++++++--- .../src/messages/mod.rs | 188 +- .../src/messages/subscriptions.rs | 5 +- .../ferrex-player-playback/src/mpv_adapter.rs | 101 +- .../src/native_presentation.rs | 52 +- .../ferrex-player-playback/src/presenter.rs | 27 +- crates/ferrex-player-playback/src/session.rs | 251 +- crates/ferrex-player-playback/src/state.rs | 237 +- crates/ferrex-player-playback/src/update.rs | 2606 +++++++++++++++-- crates/ferrex-player-playback/src/video.rs | 93 +- crates/ferrex-player-playback/src/view.rs | 123 +- crates/ferrex-player-ui/Cargo.toml | 8 + .../src/common/controller_input.rs | 149 +- .../src/common/messages/cross_domain.rs | 349 ++- .../src/common/messages/mod.rs | 24 +- .../domains/player/messages/subscriptions.rs | 9 + .../src/domains/player/update.rs | 64 +- .../src/domains/ui/messages/subscriptions.rs | 86 +- .../src/domains/ui/playback_ui/update.rs | 44 +- .../src/domains/ui/search_surface.rs | 218 +- .../src/domains/ui/shell_ui/mod.rs | 32 +- .../src/domains/ui/shell_ui/update.rs | 36 +- .../ui/update_handlers/navigation_updates.rs | 52 +- .../ui/views/components/search_dropdown.rs | 6 +- .../ui/views/tenfoot/player_overlay.rs | 306 +- .../src/domains/ui/windows.rs | 283 +- .../src/domains/ui/windows/controller.rs | 1073 ++++++- .../src/domains/ui/windows/subscriptions.rs | 40 +- crates/ferrex-player-ui/src/view.rs | 70 +- 34 files changed, 7668 insertions(+), 1110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06f36f68..b25a964d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2503,6 +2503,7 @@ dependencies = [ "fontdue", "futures", "getrandom 0.3.4", + "gilrs", "hdrhistogram", "hkdf", "hsluv", @@ -3113,6 +3114,40 @@ dependencies = [ "weezl", ] +[[package]] +name = "gilrs" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9" +dependencies = [ + "inotify 0.11.4", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix 0.31.3", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows 0.57.0", +] + [[package]] name = "gio-sys" version = "0.23.0-alpha" @@ -4165,11 +4200,22 @@ dependencies = [ "libc", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -4492,9 +4538,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" @@ -4550,6 +4596,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "lilt" version = "0.8.1" @@ -4719,7 +4775,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -5030,6 +5086,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -5079,7 +5147,7 @@ dependencies = [ "bitflags 2.11.0", "filetime", "fsevent-sys", - "inotify", + "inotify 0.10.2", "kqueue", "libc", "log", @@ -5448,6 +5516,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ + "bitflags 2.11.0", "libc", "objc2-core-foundation", ] @@ -8555,6 +8624,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 946b138e..21771895 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,7 @@ tokio = { version = "1.48", default-features = false, features = [ "rt-multi-thread", "macros", "parking_lot", + "sync", "time", "process", ] } diff --git a/crates/ferrex-player-playback/Cargo.toml b/crates/ferrex-player-playback/Cargo.toml index 26d5cf11..09d6c657 100644 --- a/crates/ferrex-player-playback/Cargo.toml +++ b/crates/ferrex-player-playback/Cargo.toml @@ -70,6 +70,8 @@ windows-sys = { version = "0.61.2", features = [ objc2 = "0.6.4" objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "std", + "NSApplication", + "NSEvent", "NSGraphics", "NSResponder", "NSView", @@ -79,5 +81,6 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [ objc2-foundation = { version = "0.3.2", default-features = false, features = [ "std", "NSGeometry", + "NSEnumerator", "NSObject", ] } diff --git a/crates/ferrex-player-playback/src/controls.rs b/crates/ferrex-player-playback/src/controls.rs index 6c7f1835..6d494528 100644 --- a/crates/ferrex-player-playback/src/controls.rs +++ b/crates/ferrex-player-playback/src/controls.rs @@ -50,81 +50,110 @@ fn icon_button( .into() } +fn shield_native_root_drag<'a>( + surface: Element<'a, PlayerMessage, Theme, iced::Renderer>, + enabled: bool, +) -> Element<'a, PlayerMessage, Theme, iced::Renderer> { + if enabled { + mouse_area(surface) + .on_press(PlayerMessage::ShowControls) + .into() + } else { + surface + } +} + impl PlayerDomainState { /// Build the full controls overlay pub fn build_controls( &self, + shield_native_drag: bool, ) -> iced::Element<'_, PlayerMessage, Theme, iced::Renderer> { - let controls = column![ - // Top bar with title and buttons - container( + // Only these rendered surfaces consume background presses. The + // full-height spacer deliberately stays event-transparent so the + // central video can initiate an AppKit native-root drag. + let top_bar = container( + row![ + // Left side - navigation buttons with spacing row![ - // Left side - navigation buttons with spacing - row![ - icon_button(Icon::ArrowLeft, Some(PlayerMessage::NavigateBack)), - Space::new().width(Length::Fixed(5.0)), - icon_button(Icon::House, Some(PlayerMessage::NavigateHome)), - ] - .align_y(Alignment::Center), - // Center - Title with HDR indicator - container( - row![ - text( - self.current_media - .as_ref() - .map(|m| m.filename.clone()) - .unwrap_or_else(|| "Unknown".to_string()) - ) - .size(18) - .color([1.0, 1.0, 1.0, 1.0]), - // HDR indicator - if self.is_hdr_content { - row![ - Space::new().width(Length::Fixed(10.0)), - container(text("HDR").size(12).color([1.0, 0.8, 0.0, 1.0])) - .padding([2, 6]) - .style(theme::container_hdr_badge), - ] - } else { - row![] - } - ] - .align_y(Alignment::Center) - ) - .width(Length::Fill) - .center_x(Length::Fill), - // Right side - fullscreen button in top icon_button( - if self.is_fullscreen { - Icon::Minimize2 - } else { - Icon::Maximize2 - }, - Some(PlayerMessage::ToggleFullscreen) + Icon::ArrowLeft, + Some(PlayerMessage::NavigateBack) ), + Space::new().width(Length::Fixed(5.0)), + icon_button(Icon::House, Some(PlayerMessage::NavigateHome)), ] - .spacing(10) - .align_y(Alignment::Center) - .padding(15) - ) - .width(Length::Fill), + .align_y(Alignment::Center), + // Center - Title with HDR indicator + container( + row![ + text( + self.current_media + .as_ref() + .map(|m| m.filename.clone()) + .unwrap_or_else(|| "Unknown".to_string()) + ) + .size(18) + .color([1.0, 1.0, 1.0, 1.0]), + // HDR indicator + if self.is_hdr_content { + row![ + Space::new().width(Length::Fixed(10.0)), + container( + text("HDR") + .size(12) + .color([1.0, 0.8, 0.0, 1.0]) + ) + .padding([2, 6]) + .style(theme::container_hdr_badge), + ] + } else { + row![] + } + ] + .align_y(Alignment::Center) + ) + .width(Length::Fill) + .center_x(Length::Fill), + // Right side - fullscreen button in top + icon_button( + if self.is_fullscreen { + Icon::Minimize2 + } else { + Icon::Maximize2 + }, + Some(PlayerMessage::ToggleFullscreen) + ), + ] + .spacing(10) + .align_y(Alignment::Center) + .padding(15), + ) + .width(Length::Fill) + .into(); + let top_bar = shield_native_root_drag(top_bar, shield_native_drag); + + let bottom_controls = column![ + // Seek bar - no padding so it reaches edges + self.build_seek_bar(), + // Control buttons - with padding + container(self.build_control_buttons()) + .padding( + crate::constants::player_controls::CONTROL_BUTTONS_PADDING + ) + .width(Length::Fill), + ] + .spacing(0) + .width(Length::Fill) + .into(); + let bottom_controls = + shield_native_root_drag(bottom_controls, shield_native_drag); + + let controls = column![ + top_bar, // Spacer to push controls to bottom Space::new().height(Length::Fill), - // Bottom controls - column![ - // Seek bar - no padding so it reaches edges - self.build_seek_bar(), - // Spacer between seek bar and controls (40px to match bottom padding) - //Space::new().height(Length::Fixed(15.0)), - // Control buttons - with padding - container(self.build_control_buttons()) - .padding( - crate::constants::player_controls::CONTROL_BUTTONS_PADDING - ) - .width(Length::Fill), - ] - .spacing(0) - .width(Length::Fill), + bottom_controls, ]; controls.into() } diff --git a/crates/ferrex-player-playback/src/external_mpv.rs b/crates/ferrex-player-playback/src/external_mpv.rs index fc0676bb..220f24dc 100644 --- a/crates/ferrex-player-playback/src/external_mpv.rs +++ b/crates/ferrex-player-playback/src/external_mpv.rs @@ -1,7 +1,10 @@ //! Minimal external MPV player management for HDR passthrough //! This module spawns MPV as a separate process and tracks playback position -use crate::diagnostics::{contains_access_token, redact_playback_url}; +use crate::{ + diagnostics::{contains_access_token, redact_playback_url}, + session::PlaybackShutdownBarrier, +}; use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Write}; use std::process::{Child, Command, Stdio}; @@ -221,13 +224,13 @@ impl ExternalMpvHandle { let stream = match UnixStream::connect(&socket_path) { Ok(stream) => stream, Err(error) => { - let _ = process.kill(); + terminate_child(&mut process); return Err(error.into()); } }; // Set non-blocking mode to prevent UI freezing if let Err(error) = stream.set_nonblocking(true) { - let _ = process.kill(); + terminate_child(&mut process); return Err(error.into()); } Arc::new(Mutex::new(BufReader::new(stream))) @@ -259,7 +262,7 @@ impl ExternalMpvHandle { .as_ref() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| "(no log file)".to_string()); - let _ = process.kill(); + terminate_child(&mut process); return Err(format!( "Failed to connect to MPV named pipe after retries: {}. \ IPC may be blocked or mpv failed to start. If antivirus is running, add an exception. \ @@ -375,14 +378,22 @@ See mpv log for details: {}", }; // Start observing properties - ID must be a number, not a string - handle.observe_property(1, "time-pos")?; - handle.observe_property(2, "eof-reached")?; - handle.observe_property(3, "fullscreen")?; - handle.observe_property(4, "duration")?; - - // Keep authenticated media out of argv/process listings. The socket is - // local to this Ferrex process and is removed when the handle drops. - handle.send_command(&["loadfile", url, "replace"])?; + let setup_result = (|| -> Result<(), Box> { + handle.observe_property(1, "time-pos")?; + handle.observe_property(2, "eof-reached")?; + handle.observe_property(3, "fullscreen")?; + handle.observe_property(4, "duration")?; + + // Keep authenticated media out of argv/process listings. The + // socket is local to this Ferrex process and is removed when the + // handle drops. + handle.send_command(&["loadfile", url, "replace"])?; + Ok(()) + })(); + if let Err(error) = setup_result { + let _ = handle.terminate_and_wait(); + return Err(error); + } Ok(handle) } @@ -559,6 +570,56 @@ See mpv log for details: {}", pub fn kill(&mut self) { let _ = self.process.kill(); } + + /// Transfer process ownership to a reaper and return a positive absence + /// barrier. A replacement root or retained shell must not be shown until + /// both termination and `Child::wait` have completed. + pub(crate) fn begin_shutdown_barrier( + self: Box, + ) -> PlaybackShutdownBarrier { + let (sender, completion) = tokio::sync::oneshot::channel(); + let spawn = std::thread::Builder::new() + .name("ferrex-external-mpv-reaper".to_string()) + .spawn(move || { + let mut handle = self; + let result = handle.terminate_and_wait(); + // Release IPC/socket ownership before publishing completion. + drop(handle); + let _ = sender.send(result); + }); + match spawn { + Ok(_reaper) => PlaybackShutdownBarrier::new(completion), + Err(error) => PlaybackShutdownBarrier::failed(format!( + "external mpv reaper could not start: {error}" + )), + } + } + + fn terminate_and_wait(&mut self) -> Result<(), String> { + match self.process.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) => {} + Err(error) => { + return Err(format!( + "external mpv status could not be observed: {error}" + )); + } + } + + if let Err(kill_error) = self.process.kill() { + match self.process.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) | Err(_) => { + return Err(format!( + "external mpv could not be terminated: {kill_error}" + )); + } + } + } + self.process.wait().map(|_| ()).map_err(|error| { + format!("external mpv could not be reaped: {error}") + }) + } } impl Drop for ExternalMpvHandle { @@ -567,6 +628,11 @@ impl Drop for ExternalMpvHandle { } } +fn terminate_child(process: &mut Child) { + let _ = process.kill(); + let _ = process.wait(); +} + #[cfg(unix)] #[derive(Debug)] struct UnixIpcPath { diff --git a/crates/ferrex-player-playback/src/macos_presenter.rs b/crates/ferrex-player-playback/src/macos_presenter.rs index 402292f9..86c2a03e 100644 --- a/crates/ferrex-player-playback/src/macos_presenter.rs +++ b/crates/ferrex-player-playback/src/macos_presenter.rs @@ -1,10 +1,11 @@ //! Conservative capability gate for the macOS native-root presenter. //! //! mpv's modern macOS backend owns its `NSWindow` and video layer. Ferrex may -//! place a transparent Iced child window above that native root only after the -//! relationship has been proven across AppKit lifetime, fullscreen, Spaces, -//! scale, and teardown transitions. Until then this module deterministically -//! selects mpv's ordinary native window and never advertises `wid` embedding. +//! reparent its transparent Iced `NSView` into that native root's content +//! hierarchy only after the relationship has been proven across AppKit +//! lifetime, fullscreen, Spaces, scale, and teardown transitions. No controls +//! `NSWindow` participates in presentation, and the presenter never advertises +//! `wid` embedding. use std::{fmt, num::NonZeroUsize}; @@ -44,6 +45,11 @@ impl MacOsPresenterBuildMode { } } + /// Whether this build may attach the AppKit in-root presenter. + pub const fn enabled(self) -> bool { + matches!(self, Self::Spike) + } + /// Mode compiled into a macOS target. #[cfg(target_os = "macos")] pub fn compiled() -> Self { @@ -55,8 +61,8 @@ impl MacOsPresenterBuildMode { /// Native relationship under evaluation for macOS integration. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MacOsPresenterStrategy { - /// mpv owns the root `NSWindow`; a transparent Iced child follows it. - NativeRootChildWindow, + /// mpv owns the root `NSWindow`; a transparent Iced view lives within it. + NativeRootSubview, } /// Stable, non-sensitive reason an integrated presenter is not available. @@ -65,7 +71,7 @@ pub enum MacOsPresenterBlocker { AppKitMainThreadUnavailable, MpvWindowUnavailable, MpvWindowLifetimeUnverified, - ChildWindowRelationshipUnverified, + InRootViewRelationshipUnverified, ContentLayoutUnverified, BackingScaleUnverified, FocusOcclusionUnverified, @@ -82,8 +88,8 @@ impl MacOsPresenterBlocker { Self::AppKitMainThreadUnavailable => "appkit_main_thread", Self::MpvWindowUnavailable => "mpv_window", Self::MpvWindowLifetimeUnverified => "mpv_window_lifetime", - Self::ChildWindowRelationshipUnverified => { - "child_window_relationship" + Self::InRootViewRelationshipUnverified => { + "in_root_view_relationship" } Self::ContentLayoutUnverified => "content_layout", Self::BackingScaleUnverified => "backing_scale", @@ -105,7 +111,7 @@ pub struct MacOsPresenterEvidence { pub appkit_main_thread: bool, pub mpv_window_available: bool, pub mpv_window_lifetime_verified: bool, - pub child_window_relationship_verified: bool, + pub in_root_view_relationship_verified: bool, pub content_layout_verified: bool, pub backing_scale_verified: bool, pub focus_occlusion_verified: bool, @@ -126,7 +132,7 @@ impl MacOsPresenterEvidence { appkit_main_thread: true, mpv_window_available: true, mpv_window_lifetime_verified: true, - child_window_relationship_verified: true, + in_root_view_relationship_verified: true, content_layout_verified: true, backing_scale_verified: true, focus_occlusion_verified: true, @@ -153,8 +159,8 @@ impl MacOsPresenterEvidence { MacOsPresenterBlocker::MpvWindowLifetimeUnverified, ), ( - self.child_window_relationship_verified, - MacOsPresenterBlocker::ChildWindowRelationshipUnverified, + self.in_root_view_relationship_verified, + MacOsPresenterBlocker::InRootViewRelationshipUnverified, ), ( self.content_layout_verified, @@ -231,7 +237,7 @@ impl MacOsPresenterDecision { }); Self { - strategy: MacOsPresenterStrategy::NativeRootChildWindow, + strategy: MacOsPresenterStrategy::NativeRootSubview, capabilities, blockers, fallback, @@ -246,24 +252,20 @@ impl MacOsPresenterDecision { /// Capabilities of the developer AppKit presenter path. /// -/// The spike is intentionally explicit-only until the manual display, Spaces, -/// fullscreen, HDR, and teardown matrix has been recorded. In particular, -/// this function does not claim native HDR support. +/// The spike remains conservative and does not advertise HDR until the native +/// Apple Silicon and Intel matrix proves real HDR/EDR behavior. pub fn macos_presenter_capabilities( build_mode: MacOsPresenterBuildMode, ) -> PresenterCapabilities { PresenterCapabilities { - integrated_overlay: matches!( - build_mode, - MacOsPresenterBuildMode::Spike - ), + integrated_overlay: build_mode.enabled(), embedded_surface: false, native_hdr: false, fractional_scaling: true, native_window_fallback: true, fullscreen_owner: Some(FullscreenOwner::VideoOutput), compositor_requirement: Some( - "macOS AppKit child-window composition".to_owned(), + "macOS AppKit in-root NSView composition".to_owned(), ), } } @@ -303,15 +305,37 @@ impl fmt::Debug for MacOsWindow { } } -/// Iced overlay window borrowed for one AppKit attach operation. +/// Opaque, non-null AppKit `NSView` identity. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacOsView(NonZeroUsize); + +impl MacOsView { + /// Wrap a non-null pointer obtained from an AppKit object lease. + pub const fn from_non_zero(value: NonZeroUsize) -> Self { + Self(value) + } + + pub const fn get(self) -> usize { + self.0.get() + } +} + +impl fmt::Debug for MacOsView { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MacOsView()") + } +} + +/// Iced view and its original staging owner, borrowed for one attach. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MacOsPresenterHost { - pub overlay: MacOsWindow, + pub view: MacOsView, + pub original_owner: MacOsWindow, } #[cfg(all(target_os = "macos", feature = "ui"))] impl MacOsPresenterHost { - /// Resolve Iced's AppKit `NSView` lease to its owning `NSWindow`. + /// Capture Iced's AppKit `NSView` and its original owner `NSWindow`. pub fn from_captured_iced_host( host: &crate::native_video_slot::CapturedIcedHost, ) -> Result { @@ -344,24 +368,29 @@ impl MacOsPresenterHost { let window = view.window().ok_or_else(|| { presenter_error("Iced AppKit NSView is not installed in a window") })?; - let raw = NonZeroUsize::new(Retained::as_ptr(&window) as usize) - .ok_or_else(|| presenter_error("Iced AppKit NSWindow is null"))?; + let view_raw = NonZeroUsize::new(Retained::as_ptr(&view) as usize) + .ok_or_else(|| presenter_error("Iced AppKit NSView is null"))?; + let owner_raw = NonZeroUsize::new(Retained::as_ptr(&window) as usize) + .ok_or_else(|| { + presenter_error("Iced AppKit NSWindow is null") + })?; Ok(Self { - overlay: MacOsWindow::from_non_zero(raw), + view: MacOsView::from_non_zero(view_raw), + original_owner: MacOsWindow::from_non_zero(owner_raw), }) } } -/// Logical screen rectangle used to align the transparent overlay. +/// Logical rectangle in the mpv root content view's local coordinates. #[derive(Debug, Clone, Copy, PartialEq)] -pub struct MacOsScreenRect { +pub struct MacOsViewRect { pub x: f64, pub y: f64, pub width: f64, pub height: f64, } -impl MacOsScreenRect { +impl MacOsViewRect { fn validate(self) -> Result { if [self.x, self.y, self.width, self.height] .into_iter() @@ -378,82 +407,111 @@ impl MacOsScreenRect { } } -/// AppKit observations retained by the spike for scale/occlusion diagnostics. +/// Pointer-free AppKit observations retained for integration diagnostics. #[derive(Debug, Clone, Copy, PartialEq)] pub struct MacOsWindowSnapshot { - pub content_rect: MacOsScreenRect, + pub content_bounds: MacOsViewRect, + pub overlay_frame: MacOsViewRect, pub backing_scale_factor: f64, pub visible_on_active_space: bool, pub occluded: bool, pub miniaturized: bool, + pub fullscreen: bool, + pub overlay_in_root_content: bool, + pub overlay_topmost: bool, + pub child_window_count: usize, +} + +/// Original state restored after the Iced view leaves mpv's hierarchy. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MacOsViewState { + pub frame: MacOsViewRect, + pub autoresizing_mask: u64, + pub hidden: bool, } /// AppKit operations isolated behind a display-free fakeable interface. +/// +/// Deliberately absent are child-window, screen-positioning, and overlay-window +/// visibility operations. Presentation is exclusively an in-root `NSView`. pub trait MacOsWindowSystem { - /// Retain a trusted AppKit window identity for subsequent operations. fn retain_window( &mut self, window: MacOsWindow, ) -> Result<(), MacOsPresenterError>; fn release_window(&mut self, window: MacOsWindow); + fn retain_view( + &mut self, + view: MacOsView, + ) -> Result<(), MacOsPresenterError>; + fn release_view(&mut self, view: MacOsView); fn is_window(&self, window: MacOsWindow) -> bool; - fn parent_window(&self, window: MacOsWindow) -> Option; - fn collection_behavior( + fn is_view(&self, view: MacOsView) -> bool; + fn view_window(&self, view: MacOsView) -> Option; + fn is_window_content_view( &self, - window: MacOsWindow, - ) -> Result; - fn set_collection_behavior( + owner: MacOsWindow, + view: MacOsView, + ) -> bool; + fn view_state( + &self, + view: MacOsView, + ) -> Result; + fn set_view_frame( &mut self, - window: MacOsWindow, - behavior: u64, + view: MacOsView, + frame: MacOsViewRect, ) -> Result<(), MacOsPresenterError>; - fn ignores_mouse_events( - &self, - window: MacOsWindow, - ) -> Result; - fn set_ignores_mouse_events( + fn set_view_autoresizing_mask( &mut self, - window: MacOsWindow, - ignores: bool, + view: MacOsView, + mask: u64, + ) -> Result<(), MacOsPresenterError>; + fn set_view_hidden( + &mut self, + view: MacOsView, + hidden: bool, ) -> Result<(), MacOsPresenterError>; - fn add_child_above( + fn reparent_view_above( &mut self, root: MacOsWindow, - child: MacOsWindow, + view: MacOsView, ) -> Result<(), MacOsPresenterError>; - fn remove_child(&mut self, root: MacOsWindow, child: MacOsWindow); + fn raise_view_above( + &mut self, + root: MacOsWindow, + view: MacOsView, + ) -> Result<(), MacOsPresenterError>; + /// Remove `view` from mpv, then restore it if `owner` remains usable. + fn restore_view_to_owner(&mut self, owner: MacOsWindow, view: MacOsView); fn snapshot( &self, root: MacOsWindow, + view: MacOsView, ) -> Result; - fn position_overlay( - &mut self, - overlay: MacOsWindow, - rect: MacOsScreenRect, - ) -> Result<(), MacOsPresenterError>; - fn set_visible_without_activation( + fn focus_view( &mut self, - overlay: MacOsWindow, - visible: bool, + root: MacOsWindow, + view: MacOsView, ) -> Result<(), MacOsPresenterError>; - fn activate( + fn begin_window_drag( &mut self, - window: MacOsWindow, - ) -> Result<(), MacOsPresenterError>; + root: MacOsWindow, + ) -> Result; } -const COLLECTION_TRANSIENT: u64 = 1 << 3; -const COLLECTION_FULLSCREEN_AUXILIARY: u64 = 1 << 8; +const VIEW_WIDTH_SIZABLE: u64 = 1 << 1; +const VIEW_HEIGHT_SIZABLE: u64 = 1 << 4; #[derive(Debug, Clone, Copy)] struct MacOsAttachment { identity: PresenterIdentity, - overlay: MacOsWindow, - original_collection_behavior: u64, - original_ignores_mouse_events: bool, + view: MacOsView, + original_owner: MacOsWindow, + original_state: MacOsViewState, } -/// UI-thread-local AppKit native-root/child-overlay presenter. +/// UI-thread-local AppKit native-root/in-root-view presenter. /// /// mpv retains fullscreen ownership. The callback serializes a fullscreen /// request through mpv; the presenter never independently toggles the root. @@ -515,7 +573,7 @@ where } pub const fn relationship(&self) -> MacOsPresenterStrategy { - MacOsPresenterStrategy::NativeRootChildWindow + MacOsPresenterStrategy::NativeRootSubview } pub const fn last_snapshot(&self) -> Option { @@ -528,7 +586,40 @@ where presenter_error("macOS presenter is not attached") })?; self.windows - .activate(attachment.overlay) + .focus_view(self.video_root, attachment.view) + .map_err(PlaybackError::from)?; + Ok(()) + } + + /// Hand the active mouse-down event to AppKit so the retained mpv root, + /// rather than the hidden Iced staging owner, participates in the drag. + /// + /// Presses that race attachment, visibility, or teardown are intentionally + /// ignored; they must not resurrect or move the donor window. + pub fn begin_window_drag(&mut self) -> Result { + let Some(attachment) = self.attachment else { + return Ok(false); + }; + if self.applied_visible != Some(true) + || self.suspended + || !self.windows.is_window(self.video_root) + || !self.windows.is_view(attachment.view) + || self.windows.view_window(attachment.view) + != Some(self.video_root) + { + return Ok(false); + } + let snapshot = + self.windows.snapshot(self.video_root, attachment.view)?; + if !snapshot.visible_on_active_space + || snapshot.miniaturized + || snapshot.fullscreen + || !snapshot.overlay_in_root_content + { + return Ok(false); + } + self.windows + .begin_window_drag(self.video_root) .map_err(Into::into) } @@ -547,19 +638,22 @@ where } } - fn refresh_position_and_visibility( + fn refresh_geometry_and_visibility( &mut self, attachment: MacOsAttachment, ) -> Result<(), PlaybackError> { if !self.windows.is_window(self.video_root) - || !self.windows.is_window(attachment.overlay) + || !self.windows.is_window(attachment.original_owner) + || !self.windows.is_view(attachment.view) { return Err(presenter_error( - "macOS presenter window was destroyed before synchronization", + "macOS presenter AppKit lease was destroyed before synchronization", )); } - let snapshot = self.windows.snapshot(self.video_root)?; - let rect = snapshot.content_rect.validate()?; + let mut snapshot = + self.windows.snapshot(self.video_root, attachment.view)?; + let bounds = snapshot.content_bounds.validate()?; + let overlay_frame = snapshot.overlay_frame.validate()?; if !snapshot.backing_scale_factor.is_finite() || snapshot.backing_scale_factor <= 0.0 { @@ -567,23 +661,47 @@ where "AppKit returned an invalid backing scale factor", )); } - if self.last_snapshot.map(|snapshot| snapshot.content_rect) - != Some(rect) + if !snapshot.overlay_in_root_content + || self.windows.view_window(attachment.view) + != Some(self.video_root) { - self.windows.position_overlay(attachment.overlay, rect)?; + return Err(presenter_error( + "Iced AppKit view left the mpv content hierarchy", + )); + } + let mut repaired = false; + if !snapshot.overlay_topmost { + self.windows + .raise_view_above(self.video_root, attachment.view)?; + repaired = true; + } + if overlay_frame != bounds { + self.windows.set_view_frame(attachment.view, bounds)?; + repaired = true; + } + if repaired { + snapshot = + self.windows.snapshot(self.video_root, attachment.view)?; + if !snapshot.overlay_in_root_content + || !snapshot.overlay_topmost + || snapshot.overlay_frame.validate()? != bounds + || self.windows.view_window(attachment.view) + != Some(self.video_root) + { + return Err(presenter_error( + "Iced AppKit view geometry or z-order repair did not stick", + )); + } } let visible = self.requested_visible && !self.suspended && self.geometry_visible && snapshot.visible_on_active_space && !snapshot.miniaturized; - // Do not hide solely from the root's occlusion bit. AppKit may count - // this presenter's own transparent child as occluding the mpv root, - // which would otherwise create a show/hide feedback loop. Parent/child - // ordering already follows other-app occlusion. + // AppKit naturally occludes the view with its owning mpv window. Do + // not mutate visibility from the root's transient occlusion bit. if self.applied_visible != Some(visible) { - self.windows - .set_visible_without_activation(attachment.overlay, visible)?; + self.windows.set_view_hidden(attachment.view, !visible)?; self.applied_visible = Some(visible); } self.last_snapshot = Some(snapshot); @@ -591,26 +709,33 @@ where } fn restore_attachment(&mut self, attachment: MacOsAttachment) { - if self.windows.is_window(attachment.overlay) { - let _ = self - .windows - .set_visible_without_activation(attachment.overlay, false); - if self.windows.parent_window(attachment.overlay) - == Some(self.video_root) - { - self.windows - .remove_child(self.video_root, attachment.overlay); - } - let _ = self.windows.set_collection_behavior( - attachment.overlay, - attachment.original_collection_behavior, + if self.windows.is_view(attachment.view) { + // Prevent a one-frame flash while the Iced renderer returns to its + // staging owner. The staging window itself is never used to + // present controls and its visibility is not changed here. + let _ = self.windows.set_view_hidden(attachment.view, true); + // First remove the view from mpv unconditionally, then restore it + // only if the retained donor is still usable. + self.windows.restore_view_to_owner( + attachment.original_owner, + attachment.view, + ); + let _ = self.windows.set_view_frame( + attachment.view, + attachment.original_state.frame, + ); + let _ = self.windows.set_view_autoresizing_mask( + attachment.view, + attachment.original_state.autoresizing_mask, ); - let _ = self.windows.set_ignores_mouse_events( - attachment.overlay, - attachment.original_ignores_mouse_events, + let _ = self.windows.set_view_hidden( + attachment.view, + attachment.original_state.hidden, ); } - self.windows.release_window(attachment.overlay); + // Restoration must happen while both objects are still retained. + self.windows.release_view(attachment.view); + self.windows.release_window(attachment.original_owner); self.applied_visible = None; } } @@ -630,7 +755,7 @@ where identity: PresenterIdentity, host: Self::Host<'_>, ) -> Result<(), PlaybackError> { - if !matches!(self.build_mode, MacOsPresenterBuildMode::Spike) { + if !self.build_mode.enabled() { return Err(presenter_error( "macOS integrated presenter is disabled in this build", )); @@ -640,61 +765,62 @@ where "macOS presenter attach was requested more than once", )); } - if host.overlay == self.video_root { + if host.original_owner == self.video_root { return Err(presenter_error( - "macOS presenter received identical root and overlay windows", + "Iced AppKit view is already owned by the mpv root window", )); } - self.windows.retain_window(host.overlay)?; - if !self.windows.is_window(host.overlay) + self.windows.retain_window(host.original_owner)?; + if let Err(error) = self.windows.retain_view(host.view) { + self.windows.release_window(host.original_owner); + return Err(error.into()); + } + if !self.windows.is_window(host.original_owner) || !self.windows.is_window(self.video_root) + || !self.windows.is_view(host.view) { - self.windows.release_window(host.overlay); + self.windows.release_view(host.view); + self.windows.release_window(host.original_owner); return Err(presenter_error( - "macOS presenter received a stale AppKit window", + "macOS presenter received a stale AppKit host", )); } - if self.windows.parent_window(host.overlay).is_some() { - self.windows.release_window(host.overlay); + if self.windows.view_window(host.view) != Some(host.original_owner) + || !self + .windows + .is_window_content_view(host.original_owner, host.view) + { + self.windows.release_view(host.view); + self.windows.release_window(host.original_owner); return Err(presenter_error( - "Iced AppKit overlay already has a parent window", + "Iced AppKit NSView is not its captured owner's content view", )); } - let original_collection_behavior = - match self.windows.collection_behavior(host.overlay) { - Ok(behavior) => behavior, - Err(error) => { - self.windows.release_window(host.overlay); - return Err(error.into()); - } - }; - let original_ignores_mouse_events = - match self.windows.ignores_mouse_events(host.overlay) { - Ok(ignores) => ignores, - Err(error) => { - self.windows.release_window(host.overlay); - return Err(error.into()); - } - }; + let original_state = match self.windows.view_state(host.view) { + Ok(state) => state, + Err(error) => { + self.windows.release_view(host.view); + self.windows.release_window(host.original_owner); + return Err(error.into()); + } + }; let attachment = MacOsAttachment { identity, - overlay: host.overlay, - original_collection_behavior, - original_ignores_mouse_events, + view: host.view, + original_owner: host.original_owner, + original_state, }; - let behavior = original_collection_behavior - | COLLECTION_TRANSIENT - | COLLECTION_FULLSCREEN_AUXILIARY; let setup = (|| { - self.windows - .set_visible_without_activation(host.overlay, false)?; + self.windows.set_view_hidden(host.view, true)?; self.applied_visible = Some(false); - self.windows.set_ignores_mouse_events(host.overlay, false)?; self.windows - .set_collection_behavior(host.overlay, behavior)?; - self.windows.add_child_above(self.video_root, host.overlay) + .reparent_view_above(self.video_root, host.view)?; + self.windows.set_view_autoresizing_mask( + host.view, + VIEW_WIDTH_SIZABLE | VIEW_HEIGHT_SIZABLE, + ) })(); if let Err(error) = setup { self.restore_attachment(attachment); @@ -702,11 +828,12 @@ where } self.attachment = Some(attachment); - if let Err(error) = self.refresh_position_and_visibility(attachment) { + if let Err(error) = self.refresh_geometry_and_visibility(attachment) { self.attachment = None; self.restore_attachment(attachment); return Err(error); } + self.attachment = Some(attachment); Ok(()) } @@ -722,7 +849,7 @@ where })?; let attachment = self.ensure_identity(identity)?; self.geometry_visible = geometry.is_visible(); - self.refresh_position_and_visibility(attachment) + self.refresh_geometry_and_visibility(attachment) } fn set_visible( @@ -731,8 +858,15 @@ where visible: bool, ) -> Result<(), PlaybackError> { let attachment = self.ensure_identity(identity)?; + let was_applied = self.applied_visible; self.requested_visible = visible; - self.refresh_position_and_visibility(attachment) + self.refresh_geometry_and_visibility(attachment)?; + if was_applied != Some(true) && self.applied_visible == Some(true) { + // Winit's donor window is deliberately not made key. Keyboard + // input follows the reparented view through mpv's root instead. + self.windows.focus_view(self.video_root, attachment.view)?; + } + Ok(()) } fn set_suspended( @@ -742,7 +876,7 @@ where ) -> Result<(), PlaybackError> { let attachment = self.ensure_identity(identity)?; self.suspended = suspended; - self.refresh_position_and_visibility(attachment) + self.refresh_geometry_and_visibility(attachment) } fn set_fullscreen( @@ -769,6 +903,7 @@ where } self.attachment = None; self.requested_visible = false; + self.suspended = false; self.geometry_visible = false; self.applied_visible = None; self.last_snapshot = None; @@ -789,6 +924,10 @@ pub struct AppKitWindowSystem { MacOsWindow, objc2::rc::Retained, >, + views: std::collections::HashMap< + MacOsView, + objc2::rc::Retained, + >, } #[cfg(target_os = "macos")] @@ -797,6 +936,7 @@ impl fmt::Debug for AppKitWindowSystem { formatter .debug_struct("AppKitWindowSystem") .field("retained_window_count", &self.windows.len()) + .field("retained_view_count", &self.views.len()) .finish() } } @@ -808,7 +948,7 @@ impl AppKitWindowSystem { objc2::MainThreadMarker::new().is_some() } - /// Retain mpv's live `NSWindow` on the AppKit main thread. + /// Resolve and retain mpv's live `NSWindow` on the AppKit main thread. /// /// Call this only after `vo-configured=true` and a non-zero macOS /// `window-id` observation. mpv owns the source pointer contract. @@ -819,6 +959,7 @@ impl AppKitWindowSystem { Ok(Self { _main_thread: main_thread, windows: std::collections::HashMap::from([(video_root, root)]), + views: std::collections::HashMap::new(), }) } @@ -830,17 +971,34 @@ impl AppKitWindowSystem { window: MacOsWindow, ) -> Result, MacOsPresenterError> { - // SAFETY: callers obtain identities only from mpv's macOS - // VOCTRL_GET_WINDOW_ID or NSView.window. Both are NSWindow pointers, - // and construction is restricted to the AppKit main thread. + let main_thread = objc2::MainThreadMarker::new() + .ok_or(MacOsPresenterError::AppKitMainThreadRequired)?; + let app = objc2_app_kit::NSApplication::sharedApplication(main_thread); + app.windows() + .into_iter() + .find(|candidate| Self::window_identity(candidate) == window) + .ok_or_else(|| { + MacOsPresenterError::Operation( + "AppKit NSWindow identity is no longer live".to_owned(), + ) + }) + } + + fn retain_native_view( + view: MacOsView, + ) -> Result, MacOsPresenterError> + { + // SAFETY: the identity comes from raw-window-handle's live `ns_view` + // lease and is retained on the AppKit main thread before that host + // lease can end. unsafe { objc2::rc::Retained::retain( - window.get() as *mut objc2_app_kit::NSWindow + view.get() as *mut objc2_app_kit::NSView ) } .ok_or_else(|| { MacOsPresenterError::Operation( - "could not retain AppKit NSWindow".to_owned(), + "could not retain AppKit NSView".to_owned(), ) }) } @@ -856,11 +1014,76 @@ impl AppKitWindowSystem { }) } - fn identity(window: &objc2_app_kit::NSWindow) -> MacOsWindow { + fn view( + &self, + view: MacOsView, + ) -> Result<&objc2_app_kit::NSView, MacOsPresenterError> { + self.views.get(&view).map(AsRef::as_ref).ok_or_else(|| { + MacOsPresenterError::Operation( + "AppKit view lease is unavailable".to_owned(), + ) + }) + } + + fn window_identity(window: &objc2_app_kit::NSWindow) -> MacOsWindow { let raw = NonZeroUsize::new(window as *const _ as usize) .expect("Objective-C object references are non-null"); MacOsWindow::from_non_zero(raw) } + + fn view_identity(view: &objc2_app_kit::NSView) -> MacOsView { + let raw = NonZeroUsize::new(view as *const _ as usize) + .expect("Objective-C object references are non-null"); + MacOsView::from_non_zero(raw) + } +} + +/// Synchronously remove mpv's live native root from the visible AppKit window +/// set without destroying it. +/// +/// Shutdown remains asynchronous because libmpv may dispatch teardown work +/// back to AppKit. `orderOut:` is the non-destructive visibility barrier that +/// lets the shell restore or a replacement root open without a two-window +/// interval. Pointer identity is matched only against AppKit's retained live +/// application windows; the observed mpv value is never blindly retained at +/// teardown. +#[cfg(target_os = "macos")] +pub(crate) fn withdraw_mpv_root_window( + native_window_id: i64, +) -> Result<(), PlaybackError> { + let target = MacOsWindow::from_mpv_window_id(native_window_id) + .map_err(PlaybackError::from)?; + let main_thread = objc2::MainThreadMarker::new().ok_or_else(|| { + presenter_error( + "mpv native-root withdrawal requires the AppKit main thread", + ) + })?; + let app = objc2_app_kit::NSApplication::sharedApplication(main_thread); + for window in app.windows() { + let Some(raw) = + NonZeroUsize::new(objc2::rc::Retained::as_ptr(&window) as usize) + else { + continue; + }; + if MacOsWindow::from_non_zero(raw) != target { + continue; + } + window.orderOut(None); + if window.isVisible() { + return Err(presenter_error( + "AppKit kept mpv's native root visible after orderOut", + )); + } + log::debug!("mpv native root withdrawn before asynchronous teardown"); + return Ok(()); + } + + // Absence from NSApplication.windows means the observed root has already + // left the application's live top-level window set. + log::debug!( + "mpv native root was already absent from AppKit's live window set" + ); + Ok(()) } #[cfg(target_os = "macos")] @@ -879,146 +1102,256 @@ impl MacOsWindowSystem for AppKitWindowSystem { self.windows.remove(&window); } + fn retain_view( + &mut self, + view: MacOsView, + ) -> Result<(), MacOsPresenterError> { + if !self.views.contains_key(&view) { + self.views.insert(view, Self::retain_native_view(view)?); + } + Ok(()) + } + + fn release_view(&mut self, view: MacOsView) { + self.views.remove(&view); + } + fn is_window(&self, window: MacOsWindow) -> bool { self.is_live(window) } - fn parent_window(&self, window: MacOsWindow) -> Option { - self.window(window) + fn is_view(&self, view: MacOsView) -> bool { + self.views.contains_key(&view) + } + + fn view_window(&self, view: MacOsView) -> Option { + self.view(view) .ok() - .and_then(objc2_app_kit::NSWindow::parentWindow) + .and_then(objc2_app_kit::NSView::window) .as_deref() - .map(Self::identity) + .map(Self::window_identity) } - fn collection_behavior( + fn is_window_content_view( &self, - window: MacOsWindow, - ) -> Result { - Ok(self.window(window)?.collectionBehavior().0 as u64) + owner: MacOsWindow, + view: MacOsView, + ) -> bool { + self.window(owner) + .ok() + .and_then(objc2_app_kit::NSWindow::contentView) + .as_deref() + .map(Self::view_identity) + == Some(view) + } + + fn view_state( + &self, + view: MacOsView, + ) -> Result { + let view = self.view(view)?; + let frame = view.frame(); + Ok(MacOsViewState { + frame: MacOsViewRect { + x: frame.origin.x, + y: frame.origin.y, + width: frame.size.width, + height: frame.size.height, + }, + autoresizing_mask: view.autoresizingMask().0 as u64, + hidden: view.isHidden(), + }) } - fn set_collection_behavior( + fn set_view_frame( &mut self, - window: MacOsWindow, - behavior: u64, + view: MacOsView, + frame: MacOsViewRect, + ) -> Result<(), MacOsPresenterError> { + let frame = frame.validate()?; + self.view(view)?.setFrame(objc2_foundation::NSRect::new( + objc2_foundation::NSPoint::new(frame.x, frame.y), + objc2_foundation::NSSize::new(frame.width, frame.height), + )); + Ok(()) + } + + fn set_view_autoresizing_mask( + &mut self, + view: MacOsView, + mask: u64, ) -> Result<(), MacOsPresenterError> { - let behavior = usize::try_from(behavior).map_err(|_| { + let mask = usize::try_from(mask).map_err(|_| { MacOsPresenterError::Operation( - "AppKit collection behavior is out of range".to_owned(), + "AppKit autoresizing mask is out of range".to_owned(), ) })?; - self.window(window)?.setCollectionBehavior( - objc2_app_kit::NSWindowCollectionBehavior::from_bits_retain( - behavior, - ), + self.view(view)?.setAutoresizingMask( + objc2_app_kit::NSAutoresizingMaskOptions::from_bits_retain(mask), ); Ok(()) } - fn ignores_mouse_events( - &self, - window: MacOsWindow, - ) -> Result { - Ok(self.window(window)?.ignoresMouseEvents()) + fn raise_view_above( + &mut self, + root: MacOsWindow, + view: MacOsView, + ) -> Result<(), MacOsPresenterError> { + let root_content = + self.window(root)?.contentView().ok_or_else(|| { + MacOsPresenterError::Operation( + "mpv AppKit window has no content view".to_owned(), + ) + })?; + let view = self.view(view)?; + root_content.addSubview_positioned_relativeTo( + view, + objc2_app_kit::NSWindowOrderingMode::Above, + None, + ); + Ok(()) } - fn set_ignores_mouse_events( + fn set_view_hidden( &mut self, - window: MacOsWindow, - ignores: bool, + view: MacOsView, + hidden: bool, ) -> Result<(), MacOsPresenterError> { - self.window(window)?.setIgnoresMouseEvents(ignores); + self.view(view)?.setHidden(hidden); Ok(()) } - fn add_child_above( + fn reparent_view_above( &mut self, root: MacOsWindow, - child: MacOsWindow, + view: MacOsView, ) -> Result<(), MacOsPresenterError> { - // SAFETY: both retained objects are NSWindows, have no existing child - // relationship, and this is executed on the AppKit main thread. - unsafe { - self.window(root)?.addChildWindow_ordered( - self.window(child)?, - objc2_app_kit::NSWindowOrderingMode::Above, - ); - } + let root_content = + self.window(root)?.contentView().ok_or_else(|| { + MacOsPresenterError::Operation( + "mpv AppKit window has no content view".to_owned(), + ) + })?; + let view = self.view(view)?; + view.removeFromSuperview(); + root_content.addSubview_positioned_relativeTo( + view, + objc2_app_kit::NSWindowOrderingMode::Above, + None, + ); Ok(()) } - fn remove_child(&mut self, root: MacOsWindow, child: MacOsWindow) { - if let (Ok(root), Ok(child)) = (self.window(root), self.window(child)) { - root.removeChildWindow(child); + fn restore_view_to_owner(&mut self, owner: MacOsWindow, view: MacOsView) { + let Ok(view) = self.view(view) else { + return; + }; + view.removeFromSuperview(); + if let Ok(owner) = self.window(owner) { + owner.setContentView(Some(view)); } } fn snapshot( &self, root: MacOsWindow, + view: MacOsView, ) -> Result { let root = self.window(root)?; + let view = self.view(view)?; let content = root.contentView().ok_or_else(|| { MacOsPresenterError::Operation( "mpv AppKit window has no content view".to_owned(), ) })?; - let window_rect = content.convertRect_toView(content.bounds(), None); - let screen_rect = root.convertRectToScreen(window_rect); + let bounds = content.bounds(); + let overlay_frame = view.frame(); let occluded = !root .occlusionState() .contains(objc2_app_kit::NSWindowOcclusionState::Visible); + let overlay_in_root_content = + view.window().as_deref().map(Self::window_identity) + == Some(Self::window_identity(root)) + && view.isDescendantOf(&content); + let overlay_topmost = content + .subviews() + .into_iter() + .last() + .is_some_and(|candidate| { + Self::view_identity(&candidate) == Self::view_identity(view) + }); + let child_window_count = root + .childWindows() + .map(|children| children.count()) + .unwrap_or(0); Ok(MacOsWindowSnapshot { - content_rect: MacOsScreenRect { - x: screen_rect.origin.x, - y: screen_rect.origin.y, - width: screen_rect.size.width, - height: screen_rect.size.height, + content_bounds: MacOsViewRect { + x: bounds.origin.x, + y: bounds.origin.y, + width: bounds.size.width, + height: bounds.size.height, + }, + overlay_frame: MacOsViewRect { + x: overlay_frame.origin.x, + y: overlay_frame.origin.y, + width: overlay_frame.size.width, + height: overlay_frame.size.height, }, backing_scale_factor: root.backingScaleFactor(), visible_on_active_space: root.isVisible() && root.isOnActiveSpace(), occluded, miniaturized: root.isMiniaturized(), + fullscreen: root + .styleMask() + .contains(objc2_app_kit::NSWindowStyleMask::FullScreen), + overlay_in_root_content, + overlay_topmost, + child_window_count, }) } - fn position_overlay( + fn focus_view( &mut self, - overlay: MacOsWindow, - rect: MacOsScreenRect, - ) -> Result<(), MacOsPresenterError> { - let rect = rect.validate()?; - self.window(overlay)?.setFrame_display( - objc2_foundation::NSRect::new( - objc2_foundation::NSPoint::new(rect.x, rect.y), - objc2_foundation::NSSize::new(rect.width, rect.height), - ), - true, - ); - Ok(()) - } - - fn set_visible_without_activation( - &mut self, - overlay: MacOsWindow, - visible: bool, + root: MacOsWindow, + view: MacOsView, ) -> Result<(), MacOsPresenterError> { - let overlay = self.window(overlay)?; - if visible { - overlay.orderFront(None); + if self + .window(root)? + .makeFirstResponder(Some(self.view(view)?)) + { + Ok(()) } else { - overlay.orderOut(None); + Err(MacOsPresenterError::Operation( + "mpv AppKit window rejected the Iced first responder" + .to_owned(), + )) } - Ok(()) } - fn activate( + fn begin_window_drag( &mut self, - window: MacOsWindow, - ) -> Result<(), MacOsPresenterError> { - self.window(window)?.makeKeyWindow(); - Ok(()) + root: MacOsWindow, + ) -> Result { + let Some(event) = + objc2_app_kit::NSApplication::sharedApplication(self._main_thread) + .currentEvent() + else { + return Ok(false); + }; + if event.r#type() != objc2_app_kit::NSEventType::LeftMouseDown { + return Ok(false); + } + let root = self.window(root)?; + let event_targets_root = event + .window(self._main_thread) + .as_deref() + .map(Self::window_identity) + == Some(Self::window_identity(root)); + if !event_targets_root { + return Ok(false); + } + root.performWindowDragWithEvent(&event); + Ok(true) } } @@ -1040,6 +1373,75 @@ impl From for PlaybackError { } } +/// Pointer-free facts required before a presenter failure can be called a +/// completed native-window fallback. +#[cfg(any(target_os = "macos", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NativeFallbackWindowObservation { + visible: bool, + on_active_space: bool, + miniaturized: bool, + has_content_view: bool, + can_become_key: bool, + movable: bool, + titled: bool, + resizable: bool, + child_window_count: usize, +} + +#[cfg(any(target_os = "macos", test))] +impl NativeFallbackWindowObservation { + const fn qualifies(self) -> bool { + self.visible + && self.on_active_space + && !self.miniaturized + && self.has_content_view + && self.can_become_key + && self.movable + && self.titled + && self.resizable + && self.child_window_count == 0 + } +} + +/// Confirm that mpv's post-detach AppKit root is a live visible, movable, +/// resizable native window. The raw identity is matched only inside AppKit and +/// is never retained in the evidence projection. +#[cfg(target_os = "macos")] +pub(crate) fn verify_mpv_native_fallback_window( + native_window_id: i64, +) -> Result { + let target = MacOsWindow::from_mpv_window_id(native_window_id) + .map_err(PlaybackError::from)?; + let main_thread = objc2::MainThreadMarker::new().ok_or_else(|| { + presenter_error( + "mpv native fallback verification requires the AppKit main thread", + ) + })?; + let app = objc2_app_kit::NSApplication::sharedApplication(main_thread); + let Some(window) = app.windows().into_iter().find(|candidate| { + AppKitWindowSystem::window_identity(candidate) == target + }) else { + return Ok(false); + }; + let style = window.styleMask(); + Ok(NativeFallbackWindowObservation { + visible: window.isVisible(), + on_active_space: window.isOnActiveSpace(), + miniaturized: window.isMiniaturized(), + has_content_view: window.contentView().is_some(), + can_become_key: window.canBecomeKeyWindow(), + movable: window.isMovable(), + titled: style.contains(objc2_app_kit::NSWindowStyleMask::Titled), + resizable: style.contains(objc2_app_kit::NSWindowStyleMask::Resizable), + child_window_count: window + .childWindows() + .map(|children| children.count()) + .unwrap_or(0), + } + .qualifies()) +} + fn presenter_error(message: impl Into) -> PlaybackError { let mut error = PlaybackError::new(PlaybackErrorKind::Presenter, message); error.backend = Some(crate::contract::BackendKind::Mpv); @@ -1063,6 +1465,10 @@ mod tests { MacOsWindow::from_non_zero(NonZeroUsize::new(value).unwrap()) } + fn view(value: usize) -> MacOsView { + MacOsView::from_non_zero(NonZeroUsize::new(value).unwrap()) + } + fn identity(value: u64) -> PresenterIdentity { PresenterIdentity::new( SessionGeneration::new(value), @@ -1082,66 +1488,192 @@ mod tests { #[derive(Debug, Clone)] struct WindowState { live: bool, - parent: Option, - behavior: u64, - ignores_mouse: bool, + content_view: MacOsView, visible: bool, - frame: Option, + active_space: bool, + occluded: bool, + miniaturized: bool, + fullscreen: bool, + backing_scale_factor: f64, + child_window_count: usize, + } + + #[derive(Debug, Clone, Copy)] + struct ViewState { + live: bool, + window: Option, + superview: Option, + topmost_in_superview: bool, + frame: MacOsViewRect, + autoresizing_mask: u64, + hidden: bool, + } + + #[derive(Debug, Default)] + struct FakeState { + windows: HashMap, + views: HashMap, + retained_windows: HashMap, + retained_views: HashMap, + dragged_roots: Vec, + drag_event_available: bool, } #[derive(Debug, Clone)] struct FakeAppKit { - state: Rc>>, - snapshot: Rc>, + state: Rc>, operations: Rc>>, + fail_next: Rc>>, } impl FakeAppKit { - fn new(video: MacOsWindow, overlay: MacOsWindow) -> Self { + fn new( + video: MacOsWindow, + owner: MacOsWindow, + overlay: MacOsView, + ) -> Self { + let root_content = view(9_000_000 + video.get()); + let content_bounds = MacOsViewRect { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; Self { - state: Rc::new(RefCell::new(HashMap::from([ - ( - video, - WindowState { - live: true, - parent: None, - behavior: 0, - ignores_mouse: false, - visible: true, - frame: None, - }, - ), - ( - overlay, - WindowState { - live: true, - parent: None, - behavior: 0x20, - ignores_mouse: true, - visible: false, - frame: None, - }, - ), - ]))), - snapshot: Rc::new(RefCell::new(MacOsWindowSnapshot { - content_rect: MacOsScreenRect { - x: 40.0, - y: 80.0, - width: 1280.0, - height: 720.0, - }, - backing_scale_factor: 2.0, - visible_on_active_space: true, - occluded: false, - miniaturized: false, + state: Rc::new(RefCell::new(FakeState { + windows: HashMap::from([ + ( + video, + WindowState { + live: true, + content_view: root_content, + visible: true, + active_space: true, + occluded: false, + miniaturized: false, + fullscreen: false, + backing_scale_factor: 2.0, + child_window_count: 0, + }, + ), + ( + owner, + WindowState { + live: true, + content_view: overlay, + // The shell hides the retained donor before it + // asks the presenter to expose the in-root view. + visible: false, + active_space: true, + occluded: false, + miniaturized: false, + fullscreen: false, + backing_scale_factor: 2.0, + child_window_count: 0, + }, + ), + ]), + views: HashMap::from([ + ( + root_content, + ViewState { + live: true, + window: Some(video), + superview: None, + topmost_in_superview: false, + frame: content_bounds, + autoresizing_mask: 0, + hidden: false, + }, + ), + ( + overlay, + ViewState { + live: true, + window: Some(owner), + superview: None, + topmost_in_superview: false, + frame: MacOsViewRect { + x: 5.0, + y: 10.0, + width: 640.0, + height: 360.0, + }, + autoresizing_mask: 0x20, + hidden: false, + }, + ), + ]), + drag_event_available: true, + ..FakeState::default() })), operations: Rc::new(RefCell::new(Vec::new())), + fail_next: Rc::new(RefCell::new(None)), } } fn operation(&self, value: impl Into) { self.operations.borrow_mut().push(value.into()); } + + fn maybe_fail( + &self, + operation: &'static str, + ) -> Result<(), MacOsPresenterError> { + if self.fail_next.borrow().as_ref() == Some(&operation) { + self.fail_next.borrow_mut().take(); + self.operation(format!("fail:{operation}")); + Err(MacOsPresenterError::Operation(format!( + "injected {operation} failure" + ))) + } else { + Ok(()) + } + } + + fn fail_on(&self, operation: &'static str) { + *self.fail_next.borrow_mut() = Some(operation); + } + + fn original_view_state(&self, overlay: MacOsView) -> MacOsViewState { + let state = self.state.borrow().views[&overlay]; + MacOsViewState { + frame: state.frame, + autoresizing_mask: state.autoresizing_mask, + hidden: state.hidden, + } + } + + fn visible_top_level_count(&self) -> usize { + self.state + .borrow() + .windows + .values() + .filter(|window| window.visible) + .count() + } + + fn lease_count(&self) -> usize { + let state = self.state.borrow(); + state.retained_windows.values().sum::() + + state.retained_views.values().sum::() + } + + fn assert_host_restored( + &self, + owner: MacOsWindow, + overlay: MacOsView, + original: MacOsViewState, + ) { + let state = self.state.borrow(); + let view = state.views[&overlay]; + assert_eq!(view.window, Some(owner)); + assert_eq!(view.superview, None); + assert_eq!(state.windows[&owner].content_view, overlay); + assert_eq!(view.frame, original.frame); + assert_eq!(view.autoresizing_mask, original.autoresizing_mask); + assert_eq!(view.hidden, original.hidden); + } } impl MacOsWindowSystem for FakeAppKit { @@ -1149,124 +1681,253 @@ mod tests { &mut self, window: MacOsWindow, ) -> Result<(), MacOsPresenterError> { - self.operation("retain"); - self.is_window(window) - .then_some(()) - .ok_or_else(|| MacOsPresenterError::Operation("stale".into())) + self.operation("retain_window"); + if !self.is_window(window) { + return Err(MacOsPresenterError::Operation("stale".into())); + } + *self + .state + .borrow_mut() + .retained_windows + .entry(window) + .or_default() += 1; + Ok(()) + } + + fn release_window(&mut self, window: MacOsWindow) { + self.operation("release_window"); + let mut state = self.state.borrow_mut(); + let count = state.retained_windows.get_mut(&window).unwrap(); + *count -= 1; + if *count == 0 { + state.retained_windows.remove(&window); + } + } + + fn retain_view( + &mut self, + view: MacOsView, + ) -> Result<(), MacOsPresenterError> { + self.operation("retain_view"); + if !self.is_view(view) { + return Err(MacOsPresenterError::Operation("stale".into())); + } + *self + .state + .borrow_mut() + .retained_views + .entry(view) + .or_default() += 1; + Ok(()) } - fn release_window(&mut self, _window: MacOsWindow) { - self.operation("release"); + fn release_view(&mut self, view: MacOsView) { + self.operation("release_view"); + let mut state = self.state.borrow_mut(); + let count = state.retained_views.get_mut(&view).unwrap(); + *count -= 1; + if *count == 0 { + state.retained_views.remove(&view); + } } fn is_window(&self, window: MacOsWindow) -> bool { self.state .borrow() + .windows .get(&window) .is_some_and(|state| state.live) } - fn parent_window(&self, window: MacOsWindow) -> Option { + fn is_view(&self, view: MacOsView) -> bool { self.state .borrow() - .get(&window) - .and_then(|state| state.parent) + .views + .get(&view) + .is_some_and(|state| state.live) } - fn collection_behavior( + fn view_window(&self, view: MacOsView) -> Option { + self.state + .borrow() + .views + .get(&view) + .and_then(|state| state.window) + } + + fn is_window_content_view( &self, - window: MacOsWindow, - ) -> Result { - Ok(self.state.borrow()[&window].behavior) + owner: MacOsWindow, + view: MacOsView, + ) -> bool { + let state = self.state.borrow(); + state.windows.get(&owner).is_some_and(|window| { + window.content_view == view + && state.views[&view].window == Some(owner) + }) + } + + fn view_state( + &self, + view: MacOsView, + ) -> Result { + let state = self.state.borrow().views[&view]; + Ok(MacOsViewState { + frame: state.frame, + autoresizing_mask: state.autoresizing_mask, + hidden: state.hidden, + }) } - fn set_collection_behavior( + fn set_view_frame( &mut self, - window: MacOsWindow, - behavior: u64, + view: MacOsView, + frame: MacOsViewRect, ) -> Result<(), MacOsPresenterError> { - self.operation(format!("behavior:{behavior:#x}")); - self.state.borrow_mut().get_mut(&window).unwrap().behavior = - behavior; + self.operation("set_frame"); + self.maybe_fail("set_frame")?; + self.state.borrow_mut().views.get_mut(&view).unwrap().frame = frame; Ok(()) } - fn ignores_mouse_events( - &self, - window: MacOsWindow, - ) -> Result { - Ok(self.state.borrow()[&window].ignores_mouse) - } - - fn set_ignores_mouse_events( + fn set_view_autoresizing_mask( &mut self, - window: MacOsWindow, - ignores: bool, + view: MacOsView, + mask: u64, ) -> Result<(), MacOsPresenterError> { - self.operation(format!("ignores:{ignores}")); + self.operation("set_autoresizing"); + self.maybe_fail("set_autoresizing")?; self.state .borrow_mut() - .get_mut(&window) + .views + .get_mut(&view) .unwrap() - .ignores_mouse = ignores; + .autoresizing_mask = mask; Ok(()) } - fn add_child_above( + fn set_view_hidden( + &mut self, + view: MacOsView, + hidden: bool, + ) -> Result<(), MacOsPresenterError> { + self.operation(format!("set_hidden:{hidden}")); + self.maybe_fail("set_hidden")?; + self.state.borrow_mut().views.get_mut(&view).unwrap().hidden = + hidden; + Ok(()) + } + + fn reparent_view_above( &mut self, root: MacOsWindow, - child: MacOsWindow, + view: MacOsView, ) -> Result<(), MacOsPresenterError> { - self.operation("add_child"); - self.state.borrow_mut().get_mut(&child).unwrap().parent = - Some(root); + self.operation("reparent_view"); + self.maybe_fail("reparent_view")?; + let mut state = self.state.borrow_mut(); + let root_content = state.windows[&root].content_view; + let view = state.views.get_mut(&view).unwrap(); + view.window = Some(root); + view.superview = Some(root_content); + view.topmost_in_superview = true; Ok(()) } - fn remove_child(&mut self, root: MacOsWindow, child: MacOsWindow) { - self.operation("remove_child"); + fn raise_view_above( + &mut self, + root: MacOsWindow, + view: MacOsView, + ) -> Result<(), MacOsPresenterError> { + self.operation("raise_view"); + self.maybe_fail("raise_view")?; let mut state = self.state.borrow_mut(); - let child = state.get_mut(&child).unwrap(); - if child.parent == Some(root) { - child.parent = None; + let root_content = state.windows[&root].content_view; + let view = state.views.get_mut(&view).unwrap(); + if view.window != Some(root) || view.superview != Some(root_content) + { + return Err(MacOsPresenterError::Operation( + "view is outside root".into(), + )); } + view.topmost_in_superview = true; + Ok(()) + } + + fn restore_view_to_owner( + &mut self, + owner: MacOsWindow, + view: MacOsView, + ) { + self.operation("restore_owner"); + let mut state = self.state.borrow_mut(); + let owner_live = + state.windows.get(&owner).is_some_and(|window| window.live); + if owner_live { + state.windows.get_mut(&owner).unwrap().content_view = view; + } + let view_state = state.views.get_mut(&view).unwrap(); + view_state.window = owner_live.then_some(owner); + view_state.superview = None; + view_state.topmost_in_superview = false; } fn snapshot( &self, - _root: MacOsWindow, + root: MacOsWindow, + view: MacOsView, ) -> Result { - Ok(*self.snapshot.borrow()) + let state = self.state.borrow(); + let root_state = &state.windows[&root]; + let root_content = root_state.content_view; + let view_state = &state.views[&view]; + Ok(MacOsWindowSnapshot { + content_bounds: state.views[&root_content].frame, + overlay_frame: view_state.frame, + backing_scale_factor: root_state.backing_scale_factor, + visible_on_active_space: root_state.visible + && root_state.active_space, + occluded: root_state.occluded, + miniaturized: root_state.miniaturized, + fullscreen: root_state.fullscreen, + overlay_in_root_content: view_state.window == Some(root) + && view_state.superview == Some(root_content), + overlay_topmost: view_state.topmost_in_superview, + child_window_count: root_state.child_window_count, + }) } - fn position_overlay( + fn focus_view( &mut self, - overlay: MacOsWindow, - rect: MacOsScreenRect, - ) -> Result<(), MacOsPresenterError> { - self.operation("position"); - self.state.borrow_mut().get_mut(&overlay).unwrap().frame = - Some(rect); - Ok(()) - } - - fn set_visible_without_activation( - &mut self, - overlay: MacOsWindow, - visible: bool, + root: MacOsWindow, + view: MacOsView, ) -> Result<(), MacOsPresenterError> { - self.operation(format!("visible:{visible}")); - self.state.borrow_mut().get_mut(&overlay).unwrap().visible = - visible; - Ok(()) + self.operation("focus_root_view"); + (self.view_window(view) == Some(root)) + .then_some(()) + .ok_or_else(|| { + MacOsPresenterError::Operation( + "view is outside root".into(), + ) + }) } - fn activate( + fn begin_window_drag( &mut self, - _window: MacOsWindow, - ) -> Result<(), MacOsPresenterError> { - self.operation("activate"); - Ok(()) + root: MacOsWindow, + ) -> Result { + self.operation("begin_window_drag"); + self.maybe_fail("begin_window_drag")?; + if !self.state.borrow().drag_event_available { + return Ok(false); + } + if !self.is_window(root) { + return Err(MacOsPresenterError::Operation( + "drag root is stale".into(), + )); + } + self.state.borrow_mut().dragged_roots.push(root); + Ok(true) } } @@ -1287,6 +1948,63 @@ mod tests { assert!(fallback.detail.contains("spaces")); } + #[test] + fn native_fallback_requires_one_manageable_live_mpv_root() { + let verified = NativeFallbackWindowObservation { + visible: true, + on_active_space: true, + miniaturized: false, + has_content_view: true, + can_become_key: true, + movable: true, + titled: true, + resizable: true, + child_window_count: 0, + }; + assert!(verified.qualifies()); + + for unqualified in [ + NativeFallbackWindowObservation { + visible: false, + ..verified + }, + NativeFallbackWindowObservation { + on_active_space: false, + ..verified + }, + NativeFallbackWindowObservation { + miniaturized: true, + ..verified + }, + NativeFallbackWindowObservation { + has_content_view: false, + ..verified + }, + NativeFallbackWindowObservation { + can_become_key: false, + ..verified + }, + NativeFallbackWindowObservation { + movable: false, + ..verified + }, + NativeFallbackWindowObservation { + titled: false, + ..verified + }, + NativeFallbackWindowObservation { + resizable: false, + ..verified + }, + NativeFallbackWindowObservation { + child_window_count: 1, + ..verified + }, + ] { + assert!(!unqualified.qualifies()); + } + } + #[test] fn presenter_decision_feeds_the_backend_fallback_policy() { let presenter = @@ -1367,20 +2085,25 @@ mod tests { MacOsPresenterBuildMode::parse(Some("spike")).unwrap(), MacOsPresenterBuildMode::Spike ); + assert!(!MacOsPresenterBuildMode::Disabled.enabled()); + assert!(MacOsPresenterBuildMode::Spike.enabled()); assert!(MacOsPresenterBuildMode::parse(Some("production")).is_err()); assert!(MacOsWindow::from_mpv_window_id(0).is_err()); assert_eq!(MacOsWindow::from_mpv_window_id(42).unwrap().get(), 42); let high_bit = MacOsWindow::from_mpv_window_id(i64::MIN).unwrap(); assert_eq!(high_bit.get() as u64, i64::MIN as u64); assert!(!format!("{:?}", window(42)).contains("42")); + assert!(!format!("{:?}", view(43)).contains("43")); } #[test] - fn child_overlay_tracks_geometry_visibility_fullscreen_and_detach() { + fn in_root_view_tracks_visibility_fullscreen_focus_and_detach() { let video = window(10); - let overlay = window(20); - let appkit = FakeAppKit::new(video, overlay); + let donor = window(20); + let overlay = view(30); + let appkit = FakeAppKit::new(video, donor, overlay); let observed = appkit.clone(); + let original = observed.original_view_state(overlay); let fullscreen_values = Rc::new(RefCell::new(Vec::new())); let fullscreen_values_for_callback = Rc::clone(&fullscreen_values); let mut presenter = MacOsPresenter::new( @@ -1395,66 +2118,327 @@ mod tests { let id = identity(1); presenter - .attach(id, MacOsPresenterHost { overlay }) + .attach( + id, + MacOsPresenterHost { + view: overlay, + original_owner: donor, + }, + ) .unwrap(); { let state = observed.state.borrow(); - assert_eq!(state[&overlay].parent, Some(video)); + let overlay_state = state.views[&overlay]; + let root_content = state.windows[&video].content_view; + assert_eq!(overlay_state.window, Some(video)); + assert_eq!(overlay_state.superview, Some(root_content)); assert_eq!( - state[&overlay].behavior, - 0x20 | COLLECTION_TRANSIENT | COLLECTION_FULLSCREEN_AUXILIARY + overlay_state.autoresizing_mask, + VIEW_WIDTH_SIZABLE | VIEW_HEIGHT_SIZABLE ); - assert!(!state[&overlay].ignores_mouse); - assert!(!state[&overlay].visible); + assert!(overlay_state.hidden); + assert_eq!(state.windows[&video].child_window_count, 0); + assert!(!state.windows[&donor].visible); } + assert_eq!(observed.visible_top_level_count(), 1); presenter.synchronize(id, geometry(true)).unwrap(); presenter.set_visible(id, true).unwrap(); - assert!(observed.state.borrow()[&overlay].visible); + let state = observed.state.borrow(); + let root_content = state.windows[&video].content_view; + assert!(!state.views[&overlay].hidden); assert_eq!( - observed.state.borrow()[&overlay].frame, - Some(observed.snapshot.borrow().content_rect) + state.views[&overlay].frame, + state.views[&root_content].frame ); + assert!(!state.windows[&donor].visible); + drop(state); assert_eq!( presenter.last_snapshot().unwrap().backing_scale_factor, 2.0 ); + assert!(presenter.last_snapshot().unwrap().overlay_in_root_content); + assert!(presenter.last_snapshot().unwrap().overlay_topmost); + assert_eq!( + presenter.last_snapshot().unwrap().overlay_frame, + presenter.last_snapshot().unwrap().content_bounds + ); + assert_eq!(presenter.last_snapshot().unwrap().child_window_count, 0); presenter.set_suspended(id, true).unwrap(); - assert!(!observed.state.borrow()[&overlay].visible); + assert!(observed.state.borrow().views[&overlay].hidden); presenter.set_suspended(id, false).unwrap(); - assert!(observed.state.borrow()[&overlay].visible); + assert!(!observed.state.borrow().views[&overlay].hidden); presenter .set_fullscreen(id, FullscreenOwner::VideoOutput, true) .unwrap(); assert_eq!(&*fullscreen_values.borrow(), &[true]); - presenter.focus_overlay().unwrap(); + assert_eq!( + observed + .operations + .borrow() + .iter() + .filter(|operation| operation.as_str() == "focus_root_view") + .count(), + 1 + ); presenter.detach(id); - let state = observed.state.borrow(); - assert_eq!(state[&overlay].parent, None); - assert_eq!(state[&overlay].behavior, 0x20); - assert!(state[&overlay].ignores_mouse); - assert!(!state[&overlay].visible); - drop(state); + observed.assert_host_restored(donor, overlay, original); + assert_eq!(observed.lease_count(), 0); + assert_eq!(observed.visible_top_level_count(), 1); let operations = observed.operations.borrow(); let hide = operations .iter() - .position(|operation| operation == "visible:false") + .position(|operation| operation == "set_hidden:true") .unwrap(); let attach = operations .iter() - .position(|operation| operation == "add_child") + .position(|operation| operation == "reparent_view") .unwrap(); assert!(hide < attach); - assert!(operations.iter().any(|operation| operation == "release")); + let restore = operations + .iter() + .rposition(|operation| operation == "restore_owner") + .unwrap(); + let release_view = operations + .iter() + .rposition(|operation| operation == "release_view") + .unwrap(); + let release_window = operations + .iter() + .rposition(|operation| operation == "release_window") + .unwrap(); + assert!(restore < release_view); + assert!(release_view < release_window); + assert!( + operations + .iter() + .all(|operation| !operation.contains("window_visible")) + ); + } + + #[test] + fn synchronize_repairs_stale_overlay_frame_when_root_bounds_are_unchanged() + { + let video = window(11); + let donor = window(12); + let overlay = view(13); + let appkit = FakeAppKit::new(video, donor, overlay); + let observed = appkit.clone(); + let mut presenter = MacOsPresenter::new( + appkit, + |_| Ok(()), + video, + MacOsPresenterBuildMode::Spike, + ); + let id = identity(11); + presenter + .attach( + id, + MacOsPresenterHost { + view: overlay, + original_owner: donor, + }, + ) + .unwrap(); + + let expected = { + let state = observed.state.borrow(); + let root_content = state.windows[&video].content_view; + state.views[&root_content].frame + }; + observed + .state + .borrow_mut() + .views + .get_mut(&overlay) + .unwrap() + .frame = MacOsViewRect { + x: 17.0, + y: 23.0, + width: 400.0, + height: 300.0, + }; + observed.operations.borrow_mut().clear(); + + presenter.synchronize(id, geometry(true)).unwrap(); + + assert_eq!(observed.state.borrow().views[&overlay].frame, expected); + assert_eq!(presenter.last_snapshot().unwrap().overlay_frame, expected); + let operations = observed.operations.borrow(); + assert_eq!( + operations + .iter() + .filter(|operation| operation.as_str() == "set_frame") + .count(), + 1 + ); + assert!(!operations.iter().any(|operation| operation == "raise_view")); + } + + #[test] + fn synchronize_repairs_lost_topmost_order_without_reparenting_host() { + let video = window(14); + let donor = window(15); + let overlay = view(16); + let appkit = FakeAppKit::new(video, donor, overlay); + let observed = appkit.clone(); + let mut presenter = MacOsPresenter::new( + appkit, + |_| Ok(()), + video, + MacOsPresenterBuildMode::Spike, + ); + let id = identity(12); + presenter + .attach( + id, + MacOsPresenterHost { + view: overlay, + original_owner: donor, + }, + ) + .unwrap(); + + observed + .state + .borrow_mut() + .views + .get_mut(&overlay) + .unwrap() + .topmost_in_superview = false; + observed.operations.borrow_mut().clear(); + + presenter.synchronize(id, geometry(true)).unwrap(); + + assert!(observed.state.borrow().views[&overlay].topmost_in_superview); + assert!(presenter.last_snapshot().unwrap().overlay_topmost); + let operations = observed.operations.borrow(); + assert_eq!( + operations + .iter() + .filter(|operation| operation.as_str() == "raise_view") + .count(), + 1 + ); + assert!( + !operations + .iter() + .any(|operation| operation == "reparent_view"), + "z-order repair must not detach the live foreign-hosted view" + ); + assert!(!operations.iter().any(|operation| operation == "set_frame")); + } + + #[test] + fn native_drag_targets_visible_attached_mpv_root_and_other_states_no_op() { + let video = window(21); + let donor = window(22); + let overlay = view(23); + let appkit = FakeAppKit::new(video, donor, overlay); + let observed = appkit.clone(); + let mut presenter = MacOsPresenter::new( + appkit, + |_| Ok(()), + video, + MacOsPresenterBuildMode::Spike, + ); + let id = identity(21); + + assert!(!presenter.begin_window_drag().unwrap()); + presenter + .attach( + id, + MacOsPresenterHost { + view: overlay, + original_owner: donor, + }, + ) + .unwrap(); + assert!(!presenter.begin_window_drag().unwrap()); + + presenter.synchronize(id, geometry(true)).unwrap(); + presenter.set_visible(id, true).unwrap(); + assert!(presenter.begin_window_drag().unwrap()); + assert_eq!(observed.state.borrow().dragged_roots, vec![video]); + assert!(!observed.state.borrow().dragged_roots.contains(&donor)); + + observed.state.borrow_mut().drag_event_available = false; + assert!(!presenter.begin_window_drag().unwrap()); + observed.state.borrow_mut().drag_event_available = true; + + observed + .state + .borrow_mut() + .windows + .get_mut(&video) + .unwrap() + .active_space = false; + assert!(!presenter.begin_window_drag().unwrap()); + observed + .state + .borrow_mut() + .windows + .get_mut(&video) + .unwrap() + .active_space = true; + + observed + .state + .borrow_mut() + .windows + .get_mut(&video) + .unwrap() + .miniaturized = true; + assert!(!presenter.begin_window_drag().unwrap()); + observed + .state + .borrow_mut() + .windows + .get_mut(&video) + .unwrap() + .miniaturized = false; + + observed + .state + .borrow_mut() + .windows + .get_mut(&video) + .unwrap() + .fullscreen = true; + assert!(!presenter.begin_window_drag().unwrap()); + observed + .state + .borrow_mut() + .windows + .get_mut(&video) + .unwrap() + .fullscreen = false; + + presenter.set_suspended(id, true).unwrap(); + assert!(!presenter.begin_window_drag().unwrap()); + presenter.set_suspended(id, false).unwrap(); + observed + .state + .borrow_mut() + .views + .get_mut(&overlay) + .unwrap() + .window = Some(donor); + assert!(!presenter.begin_window_drag().unwrap()); + assert_eq!(observed.state.borrow().dragged_roots, vec![video]); + + presenter.detach(id); + assert!(!presenter.begin_window_drag().unwrap()); } #[test] fn occlusion_does_not_oscillate_overlay_and_stale_generations_fail_safe() { let video = window(30); - let overlay = window(40); - let appkit = FakeAppKit::new(video, overlay); + let donor = window(40); + let overlay = view(50); + let appkit = FakeAppKit::new(video, donor, overlay); let observed = appkit.clone(); let mut presenter = MacOsPresenter::new( appkit, @@ -1464,34 +2448,212 @@ mod tests { ); let id = identity(2); presenter - .attach(id, MacOsPresenterHost { overlay }) + .attach( + id, + MacOsPresenterHost { + view: overlay, + original_owner: donor, + }, + ) .unwrap(); presenter.synchronize(id, geometry(true)).unwrap(); presenter.set_visible(id, true).unwrap(); - assert!(observed.state.borrow()[&overlay].visible); + assert!(!observed.state.borrow().views[&overlay].hidden); let operation_count = observed.operations.borrow().len(); - observed.snapshot.borrow_mut().occluded = true; + observed + .state + .borrow_mut() + .windows + .get_mut(&video) + .unwrap() + .occluded = true; presenter.synchronize(id, geometry(true)).unwrap(); - assert!(observed.state.borrow()[&overlay].visible); + assert!(!observed.state.borrow().views[&overlay].hidden); assert_eq!(observed.operations.borrow().len(), operation_count); - observed.snapshot.borrow_mut().miniaturized = true; + observed + .state + .borrow_mut() + .windows + .get_mut(&video) + .unwrap() + .miniaturized = true; presenter.synchronize(id, geometry(true)).unwrap(); - assert!(!observed.state.borrow()[&overlay].visible); + assert!(observed.state.borrow().views[&overlay].hidden); let stale = identity(3); assert!(presenter.synchronize(stale, geometry(true)).is_err()); presenter.detach(stale); - assert_eq!(observed.state.borrow()[&overlay].parent, Some(video)); + assert_eq!(observed.state.borrow().views[&overlay].window, Some(video)); presenter.detach(id); + assert_eq!(observed.lease_count(), 0); + } + + #[test] + fn partial_attach_failure_restores_donor_before_releasing_leases() { + let video = window(60); + let donor = window(70); + let overlay = view(80); + let appkit = FakeAppKit::new(video, donor, overlay); + let observed = appkit.clone(); + let original = observed.original_view_state(overlay); + // Initial refresh sets local root bounds after reparenting. + observed.fail_on("set_frame"); + let mut presenter = MacOsPresenter::new( + appkit, + |_| Ok(()), + video, + MacOsPresenterBuildMode::Spike, + ); + + let error = presenter + .attach( + identity(4), + MacOsPresenterHost { + view: overlay, + original_owner: donor, + }, + ) + .unwrap_err(); + + assert!(error.message.contains("injected set_frame failure")); + observed.assert_host_restored(donor, overlay, original); + assert_eq!(observed.lease_count(), 0); + assert_eq!(observed.visible_top_level_count(), 1); + let operations = observed.operations.borrow(); + let reparent = operations + .iter() + .position(|operation| operation == "reparent_view") + .unwrap(); + let failure = operations + .iter() + .position(|operation| operation == "fail:set_frame") + .unwrap(); + let restore = operations + .iter() + .position(|operation| operation == "restore_owner") + .unwrap(); + let release = operations + .iter() + .position(|operation| operation == "release_view") + .unwrap(); + assert!(reparent < failure); + assert!(failure < restore); + assert!(restore < release); + } + + #[test] + fn donor_invalidation_still_removes_view_from_mpv_before_release() { + let video = window(81); + let donor = window(82); + let overlay = view(83); + let appkit = FakeAppKit::new(video, donor, overlay); + let observed = appkit.clone(); + let mut presenter = MacOsPresenter::new( + appkit, + |_| Ok(()), + video, + MacOsPresenterBuildMode::Spike, + ); + let id = identity(5); + presenter + .attach( + id, + MacOsPresenterHost { + view: overlay, + original_owner: donor, + }, + ) + .unwrap(); + observed + .state + .borrow_mut() + .windows + .get_mut(&donor) + .unwrap() + .live = false; + + presenter.detach(id); + + let state = observed.state.borrow(); + assert_eq!(state.views[&overlay].window, None); + assert_eq!(state.views[&overlay].superview, None); + drop(state); + assert_eq!(observed.lease_count(), 0); + let operations = observed.operations.borrow(); + let restore = operations + .iter() + .rposition(|operation| operation == "restore_owner") + .unwrap(); + let release = operations + .iter() + .rposition(|operation| operation == "release_view") + .unwrap(); + assert!(restore < release); + } + + #[test] + fn one_hundred_attach_sync_fullscreen_detach_cycles_have_zero_growth() { + let video = window(90); + let donor = window(100); + let overlay = view(110); + let appkit = FakeAppKit::new(video, donor, overlay); + let observed = appkit.clone(); + let original = observed.original_view_state(overlay); + let fullscreen_values = Rc::new(RefCell::new(Vec::new())); + let callback_values = Rc::clone(&fullscreen_values); + let mut presenter = MacOsPresenter::new( + appkit, + move |fullscreen| { + callback_values.borrow_mut().push(fullscreen); + Ok(()) + }, + video, + MacOsPresenterBuildMode::Spike, + ); + + for cycle in 1..=100 { + let id = identity(cycle); + let host = MacOsPresenterHost { + view: overlay, + original_owner: donor, + }; + presenter.attach(id, host).unwrap(); + presenter.synchronize(id, geometry(true)).unwrap(); + presenter.set_visible(id, true).unwrap(); + presenter + .set_fullscreen(id, FullscreenOwner::VideoOutput, true) + .unwrap(); + presenter + .set_fullscreen(id, FullscreenOwner::VideoOutput, false) + .unwrap(); + assert!(presenter.last_snapshot().unwrap().overlay_in_root_content); + assert!(presenter.last_snapshot().unwrap().overlay_topmost); + assert_eq!( + presenter.last_snapshot().unwrap().overlay_frame, + presenter.last_snapshot().unwrap().content_bounds + ); + assert_eq!( + presenter.last_snapshot().unwrap().child_window_count, + 0 + ); + assert_eq!(observed.visible_top_level_count(), 1); + presenter.detach(id); + observed.assert_host_restored(donor, overlay, original); + assert_eq!(observed.lease_count(), 0, "cycle {cycle}"); + } + + assert_eq!(fullscreen_values.borrow().len(), 200); + assert_eq!(observed.visible_top_level_count(), 1); } #[test] fn disabled_presenter_rejects_attach_without_mutating_appkit() { - let video = window(50); - let overlay = window(60); - let appkit = FakeAppKit::new(video, overlay); + let video = window(120); + let donor = window(130); + let overlay = view(140); + let appkit = FakeAppKit::new(video, donor, overlay); let observed = appkit.clone(); let mut presenter = MacOsPresenter::new( appkit, @@ -1502,7 +2664,13 @@ mod tests { assert!( presenter - .attach(identity(4), MacOsPresenterHost { overlay }) + .attach( + identity(5), + MacOsPresenterHost { + view: overlay, + original_owner: donor, + }, + ) .is_err() ); assert!(observed.operations.borrow().is_empty()); diff --git a/crates/ferrex-player-playback/src/messages/mod.rs b/crates/ferrex-player-playback/src/messages/mod.rs index eea6e587..b93fbcc6 100644 --- a/crates/ferrex-player-playback/src/messages/mod.rs +++ b/crates/ferrex-player-playback/src/messages/mod.rs @@ -13,11 +13,53 @@ use iced::ContentFit; use std::fmt; use std::time::Duration; +/// Monotonic identity for one media-source request. +/// +/// This is deliberately distinct from a playback session generation: URL +/// authorization exists before a backend session, and one request may replace +/// or fall back across multiple backend sessions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PlaybackRequestId(u64); + +impl PlaybackRequestId { + /// Reserved initial value before the first request is allocated. + pub const INITIAL: Self = Self(0); + + /// Construct an identity for deterministic state-machine tests. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Return the numeric identity for diagnostics and tests. + pub const fn get(self) -> u64 { + self.0 + } + + /// Advance to a never-reused request identity. + pub const fn next(self) -> Option { + match self.0.checked_add(1) { + Some(value) => Some(Self(value)), + None => None, + } + } +} + +/// Navigation performed only after playback teardown is positively complete. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaybackExitDestination { + None, + Back, + Home, +} + #[derive(Clone)] pub enum PlayerMessage { // Media control PlayMedia(MediaFile), PlayMediaWithId(MediaFile, MediaID), + /// Atomically allocate a media request whose resolved source must be + /// handed to the explicit external-process backend. + PlayMediaWithIdExternally(MediaFile, MediaID), NavigateBack, // Navigate to previous view NavigateHome, // Navigate to home/library view @@ -26,7 +68,25 @@ pub enum PlayerMessage { Pause, PlayPause, Stop, - ResetAfterStop, // Internal message to reset state after progress update + /// Complete teardown for the request captured when exit began. A delayed + /// reset must never erase a newer playback request. + ResetAfterStop(Option), + /// Finish reset/navigation after any root teardown barrier. + CompletePlaybackExit { + request: Option, + destination: PlaybackExitDestination, + }, + /// Restore the retained shell after a root teardown that must preserve + /// the current error/request projection instead of resetting playback. + RestoreShellAfterRootShutdown { + request: Option, + }, + /// Positive completion from the root owner reaper. The wrapped action + /// cannot be reduced until the in-flight shutdown phase is cleared. + RootShutdownCompleted { + request: Option, + continuation: Box, + }, // Seeking Seek(f64), @@ -49,7 +109,11 @@ pub enum PlayerMessage { PreviousEpisode, // Video events - VideoLoaded(bool), // Success flag + /// Backend-open completion scoped to the source request that created it. + VideoLoaded { + request: PlaybackRequestId, + success: bool, + }, VideoReadyToPlay, // Video is ready to be loaded and played (from streaming domain) EndOfStream, /// Synchronize a legacy adapter snapshot on the bounded controls timer, @@ -74,10 +138,48 @@ pub enum PlayerMessage { // External player control PlayExternal, - // Internal: set a resolved, redacted source and trigger playback. + /// Resume an explicit external-player handoff after root owner teardown. + ResumeExternalPlaybackAfterRootShutdown { + request: PlaybackRequestId, + }, + /// Shell-ordered continuation after the retained main window is hidden and + /// any integrated controls donor has been closed without restoration. + OpenExternalStreamSource { + request: PlaybackRequestId, + }, + /// Continue with an in-process backend only after a failed external spawn + /// has restored the request-owned retained shell. + ResumeInternalPlaybackAfterExternalLaunchFailure { + request: PlaybackRequestId, + }, + // Internal/current-domain replacement: allocate a fresh request identity, + // set a resolved, redacted source, and trigger playback. SetStreamSource(PlaybackSource), + /// Completion of one asynchronous authenticated stream-source request. + StreamSourceResolved { + request: PlaybackRequestId, + source: PlaybackSource, + }, + /// Internal shell-ordered continuation after the retained main window has + /// completed its integrated-playback hide action. + OpenResolvedStreamSource { + request: PlaybackRequestId, + }, + /// Resume source selection after an older root is fully destroyed. + ResumeResolvedStreamSourceAfterRootShutdown { + request: PlaybackRequestId, + retired_request: Option, + }, + /// Fail closed when root teardown could not establish completion. + RootShutdownFailed { + request: Option, + message: String, + }, // Internal: surface stream authorization failures before opening a renderer - StreamUrlResolutionFailed(String), + StreamUrlResolutionFailed { + request: PlaybackRequestId, + message: String, + }, // UI control ShowControls, @@ -85,6 +187,11 @@ pub enum PlayerMessage { DisableFullscreen, ToggleSettings, MouseMoved(iced::Point), + /// Begin an AppKit-managed drag of mpv's retained native root window. + /// + /// This is emitted only by the central video/background surface in the + /// integrated macOS presentation path. Other targets keep `VideoClicked`. + BeginNativeRootDrag, VideoClicked, VideoDoubleClicked, @@ -111,12 +218,16 @@ pub enum PlayerMessage { CheckControlsVisibility, // External player status messages - ExternalPlaybackStarted, + ExternalPlaybackStarted { + request: PlaybackRequestId, + }, ExternalPlaybackUpdate { position: f64, duration: f64, }, - ExternalPlaybackEnded, + ExternalPlaybackEnded { + request: PlaybackRequestId, + }, PollExternalMpv, ProgressHeartbeat, } @@ -133,6 +244,9 @@ impl fmt::Debug for PlayerMessage { PlayerMessage::PlayMediaWithId(media, id) => { write!(f, "PlayMediaWithId({:?}, {:?})", media, id) } + PlayerMessage::PlayMediaWithIdExternally(media, id) => { + write!(f, "PlayMediaWithIdExternally({:?}, {:?})", media, id) + } PlayerMessage::NavigateBack => write!(f, "NavigateBack"), PlayerMessage::NavigateHome => write!(f, "NavigateHome"), @@ -141,7 +255,16 @@ impl fmt::Debug for PlayerMessage { PlayerMessage::Pause => write!(f, "Pause"), PlayerMessage::PlayPause => write!(f, "PlayPause"), PlayerMessage::Stop => write!(f, "Stop"), - PlayerMessage::ResetAfterStop => write!(f, "ResetAfterStop"), + PlayerMessage::ResetAfterStop(_) => write!(f, "ResetAfterStop"), + PlayerMessage::CompletePlaybackExit { .. } => { + write!(f, "CompletePlaybackExit") + } + PlayerMessage::RestoreShellAfterRootShutdown { .. } => { + write!(f, "RestoreShellAfterRootShutdown") + } + PlayerMessage::RootShutdownCompleted { .. } => { + write!(f, "RootShutdownCompleted") + } // Seeking PlayerMessage::Seek(pos) => write!(f, "Seek({})", pos), @@ -168,8 +291,8 @@ impl fmt::Debug for PlayerMessage { PlayerMessage::PreviousEpisode => write!(f, "PreviousEpisode"), // Video events - PlayerMessage::VideoLoaded(success) => { - write!(f, "VideoLoaded({})", success) + PlayerMessage::VideoLoaded { request, success } => { + write!(f, "VideoLoaded({request:?}, {success})") } PlayerMessage::VideoReadyToPlay => write!(f, "VideoReadyToPlay"), PlayerMessage::EndOfStream => write!(f, "EndOfStream"), @@ -199,11 +322,43 @@ impl fmt::Debug for PlayerMessage { // External player control PlayerMessage::PlayExternal => write!(f, "PlayExternal"), + PlayerMessage::ResumeExternalPlaybackAfterRootShutdown { request } => { + write!(f, "ResumeExternalPlaybackAfterRootShutdown({request:?})") + } + PlayerMessage::OpenExternalStreamSource { request } => { + write!(f, "OpenExternalStreamSource({request:?})") + } + PlayerMessage::ResumeInternalPlaybackAfterExternalLaunchFailure { + request, + } => { + write!( + f, + "ResumeInternalPlaybackAfterExternalLaunchFailure({request:?})" + ) + } PlayerMessage::SetStreamSource(_) => { write!(f, "SetStreamSource()") } - PlayerMessage::StreamUrlResolutionFailed(_) => { - write!(f, "StreamUrlResolutionFailed()") + PlayerMessage::StreamSourceResolved { request, .. } => { + write!(f, "StreamSourceResolved({request:?}, )") + } + PlayerMessage::OpenResolvedStreamSource { request } => { + write!(f, "OpenResolvedStreamSource({request:?})") + } + PlayerMessage::ResumeResolvedStreamSourceAfterRootShutdown { + request, + .. + } => { + write!( + f, + "ResumeResolvedStreamSourceAfterRootShutdown({request:?})" + ) + } + PlayerMessage::RootShutdownFailed { request, .. } => { + write!(f, "RootShutdownFailed({request:?}, )") + } + PlayerMessage::StreamUrlResolutionFailed { request, .. } => { + write!(f, "StreamUrlResolutionFailed({request:?}, )") } // UI control @@ -214,6 +369,9 @@ impl fmt::Debug for PlayerMessage { PlayerMessage::MouseMoved(point) => { write!(f, "MouseMoved({:?})", point) } + PlayerMessage::BeginNativeRootDrag => { + write!(f, "BeginNativeRootDrag") + } PlayerMessage::VideoClicked => write!(f, "VideoClicked"), PlayerMessage::VideoDoubleClicked => { write!(f, "VideoDoubleClicked") @@ -263,8 +421,8 @@ impl fmt::Debug for PlayerMessage { PlayerMessage::CheckControlsVisibility => { write!(f, "CheckControlsVisibility") } - PlayerMessage::ExternalPlaybackStarted => { - write!(f, "ExternalPlaybackStarted") + PlayerMessage::ExternalPlaybackStarted { request } => { + write!(f, "ExternalPlaybackStarted({request:?})") } PlayerMessage::ProgressHeartbeat => write!(f, "ProgressHeartbeat"), PlayerMessage::ExternalPlaybackUpdate { position, duration } => { @@ -274,8 +432,8 @@ impl fmt::Debug for PlayerMessage { position, duration ) } - PlayerMessage::ExternalPlaybackEnded => { - write!(f, "ExternalPlaybackEnded") + PlayerMessage::ExternalPlaybackEnded { request } => { + write!(f, "ExternalPlaybackEnded({request:?})") } PlayerMessage::PollExternalMpv => write!(f, "PollExternalMpv"), } diff --git a/crates/ferrex-player-playback/src/messages/subscriptions.rs b/crates/ferrex-player-playback/src/messages/subscriptions.rs index b455aab3..4c4966e4 100644 --- a/crates/ferrex-player-playback/src/messages/subscriptions.rs +++ b/crates/ferrex-player-playback/src/messages/subscriptions.rs @@ -17,6 +17,9 @@ pub struct PlaybackSubscriptionState { /// Any backend snapshot currently represents an active lifecycle. pub has_active_playback: bool, pub playback_target: Option, + /// Integrated presentation or a pending native-window fallback proof + /// still needs UI-thread AppKit/Win32 refresh turns. + pub native_presenter_refresh_required: bool, pub controls_visible: bool, pub event_signal: Option, pub is_playing: bool, @@ -70,7 +73,7 @@ pub fn subscription( // platform relationship while the integrated session is active. if state.has_internal_video && state.has_active_playback - && state.playback_target == Some(PlaybackTarget::MPV_INTEGRATED) + && state.native_presenter_refresh_required { subs.push( iced::time::every(std::time::Duration::from_millis(16)) diff --git a/crates/ferrex-player-playback/src/mpv_adapter.rs b/crates/ferrex-player-playback/src/mpv_adapter.rs index f5aebaa1..9c6761cf 100644 --- a/crates/ferrex-player-playback/src/mpv_adapter.rs +++ b/crates/ferrex-player-playback/src/mpv_adapter.rs @@ -18,6 +18,8 @@ use ferrex_player_mpv::{ }; use zeroize::Zeroizing; +#[cfg(target_os = "macos")] +use crate::session::PlaybackShutdownBarrier; use crate::{ contract::{ AudioTrack, BackendKind, BackendRequest, BufferState, Chapter, @@ -207,6 +209,8 @@ pub(crate) struct MpvPlaybackAdapter { native_output_epoch: u64, native_window_observation_revision: u64, native_window_id_refresh: Option, + #[cfg(target_os = "macos")] + native_root_withdrawn: bool, } impl std::fmt::Debug for MpvPlaybackAdapter { @@ -350,6 +354,8 @@ impl MpvPlaybackAdapter { native_output_epoch: 0, native_window_observation_revision: 0, native_window_id_refresh: None, + #[cfg(target_os = "macos")] + native_root_withdrawn: false, }; adapter.register_observations()?; adapter.submit_load(source, start)?; @@ -480,7 +486,7 @@ impl MpvPlaybackAdapter { feature = "ui", any(target_os = "windows", target_os = "macos") ))] - pub(crate) fn commit_native_window_fallback( + pub(crate) fn begin_native_window_fallback( &mut self, reason: crate::contract::FallbackReason, ) { @@ -1213,26 +1219,23 @@ impl MpvPlaybackAdapter { } fn shutdown(&mut self) -> Result<(), PlaybackError> { - self.absolute_seeks.clear(); - let Some(worker) = self.worker.take() else { - return Ok(()); - }; - - // mpv's macOS VO synchronously dispatches parts of teardown to the - // AppKit main queue. PlaybackSession::shutdown is called from Iced's - // AppKit callback, so waiting for the owner here can deadlock both - // threads. Move the worker to a named reaper and return immediately; - // the event loop can then service the native teardown dispatch. #[cfg(target_os = "macos")] { - let result = reap_macos_worker(worker); - self.mapper.terminal = true; - self.record(PlaybackEvent::StateChanged(PlaybackState::Terminated)); - return result; + // Callers that can reveal another application window retain this + // completion through `PlaybackSession::begin_shutdown_barrier`. + // Drop-only paths still start the same ordered teardown but have + // no visibility transition to gate. + let _ = self.begin_shutdown_barrier()?; + return Ok(()); } #[cfg(not(target_os = "macos"))] { + self.absolute_seeks.clear(); + let Some(worker) = self.worker.take() else { + return Ok(()); + }; + let mut worker = worker; let report = worker.shutdown().map_err(|error| { worker_error( @@ -1253,6 +1256,56 @@ impl MpvPlaybackAdapter { } } + /// Begin AppKit-safe owner teardown and return the positive completion + /// barrier required before a retained shell or replacement root is shown. + #[cfg(target_os = "macos")] + pub(crate) fn begin_shutdown_barrier( + &mut self, + ) -> Result, PlaybackError> { + self.absolute_seeks.clear(); + self.poll_events(); + if let Err(error) = self.withdraw_native_root_for_shutdown() { + // Teardown completion remains authoritative even when the + // best-effort early orderOut barrier fails. + log::warn!( + "mpv native root could not be withdrawn before owner teardown: {error}" + ); + } + let Some(worker) = self.worker.take() else { + return Ok(None); + }; + + // mpv's macOS VO synchronously dispatches parts of teardown to the + // AppKit main queue. The reaper owns the blocking wait while the + // returned receiver keeps shell restoration fail-closed until the + // native owner reports completion. + let barrier = reap_macos_worker(worker)?; + self.mapper.terminal = true; + self.record(PlaybackEvent::StateChanged(PlaybackState::Terminated)); + Ok(Some(barrier)) + } + + /// Establish a synchronous no-longer-visible barrier for mpv's AppKit + /// native root while leaving actual libmpv destruction to the reaper. + #[cfg(target_os = "macos")] + pub(crate) fn withdraw_native_root_for_shutdown( + &mut self, + ) -> Result<(), PlaybackError> { + if self.native_root_withdrawn { + return Ok(()); + } + let Some(native_window_id) = self.mapper.native_window_id else { + // No native root was ever observed, so none can overlap a shell + // restore or replacement root at this observation. Do not mark + // the barrier complete: apply_command/shutdown drains again so a + // just-arrived ID still gets withdrawn before worker handoff. + return Ok(()); + }; + crate::macos_presenter::withdraw_mpv_root_window(native_window_id)?; + self.native_root_withdrawn = true; + Ok(()) + } + fn begin_startup_diagnostics(&mut self) -> Result<(), PlaybackError> { if !self.logging_policy.startup_verbose_capture { return Ok(()); @@ -1327,7 +1380,11 @@ impl MpvPlaybackAdapter { /// closure on the caller, so capturing the worker directly would invoke its /// blocking `Drop` exactly where it is unsafe. #[cfg(target_os = "macos")] -fn reap_macos_worker(worker: MpvWorker) -> Result<(), PlaybackError> { +fn reap_macos_worker( + worker: MpvWorker, +) -> Result { + let (completion_sender, completion_receiver) = + tokio::sync::oneshot::channel(); let worker = Arc::new(Mutex::new(Some(worker))); let reaper_worker = Arc::clone(&worker); let spawn = std::thread::Builder::new() @@ -1338,21 +1395,27 @@ fn reap_macos_worker(worker: MpvWorker) -> Result<(), PlaybackError> { .unwrap_or_else(std::sync::PoisonError::into_inner) .take() .expect("macOS mpv reaper owns one worker"); - match worker.shutdown() { + let completion = match worker.shutdown() { Ok(report) if report.timed_out => { log::warn!( "libmpv macOS stop drain reached its shutdown deadline" ); + Ok(()) } Ok(_) => { log::debug!("libmpv macOS reaper completed native teardown"); + Ok(()) } Err(error) => { log::error!( "libmpv macOS reaper could not complete ordered shutdown: {error}" ); + Err(format!( + "native playback teardown did not complete: {error}" + )) } - } + }; + let _ = completion_sender.send(completion); }); if let Err(error) = spawn { @@ -1380,7 +1443,7 @@ fn reap_macos_worker(worker: MpvWorker) -> Result<(), PlaybackError> { return Err(mpv_error(PlaybackErrorKind::Shutdown, detail, true)); } - Ok(()) + Ok(PlaybackShutdownBarrier::new(completion_receiver)) } impl Drop for MpvPlaybackAdapter { diff --git a/crates/ferrex-player-playback/src/native_presentation.rs b/crates/ferrex-player-playback/src/native_presentation.rs index 6922eaee..a6ee9545 100644 --- a/crates/ferrex-player-playback/src/native_presentation.rs +++ b/crates/ferrex-player-playback/src/native_presentation.rs @@ -49,6 +49,10 @@ trait PlatformPresenterDriver { command: PresenterCommand, host: Option<&CapturedIcedHost>, ) -> Result<(), PlaybackError>; + + fn begin_native_root_drag(&mut self) -> Result { + Ok(false) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -141,9 +145,10 @@ impl BridgeInner { } /// Re-query the platform-owned video root even when Iced's logical slot - /// geometry has not changed. Win32 owned windows do not follow their owner - /// and AppKit child windows still need occlusion/Space refreshes, so this - /// deliberately bypasses lifecycle geometry-revision deduplication. + /// geometry has not changed. Win32 owned windows do not follow their owner, + /// while the AppKit in-root view must still detect root-content replacement + /// and active-Space/occlusion transitions. This deliberately bypasses + /// lifecycle geometry-revision deduplication. fn refresh_platform_window( &mut self, host: Option<&CapturedIcedHost>, @@ -448,8 +453,9 @@ impl NativePresentation { )) } - /// Conservative macOS gate. This remains explicit-spike-only until the - /// representative Spaces/fullscreen/scale/teardown matrix is signed off. + /// Conservative macOS gate. Enabled candidates still require the external + /// representative Spaces/fullscreen/scale/HDR/teardown matrix before their + /// exact archives may be promoted. #[cfg(target_os = "macos")] pub(crate) fn try_new( generation: SessionGeneration, @@ -513,10 +519,10 @@ impl NativePresentation { previous_window == Some(window_id), ); } - if let Some(old) = self.slot.borrow_mut().take() { - if !old.is_detached() { - let _ = old.detach(); - } + if let Some(old) = self.slot.borrow_mut().take() + && !old.is_detached() + { + let _ = old.detach(); } if replacing_slot { self.advance_generation(); @@ -734,6 +740,18 @@ impl NativePresentation { self.inner.borrow_mut().refresh_platform_window(None); } + #[cfg(target_os = "macos")] + pub(crate) fn begin_native_root_drag(&self) -> Result { + if self.confirmed_fullscreen.get() == Some(true) { + return Ok(false); + } + let mut inner = self.inner.borrow_mut(); + let Some(driver) = inner.driver.as_mut() else { + return Ok(false); + }; + driver.begin_native_root_drag() + } + pub(crate) fn take_fullscreen_request(&self) -> Option { self.fullscreen_request.take() } @@ -969,6 +987,10 @@ impl PlatformPresenterDriver for MacOsPresenterDriver { } } } + + fn begin_native_root_drag(&mut self) -> Result { + self.presenter.begin_window_drag() + } } fn presenter_input_label(input: &PresenterInput) -> &'static str { @@ -1179,6 +1201,18 @@ mod tests { PresenterEvent::FallbackRequested(reason) if reason.to == PlaybackTarget::MPV_NATIVE_WINDOW ))); + + let retired = bridge.handle( + None, + PresenterInputEnvelope::new(identity, PresenterInput::Detach), + ); + assert!(retired.requests_snapshot_sync()); + assert_eq!(bridge.lifecycle.state(), PresenterState::Detached); + assert_eq!(bridge.lifecycle.readiness(), (false, false, false)); + assert!(bridge.pending_events.iter().any(|event| matches!( + event, + PresenterEvent::StateChanged(PresenterState::Detached) + ))); } #[test] diff --git a/crates/ferrex-player-playback/src/presenter.rs b/crates/ferrex-player-playback/src/presenter.rs index 33e99e67..2e773044 100644 --- a/crates/ferrex-player-playback/src/presenter.rs +++ b/crates/ferrex-player-playback/src/presenter.rs @@ -310,10 +310,13 @@ impl PresenterLifecycle { self.requested_fullscreen } - #[cfg(all( - feature = "mpv", - feature = "ui", - any(target_os = "windows", target_os = "macos", test) + #[cfg(any( + test, + all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos") + ) ))] pub(crate) const fn readiness(&self) -> (bool, bool, bool) { (self.host_ready, self.video_output_ready, self.attached) @@ -383,7 +386,11 @@ impl PresenterLifecycle { Vec::new(), ); } - if self.failed { + // A failed integrated presenter still has one valid terminal input: + // Detach retires its projection before the session can publish a + // verified native-window fallback. All other inputs remain inert so a + // failed generation cannot be revived accidentally. + if self.failed && !matches!(&envelope.input, PresenterInput::Detach) { return PresenterTransition::new( PresenterDisposition::IgnoredNoChange, Vec::new(), @@ -1377,6 +1384,16 @@ mod tests { && reason.from == Some(PlaybackTarget::MPV_INTEGRATED) && reason.to == PlaybackTarget::MPV_NATIVE_WINDOW ))); + + let retired = input(&mut lifecycle, PresenterInput::Detach); + assert_eq!(lifecycle.state(), PresenterState::Detached); + assert_eq!(lifecycle.readiness(), (false, false, false)); + assert!(retired.effects.iter().any(|effect| matches!( + effect, + PresenterEffect::Event(PresenterEvent::StateChanged( + PresenterState::Detached + )) + ))); } struct LocalHost(Rc<()>); diff --git a/crates/ferrex-player-playback/src/session.rs b/crates/ferrex-player-playback/src/session.rs index bca38010..8fb2fbc6 100644 --- a/crates/ferrex-player-playback/src/session.rs +++ b/crates/ferrex-player-playback/src/session.rs @@ -42,6 +42,40 @@ pub struct PlaybackSession { backend: BackendSession, } +/// Completion gate for playback-root teardown that must finish before another +/// top-level application window may be revealed. +pub(crate) struct PlaybackShutdownBarrier { + completion: tokio::sync::oneshot::Receiver>, +} + +impl std::fmt::Debug for PlaybackShutdownBarrier { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PlaybackShutdownBarrier") + .finish_non_exhaustive() + } +} + +impl PlaybackShutdownBarrier { + pub(crate) fn new( + completion: tokio::sync::oneshot::Receiver>, + ) -> Self { + Self { completion } + } + + pub(crate) fn failed(message: String) -> Self { + let (sender, completion) = tokio::sync::oneshot::channel(); + let _ = sender.send(Err(message)); + Self { completion } + } + + pub(crate) async fn wait(self) -> Result<(), String> { + self.completion.await.unwrap_or_else(|_| { + Err("playback-root teardown completion was lost".to_string()) + }) + } +} + enum BackendSession { Subwave(Box), #[cfg(feature = "mpv")] @@ -76,6 +110,38 @@ struct MpvBackendSession { any(target_os = "windows", target_os = "macos") ))] presentation: Option, + #[cfg(all(feature = "ui", target_os = "macos"))] + native_window_fallback_pending: bool, +} + +#[cfg(any( + all(test, feature = "mpv"), + all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos") + ) +))] +const fn native_presenter_refresh_required( + presentation_available: bool, + native_window_fallback_pending: bool, +) -> bool { + presentation_available || native_window_fallback_pending +} + +#[cfg(any( + all(test, feature = "mpv"), + all(feature = "mpv", feature = "ui", target_os = "macos") +))] +fn complete_verified_native_window_fallback( + pending: &mut bool, + verified: bool, +) -> bool { + if !*pending || !verified { + return false; + } + *pending = false; + true } #[cfg(feature = "mpv")] @@ -132,18 +198,57 @@ impl MpvBackendSession { any(target_os = "windows", target_os = "macos") ))] presentation, + #[cfg(all(feature = "ui", target_os = "macos"))] + native_window_fallback_pending: false, } } + #[cfg(target_os = "macos")] + fn begin_shutdown_barrier( + &mut self, + ) -> Result, PlaybackError> { + // Drain a just-arrived window identity, detach the in-root controls, + // and then begin owner teardown. The returned completion is the only + // proof that a root whose identity arrived late can no longer appear. + self.adapter.poll_events(); + #[cfg(feature = "ui")] + if let Some(presentation) = self.presentation.as_ref() { + presentation.detach(); + } + self.adapter.begin_shutdown_barrier() + } + fn apply_command( &mut self, command: PlaybackCommand, ) -> Result<(), PlaybackError> { + #[cfg(all(feature = "ui", target_os = "macos"))] + if self.native_window_fallback_pending + && matches!( + &command, + PlaybackCommand::Load(_) + | PlaybackCommand::Stop + | PlaybackCommand::Shutdown + ) + { + // A pending proof belongs only to the still-live failed presenter + // session. A stop, replacement, or shutdown cannot be credited by + // a later unrelated mpv root. + self.native_window_fallback_pending = false; + } + #[cfg(all( feature = "ui", any(target_os = "windows", target_os = "macos") ))] { + if matches!(&command, PlaybackCommand::Shutdown) { + // Drain a just-arrived native window-id before deciding + // whether an AppKit root must be withdrawn. + self.adapter.poll_events(); + #[cfg(target_os = "macos")] + self.adapter.withdraw_native_root_for_shutdown()?; + } if let PlaybackCommand::SetFullscreen(fullscreen) = &command && self.presentation.is_some() && self.adapter.snapshot().target @@ -198,11 +303,46 @@ impl MpvBackendSession { self.drain_presenter_state() } + #[cfg(all(feature = "ui", target_os = "macos"))] + fn confirm_native_window_fallback_if_live(&mut self) { + if !self.native_window_fallback_pending + || !self.adapter.vo_configured() + || !self.adapter.native_video_output_started() + { + return; + } + let Some(native_window_id) = self.adapter.native_window_id() else { + return; + }; + match crate::macos_presenter::verify_mpv_native_fallback_window( + native_window_id, + ) { + Ok(verified) => { + complete_verified_native_window_fallback( + &mut self.native_window_fallback_pending, + verified, + ); + } + Ok(_) => {} + Err(error) => { + log::debug!( + "mpv native fallback root is not yet verifiable: {error}" + ); + } + } + } + #[cfg(all( feature = "ui", any(target_os = "windows", target_os = "macos") ))] fn drain_presenter_state(&mut self) -> Result<(), PlaybackError> { + // Verification can legitimately lag the failure event by one or more + // AppKit turns. Keep retrying the pending native root even after the + // failed integrated presentation has been retired. + #[cfg(target_os = "macos")] + self.confirm_native_window_fallback_if_live(); + let Some(presentation) = self.presentation.as_ref() else { return Ok(()); }; @@ -214,6 +354,8 @@ impl MpvBackendSession { let events = presentation.drain_events(); let mut fallback = false; + #[cfg(target_os = "macos")] + let mut native_window_fallback = false; for event in events { let fallback_reason = match &event { PresenterEvent::FallbackRequested(reason) => { @@ -225,7 +367,11 @@ impl MpvBackendSession { .record_event(crate::contract::PlaybackEvent::Presenter(event)); if let Some(reason) = fallback_reason { if reason.to == PlaybackTarget::MPV_NATIVE_WINDOW { - self.adapter.commit_native_window_fallback(reason); + self.adapter.begin_native_window_fallback(reason); + #[cfg(target_os = "macos")] + { + native_window_fallback = true; + } } else { self.adapter.record_event( crate::contract::PlaybackEvent::Fallback(reason), @@ -236,8 +382,23 @@ impl MpvBackendSession { } if fallback { presentation.detach(); + // Detach after failure is a terminal lifecycle transition. Drain + // it before dropping the presentation so the normal playback + // projection and macOS runtime evidence both record a fully + // retired integrated presenter. + for event in presentation.drain_events() { + self.adapter.record_event( + crate::contract::PlaybackEvent::Presenter(event), + ); + } self.presentation = None; + #[cfg(target_os = "macos")] + if native_window_fallback { + self.native_window_fallback_pending = true; + } } + #[cfg(target_os = "macos")] + self.confirm_native_window_fallback_if_live(); Ok(()) } @@ -421,6 +582,26 @@ impl PlaybackSession { } } + /// Begin backend shutdown and return a completion gate when native AppKit + /// teardown must yield to the main run loop. + pub(crate) fn begin_shutdown_barrier( + &mut self, + ) -> Result, PlaybackError> { + match &mut self.backend { + BackendSession::Subwave(adapter) => { + adapter.apply_command(PlaybackCommand::Shutdown)?; + Ok(None) + } + #[cfg(all(feature = "mpv", target_os = "macos"))] + BackendSession::Mpv(adapter) => adapter.begin_shutdown_barrier(), + #[cfg(all(feature = "mpv", not(target_os = "macos")))] + BackendSession::Mpv(adapter) => { + adapter.apply_command(PlaybackCommand::Shutdown)?; + Ok(None) + } + } + } + pub fn synchronize_snapshot(&mut self) { match &mut self.backend { BackendSession::Subwave(adapter) => { @@ -686,6 +867,58 @@ impl PlaybackSession { } } + /// Whether the UI must keep the native-presenter refresh clock alive. + /// + /// A verified native-window fallback can take one or more AppKit turns to + /// become visible. The pending proof therefore keeps the clock alive after + /// the integrated presentation itself has been retired. + pub fn native_presenter_refresh_required(&self) -> bool { + #[cfg(all( + feature = "mpv", + feature = "ui", + any(target_os = "windows", target_os = "macos") + ))] + if let BackendSession::Mpv(adapter) = &self.backend { + #[cfg(target_os = "macos")] + let fallback_pending = adapter.native_window_fallback_pending; + #[cfg(target_os = "windows")] + let fallback_pending = false; + return native_presenter_refresh_required( + adapter.presentation.is_some(), + fallback_pending, + ); + } + + false + } + + /// Begin an AppKit-managed drag on the active integrated mpv root. + /// + /// The operation is synchronous because AppKit requires the current + /// mouse-down event. Missing, fallback, or not-yet-attached presenters are + /// benign no-ops. + pub fn begin_native_root_drag(&mut self) -> bool { + #[cfg(all(feature = "mpv", feature = "ui", target_os = "macos"))] + if let BackendSession::Mpv(adapter) = &mut self.backend + && adapter.snapshot().target + == crate::contract::PlaybackTarget::MPV_INTEGRATED + && !adapter.snapshot().fullscreen + && let Some(presentation) = adapter.presentation.as_ref() + { + return match presentation.begin_native_root_drag() { + Ok(started) => started, + Err(error) => { + log::warn!( + "Could not begin native mpv root window drag: {error}" + ); + false + } + }; + } + + false + } + /// Complete the shell-controlled visibility handoff for an integrated /// native presenter. The presenter attaches while hidden; the shell calls /// this only after its retained main window has been hidden. @@ -802,4 +1035,20 @@ mod tests { crate::contract::FallbackReasonCode::UnsupportedPlatform ); } + + #[test] + fn transient_native_fallback_observation_keeps_refresh_alive() { + let mut pending = true; + assert!(native_presenter_refresh_required(false, pending)); + assert!(!complete_verified_native_window_fallback( + &mut pending, + false + )); + assert!(pending); + assert!(native_presenter_refresh_required(false, pending)); + + assert!(complete_verified_native_window_fallback(&mut pending, true)); + assert!(!pending); + assert!(!native_presenter_refresh_required(false, pending)); + } } diff --git a/crates/ferrex-player-playback/src/state.rs b/crates/ferrex-player-playback/src/state.rs index 195f31d2..14b9df64 100644 --- a/crates/ferrex-player-playback/src/state.rs +++ b/crates/ferrex-player-playback/src/state.rs @@ -7,11 +7,11 @@ use crate::{ contract::{ AudioTrack, BackendKind, BackendRequest, EndReason, - PlaybackCapabilities, PlaybackCommand, PlaybackSnapshot, - PlaybackSource, PlaybackState, PlaybackTarget, SessionGeneration, - SubtitleTrack, TrackId, + PlaybackCapabilities, PlaybackSnapshot, PlaybackSource, PlaybackState, + PlaybackTarget, SessionGeneration, SubtitleTrack, TrackId, }, diagnostics::{PlaybackDiagnosticSnapshot, redact_playback_url}, + messages::{PlaybackExitDestination, PlaybackRequestId}, session::PlaybackSession, }; use ferrex_core::player_prelude::{MediaFile, MediaID}; @@ -39,6 +39,48 @@ pub struct PlayerDomainState { pub current_source: Option, pub is_resolving_stream_url: bool, pub stream_url_resolution_failed: bool, + /// Last allocated source-request identity. This counter is intentionally + /// preserved by [`Self::reset`] so a delayed completion can never match a + /// later playback request. + pub playback_request_counter: PlaybackRequestId, + /// Latest media/source request allowed to mutate playback state. + pub active_playback_request: Option, + /// Active request whose authenticated source has been accepted. + pub resolved_playback_request: Option, + /// Request that owns the currently open in-process backend session. + pub session_playback_request: Option, + /// Media identity owned by the currently open in-process backend session. + /// + /// A replacement request may update `current_media_id` while the previous + /// session remains visible during authorization. Terminal progress must + /// remain attributed to the session that produced the snapshot. + pub session_media_id: Option, + /// Request that entered the shell's integrated single-window handoff. + /// + /// This provenance survives an in-session mpv-to-embedded fallback so the + /// shell can restore itself even if the replacement snapshot has no mpv + /// fallback chain of its own. + pub integrated_playback_request: Option, + /// A playback root owner is being destroyed away from the UI reducer. No shell + /// restoration or replacement backend may begin while this is true. + pub root_shutdown_in_progress: bool, + /// Root owner destruction failed without positive absence proof. This + /// latch blocks every later backend launch until the process is restarted. + pub root_shutdown_failed: bool, + /// Request that owned the hidden retained shell when native teardown + /// started. Completion uses this to retire or transfer shell ownership. + pub root_shutdown_retired_request: Option, + /// A user exit received while teardown is already in flight. It overrides + /// an older replacement/fallback continuation once completion arrives. + pub root_shutdown_exit_destination: Option, + /// Current request that explicitly selected the external-process backend. + /// This intent is allocated atomically with the source request. + pub external_playback_intent_request: Option, + /// Request that owns the live external process and its snapshot. + pub external_playback_request: Option, + /// Media identity owned by the live external process. A newer request may + /// replace `current_media_id` while the old process is still shutting down. + pub external_media_id: Option, // Video instance (unified) pub video_opt: Option, @@ -152,6 +194,31 @@ impl fmt::Debug for PlayerDomainState { "stream_url_resolution_failed", &self.stream_url_resolution_failed, ) + .field("playback_request_counter", &self.playback_request_counter) + .field("active_playback_request", &self.active_playback_request) + .field("resolved_playback_request", &self.resolved_playback_request) + .field("session_playback_request", &self.session_playback_request) + .field("session_media_id", &self.session_media_id) + .field( + "integrated_playback_request", + &self.integrated_playback_request, + ) + .field("root_shutdown_in_progress", &self.root_shutdown_in_progress) + .field("root_shutdown_failed", &self.root_shutdown_failed) + .field( + "root_shutdown_retired_request", + &self.root_shutdown_retired_request, + ) + .field( + "root_shutdown_exit_destination", + &self.root_shutdown_exit_destination, + ) + .field( + "external_playback_intent_request", + &self.external_playback_intent_request, + ) + .field("external_playback_request", &self.external_playback_request) + .field("external_media_id", &self.external_media_id) .field("video_opt", &video_opt) .field("playback_generation", &self.playback_generation) .field("backend_request", &self.backend_request) @@ -230,6 +297,19 @@ impl Default for PlayerDomainState { current_source: None, is_resolving_stream_url: false, stream_url_resolution_failed: false, + playback_request_counter: PlaybackRequestId::INITIAL, + active_playback_request: None, + resolved_playback_request: None, + session_playback_request: None, + session_media_id: None, + integrated_playback_request: None, + root_shutdown_in_progress: false, + root_shutdown_failed: false, + root_shutdown_retired_request: None, + root_shutdown_exit_destination: None, + external_playback_intent_request: None, + external_playback_request: None, + external_media_id: None, video_opt: None, playback_generation: SessionGeneration::new(0), backend_request: BackendRequest::Auto, @@ -291,6 +371,16 @@ impl Default for PlayerDomainState { )] impl PlayerDomainState { pub fn reset(&mut self) { + // Preserve the monotonic counter while retiring every request-scoped + // capability. Delayed async messages are rejected by identity. + self.active_playback_request = None; + self.resolved_playback_request = None; + self.session_playback_request = None; + self.session_media_id = None; + self.integrated_playback_request = None; + self.external_playback_intent_request = None; + self.external_playback_request = None; + self.external_media_id = None; self.current_media = None; self.current_media_id = None; self.current_url = None; @@ -328,6 +418,94 @@ impl PlayerDomainState { self.clear_external_playback(); } + /// Allocate the next source request and invalidate every earlier async + /// source completion. Exhaustion fails closed instead of reusing an ID. + pub fn begin_playback_request(&mut self) -> Option { + let next = self.playback_request_counter.next()?; + self.playback_request_counter = next; + self.active_playback_request = Some(next); + self.resolved_playback_request = None; + self.integrated_playback_request = None; + self.external_playback_intent_request = None; + Some(next) + } + + /// Retire the current source request without resetting the monotonic + /// counter or an already-open older session. + pub fn invalidate_playback_request(&mut self) { + self.active_playback_request = None; + self.resolved_playback_request = None; + self.external_playback_intent_request = None; + self.is_resolving_stream_url = false; + } + + /// Whether an async result still belongs to the newest media request. + pub fn is_active_playback_request( + &self, + request: PlaybackRequestId, + ) -> bool { + self.active_playback_request == Some(request) + } + + /// Mark one current request's authenticated source as accepted. + pub fn resolve_playback_request( + &mut self, + request: PlaybackRequestId, + ) -> bool { + if !self.is_active_playback_request(request) { + return false; + } + self.resolved_playback_request = Some(request); + true + } + + /// Whether a shell/backend continuation is still authorized. + pub fn is_resolved_playback_request( + &self, + request: PlaybackRequestId, + ) -> bool { + self.active_playback_request == Some(request) + && self.resolved_playback_request == Some(request) + } + + /// Whether native teardown still lacks positive completion proof. + pub fn root_shutdown_blocks_launch(&self) -> bool { + self.root_shutdown_in_progress || self.root_shutdown_failed + } + + /// Whether this request still owns an explicit external-process launch. + pub fn is_external_playback_intent( + &self, + request: PlaybackRequestId, + ) -> bool { + self.active_playback_request == Some(request) + && self.external_playback_intent_request == Some(request) + } + + /// Bind the current request to the external-process launch path. + pub fn request_external_playback( + &mut self, + request: PlaybackRequestId, + ) -> bool { + if self.active_playback_request != Some(request) { + return false; + } + self.external_playback_intent_request = Some(request); + true + } + + /// Request that owns the visible backend or, before backend creation, the + /// resolved single-window handoff. + pub fn playback_handoff_request(&self) -> Option { + match self.session_playback_request { + Some(request) => Some(request), + None => match self.external_playback_request { + Some(request) => Some(request), + None => self.resolved_playback_request, + }, + } + } + /// Replace the current in-process source and keep the compatibility URI /// synchronized for code that does not yet understand authenticated /// headers. @@ -350,6 +528,16 @@ impl PlayerDomainState { .or(self.external_mpv_snapshot.as_ref()) } + /// Whether an existing or not-yet-proven-absent root still owns the + /// backend-neutral progress projection. + /// + /// UI request routing may stage a replacement resume hint while this is + /// true, but must not overwrite `last_valid_*`: terminal actions still + /// attribute those values to the visible session's media. + pub fn has_observable_playback_root(&self) -> bool { + self.playback_snapshot().is_some() || self.root_shutdown_blocks_launch() + } + /// Whether an in-process backend currently owns a presentation session. pub fn has_internal_session(&self) -> bool { self.video_opt.is_some() @@ -399,6 +587,8 @@ impl PlayerDomainState { /// Start the reduced external-player lifecycle after process creation. pub fn begin_external_playback( &mut self, + request: PlaybackRequestId, + media_id: Option, generation: SessionGeneration, position: f64, duration: f64, @@ -421,6 +611,8 @@ impl PlayerDomainState { .filter(|duration| *duration > Duration::ZERO); snapshot.fullscreen = fullscreen; self.external_mpv_snapshot = Some(snapshot); + self.external_playback_request = Some(request); + self.external_media_id = media_id; } /// Mark the spawned process ready without exposing its native handle. @@ -473,8 +665,29 @@ impl PlayerDomainState { /// Drop the external process owner and its reduced lifecycle together. pub fn clear_external_playback(&mut self) { + debug_assert!( + self.external_mpv_handle.is_none(), + "live external process must pass through the root shutdown barrier" + ); self.external_mpv_handle = None; self.external_mpv_snapshot = None; + self.external_playback_request = None; + self.external_media_id = None; + } + + /// Retire a positively absent external root only when the completion still + /// belongs to that process owner. + pub fn clear_external_playback_for_request( + &mut self, + request: Option, + ) -> bool { + if self.external_mpv_handle.is_some() + || (request.is_some() && self.external_playback_request != request) + { + return false; + } + self.clear_external_playback(); + true } pub fn playback_diagnostics(&self) -> Option { @@ -528,19 +741,6 @@ impl PlayerDomainState { self.track_notification = None; } } - - /// Stop native/internal playback and release the video handle without resetting all state - pub fn stop_native_playback(&mut self) { - if let Some(mut video) = self.video_opt.take() { - let _ = video.apply_command(PlaybackCommand::Stop); - drop(video); - } - self.seeking = false; - self.dragging = false; - self.last_seek_position = None; - self.pending_seek_position = None; - self.last_seek_time = None; - } } fn valid_external_duration(seconds: f64) -> Option { @@ -589,8 +789,11 @@ mod tests { fn external_process_lifecycle_reduces_into_the_neutral_snapshot() { let mut state = PlayerDomainState::default(); let generation = SessionGeneration::new(9); + let request = PlaybackRequestId::new(3); state.begin_external_playback( + request, + None, generation, f64::NAN, f64::INFINITY, @@ -640,6 +843,8 @@ mod tests { fn reset_clears_external_snapshot_ownership() { let mut state = PlayerDomainState::default(); state.begin_external_playback( + PlaybackRequestId::new(4), + None, SessionGeneration::new(4), 3.0, 20.0, diff --git a/crates/ferrex-player-playback/src/update.rs b/crates/ferrex-player-playback/src/update.rs index eccc8ba0..5fa904d8 100644 --- a/crates/ferrex-player-playback/src/update.rs +++ b/crates/ferrex-player-playback/src/update.rs @@ -10,7 +10,8 @@ use crate::{ PlaybackContentFit, PlaybackSnapshot, PlaybackSource, PlaybackState, }, diagnostics::redact_playback_url, - messages::PlayerMessage, + messages::{PlaybackExitDestination, PlaybackRequestId, PlayerMessage}, + session::PlaybackShutdownBarrier, state::PlayerDomainState, video::{close_video, load_video, media_file_metadata_indicates_hdr}, }; @@ -38,17 +39,40 @@ pub enum PlaybackStartMode { pub enum PlaybackWindowEvent { SetWindowMode(Mode), RestoreWindow(bool), + /// A resolved integrated source is ready. The shell must hide the retained + /// main window and only then continue with backend creation. + BeginIntegratedPlayback { + request: PlaybackRequestId, + }, + /// A resolved external-process source is ready. The shell must transfer + /// request-scoped hide ownership, close any integrated donor without + /// restoration, hide the retained shell, and only then spawn the process. + BeginExternalPlayback { + request: PlaybackRequestId, + }, + /// External process creation failed after the shell handoff. The shell must + /// restore the matching retained owner before internal fallback begins. + ExternalPlaybackLaunchFailed { + request: PlaybackRequestId, + }, /// A platform presenter has attached the hidden controls host and it is /// now safe for the shell to perform the single-visible-window handoff. - NativePresenterAttached, + NativePresenterAttached { + request: PlaybackRequestId, + }, /// Integrated presentation is unavailable or failed. Dismiss the hidden /// controls host while the selected native-window fallback keeps playing. - NativePresenterUnavailable, + NativePresenterUnavailable { + request: PlaybackRequestId, + effective_target: crate::contract::PlaybackTarget, + }, /// Playback has fully exited. App shells use this idempotent signal to /// dismiss any dedicated controls host and restore the retained main /// window without coupling the playback reducer to a concrete window /// manager. - PlaybackExited, + PlaybackExited { + request: Option, + }, } /// UI/view side effects required by video playback without depending on the final app facade. @@ -102,9 +126,106 @@ pub struct PlaybackUpdateContext<'a> { pub window_position: Option, } +fn integrated_playback_requested(state: &PlayerDomainState) -> bool { + state.backend_request + == BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ) +} + +/// Move a live external process into its kill-and-wait reaper and latch all +/// replacement root launches until positive process absence is reported. +fn close_external_playback_root( + state: &mut PlayerDomainState, +) -> Option { + let handle = state.external_mpv_handle.take()?; + let retired_request = state + .external_playback_request + .or(state.external_playback_intent_request); + let barrier = handle.begin_shutdown_barrier(); + state.root_shutdown_in_progress = true; + state.root_shutdown_failed = false; + state.root_shutdown_retired_request = retired_request; + state.root_shutdown_exit_destination = None; + Some(barrier) +} + +/// Withdraw the single live playback root. In-process adapters either complete +/// synchronously or return their native-owner barrier; external processes +/// always return a kill-and-wait barrier. +fn close_playback_root( + state: &mut PlayerDomainState, +) -> Option { + if state.video_opt.is_some() { + debug_assert!( + state.external_mpv_handle.is_none(), + "in-process and external roots must never overlap" + ); + close_video(state) + } else { + close_external_playback_root(state) + } +} + +/// Retire pending source work and withdraw the active root before the shell may +/// reveal another top-level window. +fn begin_playback_exit( + state: &mut PlayerDomainState, + destination: PlaybackExitDestination, +) -> ( + Option, + Option, + bool, +) { + let awaiting_existing_shutdown = state.root_shutdown_blocks_launch(); + let request = state + .playback_handoff_request() + .or(state.active_playback_request) + .or(state.root_shutdown_retired_request); + if awaiting_existing_shutdown { + state.root_shutdown_exit_destination = Some(destination); + } + state.invalidate_playback_request(); + let shutdown = close_playback_root(state); + (request, shutdown, awaiting_existing_shutdown) +} + +fn message_after_root_shutdown

( + shutdown: Option, + request: Option, + continuation: PlayerMessage, +) -> Task +where + P: PlaybackUpdatePort + 'static, +{ + let Some(shutdown) = shutdown else { + return Task::done(P::playback_message(continuation)); + }; + Task::perform(shutdown.wait(), move |result| { + P::playback_message(match result { + Ok(()) => PlayerMessage::RootShutdownCompleted { + request, + continuation: Box::new(continuation.clone()), + }, + Err(message) => { + PlayerMessage::RootShutdownFailed { request, message } + } + }) + }) +} + +fn reset_after_stop_message(state: &PlayerDomainState) -> PlayerMessage { + PlayerMessage::ResetAfterStop( + state + .playback_handoff_request() + .or(state.active_playback_request), + ) +} + fn take_native_presenter_window_event( state: &mut PlayerDomainState, ) -> Option { + let request = state.session_playback_request?; let (generation, event) = { let snapshot = state.playback_snapshot()?; let event = if snapshot.target @@ -114,38 +235,49 @@ fn take_native_presenter_window_event( crate::contract::PresenterState::Attached | crate::contract::PresenterState::Hidden | crate::contract::PresenterState::Suspended => { - Some(PlaybackWindowEvent::NativePresenterAttached) + Some(PlaybackWindowEvent::NativePresenterAttached { + request, + }) } crate::contract::PresenterState::Failed => { - Some(PlaybackWindowEvent::NativePresenterUnavailable) + Some(PlaybackWindowEvent::NativePresenterUnavailable { + request, + effective_target: snapshot.target, + }) } crate::contract::PresenterState::Detached | crate::contract::PresenterState::AwaitingHost | crate::contract::PresenterState::AwaitingVideoOutput => None, } } else { - snapshot - .fallback_chain - .iter() - .any(|reason| { + (state.integrated_playback_request == Some(request) + || snapshot.fallback_chain.iter().any(|reason| { reason.from == Some(crate::contract::PlaybackTarget::MPV_INTEGRATED) - }) - .then_some(PlaybackWindowEvent::NativePresenterUnavailable) + })) + .then_some( + PlaybackWindowEvent::NativePresenterUnavailable { + request, + effective_target: snapshot.target, + }, + ) }?; (snapshot.generation, event) }; let emitted_generation = match event { - PlaybackWindowEvent::NativePresenterAttached => { + PlaybackWindowEvent::NativePresenterAttached { .. } => { &mut state.native_presenter_attached_generation } - PlaybackWindowEvent::NativePresenterUnavailable => { + PlaybackWindowEvent::NativePresenterUnavailable { .. } => { &mut state.native_presenter_unavailable_generation } PlaybackWindowEvent::SetWindowMode(_) | PlaybackWindowEvent::RestoreWindow(_) - | PlaybackWindowEvent::PlaybackExited => { + | PlaybackWindowEvent::BeginIntegratedPlayback { .. } + | PlaybackWindowEvent::BeginExternalPlayback { .. } + | PlaybackWindowEvent::ExternalPlaybackLaunchFailed { .. } + | PlaybackWindowEvent::PlaybackExited { .. } => { unreachable!( "native presenter reducer produced a non-presenter event" ) @@ -412,6 +544,33 @@ where } state.terminal_generation_handled = Some(snapshot.generation); + // Authorization for a replacement is allowed to overlap the previous + // session. If that older session terminates before the new source resolves, + // retire only the old root and its shell handoff. Falling back the old + // session would consume the new request's media/source state and could + // leave the retained shell hidden after the root disappeared. + if let (Some(session), Some(active)) = ( + state.session_playback_request, + state.active_playback_request, + ) && session != active + { + let progress = final_snapshot_progress(state, snapshot).map_or_else( + Task::none, + |(media_id, position, duration)| { + Task::done(P::send_progress_update( + media_id, position, duration, + )) + }, + ); + let shutdown = close_video(state); + let completion = message_after_root_shutdown::

( + shutdown, + Some(session), + PlayerMessage::ResetAfterStop(Some(session)), + ); + return Some(sequence_tasks([progress, completion])); + } + match snapshot.state { PlaybackState::Ended => match snapshot.end_reason { Some(EndReason::Eof) => Some(Task::done(P::playback_message( @@ -452,9 +611,18 @@ where { state.pending_resume_position = Some(position as f32); } - close_video(state); + let request = state.playback_handoff_request(); + let shutdown = close_video(state); state.backend_request = BackendRequest::Auto; - let fallback = load_video::

(state, ui); + let fallback = if shutdown.is_some() { + message_after_root_shutdown::

( + shutdown, + request, + PlayerMessage::VideoReadyToPlay, + ) + } else { + load_video::

(state, ui) + }; Some(sequence_tasks([progress, fallback])) } PlaybackState::Failed => { @@ -469,7 +637,7 @@ where media_id, position, duration, )) }); - close_video(state); + let _ = close_video(state); ui.set_video_error(message); Some(progress) } @@ -520,41 +688,95 @@ where } PlayerMessage::NavigateBack => { + let media_id = playback_media_id(state); let progress = current_playback_progress(state); - let update_task = if let Some(media_id) = state.current_media_id { + let update_task = if let Some(media_id) = media_id { Task::done(P::send_progress_update( media_id, progress.0, progress.1, )) } else { Task::none() }; + let (request, shutdown, awaiting_shutdown) = + begin_playback_exit(state, PlaybackExitDestination::Back); + + if awaiting_shutdown { + return DomainUpdateResult::task(update_task); + } + + if shutdown.is_some() { + let completion = message_after_root_shutdown::

( + shutdown, + request, + PlayerMessage::CompletePlaybackExit { + request, + destination: PlaybackExitDestination::Back, + }, + ); + return DomainUpdateResult::task(sequence_tasks([ + update_task, + completion, + ])); + } let tasks = sequence_tasks([ update_task, - Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), + Task::done(P::playback_message(PlayerMessage::ResetAfterStop( + request, + ))), Task::done(P::navigate_back()), ]); - DomainUpdateResult::task(tasks) + DomainUpdateResult::with_events( + tasks, + vec![PlaybackWindowEvent::PlaybackExited { request }], + ) } PlayerMessage::NavigateHome => { + let media_id = playback_media_id(state); let progress = current_playback_progress(state); - let update_task = if let Some(media_id) = state.current_media_id { + let update_task = if let Some(media_id) = media_id { Task::done(P::send_progress_update( media_id, progress.0, progress.1, )) } else { Task::none() }; + let (request, shutdown, awaiting_shutdown) = + begin_playback_exit(state, PlaybackExitDestination::Home); + + if awaiting_shutdown { + return DomainUpdateResult::task(update_task); + } + + if shutdown.is_some() { + let completion = message_after_root_shutdown::

( + shutdown, + request, + PlayerMessage::CompletePlaybackExit { + request, + destination: PlaybackExitDestination::Home, + }, + ); + return DomainUpdateResult::task(sequence_tasks([ + update_task, + completion, + ])); + } let tasks = sequence_tasks([ update_task, - Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), + Task::done(P::playback_message(PlayerMessage::ResetAfterStop( + request, + ))), Task::done(P::navigate_home()), ]); - DomainUpdateResult::task(tasks) + DomainUpdateResult::with_events( + tasks, + vec![PlaybackWindowEvent::PlaybackExited { request }], + ) } PlayerMessage::Play => { @@ -585,42 +807,267 @@ where DomainUpdateResult::task(task) } - PlayerMessage::ResetAfterStop => { + PlayerMessage::ResetAfterStop(request) => { // Reset only after the final progress task has been constructed. - // Every completed exit path funnels through this message, so one - // idempotent shell event also restores a dedicated native-player - // host for stop, EOF, close, back, and home navigation. - state.reset(); + // A delayed reset is allowed to close its own old session, but it + // must never erase a newer media/source request. + if request.is_none() + && (state.active_playback_request.is_some() + || state.session_playback_request.is_some()) + { + debug!( + "ignoring requestless stale teardown while playback is owned" + ); + return DomainUpdateResult::task(Task::none()); + } + if state.root_shutdown_blocks_launch() { + state.root_shutdown_exit_destination = + Some(PlaybackExitDestination::None); + if request == state.active_playback_request { + state.invalidate_playback_request(); + } + return DomainUpdateResult::task(Task::none()); + } + let newer_request_active = state + .active_playback_request + .is_some_and(|active| Some(active) != request); + if (state.video_opt.is_some() + && (request.is_none() + || state.session_playback_request == request)) + || (state.external_mpv_handle.is_some() + && (request.is_none() + || state.external_playback_request == request)) + { + let shutdown = close_playback_root(state); + if shutdown.is_some() { + return DomainUpdateResult::task( + message_after_root_shutdown::

( + shutdown, + request, + PlayerMessage::ResetAfterStop(request), + ), + ); + } + } + if !newer_request_active { + state.reset(); + } else { + debug!( + "preserving newer playback request while completing stale teardown {:?}", + request + ); + } DomainUpdateResult::with_events( Task::none(), - vec![PlaybackWindowEvent::PlaybackExited], + vec![PlaybackWindowEvent::PlaybackExited { request }], ) } - PlayerMessage::Stop => { - // Capture position and duration BEFORE reset - let update_task = if let Some(media_id) = state.current_media_id { - let position = state.last_valid_position; - let duration = state.last_valid_duration; + PlayerMessage::CompletePlaybackExit { + request, + destination, + } => { + if state.root_shutdown_blocks_launch() { + state.root_shutdown_exit_destination = Some(destination); + state.invalidate_playback_request(); + return DomainUpdateResult::task(Task::none()); + } + let newer_request_active = state + .active_playback_request + .is_some_and(|active| Some(active) != request); + let reset = update_player::

( + context, + PlayerMessage::ResetAfterStop(request), + ); + let navigation = if newer_request_active { + Task::none() + } else { + match destination { + PlaybackExitDestination::None => Task::none(), + PlaybackExitDestination::Back => { + Task::done(P::navigate_back()) + } + PlaybackExitDestination::Home => { + Task::done(P::navigate_home()) + } + } + }; + DomainUpdateResult::with_events( + sequence_tasks([reset.task, navigation]), + reset.events, + ) + } + + PlayerMessage::RestoreShellAfterRootShutdown { request } => { + if state.root_shutdown_blocks_launch() { + return DomainUpdateResult::task(Task::none()); + } + DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::PlaybackExited { request }], + ) + } + + PlayerMessage::RootShutdownCompleted { + request, + continuation, + } => { + let retired_request = + state.root_shutdown_retired_request.take().or(request); + let exit_destination = state.root_shutdown_exit_destination.take(); + state.root_shutdown_in_progress = false; + state.root_shutdown_failed = false; + + if let Some(destination) = exit_destination { + state.clear_external_playback_for_request(retired_request); + return update_player::

( + context, + PlayerMessage::CompletePlaybackExit { + request: retired_request, + destination, + }, + ); + } + + let resolved = state.resolved_playback_request.filter(|current| { + state.is_resolved_playback_request(*current) + && state.current_source.is_some() + }); + if let Some(current) = resolved { + let external_ended_matches = matches!( + continuation.as_ref(), + PlayerMessage::ExternalPlaybackEnded { + request: ended + } if Some(*ended) == retired_request && *ended == current + ); + if external_ended_matches { + return update_player::

(context, *continuation); + } + + let external_matches = matches!( + continuation.as_ref(), + PlayerMessage::ResumeExternalPlaybackAfterRootShutdown { + request: external + } if *external == current + ); + state.clear_external_playback_for_request(retired_request); + if external_matches { + return update_player::

(context, *continuation); + } + debug!( + "root shutdown for {request:?} completed; resuming latest resolved request {current:?}" + ); + let mut resumed = continue_resolved_stream_source::

( + state, context.ui, current, + ); + if !integrated_playback_requested(state) + && !state.is_external_playback_intent(current) + { + resumed.events.insert( + 0, + PlaybackWindowEvent::PlaybackExited { + request: retired_request, + }, + ); + } + return resumed; + } + + // A newer request may still be authorizing. The retired root is + // now positively gone, so restore its shell owner and let that + // request perform a normal handoff if it later resolves. + if state.active_playback_request.is_some() { + state.clear_external_playback_for_request(retired_request); + return DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::PlaybackExited { + request: retired_request, + }], + ); + } + + if matches!( + continuation.as_ref(), + PlayerMessage::CompletePlaybackExit { .. } + | PlayerMessage::ResetAfterStop(_) + | PlayerMessage::RestoreShellAfterRootShutdown { .. } + | PlayerMessage::ExternalPlaybackEnded { .. } + ) { + return update_player::

(context, *continuation); + } + + state.clear_external_playback_for_request(retired_request); + DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::PlaybackExited { + request: retired_request, + }], + ) + } + + PlayerMessage::RootShutdownFailed { request, message } => { + state.root_shutdown_in_progress = false; + state.root_shutdown_failed = true; + state.is_loading_video = false; + state.is_resolving_stream_url = false; + context.ui.set_video_error(format!( + "Playback could not prove its previous root closed safely: {message}" + )); + error!( + "playback root teardown failed closed for request {request:?}: {message}" + ); + DomainUpdateResult::task(Task::none()) + } - // Send final progress update with captured data + PlayerMessage::Stop => { + // Synchronize and capture the session-owned progress before reset. + // A replacement may already own `current_media_id` while the + // visible backend still belongs to the prior media item. + let media_id = playback_media_id(state); + let (position, duration) = current_playback_progress(state); + let update_task = if let Some(media_id) = media_id { Task::done(P::send_progress_update( media_id, position, duration, )) } else { Task::none() }; + let (request, shutdown, awaiting_shutdown) = + begin_playback_exit(state, PlaybackExitDestination::Back); + + if awaiting_shutdown { + return DomainUpdateResult::task(update_task); + } + + if shutdown.is_some() { + let completion = message_after_root_shutdown::

( + shutdown, + request, + PlayerMessage::CompletePlaybackExit { + request, + destination: PlaybackExitDestination::Back, + }, + ); + return DomainUpdateResult::task(sequence_tasks([ + update_task, + completion, + ])); + } // Serialize progress, reset, and navigation messages so the shell // cannot tear down the player before persistence is dispatched. let tasks = sequence_tasks([ update_task, - Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), + Task::done(P::playback_message(PlayerMessage::ResetAfterStop( + request, + ))), Task::done(P::navigate_back()), ]); - // Return tasks without resetting yet - DomainUpdateResult::task(tasks) + DomainUpdateResult::with_events( + tasks, + vec![PlaybackWindowEvent::PlaybackExited { request }], + ) } PlayerMessage::Seek(position) => { @@ -636,8 +1083,9 @@ where PlayerMessage::SeekRelease => { // Perform the seek on release + let media_id = playback_media_id(state); if let (Some(video), Some(media_id)) = - (&mut state.video_opt, state.current_media_id) + (&mut state.video_opt, media_id) { state.dragging = false; @@ -714,8 +1162,9 @@ where PlayerMessage::SeekDone => { // Seek operation completed, clear seeking flag + let media_id = playback_media_id(state); if let (Some(video), Some(media_id)) = - (&mut state.video_opt, state.current_media_id) + (&mut state.video_opt, media_id) { let video_pos = video.position().as_secs_f64(); debug!( @@ -821,8 +1270,17 @@ where DomainUpdateResult::task(Task::none()) } - PlayerMessage::VideoLoaded(success) => { + PlayerMessage::VideoLoaded { request, success } => { if success { + if state.session_playback_request != Some(request) + || !state.is_active_playback_request(request) + { + debug!( + "ignoring stale playback-open success for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } // Query available tracks state.update_available_tracks(); context.ui.set_player_view(); @@ -832,10 +1290,31 @@ where } result } else { + if !state.is_active_playback_request(request) { + debug!( + "ignoring stale playback-open failure for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } context .ui .set_video_error("Failed to load video".to_string()); - DomainUpdateResult::task(Task::none()) + state.invalidate_playback_request(); + let mut result = DomainUpdateResult::task(Task::none()); + if integrated_playback_requested(state) + && !state.root_shutdown_blocks_launch() + { + // No playback window survived this failure. The shell + // must close the hidden donor and restore its retained + // main window, unlike a successful native-mpv fallback + // where the mpv root remains the sole visible window. + result = + result.add_event(PlaybackWindowEvent::PlaybackExited { + request: Some(request), + }); + } + result } } @@ -843,16 +1322,63 @@ where info!( "[Player] Video ready to play - loading with internal backend" ); + if state.root_shutdown_blocks_launch() { + debug!( + "deferring backend start until native shutdown completes" + ); + return DomainUpdateResult::task(Task::none()); + } + if integrated_playback_requested(state) + && state.video_opt.is_none() + && !state.is_loading_video + { + let Some(request) = + state.resolved_playback_request.filter(|request| { + state.is_active_playback_request(*request) + }) + else { + context.ui.set_video_error( + "Resolved playback source identity is unavailable" + .to_string(), + ); + return DomainUpdateResult::task(Task::none()); + }; + return DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::BeginIntegratedPlayback { + request, + }], + ); + } // Load the selected in-process provider. The separate external // process handoff remains explicit through Player::PlayExternal. DomainUpdateResult::task(load_video::

(state, context.ui)) } + PlayerMessage::OpenResolvedStreamSource { request } => { + if !state.is_resolved_playback_request(request) + || state.is_resolving_stream_url + || state.current_source.is_none() + || !integrated_playback_requested(state) + || state.root_shutdown_blocks_launch() + { + debug!( + "ignoring stale integrated-open continuation for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } + info!( + "[Player] Retained shell hidden - opening integrated playback source" + ); + DomainUpdateResult::task(load_video::

(state, context.ui)) + } + PlayerMessage::EndOfStream => { info!("End of stream - finalizing playback"); // Capture position and duration for final progress update - if let Some(media_id) = state.current_media_id { + if let Some(media_id) = playback_media_id(state) { let (position, duration) = current_playback_progress(state); // If current is an episode, attempt to play the next; else exit @@ -879,21 +1405,60 @@ where } // Fallback: no next episode -> reset and navigate back + let (request, shutdown, awaiting_shutdown) = + begin_playback_exit(state, PlaybackExitDestination::Back); + if awaiting_shutdown { + return DomainUpdateResult::task(Task::done( + P::send_progress_update(media_id, position, duration), + )); + } + if shutdown.is_some() { + return DomainUpdateResult::task(sequence_tasks([ + Task::done(P::send_progress_update( + media_id, position, duration, + )), + message_after_root_shutdown::

( + shutdown, + request, + PlayerMessage::CompletePlaybackExit { + request, + destination: PlaybackExitDestination::Back, + }, + ), + ])); + } let tasks = sequence_tasks([ Task::done(P::send_progress_update( media_id, position, duration, )), Task::done(P::playback_message( - PlayerMessage::ResetAfterStop, + PlayerMessage::ResetAfterStop(request), )), Task::done(P::navigate_back()), ]); DomainUpdateResult::task(tasks) } else { // No media id - just reset and navigate back + let (request, shutdown, awaiting_shutdown) = + begin_playback_exit(state, PlaybackExitDestination::Back); + if awaiting_shutdown { + return DomainUpdateResult::task(Task::none()); + } + if shutdown.is_some() { + return DomainUpdateResult::task( + message_after_root_shutdown::

( + shutdown, + request, + PlayerMessage::CompletePlaybackExit { + request, + destination: PlaybackExitDestination::Back, + }, + ), + ); + } let tasks = sequence_tasks([ Task::done(P::playback_message( - PlayerMessage::ResetAfterStop, + PlayerMessage::ResetAfterStop(request), )), Task::done(P::navigate_back()), ]); @@ -977,7 +1542,7 @@ where PlayerMessage::CaptureNativeVideoHost(window_id) => { #[cfg(feature = "ui")] { - return DomainUpdateResult::task( + DomainUpdateResult::task( crate::native_video_slot::capture_iced_native_host( window_id, ) @@ -991,7 +1556,7 @@ where }, ) }), - ); + ) } #[cfg(not(feature = "ui"))] @@ -1223,6 +1788,16 @@ where DomainUpdateResult::task(Task::none()) } + PlayerMessage::BeginNativeRootDrag => { + // AppKit consumes the current mouse-down event synchronously. The + // session method is a benign no-op for non-macOS, fallback, and + // presenter-not-ready states. + if let Some(video) = state.video_opt.as_mut() { + video.begin_native_root_drag(); + } + DomainUpdateResult::task(Task::none()) + } + PlayerMessage::VideoClicked => { let now = std::time::Instant::now(); if let Some(last_click) = state.last_click_time { @@ -1444,10 +2019,10 @@ where // Episode navigation: Next by ordering, Prev = restart or previous by ordering (<5%) PlayerMessage::NextEpisode => { let mode = current_playback_start_mode(state); - let (current_episode_id, mid_opt) = - if let Some(MediaID::Episode(episode)) = state.current_media_id - { - (episode, state.current_media_id) + let mid_opt = playback_media_id(state); + let current_episode_id = + if let Some(MediaID::Episode(episode)) = mid_opt { + episode } else { return DomainUpdateResult::task(Task::none()); }; @@ -1478,7 +2053,8 @@ where PlayerMessage::PreviousEpisode => { // Only valid for episodes - let current_episode_id = match state.current_media_id { + let mid_opt = playback_media_id(state); + let current_episode_id = match mid_opt { Some(ferrex_core::player_prelude::MediaID::Episode(ep)) => ep, _ => return DomainUpdateResult::task(Task::none()), }; @@ -1506,7 +2082,6 @@ where } else { // Less than 5% watched: go to previous episode by ordering let mode = current_playback_start_mode(state); - let mid_opt = state.current_media_id; let (p, d) = current_playback_progress(state); let prev_opt = @@ -1543,7 +2118,53 @@ where } } + PlayerMessage::PlayMediaWithIdExternally(media, media_id) => { + let previous_counter = state.playback_request_counter; + let result = update_player::

( + context, + PlayerMessage::PlayMediaWithId(media, media_id), + ); + let state = &mut *context.playback; + if let Some(request) = state.active_playback_request + && request > previous_counter + { + let accepted = state.request_external_playback(request); + debug_assert!(accepted); + } + result + } + PlayerMessage::PlayMediaWithId(media, media_id) => { + // If an earlier request has already hidden the retained shell but + // has not opened a backend root, superseding it must release that + // hide obligation while this new URL request is pending. + let root_shutdown_blocks = state.root_shutdown_blocks_launch(); + if root_shutdown_blocks { + // The newer explicit play request supersedes an exit queued + // during the same native teardown phase. + state.root_shutdown_exit_destination = None; + } + let superseded_unopened_handoff = (!root_shutdown_blocks + && state.session_playback_request.is_none() + && state.external_playback_request.is_none()) + .then_some(state.resolved_playback_request) + .flatten(); + let Some(request) = state.begin_playback_request() else { + state.is_resolving_stream_url = false; + state.stream_url_resolution_failed = true; + context.ui.set_video_error( + "Playback request identity exhausted".to_string(), + ); + let mut result = DomainUpdateResult::task(Task::none()); + if let Some(request) = superseded_unopened_handoff { + result = + result.add_event(PlaybackWindowEvent::PlaybackExited { + request: Some(request), + }); + } + return result; + }; + // Store current media and id state.current_media = Some(media.clone()); state.current_media_id = Some(media_id); @@ -1552,17 +2173,22 @@ where state.pending_resume_position = context.watch_progress.take_pending_resume_position(); - // Seed playback UI with the position we expect to resume from (or clear if none) - state.last_valid_position = state - .pending_resume_position - .map(|pos| pos as f64) - .unwrap_or(0.0); - - // Set duration from media metadata if available - if let Some(metadata) = &media.media_file_metadata - && let Some(duration) = metadata.duration + // Keep the visible session's progress projection intact while the + // replacement authorizes. The new session consumes its resume and + // duration hints after the old backend is synchronously withdrawn. + if state.session_playback_request.is_none() + && state.external_playback_request.is_none() { - state.last_valid_duration = duration; + state.last_valid_position = state + .pending_resume_position + .map(|pos| pos as f64) + .unwrap_or(0.0); + + if let Some(metadata) = &media.media_file_metadata + && let Some(duration) = metadata.duration + { + state.last_valid_duration = duration; + } } // Content labeling uses server/decoder metadata only. Native HDR @@ -1584,25 +2210,44 @@ where let server_url = context.server_url.to_string(); let media_id_string = media.id.to_string(); let api = Arc::clone(&context.api_service); - DomainUpdateResult::task(Task::perform( + let task = Task::perform( resolve_playback_stream_source( api, server_url, media_id_string, ), - |result| match result { + move |result| match result { Ok(source) => P::playback_message( - PlayerMessage::SetStreamSource(source), + PlayerMessage::StreamSourceResolved { request, source }, ), Err(message) => P::playback_message( - PlayerMessage::StreamUrlResolutionFailed(message), + PlayerMessage::StreamUrlResolutionFailed { + request, + message, + }, ), }, - )) + ); + let mut result = DomainUpdateResult::task(task); + if let Some(request) = superseded_unopened_handoff { + result = + result.add_event(PlaybackWindowEvent::PlaybackExited { + request: Some(request), + }); + } + result } // External MPV player messages - PlayerMessage::ExternalPlaybackStarted => { + PlayerMessage::ExternalPlaybackStarted { request } => { + if state.external_playback_request != Some(request) + || state.external_mpv_handle.is_none() + { + debug!( + "ignoring stale external-player start for request {request:?}" + ); + return DomainUpdateResult::task(Task::none()); + } info!("External MPV playback started"); state.mark_external_playback_started(); DomainUpdateResult::task(Task::none()) @@ -1622,7 +2267,16 @@ where DomainUpdateResult::task(Task::none()) } - PlayerMessage::ExternalPlaybackEnded => { + PlayerMessage::ExternalPlaybackEnded { request } => { + if state.external_playback_request != Some(request) + || state.external_mpv_handle.is_some() + || state.root_shutdown_blocks_launch() + { + debug!( + "ignoring stale or premature external-player end for request {request:?}" + ); + return DomainUpdateResult::task(Task::none()); + } info!("External MPV playback ended"); // Polling captures the final IPC values into the snapshot before @@ -1646,17 +2300,19 @@ where state.last_valid_duration = final_duration; state.is_fullscreen = final_fullscreen; - let Some(media_id) = state.current_media_id else { - state.clear_external_playback(); + let Some(media_id) = playback_media_id(state) else { + state.clear_external_playback_for_request(Some(request)); let tasks = sequence_tasks([ - Task::done(P::playback_message( - PlayerMessage::ResetAfterStop, - )), + Task::done(P::playback_message(reset_after_stop_message( + state, + ))), Task::done(P::navigate_back()), ]); return DomainUpdateResult::with_events( tasks, - vec![PlaybackWindowEvent::RestoreWindow(final_fullscreen)], + vec![PlaybackWindowEvent::PlaybackExited { + request: Some(request), + }], ); }; @@ -1666,7 +2322,7 @@ where } _ => None, }; - state.clear_external_playback(); + state.clear_external_playback_for_request(Some(request)); if let (MediaID::Episode(current), Some(next)) = (media_id, next_episode) @@ -1684,7 +2340,9 @@ where ]); return DomainUpdateResult::with_events( tasks, - vec![PlaybackWindowEvent::RestoreWindow(final_fullscreen)], + vec![PlaybackWindowEvent::PlaybackExited { + request: Some(request), + }], ); } @@ -1694,12 +2352,16 @@ where final_position, final_duration, )), - Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), + Task::done(P::playback_message(reset_after_stop_message( + state, + ))), Task::done(P::navigate_back()), ]); DomainUpdateResult::with_events( tasks, - vec![PlaybackWindowEvent::RestoreWindow(final_fullscreen)], + vec![PlaybackWindowEvent::PlaybackExited { + request: Some(request), + }], ) } @@ -1749,6 +2411,9 @@ where } PlayerMessage::PollExternalMpv => { + let Some(request) = state.external_playback_request else { + return DomainUpdateResult::task(Task::none()); + }; let Some(mut handle) = state.external_mpv_handle.take() else { return DomainUpdateResult::task(Task::none()); }; @@ -1771,11 +2436,14 @@ where state.last_valid_position = position; state.last_valid_duration = duration; state.is_fullscreen = fullscreen; - drop(handle); + state.external_mpv_handle = Some(handle); + let shutdown = close_external_playback_root(state); - DomainUpdateResult::task(Task::done(P::playback_message( - PlayerMessage::ExternalPlaybackEnded, - ))) + DomainUpdateResult::task(message_after_root_shutdown::

( + shutdown, + Some(request), + PlayerMessage::ExternalPlaybackEnded { request }, + )) } else { state.external_mpv_handle = Some(handle); state.update_external_playback_snapshot(position, duration); @@ -1792,54 +2460,258 @@ where } } - PlayerMessage::PlayExternal => { - start_external_mpv_with_current_url::

(context) - } + PlayerMessage::PlayExternal => request_external_playback::

(context), - PlayerMessage::StreamUrlResolutionFailed(message) => { - state.current_url = None; - state.current_source = None; - state.is_resolving_stream_url = false; + PlayerMessage::ResumeExternalPlaybackAfterRootShutdown { request } => { + if !state.is_external_playback_intent(request) { + debug!( + "ignoring stale external-player continuation for request {request:?}" + ); + return DomainUpdateResult::task(Task::none()); + } + continue_resolved_stream_source::

(state, context.ui, request) + } + + PlayerMessage::OpenExternalStreamSource { request } => { + if !state.is_resolved_playback_request(request) + || !state.is_external_playback_intent(request) + || state.root_shutdown_blocks_launch() + { + debug!( + "ignoring stale external shell continuation for request {request:?}" + ); + return DomainUpdateResult::task(Task::none()); + } + start_external_mpv_with_current_url::

(context, request) + } + + PlayerMessage::ResumeInternalPlaybackAfterExternalLaunchFailure { + request, + } => { + if !state.is_resolved_playback_request(request) + || state.external_playback_intent_request != Some(request) + || state.root_shutdown_blocks_launch() + { + debug!( + "ignoring stale external fallback continuation for request {request:?}" + ); + return DomainUpdateResult::task(Task::none()); + } + state.external_playback_intent_request = None; + state.backend_request = BackendRequest::Auto; + DomainUpdateResult::task(load_video::

(state, context.ui)) + } + + PlayerMessage::StreamUrlResolutionFailed { request, message } => { + if !state.is_active_playback_request(request) { + debug!( + "ignoring stale stream authorization failure for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } + let integrated = integrated_playback_requested(state); + let exit_request = + state.playback_handoff_request().or(Some(request)); + let shutdown = (state.video_opt.is_some() + || state.external_mpv_handle.is_some()) + .then(|| close_playback_root(state)) + .flatten(); + state.current_url = None; + state.current_source = None; + state.invalidate_playback_request(); state.stream_url_resolution_failed = true; state.is_loading_video = false; context.ui.set_video_error(message); - DomainUpdateResult::task(Task::none()) + if shutdown.is_some() { + return DomainUpdateResult::task( + message_after_root_shutdown::

( + shutdown, + exit_request, + PlayerMessage::RestoreShellAfterRootShutdown { + request: exit_request, + }, + ), + ); + } + if state.root_shutdown_blocks_launch() { + return DomainUpdateResult::task(Task::none()); + } + let mut result = DomainUpdateResult::task(Task::none()); + if integrated { + // The main window is still visible because URL resolution + // precedes the ordered hide. Dismiss the unused controls host + // without ever hiding the application. + result = + result.add_event(PlaybackWindowEvent::PlaybackExited { + request: exit_request, + }); + } + result } - // Accept a resolved source and kick off playback. The source keeps its - // playback ticket in an Authorization header rather than the URI. + // A current-domain source replacement starts a fresh request epoch. PlayerMessage::SetStreamSource(source) => { - state.is_resolving_stream_url = false; - state.stream_url_resolution_failed = false; - let display_url = source.uri().as_str().to_string(); - state.set_playback_source(source); - - // If we're already in the Player view (e.g., next/prev episode while playing), - // keep the Player view and swap streams seamlessly without showing the loading page. - // Otherwise (e.g., initial play from library), show the loading view. - let in_player_already = - context.ui.is_player_view() || state.video_opt.is_some(); - - // Clear any previous error - context.ui.clear_error(); + let Some(request) = state.begin_playback_request() else { + context.ui.set_video_error( + "Playback request identity exhausted".to_string(), + ); + return DomainUpdateResult::task(Task::none()); + }; + accept_resolved_stream_source::

( + state, context.ui, request, source, + ) + } - if in_player_already { - // Ensure we stay on the Player view for near-instant transitions - context.ui.set_player_view(); - // Explicitly close the existing provider so load_video doesn't early-return - close_video(state); - } else { - // The displayed/loading URL contains no playback credential. - context.ui.set_loading_video_view(display_url); + PlayerMessage::StreamSourceResolved { request, source } => { + if !state.is_active_playback_request(request) { + debug!( + "ignoring stale stream authorization success for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); } + accept_resolved_stream_source::

( + state, context.ui, request, source, + ) + } - DomainUpdateResult::task(load_video::

(state, context.ui)) + PlayerMessage::ResumeResolvedStreamSourceAfterRootShutdown { + request, + retired_request, + } => { + if state.root_shutdown_blocks_launch() { + return DomainUpdateResult::task(Task::none()); + } + if state.is_resolved_playback_request(request) { + return continue_resolved_stream_source::

( + state, context.ui, request, + ); + } + if let Some(current) = state + .resolved_playback_request + .filter(|current| state.is_resolved_playback_request(*current)) + { + return continue_resolved_stream_source::

( + state, context.ui, current, + ); + } + DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::PlaybackExited { + request: retired_request, + }], + ) } } } +/// Accept one current authenticated source and either load it immediately or +/// hand its request identity to the shell-ordered integrated launch. +fn accept_resolved_stream_source

( + state: &mut PlayerDomainState, + ui: &mut dyn PlaybackUiShell, + request: PlaybackRequestId, + source: PlaybackSource, +) -> DomainUpdateResult, PlaybackWindowEvent> +where + P: PlaybackUpdatePort + 'static, +{ + if !state.resolve_playback_request(request) { + debug!("ignoring source acceptance for stale request {:?}", request); + return DomainUpdateResult::task(Task::none()); + } + + state.is_resolving_stream_url = false; + state.stream_url_resolution_failed = false; + let display_url = source.uri().as_str().to_string(); + state.set_playback_source(source); + + // A replacement keeps the current player surface until authorization has + // succeeded, then synchronously withdraws the old backend before the new + // shell handoff can expose another native root. + let in_player_already = ui.is_player_view() + || state.video_opt.is_some() + || state.external_mpv_handle.is_some(); + if state.root_shutdown_failed { + ui.set_video_error( + "Playback remains blocked because previous root teardown could not be proven complete" + .to_string(), + ); + return DomainUpdateResult::task(Task::none()); + } + ui.clear_error(); + if state.root_shutdown_in_progress { + ui.set_player_view(); + debug!( + "accepted source {:?} remains deferred behind root shutdown", + request + ); + return DomainUpdateResult::task(Task::none()); + } + let retired_request = state.playback_handoff_request(); + let mut shutdown = None; + if in_player_already { + ui.set_player_view(); + shutdown = close_playback_root(state); + } else { + ui.set_loading_video_view(display_url); + } + + if shutdown.is_some() { + return DomainUpdateResult::task(message_after_root_shutdown::

( + shutdown, + Some(request), + PlayerMessage::ResumeResolvedStreamSourceAfterRootShutdown { + request, + retired_request, + }, + )); + } + + continue_resolved_stream_source::

(state, ui, request) +} + +/// Continue one accepted source only while its request still owns the current +/// source projection. Native replacement calls this after owner destruction. +fn continue_resolved_stream_source

( + state: &mut PlayerDomainState, + ui: &mut dyn PlaybackUiShell, + request: PlaybackRequestId, +) -> DomainUpdateResult, PlaybackWindowEvent> +where + P: PlaybackUpdatePort + 'static, +{ + if !state.is_resolved_playback_request(request) + || state.is_resolving_stream_url + || state.current_source.is_none() + || state.root_shutdown_blocks_launch() + { + debug!( + "ignoring stale post-shutdown source continuation for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } + + if state.is_external_playback_intent(request) { + DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::BeginExternalPlayback { request }], + ) + } else if integrated_playback_requested(state) { + state.integrated_playback_request = Some(request); + DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::BeginIntegratedPlayback { request }], + ) + } else { + DomainUpdateResult::task(load_video::

(state, ui)) + } +} + fn finish_terminated_playback

( - state: &PlayerDomainState, + state: &mut PlayerDomainState, snapshot: &crate::contract::PlaybackSnapshot, ) -> Task where @@ -1854,9 +2726,27 @@ where // A native-window close is a user exit, not EOF. In particular, do not // auto-advance an episode after mpv's top-level window is closed. + let request = state + .playback_handoff_request() + .or(state.active_playback_request); + state.invalidate_playback_request(); + let shutdown = close_video(state); + if shutdown.is_some() { + return sequence_tasks([ + progress, + message_after_root_shutdown::

( + shutdown, + request, + PlayerMessage::CompletePlaybackExit { + request, + destination: PlaybackExitDestination::Back, + }, + ), + ]); + } sequence_tasks([ progress, - Task::done(P::playback_message(PlayerMessage::ResetAfterStop)), + Task::done(P::playback_message(PlayerMessage::ResetAfterStop(request))), Task::done(P::navigate_back()), ]) } @@ -1916,12 +2806,28 @@ fn progress_checkpoint_task

( where P: PlaybackUpdatePort + 'static, { + let media_id = playback_media_id(state); let progress = current_playback_progress(state); - state.current_media_id.map_or_else(Task::none, |media_id| { + media_id.map_or_else(Task::none, |media_id| { Task::done(P::send_progress_update(media_id, progress.0, progress.1)) }) } +/// Media identity owned by the currently observable playback snapshot. +/// +/// Source authorization for a replacement can overlap the previous session, +/// so `current_media_id` alone is not sufficient for persistence or episode +/// policy while a backend remains open. +const fn playback_media_id(state: &PlayerDomainState) -> Option { + match state.session_media_id { + Some(media_id) => Some(media_id), + None => match state.external_media_id { + Some(media_id) => Some(media_id), + None => state.current_media_id, + }, + } +} + fn final_snapshot_progress( state: &PlayerDomainState, snapshot: &crate::contract::PlaybackSnapshot, @@ -1934,7 +2840,7 @@ fn final_snapshot_progress( state.last_valid_position }; Some(( - state.current_media_id?, + playback_media_id(state)?, position, snapshot .duration @@ -1983,15 +2889,109 @@ fn prepare_restart_current_episode( } } -/// Start external MPV playback using the current URL and UI/window state. -/// Falls back to internal playback if MPV cannot be launched. +/// Convert the current resolved request to explicit external playback. Any +/// in-process root is retired first; process creation itself is deferred to the +/// shell-ordered hide continuation. +fn request_external_playback

( + context: &mut PlaybackUpdateContext<'_>, +) -> DomainUpdateResult, PlaybackWindowEvent> +where + P: PlaybackUpdatePort + 'static, +{ + let state = &mut *context.playback; + let Some(request) = state.active_playback_request else { + context.ui.set_video_error( + "External playback requires an active media request".to_string(), + ); + return DomainUpdateResult::task(Task::none()); + }; + if !state.request_external_playback(request) { + return DomainUpdateResult::task(Task::none()); + } + if state.external_playback_request == Some(request) + && state.external_mpv_handle.is_some() + { + return DomainUpdateResult::task(Task::none()); + } + if state.root_shutdown_blocks_launch() { + if state.root_shutdown_failed { + context.ui.set_video_error( + "External playback is blocked because previous root teardown could not be proven complete" + .to_string(), + ); + } + return DomainUpdateResult::task(Task::none()); + } + if !state.is_resolved_playback_request(request) + || state.current_source.is_none() + { + // The request-scoped intent remains attached while URL authorization + // completes; source acceptance will choose the external handoff. + return DomainUpdateResult::task(Task::none()); + } + + let (position, duration) = current_playback_progress(state); + let resume = (position > 0.0) + .then_some(position as f32) + .or(state.pending_resume_position); + if state.video_opt.is_some() { + let shutdown = close_playback_root(state); + state.pending_resume_position = resume; + state.last_valid_position = position.max(0.0); + state.last_valid_duration = duration.max(0.0); + if shutdown.is_some() { + return DomainUpdateResult::task(message_after_root_shutdown::

( + shutdown, + Some(request), + PlayerMessage::ResumeExternalPlaybackAfterRootShutdown { + request, + }, + )); + } + } + continue_resolved_stream_source::

(state, context.ui, request) +} + +/// Return only request-owned hints for a new external process. +/// +/// A fresh request records its watch-progress resume in +/// `pending_resume_position`. An explicit same-request handoff copies the +/// current root's observed position into that same field before teardown. +/// `last_valid_*` is a UI projection and can still describe a positively +/// reaped predecessor, so it must never seed the replacement process. +fn external_playback_start_projection( + state: &PlayerDomainState, +) -> (Option, f64) { + let duration = state + .current_media + .as_ref() + .and_then(|media| media.media_file_metadata.as_ref()) + .and_then(|metadata| metadata.duration) + .filter(|duration| duration.is_finite() && *duration > 0.0) + .unwrap_or_default(); + (state.pending_resume_position, duration) +} + +/// Start external MPV playback after the shell has completed the request-owned +/// root handoff. Spawn failure is returned to the shell so restoration happens +/// before an in-process fallback is opened. fn start_external_mpv_with_current_url

( context: &mut PlaybackUpdateContext<'_>, + request: PlaybackRequestId, ) -> DomainUpdateResult, PlaybackWindowEvent> where P: PlaybackUpdatePort + 'static, { let state = &mut *context.playback; + if state.root_shutdown_blocks_launch() { + if state.root_shutdown_failed { + context.ui.set_video_error( + "External playback is blocked because previous root teardown could not be proven complete" + .to_string(), + ); + } + return DomainUpdateResult::task(Task::none()); + } // Resolve window attributes let is_fullscreen = state.is_fullscreen; @@ -2002,13 +3002,8 @@ where let window_position = context.window_position.map(|p| (p.x as i32, p.y as i32)); - // Ensure handoff starts at the current native player position - let (observed_position, _) = current_playback_progress(state); - let resume_position = if observed_position > 0.0 { - Some(observed_position as f32) - } else { - state.pending_resume_position - }; + let (resume_position, request_duration) = + external_playback_start_projection(state); let url = state .current_source @@ -2023,20 +3018,6 @@ where .unwrap_or_else(|| Zeroizing::new(String::new())); if url.is_empty() { - if state.is_resolving_stream_url { - // URL not ready yet (e.g., tokenization async); retry shortly - info!( - "External MPV requested before stream URL resolved; retrying..." - ); - return DomainUpdateResult::task(Task::perform( - async { - tokio::time::sleep(tokio::time::Duration::from_millis(100)) - .await; - }, - |_| P::playback_message(PlayerMessage::PlayExternal), - )); - } - if !state.stream_url_resolution_failed { context.ui.set_video_error( "Playback stream is not ready. Retry playback.".to_string(), @@ -2045,8 +3026,15 @@ where return DomainUpdateResult::task(Task::none()); } - // Stop internal playback if running before handoff - state.stop_native_playback(); + let Some(generation) = state.playback_generation.next() else { + context.ui.set_video_error( + "Playback session generation exhausted".to_string(), + ); + return DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::ExternalPlaybackLaunchFailed { request }], + ); + }; match crate::external_mpv::start_external_playback( url.as_str(), @@ -2056,34 +3044,33 @@ where resume_position, ) { Ok(handle) => { - let Some(generation) = state.playback_generation.next() else { - context.ui.set_video_error( - "Playback session generation exhausted".to_string(), - ); - return DomainUpdateResult::task(Task::none()); - }; state.playback_generation = generation; state.external_mpv_handle = Some(Box::new(handle)); state.begin_external_playback( + request, + state.current_media_id, generation, resume_position.unwrap_or_default() as f64, - state.last_valid_duration, + request_duration, is_fullscreen, ); context.ui.set_player_view(); DomainUpdateResult::task(Task::done(P::playback_message( - PlayerMessage::ExternalPlaybackStarted, + PlayerMessage::ExternalPlaybackStarted { request }, ))) } Err(e) => { - // Fallback to internal provider error!( - "Failed to start external MPV (falling back to internal): {}", - e + "Failed to start external MPV; restoring shell before internal fallback: {e}" ); - state.clear_external_playback(); - DomainUpdateResult::task(load_video::

(state, context.ui)) + state.clear_external_playback_for_request(Some(request)); + DomainUpdateResult::with_events( + Task::none(), + vec![PlaybackWindowEvent::ExternalPlaybackLaunchFailed { + request, + }], + ) } } } @@ -2137,6 +3124,7 @@ fn external_mpv_url(source: &PlaybackSource) -> Zeroizing { #[cfg(test)] mod tests { use super::*; + use ferrex_core::player_prelude::{LibraryId, MediaFile}; use ferrex_player_api::testing::TestApiService; use std::sync::{Arc, Mutex}; @@ -2152,6 +3140,12 @@ mod tests { static RECORDS: Mutex> = Mutex::new(Vec::new()); static RECORDING_TEST: Mutex<()> = Mutex::new(()); + fn recording_test_guard() -> std::sync::MutexGuard<'static, ()> { + RECORDING_TEST + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + struct RecordingPort; impl PlaybackUpdatePort for RecordingPort { @@ -2194,171 +3188,1119 @@ mod tests { } } - #[derive(Default)] - struct TestUi { - player: bool, - error: Option, + #[derive(Default)] + struct TestUi { + player: bool, + error: Option, + } + + impl PlaybackUiShell for TestUi { + fn is_player_view(&self) -> bool { + self.player + } + + fn set_player_view(&mut self) { + self.player = true; + } + + fn set_loading_video_view(&mut self, _url: String) { + self.player = false; + } + + fn set_video_error(&mut self, message: String) { + self.error = Some(message); + } + + fn clear_error(&mut self) { + self.error = None; + } + } + + struct NoopWatchProgress; + + impl PlaybackWatchProgressPort for NoopWatchProgress { + fn take_pending_resume_position(&mut self) -> Option { + None + } + } + + struct NoopEpisodes; + + impl PlaybackEpisodeNavigator for NoopEpisodes { + fn next_episode(&self, _current: EpisodeID) -> Option { + None + } + + fn previous_episode(&self, _current: EpisodeID) -> Option { + None + } + } + + fn reduce_test_message( + playback: &mut PlayerDomainState, + ui: &mut TestUi, + message: PlayerMessage, + ) -> DomainUpdateResult, PlaybackWindowEvent> { + let mut watch_progress = NoopWatchProgress; + let episodes = NoopEpisodes; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback, + watch_progress: &mut watch_progress, + ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + update_player::(&mut context, message) + } + + fn test_source(path: &str) -> PlaybackSource { + PlaybackSource::new( + url::Url::parse(&format!("https://ferrex.example/{path}")).unwrap(), + ) + } + + fn test_media_file(name: &str) -> (MediaFile, MediaID) { + let movie_id = MovieID::new_uuid(); + let media_id = MediaID::Movie(movie_id); + ( + MediaFile { + id: movie_id.to_uuid(), + media_id, + path: std::path::PathBuf::from(format!("/tmp/{name}.mkv")), + filename: format!("{name}.mkv"), + size: 1, + discovered_at: ferrex_core::types::chrono::Utc::now(), + created_at: ferrex_core::types::chrono::Utc::now(), + media_file_metadata: None, + library_id: LibraryId::new(), + }, + media_id, + ) + } + + struct TestEpisodes { + next: EpisodeID, + previous: EpisodeID, + } + + impl PlaybackEpisodeNavigator for TestEpisodes { + fn next_episode(&self, _current: EpisodeID) -> Option { + Some(self.next) + } + + fn previous_episode(&self, _current: EpisodeID) -> Option { + Some(self.previous) + } + } + + fn record_terminal_update(message: PlayerMessage) -> Vec { + RECORDS.lock().unwrap().clear(); + let media_id = MediaID::Movie(MovieID::new_uuid()); + let mut playback = PlayerDomainState { + current_media_id: Some(media_id), + last_valid_position: 42.5, + last_valid_duration: 100.0, + ..PlayerDomainState::default() + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; + let episodes = NoopEpisodes; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + + drop(update_player::(&mut context, message)); + std::mem::take(&mut *RECORDS.lock().unwrap()) + } + + #[test] + fn stop_and_eof_persist_final_progress_before_exit() { + let _serial = recording_test_guard(); + for message in [PlayerMessage::Stop, PlayerMessage::EndOfStream] { + let records = record_terminal_update(message); + assert!(matches!( + records.first(), + Some(RecordedMessage::Progress(_, 42.5, 100.0)) + )); + assert!(records.contains(&RecordedMessage::Playback( + "ResetAfterStop".to_string() + ))); + assert!(records.contains(&RecordedMessage::Back)); + } + } + + #[test] + fn back_and_home_navigation_checkpoint_then_exit_playback() { + let _serial = recording_test_guard(); + for (message, destination) in [ + (PlayerMessage::NavigateBack, RecordedMessage::Back), + (PlayerMessage::NavigateHome, RecordedMessage::Home), + ] { + let records = record_terminal_update(message); + assert!(matches!( + records.first(), + Some(RecordedMessage::Progress(_, 42.5, 100.0)) + )); + assert!(records.contains(&RecordedMessage::Playback( + "ResetAfterStop".to_string() + ))); + assert!(records.contains(&destination)); + } + } + + #[test] + fn reset_after_stop_emits_one_backend_neutral_host_restore() { + let _serial = recording_test_guard(); + RECORDS.lock().unwrap().clear(); + let mut playback = PlayerDomainState { + current_media_id: Some(MediaID::Movie(MovieID::new_uuid())), + last_valid_position: 42.5, + ..PlayerDomainState::default() + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi::default(); + let episodes = NoopEpisodes; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + + let result = update_player::( + &mut context, + PlayerMessage::ResetAfterStop(None), + ); + + assert!(playback.current_media_id.is_none()); + assert!(matches!( + result.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { request: None }] + )); + } + + #[test] + fn resolved_integrated_source_defers_backend_open_to_shell_handoff() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState { + backend_request: BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ), + ..PlayerDomainState::default() + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi::default(); + let episodes = NoopEpisodes; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + let source = PlaybackSource::new( + url::Url::parse("https://ferrex.example/api/v1/stream/media") + .unwrap(), + ); + + let result = update_player::( + &mut context, + PlayerMessage::SetStreamSource(source), + ); + + assert!(context.playback.current_source.is_some()); + assert!(context.playback.video_opt.is_none()); + assert!(matches!( + result.events.as_slice(), + [PlaybackWindowEvent::BeginIntegratedPlayback { request }] + if *request == context.playback.active_playback_request.unwrap() + )); + } + + #[test] + fn external_media_request_allocates_intent_atomically() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState::default(); + let mut ui = TestUi::default(); + let (media, media_id) = test_media_file("external-atomic"); + + let result = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::PlayMediaWithIdExternally(media, media_id), + ); + + let request = playback.active_playback_request.expect("request"); + assert_eq!(playback.external_playback_intent_request, Some(request)); + assert!(playback.is_resolving_stream_url); + assert!(result.events.is_empty()); + assert!(playback.external_mpv_handle.is_none()); + } + + #[test] + fn resolved_external_source_defers_spawn_to_shell_handoff() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState::default(); + let mut ui = TestUi::default(); + let request = playback.begin_playback_request().unwrap(); + assert!(playback.request_external_playback(request)); + + let result = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::StreamSourceResolved { + request, + source: test_source("external-shell-order"), + }, + ); + + assert!(playback.external_mpv_handle.is_none()); + assert!(playback.video_opt.is_none()); + assert!(matches!( + result.events.as_slice(), + [PlaybackWindowEvent::BeginExternalPlayback { + request: emitted + }] if *emitted == request + )); + } + + #[test] + fn external_replacement_waits_for_root_absence_before_shell_handoff() { + let _serial = recording_test_guard(); + let retired = PlaybackRequestId::new(40); + let mut playback = PlayerDomainState { + root_shutdown_in_progress: true, + root_shutdown_retired_request: Some(retired), + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + let request = playback.begin_playback_request().unwrap(); + assert!(playback.request_external_playback(request)); + + let deferred = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::StreamSourceResolved { + request, + source: test_source("external-deferred"), + }, + ); + assert!(deferred.events.is_empty()); + assert!(playback.external_mpv_handle.is_none()); + + let completed = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::RootShutdownCompleted { + request: Some(retired), + continuation: Box::new( + PlayerMessage::ResumeExternalPlaybackAfterRootShutdown { + request, + }, + ), + }, + ); + assert!(matches!( + completed.events.as_slice(), + [PlaybackWindowEvent::BeginExternalPlayback { + request: emitted + }] if *emitted == request + )); + assert!(!playback.root_shutdown_blocks_launch()); + assert!(playback.external_mpv_handle.is_none()); + } + + #[test] + fn resolved_source_cannot_bypass_an_in_flight_root_shutdown() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState { + backend_request: BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ), + root_shutdown_in_progress: true, + ..PlayerDomainState::default() + }; + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; + let request = playback.begin_playback_request().unwrap(); + + let deferred = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::StreamSourceResolved { + request, + source: test_source("deferred"), + }, + ); + assert!(deferred.events.is_empty()); + assert!(playback.video_opt.is_none()); + assert_eq!(playback.resolved_playback_request, Some(request)); + assert!(playback.root_shutdown_in_progress); + + let completed = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::RootShutdownCompleted { + request: Some(PlaybackRequestId::new(40)), + continuation: Box::new( + PlayerMessage::ResumeResolvedStreamSourceAfterRootShutdown { + request, + retired_request: Some(PlaybackRequestId::new(40)), + }, + ), + }, + ); + assert!(!playback.root_shutdown_blocks_launch()); + assert!(matches!( + completed.events.as_slice(), + [PlaybackWindowEvent::BeginIntegratedPlayback { + request: resumed + }] if *resumed == request + )); + } + + #[test] + fn stale_replacement_completion_restores_the_retired_shell_owner() { + let _serial = recording_test_guard(); + let retired = PlaybackRequestId::new(40); + let mut playback = PlayerDomainState { + backend_request: BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ), + root_shutdown_in_progress: true, + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + let replacement = playback.begin_playback_request().unwrap(); + assert!(playback.resolve_playback_request(replacement)); + playback.set_playback_source(test_source("replacement")); + let newer = playback.begin_playback_request().unwrap(); + + let completed = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::RootShutdownCompleted { + request: Some(retired), + continuation: Box::new( + PlayerMessage::ResumeResolvedStreamSourceAfterRootShutdown { + request: replacement, + retired_request: Some(retired), + }, + ), + }, + ); + + assert_eq!(playback.active_playback_request, Some(newer)); + assert!(matches!( + completed.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { + request: Some(owner) + }] if *owner == retired + )); + } + + #[test] + fn stop_during_root_shutdown_defers_shell_restore_and_navigation() { + let _serial = recording_test_guard(); + RECORDS.lock().unwrap().clear(); + let request = PlaybackRequestId::new(40); + let mut playback = PlayerDomainState { + active_playback_request: Some(request), + resolved_playback_request: Some(request), + integrated_playback_request: Some(request), + root_shutdown_in_progress: true, + root_shutdown_retired_request: Some(request), + current_source: Some(test_source("current")), + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + + let stopped = + reduce_test_message(&mut playback, &mut ui, PlayerMessage::Stop); + assert!(stopped.events.is_empty()); + assert_eq!( + playback.root_shutdown_exit_destination, + Some(PlaybackExitDestination::Back) + ); + assert!(playback.active_playback_request.is_none()); + assert!(!RECORDS.lock().unwrap().contains(&RecordedMessage::Back)); + + let completed = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::RootShutdownCompleted { + request: Some(request), + continuation: Box::new( + PlayerMessage::ResumeExternalPlaybackAfterRootShutdown { + request, + }, + ), + }, + ); + assert!(matches!( + completed.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { + request: Some(owner) + }] if *owner == request + )); + assert!(RECORDS.lock().unwrap().contains(&RecordedMessage::Back)); + } + + #[test] + fn new_play_during_root_shutdown_waits_before_retiring_old_owner() { + let _serial = recording_test_guard(); + let retired = PlaybackRequestId::new(40); + let mut playback = PlayerDomainState { + active_playback_request: Some(retired), + resolved_playback_request: Some(retired), + integrated_playback_request: Some(retired), + root_shutdown_in_progress: true, + root_shutdown_retired_request: Some(retired), + current_source: Some(test_source("current")), + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + let (replacement, replacement_id) = test_media_file("replacement"); + + let pending = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::PlayMediaWithId(replacement, replacement_id), + ); + assert!(pending.events.is_empty()); + let replacement_request = playback + .active_playback_request + .expect("replacement request"); + assert_ne!(replacement_request, retired); + assert!(playback.is_resolving_stream_url); + + let completed = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::RootShutdownCompleted { + request: Some(retired), + continuation: Box::new( + PlayerMessage::ResumeExternalPlaybackAfterRootShutdown { + request: retired, + }, + ), + }, + ); + assert!(matches!( + completed.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { + request: Some(owner) + }] if *owner == retired + )); + assert_eq!(playback.active_playback_request, Some(replacement_request)); + } + + #[test] + fn nonintegrated_resume_retires_old_integrated_shell_owner() { + let _serial = recording_test_guard(); + let retired = PlaybackRequestId::new(40); + let mut playback = PlayerDomainState { + backend_request: BackendRequest::Auto, + root_shutdown_in_progress: true, + root_shutdown_retired_request: Some(retired), + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + let request = playback.begin_playback_request().unwrap(); + assert!(playback.resolve_playback_request(request)); + playback.set_playback_source(test_source("replacement")); + + let completed = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::RootShutdownCompleted { + request: Some(request), + continuation: Box::new(PlayerMessage::VideoReadyToPlay), + }, + ); + + assert!(matches!( + completed.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { + request: Some(owner) + }] if *owner == retired + )); + assert!(playback.video_opt.is_some()); + } + + #[test] + fn failed_root_shutdown_durably_blocks_later_backend_launches() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState { + backend_request: BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ), + root_shutdown_in_progress: true, + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + let request = playback.begin_playback_request().unwrap(); + assert!(playback.resolve_playback_request(request)); + playback.set_playback_source(test_source("blocked")); + + let failed = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::RootShutdownFailed { + request: Some(request), + message: "owner completion unavailable".to_string(), + }, + ); + assert!(failed.events.is_empty()); + assert!(playback.root_shutdown_failed); + assert!(playback.root_shutdown_blocks_launch()); + + let retry = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::VideoReadyToPlay, + ); + assert!(retry.events.is_empty()); + assert!(playback.video_opt.is_none()); + assert!(ui.error.is_some()); + } + + #[test] + fn out_of_order_source_results_keep_only_the_latest_request() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState { + backend_request: BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ), + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + let first = playback.begin_playback_request().unwrap(); + let second = playback.begin_playback_request().unwrap(); + + let accepted = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::StreamSourceResolved { + request: second, + source: test_source("second"), + }, + ); + assert!(matches!( + accepted.events.as_slice(), + [PlaybackWindowEvent::BeginIntegratedPlayback { request }] + if *request == second + )); + let selected = playback.current_url.clone(); + + let stale = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::StreamSourceResolved { + request: first, + source: test_source("first"), + }, + ); + assert!(stale.events.is_empty()); + assert_eq!(playback.current_url, selected); + assert_eq!(playback.active_playback_request, Some(second)); + assert_eq!(playback.resolved_playback_request, Some(second)); + } + + #[test] + fn play_media_supersedes_an_unopened_integrated_handoff() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState { + backend_request: BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ), + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + let old = playback.begin_playback_request().unwrap(); + assert!(playback.resolve_playback_request(old)); + playback.set_playback_source(test_source("old")); + playback.integrated_playback_request = Some(old); + let (replacement, replacement_id) = test_media_file("replacement"); + + let result = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::PlayMediaWithId(replacement, replacement_id), + ); + + assert!(matches!( + result.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { + request: Some(request) + }] if *request == old + )); + let replacement_request = playback + .active_playback_request + .expect("replacement request"); + assert!(replacement_request > old); + assert_eq!(playback.current_media_id, Some(replacement_id)); + assert!(playback.current_source.is_none()); + assert!(playback.is_resolving_stream_url); + + let stale = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::OpenResolvedStreamSource { request: old }, + ); + assert!(stale.events.is_empty()); + assert!(playback.video_opt.is_none()); + assert_eq!(playback.active_playback_request, Some(replacement_request)); } - impl PlaybackUiShell for TestUi { - fn is_player_view(&self) -> bool { - self.player - } - - fn set_player_view(&mut self) { - self.player = true; - } + #[test] + fn replacement_authorization_keeps_live_external_shell_owner_hidden() { + let _serial = recording_test_guard(); + let (old_media, old_media_id) = test_media_file("external-old"); + let mut playback = PlayerDomainState { + current_media: Some(old_media), + current_media_id: Some(old_media_id), + ..PlayerDomainState::default() + }; + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; + let old = playback.begin_playback_request().unwrap(); + assert!(playback.resolve_playback_request(old)); + assert!(playback.request_external_playback(old)); + playback.set_playback_source(test_source("external-old")); + playback.begin_external_playback( + old, + Some(old_media_id), + crate::contract::SessionGeneration::new(12), + 15.0, + 90.0, + false, + ); + playback.last_valid_position = 15.0; + playback.last_valid_duration = 90.0; + let (replacement, replacement_id) = + test_media_file("external-replacement"); + + let result = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::PlayMediaWithId(replacement, replacement_id), + ); - fn set_loading_video_view(&mut self, _url: String) { - self.player = false; - } + assert!( + result.events.is_empty(), + "authorizing a replacement must not restore the shell while the old external root is live" + ); + assert_eq!(playback.external_playback_request, Some(old)); + assert_eq!(playback.external_media_id, Some(old_media_id)); + assert_eq!(playback.last_valid_position, 15.0); + assert_eq!(playback.last_valid_duration, 90.0); + assert_eq!(playback.current_media_id, Some(replacement_id)); + assert!(playback.is_resolving_stream_url); + assert!( + playback + .active_playback_request + .is_some_and(|new| new > old) + ); + } - fn set_video_error(&mut self, message: String) { - self.error = Some(message); - } + #[test] + fn external_replacement_start_uses_only_replacement_progress() { + let _serial = recording_test_guard(); + let (old_media, old_media_id) = test_media_file("external-old"); + let mut playback = PlayerDomainState { + current_media: Some(old_media), + current_media_id: Some(old_media_id), + ..PlayerDomainState::default() + }; + let old = playback.begin_playback_request().unwrap(); + assert!(playback.resolve_playback_request(old)); + assert!(playback.request_external_playback(old)); + playback.begin_external_playback( + old, + Some(old_media_id), + crate::contract::SessionGeneration::new(12), + 15.0, + 90.0, + false, + ); - fn clear_error(&mut self) { - self.error = None; - } - } + let (replacement, replacement_id) = + test_media_file("external-replacement"); + let replacement_request = playback.begin_playback_request().unwrap(); + playback.current_media = Some(replacement); + playback.current_media_id = Some(replacement_id); + playback.pending_resume_position = Some(3.0); + playback.last_valid_position = 15.0; + playback.last_valid_duration = 90.0; + playback.root_shutdown_in_progress = true; + playback.root_shutdown_retired_request = Some(old); + let mut ui = TestUi { + player: true, + ..TestUi::default() + }; - struct NoopWatchProgress; + let completion = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::RootShutdownCompleted { + request: Some(old), + continuation: Box::new( + PlayerMessage::ResumeExternalPlaybackAfterRootShutdown { + request: replacement_request, + }, + ), + }, + ); - impl PlaybackWatchProgressPort for NoopWatchProgress { - fn take_pending_resume_position(&mut self) -> Option { - None - } + assert!(matches!( + completion.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { + request: Some(request) + }] if *request == old + )); + assert!(playback.external_mpv_snapshot.is_none()); + assert_eq!( + external_playback_start_projection(&playback), + (Some(3.0), 0.0) + ); } - struct NoopEpisodes; - - impl PlaybackEpisodeNavigator for NoopEpisodes { - fn next_episode(&self, _current: EpisodeID) -> Option { - None + #[test] + fn stale_failure_open_and_video_completion_cannot_mutate_latest_request() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState { + backend_request: BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ), + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + let first = playback.begin_playback_request().unwrap(); + assert!(playback.resolve_playback_request(first)); + playback.set_playback_source(test_source("first")); + let second = playback.begin_playback_request().unwrap(); + let _ = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::StreamSourceResolved { + request: second, + source: test_source("second"), + }, + ); + let selected = playback.current_url.clone(); + + for stale in [ + PlayerMessage::StreamUrlResolutionFailed { + request: first, + message: "stale failure".to_string(), + }, + PlayerMessage::OpenResolvedStreamSource { request: first }, + PlayerMessage::VideoLoaded { + request: first, + success: false, + }, + ] { + let result = reduce_test_message(&mut playback, &mut ui, stale); + assert!(result.events.is_empty()); + assert_eq!(playback.current_url, selected); + assert_eq!(playback.active_playback_request, Some(second)); + assert!(ui.error.is_none()); + assert!(!playback.is_loading_video); } + } - fn previous_episode(&self, _current: EpisodeID) -> Option { - None - } + #[test] + fn reset_invalidates_old_results_without_reusing_request_identity() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState::default(); + let mut ui = TestUi::default(); + let old = playback.begin_playback_request().unwrap(); + assert!(playback.resolve_playback_request(old)); + playback.set_playback_source(test_source("old")); + + let result = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::ResetAfterStop(Some(old)), + ); + assert!(matches!( + result.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { + request: Some(request) + }] if *request == old + )); + let new = playback.begin_playback_request().unwrap(); + assert!(new > old); + + let stale = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::StreamSourceResolved { + request: old, + source: test_source("stale"), + }, + ); + assert!(stale.events.is_empty()); + assert!(playback.current_source.is_none()); + assert_eq!(playback.active_playback_request, Some(new)); } - struct TestEpisodes { - next: EpisodeID, - previous: EpisodeID, + #[test] + fn requestless_stale_reset_never_tears_down_a_live_request() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState::default(); + let mut ui = TestUi::default(); + let request = playback.begin_playback_request().unwrap(); + assert!(playback.resolve_playback_request(request)); + playback.set_playback_source(test_source("live")); + + let result = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::ResetAfterStop(None), + ); + assert!(result.events.is_empty()); + assert_eq!(playback.active_playback_request, Some(request)); + assert_eq!(playback.resolved_playback_request, Some(request)); + assert!(playback.current_source.is_some()); } - impl PlaybackEpisodeNavigator for TestEpisodes { - fn next_episode(&self, _current: EpisodeID) -> Option { - Some(self.next) - } + #[test] + fn integrated_provenance_restores_shell_for_embedded_runtime_fallback() { + let request = PlaybackRequestId::new(9); + let mut state = state_with_presenter_snapshot( + crate::contract::SessionGeneration::new(12), + crate::contract::PlaybackTarget::GSTREAMER_EMBEDDED, + crate::contract::PresenterState::Detached, + ); + state.active_playback_request = Some(request); + state.resolved_playback_request = Some(request); + state.session_playback_request = Some(request); + state.integrated_playback_request = Some(request); - fn previous_episode(&self, _current: EpisodeID) -> Option { - Some(self.previous) - } + assert!(matches!( + take_native_presenter_window_event(&mut state), + Some(PlaybackWindowEvent::NativePresenterUnavailable { + request: event_request, + effective_target + }) if event_request == request + && effective_target + == crate::contract::PlaybackTarget::GSTREAMER_EMBEDDED + )); } - fn record_terminal_update(message: PlayerMessage) -> Vec { + #[test] + fn older_session_failure_exits_without_consuming_replacement_request() { + let _serial = recording_test_guard(); RECORDS.lock().unwrap().clear(); - let media_id = MediaID::Movie(MovieID::new_uuid()); + let (_, old_media_id) = test_media_file("old"); + let (replacement, replacement_id) = test_media_file("replacement"); + let old_request = PlaybackRequestId::new(8); + let replacement_request = PlaybackRequestId::new(9); + let requested_target = crate::contract::PlaybackTarget::MPV_INTEGRATED; let mut playback = PlayerDomainState { - current_media_id: Some(media_id), - last_valid_position: 42.5, - last_valid_duration: 100.0, + current_media: Some(replacement), + current_media_id: Some(replacement_id), + active_playback_request: Some(replacement_request), + session_playback_request: Some(old_request), + session_media_id: Some(old_media_id), + is_resolving_stream_url: true, + backend_request: BackendRequest::Exact(requested_target), + last_valid_position: 7.0, + last_valid_duration: 90.0, ..PlayerDomainState::default() }; - let mut watch_progress = NoopWatchProgress; let mut ui = TestUi { player: true, ..TestUi::default() }; - let episodes = NoopEpisodes; - let api_service: Arc = - Arc::new(TestApiService::new("https://ferrex.example")); - let mut context = PlaybackUpdateContext { - playback: &mut playback, - watch_progress: &mut watch_progress, - ui: &mut ui, - episodes: &episodes, - api_service, - server_url: "https://ferrex.example", - window_size: iced::Size::new(1280.0, 720.0), - window_position: None, - }; + let mut failed = PlaybackSnapshot::new( + crate::contract::SessionGeneration::new(17), + requested_target, + crate::contract::PlaybackCapabilities::default(), + ); + failed.state = PlaybackState::Failed; + failed.position = Duration::from_millis(12_500); + failed.duration = Some(Duration::from_secs(100)); + failed.last_error = Some(crate::contract::PlaybackError::new( + crate::contract::PlaybackErrorKind::Unknown, + "old session failed", + )); - drop(update_player::(&mut context, message)); - std::mem::take(&mut *RECORDS.lock().unwrap()) - } + drop( + handle_synchronized_terminal::( + &mut playback, + &mut ui, + &failed, + ) + .expect("terminal dispatch"), + ); - #[test] - fn stop_and_eof_persist_final_progress_before_exit() { - let _serial = RECORDING_TEST.lock().unwrap(); - for message in [PlayerMessage::Stop, PlayerMessage::EndOfStream] { - let records = record_terminal_update(message); - assert!(matches!( - records.first(), - Some(RecordedMessage::Progress(_, 42.5, 100.0)) - )); - assert!(records.contains(&RecordedMessage::Playback( - "ResetAfterStop".to_string() - ))); - assert!(records.contains(&RecordedMessage::Back)); - } - } + assert!(playback.session_playback_request.is_none()); + assert!(playback.session_media_id.is_none()); + assert_eq!(playback.active_playback_request, Some(replacement_request)); + assert!(playback.is_resolving_stream_url); + assert_eq!( + playback.backend_request, + BackendRequest::Exact(requested_target) + ); + let records = std::mem::take(&mut *RECORDS.lock().unwrap()); + assert!(records.contains(&RecordedMessage::Progress( + old_media_id, + 12.5, + 100.0 + ))); + assert!(records.contains(&RecordedMessage::Playback( + "ResetAfterStop".to_string() + ))); + assert_eq!(ui.error, None); - #[test] - fn back_and_home_navigation_checkpoint_then_exit_playback() { - let _serial = RECORDING_TEST.lock().unwrap(); - for (message, destination) in [ - (PlayerMessage::NavigateBack, RecordedMessage::Back), - (PlayerMessage::NavigateHome, RecordedMessage::Home), - ] { - let records = record_terminal_update(message); - assert!(matches!( - records.first(), - Some(RecordedMessage::Progress(_, 42.5, 100.0)) - )); - assert!(records.contains(&RecordedMessage::Playback( - "ResetAfterStop".to_string() - ))); - assert!(records.contains(&destination)); - } + let exit = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::ResetAfterStop(Some(old_request)), + ); + assert!(matches!( + exit.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { + request: Some(request) + }] if *request == old_request + )); + assert_eq!(playback.active_playback_request, Some(replacement_request)); + assert_eq!(playback.current_media_id, Some(replacement_id)); + assert!(playback.is_resolving_stream_url); } #[test] - fn reset_after_stop_emits_one_backend_neutral_host_restore() { - let _serial = RECORDING_TEST.lock().unwrap(); + fn stop_during_replacement_auth_persists_the_visible_session_media() { + let _serial = recording_test_guard(); RECORDS.lock().unwrap().clear(); + let (_, visible_media_id) = test_media_file("visible"); + let (replacement, replacement_id) = test_media_file("replacement"); + let visible_request = PlaybackRequestId::new(20); + let replacement_request = PlaybackRequestId::new(21); let mut playback = PlayerDomainState { - current_media_id: Some(MediaID::Movie(MovieID::new_uuid())), - last_valid_position: 42.5, + current_media: Some(replacement), + current_media_id: Some(replacement_id), + active_playback_request: Some(replacement_request), + session_playback_request: Some(visible_request), + session_media_id: Some(visible_media_id), + is_resolving_stream_url: true, + last_valid_position: 22.5, + last_valid_duration: 100.0, ..PlayerDomainState::default() }; - let mut watch_progress = NoopWatchProgress; - let mut ui = TestUi::default(); - let episodes = NoopEpisodes; - let api_service: Arc = - Arc::new(TestApiService::new("https://ferrex.example")); - let mut context = PlaybackUpdateContext { - playback: &mut playback, - watch_progress: &mut watch_progress, - ui: &mut ui, - episodes: &episodes, - api_service, - server_url: "https://ferrex.example", - window_size: iced::Size::new(1280.0, 720.0), - window_position: None, + let mut ui = TestUi { + player: true, + ..TestUi::default() }; - let result = update_player::( - &mut context, - PlayerMessage::ResetAfterStop, - ); + let result = + reduce_test_message(&mut playback, &mut ui, PlayerMessage::Stop); - assert!(playback.current_media_id.is_none()); assert!(matches!( result.events.as_slice(), - [PlaybackWindowEvent::PlaybackExited] + [PlaybackWindowEvent::PlaybackExited { + request: Some(request) + }] if *request == visible_request + )); + let records = std::mem::take(&mut *RECORDS.lock().unwrap()); + assert!(matches!( + records.first(), + Some(RecordedMessage::Progress(media_id, 22.5, 100.0)) + if *media_id == visible_media_id )); + assert!(!records.iter().any(|record| { + matches!( + record, + RecordedMessage::Progress(media_id, _, _) + if *media_id == replacement_id + ) + })); + } + + #[test] + fn integrated_pre_open_failures_request_shell_restoration() { + let _serial = recording_test_guard(); + RECORDS.lock().unwrap().clear(); + for backend_open_failed in [false, true] { + let mut playback = PlayerDomainState { + backend_request: BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ), + ..PlayerDomainState::default() + }; + let request = playback.begin_playback_request().unwrap(); + let message = if backend_open_failed { + PlayerMessage::VideoLoaded { + request, + success: false, + } + } else { + PlayerMessage::StreamUrlResolutionFailed { + request, + message: "authorization failed".to_string(), + } + }; + let mut watch_progress = NoopWatchProgress; + let mut ui = TestUi::default(); + let episodes = NoopEpisodes; + let api_service: Arc = + Arc::new(TestApiService::new("https://ferrex.example")); + let mut context = PlaybackUpdateContext { + playback: &mut playback, + watch_progress: &mut watch_progress, + ui: &mut ui, + episodes: &episodes, + api_service, + server_url: "https://ferrex.example", + window_size: iced::Size::new(1280.0, 720.0), + window_position: None, + }; + + let result = update_player::(&mut context, message); + + assert!(context.playback.video_opt.is_none()); + assert!(matches!( + result.events.as_slice(), + [PlaybackWindowEvent::PlaybackExited { + request: Some(event_request) + }] if *event_request == request + )); + } } fn state_with_presenter_snapshot( @@ -2374,6 +4316,9 @@ mod tests { snapshot.presenter = presenter; PlayerDomainState { external_mpv_snapshot: Some(snapshot), + active_playback_request: Some(PlaybackRequestId::new(41)), + resolved_playback_request: Some(PlaybackRequestId::new(41)), + session_playback_request: Some(PlaybackRequestId::new(41)), ..PlayerDomainState::default() } } @@ -2389,7 +4334,9 @@ mod tests { assert!(matches!( take_native_presenter_window_event(&mut state), - Some(PlaybackWindowEvent::NativePresenterAttached) + Some(PlaybackWindowEvent::NativePresenterAttached { + request + }) if request == PlaybackRequestId::new(41) )); for presenter in [ @@ -2412,7 +4359,12 @@ mod tests { .presenter = crate::contract::PresenterState::Failed; assert!(matches!( take_native_presenter_window_event(&mut state), - Some(PlaybackWindowEvent::NativePresenterUnavailable) + Some(PlaybackWindowEvent::NativePresenterUnavailable { + request, + effective_target + }) if request == PlaybackRequestId::new(41) + && effective_target + == crate::contract::PlaybackTarget::MPV_INTEGRATED )); assert!(take_native_presenter_window_event(&mut state).is_none()); @@ -2425,7 +4377,9 @@ mod tests { snapshot.presenter = crate::contract::PresenterState::Attached; assert!(matches!( take_native_presenter_window_event(&mut state), - Some(PlaybackWindowEvent::NativePresenterAttached) + Some(PlaybackWindowEvent::NativePresenterAttached { + request + }) if request == PlaybackRequestId::new(41) )); state .external_mpv_snapshot @@ -2434,7 +4388,12 @@ mod tests { .presenter = crate::contract::PresenterState::Failed; assert!(matches!( take_native_presenter_window_event(&mut state), - Some(PlaybackWindowEvent::NativePresenterUnavailable) + Some(PlaybackWindowEvent::NativePresenterUnavailable { + request, + effective_target + }) if request == PlaybackRequestId::new(41) + && effective_target + == crate::contract::PlaybackTarget::MPV_INTEGRATED )); } @@ -2460,7 +4419,12 @@ mod tests { assert!(matches!( take_native_presenter_window_event(&mut state), - Some(PlaybackWindowEvent::NativePresenterUnavailable) + Some(PlaybackWindowEvent::NativePresenterUnavailable { + request, + effective_target + }) if request == PlaybackRequestId::new(41) + && effective_target + == crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW )); assert!(take_native_presenter_window_event(&mut state).is_none()); @@ -2471,7 +4435,12 @@ mod tests { .generation = generation.next().expect("next generation"); assert!(matches!( take_native_presenter_window_event(&mut state), - Some(PlaybackWindowEvent::NativePresenterUnavailable) + Some(PlaybackWindowEvent::NativePresenterUnavailable { + request, + effective_target + }) if request == PlaybackRequestId::new(41) + && effective_target + == crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW )); } @@ -2518,9 +4487,9 @@ mod tests { #[test] fn native_window_close_persists_progress_and_does_not_advance_episode() { - let _serial = RECORDING_TEST.lock().unwrap(); + let _serial = recording_test_guard(); let media_id = MediaID::Episode(EpisodeID::new()); - let state = PlayerDomainState { + let mut state = PlayerDomainState { current_media_id: Some(media_id), ..PlayerDomainState::default() }; @@ -2541,7 +4510,7 @@ mod tests { snapshot.end_reason = Some(reason); drop(finish_terminated_playback::( - &state, &snapshot, + &mut state, &snapshot, )); let records = std::mem::take(&mut *RECORDS.lock().unwrap()); @@ -2564,7 +4533,7 @@ mod tests { #[test] fn episode_transitions_persist_progress_and_preserve_backend_mode() { - let _serial = RECORDING_TEST.lock().unwrap(); + let _serial = recording_test_guard(); let cases = [ (BackendRequest::Auto, false, PlaybackStartMode::Internal), ( @@ -2646,7 +4615,7 @@ mod tests { #[test] #[ignore = "requires generated fixtures, linked libmpv, and a working desktop VO"] fn linked_native_window_eof_reloads_next_episode_with_same_backend() { - let _serial = RECORDING_TEST.lock().unwrap(); + let _serial = recording_test_guard(); RECORDS.lock().unwrap().clear(); let fixture = std::env::var_os("FERREX_MPV_SMOKE_MEDIA") @@ -2818,12 +4787,12 @@ mod tests { first_generation.get(), second_generation.get() ); - close_video(context.playback); + let _ = close_video(context.playback); } #[test] fn external_terminal_snapshot_survives_handle_drop_and_advances_episode() { - let _serial = RECORDING_TEST.lock().unwrap(); + let _serial = recording_test_guard(); RECORDS.lock().unwrap().clear(); let current = EpisodeID::new(); let next = EpisodeID::new(); @@ -2838,9 +4807,15 @@ mod tests { snapshot.end_reason = Some(EndReason::Eof); snapshot.position = Duration::from_millis(42_500); snapshot.duration = Some(Duration::from_secs(100)); + let request = PlaybackRequestId::new(13); let mut playback = PlayerDomainState { current_media_id: Some(MediaID::Episode(current)), + active_playback_request: Some(request), + resolved_playback_request: Some(request), + external_playback_intent_request: Some(request), + external_playback_request: Some(request), + external_media_id: Some(MediaID::Episode(current)), external_mpv_snapshot: Some(snapshot), ..PlayerDomainState::default() }; @@ -2864,7 +4839,7 @@ mod tests { drop(update_player::( &mut context, - PlayerMessage::ExternalPlaybackEnded, + PlayerMessage::ExternalPlaybackEnded { request }, )); let records = std::mem::take(&mut *RECORDS.lock().unwrap()); @@ -2881,9 +4856,44 @@ mod tests { assert!(playback.external_mpv_snapshot.is_none()); } + #[test] + fn stale_external_end_cannot_retire_the_current_process_projection() { + let _serial = recording_test_guard(); + let current = PlaybackRequestId::new(22); + let stale = PlaybackRequestId::new(21); + let mut snapshot = PlaybackSnapshot::new( + crate::contract::SessionGeneration::new(8), + crate::contract::PlaybackTarget::EXTERNAL_MPV, + crate::contract::PlaybackCapabilities::default(), + ); + snapshot.state = PlaybackState::Playing; + let mut playback = PlayerDomainState { + external_playback_request: Some(current), + external_mpv_snapshot: Some(snapshot), + ..PlayerDomainState::default() + }; + let mut ui = TestUi::default(); + + let result = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::ExternalPlaybackEnded { request: stale }, + ); + + assert!(result.events.is_empty()); + assert_eq!(playback.external_playback_request, Some(current)); + assert_eq!( + playback + .external_mpv_snapshot + .as_ref() + .map(|snapshot| snapshot.state), + Some(PlaybackState::Playing) + ); + } + #[test] fn external_snapshot_participates_in_progress_heartbeat() { - let _serial = RECORDING_TEST.lock().unwrap(); + let _serial = recording_test_guard(); RECORDS.lock().unwrap().clear(); let media_id = MediaID::Movie(MovieID::new_uuid()); let mut snapshot = PlaybackSnapshot::new( @@ -3107,7 +5117,7 @@ mod tests { #[test] fn synchronized_eof_and_backend_error_have_one_terminal_dispatch() { - let _serial = RECORDING_TEST.lock().unwrap(); + let _serial = recording_test_guard(); let media_id = MediaID::Movie(MovieID::new_uuid()); let mut ui = TestUi { player: true, diff --git a/crates/ferrex-player-playback/src/video.rs b/crates/ferrex-player-playback/src/video.rs index 264036e7..95d2dd31 100644 --- a/crates/ferrex-player-playback/src/video.rs +++ b/crates/ferrex-player-playback/src/video.rs @@ -10,7 +10,7 @@ use crate::{ PlaybackCommand, PlaybackContentFit, PlaybackError, PlaybackSource, SessionGeneration, }, - session::PlaybackSession, + session::{PlaybackSession, PlaybackShutdownBarrier}, state::PlayerDomainState, subwave_adapter::SubwavePlaybackAdapter, update::{PlaybackUiShell, PlaybackUpdatePort}, @@ -30,17 +30,41 @@ use std::time::Duration; ), profiling::function )] -pub fn close_video(state: &mut PlayerDomainState) { +pub(crate) fn close_video( + state: &mut PlayerDomainState, +) -> Option { + let mut shutdown_barrier = None; + let retired_request = state + .session_playback_request + .or(state.integrated_playback_request); if let Some(mut video) = state.video_opt.take() { log::info!("Closing video"); let _ = video.apply_command(PlaybackCommand::Stop); + shutdown_barrier = match video.begin_shutdown_barrier() { + Ok(barrier) => barrier, + Err(error) => { + log::error!( + "Playback shutdown could not establish its completion barrier: {error}" + ); + Some(PlaybackShutdownBarrier::failed(error.to_string())) + } + }; + if shutdown_barrier.is_some() { + state.root_shutdown_in_progress = true; + state.root_shutdown_failed = false; + state.root_shutdown_retired_request = retired_request; + state.root_shutdown_exit_destination = None; + } drop(video); } + state.session_playback_request = None; + state.session_media_id = None; state.last_valid_position = 0.0; state.last_valid_duration = 0.0; state.dragging = false; state.last_seek_position = None; state.seeking = false; + shutdown_barrier } #[cfg_attr( @@ -58,6 +82,19 @@ pub fn load_video

( where P: PlaybackUpdatePort + 'static, { + if state.root_shutdown_blocks_launch() { + if state.root_shutdown_failed { + ui.set_video_error( + "Playback is blocked because native window teardown could not be proven complete" + .to_string(), + ); + } + log::warn!( + "Playback backend launch deferred behind native shutdown proof" + ); + return Task::none(); + } + // Check if video is already loaded or loading if state.video_opt.is_some() { log::warn!("Video already loaded, skipping duplicate load"); @@ -80,7 +117,7 @@ where let duration_hint_before_close = state.last_valid_duration; // Close existing video if any (should not happen due to guard above) - close_video(state); + let _ = close_video(state); // Restore playback hints immediately so UI elements reflect intended progress if let Some(resume) = pending_resume_hint { @@ -108,6 +145,24 @@ where return Task::none(); } }; + let request = match state + .resolved_playback_request + .filter(|request| state.is_active_playback_request(*request)) + { + Some(request) => request, + None => { + let Some(request) = state.begin_playback_request() else { + ui.set_video_error( + "Playback request identity exhausted".to_string(), + ); + state.is_loading_video = false; + return Task::none(); + }; + let accepted = state.resolve_playback_request(request); + debug_assert!(accepted); + request + } + }; let url = source.uri().clone(); log::info!("=== VIDEO LOADING DEBUG ==="); @@ -116,12 +171,11 @@ where // Seed duration from server metadata. Backend/presenter selection must not // use a filename as HDR evidence; native output capability is confirmed by // the selected backend's observed snapshot/diagnostics. - if let Some(current_media) = &state.current_media { - if let Some(metadata) = ¤t_media.media_file_metadata - && let Some(duration) = metadata.duration - { - state.last_valid_duration = duration; - } + if let Some(current_media) = &state.current_media + && let Some(metadata) = ¤t_media.media_file_metadata + && let Some(duration) = metadata.duration + { + state.last_valid_duration = duration; } // Validate URL is valid UTF-8 before using @@ -146,9 +200,10 @@ where log::error!("Playback session generation exhausted"); ui.set_video_error("Playback session generation exhausted".to_string()); state.is_loading_video = false; - return Task::done(P::playback_message(PlayerMessage::VideoLoaded( - false, - ))); + return Task::done(P::playback_message(PlayerMessage::VideoLoaded { + request, + success: false, + })); }; state.playback_generation = generation; @@ -203,16 +258,26 @@ where state.terminal_generation_handled = None; state.video_opt = Some(video); + state.session_playback_request = Some(request); + state.session_media_id = state.current_media_id; state.is_loading_video = false; ui.set_player_view(); - Task::done(P::playback_message(PlayerMessage::VideoLoaded(true))) + Task::done(P::playback_message(PlayerMessage::VideoLoaded { + request, + success: true, + })) } Err(e) => { log::error!("Failed to create video: {}", e); ui.set_video_error(format!("{}", e)); state.is_loading_video = false; - Task::done(P::playback_message(PlayerMessage::VideoLoaded(false))) + state.session_playback_request = None; + state.session_media_id = None; + Task::done(P::playback_message(PlayerMessage::VideoLoaded { + request, + success: false, + })) } } } diff --git a/crates/ferrex-player-playback/src/view.rs b/crates/ferrex-player-playback/src/view.rs index fa478d86..7a94a93a 100644 --- a/crates/ferrex-player-playback/src/view.rs +++ b/crates/ferrex-player-playback/src/view.rs @@ -151,6 +151,58 @@ pub fn playback_surface_status( } } +fn native_root_drag_policy( + target: Option, + has_native_host: bool, + is_macos: bool, +) -> bool { + is_macos + && has_native_host + && target == Some(crate::contract::PlaybackTarget::MPV_INTEGRATED) +} + +/// Whether central player-surface presses should be handed to AppKit as native +/// root-window drags instead of using the ordinary video-click action. +/// +/// The native host check keeps pre-host and fallback rendering on the canonical +/// click path. The platform condition is compile-time so Windows and Linux +/// retain their existing behavior. +pub fn native_root_drag_surface_enabled( + snapshot: Option<&PlaybackSnapshot>, + native_host_window: Option, +) -> bool { + native_root_drag_policy( + snapshot.map(|snapshot| snapshot.target), + native_host_window.is_some(), + cfg!(target_os = "macos"), + ) +} + +/// Select the canonical action for a central video/background press. +pub fn central_surface_press_message( + snapshot: Option<&PlaybackSnapshot>, + native_host_window: Option, +) -> PlayerMessage { + if native_root_drag_surface_enabled(snapshot, native_host_window) { + PlayerMessage::BeginNativeRootDrag + } else { + PlayerMessage::VideoClicked + } +} + +fn shield_native_root_drag<'a>( + surface: iced::Element<'a, PlayerMessage, Theme, iced_wgpu::Renderer>, + enabled: bool, +) -> iced::Element<'a, PlayerMessage, Theme, iced_wgpu::Renderer> { + if enabled { + mouse_area(surface) + .on_press(PlayerMessage::ShowControls) + .into() + } else { + surface + } +} + /// Render a static status plate without requesting a frame-rate redraw loop. pub fn playback_status_overlay( status: PlaybackSurfaceStatus, @@ -243,8 +295,12 @@ impl PlayerDomainState { .into(); } + let native_root_drag = + native_root_drag_surface_enabled(playback, native_host_window); + let central_press = + central_surface_press_message(playback, native_host_window); if let Some(video) = self.playback_widget(native_host_window) { - let clickable_video = self.video_view(video); + let clickable_video = self.video_view(video, central_press); // Overlay stack: video, snapshot-derived transient status, then // controls. The status plate is static and never drives redraws. @@ -256,7 +312,7 @@ impl PlayerDomainState { children.push(playback_status_overlay(status)); } if self.controls { - children.push(self.controls_overlay()); + children.push(self.controls_overlay(native_root_drag)); } let player_with_overlay: iced::Element = @@ -264,7 +320,10 @@ impl PlayerDomainState { let player_with_settings: iced::Element = if self.show_settings { - let settings = self.settings_panel(); + let settings = shield_native_root_drag( + self.settings_panel(), + native_root_drag, + ); let positioned_settings = container(row![ Space::new().width(Length::Fill), container(settings) @@ -295,14 +354,15 @@ impl PlayerDomainState { Theme, iced_wgpu::Renderer, > = if self.show_quality_menu { - let quality_menu = self.quality_menu_overlay(); + let quality_menu = self.quality_menu_overlay(native_root_drag); iced::widget::Stack::with_children(vec![ player_with_settings, quality_menu, ]) .into() } else if self.show_subtitle_menu { - let subtitle_menu = self.subtitle_menu_overlay(); + let subtitle_menu = + self.subtitle_menu_overlay(native_root_drag); iced::widget::Stack::with_children(vec![ player_with_settings, subtitle_menu, @@ -362,19 +422,21 @@ impl PlayerDomainState { fn video_view<'a>( &self, player: Element<'a, PlayerMessage>, + press: PlayerMessage, ) -> Element<'a, PlayerMessage> { // Presentation redraw cadence is backend-owned. Player state arrives // through copied event signals or the bounded legacy snapshot timer, // never through a decoded-frame callback. - iced::widget::mouse_area(player) - .on_press(PlayerMessage::VideoClicked) - .into() + iced::widget::mouse_area(player).on_press(press).into() } /// Build the controls overlay - fn controls_overlay(&self) -> iced::Element<'_, PlayerMessage, Theme> { + fn controls_overlay( + &self, + shield_native_drag: bool, + ) -> iced::Element<'_, PlayerMessage, Theme> { // Delegate to controls.rs for the full implementation - self.build_controls() + self.build_controls(shield_native_drag) } /// Build the track notification overlay @@ -408,12 +470,16 @@ impl PlayerDomainState { /// Build the quality menu overlay fn quality_menu_overlay( &self, + shield_native_drag: bool, ) -> iced::Element<'_, PlayerMessage, Theme, iced_wgpu::Renderer> { // Position the menu near the quality button (bottom right) container(row![ Space::new().width(Length::Fill), - container(self.build_quality_menu()) - .style(theme::container_subtitle_menu_wrapper), + container(shield_native_root_drag( + self.build_quality_menu(), + shield_native_drag, + )) + .style(theme::container_subtitle_menu_wrapper), Space::new().width(Length::Fixed(200.0)), // Offset from right edge ]) .width(Length::Fill) @@ -430,12 +496,16 @@ impl PlayerDomainState { fn subtitle_menu_overlay( &self, + shield_native_drag: bool, ) -> iced::Element<'_, PlayerMessage, Theme, iced_wgpu::Renderer> { // Position the menu near the subtitle button (bottom right) container(row![ Space::new().width(Length::Fill), - container(self.build_subtitle_menu()) - .style(theme::container_subtitle_menu_wrapper), + container(shield_native_root_drag( + self.build_subtitle_menu(), + shield_native_drag, + )) + .style(theme::container_subtitle_menu_wrapper), Space::new().width(Length::Fixed(100.0)), // Offset from right edge ]) .width(Length::Fill) @@ -595,4 +665,29 @@ mod tests { )) ); } + + #[test] + fn native_root_drag_replaces_video_click_only_for_integrated_macos_host() { + assert!(native_root_drag_policy( + Some(PlaybackTarget::MPV_INTEGRATED), + true, + true, + )); + assert!(!native_root_drag_policy( + Some(PlaybackTarget::MPV_INTEGRATED), + false, + true, + )); + assert!(!native_root_drag_policy( + Some(PlaybackTarget::MPV_NATIVE_WINDOW), + true, + true, + )); + assert!(!native_root_drag_policy( + Some(PlaybackTarget::MPV_INTEGRATED), + true, + false, + )); + assert!(!native_root_drag_policy(None, true, true)); + } } diff --git a/crates/ferrex-player-ui/Cargo.toml b/crates/ferrex-player-ui/Cargo.toml index 17c72b1a..b97f3020 100644 --- a/crates/ferrex-player-ui/Cargo.toml +++ b/crates/ferrex-player-ui/Cargo.toml @@ -239,3 +239,11 @@ iced_runtime = { git = "https://github.com/Lowband21/iced-ferrex.git", package = #[[bench]] #name = "grid_rendering_bench" #harness = false + +[target.'cfg(target_os = "macos")'.dependencies] +# The native presenter must preserve real controller input while the Iced view +# is hosted inside mpv's AppKit window. Keep the runtime dependency macOS-only +# so other desktop targets retain their existing input stacks. +# gilrs-core 0.6.8's Windows feature guard is not target-gated, so retain the +# default feature on Darwin; Cargo still excludes its Windows-only dependency. +gilrs = "0.11.2" diff --git a/crates/ferrex-player-ui/src/common/controller_input.rs b/crates/ferrex-player-ui/src/common/controller_input.rs index 71ab1612..a8b72c5b 100644 --- a/crates/ferrex-player-ui/src/common/controller_input.rs +++ b/crates/ferrex-player-ui/src/common/controller_input.rs @@ -1,10 +1,20 @@ -//! Runtime-agnostic controller input mapping for 10-foot navigation. +//! Controller input mapping and native event delivery for 10-foot navigation. //! //! Native backends can normalize their controller events into these small enums //! and feed them through [`ControllerInputMapper`] to produce spatial actions. -//! This module deliberately avoids depending on a gamepad runtime crate. +//! The normalized contract remains runtime-neutral; macOS additionally owns a +//! native event subscription so a physical controller reaches the same +//! canonical action path as keyboard navigation. use crate::common::focus::{SpatialAction, SpatialDirection}; +use iced::Subscription; + +#[cfg(target_os = "macos")] +use futures::{StreamExt, stream::BoxStream}; +#[cfg(target_os = "macos")] +use std::{sync::OnceLock, time::Duration}; +#[cfg(target_os = "macos")] +use tokio::sync::broadcast; /// Digital controller buttons that participate in 10-foot navigation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -58,6 +68,123 @@ impl ControllerInputMapper { } } +/// Delivers normalized button presses from the platform controller runtime. +/// +/// macOS subscribes to one process-wide, bounded native gamepad event hub. +/// Other targets return no events until their platform backends are wired; the +/// public normalized contract remains identical. +pub fn native_controller_subscription() -> Subscription { + #[cfg(target_os = "macos")] + { + Subscription::run(native_controller_stream) + } + + #[cfg(not(target_os = "macos"))] + { + Subscription::none() + } +} + +#[cfg(target_os = "macos")] +const CONTROLLER_EVENT_CAPACITY: usize = 64; + +#[cfg(target_os = "macos")] +static CONTROLLER_EVENTS: OnceLock> = + OnceLock::new(); + +#[cfg(target_os = "macos")] +fn native_controller_stream() -> BoxStream<'static, ControllerButton> { + let receiver = controller_events().subscribe(); + + futures::stream::unfold(receiver, |mut receiver| async move { + loop { + match receiver.recv().await { + Ok(button) => return Some((button, receiver)), + Err(broadcast::error::RecvError::Lagged(skipped)) => { + log::warn!( + "macOS controller input dropped {skipped} stale events" + ); + } + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }) + .boxed() +} + +#[cfg(target_os = "macos")] +fn controller_events() -> &'static broadcast::Sender { + CONTROLLER_EVENTS.get_or_init(|| { + let (sender, initial_receiver) = + broadcast::channel(CONTROLLER_EVENT_CAPACITY); + drop(initial_receiver); + spawn_controller_worker(sender.clone()); + sender + }) +} + +#[cfg(target_os = "macos")] +fn spawn_controller_worker(sender: broadcast::Sender) { + let spawn_result = std::thread::Builder::new() + .name("ferrex-macos-controller".to_string()) + .spawn(move || { + let mut controllers = match gilrs::GilrsBuilder::new() + .with_force_feedback(false) + .build() + { + Ok(controllers) => controllers, + Err(error) => { + log::error!( + "macOS controller input initialization failed: {error}" + ); + return; + } + }; + + // gilrs-core's macOS IOHID implementation owns a CFRunLoop thread + // without a public cancellation handle. Keep exactly one gilrs + // instance for the process lifetime instead of constructing one + // whenever an Iced subscription snapshot changes. + loop { + let Some(event) = controllers + .next_event_blocking(Some(Duration::from_millis(250))) + else { + continue; + }; + let gilrs::EventType::ButtonPressed(button, _) = event.event + else { + continue; + }; + let Some(button) = normalize_gilrs_button(button) else { + continue; + }; + // The broadcast ring is bounded. With no active player input + // subscription this simply reports no receivers and retains + // no queued button history. + let _ = sender.send(button); + } + }); + + if let Err(error) = spawn_result { + log::error!("could not start macOS controller input worker: {error}"); + } +} + +#[cfg(target_os = "macos")] +fn normalize_gilrs_button(button: gilrs::Button) -> Option { + match button { + gilrs::Button::DPadUp => Some(ControllerButton::DPadUp), + gilrs::Button::DPadDown => Some(ControllerButton::DPadDown), + gilrs::Button::DPadLeft => Some(ControllerButton::DPadLeft), + gilrs::Button::DPadRight => Some(ControllerButton::DPadRight), + gilrs::Button::South => Some(ControllerButton::South), + gilrs::Button::East => Some(ControllerButton::East), + gilrs::Button::Start => Some(ControllerButton::Start), + gilrs::Button::Select => Some(ControllerButton::Select), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -121,4 +248,22 @@ mod tests { SpatialAction::Search ); } + + #[cfg(target_os = "macos")] + #[test] + fn gilrs_buttons_normalize_to_the_runtime_neutral_contract() { + for (native, normalized) in [ + (gilrs::Button::DPadUp, ControllerButton::DPadUp), + (gilrs::Button::DPadDown, ControllerButton::DPadDown), + (gilrs::Button::DPadLeft, ControllerButton::DPadLeft), + (gilrs::Button::DPadRight, ControllerButton::DPadRight), + (gilrs::Button::South, ControllerButton::South), + (gilrs::Button::East, ControllerButton::East), + (gilrs::Button::Start, ControllerButton::Start), + (gilrs::Button::Select, ControllerButton::Select), + ] { + assert_eq!(normalize_gilrs_button(native), Some(normalized)); + } + assert_eq!(normalize_gilrs_button(gilrs::Button::North), None); + } } diff --git a/crates/ferrex-player-ui/src/common/messages/cross_domain.rs b/crates/ferrex-player-ui/src/common/messages/cross_domain.rs index 3045454e..85cac9a1 100644 --- a/crates/ferrex-player-ui/src/common/messages/cross_domain.rs +++ b/crates/ferrex-player-ui/src/common/messages/cross_domain.rs @@ -18,6 +18,17 @@ use crate::{ }; use iced::Task; +/// Return the retained application-shell window. +/// +/// Window-management events in this module describe the shell, not whichever +/// Iced window happened to be allocated most recently. In particular, the +/// macOS native-mpv controls host is deliberately a hidden donor window whose +/// view is reparented into mpv's root. Selecting it via `window::latest()` would +/// mutate that donor instead of the user-visible root. +fn main_window_id(state: &State) -> Option { + state.windows.get(ui::windows::WindowKind::Main) +} + #[cfg_attr( any( feature = "profile-with-puffin", @@ -284,11 +295,15 @@ pub fn handle_event( let duration_hint = watch_duration_hint.or(metadata_duration_hint); - // Seed player state with progress hints so UI can update immediately - state.domains.player.state.last_valid_position = - resume_opt.map(|pos| pos as f64).unwrap_or(0.0); - state.domains.player.state.last_valid_duration = - duration_hint.unwrap_or(0.0); + // Seed an unowned projection for immediate UI feedback. A visible + // or not-yet-proven-absent root keeps ownership of `last_valid_*` + // until its terminal checkpoint has been attributed. + if !state.domains.player.state.has_observable_playback_root() { + state.domains.player.state.last_valid_position = + resume_opt.map(|pos| pos as f64).unwrap_or(0.0); + state.domains.player.state.last_valid_duration = + duration_hint.unwrap_or(0.0); + } // Store resume position so the player picks it up during PlayMediaWithId state.domains.media.state.pending_resume_position = resume_opt; @@ -314,11 +329,14 @@ pub fn handle_event( // Window management events CrossDomainEvent::HideWindow => { log::info!("[CrossDomain] Hide window requested"); - iced::window::latest().and_then(|id| { - log::info!("Hiding window with id: {:?}", id); - //iced::window::set_mode(id, iced::window::Mode::Fullscreen) - iced::window::minimize(id, true) - }) + let Some(id) = main_window_id(state) else { + log::warn!( + "[CrossDomain] Cannot hide shell: main window is not registered" + ); + return Task::none(); + }; + log::info!("Hiding main window with id: {:?}", id); + iced::window::minimize(id, true) } CrossDomainEvent::RestoreWindow(fullscreen) => { @@ -326,51 +344,98 @@ pub fn handle_event( "[CrossDomain] Restore window requested (fullscreen: {})", fullscreen ); - let minimize_task = iced::window::latest().and_then(|id| { - log::info!("Hiding window with id: {:?}", id); - //iced::window::set_mode(id, iced::window::Mode::Fullscreen) - iced::window::minimize(id, true) - }); - + let Some(id) = main_window_id(state) else { + log::warn!( + "[CrossDomain] Cannot restore shell: main window is not registered" + ); + return Task::none(); + }; let mode = if fullscreen { iced::window::Mode::Fullscreen } else { iced::window::Mode::Windowed }; - let restore_task = iced::window::latest().and_then(move |id| { - log::info!("Re storing window {:?} to mode: {:?}", id, mode); - iced::window::set_mode(id, mode) - }); - Task::batch(vec![minimize_task, restore_task]) + log::info!("Restoring main window {:?} to mode: {:?}", id, mode); + iced::window::set_mode(id, mode) + .chain(iced::window::minimize(id, false)) + .chain(iced::window::gain_focus(id)) } CrossDomainEvent::SetWindowMode(mode) => { log::info!("[CrossDomain] Set window mode: {:?}", mode); - iced::window::latest().and_then(move |id| { - log::info!("Setting window {:?} to mode: {:?}", id, mode); - iced::window::set_mode(id, mode) - }) + let Some(id) = main_window_id(state) else { + log::warn!( + "[CrossDomain] Cannot set shell mode: main window is not registered" + ); + return Task::none(); + }; + log::info!("Setting main window {:?} to mode: {:?}", id, mode); + iced::window::set_mode(id, mode) } - CrossDomainEvent::NativePresenterAttached => { + CrossDomainEvent::BeginIntegratedPlayback { request } => { + log::info!( + "[CrossDomain] Resolved integrated source ready; ordering shell hide before backend open" + ); + ui::windows::controller::begin_integrated_playback(state, request) + .task + } + + CrossDomainEvent::BeginExternalPlayback { request } => { + log::info!( + "[CrossDomain] Resolved external source ready; ordering donor close and shell hide before process spawn" + ); + ui::windows::controller::begin_external_playback(state, request) + .task + } + + CrossDomainEvent::ExternalPlaybackLaunchFailed { request } => { + log::warn!( + "[CrossDomain] External process launch failed; restoring matching shell owner before fallback" + ); + ui::windows::controller::recover_external_playback_launch( + state, request, + ) + .task + } + + CrossDomainEvent::NativePresenterAttached { request } => { log::info!( "[CrossDomain] Native presenter attached; activating player overlay" ); - ui::windows::controller::activate_player_overlay(state).task + ui::windows::controller::activate_player_overlay(state, request) + .task } - CrossDomainEvent::NativePresenterUnavailable => { + CrossDomainEvent::NativePresenterUnavailable { + request, + effective_target, + } => { log::warn!( - "[CrossDomain] Native presenter unavailable; dismissing player overlay" + "[CrossDomain] Native presenter unavailable; applying effective-target shell disposition: {:?}", + effective_target ); - ui::windows::controller::dismiss_player_overlay(state).task + if effective_target + == ferrex_player_playback::contract::PlaybackTarget::MPV_NATIVE_WINDOW + { + ui::windows::controller::dismiss_player_overlay_for_native_fallback( + state, request, + ) + .task + } else { + ui::windows::controller::dismiss_player_overlay( + state, + Some(request), + ) + .task + } } - CrossDomainEvent::PlaybackExited => { + CrossDomainEvent::PlaybackExited { request } => { log::info!( "[CrossDomain] Playback exited; restoring the retained main window" ); - ui::windows::controller::dismiss_player_overlay(state).task + ui::windows::controller::dismiss_player_overlay(state, request).task } // Media playback events @@ -653,3 +718,223 @@ fn handle_library_refresh_request(state: &State) -> Task { pub fn emit_event(event: CrossDomainEvent) -> Task { Task::done(DomainMessage::Event(event)) } + +#[cfg(test)] +mod tests { + use super::*; + use ferrex_core::player_prelude::{ + LibraryId, MediaFile, MediaID, MovieID, UserWatchState, + }; + use ferrex_player_playback::{ + contract::SessionGeneration, + messages::PlayerMessage as PlaybackPlayerMessage, + }; + use iced_runtime::{ + Action, futures::futures::StreamExt, task, + window::Action as WindowAction, + }; + + async fn output_messages( + task_to_inspect: Task, + ) -> Vec { + let Some(stream) = task::into_stream(task_to_inspect) else { + return Vec::new(); + }; + + stream + .filter_map(|action| async move { + match action { + Action::Output(message) => Some(message), + _ => None, + } + }) + .collect() + .await + } + + async fn window_actions( + task_to_inspect: Task, + ) -> Vec { + let Some(stream) = task::into_stream(task_to_inspect) else { + return Vec::new(); + }; + + stream + .filter_map(|action| async move { + match action { + Action::Window(action) => Some(action), + _ => None, + } + }) + .collect() + .await + } + + fn assert_targets_main(action: &WindowAction, main: iced::window::Id) { + let target = match action { + WindowAction::Minimize(id, _) + | WindowAction::SetMode(id, _) + | WindowAction::GainFocus(id) => *id, + _ => panic!("unexpected root-window action emitted by test"), + }; + assert_eq!(target, main); + } + + fn test_media_file(name: &str) -> (MediaFile, MediaID) { + let movie_id = MovieID::new_uuid(); + let media_id = MediaID::Movie(movie_id); + ( + MediaFile { + id: movie_id.to_uuid(), + media_id, + path: std::path::PathBuf::from(format!("/tmp/{name}.mkv")), + filename: format!("{name}.mkv"), + size: 1, + discovered_at: ferrex_core::types::chrono::Utc::now(), + created_at: ferrex_core::types::chrono::Utc::now(), + media_file_metadata: None, + library_id: LibraryId::new(), + }, + media_id, + ) + } + + #[tokio::test(flavor = "current_thread")] + async fn replacement_hint_cannot_relabel_visible_external_checkpoint() { + let mut state = State::default(); + let (old_media, old_media_id) = test_media_file("external-old"); + let old_request = + state.domains.player.state.begin_playback_request().unwrap(); + assert!( + state + .domains + .player + .state + .resolve_playback_request(old_request) + ); + assert!( + state + .domains + .player + .state + .request_external_playback(old_request) + ); + state.domains.player.state.current_media = Some(old_media); + state.domains.player.state.current_media_id = Some(old_media_id); + state.domains.player.state.begin_external_playback( + old_request, + Some(old_media_id), + SessionGeneration::new(3), + 0.0, + 100.0, + false, + ); + state.domains.player.state.last_valid_position = 0.0; + state.domains.player.state.last_valid_duration = 100.0; + + let (replacement, replacement_id) = + test_media_file("external-replacement"); + let mut watch_state = UserWatchState::new(); + watch_state.update_progress(*replacement_id.as_uuid(), 25.0, 100.0); + state.domains.media.state.user_watch_state = Some(watch_state); + + let routed = output_messages(handle_event( + &mut state, + CrossDomainEvent::MediaPlayWithId(replacement, replacement_id), + )) + .await; + assert_eq!(state.domains.player.state.last_valid_position, 0.0); + assert_eq!(state.domains.player.state.last_valid_duration, 100.0); + assert_eq!( + state.domains.player.state.pending_resume_position, + Some(25.0) + ); + + let play = routed + .into_iter() + .find_map(|message| match message { + DomainMessage::Player(message) => Some(message), + _ => None, + }) + .expect("cross-domain route must emit the player request"); + let replacement_update = + crate::domains::player::update::update_player(&mut state, play); + assert!(replacement_update.events.is_empty()); + assert_eq!( + state.domains.player.state.external_playback_request, + Some(old_request) + ); + assert_eq!(state.domains.player.state.last_valid_position, 0.0); + assert_eq!(state.domains.player.state.last_valid_duration, 100.0); + + let stopped = crate::domains::player::update::update_player( + &mut state, + PlaybackPlayerMessage::Stop, + ); + let stop_outputs = output_messages(stopped.task).await; + assert!(stop_outputs.iter().any(|message| { + matches!( + message, + DomainMessage::Media( + crate::domains::media::messages::MediaMessage::SendProgressUpdateWithData( + media_id, + position, + duration, + ) + ) if *media_id == old_media_id + && *position == 0.0 + && *duration == 100.0 + ) + })); + } + + #[tokio::test(flavor = "current_thread")] + async fn shell_root_mutators_never_select_player_overlay() { + let mut state = State::default(); + let main = iced::window::Id::unique(); + let overlay = iced::window::Id::unique(); + state.windows.set(ui::windows::WindowKind::Main, main); + // Register and focus the overlay last: this is the exact state in + // which `window::latest()` could select its hidden donor. + state + .windows + .set(ui::windows::WindowKind::PlayerOverlay, overlay); + assert!(state.windows.record_focus(overlay)); + + let cases = [ + CrossDomainEvent::HideWindow, + CrossDomainEvent::RestoreWindow(true), + CrossDomainEvent::SetWindowMode(iced::window::Mode::Windowed), + ]; + + for event in cases { + let actions = window_actions(handle_event(&mut state, event)).await; + assert!(!actions.is_empty()); + for action in &actions { + assert_targets_main(action, main); + } + } + } + + #[tokio::test(flavor = "current_thread")] + async fn shell_root_mutators_ignore_overlay_when_main_is_absent() { + let mut state = State::default(); + let overlay = iced::window::Id::unique(); + state + .windows + .set(ui::windows::WindowKind::PlayerOverlay, overlay); + assert!(state.windows.record_focus(overlay)); + + for event in [ + CrossDomainEvent::HideWindow, + CrossDomainEvent::RestoreWindow(false), + CrossDomainEvent::SetWindowMode(iced::window::Mode::Fullscreen), + ] { + assert!( + window_actions(handle_event(&mut state, event)) + .await + .is_empty() + ); + } + } +} diff --git a/crates/ferrex-player-ui/src/common/messages/mod.rs b/crates/ferrex-player-ui/src/common/messages/mod.rs index 16131d20..d9db5e62 100644 --- a/crates/ferrex-player-ui/src/common/messages/mod.rs +++ b/crates/ferrex-player-ui/src/common/messages/mod.rs @@ -240,11 +240,29 @@ pub enum CrossDomainEvent { HideWindow, // Hide the application window (e.g., for external MPV) RestoreWindow(bool), // Restore window with fullscreen state SetWindowMode(iced::window::Mode), // Set specific window mode - NativePresenterAttached, - NativePresenterUnavailable, + /// Hide the retained main window, then continue opening the already + /// resolved integrated playback source. + BeginIntegratedPlayback { + request: ferrex_player_playback::messages::PlaybackRequestId, + }, + BeginExternalPlayback { + request: ferrex_player_playback::messages::PlaybackRequestId, + }, + ExternalPlaybackLaunchFailed { + request: ferrex_player_playback::messages::PlaybackRequestId, + }, + NativePresenterAttached { + request: ferrex_player_playback::messages::PlaybackRequestId, + }, + NativePresenterUnavailable { + request: ferrex_player_playback::messages::PlaybackRequestId, + effective_target: ferrex_player_playback::contract::PlaybackTarget, + }, /// Playback teardown completed; dismiss any dedicated native-player host /// and restore the retained main window. - PlaybackExited, + PlaybackExited { + request: Option, + }, WindowResized(iced::Size), DatabaseCleared, // Database was cleared, refresh needed diff --git a/crates/ferrex-player-ui/src/domains/player/messages/subscriptions.rs b/crates/ferrex-player-ui/src/domains/player/messages/subscriptions.rs index e87ec4bc..3fd53797 100644 --- a/crates/ferrex-player-ui/src/domains/player/messages/subscriptions.rs +++ b/crates/ferrex-player-ui/src/domains/player/messages/subscriptions.rs @@ -20,6 +20,10 @@ pub fn subscription(state: &State) -> Subscription { has_active_playback: playback .is_some_and(|snapshot| snapshot.has_active_session()), playback_target: playback.map(|snapshot| snapshot.target), + native_presenter_refresh_required: player + .video_opt + .as_ref() + .is_some_and(|session| session.native_presenter_refresh_required()), controls_visible: player.controls, event_signal: player .video_opt @@ -39,6 +43,11 @@ pub fn subscription(state: &State) -> Subscription { state, ), ); + subscriptions.push( + crate::domains::ui::views::tenfoot::player_overlay::controller_subscription( + state, + ), + ); } Subscription::batch(subscriptions) diff --git a/crates/ferrex-player-ui/src/domains/player/update.rs b/crates/ferrex-player-ui/src/domains/player/update.rs index 105a77eb..eee6f26a 100644 --- a/crates/ferrex-player-ui/src/domains/player/update.rs +++ b/crates/ferrex-player-ui/src/domains/player/update.rs @@ -189,7 +189,10 @@ pub fn update_player( let api_service: Arc = Arc::clone(&state.api_service); let server_url = state.server_url.clone(); - let window_size = state.window_size; + let window_size = state + .windows + .player_overlay_size() + .unwrap_or(state.window_size); let window_position = state.window_position; let result = { @@ -223,17 +226,64 @@ pub fn update_player( PlaybackWindowEvent::RestoreWindow(fullscreen) => { CrossDomainEvent::RestoreWindow(fullscreen) } - PlaybackWindowEvent::NativePresenterAttached => { - CrossDomainEvent::NativePresenterAttached + PlaybackWindowEvent::BeginIntegratedPlayback { request } => { + CrossDomainEvent::BeginIntegratedPlayback { request } } - PlaybackWindowEvent::NativePresenterUnavailable => { - CrossDomainEvent::NativePresenterUnavailable + PlaybackWindowEvent::BeginExternalPlayback { request } => { + CrossDomainEvent::BeginExternalPlayback { request } } - PlaybackWindowEvent::PlaybackExited => { - CrossDomainEvent::PlaybackExited + PlaybackWindowEvent::ExternalPlaybackLaunchFailed { request } => { + CrossDomainEvent::ExternalPlaybackLaunchFailed { request } + } + PlaybackWindowEvent::NativePresenterAttached { request } => { + CrossDomainEvent::NativePresenterAttached { request } + } + PlaybackWindowEvent::NativePresenterUnavailable { + request, + effective_target, + } => CrossDomainEvent::NativePresenterUnavailable { + request, + effective_target, + }, + PlaybackWindowEvent::PlaybackExited { request } => { + CrossDomainEvent::PlaybackExited { request } } }) .collect(); DomainUpdateResult::with_events(result.task, events) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domains::ui::windows::WindowKind; + use ferrex_player_playback::constants::player_controls; + use iced::{Point, Size, window}; + + #[tokio::test(flavor = "current_thread")] + async fn mouse_seek_uses_live_overlay_viewport_after_native_resize() { + let mut state = State::default(); + let retained_shell_size = state.window_size; + let overlay = window::Id::unique(); + let overlay_size = Size::new(1_920.0, 1_080.0); + + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.set_player_overlay_size(overlay, overlay_size)); + state.domains.player.state.source_duration = Some(200.0); + + let seek_bar_center_y = + overlay_size.height - player_controls::SEEK_BAR_CENTER_FROM_BOTTOM; + drop(update_player( + &mut state, + PlayerMessage::MouseMoved(Point::new( + overlay_size.width / 2.0, + seek_bar_center_y, + )), + )); + + assert!(state.domains.player.state.seek_bar_hovered); + assert_eq!(state.domains.player.state.last_seek_position, Some(100.0)); + assert_eq!(state.window_size, retained_shell_size); + } +} diff --git a/crates/ferrex-player-ui/src/domains/ui/messages/subscriptions.rs b/crates/ferrex-player-ui/src/domains/ui/messages/subscriptions.rs index cc376c8c..910fe8a0 100644 --- a/crates/ferrex-player-ui/src/domains/ui/messages/subscriptions.rs +++ b/crates/ferrex-player-ui/src/domains/ui/messages/subscriptions.rs @@ -514,36 +514,28 @@ fn search_surface_key_handler( return None; } - if tenfoot_search_overlay && tenfoot_keyboard_open { - return match key { - Key::Named(Named::Escape) => { - Some(DomainMessage::Search(SearchMessage::HandleEscape)) + if tenfoot_search_overlay { + let button = match key { + Key::Named(Named::Escape) => Some(ControllerButton::East), + Key::Named(Named::Enter) => Some(ControllerButton::South), + Key::Named(Named::ArrowUp) => Some(ControllerButton::DPadUp), + Key::Named(Named::ArrowDown) => { + Some(ControllerButton::DPadDown) + } + Key::Named(Named::ArrowLeft) => { + Some(ControllerButton::DPadLeft) + } + Key::Named(Named::ArrowRight) => { + Some(ControllerButton::DPadRight) } - Key::Named(Named::Enter) => Some(DomainMessage::Search( - SearchMessage::TenFootKeyboardActivate, - )), - Key::Named(Named::ArrowUp) => Some(DomainMessage::Search( - SearchMessage::TenFootKeyboardMove( - TenFootKeyboardDirection::Up, - ), - )), - Key::Named(Named::ArrowDown) => Some(DomainMessage::Search( - SearchMessage::TenFootKeyboardMove( - TenFootKeyboardDirection::Down, - ), - )), - Key::Named(Named::ArrowLeft) => Some(DomainMessage::Search( - SearchMessage::TenFootKeyboardMove( - TenFootKeyboardDirection::Left, - ), - )), - Key::Named(Named::ArrowRight) => Some(DomainMessage::Search( - SearchMessage::TenFootKeyboardMove( - TenFootKeyboardDirection::Right, - ), - )), _ => None, }; + if let Some(button) = button { + return tenfoot_search_controller_message( + button, + tenfoot_keyboard_open, + ); + } } match key { @@ -578,6 +570,46 @@ fn search_surface_key_handler( } } +/// Map normalized controller buttons through the same canonical Search domain +/// messages as the in-root ten-foot keyboard. +pub(crate) fn tenfoot_search_controller_message( + button: ControllerButton, + keyboard_open: bool, +) -> Option { + let message = if keyboard_open { + match button { + ControllerButton::DPadUp => { + SearchMessage::TenFootKeyboardMove(TenFootKeyboardDirection::Up) + } + ControllerButton::DPadDown => SearchMessage::TenFootKeyboardMove( + TenFootKeyboardDirection::Down, + ), + ControllerButton::DPadLeft => SearchMessage::TenFootKeyboardMove( + TenFootKeyboardDirection::Left, + ), + ControllerButton::DPadRight => SearchMessage::TenFootKeyboardMove( + TenFootKeyboardDirection::Right, + ), + ControllerButton::South => SearchMessage::TenFootKeyboardActivate, + ControllerButton::East => SearchMessage::HandleEscape, + ControllerButton::Start | ControllerButton::Select => return None, + } + } else { + match button { + ControllerButton::DPadUp => SearchMessage::SelectPrevious, + ControllerButton::DPadDown => SearchMessage::SelectNext, + ControllerButton::DPadLeft | ControllerButton::DPadRight => { + SearchMessage::ShowTenFootKeyboard + } + ControllerButton::South => SearchMessage::SelectCurrent, + ControllerButton::East => SearchMessage::HandleEscape, + ControllerButton::Start | ControllerButton::Select => return None, + } + }; + + Some(DomainMessage::Search(message)) +} + fn main_window_grid_key_handler( event: RuntimeEvent, status: EventStatus, diff --git a/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs b/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs index 1aca516c..0784d9a9 100644 --- a/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs +++ b/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs @@ -36,8 +36,11 @@ fn play_media_with_position( .filter(|duration| *duration > 0.0) .unwrap_or(0.0); - state.domains.player.state.last_valid_position = position as f64; - state.domains.player.state.last_valid_duration = duration_hint; + if !state.domains.player.state.has_observable_playback_root() { + state.domains.player.state.last_valid_position = + position as f64; + state.domains.player.state.last_valid_duration = duration_hint; + } state.domains.media.state.pending_resume_position = Some(position); state.domains.player.state.pending_resume_position = Some(position); @@ -99,26 +102,27 @@ fn play_media_with_mpv_mode( .filter(|duration| *duration > 0.0); let duration_hint = watch_duration_hint.or(metadata_duration_hint); - state.domains.player.state.last_valid_position = - resume_opt.map(|position| position as f64).unwrap_or(0.0); - state.domains.player.state.last_valid_duration = - duration_hint.unwrap_or(0.0); + if !state.domains.player.state.has_observable_playback_root() { + state.domains.player.state.last_valid_position = + resume_opt.map(|position| position as f64).unwrap_or(0.0); + state.domains.player.state.last_valid_duration = + duration_hint.unwrap_or(0.0); + } state.domains.media.state.pending_resume_position = resume_opt; state.domains.player.state.pending_resume_position = resume_opt; - let play = Task::done(DomainMessage::Player( - crate::domains::player::messages::PlayerMessage::PlayMediaWithId( - media_file, media_id, - ), - )); if external_process { - DomainUpdateResult::task(Task::batch(vec![ - play, - Task::done(DomainMessage::Player( - crate::domains::player::messages::PlayerMessage::PlayExternal, - )), - ])) + DomainUpdateResult::task(Task::done(DomainMessage::Player( + crate::domains::player::messages::PlayerMessage::PlayMediaWithIdExternally( + media_file, media_id, + ), + ))) } else if in_process_mpv_target() == PlaybackTarget::MPV_INTEGRATED { + let play = Task::done(DomainMessage::Player( + crate::domains::player::messages::PlayerMessage::PlayMediaWithId( + media_file, media_id, + ), + )); // Allocate the transparent controls host before playback attachment. // It remains invisible until the presenter reports `Attached`. DomainUpdateResult::task( @@ -128,7 +132,11 @@ fn play_media_with_mpv_mode( .chain(play), ) } else { - DomainUpdateResult::task(play) + DomainUpdateResult::task(Task::done(DomainMessage::Player( + crate::domains::player::messages::PlayerMessage::PlayMediaWithId( + media_file, media_id, + ), + ))) } } diff --git a/crates/ferrex-player-ui/src/domains/ui/search_surface.rs b/crates/ferrex-player-ui/src/domains/ui/search_surface.rs index 829fb4ff..05b88ec0 100644 --- a/crates/ferrex-player-ui/src/domains/ui/search_surface.rs +++ b/crates/ferrex-player-ui/src/domains/ui/search_surface.rs @@ -6,17 +6,84 @@ use crate::{ search::types::{SearchMode, SearchPresentation}, ui::{ update_handlers::search_updates, - windows::{WindowKind, focus::focus_active_search_input}, + windows::{ + PlayerOverlayWindowState, WindowKind, + focus::focus_active_search_input, + }, }, }, state::State, }; +fn overlay_host_window(state: &State) -> Option { + if state.windows.player_overlay_state() == PlayerOverlayWindowState::Active + { + state.windows.get(WindowKind::PlayerOverlay) + } else { + state.windows.get(WindowKind::Main) + } +} + +/// Whether playback currently owns the application's single visible window. +/// +/// The request-scoped shell handoff outlives the hidden Iced donor during +/// native-window fallback, so it is the durable guard against opening or +/// refocusing a detached Search `NSWindow` while mpv's root remains visible. +pub(crate) fn native_playback_requires_in_root_search(state: &State) -> bool { + state.windows.shell_hidden_for_playback().is_some() +} + +/// Whether Search may use its ordinary detached-window presentation. +/// +/// During playback every Search entry point must stay inside mpv's root. This +/// predicate is shared by the reducer and view so a stale message cannot open +/// a window after the affordance has disappeared. +pub(crate) fn detached_search_allowed(state: &State) -> bool { + !native_playback_requires_in_root_search(state) +} + +/// Retire the registered detached Search host synchronously. +/// +/// Removing both registries before queueing `window::close` makes a delayed +/// raw-close notification stale by construction, so it cannot hide a newer +/// in-root Search overlay. +fn retire_detached_window(state: &mut State) -> Option { + let registered = state.windows.get(WindowKind::Search); + let id = state.search_window_id.take().or(registered)?; + if registered == Some(id) { + state.windows.remove_by_id(id); + } + Some(id) +} + +/// Retire a detached Search window before opening a native playback root. +/// +/// This intentionally does not focus the retained main window: the caller is +/// about to hide that shell and transfer focus to the in-root player view. +pub(crate) fn close_detached_for_native_playback( + state: &mut State, +) -> Task { + let Some(id) = retire_detached_window(state) else { + return Task::none(); + }; + + state.domains.search.state.presentation = SearchPresentation::Hidden; + state.domains.search.state.escape_pending = false; + state.domains.search.state.tenfoot_keyboard.close(); + window::close(id) +} + pub fn open_overlay( state: &mut State, seed: Option, ) -> DomainUpdateResult { - if state.search_window_id.is_some() { + let detached_to_close = if native_playback_requires_in_root_search(state) { + retire_detached_window(state) + } else { + None + }; + + if detached_to_close.is_none() && state.search_window_id.is_some() { state.domains.search.state.presentation = SearchPresentation::DetachedWindow; state.domains.search.state.tenfoot_keyboard.close(); @@ -38,14 +105,18 @@ pub fn open_overlay( let mut tasks: Vec> = Vec::new(); let mut events = Vec::new(); + if let Some(id) = detached_to_close { + tasks.push(window::close(id)); + } + if let Some(seed) = seed { let update = search_updates::update_search_query(state, seed); tasks.push(update.task); events.extend(update.events); } - if let Some(main_id) = state.windows.get(WindowKind::Main) { - tasks.push(window::gain_focus(main_id)); + if let Some(host_id) = overlay_host_window(state) { + tasks.push(window::gain_focus(host_id)); } tasks.push(focus_active_search_input(state)); @@ -54,7 +125,11 @@ pub fn open_overlay( } pub fn pop_out(state: &mut State) -> DomainUpdateResult { - crate::domains::ui::windows::controller::open_search(state, None) + if detached_search_allowed(state) { + crate::domains::ui::windows::controller::open_search(state, None) + } else { + open_overlay(state, None) + } } pub fn close(state: &mut State) -> DomainUpdateResult { @@ -66,8 +141,8 @@ pub fn close(state: &mut State) -> DomainUpdateResult { crate::domains::ui::windows::controller::close_search(state) } else { let mut tasks: Vec> = Vec::new(); - if let Some(main_id) = state.windows.get(WindowKind::Main) { - tasks.push(window::gain_focus(main_id)); + if let Some(host_id) = overlay_host_window(state) { + tasks.push(window::gain_focus(host_id)); } let task = if tasks.is_empty() { Task::none() @@ -77,3 +152,132 @@ pub fn close(state: &mut State) -> DomainUpdateResult { DomainUpdateResult::task(task) } } + +#[cfg(test)] +mod tests { + use super::*; + use ferrex_player_playback::messages::PlaybackRequestId; + use iced_runtime::{ + Action, futures::futures::StreamExt, task, + window::Action as WindowAction, + }; + + fn active_player_overlay_state() -> (State, window::Id, window::Id) { + let mut state = State::default(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(1); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(request)); + assert!(state.windows.activate_player_overlay(request)); + assert!(state.windows.finish_player_overlay_activation(request)); + (state, main, overlay) + } + + async fn assert_gain_focus( + task_to_inspect: Task, + expected: window::Id, + ) { + let mut actions = task::into_stream(task_to_inspect) + .expect("search focus should emit a task"); + let mut focused = None; + while let Some(action) = actions.next().await { + if let Action::Window(WindowAction::GainFocus(id)) = action { + focused = Some(id); + } + } + assert_eq!(focused, Some(expected)); + } + + #[tokio::test(flavor = "current_thread")] + async fn search_open_and_close_focus_active_player_host_not_hidden_main() { + let (mut state, main, overlay) = active_player_overlay_state(); + assert_eq!(overlay_host_window(&state), Some(overlay)); + assert_ne!(overlay_host_window(&state), Some(main)); + + let opened = open_overlay(&mut state, None); + assert_eq!( + state.domains.search.state.presentation, + SearchPresentation::Overlay + ); + assert_gain_focus(opened.task, overlay).await; + + let closed = close(&mut state); + assert_eq!( + state.domains.search.state.presentation, + SearchPresentation::Hidden + ); + assert_gain_focus(closed.task, overlay).await; + } + + #[tokio::test(flavor = "current_thread")] + async fn search_overlay_uses_main_for_ordinary_shell_surface() { + let mut state = State::default(); + let main = window::Id::unique(); + let hidden_donor = window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, hidden_donor); + + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Hidden + ); + assert_eq!(overlay_host_window(&state), Some(main)); + } + + #[tokio::test(flavor = "current_thread")] + async fn pop_out_during_native_playback_stays_inside_player_root() { + let (mut state, _main, overlay) = active_player_overlay_state(); + + assert!(!detached_search_allowed(&state)); + let result = pop_out(&mut state); + + assert_eq!( + state.domains.search.state.presentation, + SearchPresentation::Overlay + ); + assert_eq!(state.search_window_id, None); + assert_eq!(state.windows.get(WindowKind::Search), None); + assert_gain_focus(result.task, overlay).await; + } + + #[tokio::test(flavor = "current_thread")] + async fn in_root_search_retires_detached_host_before_refocusing_player() { + let (mut state, _main, overlay) = active_player_overlay_state(); + let detached = window::Id::unique(); + state.windows.set(WindowKind::Search, detached); + state.search_window_id = Some(detached); + state.domains.search.state.presentation = + SearchPresentation::DetachedWindow; + + let result = open_overlay(&mut state, None); + + assert_eq!( + state.domains.search.state.presentation, + SearchPresentation::Overlay + ); + assert_eq!(state.search_window_id, None); + assert_eq!(state.windows.get(WindowKind::Search), None); + + let mut actions = task::into_stream(result.task) + .expect("rehome must close Search and focus the player root"); + let mut saw_close = false; + let mut saw_focus = false; + while let Some(action) = actions.next().await { + match action { + Action::Window(WindowAction::Close(id)) if id == detached => { + saw_close = true; + } + Action::Window(WindowAction::GainFocus(id)) + if id == overlay => + { + saw_focus = true; + } + _ => {} + } + } + assert!(saw_close); + assert!(saw_focus); + } +} diff --git a/crates/ferrex-player-ui/src/domains/ui/shell_ui/mod.rs b/crates/ferrex-player-ui/src/domains/ui/shell_ui/mod.rs index 25a957e2..026eab49 100644 --- a/crates/ferrex-player-ui/src/domains/ui/shell_ui/mod.rs +++ b/crates/ferrex-player-ui/src/domains/ui/shell_ui/mod.rs @@ -67,9 +67,15 @@ pub enum UiShellMessage { PlayerOverlayOpened(window::Id), PlayerOverlayResized(window::Id, iced::Size), ActivatePlayerOverlay, - PlayerOverlayHandoffReady, + PlayerOverlayHandoffReady { + request: ferrex_player_playback::messages::PlaybackRequestId, + }, PlayerOverlayFocused, - ClosePlayerOverlay, + PlayerOverlayUnfocused, + PlayerControllerInput(crate::common::controller_input::ControllerButton), + ClosePlayerOverlay { + request: ferrex_player_playback::messages::PlaybackRequestId, + }, // Search surface and query management UpdateSearchQuery(String), @@ -119,9 +125,13 @@ impl UiShellMessage { Self::PlayerOverlayOpened(_) => "UI::PlayerOverlayOpened", Self::PlayerOverlayResized(_, _) => "UI::PlayerOverlayResized", Self::ActivatePlayerOverlay => "UI::ActivatePlayerOverlay", - Self::PlayerOverlayHandoffReady => "UI::PlayerOverlayHandoffReady", + Self::PlayerOverlayHandoffReady { .. } => { + "UI::PlayerOverlayHandoffReady" + } Self::PlayerOverlayFocused => "UI::PlayerOverlayFocused", - Self::ClosePlayerOverlay => "UI::ClosePlayerOverlay", + Self::PlayerOverlayUnfocused => "UI::PlayerOverlayUnfocused", + Self::PlayerControllerInput(_) => "UI::PlayerControllerInput", + Self::ClosePlayerOverlay { .. } => "UI::ClosePlayerOverlay", // Search surface and query management Self::UpdateSearchQuery(_) => "UI::UpdateSearchQuery", @@ -190,14 +200,20 @@ impl std::fmt::Debug for UiShellMessage { UiShellMessage::ActivatePlayerOverlay => { write!(f, "UI::ActivatePlayerOverlay") } - UiShellMessage::PlayerOverlayHandoffReady => { - write!(f, "UI::PlayerOverlayHandoffReady") + UiShellMessage::PlayerOverlayHandoffReady { request } => { + write!(f, "UI::PlayerOverlayHandoffReady({request:?})") } UiShellMessage::PlayerOverlayFocused => { write!(f, "UI::PlayerOverlayFocused") } - UiShellMessage::ClosePlayerOverlay => { - write!(f, "UI::ClosePlayerOverlay") + UiShellMessage::PlayerOverlayUnfocused => { + write!(f, "UI::PlayerOverlayUnfocused") + } + UiShellMessage::PlayerControllerInput(button) => { + write!(f, "UI::PlayerControllerInput({button:?})") + } + UiShellMessage::ClosePlayerOverlay { request } => { + write!(f, "UI::ClosePlayerOverlay({request:?})") } UiShellMessage::UpdateSearchQuery(_) => { write!(f, "UI::UpdateSearchQuery") diff --git a/crates/ferrex-player-ui/src/domains/ui/shell_ui/update.rs b/crates/ferrex-player-ui/src/domains/ui/shell_ui/update.rs index a4195fcf..ee150d76 100644 --- a/crates/ferrex-player-ui/src/domains/ui/shell_ui/update.rs +++ b/crates/ferrex-player-ui/src/domains/ui/shell_ui/update.rs @@ -360,9 +360,6 @@ pub fn update_shell_ui( UiShellMessage::PopOutSearch => search_surface::pop_out(state), UiShellMessage::CloseSearch => search_surface::close(state), UiShellMessage::SearchDetachedOpened(id) => { - state.search_window_id = Some(id); - state.domains.search.state.presentation = - crate::domains::search::types::SearchPresentation::DetachedWindow; windows::controller::on_search_opened(state, id) } UiShellMessage::MainWindowOpened(id) => { @@ -370,6 +367,9 @@ pub fn update_shell_ui( DomainUpdateResult::task(Task::none()) } UiShellMessage::MainWindowFocused => { + if let Some(id) = state.windows.get(windows::WindowKind::Main) { + state.windows.record_focus(id); + } // When regaining focus, re-emit initial snapshots to ensure images load init_all_tab_view(state); emit_initial_all_tab_snapshots_combined(state); @@ -381,7 +381,9 @@ pub fn update_shell_ui( } } UiShellMessage::MainWindowUnfocused => { - // No special handling currently; keep behavior simple + if let Some(id) = state.windows.get(windows::WindowKind::Main) { + state.windows.record_unfocus(id); + } DomainUpdateResult::task(Task::none()) } UiShellMessage::OpenPlayerOverlay => { @@ -395,16 +397,32 @@ pub fn update_shell_ui( DomainUpdateResult::task(Task::none()) } UiShellMessage::ActivatePlayerOverlay => { - windows::controller::activate_player_overlay(state) + let Some(request) = state.windows.player_overlay_launch_request() + else { + return DomainUpdateResult::task(Task::none()); + }; + windows::controller::activate_player_overlay(state, request) } - UiShellMessage::PlayerOverlayHandoffReady => { - windows::controller::finish_player_overlay_activation(state) + UiShellMessage::PlayerOverlayHandoffReady { request } => { + windows::controller::finish_player_overlay_activation( + state, request, + ) } UiShellMessage::PlayerOverlayFocused => { windows::controller::on_player_overlay_focused(state) } - UiShellMessage::ClosePlayerOverlay => { - windows::controller::close_player_overlay(state) + UiShellMessage::PlayerOverlayUnfocused => { + windows::controller::on_player_overlay_unfocused(state) + } + UiShellMessage::PlayerControllerInput(button) => { + DomainUpdateResult::task(Task::done( + crate::domains::ui::views::tenfoot::player_overlay::controller_message_from_state( + state, button, + ), + )) + } + UiShellMessage::ClosePlayerOverlay { request } => { + windows::controller::close_player_overlay(state, request) } UiShellMessage::RawWindowClosed(id) => { windows::controller::on_raw_window_closed(state, id) diff --git a/crates/ferrex-player-ui/src/domains/ui/update_handlers/navigation_updates.rs b/crates/ferrex-player-ui/src/domains/ui/update_handlers/navigation_updates.rs index 953c6784..025be545 100644 --- a/crates/ferrex-player-ui/src/domains/ui/update_handlers/navigation_updates.rs +++ b/crates/ferrex-player-ui/src/domains/ui/update_handlers/navigation_updates.rs @@ -782,12 +782,12 @@ pub fn handle_navigate_home(state: &mut State) -> Task { profiling::function )] pub fn handle_exit_fullscreen(state: &mut State) -> Task { - // Only exit fullscreen if we're actually in fullscreen + // Route through the player reducer instead of mutating an arbitrary Iced + // window. Native mpv playback translates this existing toggle path into + // an mpv fullscreen command; the non-mpv fallback emits a shell + // SetWindowMode event that is explicitly scoped to the main window. if state.domains.player.state.is_fullscreen { - state.domains.player.state.is_fullscreen = false; - let mode = iced::window::Mode::Windowed; - iced::window::latest() - .and_then(move |id| iced::window::set_mode(id, mode)) + Task::done(UiShellMessage::ToggleFullscreen.into()) } else { Task::none() } @@ -860,3 +860,45 @@ fn save_current_scroll_state(state: &mut State) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domains::ui::windows::WindowKind; + use iced_runtime::{Action, futures::futures::StreamExt, task}; + + #[tokio::test(flavor = "current_thread")] + async fn exit_fullscreen_uses_player_route_with_overlay_registered_last() { + let mut state = State::default(); + let main = iced::window::Id::unique(); + let overlay = iced::window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.record_focus(overlay)); + state.domains.player.state.is_fullscreen = true; + + let mut actions = task::into_stream(handle_exit_fullscreen(&mut state)) + .expect("fullscreen exit should emit a player-route message"); + + assert!(matches!( + actions.next().await, + Some(Action::Output(UiMessage::Shell( + UiShellMessage::ToggleFullscreen + ))) + )); + assert!(actions.next().await.is_none()); + // The player reducer owns confirmed fullscreen state. This handler + // must not optimistically change it before mpv acknowledges the + // command. + assert!(state.domains.player.state.is_fullscreen); + } + + #[tokio::test(flavor = "current_thread")] + async fn exit_fullscreen_is_idle_when_player_is_windowed() { + let mut state = State::default(); + + assert!( + task::into_stream(handle_exit_fullscreen(&mut state)).is_none() + ); + } +} diff --git a/crates/ferrex-player-ui/src/domains/ui/views/components/search_dropdown.rs b/crates/ferrex-player-ui/src/domains/ui/views/components/search_dropdown.rs index 2b0fc5ca..ce11cbc6 100644 --- a/crates/ferrex-player-ui/src/domains/ui/views/components/search_dropdown.rs +++ b/crates/ferrex-player-ui/src/domains/ui/views/components/search_dropdown.rs @@ -216,7 +216,7 @@ fn view_tenfoot_search_overlay(state: &State) -> Element<'_, Message> { tenfoot_guidance_chip("Search/Done browses results"), tenfoot_guidance_chip("Esc hides keys"), Space::new().width(Length::Fill), - text("Remote/D-pad text entry is available here · Native gamepad backend is not wired yet") + text("Remote/D-pad text entry is available here") .size(18) .color(MediaServerTheme::TEXT_DIMMED), ] @@ -884,7 +884,9 @@ fn view_search_panel( .spacing(12.0) .align_y(Alignment::Center); - if matches!(surface, SearchSurface::Overlay) { + if matches!(surface, SearchSurface::Overlay) + && crate::domains::ui::search_surface::detached_search_allowed(state) + { header_row = header_row.push( button(text("Pop out").size(14)) .on_press(DomainMessage::Ui( diff --git a/crates/ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs b/crates/ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs index 1bee9794..c044b3a6 100644 --- a/crates/ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs +++ b/crates/ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs @@ -11,6 +11,7 @@ use crate::{ common::{ controller_input::{ ControllerButton, ControllerEvent, ControllerInputMapper, + native_controller_subscription, }, focus::{ FocusLayoutRect, FocusMargins, SpatialAction, SpatialDirection, @@ -31,7 +32,10 @@ use crate::{ }; use ferrex_player_playback::{ contract::{BackendKind, PlaybackSnapshot, PlaybackState}, - view::{playback_status_overlay, playback_surface_status}, + view::{ + central_surface_press_message, native_root_drag_surface_enabled, + playback_status_overlay, playback_surface_status, + }, }; use iced::{ Alignment, Background, Border, Color, Element, Length, Padding, Shadow, @@ -475,6 +479,62 @@ pub fn keyboard_subscription(state: &State) -> Subscription { }) } +/// Player-view physical-controller subscription for 10-foot mode. +/// +/// The native backend emits normalized buttons only. State-sensitive routing +/// stays here beside keyboard routing so both inputs use the same focus, +/// visibility, and canonical player message path. +pub fn controller_subscription(state: &State) -> Subscription { + if !controller_input_is_active(state) { + return Subscription::none(); + } + + native_controller_subscription().map(|button| { + DomainMessage::Ui(UiShellMessage::PlayerControllerInput(button).into()) + }) +} + +fn controller_input_is_active(state: &State) -> bool { + state.interface_mode.is_tenfoot() + && matches!( + state.domains.ui.state.view, + crate::domains::ui::types::ViewState::Player + ) + && state.windows.is_player_surface_focused() + && (!state.domains.search.state.presentation.is_open() + || state.domains.search.state.presentation.is_overlay()) +} + +/// Revalidate a process-global controller event against current application +/// state before translating it into canonical player or shell messages. +pub(crate) fn controller_message_from_state( + state: &State, + button: ControllerButton, +) -> DomainMessage { + if !controller_input_is_active(state) { + return DomainMessage::NoOp; + } + + if state.domains.search.state.presentation.is_overlay() { + return crate::domains::ui::messages::subscriptions::tenfoot_search_controller_message( + button, + state.domains.search.state.tenfoot_keyboard.is_open(), + ) + .unwrap_or(DomainMessage::NoOp); + } + + controller_message(TenFootPlayerInputSnapshot::from_state(state), button) +} + +fn controller_message( + snapshot: TenFootPlayerInputSnapshot, + button: ControllerButton, +) -> DomainMessage { + let action = ControllerInputMapper::new() + .handle_event(ControllerEvent::ButtonPressed(button)); + snapshot.handle_action(action) +} + /// Build the 10-foot player view while preserving the existing video widget. pub fn view_player( state: &State, @@ -489,15 +549,21 @@ pub fn view_player( return external_player_view(snapshot); } + let native_root_drag = native_root_drag_surface_enabled( + player.playback_snapshot(), + native_host_window, + ); + let central_press = central_surface_press_message( + player.playback_snapshot(), + native_host_window, + ); if let Some(video) = player.playback_widget(native_host_window) { let video_surface: Element< '_, PlayerMessage, Theme, iced_wgpu::Renderer, - > = mouse_area(video) - .on_press(PlayerMessage::VideoClicked) - .into(); + > = mouse_area(video).on_press(central_press).into(); let mut layers: Vec< Element<'_, PlayerMessage, Theme, iced_wgpu::Renderer>, @@ -507,7 +573,7 @@ pub fn view_player( layers.push(playback_status_overlay(status)); } if overlay_controls_visible(player) { - layers.push(overlay(state)); + layers.push(overlay(state, native_root_drag)); } if let Some(notification) = &player.track_notification { @@ -550,6 +616,7 @@ pub fn view_loading_status( fn overlay( state: &State, + shield_native_drag: bool, ) -> Element<'_, PlayerMessage, Theme, iced_wgpu::Renderer> { let player = &state.domains.player.state; let viewport = player_viewport_size(state); @@ -573,23 +640,26 @@ fn overlay( "--:--".to_string() }; - let top_bar = container( - row![ - column![ - text(status).size(14).color(MediaServerTheme::ACCENT), - text(title).size(28).color(Color::WHITE), + let top_bar: Element<'_, PlayerMessage, Theme, iced_wgpu::Renderer> = + container( + row![ + column![ + text(status).size(14).color(MediaServerTheme::ACCENT), + text(title).size(28).color(Color::WHITE), + ] + .spacing(2), + Space::new().width(Length::Fill), + text("10-foot player") + .size(18) + .color(MediaServerTheme::TEXT_SECONDARY), ] - .spacing(2), - Space::new().width(Length::Fill), - text("10-foot player") - .size(18) - .color(MediaServerTheme::TEXT_SECONDARY), - ] - .align_y(Alignment::Center), - ) - .width(Length::Fill) - .padding(PlayerOverlayLayout::top_bar_padding()) - .style(top_gradient_style); + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .padding(PlayerOverlayLayout::top_bar_padding()) + .style(top_gradient_style) + .into(); + let top_bar = shield_native_root_drag(top_bar, shield_native_drag); let progress_layout = PlayerOverlayLayout::progress_rect(viewport.width, viewport.height); @@ -681,49 +751,55 @@ fn overlay( .spacing(PlayerOverlayLayout::COMMAND_GAP) .align_y(Alignment::Center); - let bottom_panel = container( - column![ - row![ - container(text(elapsed_time).size(24).color(Color::WHITE)) - .width(Length::Fixed(PlayerOverlayLayout::TIME_LABEL_W)) - .align_x(iced::alignment::Horizontal::Right), - seek, - container(text(remaining_time).size(24).color(Color::WHITE)) + let bottom_panel: Element<'_, PlayerMessage, Theme, iced_wgpu::Renderer> = + container( + column![ + row![ + container(text(elapsed_time).size(24).color(Color::WHITE)) + .width(Length::Fixed(PlayerOverlayLayout::TIME_LABEL_W)) + .align_x(iced::alignment::Horizontal::Right), + seek, + container( + text(remaining_time).size(24).color(Color::WHITE) + ) .width(Length::Fixed(PlayerOverlayLayout::TIME_LABEL_W)) .align_x(iced::alignment::Horizontal::Left), - ] - .spacing(PlayerOverlayLayout::PROGRESS_ROW_GAP) - .align_y(Alignment::Center) - .height(Length::Fixed(PlayerOverlayLayout::PROGRESS_ROW_H)), - row![ - container( - column![ - text(action_label(&focused_id)) - .size(22) - .color(MediaServerTheme::ACCENT), - text("Esc hides • Enter selects • / or S searches") - .size(15) - .color(MediaServerTheme::TEXT_SECONDARY), - ] - .spacing(2), - ) - .width(Length::Fill) - .align_x(iced::alignment::Horizontal::Left), - container(transport).width(Length::Shrink), - container(command_row) + ] + .spacing(PlayerOverlayLayout::PROGRESS_ROW_GAP) + .align_y(Alignment::Center) + .height(Length::Fixed(PlayerOverlayLayout::PROGRESS_ROW_H)), + row![ + container( + column![ + text(action_label(&focused_id)) + .size(22) + .color(MediaServerTheme::ACCENT), + text("Esc hides • Enter selects • / or S searches") + .size(15) + .color(MediaServerTheme::TEXT_SECONDARY), + ] + .spacing(2), + ) .width(Length::Fill) - .align_x(iced::alignment::Horizontal::Right), + .align_x(iced::alignment::Horizontal::Left), + container(transport).width(Length::Shrink), + container(command_row) + .width(Length::Fill) + .align_x(iced::alignment::Horizontal::Right), + ] + .spacing(PlayerOverlayLayout::CONTROL_ROW_GAP) + .align_y(Alignment::Center) + .height(Length::Fixed(PlayerOverlayLayout::CONTROL_ROW_H)), ] - .spacing(PlayerOverlayLayout::CONTROL_ROW_GAP) - .align_y(Alignment::Center) - .height(Length::Fixed(PlayerOverlayLayout::CONTROL_ROW_H)), - ] - .spacing(PlayerOverlayLayout::PANEL_COLUMN_GAP), - ) - .width(Length::Fill) - .height(Length::Fixed(PlayerOverlayLayout::PANEL_H)) - .padding(PlayerOverlayLayout::panel_padding()) - .style(panel_style); + .spacing(PlayerOverlayLayout::PANEL_COLUMN_GAP), + ) + .width(Length::Fill) + .height(Length::Fixed(PlayerOverlayLayout::PANEL_H)) + .padding(PlayerOverlayLayout::panel_padding()) + .style(panel_style) + .into(); + let bottom_panel = + shield_native_root_drag(bottom_panel, shield_native_drag); container(column![ top_bar, @@ -735,6 +811,19 @@ fn overlay( .into() } +fn shield_native_root_drag<'a>( + surface: Element<'a, PlayerMessage, Theme, iced_wgpu::Renderer>, + enabled: bool, +) -> Element<'a, PlayerMessage, Theme, iced_wgpu::Renderer> { + if enabled { + mouse_area(surface) + .on_press(PlayerMessage::ShowControls) + .into() + } else { + surface + } +} + fn player_status_label(player: &PlayerDomainState) -> &'static str { if player.dragging { return "SCRUBBING"; @@ -1538,4 +1627,101 @@ mod tests { PlayerMessage::NavigateBack )); } + + #[test] + fn normalized_controller_buttons_use_canonical_player_messages() { + let snapshot = TenFootPlayerInputSnapshot { + has_internal_video: false, + external_active: false, + overlay_visible: false, + viewport_width_bits: 1920.0_f32.to_bits(), + viewport_height_bits: 1080.0_f32.to_bits(), + }; + + assert!(matches!( + controller_message(snapshot, ControllerButton::East), + DomainMessage::Player(PlayerMessage::NavigateBack) + )); + assert!(matches!( + controller_message(snapshot, ControllerButton::Select), + DomainMessage::Ui(_) + )); + assert!(matches!( + controller_message(snapshot, ControllerButton::South), + DomainMessage::NoOp + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn physical_controller_requires_confirmed_current_player_focus() { + let mut state = State::default(); + state.interface_mode = crate::state::InterfaceMode::TenFoot; + state.domains.ui.state.view = + crate::domains::ui::types::ViewState::Player; + + let main = iced::window::Id::unique(); + let overlay = iced::window::Id::unique(); + let request = + ferrex_player_playback::messages::PlaybackRequestId::new(1); + state + .windows + .set(crate::domains::ui::windows::WindowKind::Main, main); + state.windows.set( + crate::domains::ui::windows::WindowKind::PlayerOverlay, + overlay, + ); + assert!(state.windows.record_focus(main)); + assert!(matches!( + controller_message_from_state(&state, ControllerButton::East), + DomainMessage::Player(PlayerMessage::NavigateBack) + )); + + assert!(state.windows.begin_player_overlay_launch(request)); + assert!(state.windows.activate_player_overlay(request)); + assert!(state.windows.finish_player_overlay_activation(request)); + + assert!(matches!( + controller_message_from_state(&state, ControllerButton::East), + DomainMessage::NoOp + )); + + assert!(state.windows.record_focus(overlay)); + assert!(matches!( + controller_message_from_state(&state, ControllerButton::East), + DomainMessage::Player(PlayerMessage::NavigateBack) + )); + + state.domains.search.state.presentation = + crate::domains::search::types::SearchPresentation::Overlay; + state.domains.search.state.tenfoot_keyboard.open(); + assert!(matches!( + controller_message_from_state( + &state, + ControllerButton::DPadLeft + ), + DomainMessage::Search( + crate::domains::search::messages::SearchMessage::TenFootKeyboardMove( + crate::domains::search::keyboard::TenFootKeyboardDirection::Left + ) + ) + )); + assert!(matches!( + controller_message_from_state(&state, ControllerButton::South), + DomainMessage::Search( + crate::domains::search::messages::SearchMessage::TenFootKeyboardActivate + ) + )); + assert!(matches!( + controller_message_from_state(&state, ControllerButton::East), + DomainMessage::Search( + crate::domains::search::messages::SearchMessage::HandleEscape + ) + )); + + assert!(state.windows.record_unfocus(overlay)); + assert!(matches!( + controller_message_from_state(&state, ControllerButton::East), + DomainMessage::NoOp + )); + } } diff --git a/crates/ferrex-player-ui/src/domains/ui/windows.rs b/crates/ferrex-player-ui/src/domains/ui/windows.rs index ab9f6433..fd9c3747 100644 --- a/crates/ferrex-player-ui/src/domains/ui/windows.rs +++ b/crates/ferrex-player-ui/src/domains/ui/windows.rs @@ -2,6 +2,7 @@ pub mod controller; pub mod focus; pub mod subscriptions; +use ferrex_player_playback::messages::PlaybackRequestId; use iced::{Size, window}; use std::collections::HashMap; @@ -20,6 +21,9 @@ pub enum PlayerOverlayWindowState { Closed, /// Allocated with `visible = false`; native attachment may proceed safely. Hidden, + /// A source has resolved and the retained main window is being hidden + /// before the integrated mpv backend may open its native root. + Launching, /// Native attachment is ready and the retained main window is being /// hidden; the overlay itself is still invisible. Activating, @@ -35,7 +39,11 @@ pub struct WindowManager { by_id: HashMap, player_overlay: PlayerOverlayWindowState, player_overlay_size: Option, - pub focused: Option, + player_overlay_launch_request: Option, + deferred_player_overlay_launch: Option, + deferred_external_playback_launch: Option, + shell_hidden_for_playback: Option, + focused: Option, } impl WindowManager { @@ -46,12 +54,16 @@ impl WindowManager { pub fn set(&mut self, kind: WindowKind, id: window::Id) { if let Some(previous_id) = self.by_kind.remove(&kind) { self.by_id.remove(&previous_id); + if previous_id != id && self.focused == Some(previous_id) { + self.focused = None; + } } if let Some(previous_kind) = self.by_id.remove(&id) { self.by_kind.remove(&previous_kind); if previous_kind == WindowKind::PlayerOverlay { self.player_overlay = PlayerOverlayWindowState::Closed; self.player_overlay_size = None; + self.player_overlay_launch_request = None; } } @@ -60,6 +72,12 @@ impl WindowManager { if kind == WindowKind::PlayerOverlay { self.player_overlay = PlayerOverlayWindowState::Hidden; self.player_overlay_size = None; + self.player_overlay_launch_request = None; + // A newly allocated donor is hidden. Focus must be confirmed by a + // later platform event from its actual hosted root. + if self.focused == Some(id) { + self.focused = None; + } } } @@ -71,12 +89,61 @@ impl WindowManager { self.by_id.get(&id).copied() } + /// Record platform-confirmed focus for a registered window. + pub fn record_focus(&mut self, id: window::Id) -> bool { + if !self.by_id.contains_key(&id) { + return false; + } + let changed = self.focused != Some(id); + self.focused = Some(id); + changed + } + + /// Clear focus only when the matching platform window loses it. + pub fn record_unfocus(&mut self, id: window::Id) -> bool { + if self.focused != Some(id) { + return false; + } + self.focused = None; + true + } + + pub const fn focused_window(&self) -> Option { + self.focused + } + + /// Whether the active integrated controls view's actual host owns focus. + /// + /// The vendored winit AppKit path reports root focus under the retained + /// donor id, so this remains pointer-free and does not infer focus from + /// route or lifecycle state. + pub fn is_player_overlay_focused(&self) -> bool { + self.player_overlay == PlayerOverlayWindowState::Active + && self.get(WindowKind::PlayerOverlay) == self.focused + } + + /// Whether the currently rendered Ferrex player surface owns focus. + /// + /// Integrated native playback routes focus from mpv's root under the + /// hosted overlay id. Ordinary in-shell playback continues to use the main + /// window, so adding the native controller backend does not regress it. + pub fn is_player_surface_focused(&self) -> bool { + let surface = if self.player_overlay == PlayerOverlayWindowState::Active + { + WindowKind::PlayerOverlay + } else { + WindowKind::Main + }; + self.get(surface) == self.focused + } + pub fn remove_by_id(&mut self, id: window::Id) -> Option { if let Some(kind) = self.by_id.remove(&id) { let _ = self.by_kind.remove(&kind); if kind == WindowKind::PlayerOverlay { self.player_overlay = PlayerOverlayWindowState::Closed; self.player_overlay_size = None; + self.player_overlay_launch_request = None; } if self.focused == Some(id) { self.focused = None; @@ -114,10 +181,123 @@ impl WindowManager { true } + /// Begin the source-level single-window handoff. + /// + /// Active or activating hosts can re-enter this state after their previous + /// presenter detached for an episode or rendition replacement. + pub fn begin_player_overlay_launch( + &mut self, + request: PlaybackRequestId, + ) -> bool { + if self.player_overlay_launch_request == Some(request) { + return false; + } + if self.get(WindowKind::PlayerOverlay).is_some() + && matches!( + self.player_overlay, + PlayerOverlayWindowState::Hidden + | PlayerOverlayWindowState::Launching + | PlayerOverlayWindowState::Activating + | PlayerOverlayWindowState::Active + ) + { + self.player_overlay = PlayerOverlayWindowState::Launching; + self.player_overlay_launch_request = Some(request); + self.deferred_player_overlay_launch = None; + self.shell_hidden_for_playback = Some(request); + // The current shell/root is being withdrawn. The replacement + // surface must earn focus through a fresh platform event. + self.focused = None; + true + } else { + false + } + } + + pub const fn player_overlay_launch_request( + &self, + ) -> Option { + self.player_overlay_launch_request + } + + pub const fn shell_hidden_for_playback(&self) -> Option { + self.shell_hidden_for_playback + } + + pub fn defer_player_overlay_launch(&mut self, request: PlaybackRequestId) { + self.deferred_external_playback_launch = None; + self.deferred_player_overlay_launch = Some(request); + // The old backend was synchronously withdrawn before this request was + // deferred. Transfer the durable restore obligation now so a failure + // that beats RawWindowClosed cannot strand the retained shell hidden. + self.shell_hidden_for_playback = Some(request); + } + + pub fn take_deferred_player_overlay_launch( + &mut self, + ) -> Option { + self.deferred_player_overlay_launch.take() + } + + /// Transfer the durable retained-shell hide obligation to an external + /// process request before any donor close or process spawn is queued. + pub fn begin_external_shell_handoff(&mut self, request: PlaybackRequestId) { + self.deferred_player_overlay_launch = None; + self.shell_hidden_for_playback = Some(request); + self.focused = None; + if self.get(WindowKind::PlayerOverlay).is_some() { + self.player_overlay_launch_request = Some(request); + } + } + + /// Defer process spawn until the integrated donor's raw close completion. + pub fn defer_external_playback_launch( + &mut self, + request: PlaybackRequestId, + ) { + self.deferred_player_overlay_launch = None; + self.deferred_external_playback_launch = Some(request); + self.shell_hidden_for_playback = Some(request); + } + + pub fn take_deferred_external_playback_launch( + &mut self, + ) -> Option { + self.deferred_external_playback_launch.take() + } + + /// Release shell-hide ownership only for the matching playback request. + pub fn finish_shell_handoff( + &mut self, + request: Option, + ) -> bool { + match request { + Some(request) + if self.shell_hidden_for_playback == Some(request) => + { + self.shell_hidden_for_playback = None; + true + } + Some(_) => false, + None => { + self.shell_hidden_for_playback = None; + true + } + } + } + /// Confirm native attachment and begin the hidden-to-visible handoff. - pub fn activate_player_overlay(&mut self) -> bool { + pub fn activate_player_overlay( + &mut self, + request: PlaybackRequestId, + ) -> bool { if self.get(WindowKind::PlayerOverlay).is_some() - && self.player_overlay == PlayerOverlayWindowState::Hidden + && self.player_overlay_launch_request == Some(request) + && matches!( + self.player_overlay, + PlayerOverlayWindowState::Hidden + | PlayerOverlayWindowState::Launching + ) { self.player_overlay = PlayerOverlayWindowState::Activating; true @@ -127,8 +307,12 @@ impl WindowManager { } /// Record that the native presenter has synchronously revealed the host. - pub fn finish_player_overlay_activation(&mut self) -> bool { + pub fn finish_player_overlay_activation( + &mut self, + request: PlaybackRequestId, + ) -> bool { if self.get(WindowKind::PlayerOverlay).is_some() + && self.player_overlay_launch_request == Some(request) && self.player_overlay == PlayerOverlayWindowState::Activating { self.player_overlay = PlayerOverlayWindowState::Active; @@ -145,6 +329,12 @@ impl WindowManager { && previous != PlayerOverlayWindowState::Closing { self.player_overlay = PlayerOverlayWindowState::Closing; + if self + .get(WindowKind::PlayerOverlay) + .is_some_and(|id| self.focused == Some(id)) + { + self.focused = None; + } } previous } @@ -189,26 +379,36 @@ mod tests { fn player_overlay_requires_hidden_attach_reveal_active_close_order() { let mut windows = WindowManager::new(); let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(1); assert_eq!( windows.player_overlay_state(), PlayerOverlayWindowState::Closed ); - assert!(!windows.activate_player_overlay()); + assert!(!windows.activate_player_overlay(request)); windows.set(WindowKind::PlayerOverlay, overlay); assert_eq!( windows.player_overlay_state(), PlayerOverlayWindowState::Hidden, ); - assert!(windows.activate_player_overlay()); - assert!(!windows.activate_player_overlay()); + assert!(windows.begin_player_overlay_launch(request)); + assert!(!windows.begin_player_overlay_launch(request)); + assert_eq!( + windows.player_overlay_state(), + PlayerOverlayWindowState::Launching, + ); + assert!(windows.activate_player_overlay(request)); + assert!(!windows.activate_player_overlay(request)); assert_eq!( windows.player_overlay_state(), PlayerOverlayWindowState::Activating, ); - assert!(windows.finish_player_overlay_activation()); - assert!(!windows.finish_player_overlay_activation()); + assert!(windows.finish_player_overlay_activation(request)); + assert!(!windows.finish_player_overlay_activation(request)); + assert!(!windows.is_player_overlay_focused()); + assert!(windows.record_focus(overlay)); + assert!(windows.is_player_overlay_focused()); assert_eq!( windows.begin_player_overlay_close(), PlayerOverlayWindowState::Active @@ -217,6 +417,8 @@ mod tests { windows.player_overlay_state(), PlayerOverlayWindowState::Closing ); + assert!(!windows.is_player_overlay_focused()); + assert_eq!(windows.focused_window(), None); assert_eq!( windows.remove_by_id(overlay), @@ -228,6 +430,25 @@ mod tests { ); } + #[test] + fn newer_request_supersedes_launching_while_same_request_is_deduplicated() { + let mut windows = WindowManager::new(); + let overlay = window::Id::unique(); + let first = PlaybackRequestId::new(1); + let second = PlaybackRequestId::new(2); + windows.set(WindowKind::PlayerOverlay, overlay); + + assert!(windows.begin_player_overlay_launch(first)); + assert!(!windows.begin_player_overlay_launch(first)); + assert!(windows.begin_player_overlay_launch(second)); + assert_eq!(windows.player_overlay_launch_request(), Some(second)); + assert_eq!(windows.shell_hidden_for_playback(), Some(second)); + assert_eq!( + windows.player_overlay_state(), + PlayerOverlayWindowState::Launching + ); + } + #[test] fn player_overlay_size_is_scoped_validated_and_cleared() { let mut windows = WindowManager::new(); @@ -262,9 +483,11 @@ mod tests { fn activating_overlay_can_close_before_reveal_completion() { let mut windows = WindowManager::new(); let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(1); windows.set(WindowKind::PlayerOverlay, overlay); - assert!(windows.activate_player_overlay()); + assert!(windows.begin_player_overlay_launch(request)); + assert!(windows.activate_player_overlay(request)); assert_eq!( windows.begin_player_overlay_close(), PlayerOverlayWindowState::Activating @@ -273,6 +496,44 @@ mod tests { windows.player_overlay_state(), PlayerOverlayWindowState::Closing ); - assert!(!windows.finish_player_overlay_activation()); + assert!(!windows.finish_player_overlay_activation(request)); + } + + #[test] + fn player_focus_requires_platform_confirmation_and_clears_on_unfocus() { + let mut windows = WindowManager::new(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let unknown = window::Id::unique(); + let request = PlaybackRequestId::new(1); + windows.set(WindowKind::Main, main); + windows.set(WindowKind::PlayerOverlay, overlay); + + assert!(!windows.record_focus(unknown)); + assert!(windows.record_focus(main)); + assert!(windows.is_player_surface_focused()); + assert!(windows.record_focus(overlay)); + assert_eq!(windows.focused_window(), Some(overlay)); + assert!(!windows.is_player_surface_focused()); + assert!(!windows.is_player_overlay_focused()); + + assert!(windows.begin_player_overlay_launch(request)); + assert_eq!(windows.focused_window(), None); + assert!(windows.activate_player_overlay(request)); + assert!(windows.finish_player_overlay_activation(request)); + assert!(!windows.is_player_overlay_focused()); + + assert!(windows.record_focus(overlay)); + assert!(windows.is_player_overlay_focused()); + assert!(windows.is_player_surface_focused()); + assert!(windows.record_unfocus(overlay)); + assert!(!windows.is_player_overlay_focused()); + assert!(!windows.is_player_surface_focused()); + assert_eq!(windows.focused_window(), None); + + assert!(windows.record_focus(main)); + assert!(!windows.record_unfocus(overlay)); + assert_eq!(windows.focused_window(), Some(main)); + assert!(!windows.is_player_surface_focused()); } } diff --git a/crates/ferrex-player-ui/src/domains/ui/windows/controller.rs b/crates/ferrex-player-ui/src/domains/ui/windows/controller.rs index a00bc3e8..de76085d 100644 --- a/crates/ferrex-player-ui/src/domains/ui/windows/controller.rs +++ b/crates/ferrex-player-ui/src/domains/ui/windows/controller.rs @@ -1,3 +1,4 @@ +use ferrex_player_playback::messages::PlaybackRequestId; use iced::{Point, Task, window}; use crate::{ @@ -65,6 +66,14 @@ fn player_overlay_window_settings(state: &State) -> window::Settings { } } +fn focus_active_player_overlay(id: window::Id) -> Task { + // Callers reach this helper only after native attachment is confirmed and + // the overlay lifecycle is Active. On macOS the vendored winit backend + // detects the foreign-hosted view and makes it first responder in its + // actual host; it never orders or activates the hidden donor NSWindow. + window::gain_focus(id) +} + /// Allocate the dedicated controls overlay in a hidden state. /// /// Showing it is a separate operation that must only happen after a presenter @@ -74,7 +83,7 @@ pub fn open_player_overlay(state: &mut State) -> DomainUpdateResult { return if state.windows.player_overlay_state() == PlayerOverlayWindowState::Active { - DomainUpdateResult::task(window::gain_focus(id)) + DomainUpdateResult::task(focus_active_player_overlay(id)) } else { DomainUpdateResult::task(Task::none()) }; @@ -105,16 +114,219 @@ pub fn on_player_overlay_opened( } } +/// Hide the retained shell before opening a resolved integrated source. +/// +/// `Task::chain` is the synchronization boundary: libmpv backend creation only +/// happens in the follow-up message after Iced has completed the main-window +/// hide action. URL authorization remains outside this transition, so the app +/// stays visible while the asynchronous source request is pending. +pub fn begin_integrated_playback( + state: &mut State, + request: PlaybackRequestId, +) -> DomainUpdateResult { + if !state + .domains + .player + .state + .is_resolved_playback_request(request) + { + log::debug!( + "ignoring stale integrated shell handoff for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } + + if state.windows.player_overlay_state() == PlayerOverlayWindowState::Closing + { + state.windows.defer_player_overlay_launch(request); + log::debug!( + "deferring integrated shell handoff until the closing donor is gone" + ); + return DomainUpdateResult::task(Task::none()); + } + + // A detached Search window would remain visible after the retained main + // shell is hidden, creating a second externally manageable window beside + // mpv's root. Retire it before any backend-open continuation can run. + let close_detached_search = + crate::domains::ui::search_surface::close_detached_for_native_playback( + state, + ); + + let allocate = if state.windows.get(WindowKind::PlayerOverlay).is_none() { + open_player_overlay(state).task + } else { + Task::none() + }; + + if !state.windows.begin_player_overlay_launch(request) { + log::debug!( + "native player overlay launch already pending or unavailable" + ); + return DomainUpdateResult::task(Task::none()); + } + + log::debug!( + "native player overlay handoff: hiding retained main window before backend open" + ); + let open = Task::done(DomainMessage::Player( + PlayerMessage::OpenResolvedStreamSource { request }, + )); + let hide_then_open = + if let Some(main_id) = state.windows.get(WindowKind::Main) { + window::set_mode(main_id, window::Mode::Hidden).chain(open) + } else { + log::warn!( + "native player overlay handoff: main window is not registered" + ); + open + }; + + DomainUpdateResult::task( + close_detached_search.chain(allocate).chain(hide_then_open), + ) +} + +fn continue_external_playback_handoff( + state: &mut State, + request: PlaybackRequestId, +) -> DomainUpdateResult { + if !state + .domains + .player + .state + .is_resolved_playback_request(request) + || !state + .domains + .player + .state + .is_external_playback_intent(request) + { + log::debug!( + "retiring stale external shell handoff for request {:?}", + request + ); + return if state.windows.finish_shell_handoff(Some(request)) { + DomainUpdateResult::task(restore_main_window(state)) + } else { + DomainUpdateResult::task(Task::none()) + }; + } + + let spawn = Task::done(DomainMessage::Player( + PlayerMessage::OpenExternalStreamSource { request }, + )); + let hide_then_spawn = if let Some(main_id) = + state.windows.get(WindowKind::Main) + { + window::set_mode(main_id, window::Mode::Hidden).chain(spawn) + } else { + log::warn!("external playback handoff: main window is not registered"); + spawn + }; + DomainUpdateResult::task(hide_then_spawn) +} + +/// Transfer shell ownership to an external request, then ensure an integrated +/// donor is positively closed before the retained shell hide and process spawn. +pub fn begin_external_playback( + state: &mut State, + request: PlaybackRequestId, +) -> DomainUpdateResult { + if !state + .domains + .player + .state + .is_resolved_playback_request(request) + || !state + .domains + .player + .state + .is_external_playback_intent(request) + { + log::debug!( + "ignoring stale external shell handoff for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } + + state.windows.begin_external_shell_handoff(request); + if let Some(id) = state.windows.get(WindowKind::PlayerOverlay) { + state.windows.defer_external_playback_launch(request); + let previous = state.windows.begin_player_overlay_close(); + if previous == PlayerOverlayWindowState::Closing { + return DomainUpdateResult::task(Task::none()); + } + prepare_player_overlay_close(id); + log::debug!( + "external playback handoff: waiting for integrated donor raw close" + ); + return DomainUpdateResult::task(window::close(id)); + } + + continue_external_playback_handoff(state, request) +} + +/// Restore the matching retained shell before permitting internal fallback. +/// A stale failure may restore its old owner, but can never launch its backend. +pub fn recover_external_playback_launch( + state: &mut State, + request: PlaybackRequestId, +) -> DomainUpdateResult { + if !state.windows.finish_shell_handoff(Some(request)) { + log::debug!( + "ignoring stale external launch failure for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } + + let current = state + .domains + .player + .state + .is_resolved_playback_request(request) + && state + .domains + .player + .state + .is_external_playback_intent(request); + let restore = restore_main_window(state); + if current { + DomainUpdateResult::task(restore.chain(Task::done( + DomainMessage::Player( + PlayerMessage::ResumeInternalPlaybackAfterExternalLaunchFailure { + request, + }, + ), + ))) + } else { + DomainUpdateResult::task(restore) + } +} + /// Reveal the overlay only after native attachment has been confirmed. -pub fn activate_player_overlay(state: &mut State) -> DomainUpdateResult { +pub fn activate_player_overlay( + state: &mut State, + request: PlaybackRequestId, +) -> DomainUpdateResult { + if state.domains.player.state.session_playback_request != Some(request) { + log::debug!( + "ignoring stale native-presenter attachment for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } let Some(_overlay_id) = state.windows.get(WindowKind::PlayerOverlay) else { return DomainUpdateResult::task(Task::none()); }; - if !state.windows.activate_player_overlay() { + if !state.windows.activate_player_overlay(request) { return DomainUpdateResult::task(Task::none()); } log::debug!( - "native player overlay handoff: presenter attached and positioned; queuing retained main-window hide" + "native player overlay handoff: presenter attached and positioned; defensively reasserting retained main-window hide" ); // The platform presenter already positioned the hidden overlay against @@ -127,7 +339,7 @@ pub fn activate_player_overlay(state: &mut State) -> DomainUpdateResult { .map(|main_id| window::set_mode(main_id, window::Mode::Hidden)) .unwrap_or_else(Task::none) .chain(Task::done(DomainMessage::Ui( - UiShellMessage::PlayerOverlayHandoffReady.into(), + UiShellMessage::PlayerOverlayHandoffReady { request }.into(), ))); DomainUpdateResult::task(handoff) @@ -137,12 +349,20 @@ pub fn activate_player_overlay(state: &mut State) -> DomainUpdateResult { /// completed its hide command. pub fn finish_player_overlay_activation( state: &mut State, + request: PlaybackRequestId, ) -> DomainUpdateResult { + if state.domains.player.state.root_shutdown_blocks_launch() { + log::debug!( + "deferring stale native-presenter reveal while root teardown is unresolved" + ); + return DomainUpdateResult::task(Task::none()); + } let Some(overlay_id) = state.windows.get(WindowKind::PlayerOverlay) else { return DomainUpdateResult::task(Task::none()); }; if state.windows.player_overlay_state() != PlayerOverlayWindowState::Activating + || state.windows.player_overlay_launch_request() != Some(request) { return DomainUpdateResult::task(Task::none()); } @@ -158,21 +378,28 @@ pub fn finish_player_overlay_activation( .as_mut() .is_some_and(|video| video.set_native_presenter_host_visible(true)); if !presenter_visible { - return dismiss_player_overlay(state); + return dismiss_player_overlay_for_effective_target(state, request); } - if !state.windows.finish_player_overlay_activation() { - return dismiss_player_overlay(state); + if !state.windows.finish_player_overlay_activation(request) { + return dismiss_player_overlay_for_effective_target(state, request); } + // Attachment is now complete and the lifecycle is Active, so the + // canonical focus task is safe to queue. The vendored macOS backend routes + // a foreign-hosted view to its actual host's first responder without + // ordering the hidden donor window. log::debug!( "native player overlay handoff: presenter host visible; overlay focus requested" ); - - DomainUpdateResult::task(window::gain_focus(overlay_id)) + DomainUpdateResult::task(focus_active_player_overlay(overlay_id)) } /// Retain an observable, pointer-free confirmation that the platform delivered /// focus after the native host became visible. -pub fn on_player_overlay_focused(state: &State) -> DomainUpdateResult { +pub fn on_player_overlay_focused(state: &mut State) -> DomainUpdateResult { + let Some(id) = state.windows.get(WindowKind::PlayerOverlay) else { + return DomainUpdateResult::task(Task::none()); + }; + state.windows.record_focus(id); if state.windows.player_overlay_state() == PlayerOverlayWindowState::Active { log::debug!("native player overlay handoff: overlay focus confirmed"); @@ -180,6 +407,18 @@ pub fn on_player_overlay_focused(state: &State) -> DomainUpdateResult { DomainUpdateResult::task(Task::none()) } +/// Clear actual-host focus when the platform reports that the player root lost +/// it. A stale unfocus cannot clear focus from a newer registered window. +pub fn on_player_overlay_unfocused(state: &mut State) -> DomainUpdateResult { + let Some(id) = state.windows.get(WindowKind::PlayerOverlay) else { + return DomainUpdateResult::task(Task::none()); + }; + if state.windows.record_unfocus(id) { + log::debug!("native player overlay handoff: overlay focus released"); + } + DomainUpdateResult::task(Task::none()) +} + fn restore_main_window(state: &State) -> Task { let Some(main_id) = state.windows.get(WindowKind::Main) else { return Task::none(); @@ -210,54 +449,164 @@ fn prepare_player_overlay_close(id: window::Id) { ); } -fn close_player_overlay_with_policy( +/// Close a user-visible controls donor and request playback teardown. +/// +/// The retained shell is deliberately not restored here. Playback first +/// withdraws mpv's native root synchronously, then emits the request-scoped +/// `PlaybackExited` event that owns shell restoration. +pub fn close_player_overlay( state: &mut State, - stop_playback: bool, + request: PlaybackRequestId, ) -> DomainUpdateResult { - let Some(id) = state.windows.get(WindowKind::PlayerOverlay) else { + if state.windows.shell_hidden_for_playback() != Some(request) { + log::debug!( + "ignoring stale native root close for request {:?}", + request + ); return DomainUpdateResult::task(Task::none()); + } + let Some(id) = state.windows.get(WindowKind::PlayerOverlay) else { + // A native-root CloseRequested can already be queued when a fallback + // donor close publishes RawWindowClosed and removes its subscription. + // The request-scoped message still owns this shell, so preserve the + // root-close intent even though no donor remains to close. + return DomainUpdateResult::task(Task::done(DomainMessage::Player( + PlayerMessage::Stop, + ))); }; let previous = state.windows.begin_player_overlay_close(); if previous == PlayerOverlayWindowState::Closing { - return DomainUpdateResult::task(Task::none()); + // `ClosePlayerOverlay` is sourced from an actual native close request, + // not from our programmatic donor-close action. A presenter refresh can + // race that queued request and already mark the donor Closing while + // attempting native-window fallback. Preserve the user's root-close + // intent by still stopping playback; otherwise fallback verification + // can wait forever on the root that is already closing. + return DomainUpdateResult::task(Task::done(DomainMessage::Player( + PlayerMessage::Stop, + ))); } - - // Presenter detach and raw-handle release happen synchronously in this - // update, before the native close action can be processed. prepare_player_overlay_close(id); - let mut close: Task = window::close(id); - let handoff_started = matches!( + let mut tasks = vec![window::close(id)]; + if matches!( previous, - PlayerOverlayWindowState::Activating | PlayerOverlayWindowState::Active - ); - if handoff_started { - close = close.chain(restore_main_window(state)); - } - - let mut tasks = vec![close]; - if stop_playback && handoff_started { + PlayerOverlayWindowState::Launching + | PlayerOverlayWindowState::Activating + | PlayerOverlayWindowState::Active + ) { tasks.push(Task::done(DomainMessage::Player(PlayerMessage::Stop))); } DomainUpdateResult::task(Task::batch(tasks)) } -/// Close a user-visible player overlay, stop playback, and restore the main -/// window geometry/focus. Presenter fallback can use -/// [`dismiss_player_overlay`] without stopping the replacement backend. -pub fn close_player_overlay(state: &mut State) -> DomainUpdateResult { - close_player_overlay_with_policy(state, true) +/// Remove the overlay after playback has exited or failed, restoring the +/// retained application shell only for the request that owns its hide. +pub fn dismiss_player_overlay( + state: &mut State, + request: Option, +) -> DomainUpdateResult { + if request.is_none() + && (state.windows.shell_hidden_for_playback().is_some() + || state.windows.player_overlay_launch_request().is_some()) + { + log::debug!( + "ignoring requestless stale exit while a playback request owns the shell" + ); + return DomainUpdateResult::task(Task::none()); + } + let owner_matches = request.is_none() + || state.windows.shell_hidden_for_playback() == request; + let overlay_matches = request.is_none() + || state.windows.player_overlay_launch_request() == request; + if !owner_matches && !overlay_matches { + log::debug!( + "ignoring stale playback-exit shell disposition {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } + + let restore = state.windows.finish_shell_handoff(request); + let close = state.windows.get(WindowKind::PlayerOverlay).and_then(|id| { + let previous = state.windows.begin_player_overlay_close(); + if previous == PlayerOverlayWindowState::Closing { + None + } else { + prepare_player_overlay_close(id); + Some(window::close(id)) + } + }); + + let task = match (close, restore) { + (Some(close), true) => close.chain(restore_main_window(state)), + (Some(close), false) => close, + (None, true) => restore_main_window(state), + (None, false) => Task::none(), + }; + DomainUpdateResult::task(task) +} + +/// Remove the controls donor while a normal native-mpv fallback keeps playing. +/// +/// The retained Ferrex shell must remain hidden: mpv's fallback root is still +/// the sole visible/manageable playback window. Restoring the shell here would +/// create two externally visible windows. +pub fn dismiss_player_overlay_for_native_fallback( + state: &mut State, + request: PlaybackRequestId, +) -> DomainUpdateResult { + if state.windows.shell_hidden_for_playback() != Some(request) + && state.windows.player_overlay_launch_request() != Some(request) + { + log::debug!( + "ignoring stale native-window fallback disposition for request {:?}", + request + ); + return DomainUpdateResult::task(Task::none()); + } + let Some(id) = state.windows.get(WindowKind::PlayerOverlay) else { + return DomainUpdateResult::task(Task::none()); + }; + let previous = state.windows.begin_player_overlay_close(); + if previous == PlayerOverlayWindowState::Closing { + return DomainUpdateResult::task(Task::none()); + } + prepare_player_overlay_close(id); + DomainUpdateResult::task(window::close(id)) } -/// Remove the overlay while allowing playback fallback to continue. -pub fn dismiss_player_overlay(state: &mut State) -> DomainUpdateResult { - close_player_overlay_with_policy(state, false) +fn dismiss_player_overlay_for_effective_target( + state: &mut State, + request: PlaybackRequestId, +) -> DomainUpdateResult { + let native_root_continues = state + .domains + .player + .state + .video_opt + .as_ref() + .is_some_and(|video| { + state.domains.player.state.session_playback_request + == Some(request) + && video.snapshot().target + == ferrex_player_playback::contract::PlaybackTarget::MPV_NATIVE_WINDOW + }); + if native_root_continues { + dismiss_player_overlay_for_native_fallback(state, request) + } else { + dismiss_player_overlay(state, Some(request)) + } } pub fn open_search( state: &mut State, seed: Option, ) -> DomainUpdateResult { + if !crate::domains::ui::search_surface::detached_search_allowed(state) { + return crate::domains::ui::search_surface::open_overlay(state, seed); + } + state.domains.search.state.presentation = SearchPresentation::DetachedWindow; @@ -301,9 +650,28 @@ pub fn open_search( } pub fn on_search_opened( - _state: &mut State, + state: &mut State, id: window::Id, ) -> DomainUpdateResult { + let registered = state.windows.get(WindowKind::Search); + let is_current_detached = state.search_window_id == Some(id) + && registered == Some(id) + && state.domains.search.state.presentation + == SearchPresentation::DetachedWindow + && crate::domains::ui::search_surface::detached_search_allowed(state); + if !is_current_detached { + // The open task can complete after playback has claimed the shell or + // after Search has moved back in-root. Retire only this exact stale + // host and never overwrite the newer presentation/focus state. + if state.search_window_id == Some(id) { + state.search_window_id = None; + } + if registered == Some(id) { + state.windows.remove_by_id(id); + } + return DomainUpdateResult::task(window::close(id)); + } + let focus_input = super::focus::focus_search_window_input(); let focus_window = window::gain_focus(id); let set_top = window::set_level(id, window::Level::AlwaysOnTop); @@ -322,7 +690,13 @@ pub fn focus_search(state: &State) -> DomainUpdateResult { } } -pub fn focus_search_input(state: &State) -> DomainUpdateResult { +pub fn focus_search_input(state: &mut State) -> DomainUpdateResult { + if state.domains.search.state.presentation + == SearchPresentation::DetachedWindow + && let Some(id) = state.search_window_id + { + state.windows.record_focus(id); + } if state.domains.search.state.presentation.is_open() { DomainUpdateResult::task(super::focus::focus_active_search_input(state)) } else { @@ -332,6 +706,9 @@ pub fn focus_search_input(state: &State) -> DomainUpdateResult { pub fn close_search(state: &mut State) -> DomainUpdateResult { if let Some(id) = state.search_window_id.take() { + if state.windows.get(WindowKind::Search) == Some(id) { + state.windows.remove_by_id(id); + } let mut tasks: Vec> = Vec::new(); tasks.push(window::close(id)); push_main_focus(&mut tasks, state); @@ -347,15 +724,20 @@ pub fn on_raw_window_closed( ) -> DomainUpdateResult { let mut tasks: Vec> = Vec::new(); let overlay_state = state.windows.player_overlay_state(); + let search_was_current = state.search_window_id == Some(id); if let Some(kind) = state.windows.remove_by_id(id) { if matches!(kind, WindowKind::Main) { return DomainUpdateResult::task(iced::exit()); } - if matches!(kind, WindowKind::Search) { + if matches!(kind, WindowKind::Search) && search_was_current { state.search_window_id = None; state.domains.search.state.presentation = SearchPresentation::Hidden; - push_main_focus(&mut tasks, state); + if crate::domains::ui::search_surface::detached_search_allowed( + state, + ) { + push_main_focus(&mut tasks, state); + } } if matches!(kind, WindowKind::PlayerOverlay) { // Normally this is idempotent because the ClosePlayerOverlay @@ -363,14 +745,25 @@ pub fn on_raw_window_closed( prepare_player_overlay_close(id); if matches!( overlay_state, - PlayerOverlayWindowState::Activating + PlayerOverlayWindowState::Launching + | PlayerOverlayWindowState::Activating | PlayerOverlayWindowState::Active ) { // Defensive path for a platform that bypassed CloseRequested. tasks.push(Task::done(DomainMessage::Player( PlayerMessage::Stop, ))); - tasks.push(restore_main_window(state)); + } + if let Some(request) = + state.windows.take_deferred_external_playback_launch() + { + tasks.push( + continue_external_playback_handoff(state, request).task, + ); + } else if let Some(request) = + state.windows.take_deferred_player_overlay_launch() + { + tasks.push(begin_integrated_playback(state, request).task); } } } @@ -391,6 +784,10 @@ fn push_main_focus(tasks: &mut Vec>, state: &State) { #[cfg(test)] mod tests { use super::*; + use iced_runtime::{ + Action, futures::futures::StreamExt, task, + window::Action as WindowAction, + }; #[tokio::test(flavor = "current_thread")] async fn player_overlay_is_transparent_undecorated_and_initially_hidden() { @@ -406,6 +803,45 @@ mod tests { assert_eq!(settings.size, state.window_size); } + #[tokio::test(flavor = "current_thread")] + async fn active_overlay_focus_uses_canonical_window_action() { + let donor = window::Id::unique(); + let mut actions = task::into_stream(focus_active_player_overlay(donor)) + .expect("active overlay focus should emit an action"); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::GainFocus(id))) if id == donor + )); + assert!(actions.next().await.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn existing_overlay_refocus_requires_active_lifecycle() { + let mut state = State::default(); + let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(1); + state.windows.set(WindowKind::PlayerOverlay, overlay); + + assert!( + task::into_stream(open_player_overlay(&mut state).task).is_none() + ); + assert!(state.windows.begin_player_overlay_launch(request)); + assert!(state.windows.activate_player_overlay(request)); + assert!( + task::into_stream(open_player_overlay(&mut state).task).is_none() + ); + + assert!(state.windows.finish_player_overlay_activation(request)); + let mut actions = + task::into_stream(open_player_overlay(&mut state).task) + .expect("active overlay refocus should emit an action"); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::GainFocus(id))) if id == overlay + )); + assert!(actions.next().await.is_none()); + } + #[tokio::test(flavor = "current_thread")] async fn opening_overlay_records_hidden_lifecycle_before_task_completion() { let mut state = State::default(); @@ -419,18 +855,347 @@ mod tests { drop(result); } + #[tokio::test(flavor = "current_thread")] + async fn integrated_open_hides_main_before_emitting_backend_continuation() { + let mut state = State::default(); + let request = + state.domains.player.state.begin_playback_request().unwrap(); + assert!(state.domains.player.state.resolve_playback_request(request)); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + + let result = begin_integrated_playback(&mut state, request); + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Launching + ); + + let mut actions = task::into_stream(result.task) + .expect("integrated launch should emit ordered actions"); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::SetMode( + id, + window::Mode::Hidden + ))) if id == main + )); + assert!(matches!( + actions.next().await, + Some(Action::Output(DomainMessage::Player( + PlayerMessage::OpenResolvedStreamSource { + request: emitted_request + } + ))) if emitted_request == request + )); + assert!(actions.next().await.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn integrated_open_closes_detached_search_before_backend_continuation() + { + let mut state = State::default(); + let request = + state.domains.player.state.begin_playback_request().unwrap(); + assert!(state.domains.player.state.resolve_playback_request(request)); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let search = window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + state.windows.set(WindowKind::Search, search); + state.search_window_id = Some(search); + state.domains.search.state.presentation = + SearchPresentation::DetachedWindow; + + let result = begin_integrated_playback(&mut state, request); + + assert_eq!(state.search_window_id, None); + assert_eq!(state.windows.get(WindowKind::Search), None); + assert_eq!( + state.domains.search.state.presentation, + SearchPresentation::Hidden + ); + let mut actions = task::into_stream(result.task).expect( + "search close, shell hide, and backend open must be ordered", + ); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::Close(id))) if id == search + )); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::SetMode( + id, + window::Mode::Hidden + ))) if id == main + )); + assert!(matches!( + actions.next().await, + Some(Action::Output(DomainMessage::Player( + PlayerMessage::OpenResolvedStreamSource { + request: emitted_request + } + ))) if emitted_request == request + )); + assert!(actions.next().await.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn delayed_search_open_cannot_escape_native_player_root() { + let mut state = State::default(); + let request = + state.domains.player.state.begin_playback_request().unwrap(); + assert!(state.domains.player.state.resolve_playback_request(request)); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let search = window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + state.windows.set(WindowKind::Search, search); + state.search_window_id = Some(search); + state.domains.search.state.presentation = + SearchPresentation::DetachedWindow; + + drop(begin_integrated_playback(&mut state, request)); + drop(crate::domains::ui::search_surface::open_overlay( + &mut state, None, + )); + let late = on_search_opened(&mut state, search); + + assert_eq!( + state.domains.search.state.presentation, + SearchPresentation::Overlay + ); + assert_eq!(state.search_window_id, None); + assert_eq!(state.windows.get(WindowKind::Search), None); + let mut actions = task::into_stream(late.task) + .expect("late detached host must be closed"); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::Close(id))) if id == search + )); + assert!(actions.next().await.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn delayed_search_raw_close_cannot_hide_new_in_root_overlay() { + let mut state = State::default(); + let request = + state.domains.player.state.begin_playback_request().unwrap(); + assert!(state.domains.player.state.resolve_playback_request(request)); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let search = window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + state.windows.set(WindowKind::Search, search); + state.search_window_id = Some(search); + state.domains.search.state.presentation = + SearchPresentation::DetachedWindow; + + drop(begin_integrated_playback(&mut state, request)); + drop(crate::domains::ui::search_surface::open_overlay( + &mut state, None, + )); + let late = on_raw_window_closed(&mut state, search); + + assert!(task::into_stream(late.task).is_none()); + assert_eq!( + state.domains.search.state.presentation, + SearchPresentation::Overlay + ); + assert_eq!(state.search_window_id, None); + assert_eq!(state.windows.get(WindowKind::Search), None); + } + + #[tokio::test(flavor = "current_thread")] + async fn external_handoff_waits_for_donor_close_before_hide_and_spawn() { + let mut state = State::default(); + let request = + state.domains.player.state.begin_playback_request().unwrap(); + assert!(state.domains.player.state.resolve_playback_request(request)); + assert!( + state + .domains + .player + .state + .request_external_playback(request) + ); + let main = window::Id::unique(); + let donor = window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, donor); + + let begin = begin_external_playback(&mut state, request); + assert_eq!(state.windows.shell_hidden_for_playback(), Some(request)); + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Closing + ); + let mut begin_actions = task::into_stream(begin.task) + .expect("donor close must be requested"); + assert!(matches!( + begin_actions.next().await, + Some(Action::Window(WindowAction::Close(id))) if id == donor + )); + assert!( + begin_actions.next().await.is_none(), + "shell hide and spawn must wait for raw donor close" + ); + + let closed = on_raw_window_closed(&mut state, donor); + let mut closed_actions = task::into_stream(closed.task) + .expect("raw close must continue the ordered handoff"); + assert!(matches!( + closed_actions.next().await, + Some(Action::Window(WindowAction::SetMode( + id, + window::Mode::Hidden + ))) if id == main + )); + assert!(matches!( + closed_actions.next().await, + Some(Action::Output(DomainMessage::Player( + PlayerMessage::OpenExternalStreamSource { + request: emitted + } + ))) if emitted == request + )); + assert!(closed_actions.next().await.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn external_spawn_failure_restores_shell_before_internal_fallback() { + let mut state = State::default(); + let request = + state.domains.player.state.begin_playback_request().unwrap(); + assert!(state.domains.player.state.resolve_playback_request(request)); + assert!( + state + .domains + .player + .state + .request_external_playback(request) + ); + let main = window::Id::unique(); + state.windows.set(WindowKind::Main, main); + state.windows.begin_external_shell_handoff(request); + + let result = recover_external_playback_launch(&mut state, request); + assert_eq!(state.windows.shell_hidden_for_playback(), None); + let mut actions = task::into_stream(result.task) + .expect("matching launch failure must restore and fall back"); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::Resize(id, _))) if id == main + )); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::SetMode( + id, + window::Mode::Windowed + ))) if id == main + )); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::GainFocus(id))) if id == main + )); + assert!(matches!( + actions.next().await, + Some(Action::Output(DomainMessage::Player( + PlayerMessage::ResumeInternalPlaybackAfterExternalLaunchFailure { + request: emitted + } + ))) if emitted == request + )); + assert!(actions.next().await.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn pre_attach_failure_closes_host_then_restores_main_window() { + let mut state = State::default(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(1); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(request)); + + let result = dismiss_player_overlay(&mut state, Some(request)); + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Closing + ); + + let mut actions = task::into_stream(result.task) + .expect("pre-attach failure should close and restore"); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::Close(id))) if id == overlay + )); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::Resize(id, _))) if id == main + )); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::SetMode( + id, + window::Mode::Windowed + ))) if id == main + )); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::GainFocus(id))) if id == main + )); + assert!(actions.next().await.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn native_fallback_closes_donor_without_restoring_main_window() { + let mut state = State::default(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(1); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(request)); + + let result = + dismiss_player_overlay_for_native_fallback(&mut state, request); + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Closing + ); + + let mut actions = task::into_stream(result.task) + .expect("native fallback should close the hidden donor"); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::Close(id))) if id == overlay + )); + assert!( + actions.next().await.is_none(), + "native fallback must not restore or focus the retained main window" + ); + } + #[tokio::test(flavor = "current_thread")] async fn playback_exit_dismisses_active_overlay_without_mutating_player() { let mut state = State::default(); let main = window::Id::unique(); let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(1); state.windows.set(WindowKind::Main, main); state.windows.set(WindowKind::PlayerOverlay, overlay); - assert!(state.windows.activate_player_overlay()); - assert!(state.windows.finish_player_overlay_activation()); + assert!(state.windows.begin_player_overlay_launch(request)); + assert!(state.windows.activate_player_overlay(request)); + assert!(state.windows.finish_player_overlay_activation(request)); state.domains.player.state.last_valid_position = 42.5; - let result = dismiss_player_overlay(&mut state); + let result = dismiss_player_overlay(&mut state, Some(request)); assert_eq!( state.windows.player_overlay_state(), @@ -441,4 +1206,222 @@ mod tests { assert_eq!(state.windows.get(WindowKind::PlayerOverlay), Some(overlay)); drop(result); } + + #[tokio::test(flavor = "current_thread")] + async fn native_fallback_exit_restores_shell_after_donor_is_gone() { + let mut state = State::default(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(7); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(request)); + + let close = + dismiss_player_overlay_for_native_fallback(&mut state, request); + drop(close); + assert_eq!( + state.windows.remove_by_id(overlay), + Some(WindowKind::PlayerOverlay) + ); + + let result = dismiss_player_overlay(&mut state, Some(request)); + let mut actions = task::into_stream(result.task) + .expect("fallback exit must restore the retained shell"); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::Resize(id, _))) if id == main + )); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::SetMode( + id, + window::Mode::Windowed + ))) if id == main + )); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::GainFocus(id))) if id == main + )); + assert!(actions.next().await.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn deferred_replacement_failure_restores_before_raw_close() { + let mut state = State::default(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let old = PlaybackRequestId::new(3); + let replacement = PlaybackRequestId::new(4); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(old)); + drop(dismiss_player_overlay_for_native_fallback(&mut state, old)); + + state.domains.player.state.active_playback_request = Some(replacement); + state.domains.player.state.resolved_playback_request = + Some(replacement); + let deferred = begin_integrated_playback(&mut state, replacement); + assert!(task::into_stream(deferred.task).is_none()); + assert_eq!( + state.windows.shell_hidden_for_playback(), + Some(replacement) + ); + + let result = dismiss_player_overlay(&mut state, Some(replacement)); + let mut actions = task::into_stream(result.task) + .expect("deferred replacement failure must restore the shell"); + assert!(matches!( + actions.next().await, + Some(Action::Window(WindowAction::Resize(id, _))) if id == main + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn requestless_stale_exit_cannot_restore_an_owned_shell() { + let mut state = State::default(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(5); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(request)); + + let result = dismiss_player_overlay(&mut state, None); + assert!(task::into_stream(result.task).is_none()); + assert_eq!(state.windows.shell_hidden_for_playback(), Some(request)); + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Launching + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn user_close_never_restores_shell_before_stop_is_reduced() { + let mut state = State::default(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(6); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(request)); + assert!(state.windows.activate_player_overlay(request)); + assert!(state.windows.finish_player_overlay_activation(request)); + + let result = close_player_overlay(&mut state, request); + let mut actions = task::into_stream(result.task) + .expect("user close must close the donor and request stop"); + let mut saw_close = false; + let mut saw_stop = false; + while let Some(action) = actions.next().await { + match action { + Action::Window(WindowAction::Close(id)) if id == overlay => { + saw_close = true; + } + Action::Output(DomainMessage::Player(PlayerMessage::Stop)) => { + saw_stop = true; + } + Action::Window( + WindowAction::Resize(id, _) + | WindowAction::SetMode(id, _) + | WindowAction::GainFocus(id), + ) if id == main => { + panic!( + "main shell restoration must wait for PlaybackExited" + ); + } + _ => {} + } + } + assert!(saw_close); + assert!(saw_stop); + } + + #[tokio::test(flavor = "current_thread")] + async fn queued_native_root_close_stops_after_fallback_donor_raw_close() { + let mut state = State::default(); + let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(7); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(request)); + assert!(state.windows.activate_player_overlay(request)); + assert!(state.windows.finish_player_overlay_activation(request)); + + let fallback = + dismiss_player_overlay_for_native_fallback(&mut state, request); + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Closing + ); + let mut fallback_actions = task::into_stream(fallback.task) + .expect("fallback must queue donor close"); + assert!(matches!( + fallback_actions.next().await, + Some(Action::Window(WindowAction::Close(id))) if id == overlay + )); + assert!(fallback_actions.next().await.is_none()); + + let donor_closed = on_raw_window_closed(&mut state, overlay); + assert!(task::into_stream(donor_closed.task).is_none()); + assert_eq!(state.windows.get(WindowKind::PlayerOverlay), None); + + let user_close = close_player_overlay(&mut state, request); + let mut user_close_actions = task::into_stream(user_close.task) + .expect("queued native root close must preserve stop intent"); + assert!(matches!( + user_close_actions.next().await, + Some(Action::Output(DomainMessage::Player(PlayerMessage::Stop))) + )); + assert!(user_close_actions.next().await.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn stale_native_root_close_cannot_stop_replacement_request() { + let mut state = State::default(); + let overlay = window::Id::unique(); + let stale = PlaybackRequestId::new(7); + let replacement = PlaybackRequestId::new(8); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(replacement)); + assert!(state.windows.activate_player_overlay(replacement)); + assert!(state.windows.finish_player_overlay_activation(replacement)); + + let result = close_player_overlay(&mut state, stale); + + assert!(task::into_stream(result.task).is_none()); + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Active + ); + assert_eq!( + state.windows.shell_hidden_for_playback(), + Some(replacement) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn delayed_handoff_ready_cannot_reveal_or_restore_during_shutdown() { + let mut state = State::default(); + let main = window::Id::unique(); + let overlay = window::Id::unique(); + let request = PlaybackRequestId::new(9); + state.windows.set(WindowKind::Main, main); + state.windows.set(WindowKind::PlayerOverlay, overlay); + assert!(state.windows.begin_player_overlay_launch(request)); + assert!(state.windows.activate_player_overlay(request)); + state.domains.player.state.root_shutdown_in_progress = true; + + let result = finish_player_overlay_activation(&mut state, request); + + assert!(task::into_stream(result.task).is_none()); + assert_eq!( + state.windows.player_overlay_state(), + PlayerOverlayWindowState::Activating + ); + assert_eq!(state.windows.shell_hidden_for_playback(), Some(request)); + assert_eq!( + state.windows.player_overlay_launch_request(), + Some(request) + ); + } } diff --git a/crates/ferrex-player-ui/src/domains/ui/windows/subscriptions.rs b/crates/ferrex-player-ui/src/domains/ui/windows/subscriptions.rs index a748e670..c34ce8bd 100644 --- a/crates/ferrex-player-ui/src/domains/ui/windows/subscriptions.rs +++ b/crates/ferrex-player-ui/src/domains/ui/windows/subscriptions.rs @@ -74,6 +74,11 @@ pub fn subscription(state: &State) -> Subscription { if let Some(overlay_id) = state.windows.get(WindowKind::PlayerOverlay) { subs.push(iced::window::events().with(overlay_id).map( |(tracked_id, (id, event))| match event { + iced::window::Event::Moved(position) if id == tracked_id => { + DomainMessage::Ui( + WindowUiMessage::WindowMoved(Some(position)).into(), + ) + } iced::window::Event::Resized(size) if id == tracked_id => { DomainMessage::Ui( UiShellMessage::PlayerOverlayResized(id, size).into(), @@ -84,20 +89,37 @@ pub fn subscription(state: &State) -> Subscription { UiShellMessage::PlayerOverlayFocused.into(), ) } + iced::window::Event::Unfocused if id == tracked_id => { + DomainMessage::Ui( + UiShellMessage::PlayerOverlayUnfocused.into(), + ) + } _ => DomainMessage::NoOp, }, )); // `exit_on_close_request` is disabled for this window so presenter // detach and host-lease release run before native destruction. - subs.push(iced::window::close_requests().with(overlay_id).map( - |(tracked_id, id)| { - if id == tracked_id { - DomainMessage::Ui(UiShellMessage::ClosePlayerOverlay.into()) - } else { - DomainMessage::NoOp - } - }, - )); + if let Some(request) = state.windows.shell_hidden_for_playback() { + // Include the request in the subscription identity as well as the + // emitted message. A donor reused by a replacement must not keep + // the previous request in an otherwise identical mapper. + subs.push( + iced::window::close_requests() + .with((overlay_id, request)) + .map(|((tracked_id, tracked_request), id)| { + if id == tracked_id { + DomainMessage::Ui( + UiShellMessage::ClosePlayerOverlay { + request: tracked_request, + } + .into(), + ) + } else { + DomainMessage::NoOp + } + }), + ); + } } Subscription::batch(subs) diff --git a/crates/ferrex-player-ui/src/view.rs b/crates/ferrex-player-ui/src/view.rs index 721dd18c..045e9f11 100644 --- a/crates/ferrex-player-ui/src/view.rs +++ b/crates/ferrex-player-ui/src/view.rs @@ -47,6 +47,26 @@ use iced::{Element, Font, Length, Theme}; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; +fn compose_search_overlay<'a>( + state: &'a State, + content: Element<'a, DomainMessage, Theme, iced::Renderer>, +) -> Element<'a, DomainMessage, Theme, iced::Renderer> { + if !state.domains.search.state.presentation.is_overlay() { + return content; + } + let Some(overlay) = + crate::domains::ui::views::components::view_search_overlay(state) + else { + return content; + }; + Stack::new() + .push(content) + .push(overlay) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + #[cfg_attr( any( feature = "profile-with-puffin", @@ -72,8 +92,11 @@ pub fn view( if state.windows.is_player_overlay_window(window_id) { // A dedicated native-root overlay renders no library shell, header, or // background scene. Its surface consists solely of player controls, - // status plates, and the transparent native-video slot region. - return view_player(state, Some(window_id)).map(DomainMessage::Player); + // status plates, the transparent native-video slot region, and any + // in-root search surface opened by those canonical controls. + let player = + view_player(state, Some(window_id)).map(DomainMessage::Player); + return compose_search_overlay(state, player); } // debug timing disabled in tests to simplify renderer unification // Check for first-run setup @@ -375,25 +398,7 @@ pub fn view( } }; - let with_search_overlay = - if state.domains.search.state.presentation.is_overlay() { - if let Some(overlay) = - crate::domains::ui::views::components::view_search_overlay( - state, - ) - { - Stack::new() - .push(layered) - .push(overlay) - .width(Length::Fill) - .height(Length::Fill) - .into() - } else { - layered - } - } else { - layered - }; + let with_search_overlay = compose_search_overlay(state, layered); // Overlay toast notifications if any are active if state.domains.ui.state.toast_manager.has_toasts() { @@ -736,10 +741,12 @@ fn theater_plate_cache_key(request: &ImageRequest) -> u64 { mod tests { use super::*; use crate::{ + domains::{search::types::SearchPresentation, ui::windows::WindowKind}, infra::constants::layout::header::HEIGHT as HEADER_HEIGHT, state::InterfaceMode, }; use ferrex_core::player_prelude::MovieID; + use iced::advanced::widget::Tree; use uuid::Uuid; fn detail_state(interface_mode: InterfaceMode) -> State { @@ -756,6 +763,27 @@ mod tests { state } + #[tokio::test(flavor = "current_thread")] + async fn player_overlay_root_composes_open_search_in_the_same_window() { + let mut state = State::new_with_interface_mode( + "http://localhost:9".to_string(), + InterfaceMode::TenFoot, + ); + state.window_size = iced::Size::new(1_280.0, 720.0); + state.domains.ui.state.view = ViewState::Player; + state.domains.search.state.presentation = SearchPresentation::Overlay; + let overlay = iced::window::Id::unique(); + state.windows.set(WindowKind::PlayerOverlay, overlay); + + let element = view(&state, overlay); + let tree = Tree::new(element.as_widget()); + assert_eq!( + tree.children.len(), + 2, + "player content and search must share one PlayerOverlay root" + ); + } + #[test] fn theater_plate_geometry_maps_hero_art_rect_to_uniforms() { let layout = DetailTheaterPlateLayout { From 94411a15e0e46535e4473c395fc3621dfc160bbe Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 10:56:31 -0600 Subject: [PATCH 17/56] fix(macos): harden bundled runtime staging --- .../ferrex-player/src/macos_bundle_runtime.rs | 8 +- scripts/release/macos-gstreamer-plugins.txt | 2 +- scripts/release/macos_bundle.py | 60 +++++++++++---- scripts/release/test_macos_build_profile.py | 2 + scripts/release/test_macos_bundle.py | 75 ++++++++++++++++++- 5 files changed, 125 insertions(+), 22 deletions(-) diff --git a/crates/ferrex-player/src/macos_bundle_runtime.rs b/crates/ferrex-player/src/macos_bundle_runtime.rs index 375e895d..2f73cfd8 100644 --- a/crates/ferrex-player/src/macos_bundle_runtime.rs +++ b/crates/ferrex-player/src/macos_bundle_runtime.rs @@ -31,9 +31,9 @@ fn bundle_runtime_paths(executable: &Path) -> Option { return None; } - let gstreamer_plugins = contents.join("PlugIns/gstreamer-1.0"); + let gstreamer_plugins = contents.join("Resources/gstreamer-1.0"); let gstreamer_scanner = contents.join("Helpers/gst-plugin-scanner"); - let gio_modules = contents.join("PlugIns/gio/modules"); + let gio_modules = contents.join("Resources/gio/modules"); let libsoup = contents.join("Frameworks/libsoup-3.0.0.dylib"); let ca_bundle = contents.join("Resources/tls/cacert.pem"); if !gstreamer_plugins.is_dir() @@ -190,9 +190,9 @@ mod tests { assert_eq!(bundle_runtime_paths(&executable), None); - let plugins = contents.join("PlugIns/gstreamer-1.0"); + let plugins = contents.join("Resources/gstreamer-1.0"); let scanner = contents.join("Helpers/gst-plugin-scanner"); - let gio_modules = contents.join("PlugIns/gio/modules"); + let gio_modules = contents.join("Resources/gio/modules"); let libsoup = contents.join("Frameworks/libsoup-3.0.0.dylib"); let ca_bundle = contents.join("Resources/tls/cacert.pem"); let icd = contents.join("Resources/vulkan/icd.d/MoltenVK_icd.json"); diff --git a/scripts/release/macos-gstreamer-plugins.txt b/scripts/release/macos-gstreamer-plugins.txt index 6fc51cf3..a6aa5e6d 100644 --- a/scripts/release/macos-gstreamer-plugins.txt +++ b/scripts/release/macos-gstreamer-plugins.txt @@ -26,5 +26,5 @@ libgstflac.dylib libgstmpg123.dylib libgstopus.dylib libgstvorbis.dylib -libgstdav1d.dylib +libgstaom.dylib libgstvpx.dylib diff --git a/scripts/release/macos_bundle.py b/scripts/release/macos_bundle.py index 6749d896..679787d3 100644 --- a/scripts/release/macos_bundle.py +++ b/scripts/release/macos_bundle.py @@ -288,6 +288,12 @@ def resolve(self, install_name: str, loader: Path) -> Path: raise BundleError( f"cannot resolve {install_name!r} required by {loader}" ) + if len(candidates) != 1: + choices = ", ".join(str(candidate) for candidate in candidates) + raise BundleError( + f"ambiguous dependency {install_name!r} required by {loader}: " + f"{choices}" + ) return candidates[0] def _require_declared_source( @@ -330,6 +336,30 @@ def _index(self) -> dict[str, list[Path]]: return self._basename_index +def dependency_destination_name( + preferred_name: str, + source: Path, + occupied: dict[str, Path], +) -> str: + """Return a deterministic non-colliding name for an exact dependency.""" + resolved = source.resolve() + previous = occupied.get(preferred_name) + if previous is None or previous == resolved: + return preferred_name + + digest = hashlib.sha256(resolved.read_bytes()).hexdigest() + suffix = Path(preferred_name).suffix + stem = preferred_name[: -len(suffix)] if suffix else preferred_name + for length in range(12, len(digest) + 1, 4): + candidate = f"{stem}-{digest[:length]}{suffix}" + previous = occupied.get(candidate) + if previous is None or previous == resolved: + return candidate + raise BundleError( + f"could not allocate a unique bundle name for {preferred_name}: {resolved}" + ) + + def copy_dependency_closure( binary: Path, staged_binary: Path, @@ -393,11 +423,13 @@ def copy_dependency_closure( existing_target = staged_by_source.get(resolved) if existing_target is not None: basename = existing_target.name - previous_source = source_by_basename.get(basename) - if previous_source is not None and previous_source != resolved: - raise BundleError( - "dependency closure contains two different libraries named " - f"{basename}: {previous_source} and {resolved}" + else: + # Nix closures can legitimately contain ABI-distinct libraries + # with the same SONAME. Their load commands identify exact store + # paths, so give colliding transitive files deterministic names + # and rewrite each caller to the corresponding bundled target. + basename = dependency_destination_name( + basename, resolved, source_by_basename ) source_by_basename[basename] = resolved target = frameworks / basename @@ -449,11 +481,11 @@ def rewrite_install_names(records: Iterable[StagedMachO]) -> None: required_rpath = None if staged.record.executable: required_rpath = APP_RPATH - elif "Contents/PlugIns/gstreamer-1.0" in staged.staged.as_posix(): + elif "Contents/Resources/gstreamer-1.0" in staged.staged.as_posix(): # GStreamer's soup loader opens libsoup by leaf name rather than a # Mach-O import. Give dyld a bundle-local search path for that load. required_rpath = GSTREAMER_PLUGIN_RPATH - elif "Contents/PlugIns/gio/modules" in staged.staged.as_posix(): + elif "Contents/Resources/gio/modules" in staged.staged.as_posix(): required_rpath = GIO_MODULE_RPATH if required_rpath is not None: current = macho_record(staged.staged, executable=staged.record.executable) @@ -550,7 +582,7 @@ def stage_gstreamer_runtime( seeds: list[tuple[Path, Path, bool, bool]] = [] exclusions = set(excluded_plugins) - plugin_target = contents / "PlugIns/gstreamer-1.0" + plugin_target = contents / "Resources/gstreamer-1.0" seen_names: dict[str, Path] = {} candidates = list(files) for directory in directories: @@ -653,7 +685,7 @@ def stage_gio_modules( modules: Iterable[Path], contents: Path ) -> list[tuple[Path, Path, bool, bool]]: seeds: list[tuple[Path, Path, bool, bool]] = [] - destination = contents / "PlugIns/gio/modules" + destination = contents / "Resources/gio/modules" for module in modules: seeds.append( copy_macho_seed( @@ -746,7 +778,7 @@ def audit_records( def audit_gstreamer_runtime(app: Path, records: Iterable[MachORecord]) -> None: - plugins = app / "Contents/PlugIns/gstreamer-1.0" + plugins = app / "Contents/Resources/gstreamer-1.0" if not plugins.is_dir(): return actual_names = {path.name for path in plugins.glob("*.dylib") if path.is_file()} @@ -804,7 +836,7 @@ def load_bundle_records(app: Path) -> tuple[list[MachORecord], str, str]: frameworks = app / "Contents/Frameworks" records = [macho_record(executable, executable=True)] records.extend(macho_record(path) for path in sorted(frameworks.rglob("*")) if path.is_file()) - plugins = app / "Contents/PlugIns/gstreamer-1.0" + plugins = app / "Contents/Resources/gstreamer-1.0" if plugins.is_dir(): records.extend( macho_record(path) for path in sorted(plugins.rglob("*.dylib")) if path.is_file() @@ -812,7 +844,7 @@ def load_bundle_records(app: Path) -> tuple[list[MachORecord], str, str]: scanner = app / "Contents/Helpers/gst-plugin-scanner" if scanner.is_file(): records.append(macho_record(scanner, executable=True)) - gio_modules = app / "Contents/PlugIns/gio/modules" + gio_modules = app / "Contents/Resources/gio/modules" if gio_modules.is_dir(): records.extend( macho_record(path, install_id_required=False) @@ -845,13 +877,13 @@ def verify_bundle( if missing: raise BundleError(f"required bundled libraries are missing: {', '.join(missing)}") if require_gstreamer_runtime: - plugins = app / "Contents/PlugIns/gstreamer-1.0" + plugins = app / "Contents/Resources/gstreamer-1.0" scanner = app / "Contents/Helpers/gst-plugin-scanner" if not plugins.is_dir() or not any(plugins.glob("*.dylib")): raise BundleError("required bundled GStreamer plugins are missing") if not scanner.is_file() or not os.access(scanner, os.X_OK): raise BundleError("required bundled GStreamer plugin scanner is missing") - gio_modules = app / "Contents/PlugIns/gio/modules" + gio_modules = app / "Contents/Resources/gio/modules" if not gio_modules.is_dir() or not any(gio_modules.iterdir()): raise BundleError("required bundled GIO TLS modules are missing") for record in records: diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index cda715c0..23ca2eb4 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -87,6 +87,7 @@ def test_curated_plugins_avoid_conflicting_or_gpl_codec_surfaces(self) -> None: "libgstapplemedia.dylib", "libgstosxaudio.dylib", "libgstaudiofx.dylib", + "libgstaom.dylib", ): self.assertIn(required, PLUGINS) for forbidden in ( @@ -97,6 +98,7 @@ def test_curated_plugins_avoid_conflicting_or_gpl_codec_surfaces(self) -> None: "libgstscaletempo.dylib", ): self.assertNotIn(forbidden, PLUGINS) + self.assertNotIn("libgstdav1d.dylib", PLUGINS) def test_workflows_stage_moltenvk_and_clean_gstreamer_runtime(self) -> None: for expected in ( diff --git a/scripts/release/test_macos_bundle.py b/scripts/release/test_macos_bundle.py index 748419b2..6291de54 100644 --- a/scripts/release/test_macos_bundle.py +++ b/scripts/release/test_macos_bundle.py @@ -208,12 +208,55 @@ def test_existing_dependency_must_be_in_declared_search_root(self) -> None: executable = root / "ferrex-player" executable.touch() resolver = macos_bundle.DependencyResolver([declared], executable) - self.assertEqual(resolver.resolve(str(allowed), executable), allowed) + self.assertEqual( + resolver.resolve(str(allowed), executable), + allowed.resolve(), + ) with self.assertRaisesRegex( macos_bundle.BundleError, "outside declared search roots" ): resolver.resolve(str(outside), executable) + def test_bare_dependency_rejects_duplicate_search_root_names(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + first_root = root / "first" + second_root = root / "second" + first_root.mkdir() + second_root.mkdir() + (first_root / "libduplicate.dylib").touch() + (second_root / "libduplicate.dylib").touch() + executable = root / "ferrex-player" + executable.touch() + resolver = macos_bundle.DependencyResolver( + [first_root, second_root], executable + ) + with self.assertRaisesRegex( + macos_bundle.BundleError, "ambiguous dependency" + ): + resolver.resolve("libduplicate.dylib", executable) + + def test_exact_dependency_collisions_get_content_derived_names(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + first = root / "first.dylib" + second = root / "second.dylib" + first.write_bytes(b"first") + second.write_bytes(b"second") + occupied = {"libiconv.2.dylib": first.resolve()} + + name = macos_bundle.dependency_destination_name( + "libiconv.2.dylib", second, occupied + ) + + self.assertRegex(name, r"^libiconv\.2-[0-9a-f]{12}\.dylib$") + self.assertEqual( + name, + macos_bundle.dependency_destination_name( + "libiconv.2.dylib", second, occupied + ), + ) + def test_normalizes_prerelease_bundle_version(self) -> None: self.assertEqual(macos_bundle.apple_bundle_version("0.1.2-alpha.1"), "0.1.2") @@ -281,10 +324,10 @@ def test_rejects_invalid_presenter_mode_before_staging(self) -> None: def test_rejects_forbidden_gstreamer_dependency(self) -> None: with tempfile.TemporaryDirectory() as temporary: app = Path(temporary) / "Ferrex Player.app" - plugins = app / "Contents/PlugIns/gstreamer-1.0" + plugins = app / "Contents/Resources/gstreamer-1.0" resources = app / "Contents/Resources" plugins.mkdir(parents=True) - resources.mkdir(parents=True) + resources.mkdir(parents=True, exist_ok=True) plugin = plugins / "libgstplayback.dylib" plugin.touch() (resources / "gstreamer-plugin-manifest.txt").write_text( @@ -300,6 +343,32 @@ def test_rejects_forbidden_gstreamer_dependency(self) -> None: ): macos_bundle.audit_gstreamer_runtime(app, [record]) + def test_raw_runtime_modules_avoid_reserved_plugins_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + contents = root / "Ferrex Player.app/Contents" + plugin = root / "libgstcoreelements.dylib" + scanner = root / "gst-plugin-scanner" + gio_module = root / "libgiognutls.dylib" + for source in (plugin, scanner, gio_module): + source.write_bytes(b"Mach-O fixture") + + gstreamer = macos_bundle.stage_gstreamer_runtime( + [], [plugin], scanner, contents + ) + gio = macos_bundle.stage_gio_modules([gio_module], contents) + targets = [target for _source, target, _executable, _id in gstreamer + gio] + + self.assertIn( + contents / "Resources/gstreamer-1.0/libgstcoreelements.dylib", + targets, + ) + self.assertIn( + contents / "Resources/gio/modules/libgiognutls.dylib", + targets, + ) + self.assertFalse((contents / "PlugIns").exists()) + def test_system_library_policy_is_narrow(self) -> None: self.assertTrue(macos_bundle.is_system_library("/usr/lib/libSystem.B.dylib")) self.assertTrue( From c71fdab188e1c1253a92821b9d18465310ef6198 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 10:56:49 -0600 Subject: [PATCH 18/56] docs(player): document in-root macOS presentation --- README.md | 2 +- docs/plans/native-mpv-playback-migration.md | 74 +++++--- docs/specs/native-mpv-playback.md | 43 ++++- .../developer/native-playback-fixtures.md | 168 ++++-------------- 4 files changed, 112 insertions(+), 175 deletions(-) diff --git a/README.md b/README.md index 31fe0f04..65c42db6 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ See `ferrexctl --help` for all packaging options. | Linux (Wayland) | GStreamer 1.28 subsurface | In-process native window or external process | HYBRID. GStreamer HDR/zero-copy and mpv `gpu-next`/hwdec have platform evidence; integrated mpv is deferred. | | Linux (X11) | Integrated GStreamer | External process only in the reviewed package | HYBRID. mpv 0.41 X11 VO is excluded from the LGPL-only in-process build. | | Windows | GStreamer rollback | Compile-gated Win32 owned-overlay presenter; native-window/external fallback | Representative-system handoff ready; Auto, HDR, hwdec, taskbar/focus/fullscreen, and stress gates remain open. | -| macOS | GStreamer rollback | Compile-gated AppKit child-window presenter; native-window/external fallback | Representative-system handoff ready; Auto, HDR/EDR, VideoToolbox, Spaces/fullscreen, and stress gates remain open. | +| macOS | GStreamer rollback | Compile-gated AppKit in-root `NSView` presenter; native-window/external fallback | Representative-system handoff ready; Auto, HDR/EDR, VideoToolbox, Spaces/fullscreen, and stress gates remain open. | See [Desktop playback backends](https://ferrexmedia.org/developer/desktop-playback-backends/) for build selection, deterministic fallback order, diagnostics, platform diff --git a/docs/plans/native-mpv-playback-migration.md b/docs/plans/native-mpv-playback-migration.md index 8fcdae27..e602653c 100644 --- a/docs/plans/native-mpv-playback-migration.md +++ b/docs/plans/native-mpv-playback-migration.md @@ -61,7 +61,7 @@ those markers are textual status labels and must not be treated as completed. | P3 | mpv control plane and native-window vertical slice | P1, P2 | In progress | End-to-end playback without render API | | P4 | Native presenter and Iced surface lifecycle | P1, P3 | In progress | Fake presenter and host lifecycle are stable | | P5 | Windows presenter and X11 platform decision | P4 | Handoff ready; hardware gate open | Compile-gated owned-overlay spike plus deterministic fallback | -| P6 | macOS integrated presenter | P4 | Handoff ready; hardware gate open | Compile-gated AppKit child-window spike plus deterministic fallback | +| P6 | macOS integrated presenter | P4 | Handoff ready; hardware gate open | Compile-gated AppKit in-root-view spike plus deterministic fallback | | P7 | Wayland protocol bridge | P3, P4 | Complete (HYBRID) | GStreamer integrated; mpv native-window until a safe bridge exists | | P8 | Playback feature parity | P3, platform presenter | In progress | Current player controls and tracks work through mpv | | P9 | Hardening, performance, and release packaging | P5–P8 | In progress | Platform acceptance matrix passes | @@ -402,7 +402,10 @@ native window before attempting embedding. - [x] Implement ordered stop and termination. - [~] AppKit presenter work is main-thread-token-gated, detaches before shutdown, and hands the blocking `MpvWorker` drain to a named off-main - reaper; a real macOS load/quit/fullscreen stress run remains required. + reaper. Retained-shell restoration and replacement native launch remain + closed behind the reaper's positive completion signal, and a failed teardown + latches native launch closed for the rest of the process; a real macOS + load/quit/fullscreen stress run remains required. The first P3 control-plane tranche lives entirely in `ferrex-player-mpv`. `MpvSession` is a thread-affine serialized owner; `MpvWorker` creates it on a @@ -600,7 +603,7 @@ a decoded-frame callback for player-state updates: native backends wake through the copied-event signal, while the legacy adapter synchronizes only on the bounded controls timer and the existing low-rate progress heartbeat. -### Dedicated playback overlay window +### Dedicated playback controls host - [x] Add a player/overlay `WindowKind` to the existing daemon window manager. - [x] Create transparent overlays hidden before native attachment. @@ -610,14 +613,16 @@ bounded controls timer and the existing low-rate progress heartbeat. - [x] Restore geometry/focus after playback. - [ ] Ensure one visible player/taskbar identity at a time. -The daemon window manager now owns a deterministic -`Closed -> Hidden -> Active -> Closing` player-overlay lifecycle. Allocation is -transparent, undecorated, and invisible. Native attachment/positioning occurs -while hidden; an explicit post-attachment task hides the still-live main -window before a follow-up marks the presenter host visible and focuses it. -That follow-up never reapplies stale main-window geometry. The window manager -also retains a separate live overlay viewport for controls/focus/hit testing, -leaving main geometry untouched for restoration. User close detaches every +The daemon window manager owns a deterministic +`Closed -> Hidden -> Active -> Closing` player-controls lifecycle. Its Iced +staging viewport is transparent, undecorated, permanently unordered, and never +becomes a visible controls `NSWindow` on macOS. Native attachment occurs while +hidden; an explicit post-attachment task hides the still-live main window +before a follow-up reveals and focuses the reparented controls `NSView` inside +mpv's root. That follow-up never focuses the staging window or reapplies stale +main-window geometry. The window manager retains the staging viewport only to +drive the canonical controls/message path, leaving main geometry untouched for +restoration. User close detaches every registered native slot and releases the event-loop-local raw-host lease before queuing native destruction, then restores the retained main geometry, fullscreen mode, and focus. A @@ -744,9 +749,10 @@ HYBRID decision does not change this target. - [x] Confirm mpv 0.41 returns its live `NSWindow` pointer through the read-only `window-id` property; no unsupported macOS `wid` input is used. -- [x] Resolve and retain the mpv `NSWindow` and Iced host `NSView`/`NSWindow` +- [x] Resolve and retain the mpv `NSWindow` and Iced host `NSView`/staging owner only with an AppKit main-thread marker. -- [x] Implement a transparent Iced child `NSWindow` above mpv's content view. +- [x] Reparent the transparent Iced controls `NSView` inside mpv's content + hierarchy without creating an AppKit child window. - [~] Movement, resize, backing-scale, focus, occlusion, close, and app visibility synchronization are implemented and fake-tested; target observation remains open. @@ -756,29 +762,39 @@ HYBRID decision does not change this target. representative Spaces matrix remains open. - [~] Apple Silicon and Intel build/package jobs are defined; representative hardware execution remains open. -- [~] Child-window composition is the selected handoff strategy; retain the +- [~] In-root view composition is the selected spike strategy; retain the native-window fallback until representative testing proves it sufficient. -### Production presenter +### In-root presenter -- [x] Implement the child-window relationship behind the presenter contract. +- [x] Implement the in-root `NSView` relationship behind the presenter + contract, with no child-window or screen-positioning operations. - [x] Keep all AppKit object access behind the non-`Send` main-thread window system. -- [x] Synchronize the root content-view screen rectangle rather than the outer - frame. +- [~] Pin a narrow winit 0.30.13 AppKit compatibility patch that retains the + renderer `WinitView` independently, preserves its logical `WindowId`, follows + the actual host for scale/focus/cursor/IME/metrics, and removes foreign-root + observers on every detach/close edge; native compilation and the transition + matrix remain hardware gates. +- [x] Size the controls view from the root content view's local bounds rather + than an outer-frame or screen-coordinate projection. - [x] Detach the AppKit relationship first, then move blocking libmpv shutdown - to a named reaper so the main run loop remains serviceable. + to a named reaper so the main run loop remains serviceable. A single durable + player-domain arbiter defers replacement launch, retained-shell restoration, + navigation, and queued exit until positive reaper completion; teardown + failure blocks later native launches rather than permitting overlap. - [~] VideoToolbox diagnostics are exposed; representative hardware decoding evidence remains open. -- [~] HDR/EDR overlay-visible/hidden validation remains open on a capable - display, and native HDR capability stays false meanwhile. -- [~] The macOS child-window-specific 100-cycle playback/fullscreen/teardown - run remains open. +- [~] HDR/EDR overlay-visible/hidden validation remains open on capable Apple + Silicon and Intel displays, so the spike does not advertise native HDR. +- [~] A display-free 100-cycle in-root attach/synchronize/fullscreen/detach + contract passes; the Apple Silicon/Intel hardware matrix remains open. ### Exit decision -- [~] Integrated capability is enabled only in an explicit compile-time spike - for representative-system handoff; Auto/production remains closed. +- [~] Integrated capability remains behind the explicit compile-time `spike` + mode. Auto and production packaging remain closed until the full native + matrix passes. - [x] Any preflight or attachment failure selects mpv native-window mode and dismisses the hidden Iced host. - [-] Do not substitute a deprecated OpenGL render path solely to claim @@ -1388,7 +1404,7 @@ cookie, local path, or configuration path enters the summary. | R1 | mpv cannot be safely proxied onto Iced's Wayland connection | Blocks integrated mpv on Wayland | D-022 selects GStreamer integration plus mpv native-window rather than a framework/environment hack; reopen only for a maintainable per-session path | Mitigated by HYBRID; research deferred | | R2 | Proxy misses evolving Wayland protocols used by mpv/driver | Playback/HDR failures by compositor or version | Retain the pinned trace fixture and W1–W5 re-entry matrix; no bridge ships under D-022 | Deferred under HYBRID | | R3 | Transparent overlay breaks HDR, independent flip, or latency | Quality/performance regression | Measure overlay shown/hidden; native-window fallback; per-platform rollout | Open | -| R4 | macOS child window fails fullscreen/Spaces behavior | No integrated macOS controls | P6 spike; retain native-window mode; do not force OpenGL | Open | +| R4 | macOS foreign Iced view fails fullscreen/Spaces/scale behavior | No integrated macOS controls | P6 spike; retain native-window mode; do not force OpenGL | Open | | R5 | libmpv/AppKit/event-loop threading deadlocks | Application hang on load/exit | Serialized owner, callback rules, lifecycle stress, main-loop-aware teardown | Open | | R6 | Native resource teardown races host window destruction | Crashes/leaks | Explicit generations, close ordering, 100-cycle tests | Open | | R7 | Packaging differs from developer environment | Backend absent in releases | P2 packaging workstream and clean-machine smoke tests | Open | @@ -1414,7 +1430,7 @@ cookie, local path, or configuration path enters the summary. | D-007 | Wayland Iced root plus proxied mpv subsurface | Deferred | 2026-07-12 | D-022 selects HYBRID. Retain this architecture only as re-entry criteria if a safe connection bootstrap becomes available. | | D-008 | Wayland-only mpv connection redirection mechanism | Deferred | 2026-07-12 | W0 confirms stable libmpv has no per-context Wayland endpoint and delayed helper connections make temporary environment overrides unsafe. A process-lifetime startup proxy is the only race-free candidate found, but routing Iced too conflicts with the private mpv-only socket requirement. D-022 selects HYBRID until a better path exists. | | D-009 | Retain external mpv as an explicit process-isolated compatibility handoff through the rollback window | Accepted | 2026-07-13 | In-process native-window mpv now has control/track/progress parity for the supported matrix, but the external process still supplies crash isolation and the D-023 X11 handoff without linking GPL-only X11 VO code into Ferrex. D-017 sends its credential-bearing source through private IPC rather than argv; D-019 projects copied state into `PlaybackSnapshot`, and the capability-UX tranche adds the same redacted diagnostic summary. Pure tests cover lifecycle/progress/episode/diagnostic behavior; the ignored real-mpv smoke verifies IPC load, observation, private socket cleanup, and `/proc//cmdline` non-disclosure. Fallback impact: external mpv remains explicit and is never selected by Auto; GStreamer and in-process native-window selection are unchanged. Revisit removal only in P11 after the rollback window and an approved X11 alternative. | -| D-010 | Optional Iced foreign-parent proposal | Deferred | 2026-07-11 | Discuss only after working external implementation | +| D-010 | Optional upstream foreign-AppKit-view proposal | Deferred | 2026-07-24 | Ferrex now carries a pinned, generic winit 0.30.13 compatibility patch for a retained `WinitView` hosted by an external `NSWindow`; it does not add mpv-specific Iced API. An upstream proposal remains deferred until native Apple Silicon/Intel evidence proves the contract and the API can be stated independently of Ferrex. | | D-011 | Keep the neutral contract in `ferrex-player-playback::contract` | Accepted | 2026-07-11 | `dev` already extracted the playback crate; contract/reducer/channel/fallback and adapter tests live there, with no second crate until another client needs it. Evidence: playback and UI unit suites plus workspace all-target check in the initial implementation change | | D-012 | Serialize libmpv through a thread-affine `MpvSession`, with an optional owner-thread `MpvWorker` | Accepted | 2026-07-11 | The wakeup callback performs only atomic coalescing and `Thread::unpark`; native pointers stay on the owner, and event payloads are bounded/copied before the next wait. Fake ABI tests cover nodes, replies, cancellation, hooks, logs, wake storms, and 50 teardown cycles; linked tests cover a real property reply and ordered stop. macOS can use the local owner until its AppKit model is proven. Fallback impact: none when the `mpv` feature is disabled. | | D-013 | Route the existing explicit “Play in MPV” action to in-process native-window libmpv when the `mpv` feature is enabled | Accepted | 2026-07-11 | `mpv_adapter.rs`, `video::open_requested_session`, and a coalesced copied-event readiness subscription provide the vertical slice without frame uploads or periodic event polling. Auto remains Subwave; async mpv load failure resumes through Auto/Subwave, the separate external-process handoff remains available, and backend-disabled builds retain the historical external action. Unit tests cover source/log redaction, property/event/track mapping, close-versus-EOF terminal policy, version/VO/GPU/hwdec/frame diagnostic serialization, and load/seek/EOF stop order; the expanded local real-VO smoke test passes with mpv 0.41.0 and verifies public runtime diagnostics, confirmed fullscreen enter/exit, stop/reload, and native close/quit, while the earlier authenticated HTTP range variant separately proves header/cookie/query-ticket transport. | @@ -1422,14 +1438,14 @@ cookie, local path, or configuration path enters the summary. | D-015 | Acquire Iced native hosts with a `window::run` handshake and event-loop-local lease | Accepted | 2026-07-11 | The pinned Iced API does not expose a window through widget `Shell`. `native_video_slot.rs` therefore requests capture once, returns only a pointer-free result through `Task`, and keeps copied raw handles in a thread-local borrow registry used by generation-scoped presenter callbacks. `Tree::State` revisions geometry only on redraw and detaches on replacement/drop/close. Unit tests cover host capture, clipping/scale revisions, capture deferral, and teardown. Fallback impact: none; no integrated presenter or Auto selection is enabled. | | D-016 | Decouple player snapshot updates from decoded-frame callbacks | Accepted | 2026-07-12 | Desktop and 10-foot views construct the backend presentation widget without `on_new_frame`. Native mpv wakes Iced only through its coalesced copied-event signal; legacy Subwave synchronization reuses the bounded controls timer, and progress persistence keeps its existing ten-second heartbeat. The player/UI feature suite and backend-disabled check pass. Fallback impact: Subwave remains fully available, but its UI position refresh is intentionally bounded instead of frame-rate-driven. | | D-017 | Carry in-process playback tickets in typed source headers | Accepted | 2026-07-13 | `resolve_playback_stream_source`, `PlayerDomainState::current_source`, and `SetStreamSource` keep direct Ferrex stream URIs credential-free and store the playback-scoped token in a redacted, zeroizing `Authorization` header. The formerly string-only streaming/HLS service now returns a typed `StreamingPlaybackSource` with the same constraints and projects it into `PlaybackSource`; embedded query/userinfo credentials and header injection fail closed. Subwave and libmpv pass source headers in process; the legacy external path creates a temporary zeroizing query URL only at its compatibility boundary and sends it through private mpv IPC rather than the child argument vector. Unit tests verify direct and streaming-service header transport, injection rejection, redacted source/state/error diagnostics, fail-closed ticket errors, legacy conversion, and `0700` IPC-directory cleanup. Real native-mpv smokes pass against a bearer-header range server, a real Ferrex router direct stream, and a router-backed HLS manifest whose four segments require the same ticket; the separate external-mpv smoke verifies query-ticket IPC load plus `/proc//cmdline` non-disclosure. Fallback impact: in-process mpv-to-Subwave fallback retains the same authenticated source; only the explicit external compatibility handoff reconstructs a query ticket. | -| D-018 | Allocate the dedicated Iced player overlay hidden and reveal it only after explicit native attachment | Accepted | 2026-07-12 | `WindowKind::PlayerOverlay`, the daemon window controller, player-only root view routing, and the explicit transparent theme implement a generation-independent host window shell without platform objects. Attachment and presenter positioning occur while hidden; an explicit `Activating` state covers the serialized retained-main hide, and `Active` is recorded only after the presenter synchronously reveals the host. Pointer-free transition logs then confirm the delivered overlay-focus event. No stale main resize/move is applied after attachment. A separate live overlay viewport drives controls/focus/hit testing while retained main geometry remains restoration state. Close handling calls `prepare_iced_native_host_close` before `window::close`, detaching all registered slot generations before releasing the raw-host lease; the retained main window is restored after activating/active-overlay teardown. Manager, settings, theme, viewport, and native-slot tests cover hidden/activating/active/closing order and detach-before-release. P5/P6 still own native root relationship, z-order, and taskbar identity validation. Fallback impact: the overlay is dormant until an attachment confirmation explicitly activates it, and presenter fallback can dismiss it without stopping playback; current Subwave, mpv native-window, and external modes are unchanged. | +| D-018 | Allocate a dedicated Iced player host hidden and activate its presented surface only after explicit native attachment | Accepted, amended for macOS | 2026-07-24 | `WindowKind::PlayerOverlay`, the daemon window controller, player-only root view routing, and the explicit transparent theme implement a generation-independent staging shell without platform objects. Attachment occurs while hidden; an explicit `Activating` state covers the serialized retained-main hide, and `Active` is recorded only after the presenter synchronously reveals the native-hosted surface. On macOS the Iced `NSWindow` remains unordered and the presenter makes only its reparented `NSView` first responder in mpv's root; no Iced focus command can activate the staging owner. No stale main resize/move is applied after attachment. Close handling calls `prepare_iced_native_host_close` before `window::close`, detaching all registered slot generations before releasing the raw-host lease; the retained main window is restored after activating/active-host teardown. Manager, settings, theme, viewport, and native-slot tests cover hidden/activating/active/closing order and detach-before-release. P5/P6 still own native root relationship, z-order, and taskbar identity validation. Fallback impact: the host is dormant until an attachment confirmation explicitly activates it, and presenter fallback can dismiss it without stopping playback; current Subwave, mpv native-window, and external modes are unchanged. | | D-019 | Project retained external-mpv process observations into the backend-neutral snapshot | Accepted | 2026-07-12 | `PlayerDomainState::external_mpv_snapshot` is reduced from copied private-IPC observations while `ExternalMpvHandle` owns only process resources. Desktop/10-foot views, progress heartbeat, navigation, and episode start-mode policy now consume the same snapshot/progress projection as in-process backends; only process polling and external seek remain explicit compatibility branches. Final observations are captured before handle drop, and tests prove terminal episode advancement and progress persistence without a surviving handle. Fallback impact: external process mode remains available and D-009 remains pending; Subwave and in-process mpv selection are unchanged. | | D-020 | Keep mpv user config and scripts disabled unless trusted-code policy is explicit | Accepted | 2026-07-12 | `ferrex-player-mpv::MpvConfigPolicy` defaults to Ferrex's deterministic native-window profile. `FERREX_MPV_CONFIG_POLICY=trusted-user` is the developer-only opt-in for standard mpv config, `input.conf`, and scripts; invalid values fail closed and are not logged. Diagnostic schema v4 reports policy and effective high-level switches. Fake session tests verify pre-initialization config/script options and retained external-resolver disablement; playback parser/diagnostic tests and backend-disabled compilation pass. Fallback impact: none—Auto remains Subwave, exact mpv selection is unchanged, and builds without mpv do not read the policy. | | D-021 | Expose local video extensions as capability-gated Ferrex commands | Accepted | 2026-07-12 | `PlaybackCommand`/`PlaybackSession` model external sidecar subtitle loading, named profile application, ordered local shader replacement, and explicit-path screenshots without exposing the mpv owner. `PlaybackFilePath` and `VideoProfileName` redact debug output; adapter validation and copied-log filtering never echo values. mpv uses argument-separated standard commands, while Subwave returns structured `UnsupportedOperation`; user profiles are available only under D-020's trusted policy. Diagnostic schema v5 adds the four support booleans and only an observed shader count. Pure mapping/redaction/policy tests pass; display-backed mpv 0.41 native-VO smokes passed with a real external SRT track, identity shader, and non-empty screenshot on 2026-07-12. Fallback impact: unsupported backends remain selected and report the unavailable operation rather than changing backend or silently doing nothing. | | D-022 | Use a HYBRID Wayland backend until a safe integrated mpv connection path exists | Accepted | 2026-07-12 | W0 traces on SDR, HDR10/PQ, and HLG prove ordinary mpv 0.41 `gpu-next`/Vulkan native VO and identify one shell candidate, but stable libmpv cannot direct a session to the private bridge without a process-global race; the only race-free startup proxy candidate would also proxy Iced and violates the current boundary. Wayland therefore keeps GStreamer/Subwave for integrated presentation and offers mpv in native-window mode. Windows P5 and macOS P6 remain fully integrated native-VO targets. Fallback impact: no Wayland Auto change, no CPU/wgpu mpv frame path, and no change to Windows/macOS rollout gates. | | D-023 | Keep X11 on integrated GStreamer under the LGPL release profile | Accepted | 2026-07-12 | mpv 0.41's Meson graph requires `gpl=true` for the X11 VO and therefore for native-window/overlay/`wid` presentation. The reviewed D-005 profile cannot ship that code. The Flatpak build asserts `gpl=false` and `x11=disabled`, retains Wayland Vulkan/dmabuf/VA-API, and its built/installed bundle resolves the pinned libmpv/FFmpeg/libplacebo closure from `/app`; Nix uses the same mpv license option. Both package builds compile `FERREX_MPV_X11=disabled`; `open_requested_session` preflights an X11-only environment into a structured `UnsupportedPlatform` fallback before creating libmpv, with a pure display/profile matrix test. Re-entry requires compatibly licensed upstream X11 support or an explicit distribution-policy/specification amendment, followed by the retained P5 matrix. Fallback impact: X11 Auto remains integrated GStreamer, in-process mpv is reported unavailable, and the separate external mpv process may remain an explicit handoff; Windows/macOS gates are unchanged. | | D-024 | Use mpv's Win32 HWND as the video root and an owned, taskbar-suppressed Iced HWND as the controls overlay | Accepted for handoff | 2026-07-13 | The compile-time `FERREX_MPV_WINDOWS_PRESENTER=spike` path observes the full pointer-width `window-id`, validates both HWNDs, synchronizes client geometry/DPI/minimize/visibility at an independent native-root cadence, delegates fullscreen to mpv, and restores owner/style state on detach. Display-free presenter tests, pinned LGPL libmpv SDK/import-library tooling, exhaustive provenance for the staged runtime DLL closure, and a reviewed GStreamer PE/GIO/TLS closure with HLS/HTTPS smoke are defined; floating Rust/MSYS2 build tools remain identified in workflow logs rather than covered by that runtime-closure claim. Fallback impact: any preflight/attach failure dismisses the hidden overlay and selects mpv native-window; Auto, native HDR, and production capability remain closed until the Windows hardware/package/100-cycle matrix passes. | -| D-025 | Use mpv's AppKit NSWindow as the video root and an AppKit child Iced NSWindow as the controls overlay | Accepted for handoff | 2026-07-13 | The compile-time `FERREX_MPV_MACOS_PRESENTER=spike` path treats `window-id` only as mpv's read-only NSWindow observation, retains and manipulates AppKit objects behind a non-Send main-thread marker, follows the content layout/active Space/occlusion/scale state, and detaches before handing blocking libmpv termination to an off-main reaper. Display-free presenter tests, pinned core LGPL sources, version/hash-recorded Homebrew inputs, and a strict macOS 15+ bundle closure/HLS audit are defined; the first target artifact run remains open. Fallback impact: any preflight/attach failure dismisses the hidden overlay and selects mpv native-window; Auto, EDR/HDR, VideoToolbox, and production capability remain closed until Apple Silicon/Intel package/fullscreen/Spaces/100-cycle evidence passes. | +| D-025 | Use mpv's AppKit `NSWindow` as the sole visible root and reparent the Iced controls `NSView` into its content hierarchy | Accepted for spike | 2026-07-25 | The compile-time `FERREX_MPV_MACOS_PRESENTER=spike` path treats `window-id` only as mpv's read-only `NSWindow` observation, retains the Iced view and its permanently unordered staging owner behind a non-`Send` main-thread marker, uses root-local content bounds and autoresizing, and never creates, orders, positions, or focuses a controls `NSWindow`. A pinned winit 0.30.13 AppKit patch retains the renderer view directly, preserves donor `WindowId` event routing, follows the actual host for scale/focus/cursor/IME, suppresses donor lifecycle mutations while hosted, and removes foreign-root observers before restoration or close. Detach restores the view before native leases are released, and the player-domain arbiter withholds shell restoration, navigation, and replacement launch until teardown completes. Display-free hierarchy, rollback, and lifecycle tests are present; native mixed-scale input, fullscreen/Spaces, generic third-party window-manager behavior, VideoToolbox, HDR/EDR, and clean-host execution remain hardware gates. Fallback impact: any preflight or attachment failure dismisses the hidden staging host and selects mpv native-window; Auto and production packaging remain closed until the native matrix passes. | When resolving a pending/proposed decision, add the implementation reference, test evidence, and fallback impact to its Notes field. diff --git a/docs/specs/native-mpv-playback.md b/docs/specs/native-mpv-playback.md index 7d702185..ab02f9d0 100644 --- a/docs/specs/native-mpv-playback.md +++ b/docs/specs/native-mpv-playback.md @@ -2,7 +2,7 @@ - **Status:** Accepted; implementation in progress - **Scope:** `ferrex-player` desktop playback -- **Last updated:** 2026-07-12 +- **Last updated:** 2026-07-24 - **Tracking plan:** [Native mpv Playback Migration Plan](../plans/native-mpv-playback-migration.md) ## 1. Purpose @@ -168,7 +168,7 @@ The initial migration does not attempt to: use its native input/OSC behavior. - **Surface slot:** an axis-aligned logical rectangle reserved by Iced for native video. It is not an Iced texture. -- **Host:** the Iced window or native overlay window participating in the +- **Host:** the Iced view/window or native overlay surface participating in the presenter relationship. ## 7. Architectural Invariants @@ -194,6 +194,10 @@ The following invariants apply across all platforms: 9. The native presenter MUST be detached before its host window is destroyed. 10. Backend selection and every fallback transition MUST be logged with a machine-readable reason. +11. A retained shell MUST NOT be restored and a replacement native session + MUST NOT launch until prior native teardown has reported positive + completion. Teardown failure MUST keep later native launches closed for the + remainder of that process. ## 8. Target Component Model @@ -645,19 +649,33 @@ bundle. ### 12.4 macOS -The modern mpv Cocoa/Swift path should own its native NSWindow and video layer. -The presenter obtains the native mpv window when available and attaches a -transparent Iced child window or native overlay view above the content area. -It synchronizes backing scale, content bounds, focus, Spaces/fullscreen -transitions, occlusion, and teardown on the AppKit main thread. +The modern mpv Cocoa/Swift path owns its native `NSWindow` and video layer. The +presenter obtains the native mpv window when available and attaches the +transparent Iced controls `NSView` inside mpv's content hierarchy. The Iced +staging window remains unordered and is never a visible controls overlay. The +presenter synchronizes backing scale, content bounds, focus, +Spaces/fullscreen transitions, occlusion, and teardown on the AppKit main +thread. + +The pinned winit 0.30.13 implementation is not natively reparent-safe: upstream +recovers its renderer view by casting the staging window's current content +view and resolves host-sensitive state through that staging window. Ferrex +therefore carries a narrow AppKit compatibility patch that retains the exact +`WinitView`, preserves its original logical `WindowId`, follows `[view window]` +for metrics/input state, observes an external root without replacing mpv's +delegate, suppresses donor lifecycle/fullscreen mutations while hosted, and +removes those observations before detach or close. This is a generic +foreign-view correction, not an mpv-specific Iced API. Native Apple +Silicon/Intel evidence remains mandatory before the patch is production +qualified or proposed upstream. Ferrex MUST NOT make macOS depend on `wid`. Although generic libmpv header text still mentions macOS, current mpv source does not consume `WinID` in the modern macOS window backend. Native-root composition and ordinary native-window mode are the supported strategies. -Child-window behavior across native fullscreen and Spaces must be proven in the -platform spike. If a transparent Iced child window cannot be made reliable, +In-root view behavior across native fullscreen and Spaces must be proven in the +platform spike. If the foreign-view relationship cannot be made reliable, normal mpv native-window mode remains the release fallback; an OpenGL render path is not promoted merely to emulate embedding. @@ -667,6 +685,13 @@ Integrated mode routes keyboard, pointer, touch, and controller gestures through Iced. Player actions produce `PlaybackCommand`s or raw mpv input commands. Ferrex remains responsible for its current shortcuts and controls. +On macOS native-root playback, a background press is routed through the +canonical player message path to `performWindowDragWithEvent:` on mpv's root +window. Actual control and menu surfaces consume the press first; titlebar and +resize-frame behavior remains AppKit-owned. The hidden staging window MUST NOT +be the target of an Iced window-drag, focus, mode, minimize, maximize, or +fullscreen action. + The integration SHOULD expose a mapping layer for mpv key names so scripts and bindings can be invoked intentionally. It does not need to forward every host input event by default. diff --git a/docs/src/content/docs/developer/native-playback-fixtures.md b/docs/src/content/docs/developer/native-playback-fixtures.md index b22d4b8d..ba616079 100644 --- a/docs/src/content/docs/developer/native-playback-fixtures.md +++ b/docs/src/content/docs/developer/native-playback-fixtures.md @@ -296,13 +296,12 @@ job does not replace the Windows HWND or macOS AppKit presenter-specific ## Windows and macOS integrated-presenter handoff -The Windows and macOS implementations have an explicit `spike` build gate so -they can be exercised on representative systems without changing the Auto -backend policy. In a spike build, **Play in MPV** requests -`mpv-integrated`: mpv owns the native video window and Iced attaches a hidden, -transparent controls window after both native handles are ready. A failed -preflight or attachment records a structured reason and returns to -`mpv-native-window` while GStreamer remains available for rollback. +Windows and macOS retain explicit `spike` build gates so the presenters can be +exercised on representative systems without changing the Auto backend policy. +**Play in MPV** requests `mpv-integrated`: mpv owns the native video window and +Iced attaches its transparent controls surface after both native identities +are ready. A failed preflight or attachment records a structured reason and +returns to `mpv-native-window` while GStreamer remains available for rollback. "Handoff ready" here means the target code, deterministic fallback, source builders, package staging, and display-free tests are present. It does **not** @@ -456,131 +455,26 @@ Do not substitute a locally installed default mpv build for release evidence. ### macOS test build -The canonical, provenance-recorded path is the `macOS App Bundle` workflow, -which builds both Apple Silicon and Intel artifacts. mpv, FFmpeg, libplacebo, -libass, and Lua 5.2 sources are pinned; Homebrew build/runtime inputs are -version/hash recorded and rejected if their expected GStreamer profile drifts. -The workflow rewrites the complete dylib/GIO/trust closure to bundle-relative -paths, signs nested code, and performs strict clean-bundle HTTP/HTTPS HLS -playback. This engineering handoff artifact explicitly requires macOS 15 or -newer. Use the uploaded app—not a raw Cargo binary—for clean-bundle, Dock, -fullscreen, GStreamer rollback, and runtime-path evidence: +The canonical platform test path is the `macOS App Bundle` workflow. Dispatch +the same revision once with `presenter_mode=spike` and once with +`presenter_mode=disabled`, then run the architecture-matched artifacts on Apple +Silicon and Intel macOS 15+ systems. The workflow builds the pinned LGPL mpv +profile, stages the reviewed dylib/GStreamer/GIO/trust closure, signs the app, +and verifies the bundle before upload. Tag builds keep the presenter disabled; +production signing, notarization, and release promotion remain separate +packaging work. -```bash -ref='' -expected_sha="$(git rev-parse "$ref")" -gh workflow run macos-dist.yml --ref "$ref" -f presenter_mode=spike -gh run list --workflow macos-dist.yml --event workflow_dispatch \ - --commit "$expected_sha" --limit 5 -# Select the spike run dispatched above and use arm64 or x86_64 for this Mac. -run='' -arch="$(uname -m)" -actual_sha="$(gh run view "$run" --json headSha --jq .headSha)" -test "$actual_sha" = "$expected_sha" -gh run watch "$run" --exit-status -spike_dir="target/macos-handoff/$run" -test ! -e "$spike_dir" || { - echo "refusing to reuse handoff directory: $spike_dir" >&2 - exit 1 -} -mkdir -p "$spike_dir" -gh run download "$run" --name "ferrex-player-macos-$arch-spike" \ - --dir "$spike_dir" -(cd "$spike_dir" && shasum -a 256 --check ./*.sha256) -archive_count="$(find "$spike_dir" -maxdepth 1 -type f -name '*.zip' | - wc -l | tr -d '[:space:]')" -test "$archive_count" -eq 1 || { - echo "expected exactly one zip in $spike_dir, found $archive_count" >&2 - exit 1 -} -archive="$(find "$spike_dir" -maxdepth 1 -type f -name '*.zip' -print)" -ditto -x -k "$archive" "$spike_dir/app" -registry="$HOME/Library/Caches/io.github.lowband21.FerrexPlayer/gstreamer-registry-1.0.bin" -test ! -e "$registry" || { - echo "clean-user prerequisite failed; archive or remove $registry first" >&2 - exit 1 -} -open "$spike_dir/app/Ferrex Player.app" -``` - -Use a clean macOS user with Homebrew library paths, `DYLD_*`, `GST_*`, and -`VK_*` overrides unset. At least one representative run must use a host or VM -without Homebrew installed, rather than relying only on a clean account on the -build host. The app must obtain libmpv, MoltenVK, the GStreamer -plugins/scanner, GIO TLS module, CA trust database, and their closure from -`Contents` only. An unsigned workflow run is ad-hoc signed for engineering -handoff rather than notarized for public distribution; preserve the verified -archive hash, run ID, `headSha`, signing output, and closure/HLS audit output. - -Complete the spike cases below and quit the app before continuing. Dispatch -`presenter_mode=disabled` at the same `ref` for the fallback control, verify -that run's `headSha`, and download `ferrex-player-macos-$arch-disabled`. -Tag-triggered artifacts force the disabled mode but remain Actions artifacts; -this engineering workflow never attaches macOS artifacts to a GitHub Release. -Public distribution requires a separate Developer ID signing and notarization -workflow. Verify and launch the disabled artifact from its own directory: - -```bash -gh workflow run macos-dist.yml --ref "$ref" -f presenter_mode=disabled -gh run list --workflow macos-dist.yml --event workflow_dispatch \ - --commit "$expected_sha" --limit 5 -# Select the disabled run dispatched above. -disabled_run='' -disabled_sha="$(gh run view "$disabled_run" --json headSha --jq .headSha)" -test "$disabled_sha" = "$expected_sha" -gh run watch "$disabled_run" --exit-status -disabled_dir="target/macos-disabled-handoff/$disabled_run" -test ! -e "$disabled_dir" || { - echo "refusing to reuse handoff directory: $disabled_dir" >&2 - exit 1 -} -mkdir -p "$disabled_dir" -gh run download "$disabled_run" \ - --name "ferrex-player-macos-$arch-disabled" --dir "$disabled_dir" -(cd "$disabled_dir" && shasum -a 256 --check ./*.sha256) -disabled_archive_count="$(find "$disabled_dir" -maxdepth 1 -type f \ - -name '*.zip' | wc -l | tr -d '[:space:]')" -test "$disabled_archive_count" -eq 1 || { - echo "expected exactly one zip in $disabled_dir, found $disabled_archive_count" >&2 - exit 1 -} -disabled_archive="$(find "$disabled_dir" -maxdepth 1 -type f \ - -name '*.zip' -print)" -ditto -x -k "$disabled_archive" "$disabled_dir/app" -test "$(cat "$disabled_dir/app/Ferrex Player.app/Contents/Resources/presenter-build-mode.txt")" = disabled -if test -e "$registry"; then - mkdir -p "$disabled_dir/evidence" - mv "$registry" "$disabled_dir/evidence/registry-after-spike.bin" -fi -test ! -e "$registry" -open "$disabled_dir/app/Ferrex Player.app" -``` - -Run the same fallback and Auto cases against this copy. The commands refuse -stale directories, require one archive, and archive the spike registry before -the disabled launch. A separate clean user is also acceptable; never reuse the -spike bundle or registry evidence. - -For a local development run, install the native build prerequisites listed in -that workflow and choose a new empty prefix: - -```bash -prefix="$PWD/target/ferrex-libmpv-macos" -export MACOSX_DEPLOYMENT_TARGET=15.0 -bash scripts/release/macos-build-libmpv.sh "$prefix" - -export PKG_CONFIG_PATH="$prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" -export LIBRARY_PATH="$prefix/lib${LIBRARY_PATH:+:$LIBRARY_PATH}" -export DYLD_FALLBACK_LIBRARY_PATH="$prefix/lib${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" -export FERREX_MPV_MACOS_PRESENTER=spike -cargo run -p ferrex-player --features mpv -``` +Use a clean macOS account or host without external GStreamer or mpv paths in +the environment. Preserve the workflow revision and archive checksum with the +result, and use the same generic window-manager configuration throughout each +declared run. -The prefix builder refuses to install over a non-empty directory. The -presenter build value is compile-time state, so rebuild after changing it. Normal AppKit object access stays on the Iced/AppKit main thread; blocking -libmpv teardown is handed to the named background reaper only after the child -window has detached. +libmpv teardown is handed to the named background reaper only after the +in-root view has detached. Retained-shell restoration, navigation, and +replacement native launch wait for a positive completion signal from that +reaper. A failed teardown keeps later native launches closed for the rest of +the process instead of allowing a second native owner to overlap the first. ### Representative-system procedure @@ -592,7 +486,7 @@ display with the OS HDR state recorded. For every run: -1. Start Ferrex with the spike compiled in and use **Play in MPV**. Confirm +1. Start Ferrex from the `spike` artifact and use **Play in MPV**. Confirm diagnostics report requested/selected `mpv-integrated`, then `presenter_state=attached`, with no fallback reason. Retain the pointer-free `native player overlay handoff:` debug transition log plus a native-window @@ -634,7 +528,8 @@ For every run: 9. Validate fallback with a build whose presenter gate is `disabled`, and capture the structured transition to `mpv-native-window`. Playback must remain controllable and the hidden Iced overlay must be dismissed. From the - same spike and disabled artifacts, select Auto and actually play the direct + same qualification candidate and disabled control artifacts, select Auto + and actually play the direct Ferrex SDR stream and `h264-sdr-8bit.mkv` through EOF with seek, pause, audio, and stop working. Then select a non-original quality profile so the local Ferrex server generates its protected HLS rendition; wait for the job @@ -670,9 +565,10 @@ a final 30-second quiescence. Do not restart the process between samples. - GPU process memory must return to no more than baseline +64 MiB after final quiescence, with no live decoder, swapchain, or video texture from a stopped generation. -- Native player/overlay window count and ownership/child relationships must - return exactly to baseline after every stop. Any orphan window, second - taskbar/Dock identity, or stale native relationship fails immediately. +- Native player window count and presenter view ancestry must return exactly + to baseline after every stop. Any orphan view/window, child controls window, + second taskbar/Dock identity, or stale native relationship fails + immediately. - The final 20 quiescent samples must not be monotonically non-decreasing with a net increase of at least 1 MiB, one handle/FD, or one native/GPU object. Any limit breach, crash, hang, fallback, or diagnostics from a stale @@ -695,8 +591,8 @@ $p = Get-Process ferrex-player -ErrorAction Stop ``` On macOS, record RSS/VSZ and open descriptors at the same cadence, and use -Activity Monitor plus Instruments/Quartz Debug (or an equivalent reviewed -tool) for GPU and AppKit child-window evidence: +Activity Monitor plus Instruments/Quartz Debug (or equivalent reviewed tools) +for GPU and in-root `NSView` ancestry evidence: ```bash pid="$(pgrep -n ferrex-player)" From c3f78a945c17759b688bcd8976df2cbea7ac044d Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 13:22:28 -0600 Subject: [PATCH 19/56] feat(macos): make native mpv the default backend --- crates/ferrex-player-app/Cargo.toml | 6 + .../src/contract/policy.rs | 67 +++++++++- .../src/macos_presenter.rs | 67 +++++----- .../src/native_presentation.rs | 5 +- crates/ferrex-player-playback/src/video.rs | 120 +++++++++++++++--- .../src/common/messages/cross_domain.rs | 12 +- .../src/domains/ui/playback_ui/update.rs | 20 ++- 7 files changed, 240 insertions(+), 57 deletions(-) diff --git a/crates/ferrex-player-app/Cargo.toml b/crates/ferrex-player-app/Cargo.toml index 18dc5841..6983a065 100644 --- a/crates/ferrex-player-app/Cargo.toml +++ b/crates/ferrex-player-app/Cargo.toml @@ -71,6 +71,12 @@ png = "0.18" iced_test = { git = "https://github.com/Lowband21/iced-ferrex.git", package = "iced_test", default-features = false } tracy-client = { version = "0.18", default-features = false, optional = true } +[target.'cfg(target_os = "macos")'.dependencies] +# In-process mpv is the only supported macOS playback engine. Enabling the UI +# feature here makes it part of every normal macOS player build without +# changing defaults on Linux or Windows. +ferrex-player-ui = { workspace = true, features = ["mpv"] } + [features] default = [] mpv = ["ferrex-player-ui/mpv"] diff --git a/crates/ferrex-player-playback/src/contract/policy.rs b/crates/ferrex-player-playback/src/contract/policy.rs index ccfeccc4..9ee8a01d 100644 --- a/crates/ferrex-player-playback/src/contract/policy.rs +++ b/crates/ferrex-player-playback/src/contract/policy.rs @@ -61,7 +61,7 @@ pub struct FallbackPolicy { } impl FallbackPolicy { - /// Current migration policy: keep GStreamer ahead of every mpv mode. + /// Cross-platform policy used outside macOS during the migration. pub fn migration_default() -> Self { Self { auto_order: vec![ @@ -78,6 +78,31 @@ impl FallbackPolicy { } } + /// macOS golden-path policy: integrated mpv, then mpv's native window. + /// + /// GStreamer is intentionally absent because its macOS path is currently + /// unsupported. External mpv remains an explicit process boundary rather + /// than an automatic fallback. + pub fn macos_default() -> Self { + Self { + auto_order: vec![ + PlaybackTarget::MPV_INTEGRATED, + PlaybackTarget::MPV_NATIVE_WINDOW, + ], + fallback_order: vec![PlaybackTarget::MPV_NATIVE_WINDOW], + allow_explicit_fallback: true, + } + } + + /// Runtime policy for the current target platform. + pub fn platform_default() -> Self { + if cfg!(target_os = "macos") { + Self::macos_default() + } else { + Self::migration_default() + } + } + /// Require the exact target requested by the caller. pub fn strict() -> Self { Self { @@ -218,6 +243,46 @@ mod tests { ); } + #[test] + fn macos_auto_selects_integrated_mpv_without_gstreamer_fallback() { + let policy = FallbackPolicy::macos_default(); + assert_eq!( + policy.auto_order, + vec![ + PlaybackTarget::MPV_INTEGRATED, + PlaybackTarget::MPV_NATIVE_WINDOW, + ] + ); + assert_eq!( + policy.fallback_order, + vec![PlaybackTarget::MPV_NATIVE_WINDOW] + ); + assert!(policy.auto_order.iter().chain(&policy.fallback_order).all( + |target| target.backend != super::super::BackendKind::GStreamer + )); + + let candidates = [ + BackendCandidate::available(PlaybackTarget::MPV_INTEGRATED, true), + BackendCandidate::available( + PlaybackTarget::MPV_NATIVE_WINDOW, + true, + ), + BackendCandidate::available( + PlaybackTarget::GSTREAMER_EMBEDDED, + false, + ), + ]; + let decision = select_backend( + BackendRequest::Auto, + PlaybackRequirements::default(), + &policy, + &candidates, + ) + .unwrap(); + assert_eq!(decision.selected, PlaybackTarget::MPV_INTEGRATED); + assert!(decision.fallback.is_none()); + } + #[test] fn integrated_mpv_failure_falls_back_to_native_window() { let candidates = [ diff --git a/crates/ferrex-player-playback/src/macos_presenter.rs b/crates/ferrex-player-playback/src/macos_presenter.rs index 86c2a03e..6221457a 100644 --- a/crates/ferrex-player-playback/src/macos_presenter.rs +++ b/crates/ferrex-player-playback/src/macos_presenter.rs @@ -20,25 +20,28 @@ use crate::{ }, }; -/// Build-time switch used to compile the developer AppKit presenter path. +/// Build-time override for the AppKit presenter path. pub const MACOS_PRESENTER_BUILD_ENV: &str = "FERREX_MPV_MACOS_PRESENTER"; -/// Release-safe build mode for the native-root presenter. +/// Build mode for the native-root presenter. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum MacOsPresenterBuildMode { - /// Integrated presentation is unavailable and selection falls back. - #[default] + /// Explicit diagnostic mode that forces native-window fallback. Disabled, - /// Explicit developer/hardware-validation path; never selected by Auto. - Spike, + /// Validated Apple Silicon presenter and the normal macOS default. + #[default] + Enabled, } impl MacOsPresenterBuildMode { - /// Parse the build environment. Unknown values fail closed. + /// Parse the build override. `spike` remains an accepted compatibility + /// spelling for existing local and CI invocations. pub fn parse(value: Option<&str>) -> Result { match value.map(str::trim) { - None | Some("") | Some("disabled") => Ok(Self::Disabled), - Some("spike") => Ok(Self::Spike), + None | Some("") | Some("enabled") | Some("spike") => { + Ok(Self::Enabled) + } + Some("disabled") => Ok(Self::Disabled), Some(value) => { Err(MacOsPresenterError::InvalidBuildMode(value.to_owned())) } @@ -47,7 +50,7 @@ impl MacOsPresenterBuildMode { /// Whether this build may attach the AppKit in-root presenter. pub const fn enabled(self) -> bool { - matches!(self, Self::Spike) + matches!(self, Self::Enabled) } /// Mode compiled into a macOS target. @@ -58,7 +61,7 @@ impl MacOsPresenterBuildMode { } } -/// Native relationship under evaluation for macOS integration. +/// Native relationship used for macOS integration. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MacOsPresenterStrategy { /// mpv owns the root `NSWindow`; a transparent Iced view lives within it. @@ -102,7 +105,7 @@ impl MacOsPresenterBlocker { } } -/// Evidence collected by the macOS AppKit spike/integration harness. +/// Evidence collected by the macOS AppKit integration harness. /// /// This deliberately stores no raw `NSWindow`, `NSView`, or `window-id` value; /// diagnostics can report availability without leaking or retaining pointers. @@ -123,9 +126,7 @@ pub struct MacOsPresenterEvidence { } impl MacOsPresenterEvidence { - /// Evidence fixture representing a completed non-HDR integration spike. - /// Production code must populate equivalent facts from the platform gate; - /// this constructor does not itself enable any backend selector. + /// Evidence fixture representing completed non-HDR integration coverage. #[cfg(test)] const fn verified() -> Self { Self { @@ -250,10 +251,10 @@ impl MacOsPresenterDecision { } } -/// Capabilities of the developer AppKit presenter path. +/// Capabilities of the AppKit presenter path. /// -/// The spike remains conservative and does not advertise HDR until the native -/// Apple Silicon and Intel matrix proves real HDR/EDR behavior. +/// The presenter remains conservative and does not advertise HDR until native +/// runtime evidence proves real HDR/EDR behavior. pub fn macos_presenter_capabilities( build_mode: MacOsPresenterBuildMode, ) -> PresenterCapabilities { @@ -2023,7 +2024,7 @@ mod tests { let selected = select_backend( BackendRequest::Exact(PlaybackTarget::MPV_INTEGRATED), PlaybackRequirements::default(), - &FallbackPolicy::migration_default(), + &FallbackPolicy::macos_default(), &candidates, ) .unwrap(); @@ -2076,17 +2077,21 @@ mod tests { } #[test] - fn build_mode_and_mpv_window_id_fail_closed() { + fn build_mode_defaults_enabled_and_window_id_fails_closed() { assert_eq!( MacOsPresenterBuildMode::parse(None).unwrap(), - MacOsPresenterBuildMode::Disabled + MacOsPresenterBuildMode::Enabled ); assert_eq!( MacOsPresenterBuildMode::parse(Some("spike")).unwrap(), - MacOsPresenterBuildMode::Spike + MacOsPresenterBuildMode::Enabled ); assert!(!MacOsPresenterBuildMode::Disabled.enabled()); - assert!(MacOsPresenterBuildMode::Spike.enabled()); + assert!(MacOsPresenterBuildMode::Enabled.enabled()); + assert_eq!( + MacOsPresenterBuildMode::parse(Some("enabled")).unwrap(), + MacOsPresenterBuildMode::Enabled + ); assert!(MacOsPresenterBuildMode::parse(Some("production")).is_err()); assert!(MacOsWindow::from_mpv_window_id(0).is_err()); assert_eq!(MacOsWindow::from_mpv_window_id(42).unwrap().get(), 42); @@ -2113,7 +2118,7 @@ mod tests { Ok(()) }, video, - MacOsPresenterBuildMode::Spike, + MacOsPresenterBuildMode::Enabled, ); let id = identity(1); @@ -2230,7 +2235,7 @@ mod tests { appkit, |_| Ok(()), video, - MacOsPresenterBuildMode::Spike, + MacOsPresenterBuildMode::Enabled, ); let id = identity(11); presenter @@ -2288,7 +2293,7 @@ mod tests { appkit, |_| Ok(()), video, - MacOsPresenterBuildMode::Spike, + MacOsPresenterBuildMode::Enabled, ); let id = identity(12); presenter @@ -2342,7 +2347,7 @@ mod tests { appkit, |_| Ok(()), video, - MacOsPresenterBuildMode::Spike, + MacOsPresenterBuildMode::Enabled, ); let id = identity(21); @@ -2444,7 +2449,7 @@ mod tests { appkit, |_| Ok(()), video, - MacOsPresenterBuildMode::Spike, + MacOsPresenterBuildMode::Enabled, ); let id = identity(2); presenter @@ -2504,7 +2509,7 @@ mod tests { appkit, |_| Ok(()), video, - MacOsPresenterBuildMode::Spike, + MacOsPresenterBuildMode::Enabled, ); let error = presenter @@ -2554,7 +2559,7 @@ mod tests { appkit, |_| Ok(()), video, - MacOsPresenterBuildMode::Spike, + MacOsPresenterBuildMode::Enabled, ); let id = identity(5); presenter @@ -2610,7 +2615,7 @@ mod tests { Ok(()) }, video, - MacOsPresenterBuildMode::Spike, + MacOsPresenterBuildMode::Enabled, ); for cycle in 1..=100 { diff --git a/crates/ferrex-player-playback/src/native_presentation.rs b/crates/ferrex-player-playback/src/native_presentation.rs index a6ee9545..6041a67f 100644 --- a/crates/ferrex-player-playback/src/native_presentation.rs +++ b/crates/ferrex-player-playback/src/native_presentation.rs @@ -453,9 +453,8 @@ impl NativePresentation { )) } - /// Conservative macOS gate. Enabled candidates still require the external - /// representative Spaces/fullscreen/scale/HDR/teardown matrix before their - /// exact archives may be promoted. + /// Create the default macOS in-root presenter. An explicit disabled build + /// override retains the native-window compatibility path for diagnostics. #[cfg(target_os = "macos")] pub(crate) fn try_new( generation: SessionGeneration, diff --git a/crates/ferrex-player-playback/src/video.rs b/crates/ferrex-player-playback/src/video.rs index 95d2dd31..5a131d75 100644 --- a/crates/ferrex-player-playback/src/video.rs +++ b/crates/ferrex-player-playback/src/video.rs @@ -3,20 +3,20 @@ //! This module coordinates the unified video backend, stream URL redaction, //! loading flags, and playback state transitions around media file playback. +#[cfg(feature = "mpv")] +use crate::mpv_adapter::MpvPlaybackAdapter; use crate::{ PlayerMessage, contract::{ BackendKind, BackendRequest, FallbackReason, FallbackReasonCode, - PlaybackCommand, PlaybackContentFit, PlaybackError, PlaybackSource, - SessionGeneration, + PlaybackCommand, PlaybackContentFit, PlaybackError, PlaybackErrorKind, + PlaybackSource, PlaybackTarget, SessionGeneration, }, session::{PlaybackSession, PlaybackShutdownBarrier}, state::PlayerDomainState, subwave_adapter::SubwavePlaybackAdapter, update::{PlaybackUiShell, PlaybackUpdatePort}, }; -#[cfg(feature = "mpv")] -use crate::{contract::PlaybackErrorKind, mpv_adapter::MpvPlaybackAdapter}; use iced::Task; use std::time::Duration; @@ -217,8 +217,8 @@ where let start = Duration::try_from_secs_f64(res_pos).unwrap_or_default(); // Create the selected adapter synchronously and update state immediately. - // Auto deliberately remains Subwave during the staged rollout; only an - // exact mpv request enters the in-process native-window path. + // macOS Auto uses the integrated mpv presenter; other platforms retain + // their existing policy until independently changed. match open_playback_session( &source, start, @@ -286,26 +286,31 @@ where /// /// Callers must keep authentication in [`PlaybackSource`] headers or cookies. /// Exact backend requests still follow Ferrex's deterministic fallback policy, -/// which is reflected by the returned session snapshot and diagnostics. +/// which is reflected by the returned session snapshot and diagnostics. On +/// macOS, Auto selects integrated mpv and GStreamer is unavailable. pub fn open_playback_session( source: &PlaybackSource, start: Duration, generation: SessionGeneration, request: BackendRequest, ) -> Result { - let requested_mpv_target = match request { - BackendRequest::Exact(target) if target.backend == BackendKind::Mpv => { - Some(target) - } - BackendRequest::Auto | BackendRequest::Exact(_) => None, - }; + let requested_mpv_target = + requested_mpv_target(request, cfg!(target_os = "macos")); + if cfg!(target_os = "macos") && requested_mpv_target.is_none() { + return Err(macos_gstreamer_unavailable()); + } let mut fallback = None; #[cfg(feature = "mpv")] if let Some(requested_target) = requested_mpv_target { + let unavailable_target = if cfg!(target_os = "macos") { + "unavailable" + } else { + "gstreamer-auto" + }; if let Some(detail) = packaged_mpv_platform_unavailability() { log::warn!( - "playback_fallback code=unsupported_platform from={} to=gstreamer-auto detail={detail}", + "playback_fallback code=unsupported_platform from={} to={unavailable_target} detail={detail}", playback_target_label(requested_target), ); fallback = Some(( @@ -349,7 +354,7 @@ pub fn open_playback_session( Err(error) => { let code = mpv_initialization_fallback_code(&error); log::warn!( - "playback_fallback code={} from={} to=gstreamer-auto detail={error}", + "playback_fallback code={} from={} to={unavailable_target} detail={error}", fallback_reason_code_label(code), playback_target_label(requested_target), ); @@ -362,13 +367,39 @@ pub fn open_playback_session( #[cfg(not(feature = "mpv"))] if let Some(requested_target) = requested_mpv_target { let detail = "in-process mpv support is disabled in this build"; + let unavailable_target = if cfg!(target_os = "macos") { + "unavailable" + } else { + "gstreamer-auto" + }; log::warn!( - "playback_fallback code=backend_disabled from={} to=gstreamer-auto detail={detail}", + "playback_fallback code=backend_disabled from={} to={unavailable_target} detail={detail}", playback_target_label(requested_target), ); fallback = Some((FallbackReasonCode::BackendDisabled, detail.into())); } + if cfg!(target_os = "macos") { + let (code, detail) = fallback.unwrap_or(( + FallbackReasonCode::RequestedUnavailable, + "the macOS mpv backend is unavailable".to_string(), + )); + let mut error = PlaybackError::new( + match code { + FallbackReasonCode::BackendDisabled + | FallbackReasonCode::RuntimeIncompatible + | FallbackReasonCode::UnsupportedPlatform => { + PlaybackErrorKind::BackendUnavailable + } + _ => PlaybackErrorKind::BackendInitialization, + }, + detail, + ); + error.backend = Some(BackendKind::Mpv); + error.recoverable = true; + return Err(error); + } + let mut adapter = SubwavePlaybackAdapter::open(source, start, generation)?; if let Some((code, detail)) = fallback { adapter.record_fallback(FallbackReason { @@ -381,6 +412,31 @@ pub fn open_playback_session( Ok(PlaybackSession::from_subwave(adapter, request)) } +fn requested_mpv_target( + request: BackendRequest, + macos_default: bool, +) -> Option { + match request { + BackendRequest::Exact(target) if target.backend == BackendKind::Mpv => { + Some(target) + } + BackendRequest::Auto if macos_default => { + Some(PlaybackTarget::MPV_INTEGRATED) + } + BackendRequest::Auto | BackendRequest::Exact(_) => None, + } +} + +fn macos_gstreamer_unavailable() -> PlaybackError { + let mut error = PlaybackError::new( + PlaybackErrorKind::BackendUnavailable, + "GStreamer playback is unavailable on macOS; use the in-process mpv backend", + ); + error.backend = Some(BackendKind::GStreamer); + error.recoverable = false; + error +} + #[cfg(all(feature = "mpv", target_os = "linux"))] fn packaged_mpv_platform_unavailability() -> Option<&'static str> { mpv_linux_platform_unavailability( @@ -504,6 +560,38 @@ pub(crate) fn media_file_metadata_indicates_hdr( || primaries_are_wide } +#[cfg(test)] +mod platform_policy_tests { + use super::{macos_gstreamer_unavailable, requested_mpv_target}; + use crate::contract::{BackendKind, BackendRequest, PlaybackTarget}; + + #[test] + fn macos_auto_uses_integrated_mpv() { + assert_eq!( + requested_mpv_target(BackendRequest::Auto, true), + Some(PlaybackTarget::MPV_INTEGRATED) + ); + assert_eq!(requested_mpv_target(BackendRequest::Auto, false), None); + } + + #[test] + fn macos_gstreamer_unavailability_is_explicit() { + for target in [ + PlaybackTarget::GSTREAMER_INTEGRATED, + PlaybackTarget::GSTREAMER_EMBEDDED, + ] { + assert_eq!( + requested_mpv_target(BackendRequest::Exact(target), true), + None + ); + } + let error = macos_gstreamer_unavailable(); + assert_eq!(error.backend, Some(BackendKind::GStreamer)); + assert!(!error.recoverable); + assert!(error.message.contains("unavailable on macOS")); + } +} + #[cfg(all(test, feature = "mpv", target_os = "linux"))] mod tests { use super::{ diff --git a/crates/ferrex-player-ui/src/common/messages/cross_domain.rs b/crates/ferrex-player-ui/src/common/messages/cross_domain.rs index 85cac9a1..de32a398 100644 --- a/crates/ferrex-player-ui/src/common/messages/cross_domain.rs +++ b/crates/ferrex-player-ui/src/common/messages/cross_domain.rs @@ -310,11 +310,19 @@ pub fn handle_event( // Also prime the player domain for immediate seek during load state.domains.player.state.pending_resume_position = resume_opt; - Task::done(DomainMessage::Player( + let play = Task::done(DomainMessage::Player( crate::domains::player::messages::PlayerMessage::PlayMediaWithId( media_file, media_id, ), - )) + )); + if cfg!(target_os = "macos") { + Task::done(DomainMessage::Ui( + UiShellMessage::OpenPlayerOverlay.into(), + )) + .chain(play) + } else { + play + } } // // Legacy transcoding events (deprecated) diff --git a/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs b/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs index 0784d9a9..dee20492 100644 --- a/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs +++ b/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs @@ -44,11 +44,12 @@ fn play_media_with_position( state.domains.media.state.pending_resume_position = Some(position); state.domains.player.state.pending_resume_position = Some(position); - DomainUpdateResult::task(Task::done(DomainMessage::Player( + let play = Task::done(DomainMessage::Player( crate::domains::player::messages::PlayerMessage::PlayMediaWithId( media_file, media_id, ), - ))) + )); + DomainUpdateResult::task(prepare_macos_player_overlay(play)) } Err(_) => { log::error!("Failed to get media with id {}", media_id); @@ -148,6 +149,17 @@ const fn in_process_mpv_target() -> PlaybackTarget { } } +fn prepare_macos_player_overlay( + play: Task, +) -> Task { + if cfg!(target_os = "macos") { + Task::done(DomainMessage::Ui(UiShellMessage::OpenPlayerOverlay.into())) + .chain(play) + } else { + play + } +} + pub fn update_playback_ui( state: &mut State, message: PlaybackMessage, @@ -186,8 +198,8 @@ pub fn update_playback_ui( play_media_with_position(state, media_id, 0.0) } PlaybackMessage::PlayMediaWithIdInMpv(media_id) => { - // Explicit opt-in to in-process libmpv native-window mode. Auto - // remains Subwave, and initialization failures fall back there. + // Explicit mpv selection on non-macOS platforms. macOS Auto uses + // the same in-process presenter by default. play_media_with_mpv_mode(state, media_id, false) } PlaybackMessage::PlayMediaWithIdExternally(media_id) => { From aa293648ff83a75a36a7a0c4e505356e7ad08d8c Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 13:55:04 -0600 Subject: [PATCH 20/56] fix(macos): route auto playback through shell handoff --- crates/ferrex-player-playback/src/update.rs | 68 +++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/ferrex-player-playback/src/update.rs b/crates/ferrex-player-playback/src/update.rs index 5fa904d8..b0a8b9ea 100644 --- a/crates/ferrex-player-playback/src/update.rs +++ b/crates/ferrex-player-playback/src/update.rs @@ -127,10 +127,20 @@ pub struct PlaybackUpdateContext<'a> { } fn integrated_playback_requested(state: &PlayerDomainState) -> bool { - state.backend_request - == BackendRequest::Exact( - crate::contract::PlaybackTarget::MPV_INTEGRATED, - ) + backend_request_uses_integrated_presenter( + state.backend_request, + cfg!(target_os = "macos"), + ) +} + +const fn backend_request_uses_integrated_presenter( + request: BackendRequest, + macos_auto_is_integrated: bool, +) -> bool { + matches!( + request, + BackendRequest::Exact(crate::contract::PlaybackTarget::MPV_INTEGRATED) + ) || (macos_auto_is_integrated && matches!(request, BackendRequest::Auto)) } /// Move a live external process into its kill-and-wait reaper and latch all @@ -3140,6 +3150,30 @@ mod tests { static RECORDS: Mutex> = Mutex::new(Vec::new()); static RECORDING_TEST: Mutex<()> = Mutex::new(()); + #[test] + fn macos_auto_uses_the_integrated_shell_handoff() { + assert!(backend_request_uses_integrated_presenter( + BackendRequest::Auto, + true, + )); + assert!(!backend_request_uses_integrated_presenter( + BackendRequest::Auto, + false, + )); + assert!(backend_request_uses_integrated_presenter( + BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + ), + false, + )); + assert!(!backend_request_uses_integrated_presenter( + BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + ), + true, + )); + } + fn recording_test_guard() -> std::sync::MutexGuard<'static, ()> { RECORDING_TEST .lock() @@ -3445,6 +3479,32 @@ mod tests { )); } + #[cfg(target_os = "macos")] + #[test] + fn resolved_macos_auto_source_defers_backend_open_to_shell_handoff() { + let _serial = recording_test_guard(); + let mut playback = PlayerDomainState::default(); + let mut ui = TestUi::default(); + let source = PlaybackSource::new( + url::Url::parse("https://ferrex.example/api/v1/stream/media") + .unwrap(), + ); + + let result = reduce_test_message( + &mut playback, + &mut ui, + PlayerMessage::SetStreamSource(source), + ); + + assert_eq!(playback.backend_request, BackendRequest::Auto); + assert!(playback.video_opt.is_none()); + assert!(matches!( + result.events.as_slice(), + [PlaybackWindowEvent::BeginIntegratedPlayback { request }] + if *request == playback.active_playback_request.unwrap() + )); + } + #[test] fn external_media_request_allocates_intent_atomically() { let _serial = recording_test_guard(); From 890685ba04d691afdfff65096469373b7ac19d19 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 13:57:19 -0600 Subject: [PATCH 21/56] fix(macos): serialize overlay host allocation --- .../src/common/messages/cross_domain.rs | 38 ++++++++++++++----- .../src/domains/ui/playback_ui/update.rs | 16 +------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/crates/ferrex-player-ui/src/common/messages/cross_domain.rs b/crates/ferrex-player-ui/src/common/messages/cross_domain.rs index de32a398..e3e7425a 100644 --- a/crates/ferrex-player-ui/src/common/messages/cross_domain.rs +++ b/crates/ferrex-player-ui/src/common/messages/cross_domain.rs @@ -310,19 +310,11 @@ pub fn handle_event( // Also prime the player domain for immediate seek during load state.domains.player.state.pending_resume_position = resume_opt; - let play = Task::done(DomainMessage::Player( + Task::done(DomainMessage::Player( crate::domains::player::messages::PlayerMessage::PlayMediaWithId( media_file, media_id, ), - )); - if cfg!(target_os = "macos") { - Task::done(DomainMessage::Ui( - UiShellMessage::OpenPlayerOverlay.into(), - )) - .chain(play) - } else { - play - } + )) } // // Legacy transcoding events (deprecated) @@ -807,6 +799,32 @@ mod tests { ) } + #[cfg(target_os = "macos")] + #[tokio::test(flavor = "current_thread")] + async fn normal_play_defers_donor_allocation_to_the_shell_handoff() { + let mut state = State::default(); + let (media, media_id) = test_media_file("macos-auto"); + + let routed = output_messages(handle_event( + &mut state, + CrossDomainEvent::MediaPlayWithId(media, media_id), + )) + .await; + + assert!(matches!( + routed.as_slice(), + [DomainMessage::Player( + PlaybackPlayerMessage::PlayMediaWithId(_, routed_id) + )] if *routed_id == media_id + )); + assert!( + state + .windows + .get(crate::domains::ui::windows::WindowKind::PlayerOverlay) + .is_none() + ); + } + #[tokio::test(flavor = "current_thread")] async fn replacement_hint_cannot_relabel_visible_external_checkpoint() { let mut state = State::default(); diff --git a/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs b/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs index dee20492..678cbac6 100644 --- a/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs +++ b/crates/ferrex-player-ui/src/domains/ui/playback_ui/update.rs @@ -44,12 +44,11 @@ fn play_media_with_position( state.domains.media.state.pending_resume_position = Some(position); state.domains.player.state.pending_resume_position = Some(position); - let play = Task::done(DomainMessage::Player( + DomainUpdateResult::task(Task::done(DomainMessage::Player( crate::domains::player::messages::PlayerMessage::PlayMediaWithId( media_file, media_id, ), - )); - DomainUpdateResult::task(prepare_macos_player_overlay(play)) + ))) } Err(_) => { log::error!("Failed to get media with id {}", media_id); @@ -149,17 +148,6 @@ const fn in_process_mpv_target() -> PlaybackTarget { } } -fn prepare_macos_player_overlay( - play: Task, -) -> Task { - if cfg!(target_os = "macos") { - Task::done(DomainMessage::Ui(UiShellMessage::OpenPlayerOverlay.into())) - .chain(play) - } else { - play - } -} - pub fn update_playback_ui( state: &mut State, message: PlaybackMessage, From 450cb107992b8e73d241f34df1e0dc1aab8a464d Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 14:43:12 -0600 Subject: [PATCH 22/56] fix(macos): stop fallback after native-window failure --- crates/ferrex-player-playback/src/update.rs | 104 ++++++++++++++++---- 1 file changed, 86 insertions(+), 18 deletions(-) diff --git a/crates/ferrex-player-playback/src/update.rs b/crates/ferrex-player-playback/src/update.rs index b0a8b9ea..96a36618 100644 --- a/crates/ferrex-player-playback/src/update.rs +++ b/crates/ferrex-player-playback/src/update.rs @@ -8,6 +8,7 @@ use crate::{ contract::{ BackendKind, BackendRequest, DurationDelta, EndReason, PlaybackCommand, PlaybackContentFit, PlaybackSnapshot, PlaybackSource, PlaybackState, + PlaybackTarget, }, diagnostics::redact_playback_url, messages::{PlaybackExitDestination, PlaybackRequestId, PlayerMessage}, @@ -143,6 +144,20 @@ const fn backend_request_uses_integrated_presenter( ) || (macos_auto_is_integrated && matches!(request, BackendRequest::Auto)) } +fn mpv_runtime_failure_fallback( + target: PlaybackTarget, + macos: bool, +) -> Option { + if target.backend != BackendKind::Mpv { + return None; + } + if !macos { + return Some(BackendRequest::Auto); + } + (target == PlaybackTarget::MPV_INTEGRATED) + .then_some(BackendRequest::Exact(PlaybackTarget::MPV_NATIVE_WINDOW)) +} + /// Move a live external process into its kill-and-wait reaper and latch all /// replacement root launches until positive process absence is reported. fn close_external_playback_root( @@ -533,6 +548,30 @@ fn apply_snapshot_to_domain( /// Handle a terminal snapshot exactly once for its generation. `Some` means /// the snapshot was terminal, including terminal states that intentionally /// produce no follow-up message. +fn finish_failed_playback

( + state: &mut PlayerDomainState, + ui: &mut dyn PlaybackUiShell, + snapshot: &PlaybackSnapshot, +) -> Task +where + P: PlaybackUpdatePort + 'static, +{ + let message = snapshot + .last_error + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "Playback backend failed".to_string()); + let progress = final_snapshot_progress(state, snapshot).map_or_else( + Task::none, + |(media_id, position, duration)| { + Task::done(P::send_progress_update(media_id, position, duration)) + }, + ); + let _ = close_video(state); + ui.set_video_error(message); + progress +} + fn handle_synchronized_terminal

( state: &mut PlayerDomainState, ui: &mut dyn PlaybackUiShell, @@ -599,14 +638,29 @@ where PlaybackState::Failed if snapshot.target.backend == BackendKind::Mpv => { + let Some(fallback_request) = mpv_runtime_failure_fallback( + snapshot.target, + cfg!(target_os = "macos"), + ) else { + return Some(finish_failed_playback::

(state, ui, snapshot)); + }; let reason = snapshot .last_error .as_ref() .map(ToString::to_string) .unwrap_or_else(|| "unknown mpv failure".to_string()); - warn!( - "playback_fallback code=backend_failure from=mpv-native-window to=gstreamer-auto detail={reason}" - ); + if matches!( + fallback_request, + BackendRequest::Exact(PlaybackTarget::MPV_NATIVE_WINDOW) + ) { + warn!( + "playback_fallback code=backend_failure from=mpv-integrated to=mpv-native-window detail={reason}" + ); + } else { + warn!( + "playback_fallback code=backend_failure from=mpv to=platform-auto detail={reason}" + ); + } let final_progress = final_snapshot_progress(state, snapshot); let progress = final_progress.map_or_else( Task::none, @@ -623,7 +677,7 @@ where } let request = state.playback_handoff_request(); let shutdown = close_video(state); - state.backend_request = BackendRequest::Auto; + state.backend_request = fallback_request; let fallback = if shutdown.is_some() { message_after_root_shutdown::

( shutdown, @@ -636,20 +690,7 @@ where Some(sequence_tasks([progress, fallback])) } PlaybackState::Failed => { - let message = snapshot - .last_error - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "Playback backend failed".to_string()); - let progress = final_snapshot_progress(state, snapshot) - .map_or_else(Task::none, |(media_id, position, duration)| { - Task::done(P::send_progress_update( - media_id, position, duration, - )) - }); - let _ = close_video(state); - ui.set_video_error(message); - Some(progress) + Some(finish_failed_playback::

(state, ui, snapshot)) } _ => Some(Task::none()), } @@ -3174,6 +3215,33 @@ mod tests { )); } + #[test] + fn macos_mpv_runtime_failure_falls_back_once() { + assert_eq!( + mpv_runtime_failure_fallback( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + true, + ), + Some(BackendRequest::Exact( + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + )) + ); + assert_eq!( + mpv_runtime_failure_fallback( + crate::contract::PlaybackTarget::MPV_NATIVE_WINDOW, + true, + ), + None + ); + assert_eq!( + mpv_runtime_failure_fallback( + crate::contract::PlaybackTarget::MPV_INTEGRATED, + false, + ), + Some(BackendRequest::Auto) + ); + } + fn recording_test_guard() -> std::sync::MutexGuard<'static, ()> { RECORDING_TEST .lock() From 31748ffe972b70d06b916cfa3d8ad3a8c1dcb394 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 14:44:02 -0600 Subject: [PATCH 23/56] ci(macos): validate the default native path --- .github/workflows/ci.yml | 9 +- .github/workflows/macos-dist.yml | 251 ------------------ .github/workflows/release.yml | 8 - .../src/macos_presenter.rs | 5 + .../ferrex-player/src/macos_bundle_runtime.rs | 4 +- dist-workspace.toml | 2 +- scripts/release/macos-build-libmpv.sh | 2 +- scripts/release/macos-gstreamer-plugins.txt | 2 +- scripts/release/macos_bundle.py | 4 +- scripts/release/test_macos_build_profile.py | 49 ++-- scripts/release/test_macos_bundle.py | 4 + 11 files changed, 45 insertions(+), 295 deletions(-) delete mode 100644 .github/workflows/macos-dist.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 134d9eb9..40c691d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,7 +156,6 @@ jobs: echo "PKG_CONFIG_PATH=$prefix/lib/pkgconfig:${PKG_CONFIG_PATH:-}" >> "$GITHUB_ENV" echo "LIBRARY_PATH=$prefix/lib:${LIBRARY_PATH:-}" >> "$GITHUB_ENV" echo "DYLD_FALLBACK_LIBRARY_PATH=$prefix/lib:${DYLD_FALLBACK_LIBRARY_PATH:-}" >> "$GITHUB_ENV" - echo "FERREX_MPV_MACOS_PRESENTER=spike" >> "$GITHUB_ENV" - name: Test macOS bundle policy parser if: runner.os == 'macOS' @@ -364,7 +363,7 @@ jobs: --architecture arm64 \ --version "$version" \ --minimum-macos "$MACOSX_DEPLOYMENT_TARGET" \ - --presenter-mode spike \ + --presenter-mode enabled \ "${search_roots[@]}" \ --resource "$prefix/share/ferrex/native-mpv" \ --extra-library "$molten_vk_prefix/lib/libMoltenVK.dylib" \ @@ -381,18 +380,18 @@ jobs: if: runner.os == 'macOS' run: cargo test -p ferrex-player-playback --features mpv --target ${{ matrix.target }} --lib macos_presenter::tests -- --test-threads=1 - - name: Smoke bundled GStreamer fallback factories (macOS) + - name: Smoke bundled GStreamer runtime closure (macOS) if: runner.os == 'macOS' shell: bash run: | app="${RUNNER_TEMP}/Ferrex Player.app" - plugins="$app/Contents/PlugIns/gstreamer-1.0" + plugins="$app/Contents/Resources/gstreamer-1.0" export GST_PLUGIN_SYSTEM_PATH_1_0="$plugins" export GST_PLUGIN_PATH_1_0="$plugins" export GST_PLUGIN_SCANNER_1_0="$app/Contents/Helpers/gst-plugin-scanner" export GST_PLUGIN_SCANNER="$GST_PLUGIN_SCANNER_1_0" export GST_REGISTRY_1_0="${RUNNER_TEMP}/ferrex-gstreamer-registry.bin" - export GIO_EXTRA_MODULES="$app/Contents/PlugIns/gio/modules" + export GIO_EXTRA_MODULES="$app/Contents/Resources/gio/modules" unset DYLD_LIBRARY_PATH DYLD_FALLBACK_LIBRARY_PATH fixture_dir="${RUNNER_TEMP}/ferrex-gstreamer-hls" mkdir -p "$fixture_dir" diff --git a/.github/workflows/macos-dist.yml b/.github/workflows/macos-dist.yml deleted file mode 100644 index 57ce8e9d..00000000 --- a/.github/workflows/macos-dist.yml +++ /dev/null @@ -1,251 +0,0 @@ -name: macOS App Bundle - -on: - workflow_dispatch: - inputs: - presenter_mode: - description: Native AppKit presenter build mode - required: true - type: choice - default: spike - options: - - spike - - disabled - push: - tags: - - "v*" - -permissions: - contents: read - -jobs: - build-macos-app: - name: Ferrex Player (${{ matrix.architecture }}, ${{ github.event_name == 'workflow_dispatch' && inputs.presenter_mode || 'disabled' }}) - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15 - target: aarch64-apple-darwin - architecture: arm64 - - runner: macos-15-intel - target: x86_64-apple-darwin - architecture: x86_64 - runs-on: ${{ matrix.runner }} - env: - MACOSX_DEPLOYMENT_TARGET: "15.0" - TARGET: ${{ matrix.target }} - MACOS_SIGNING_CERTIFICATE_P12: ${{ secrets.MACOS_SIGNING_CERTIFICATE_P12 }} - MACOS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_SIGNING_CERTIFICATE_PASSWORD }} - MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} - PRESENTER_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.presenter_mode || 'disabled' }} - FERREX_MPV_MACOS_PRESENTER: ${{ github.event_name == 'workflow_dispatch' && inputs.presenter_mode || 'disabled' }} - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Install native build dependencies - run: | - brew update - brew install \ - pkg-config meson ninja nasm \ - freetype fribidi harfbuzz \ - molten-vk shaderc vulkan-headers vulkan-loader \ - ca-certificates glib glib-networking gstreamer libsoup openssl@3 - - - name: Build pinned LGPL libmpv - shell: bash - run: | - prefix="${RUNNER_TEMP}/ferrex-libmpv" - export PKG_CONFIG_PATH="$(brew --prefix vulkan-loader)/lib/pkgconfig:$(brew --prefix shaderc)/lib/pkgconfig:${PKG_CONFIG_PATH:-}" - bash scripts/release/macos-build-libmpv.sh "$prefix" - echo "PKG_CONFIG_PATH=$prefix/lib/pkgconfig:${PKG_CONFIG_PATH:-}" >> "$GITHUB_ENV" - echo "LIBRARY_PATH=$prefix/lib:${LIBRARY_PATH:-}" >> "$GITHUB_ENV" - echo "DYLD_FALLBACK_LIBRARY_PATH=$prefix/lib:${DYLD_FALLBACK_LIBRARY_PATH:-}" >> "$GITHUB_ENV" - - - name: Setup Rust - uses: moonrepo/setup-rust@v1 - with: - profile: minimal - targets: ${{ matrix.target }} - - - name: Check backend-disabled rollback build - run: cargo check -p ferrex-player --no-default-features --target "$TARGET" - - - name: Test display-free bundle policy - run: | - python3 -m unittest \ - scripts/release/test_macos_bundle.py \ - scripts/release/test_macos_build_profile.py - - - name: Build player with in-process mpv - run: cargo build -p ferrex-player --features mpv --release --target "$TARGET" - - - name: Import optional release signing identity - if: env.MACOS_SIGNING_CERTIFICATE_P12 != '' - shell: bash - run: | - keychain="${RUNNER_TEMP}/ferrex-signing.keychain-db" - certificate="${RUNNER_TEMP}/ferrex-signing.p12" - printf '%s' "$MACOS_SIGNING_CERTIFICATE_P12" | base64 -D > "$certificate" - security create-keychain -p temporary "$keychain" - security set-keychain-settings -lut 21600 "$keychain" - security unlock-keychain -p temporary "$keychain" - security import "$certificate" -k "$keychain" -P "$MACOS_SIGNING_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -s -k temporary "$keychain" - security list-keychains -d user -s "$keychain" login.keychain-db - - - name: Stage, rewrite, sign, and audit app bundle - shell: bash - run: | - version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(package["version"] for package in data["packages"] if package["name"] == "ferrex-player"))')" - identity="${MACOS_SIGNING_IDENTITY:--}" - app="${RUNNER_TEMP}/Ferrex Player.app" - prefix="${RUNNER_TEMP}/ferrex-libmpv" - search_roots=(--search-root "$prefix/lib") - while IFS= read -r formula; do - formula_lib="$(brew --prefix "$formula")/lib" - if [[ -d "$formula_lib" ]]; then - search_roots+=(--search-root "$formula_lib") - fi - done <"$prefix/share/ferrex/native-mpv/homebrew-formulae.txt" - gstreamer_prefix="$(brew --prefix gstreamer)" - molten_vk_prefix="$(brew --prefix molten-vk)" - libsoup_prefix="$(brew --prefix libsoup)" - gio_module="" - for candidate in "$(brew --prefix glib-networking)/lib/gio/modules"/libgiognutls.{so,dylib}; do - if [[ -f "$candidate" ]]; then - gio_module="$candidate" - break - fi - done - if [[ -z "$gio_module" ]]; then - echo "glib-networking TLS module was not installed" >&2 - exit 1 - fi - ca_bundle="$(brew --prefix ca-certificates)/share/ca-certificates/cacert.pem" - if [[ ! -f "$ca_bundle" ]]; then - echo "immutable Homebrew Mozilla CA bundle is missing" >&2 - exit 1 - fi - expected_ca_hash="$(sed -n 's/^ca_certificates_bundle_sha256=//p' "$prefix/share/ferrex/native-mpv/build-profile.txt")" - actual_ca_hash="$(shasum -a 256 "$ca_bundle" | awk '{ print $1 }')" - if [[ -z "$expected_ca_hash" || "$actual_ca_hash" != "$expected_ca_hash" ]]; then - echo "immutable Homebrew Mozilla CA bundle does not match the build profile" >&2 - exit 1 - fi - gstreamer_plugin_args=() - while IFS= read -r plugin; do - if [[ -n "$plugin" && "$plugin" != \#* ]]; then - gstreamer_plugin_args+=(--gstreamer-plugin "$gstreamer_prefix/lib/gstreamer-1.0/$plugin") - fi - done "$archive.sha256" - echo "ARCHIVE=$archive" >> "$GITHUB_ENV" - - - name: Smoke bundled GStreamer fallback factories - shell: bash - run: | - app="${RUNNER_TEMP}/Ferrex Player.app" - plugins="$app/Contents/PlugIns/gstreamer-1.0" - export GST_PLUGIN_SYSTEM_PATH_1_0="$plugins" - export GST_PLUGIN_PATH_1_0="$plugins" - export GST_PLUGIN_SCANNER_1_0="$app/Contents/Helpers/gst-plugin-scanner" - export GST_PLUGIN_SCANNER="$GST_PLUGIN_SCANNER_1_0" - export GST_REGISTRY_1_0="${RUNNER_TEMP}/ferrex-gstreamer-registry.bin" - export GIO_EXTRA_MODULES="$app/Contents/PlugIns/gio/modules" - unset DYLD_LIBRARY_PATH DYLD_FALLBACK_LIBRARY_PATH - fixture_dir="${RUNNER_TEMP}/ferrex-gstreamer-hls" - mkdir -p "$fixture_dir" - "$(brew --prefix ffmpeg)/bin/ffmpeg" \ - -hide_banner -loglevel error -y \ - -f lavfi -i testsrc2=size=320x180:rate=24:duration=2 \ - -f lavfi -i sine=frequency=440:sample_rate=48000:duration=2 \ - -c:v libx264 -pix_fmt yuv420p -c:a aac \ - -f hls -hls_time 1 -hls_list_size 0 "$fixture_dir/stream.m3u8" - cc scripts/release/macos_gstreamer_bundle_smoke.c \ - $(pkg-config --cflags gstreamer-1.0 gio-2.0) \ - -L"$app/Contents/Frameworks" \ - -Wl,-rpath,"$app/Contents/Frameworks" \ - $(pkg-config --libs-only-l gstreamer-1.0 gio-2.0) \ - -o "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" - if otool -L "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" | \ - grep -E '/opt/homebrew/|/usr/local/|/nix/store/' >/dev/null; then - echo "clean-bundle smoke helper links a developer/package-manager path" >&2 - exit 1 - fi - python3 -m http.server 18765 --bind 127.0.0.1 --directory "$fixture_dir" & - http_server_pid=$! - openssl="$(brew --prefix openssl@3)/bin/openssl" - "$openssl" req -x509 -newkey rsa:2048 -nodes -days 1 \ - -subj /CN=FerrexTestCA \ - -addext basicConstraints=critical,CA:TRUE \ - -addext keyUsage=critical,keyCertSign,cRLSign \ - -keyout "${RUNNER_TEMP}/ferrex-ca.key" \ - -out "${RUNNER_TEMP}/ferrex-ca.crt" >/dev/null 2>&1 - "$openssl" req -newkey rsa:2048 -nodes \ - -subj /CN=127.0.0.1 \ - -addext subjectAltName=IP:127.0.0.1 \ - -keyout "${RUNNER_TEMP}/ferrex-test.key" \ - -out "${RUNNER_TEMP}/ferrex-test.csr" >/dev/null 2>&1 - "$openssl" x509 -req -days 1 \ - -in "${RUNNER_TEMP}/ferrex-test.csr" \ - -CA "${RUNNER_TEMP}/ferrex-ca.crt" \ - -CAkey "${RUNNER_TEMP}/ferrex-ca.key" \ - -CAcreateserial -copy_extensions copy \ - -out "${RUNNER_TEMP}/ferrex-test.crt" >/dev/null 2>&1 - python3 scripts/release/https_test_server.py \ - --directory "$fixture_dir" \ - --certificate "${RUNNER_TEMP}/ferrex-test.crt" \ - --key "${RUNNER_TEMP}/ferrex-test.key" \ - --port 18766 & - https_server_pid=$! - trap 'kill "$http_server_pid" "$https_server_pid" 2>/dev/null || true' EXIT - curl --fail --retry 20 --retry-connrefused --retry-delay 1 \ - http://127.0.0.1:18765/stream.m3u8 >/dev/null - curl --insecure --fail --retry 20 --retry-connrefused --retry-delay 1 \ - https://127.0.0.1:18766/stream.m3u8 >/dev/null - "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ - http://127.0.0.1:18765/stream.m3u8 \ - "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ - "$app/Contents/Resources/tls/cacert.pem" - if "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ - https://127.0.0.1:18766/stream.m3u8 \ - "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ - "$app/Contents/Resources/tls/cacert.pem"; then - echo "strict HTTPS smoke unexpectedly trusted the unbundled test CA" >&2 - exit 1 - fi - "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ - https://127.0.0.1:18766/stream.m3u8 \ - "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ - "${RUNNER_TEMP}/ferrex-ca.crt" - - - name: Upload app artifact - uses: actions/upload-artifact@v4 - with: - name: ferrex-player-macos-${{ matrix.architecture }}-${{ env.PRESENTER_MODE }} - path: | - ${{ env.ARCHIVE }} - ${{ env.ARCHIVE }}.sha256 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fff94b0..382538ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,8 +17,6 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - - os: macos-latest - target: aarch64-apple-darwin runs-on: ${{ matrix.os }} steps: - name: Checkout @@ -41,12 +39,6 @@ jobs: libswscale-dev \ libswresample-dev - - name: Install dependencies (macOS) - if: runner.os == 'macOS' - run: | - brew update - brew install pkg-config glib gstreamer gst-plugins-base ffmpeg - - name: Setup Rust uses: dtolnay/rust-toolchain@stable diff --git a/crates/ferrex-player-playback/src/macos_presenter.rs b/crates/ferrex-player-playback/src/macos_presenter.rs index 6221457a..7a0f505c 100644 --- a/crates/ferrex-player-playback/src/macos_presenter.rs +++ b/crates/ferrex-player-playback/src/macos_presenter.rs @@ -2078,6 +2078,11 @@ mod tests { #[test] fn build_mode_defaults_enabled_and_window_id_fails_closed() { + #[cfg(target_os = "macos")] + assert_eq!( + MacOsPresenterBuildMode::compiled(), + MacOsPresenterBuildMode::Enabled + ); assert_eq!( MacOsPresenterBuildMode::parse(None).unwrap(), MacOsPresenterBuildMode::Enabled diff --git a/crates/ferrex-player/src/macos_bundle_runtime.rs b/crates/ferrex-player/src/macos_bundle_runtime.rs index 2f73cfd8..5c70b2c5 100644 --- a/crates/ferrex-player/src/macos_bundle_runtime.rs +++ b/crates/ferrex-player/src/macos_bundle_runtime.rs @@ -115,7 +115,7 @@ fn configure_bundle_tls(path: &Path) { let backend = unsafe { g_tls_backend_get_default() }; if database.is_null() || backend.is_null() { eprintln!( - "bundled CA database initialization failed; trusted GStreamer HTTPS is unavailable" + "bundled CA database initialization failed; bundled GStreamer HTTPS runtime is unavailable" ); return; } @@ -140,7 +140,7 @@ fn preload_bundled_libsoup(path: &Path) { let _ = HANDLE.set(handle as usize); } else { eprintln!( - "bundled libsoup preload failed; GStreamer network fallback is unavailable" + "bundled libsoup preload failed; bundled GStreamer network runtime is unavailable" ); } } diff --git a/dist-workspace.toml b/dist-workspace.toml index 0c8fed2d..40cc40bc 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -9,7 +9,7 @@ allow-dirty = ["ci"] # CI backends to support ci = "github" # Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"] +targets = ["x86_64-unknown-linux-gnu"] # The installers to generate for each app installers = ["shell", "homebrew"] # The preferred dist version to use in CI (Cargo.toml SemVer syntax) diff --git a/scripts/release/macos-build-libmpv.sh b/scripts/release/macos-build-libmpv.sh index 4242d9fd..54ec7bb8 100644 --- a/scripts/release/macos-build-libmpv.sh +++ b/scripts/release/macos-build-libmpv.sh @@ -6,7 +6,7 @@ set -euo pipefail : "${MACOSX_DEPLOYMENT_TARGET:=15.0}" export MACOSX_DEPLOYMENT_TARGET if [[ "$MACOSX_DEPLOYMENT_TARGET" != "15.0" ]]; then - echo "Ferrex handoff packages target macOS 15.0, not $MACOSX_DEPLOYMENT_TARGET" >&2 + echo "The validated Ferrex macOS build profile targets macOS 15.0, not $MACOSX_DEPLOYMENT_TARGET" >&2 exit 2 fi diff --git a/scripts/release/macos-gstreamer-plugins.txt b/scripts/release/macos-gstreamer-plugins.txt index a6aa5e6d..c95d4a49 100644 --- a/scripts/release/macos-gstreamer-plugins.txt +++ b/scripts/release/macos-gstreamer-plugins.txt @@ -1,4 +1,4 @@ -# Reviewed macOS GStreamer fallback surface. Homebrew's current monolithic +# Reviewed macOS GStreamer runtime surface. Homebrew's current monolithic # formula also ships GPL/codec plugins and a libav plugin that conflicts with # the pinned FFmpeg SONAME closure; release packaging stages only this set. libgstcoreelements.dylib diff --git a/scripts/release/macos_bundle.py b/scripts/release/macos_bundle.py index 679787d3..5117bf4f 100644 --- a/scripts/release/macos_bundle.py +++ b/scripts/release/macos_bundle.py @@ -27,7 +27,9 @@ APP_RPATH = "@executable_path/../Frameworks" GSTREAMER_PLUGIN_RPATH = "@loader_path/../../Frameworks" GIO_MODULE_RPATH = "@loader_path/../../../Frameworks" -PRESENTER_BUILD_MODES = frozenset({"disabled", "spike"}) +# `spike` remains accepted for older artifacts, but new validation +# records the native presenter by its durable state. +PRESENTER_BUILD_MODES = frozenset({"disabled", "enabled", "spike"}) FORBIDDEN_RUNTIME_PREFIXES = ( "/opt/homebrew/", "/usr/local/", diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 23ca2eb4..957c0a38 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -15,13 +15,25 @@ SMOKE = (ROOT / "scripts/release/macos_gstreamer_bundle_smoke.c").read_text( encoding="utf-8" ) -WORKFLOWS = "\n".join( - (ROOT / path).read_text(encoding="utf-8") - for path in (".github/workflows/ci.yml", ".github/workflows/macos-dist.yml") +WORKFLOW = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") +RELEASE_WORKFLOW = (ROOT / ".github/workflows/release.yml").read_text( + encoding="utf-8" ) +DIST_WORKSPACE = (ROOT / "dist-workspace.toml").read_text(encoding="utf-8") class MacOSBuildProfileTests(unittest.TestCase): + def test_ci_uses_default_apple_silicon_presenter_without_distribution( + self, + ) -> None: + self.assertIn("target: aarch64-apple-darwin", WORKFLOW) + self.assertNotIn("x86_64-apple-darwin", WORKFLOW) + self.assertNotIn("FERREX_MPV_MACOS_PRESENTER", WORKFLOW) + self.assertIn("--presenter-mode enabled", WORKFLOW) + self.assertFalse((ROOT / ".github/workflows/macos-dist.yml").exists()) + self.assertNotIn("apple-darwin", RELEASE_WORKFLOW) + self.assertNotIn("apple-darwin", DIST_WORKSPACE) + def test_sources_and_revisions_are_pinned(self) -> None: for expected in ( 'MPV_VERSION="0.41.0"', @@ -114,11 +126,15 @@ def test_workflows_stage_moltenvk_and_clean_gstreamer_runtime(self) -> None: "gst-plugin-scanner", "GST_REGISTRY_1_0", "GIO_EXTRA_MODULES", + "Contents/Resources/gstreamer-1.0", + "Contents/Resources/gio/modules", "libgiognutls", "https_test_server.py", ): - self.assertIn(expected, WORKFLOWS) - self.assertNotIn("export DYLD_LIBRARY_PATH", WORKFLOWS) + self.assertIn(expected, WORKFLOW) + self.assertNotIn("export DYLD_LIBRARY_PATH", WORKFLOW) + self.assertNotIn("Contents/PlugIns/gstreamer-1.0", WORKFLOW) + self.assertNotIn("Contents/PlugIns/gio/modules", WORKFLOW) for expected in ("avdec_h264", "atdec", "g_tls_file_database_new"): self.assertIn(expected, SMOKE) @@ -126,8 +142,8 @@ def test_ca_bundle_is_immutable_and_provenance_recorded(self) -> None: immutable_path = "share/ca-certificates/cacert.pem" merged_path = "etc/ca-certificates/cert.pem" self.assertIn(immutable_path, BUILD) - self.assertIn(immutable_path, WORKFLOWS) - self.assertNotIn(merged_path, WORKFLOWS) + self.assertIn(immutable_path, WORKFLOW) + self.assertNotIn(merged_path, WORKFLOW) for field in ( "ca_certificates_version", "ca_certificates_source", @@ -136,29 +152,12 @@ def test_ca_bundle_is_immutable_and_provenance_recorded(self) -> None: ): self.assertIn(field, BUILD) - def test_tag_build_is_disabled_and_engineering_artifact_only(self) -> None: - distribution = (ROOT / ".github/workflows/macos-dist.yml").read_text( - encoding="utf-8" - ) - self.assertIn("presenter_mode:", distribution) - self.assertIn("|| 'disabled'", distribution) - self.assertIn("contents: read", distribution) - self.assertIn("actions/upload-artifact", distribution) - self.assertNotIn("ncipollo/release-action", distribution) - self.assertNotIn("Attach app to GitHub release", distribution) - self.assertIn("_${PRESENTER_MODE}.zip", distribution) - self.assertIn('--presenter-mode "$PRESENTER_MODE"', distribution) - self.assertIn( - "presenter-build-mode.txt", - (ROOT / "scripts/release/macos_bundle.py").read_text(encoding="utf-8"), - ) - def test_gstreamer_version_and_allowlist_are_exact(self) -> None: self.assertIn('GSTREAMER_VERSION="1.28.5"', BUILD) self.assertIn("pkg-config --exact-version=", BUILD) self.assertIn("gstreamer_plugins_sha256", BUILD) self.assertIn('MACOSX_DEPLOYMENT_TARGET:=15.0', BUILD) - self.assertIn('MACOSX_DEPLOYMENT_TARGET: "15.0"', WORKFLOWS) + self.assertIn('MACOSX_DEPLOYMENT_TARGET="15.0"', WORKFLOW) if __name__ == "__main__": diff --git a/scripts/release/test_macos_bundle.py b/scripts/release/test_macos_bundle.py index 6291de54..c29b228f 100644 --- a/scripts/release/test_macos_bundle.py +++ b/scripts/release/test_macos_bundle.py @@ -302,6 +302,10 @@ def test_stages_and_audits_presenter_build_mode(self) -> None: app = Path(temporary) / "Ferrex Player.app" resources = app / "Contents/Resources" resources.mkdir(parents=True) + target = macos_bundle.stage_presenter_build_mode("enabled", resources) + self.assertEqual(target.read_text(encoding="utf-8"), "enabled\n") + self.assertEqual(macos_bundle.load_presenter_build_mode(app), "enabled") + target = macos_bundle.stage_presenter_build_mode("spike", resources) self.assertEqual(target.read_text(encoding="utf-8"), "spike\n") self.assertEqual(macos_bundle.load_presenter_build_mode(app), "spike") From 0ae5bdea7827e0df4cd7b4a012b068bb858d0608 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 14:44:19 -0600 Subject: [PATCH 24/56] docs(player): consolidate native playback guidance --- README.md | 33 +- crates/ferrex-player/README.md | 64 +- docs/plans/native-mpv-playback-migration.md | 1500 ----------------- docs/specs/native-mpv-playback.md | 1200 +++---------- .../content/docs/developer/architecture.md | 51 +- .../developer/desktop-playback-backends.md | 218 +-- docs/src/content/docs/developer/index.mdx | 1 + .../developer/native-mpv-extension-api.md | 172 -- .../developer/native-mpv-wayland-spike.md | 196 --- .../developer/native-playback-baseline.md | 147 -- .../developer/native-playback-fixtures.md | 675 -------- .../content/docs/operator/configuration.md | 13 +- docs/src/content/docs/operator/faq.md | 12 +- .../reference/qa/playback-auth-regression.md | 49 +- .../docs/release/flathub-submission.md | 29 - .../winit-0.30.13-ferrex/FERREX-PATCH.md | 94 +- utils/build-windows/README.txt | 11 - 17 files changed, 410 insertions(+), 4055 deletions(-) delete mode 100644 docs/plans/native-mpv-playback-migration.md delete mode 100644 docs/src/content/docs/developer/native-mpv-extension-api.md delete mode 100644 docs/src/content/docs/developer/native-mpv-wayland-spike.md delete mode 100644 docs/src/content/docs/developer/native-playback-baseline.md delete mode 100644 docs/src/content/docs/developer/native-playback-fixtures.md diff --git a/README.md b/README.md index 65c42db6..be2f175a 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,7 @@ A Rust‑native media server and player focused on delivering a smooth and low l - Feels local, because it is: batched rendering of custom UI primitives keeps latency spikes in check as you fling through high‑DPI posters. - Zero‑copy HDR on Wayland: a Wayland‑subsurface path uses current GStreamer stable HDR support to preserve metadata and avoid expensive copies. -- Pragmatic elsewhere: playback runs behind a backend-neutral contract with - GStreamer rollback, an opt-in in-process libmpv native-window path, and an - explicit process-isolated mpv handoff. +- Pragmatic elsewhere: Ferrex uses native mpv presentation where supported. Status: pre-alpha (0.1.0-alpha). Expect rapid changes while core surfaces continue to stabilize. @@ -37,7 +35,7 @@ Existing home media tools are flexible but often not fast in the ways that feel ## Who it’s for -Self‑hosters and performance‑minded enthusiasts who value a fluid desktop experience and want to make use of their hardware efficiently—especially on Wayland, where full HDR zero‑copy playback relies on the GStreamer 1.28 stable series for correct HDR metadata passthrough (tested with **GStreamer 1.28.4**). Windows and macOS have explicit in-process libmpv presenter handoff builds while GStreamer/external-mpv remain the rollback policy; Auto, HDR, and hardware-decoding capability claims stay gated on representative native-output evidence. +Self‑hosters and performance‑minded enthusiasts who value a fluid desktop experience and want to make use of their hardware efficiently—especially on Wayland, where full HDR zero‑copy playback relies on the GStreamer 1.28 stable series for correct HDR metadata passthrough (tested with **GStreamer 1.28.4**). Windows may use an alternate backend; macOS on Apple Silicon uses native in-process mpv presentation. Neither path currently claims HDR passthrough or tone-mapping. ## Highlights @@ -45,8 +43,7 @@ Self‑hosters and performance‑minded enthusiasts who value a fluid desktop ex - Animated poster grids that stream in as fast as your GPU can swallow textures. - Keyboard driven and animated UI navigation/scrolling. - Wayland HDR pipeline with a subsurface strategy tailored for native output. -- In-process libmpv native-window playback and an external mpv handoff, both - with backend-neutral watch status and redacted diagnostics. +- Native mpv playback with watch status tracking maintained. ## Quickstart @@ -135,24 +132,16 @@ See `ferrexctl --help` for all packaging options. - Tested environment: Arch Linux (Hyprland WM). Please report results for GNOME/KDE/wlroots compositors. - Player specifics and platform notes: see [crates/ferrex-player/README.md](crates/ferrex-player/README.md). -- Other platforms: the cross-platform GStreamer path remains the current Auto - policy. An mpv-enabled developer/release build can explicitly request - in-process native-window playback; the separate external action remains a - crash-isolated compatibility handoff. - ### Compatibility -| Platform | Current Auto/integrated path | Explicit mpv path | Evidence-qualified status | -|---|---|---|---| -| Linux (Wayland) | GStreamer 1.28 subsurface | In-process native window or external process | HYBRID. GStreamer HDR/zero-copy and mpv `gpu-next`/hwdec have platform evidence; integrated mpv is deferred. | -| Linux (X11) | Integrated GStreamer | External process only in the reviewed package | HYBRID. mpv 0.41 X11 VO is excluded from the LGPL-only in-process build. | -| Windows | GStreamer rollback | Compile-gated Win32 owned-overlay presenter; native-window/external fallback | Representative-system handoff ready; Auto, HDR, hwdec, taskbar/focus/fullscreen, and stress gates remain open. | -| macOS | GStreamer rollback | Compile-gated AppKit in-root `NSView` presenter; native-window/external fallback | Representative-system handoff ready; Auto, HDR/EDR, VideoToolbox, Spaces/fullscreen, and stress gates remain open. | +| Platform | Playback path | HDR passthrough | Zero‑copy | Status | +|-----------------------|-----------------------------------------|-----------------|-----------|------------------------------| +| Linux (Wayland) | GStreamer + subsurface | Yes (1.28.x) | Yes | Primary, supported | +| Linux (Xorg) | Alt backend / mpv hand‑off | No | No | Works, less ideal | +| Windows | Alt backend / mpv hand‑off | No (today) | No | Experimental | +| macOS (Apple Silicon) | In-process mpv presenter / native-window fallback | Not claimed | Not claimed | Default; core path validated | -See [Desktop playback backends](https://ferrexmedia.org/developer/desktop-playback-backends/) -for build selection, deterministic fallback order, diagnostics, platform -limitations, and rollback. The implementation specification and live rollout -checklist are linked from the [architecture page](https://ferrexmedia.org/developer/architecture/). +Intel/x86_64 Macs are legacy and outside the supported validation matrix. ## Security notes @@ -169,7 +158,7 @@ See [Architecture](https://ferrexmedia.org/developer/architecture/) for the diag ## Configuration -See [Configuration](https://ferrexmedia.org/operator/configuration/) for options and workflows, and [`.env.example`](.env.example) for the authoritative reference of environment variables. +See [Configuration](https://ferrexmedia.org/operator/configuration/) for options and workflows, and [`.env.example`](.env.example) for generated server and stack variables. ## FAQ diff --git a/crates/ferrex-player/README.md b/crates/ferrex-player/README.md index 7e86cd92..f630d77e 100644 --- a/crates/ferrex-player/README.md +++ b/crates/ferrex-player/README.md @@ -139,55 +139,18 @@ Ferrex’s Wayland HDR path relies on the GStreamer 1.28 stable series. The Nix and Flatpak packaging pin **GStreamer 1.28.4**; when building outside those environments, use matching GStreamer and plugin development headers. -## Experimental in-process mpv - -Build with `--features mpv` to enable the developer-only in-process backend. -The explicit **Play in MPV** action requests the compile-gated integrated -presenter on Windows/macOS and the native-window backend elsewhere; failed -presenter preflight/attachment falls back to native-window mode with a -structured reason. Auto continues to use the existing backend during -migration. - -The reviewed macOS handoff bundle targets **macOS 15.0 or newer** on both -Apple Silicon and Intel. Its bundle metadata and every staged Mach-O load -command are audited against that floor; this handoff does not claim support -for older macOS releases. - -Ferrex uses a deterministic mpv profile by default: standard user config, -scripts, and external URL resolvers are disabled. Controlled native OSC/input -bindings are enabled for native-window compatibility and disabled when Iced -owns an integrated controls overlay. For trusted local development only, -standard mpv config, `input.conf`, and scripts can be enabled explicitly: +## In-process mpv -```bash -FERREX_MPV_CONFIG_POLICY=trusted-user \ - cargo run -p ferrex-player --features mpv -``` - -mpv config and scripts execute inside the Ferrex process. Do not enable this -policy for untrusted configuration. Invalid policy values fail closed to the -deterministic profile. Playback diagnostics report the effective policy, -capability-gated external-subtitle/screenshot/shader/profile support, and only -the active shader count—never config contents, profile names, or local paths. Extension authors -should follow the -[native mpv extension API](https://ferrexmedia.org/developer/native-mpv-extension-api/) -for owner-thread, local-extension, raw-command, observation, and redaction -rules. - -Native messages use a bounded verbose-at-startup then informational policy by -default. Diagnostic runs may select a fixed filter without changing playback -behavior: +Normal macOS builds enable in-process mpv automatically. On Apple Silicon, +`Auto` uses the functionally validated in-root AppKit presenter and may fall +back only to mpv's native window; GStreamer playback is unavailable. Intel +Macs are outside the supported validation matrix. -```bash -FERREX_MPV_LOG_LEVEL=trace \ - RUST_LOG=ferrex_player_playback=trace,ferrex_player_mpv=trace \ - cargo run -p ferrex-player --features mpv -``` - -Accepted levels are `none`, `fatal`, `error`, `warn`, `info`, `verbose`, -`debug`, and `trace`. Invalid values fail closed without being echoed. Copied -messages remain credential/source-redacted, but traces can still reveal local -filenames or system topology and must be reviewed before sharing. +Other platforms enable the backend with `--features mpv`. Ferrex defaults to a +deterministic mpv profile with user config, scripts, and external URL resolvers +disabled. See [Desktop playback backends](https://ferrexmedia.org/developer/desktop-playback-backends/) +for platform behavior, current limitations, trusted configuration, and safe +diagnostics. ## Windows MPV override @@ -202,10 +165,3 @@ When distributed as a Flatpak bundle: flatpak install --user ./ferrex-player*.flatpak flatpak run io.github.lowband21.FerrexPlayer ``` - -The manifest enables the in-process mpv feature and bundles pinned mpv 0.41.0, -FFmpeg 8.1.2, and libplacebo with a build-time-asserted LGPL-only profile. -Wayland native-window mpv includes Vulkan, dmabuf, VA-API, PipeWire, and Pulse -support. mpv 0.41's X11 VO is GPL-only and is deliberately excluded from this -profile; Flatpak X11 sessions retain integrated GStreamer playback and the -separate external-player compatibility action. diff --git a/docs/plans/native-mpv-playback-migration.md b/docs/plans/native-mpv-playback-migration.md deleted file mode 100644 index e602653c..00000000 --- a/docs/plans/native-mpv-playback-migration.md +++ /dev/null @@ -1,1500 +0,0 @@ -# Native mpv Playback Migration Plan - -- **Status:** In progress -- **Branch:** `feat/mpv-integration` -- **Worktree:** `~/dev/ferrex/mpv` -- **Specification:** [Native mpv Playback Integration](../specs/native-mpv-playback.md) -- **Last updated:** 2026-07-24 - -## 1. How to Use This Plan - -This document tracks implementation and migration. The specification is the -source of truth for architecture and acceptance criteria; this plan owns task -order, status, dependencies, decisions, and rollout checkpoints. - -Update this file in the same change that: - -- completes or materially changes a milestone; -- resolves an open design spike; -- changes fallback or rollout policy; -- adds a newly discovered blocker or risk; or -- changes a platform's default backend. - -### Status legend - -- `[ ]` Not started -- `[~]` In progress (use only one owner/status note beneath the item) -- `[x]` Complete and verified -- `[!]` Blocked; link or describe the blocker -- `[-]` Deliberately deferred or rejected with rationale - -Checklist syntax cannot encode `[~]` or `[!]` as interactive Markdown boxes, so -those markers are textual status labels and must not be treated as completed. - -## 2. Delivery Rules - -1. Every phase must leave the player buildable and keep a working playback - fallback. -2. Refactoring the player contract must land before mpv-specific branches - spread through domain/view code. -3. The current default remains unchanged until the relevant platform gate - passes. -4. Native-window mpv is implemented before integrated presentation; it is the - compatibility and debugging baseline. -5. Release packaging is part of backend completion, not a follow-up. -6. Wayland is a gated feasibility project. Failure results in a documented - hybrid backend, not unsafe framework coupling or silent HDR regression. -7. No mpv-specific Iced change is proposed upstream. -8. Each platform presenter must have a deterministic fallback and teardown - test before it can be selected by Auto. -9. No decoded video frame may enter wgpu in the native-VO milestone. -10. Any private mpv patch requires an explicit specification amendment and a - maintenance/exit strategy before adoption. - -## 3. Milestone Overview - -| ID | Milestone | Depends on | Status | Exit result | -|---|---|---|---|---| -| P0 | Baseline, fixtures, and design records | — | In progress | Reproducible current behavior and test matrix | -| P1 | Backend-neutral player contract | P0 | In progress | Subwave runs through Ferrex-owned commands/events | -| P2 | libmpv FFI and packaging foundation | P0 | In progress | Versioned libmpv loads/builds on target CI | -| P3 | mpv control plane and native-window vertical slice | P1, P2 | In progress | End-to-end playback without render API | -| P4 | Native presenter and Iced surface lifecycle | P1, P3 | In progress | Fake presenter and host lifecycle are stable | -| P5 | Windows presenter and X11 platform decision | P4 | Handoff ready; hardware gate open | Compile-gated owned-overlay spike plus deterministic fallback | -| P6 | macOS integrated presenter | P4 | Handoff ready; hardware gate open | Compile-gated AppKit in-root-view spike plus deterministic fallback | -| P7 | Wayland protocol bridge | P3, P4 | Complete (HYBRID) | GStreamer integrated; mpv native-window until a safe bridge exists | -| P8 | Playback feature parity | P3, platform presenter | In progress | Current player controls and tracks work through mpv | -| P9 | Hardening, performance, and release packaging | P5–P8 | In progress | Platform acceptance matrix passes | -| P10 | Staged default rollout | P9 | Not started | mpv selected by Auto per approved platform | -| P11 | Legacy cleanup and optional upstream work | P10 | Not started | Obsolete playback code removed after rollback window | - -P2 packaging work and P5/P6 platform work may proceed in parallel after their -listed interfaces are stable. The recorded Wayland HYBRID decision does not -reduce the Windows or macOS scope: both remain active targets for fully -integrated native-VO mpv presentation inside the Ferrex player experience. -D-023 separately records X11 as HYBRID because mpv 0.41 excludes its X11 VO -from the LGPL-only build required by D-005. - -## 4. P0 — Baseline, Fixtures, and Design Records - -**Objective:** make regressions measurable before changing the player model. - -### Documentation - -- [x] Create the target architecture specification. -- [x] Create this migration tracking plan. -- [x] Link the specification, plan, and baseline from the canonical Starlight - architecture page when implementation begins. -- [x] Record the exact current Iced fork revisions, Subwave revision, GStreamer - version, and release packaging inputs in - [`native-playback-baseline.md`](../src/content/docs/developer/native-playback-baseline.md). - -### Current behavior inventory - -- [x] Inventory every direct use of `SubwaveVideo` in the extracted playback - and UI crates. -- [x] Inventory every branch on `external_mpv_active` and - `external_mpv_handle`. -- [x] Map each `PlayerMessage` to current backend calls and resulting state - changes. -- [x] Record current behavior for stop, EOF, error, next episode, previous - episode, and navigation while playing. -- [x] Record current progress heartbeat and final progress persistence behavior. -- [x] Record current track identity/index semantics for audio and subtitles. -- [x] Record current content-fit behavior for contain, cover, and fill. -- [x] Record current fullscreen ownership and window restoration behavior. - -The inventory above is maintained in -[`native-playback-baseline.md`](../src/content/docs/developer/native-playback-baseline.md) -and is anchored to the pre-contract `dev` commit recorded there. - -### Test media and environments - -- [x] Define a redistributable or locally generated media fixture set covering: - - [x] H.264 SDR 8-bit; - - [x] HEVC Main10 SDR; - - [x] HDR10/PQ metadata; - - [x] HLG; - - [x] VP9 and AV1; - - [x] ASS with fonts and animation; - - [x] PGS bitmap subtitles; - - [x] multiple audio/subtitle tracks; - - [x] chapters and attachments; and - - [x] malformed/unsupported input. -- [x] Document commands to generate synthetic fixtures when redistribution is - not permitted. -- [x] Define authenticated HTTP range and transcoded-stream fixtures against a - local Ferrex server. -- [x] Create the initial platform/GPU/compositor test inventory. - -The schema-versioned generator and validator are -[`scripts/qa/native_playback_fixtures.py`](../../scripts/qa/native_playback_fixtures.py); -all generated media and checksummed manifests live under the ignored -`target/native-playback-fixtures/` directory. It creates nine primary files, -including locally constructed PGS packets, plus malformed inputs and segmented -HLS output. The loopback-only -[`native_playback_fixture_server.py`](../../scripts/qa/native_playback_fixture_server.py) -reads its token outside argv and supports bearer/query authentication and real -single-range `206` responses. Generation and validation passed with FFmpeg 8.1 -on 2026-07-12; Linux CI now regenerates and verifies the same matrix. A -transport smoke separately verified `401`, bounded `206`, and -header-authenticated HLS segment reads without retained-token disclosure. On -2026-07-13 a second display-backed acceptance passed through a real -network-bound Ferrex router: it rewrote only the generated HLS fixture's local -segment references to credential-free protected stream routes, required the -same playback-scoped bearer ticket for the manifest and all four MPEG-TS -segments, verified HLS MIME types and unauthenticated rejection, and completed -the native-mpv control/screenshot lifecycle. This proves server/router HLS -transport and header propagation, independently of Ferrex-side transcode -generation. A third display-backed run on 2026-07-13 submitted the real `360p` -profile, waited for bounded FFmpeg generation and atomic cache publication, -verified ticket enforcement on the generated manifest and every segment, -confirmed cached reuse, and completed the same native-mpv -control/shader/screenshot/stop lifecycle. -[The fixture procedure and initial platform/Wayland matrix](../src/content/docs/developer/native-playback-fixtures.md) -also define local Ferrex import/direct/transcode acceptance. The manual player -quality-picker run and the UI episode run remain P8 gates. - -### Baseline measurements - -- [ ] Capture current startup-to-first-frame time. -- [ ] Capture CPU/GPU usage for SDR and HDR reference playback. -- [ ] Capture seek latency and resize/fullscreen behavior. -- [ ] Verify and record current hardware-decoder selection. -- [ ] Capture Wayland protocol traces for the known-good GStreamer HDR path. -- [ ] Run repeated load/stop cycles and record native/GPU memory behavior. - -### Exit criteria - -- [ ] Current player behavior is represented by a written mapping and tests - where practical. -- [ ] Test fixtures and manual HDR procedure are reproducible. -- [ ] Baseline measurements are stored under an appropriate ignored or - documented results location. - -## 5. P1 — Backend-neutral Player Contract - -**Objective:** remove backend objects from domain/view behavior before adding a -second in-process backend. - -### Contract design - -- [x] Keep the initial Ferrex-owned contract in the already extracted - `crates/ferrex-player-playback/src/contract/` boundary; split it again only - when another client needs a dependency-lighter crate. -- [x] Define `PlaybackCommand`. -- [x] Define `PlaybackEvent`. -- [x] Define `PlaybackSnapshot` and `PlaybackState`. -- [x] Define Ferrex-owned `TrackId`, `AudioTrack`, `SubtitleTrack`, chapter, and - video-parameter models. -- [x] Define `PlaybackCapabilities`, `BackendKind`, and presentation capability - models. -- [x] Define structured `PlaybackError` and `FallbackReason` types. -- [x] Define source/authentication data without embedding access tokens in log - output. -- [x] Define controller/event channel ownership and shutdown semantics. - -### Subwave adapter - -- [x] Wrap `SubwaveVideo` behind the new command/event contract. -- [x] Convert Subwave audio/subtitle models into Ferrex-owned models. -- [x] Move direct `SubwaveVideo` polling out of player `view` code. -- [x] Reduce adapter events into one `PlaybackSnapshot`. -- [x] Preserve current progress, seek timeout, controls, and track behavior. -- [x] Preserve current backend toggle only as a Subwave diagnostic during the - migration; do not add it to the generic contract unless it represents a real - user capability. - -### Player domain migration - -- [x] Replace the concrete value in `PlayerDomainState::video_opt` with a - backend-neutral `PlaybackSession` handle and reduced snapshot (the temporary - field name is retained for compatibility during the migration). -- [x] Stop storing Subwave track types in `PlayerDomainState`. -- [x] Move concrete position/duration polling into adapter snapshot event - reduction; legacy `last_valid_*` mirrors remain only for seek-preview and - persistence compatibility until P11 cleanup. -- [x] Consolidate internal and external playback branches where behavior is - backend-independent. -- [x] Keep `external_mpv.rs` operational through an adapter or explicit legacy - path. -- [x] Ensure `view.rs` reads only snapshot and presenter state. -- [x] Ensure player commands do not unwrap `current_media_id` on paths where it - may legitimately be absent. - -The external process remains an explicit legacy path rather than a generic -backend session. It now starts mpv idle, observes state over its private IPC -socket, and submits the media URL with `loadfile` over IPC so playback tickets -do not appear in the child argument vector. Its Unix socket lives under an -RAII-owned `0700` temporary directory. On 2026-07-12 the ignored Linux real-mpv -smoke passed against a generated Matroska file behind a local query-ticket HTTP -range server, verifying IPC load, observed media state, and -`/proc//cmdline` non-disclosure while preserving the existing -process-liveness fallback. The bounded legacy snapshot synchronization now -samples Subwave's owned EOS flag inside the adapter and reduces it to -`Ended(Eof)` instead of allowing the backend's terminal pause to overwrite EOF. -The same generation-scoped terminal handler is used by legacy ticks, the -progress heartbeat, and event-driven mpv wakeups, so EOF/error dispatch and -final progress happen once. Event-driven snapshots now clear seeking only from -mpv's confirmed state, while Subwave retains its one-second timeout; snapshot -projection also preserves pending resume hints and the last available subtitle -selection. Pure regression tests cover EOS-vs-pause ordering, confirmed seek -completion, one-shot terminal dispatch/error progress, resume projection, and -subtitle restoration. The retained external process now has one reduced -`PlaybackSnapshot`; its native handle owns only process/IPC resources. Desktop -and 10-foot views select presentation through that snapshot and obtain the -backend-owned widget through a state presentation boundary, while progress -heartbeats, episode mode, and navigation use one neutral progress projection. -The obsolete `external_mpv_active` flag and all direct view reads of the process -handle/session field are removed. Final IPC values are reduced before the -process handle is dropped, fixing external episode advancement and avoiding the -old duplicate terminal progress/navigation dispatch. State/update regression -tests cover external loading/playing/terminal reduction, invalid observations, -reset cleanup, heartbeat persistence, and terminal episode transition after -handle teardown. - -### Tests - -- [x] Command-to-Subwave adapter tests. -- [x] Snapshot reducer tests for duplicate, out-of-order, and missing values. -- [x] Track identity/selection tests across reload. -- [x] Stop/EOF/error progress persistence tests. -- [x] Episode transition tests independent of backend. -- [x] Fallback-policy unit tests. - -### Exit criteria - -- [ ] Existing GStreamer playback works with no intended UI behavior change. -- [x] `SubwaveVideo` is not referenced by player view or domain policy code. -- [x] Backend-specific track types do not escape the adapter. -- [x] All new contract/reducer tests pass. - -## 6. P2 — libmpv FFI and Packaging Foundation - -**Objective:** select and ship a libmpv foundation capable of exposing the full -control API on every desktop target. - -### FFI decision spike - -Evaluate at least: - -- a thin Ferrex wrapper over maintained raw `libmpv` bindings; -- `libmpv2` plus direct raw access for missing APIs; and -- generated/local bindings with dynamic symbol loading. - -For each option record: - -- [x] supported client API and mpv versions; -- [x] command/property/node/event coverage; -- [x] wakeup callback and async reply support; -- [x] raw escape-hatch feasibility; -- [x] Windows and macOS linking behavior; -- [x] maintenance activity and licensing; -- [x] cross-compilation behavior; and -- [x] ability to test with a fake function table. - -Spike result (crate releases and repository activity checked 2026-07-11): - -| Option | Coverage and version | Build/test characteristics | Decision | -|---|---|---|---| -| `libmpv2` 6.0.0 plus raw access | Maintained; high-level crate declares API 2.2 while `libmpv2-sys` 4.0.1 ships current API 2.5 declarations. High-level events omit node payloads and it exposes no public async command/property, node-command, hook, or log-request methods. | LGPL-2.1; linked through `libmpv2-sys`; public raw context permits escape hatches but couples two ownership layers and is not naturally fakeable. | Rejected as the ownership layer; useful only as prior art. | -| Thin Ferrex wrapper over `libmpv2-sys` 4.0.1 | Complete API 2.5 client/node/event/wakeup/async declarations from mpv 0.40+ headers, including APIs required by P3. | Maintained, LGPL-2.1, pregenerated bindings avoid target bindgen/Clang, and the build script links `mpv`. Ferrex's own function table makes it fakeable and keeps unsafe ownership local. | **Selected.** | -| Generated/local bindings with dynamic symbol loading | Can provide complete coverage and graceful runtime absence. | Adds header/generated-code drift, a library-lifetime loader, per-platform search policy, and a second binding-maintenance surface before packaging is proven. | Deferred; reconsider only if linked development/release layouts cannot meet diagnostics or rollback requirements. | - -`mpv-client-dyn` 0.5.0 was also rejected: it is GPL-3.0, hard-codes -`mpv.exe`, and omits required version, terminate, wakeup, node/async-property, -and log APIs. `mpv-client-cross-sys` 4.0.0 is current but GPL-3.0 and its -dynamic-symbol path is designed for C plugins hosted by mpv, not an embedding -application. - -Decision: - -- [x] Write `D-004` in the decision log with the selected FFI foundation. -- [x] Set the minimum supported client API version to 2.2 (mpv 0.37.0), - which contains every P3 client symbol; release packaging targets mpv 0.41.0 - / API 2.5. -- [x] Decide release bundling versus development dynamic loading: use normal - shared-library linking, keep it behind the `linked` Cargo feature, use an - explicitly LGPL-only mpv/FFmpeg build, and bundle/reference that exact shared - library in release artifacts. Do not add a bespoke runtime `dlopen` layer - unless packaging evidence requires it. - -### Build integration - -- [x] Add the selected Rust dependencies with minimal features in the isolated - `ferrex-player-mpv` crate; its default feature set does not link libmpv. -- [x] Add compile-time platform gating without compiling Wayland dependencies on - Windows/macOS (`linked` enables only the raw client bindings). -- [x] Add libmpv version detection and actionable build errors: Unix builds - require `mpv >= 2.2.0` through pkg-config, Windows names the required - `LIBMPV_LIB_DIR`, and runtime client API compatibility is checked before - allocation. -- [x] Add a runtime compatibility report. -- [x] Ensure the player can still build in a configuration where mpv is - deliberately disabled during the migration. - -### Packaging workstream - -- [x] Nix development shell provides mpv 0.41.0 headers and runtime library - built with `gpl=false`, LGPL-only FFmpeg, and GPL-only optional inputs - disabled. -- [x] Nix package references the selected LGPL libmpv closure and includes its - library directory in the wrapped runtime path; package and license-profile - checks pass. -- [x] Linux CI installs `libmpv-dev` at the API 2.2 compatibility floor and - runs the linked handle smoke test; Nix/release packaging remains pinned to - mpv 0.41.0. -- [x] Flatpak manifest builds/bundles libmpv with required VO/protocol features. -- [~] Windows CI/source builder, MSVC import-library generation, hashed DLL - closure staging, and package audit are implemented; the first target CI and - clean-VM artifact run remain representative-system gates. -- [~] macOS CI/source builder and complete dylib closure rewrite/audit are - implemented for Apple Silicon and Intel; the first target CI and clean-app - launch remain representative-system gates. -- [~] Windows/macOS package audits reject loader dependencies and runtime search - paths into developer Nix/Homebrew prefixes; execute them against the produced - target artifacts before closing this item. These checks do not claim to scan - arbitrary resource strings for unrelated build-host paths. -- [~] The target builders emit exact mpv, FFmpeg, libplacebo, libass, and - target Lua runtime profiles/notices/hashes (LuaJIT on Windows/Flatpak, static - Lua 5.2.4 on macOS); final license review remains open until the target - artifacts are produced. - -The Flatpak manifest now builds the player with its `mpv` feature and pins mpv -0.41.0/API 2.5, FFmpeg 8.1.2, libplacebo 7.360.1, libass, and LuaJIT. Configure -and post-install assertions require `gpl=false`, reject FFmpeg GPL/nonfree/ -version-3 options, require Vulkan/Wayland/dmabuf support, verify the final Rust -binary directly needs `libmpv.so.2`, and install the component license/build -profile files. `gst-libav` is built against the same bundled FFmpeg ABI so the -process does not load both the runtime and bundled FFmpeg versions. On -2026-07-12 a clean Flatpak builder run, 50 MiB bundle export, user installation, -and installed-runtime loader smoke passed; libmpv, FFmpeg, libplacebo, libass, -and LuaJIT all resolved from `/app/lib` without a Nix/store or host package -path. The Flatpak workflow now installs every produced bundle and repeats the -closure/profile assertions before upload. mpv 0.41 gates X11 VO/`wid` sources -on its GPL option, so D-023 keeps them out of the reviewed bundle and selects -GStreamer integration on X11; the required Wayland native-window -`gpu-next`/Vulkan path remains present. - -### Exit criteria - -- [ ] A minimal program creates and destroys a libmpv handle on Linux, Windows, - and macOS CI or documented equivalent builders. -- [x] Version/capability diagnostics are available. -- [x] Release package layout is defined for all targets. -- [ ] The FFI decision is recorded and reviewed. - -## 7. P3 — mpv Control Plane and Native-window Vertical Slice - -**Objective:** deliver end-to-end in-process playback using mpv's ordinary -native window before attempting embedding. - -### Session core - -- [x] Add RAII ownership for `mpv_handle`. -- [x] Set deterministic pre-initialization options. -- [x] Initialize without creating `mpv_render_context`. -- [x] Add the serialized command/property owner. -- [x] Install a wakeup callback that only signals the owner/runtime. -- [x] Drain and copy events safely. -- [x] Correlate async command and property replies. -- [x] Forward mpv logs with level mapping and redaction. -- [x] Implement ordered stop and termination. -- [~] AppKit presenter work is main-thread-token-gated, detaches before - shutdown, and hands the blocking `MpvWorker` drain to a named off-main - reaper. Retained-shell restoration and replacement native launch remain - closed behind the reaper's positive completion signal, and a failed teardown - latches native launch closed for the rest of the process; a real macOS - load/quit/fullscreen stress run remains required. - -The first P3 control-plane tranche lives entirely in `ferrex-player-mpv`. -`MpvSession` is a thread-affine serialized owner; `MpvWorker` creates it on a -named owner thread, wakes through an atomic/unpark-only callback, forwards only -owned events, and performs a bounded stop/reply/final-event drain before RAII -termination. The local session form remains available for the unresolved macOS -main-loop model. - -### Generic mpv API - -- [x] Set/get string, flag, integer, double, and node properties. -- [x] Observe and unobserve arbitrary properties. -- [x] Submit arbitrary async commands and node commands. -- [x] Expose hook/client-message support needed by scripts and future features. -- [x] Expose API/FFmpeg/libplacebo version diagnostics. -- [x] Add an explicit raw/unsafe extension boundary. - -### Ferrex mapping - -- [x] Load authenticated HTTP media without exposing tokens in logs/process - arguments. -- [x] Observe pause, time, duration, cache, seeking, EOF, and idle state. -- [x] Observe tracks, chapters, editions, video parameters, and hardware decoder. -- [x] Map core events into `PlaybackEvent`. -- [x] Map play/pause, absolute/relative seek, volume, mute, and speed. -- [x] Map audio/subtitle selection. -- [x] Map stop and end reasons. -- [x] Preserve final and heartbeat watch progress. - -The Ferrex mapping now lives in the feature-gated -`ferrex-player-playback::mpv_adapter`. Direct-stream ticket resolution creates a -credential-free URI plus a zeroizing `Authorization` header on -`PlaybackSource`; both Subwave and libmpv receive that source in process. Only -the explicit legacy external-player boundary reconstructs a query-ticket URL. -The adapter submits authenticated sources as in-process node commands with -per-file options, validates header/cookie input, redacts source-specific secrets -from copied logs, and reduces the observed mpv property/event surface into the -existing generation-scoped snapshot. The mpv owner emits a coalesced -backend-neutral readiness signal after copied events -are queued, so Iced drains them without a video-frame or periodic polling -redraw loop. Async load failure falls back to Auto/Subwave from the last -observed position. A schema-versioned, serializable diagnostic snapshot now -reports requested/selected backend, backend and presenter lifecycle, client API -compatibility, mpv/FFmpeg/libplacebo versions, compiled features, VO/GPU -context and adapter, hwdec/interop, input/output color parameters, frame timing -counters, presenter geometry/display scale, the ordered deduplicated fallback -chain, and the last structured fallback/error. Geometry and fallback history -introduced diagnostic schema version 2; additive chapter/edition capabilities -advanced it to version 3; effective mpv config/script trust policy advanced it -to version 4. Capability-gated local extensions and the redacted active shader -count advanced it to version 5. The effective native log policy advances the -current schema to version 6. By default, startup-only verbose logging is -reduced to informational filtering after file initialization; the explicit -`FERREX_MPV_LOG_LEVEL` diagnostic switch can instead retain a fixed native -filter without recording log contents or an invalid environment value. - -### Native-window vertical slice - -- [x] Add an opt-in backend selector for in-process mpv native-window mode. -- [x] Play a local fixture. -- [x] Play an authenticated Ferrex URL. -- [x] Verify server transcoding output. The bounded FFmpeg job provider, - start/status/assets routes, quality-profile request, authenticated rendition - source, atomic cache publication, protected reload, and display-backed - native-mpv run pass. -- [x] Verify next-episode transition through real native-mpv EOF and the - backend-neutral replacement path. -- [x] Verify ordinary mpv fullscreen and close handling. -- [x] Enable a controlled mpv OSC fallback in native-window mode. -- [x] Keep existing external mpv fallback available. - -Evidence: the ignored -`mpv_adapter::tests::linked_native_window_load_control_fullscreen_stop_and_close_smoke` -test loaded a locally generated MPEG-4/AAC Matroska fixture through the real -mpv 0.41.0 `gpu-next` native VO on 2026-07-11, then exercised metadata/track -observation, pause, seek, confirmed fullscreen enter/exit, stop, replacement -load, orderly native-window quit, and teardown. The copied quit event is kept -distinct from EOF, and the domain test verifies that close/core termination -persists final progress and exits rather than auto-advancing an episode. The -transport form of the same smoke path passed again on 2026-07-12 against a -temporary authenticated HTTP range server whose media endpoint required the -current bearer header; it completed load, metadata/track discovery, pause, -fullscreen enter/exit, seek, stop, replacement load, and close. An earlier -variant also covered query-ticket and cookie input. On 2026-07-12 the expanded -smoke also passed against the schema-generated multitrack fixture, confirming -an initial resume offset, observed volume, mute, speed, content-fit, -audio-track selection, subtitle selection/off, chapter selection, edition -catalog/selection, confirmed fullscreen, absolute/relative seek, explicit stop, -natural EOF, post-terminal reload, and native close. -The normal server integration test separately proves that a real Ferrex router -accepts the scoped playback ticket (not a full account session) in the same -`Authorization: Bearer` form and serves a bounded `206` range. The stream -handler now also returns demuxer-appropriate MIME types for protected HLS -manifests, MPEG-TS/AAC segments, and fragmented-MP4 segments. On 2026-07-13 -the feature-gated ignored -`playback_ticket_drives_display_backed_native_mpv_through_ferrex_router` test -then combined both ends against an isolated PostgreSQL database and a real -network-bound Ferrex router: it registered a user, seeded the generated H.264 -fixture with its actual size, issued the normal playback-scoped ticket, and -opened that protected URL through the backend-neutral exact-mpv session. The -real native VO confirmed resume/metadata, pause, an authenticated range seek, -shader application, a non-empty screenshot, redacted diagnostics, and ordered -stop. The normal feature suite remains display-free; the test is opt-in through -the server's `native-mpv-e2e` feature and its command is documented with the -fixture procedure. A second run the same day loaded the generated -`transcoded-hls/index.m3u8` transport fixture through protected real-router -URLs, requiring one header-carried ticket on the manifest and every segment; -it verified unauthenticated rejection, credential-free manifest URLs, -redacted diagnostics, controls, seek, shader, screenshot, and ordered stop. -That closes router/HLS transport propagation independently. A third ignored -acceptance, -`server_generated_transcode_plays_through_display_backed_native_mpv`, passed on -2026-07-13 with the real bounded FFmpeg provider and generated HLS assets. It -submitted `360p`, polled the authenticated job to completion, verified atomic -publication, unauthenticated rejection and ticket access for the manifest and -every segment, confirmed immediate cached reuse, and completed the same real -native-VO resume/control/seek/shader/screenshot/ordered-stop lifecycle. The -manual quality-picker run and end-to-end UI episode transition remain open. -The ignored -`update::tests::linked_native_window_eof_reloads_next_episode_with_same_backend` -smoke also passed on 2026-07-13. It let the first synthetic episode reach real -native-mpv EOF, required one final-progress plus backend-preserving next-episode -request, then drove the normal `SetStreamSource` close/reopen path and confirmed -a newer mpv session generation playing the second episode. This closes the P3 -backend/domain transition; outer repository selection, ticket resolution, and -the visible app-shell transition remain in the P8 manual UI gate. - -### Tests - -- [x] Fake-FFI tests for copied event lifetimes. -- [x] Node conversion tests including nested maps/arrays and null values. -- [x] Async reply correlation and cancellation tests. -- [x] Wakeup storm/coalescing tests. -- [x] Stop during load/seek/EOF tests. -- [x] Repeated session create/destroy test. - -### Exit criteria - -- [ ] mpv plays supported fixtures through its native VO with no render context. -- [x] Current basic controls, tracks, EOF, and progress work through the generic - player contract. -- [x] Hardware-decoder and VO diagnostics are visible. -- [x] Failure returns cleanly to GStreamer/external fallback. - -## 8. P4 — Native Presenter and Iced Surface Lifecycle - -**Objective:** implement platform-neutral host geometry/lifecycle before native -platform attachment code. - -### Presenter state model - -- [x] Define host-ready, VO-ready, attach, hidden, suspended, detach, and failure - transitions. -- [x] Add monotonically increasing session/presenter generations. -- [x] Define presenter commands and events without requiring native resources to - be `Send`. -- [x] Define logical bounds, visible bounds, scale factor, and geometry revision. -- [x] Define fullscreen ownership and actual-state confirmation. -- [x] Define deterministic fallback requests. - -The platform-neutral implementation is in -`ferrex-player-playback::presenter`. `PresenterLifecycle` accepts only -session/presenter-generation-scoped inputs, emits UI-thread-local commands plus -existing playback presenter events, attaches at most once per generation, and -rejects stale generations and geometry revisions. `NativePresenter` uses a -borrowed generic associated host with no `Send` bound, so later Wayland, -AppKit, and window-system resources can remain event-loop-local. Fullscreen -changes reach the playback snapshot only after native confirmation; presenter -failures detach first and request the configured native-window fallback. - -### `NativeVideoSlot` - -- [x] Implement a renderer-generic custom widget outside Iced. -- [x] Store attachment state in `Tree::State`. -- [x] Acquire host raw handles through current generic Iced APIs. -- [x] Synchronize only on geometry revisions during redraw. -- [x] Handle zero size and full clipping as hidden. -- [x] Detach on tree removal and explicit window-close flow. -- [x] Draw loading/failure fallback without drawing decoded video. -- [x] Remove continuous redraw behavior used only for polling. - -`ferrex-player-playback::native_video_slot` now provides a renderer-neutral -layout slot and an explicit `window::run` host-capture task. Raw window/display -handles remain in an event-loop thread-local registry and are exposed only as a -borrow during presenter callbacks, preserving the presenter's non-`Send` -contract. `Tree::State` owns the generation handle, monotonically revisions -changed bounds/clip/scale observations only on redraw, requests host capture at -most once while absent, and performs idempotent detach on replacement, drop, -and close request. Loading/failure plates use only generic renderer quads; the -slot contains no decoded image or wgpu video primitive. Unit tests cover raw -host capture, duplicate suppression, scaling, clipping/zero size, deferred host -capture, and detach-before-drop. Platform callbacks and selection of the slot -remain gated on P5–P7 presenters. Desktop and 10-foot views no longer register -a decoded-frame callback for player-state updates: native backends wake through -the copied-event signal, while the legacy adapter synchronizes only on the -bounded controls timer and the existing low-rate progress heartbeat. - -### Dedicated playback controls host - -- [x] Add a player/overlay `WindowKind` to the existing daemon window manager. -- [x] Create transparent overlays hidden before native attachment. -- [x] Render only the player UI for the overlay window. -- [x] Keep the library/main window alive but hidden or suspended during dedicated - native-root playback. -- [x] Restore geometry/focus after playback. -- [ ] Ensure one visible player/taskbar identity at a time. - -The daemon window manager owns a deterministic -`Closed -> Hidden -> Active -> Closing` player-controls lifecycle. Its Iced -staging viewport is transparent, undecorated, permanently unordered, and never -becomes a visible controls `NSWindow` on macOS. Native attachment occurs while -hidden; an explicit post-attachment task hides the still-live main window -before a follow-up reveals and focuses the reparented controls `NSView` inside -mpv's root. That follow-up never focuses the staging window or reapplies stale -main-window geometry. The window manager retains the staging viewport only to -drive the canonical controls/message path, leaving main geometry untouched for -restoration. User close detaches every -registered native slot and -releases the event-loop-local raw-host lease before queuing native destruction, -then restores the retained main geometry, fullscreen mode, and focus. A -separate dismiss path preserves playback during presenter fallback. Every -completed exit now funnels through a backend-neutral `PlaybackExited` window -event; the app shell idempotently dismisses an active overlay, detaches its -host, and restores the retained main geometry/focus after stop, EOF, native -close, Back, or Home. Pure manager/settings/theme tests cover map replacement, -lifecycle ordering, hidden allocation, explicit surface alpha, and exit -dismissal without player mutation; the native-slot test covers multi-slot -detach-before-host-release. Platform presenters must still establish native -ownership/z-order and prove the single taskbar/Alt-Tab identity in P5/P6. - -### Transparency - -- [x] Make player background alpha explicit. -- [x] Verify the wgpu surface uses a compositing alpha mode where required. -- [x] Verify Iced does not advertise a full opaque region over video. -- [ ] Verify controls and text remain SDR and readable over HDR output. - -The pinned Iced revision `577abb7f` selects post-multiplied alpha when -available, then pre-multiplied alpha, and configures every wgpu surface with -the selected mode. The dedicated player window is created with -`Settings::transparent = true`; winit 0.30.13 responds on Wayland by issuing -`wl_surface.set_opaque_region(null)` instead of the full-surface opaque region. -Ferrex unit tests independently require the hidden overlay setting and its root -theme background to remain transparent. Actual compositor support and SDR UI -legibility over HDR remain platform acceptance measurements rather than an -assumption from these code paths. - -### Fake presenter tests - -- [x] attach occurs once per generation; -- [x] host-before-VO and VO-before-host ordering; -- [x] duplicate geometry suppression; -- [x] clipping/hide/show transitions; -- [x] scale and window recreation; -- [x] stale event rejection; -- [x] explicit close before drop; and -- [x] presenter error to fallback transition. - -### Exit criteria - -- [x] The presenter/widget contract is stable without mpv or platform-specific - types in Iced-facing public APIs. -- [x] Fake presenter lifecycle tests pass. -- [x] Player UI no longer needs video-frame redraws to update progress. -- [x] No Iced fork change has been added for native presentation. - -## 9. P5 — Windows and X11 Presenters - -Windows may proceed after P4 and remains a target for fully integrated -native-VO mpv presentation; the Wayland HYBRID decision does not defer or -weaken its production presenter gate. X11 is now a separate licensing-gated -HYBRID under D-023: the reviewed LGPL libmpv profile has no X11 VO or `wid` -implementation, so the checklist is retained only as re-entry criteria. - -### Windows - -- [x] Observe/query mpv `window-id` as a full pointer-width `i64`, reject zero - or out-of-range values, and validate with `IsWindow` before attach. -- [x] Choose and record an mpv-root/owned-Iced-overlay relationship. -- [x] Allocate the Iced overlay hidden and reveal it only after attachment. -- [x] Synchronize the mpv client rectangle and per-monitor DPI; the active - spike re-queries the native root independently of Iced layout revisions. -- [x] Hide the retained main window before presenter-driven reveal/focus and - keep live overlay viewport geometry independent from restoration geometry. -- [x] Implement owned-window z-order, minimize/restore/visibility, focus - handoff, task-switcher styles, and idempotent restoration. -- [x] Delegate fullscreen to mpv and update state only from its observed - confirmation. -- [x] Route integrated controls/input through Iced and disable mpv OSC/default - input for the integrated request. -- [x] Detach and restore the overlay before either HWND is destroyed. -- [-] `wid` inline mode is not retained for the full-player experience; the - native-root owned-overlay path preserves mpv's modern VO and the ordinary - native window is the deterministic fallback. -- [~] One taskbar entry and correct Alt-Tab behavior are ready for the - representative Windows matrix; target observation remains open. -- [~] SDR/HDR overlay-visible/hidden behavior is ready for representative - display testing; native HDR capability remains false until recorded. -- [~] D3D11 `gpu-next` and D3D11VA/DXVA2 diagnostics are packaged and exposed; - actual hardware evidence remains open. -- [~] The generic lower-level stress harness exists; the Win32 - owned-overlay-specific 100-cycle run remains open. - -**Windows exit decision:** - -- [~] The compile-gated owned-overlay implementation is ready for - representative-system handoff; production/Auto approval remains open. -- [x] Native-window mode is the explicit structured fallback and render API - integration is not forced. - -### X11 - -**Status:** Deferred under D-023; retained as X11 re-entry criteria. - -- [-] Detect X11 backend and compositing-manager presence. -- [-] Obtain mpv and Iced XIDs and verify display/screen compatibility. -- [-] Create/attach an ARGB overlay above the mpv window. -- [-] Synchronize configure, map/unmap, stack, focus, and scale behavior. -- [-] Define input shape/region behavior. -- [-] Delegate and confirm fullscreen state. -- [-] Detach/destroy in protocol-safe order. -- [-] Implement/test `wid` inline mode. -- [-] Test with and without a compositing manager. -- [-] Verify one taskbar entry and window-manager compatibility. -- [-] Stress 100 window/session cycles. - -**X11 exit decision:** - -- [-] Integrated mpv and `wid` are not built from mpv 0.41's GPL-only X11 - sources under D-005's LGPL release policy. -- [ ] Verify packaged X11 GStreamer fallback and the optional external-process - handoff before rollout; do not advertise in-process mpv native-window mode. - -## 10. P6 — macOS Presenter - -**Objective:** preserve mpv's native modern macOS VO while delivering a fully -integrated Ferrex player and Iced controls where AppKit permits. The Wayland -HYBRID decision does not change this target. - -### AppKit spike - -- [x] Confirm mpv 0.41 returns its live `NSWindow` pointer through the - read-only `window-id` property; no unsupported macOS `wid` input is used. -- [x] Resolve and retain the mpv `NSWindow` and Iced host `NSView`/staging owner - only with an AppKit main-thread marker. -- [x] Reparent the transparent Iced controls `NSView` inside mpv's content - hierarchy without creating an AppKit child window. -- [~] Movement, resize, backing-scale, focus, occlusion, close, and app - visibility synchronization are implemented and fake-tested; target - observation remains open. -- [~] Fullscreen ownership/confirmation and auxiliary-window behavior are - implemented; native animation observation remains open. -- [~] Active-Space and hide/unhide visibility refresh is implemented; the - representative Spaces matrix remains open. -- [~] Apple Silicon and Intel build/package jobs are defined; representative - hardware execution remains open. -- [~] In-root view composition is the selected spike strategy; retain the - native-window fallback until representative testing proves it sufficient. - -### In-root presenter - -- [x] Implement the in-root `NSView` relationship behind the presenter - contract, with no child-window or screen-positioning operations. -- [x] Keep all AppKit object access behind the non-`Send` main-thread window - system. -- [~] Pin a narrow winit 0.30.13 AppKit compatibility patch that retains the - renderer `WinitView` independently, preserves its logical `WindowId`, follows - the actual host for scale/focus/cursor/IME/metrics, and removes foreign-root - observers on every detach/close edge; native compilation and the transition - matrix remain hardware gates. -- [x] Size the controls view from the root content view's local bounds rather - than an outer-frame or screen-coordinate projection. -- [x] Detach the AppKit relationship first, then move blocking libmpv shutdown - to a named reaper so the main run loop remains serviceable. A single durable - player-domain arbiter defers replacement launch, retained-shell restoration, - navigation, and queued exit until positive reaper completion; teardown - failure blocks later native launches rather than permitting overlap. -- [~] VideoToolbox diagnostics are exposed; representative hardware decoding - evidence remains open. -- [~] HDR/EDR overlay-visible/hidden validation remains open on capable Apple - Silicon and Intel displays, so the spike does not advertise native HDR. -- [~] A display-free 100-cycle in-root attach/synchronize/fullscreen/detach - contract passes; the Apple Silicon/Intel hardware matrix remains open. - -### Exit decision - -- [~] Integrated capability remains behind the explicit compile-time `spike` - mode. Auto and production packaging remain closed until the full native - matrix passes. -- [x] Any preflight or attachment failure selects mpv native-window mode and - dismisses the hidden Iced host. -- [-] Do not substitute a deprecated OpenGL render path solely to claim - embedding. - -The exact target build commands, representative fixture matrix, retained -artifact rules, and production-pass boundary for P5/P6 are documented in -[`native-playback-fixtures.md`](../src/content/docs/developer/native-playback-fixtures.md#windows-and-macos-integrated-presenter-handoff). - -## 11. P7 — Wayland Protocol Bridge - -**Objective:** determine whether mpv's normal Wayland VO can be safely -virtualized as an Iced subsurface without copying frames or modifying Iced with -platform hacks. W0 found the connection bootstrap unsafe under the current -boundary, so D-022 records HYBRID and defers bridge implementation. - -This phase has recorded the **HYBRID** outcome in D-022. GStreamer remains the -integrated Wayland backend and mpv remains available through ordinary -native-window presentation. W1–W5 are retained below as re-entry criteria, but -are deliberately deferred until a safer per-session Wayland connection path or -other maintainable architecture exists. Auto defaults are unchanged. - -### W0 — Research fixture and bridge boundary - -- [x] Pin the mpv version used by the spike. -- [x] Inventory every Wayland global/protocol used by mpv for `gpu-next` on the - test environment. -- [x] Evaluate reuse/forking of the `wl-proxy` library used by Jellyfin's - precedent. -- [x] Define a raw protocol trace fixture for basic map, resize, fullscreen, - frame presentation, and teardown. -- [x] Define how the bridge identifies the intended mpv VO connection/surface. -- [!] Define how only mpv is directed to the private socket without racing other - process users of `WAYLAND_DISPLAY`/`WAYLAND_SOCKET`. - -The versioned -[`native_playback_wayland_trace.py`](../../scripts/qa/native_playback_wayland_trace.py) -harness pins mpv 0.41.0, runs ordinary `gpu-next`/Vulkan/`waylandvk`, inserts -operation markers for map, pause/seek, resize, fullscreen, stop, VO reload, and -teardown, and writes only redacted mode-private artifacts below the ignored -results directory. Its display-free parser/redaction tests run in Linux CI. -Three `wl-wlroots-amd` runs against the generated SDR, HDR10/PQ, and HLG -fixtures passed on 2026-07-13 UTC with Vulkan hardware decoding and the expected -input color parameters. They used the same protocol-interface set, issued ten -registry requests across mpv/libplacebo/driver activity, and exposed exactly -one `xdg_surface.get_toplevel` VO candidate per run. The exact globals, -interfaces, per-method inventory, surface-identification rule, and evaluation -of permissively licensed `wl-proxy` 0.1.3/Jellyfin precedent are recorded in -[the W0 spike page](../src/content/docs/developer/native-mpv-wayland-spike.md). - -Stable libmpv provides no per-context Wayland endpoint, and a temporary -process-environment override cannot cover delayed/internal-thread VO and driver -connections safely. The only race-free candidate found so far is a -process-lifetime startup proxy that routes Iced and mpv into one upstream -namespace and virtualizes only the protocol-identified mpv shell candidate. -That is broader than the specification's private mpv-only socket, so it is not -selected. D-022 records HYBRID and D-008 is deferred. Reopening W1 requires a -compliant redirection mechanism or an explicit specification amendment backed -by a maintainable ownership/teardown design. No environment race or symbol -interposition is accepted. - -### W1 — Same-upstream connection proof - -**Status:** Deferred under D-022; retained as Wayland re-entry criteria. - -- [ ] Obtain Iced's borrowed `wl_display` and parent `wl_surface` safely. -- [ ] Build an upstream client/event queue over the borrowed display without - taking ownership of it. -- [ ] Start a private downstream Wayland socket for mpv. -- [ ] Forward registry/global binding and core object traffic. -- [ ] Prove the mpv child and Iced parent are objects on the same upstream - connection. -- [ ] Prove bridge teardown does not disconnect or consume Iced's display. -- [ ] Test concurrent event queues for deadlock/starvation under resize and - playback. - -**Gate W1:** - -- [-] Continue only if same-connection forwarding and ownership are reliable; - deferred because no safe mpv-only connection bootstrap exists. -- [x] Record a hybrid decision: retain GStreamer integrated Wayland and use mpv - native-window mode as the mpv fallback. - -### W2 — Shell-role virtualization - -**Status:** Deferred under D-022; retained as Wayland re-entry criteria. - -- [ ] Capture the mpv video `wl_surface`. -- [ ] Suppress upstream `xdg_wm_base.get_xdg_surface` for that surface. -- [ ] Suppress its toplevel role and virtualize required downstream objects. -- [ ] Assign upstream `wl_subsurface` under Iced's parent. -- [ ] Set desynchronized child commits. -- [ ] Set position from the surface-slot geometry. -- [ ] Apply an empty native input region or otherwise ensure Iced owns input. -- [ ] Synthesize initial and subsequent configure events with valid serials. -- [ ] Consume/validate downstream ack-configure behavior. -- [ ] Handle surface recreation and VO restart generations. - -### W3 — Protocol and WSI preservation - -**Status:** Deferred under D-022; retained as Wayland re-entry criteria. - -- [ ] Vulkan WSI playback through the bridge. -- [ ] EGL/OpenGL fallback where supported. -- [ ] dmabuf file-descriptor forwarding. -- [ ] explicit synchronization and release behavior. -- [ ] viewporter without creating a competing host viewport. -- [ ] fractional-scale events and mixed-DPI display movement. -- [ ] output enter/leave behavior. -- [ ] presentation-time/frame callbacks. -- [ ] tearing-control/content-type where selected by mpv. -- [ ] idle-inhibit behavior. -- [ ] color-management and color-representation objects owned by mpv. -- [ ] gracefully forward or reject unknown/unsupported optional protocols. - -### W4 — Host window semantics - -**Status:** Deferred under D-022; retained as Wayland re-entry criteria. - -- [ ] Translate fullscreen requests to Iced and synthesize resulting state. -- [ ] Translate close requests. -- [ ] Define minimize/maximize behavior. -- [ ] Define interactive move/resize behavior or explicitly leave it to Iced - decorations. -- [ ] Preserve Iced keyboard, pointer, touch, IME, clipboard, and drag/drop. -- [ ] Synchronize slot geometry before the relevant parent commit. -- [ ] Measure whether current redraw ordering is sufficient. -- [ ] If insufficient, document a minimal generic Iced use case before any - upstream discussion; do not restore the persistent `wayland-hack` hook. - -### W5 — HDR and robustness gate - -**Status:** Deferred under D-022; retained as Wayland re-entry criteria. - -- [ ] Verify `gpu-next` and expected hardware decoder. -- [ ] Verify HDR10/PQ and HLG color-description traffic on a capable compositor. -- [ ] Verify SDR Iced controls compose over HDR video without incorrect output - labeling. -- [ ] Test Hyprland/wlroots, KDE, and GNOME where available. -- [ ] Test Intel, AMD, and NVIDIA proprietary drivers where available. -- [ ] Test pause, seek, resize, fractional scaling, fullscreen, minimize, - suspend/resume, monitor removal, VO reload, and stop. -- [ ] Stress 100 load/stop and 100 fullscreen cycles. -- [ ] Verify clean fallback when optional protocols are absent. -- [ ] Verify no CPU frame path is used. - -### Wayland decision - -Recorded outcome: - -- [-] **GO:** not selected; integrated mpv is not eligible for Wayland rollout. -- [x] **HYBRID:** GStreamer remains the integrated Wayland backend; mpv is used - in native-window mode on Wayland and remains the integrated target on Windows - and macOS. -- [-] **STOP:** not selected; the bridge criteria and research fixture are - retained for reconsideration when a better path exists. - -D-022 is a platform-specific release decision, not an abandonment of embedded -mpv elsewhere. Windows P5 and macOS P6 continue toward fully integrated -native-VO presentation. Reopening Wayland GO requires a new decision backed by -a safe connection bootstrap, W1–W5 evidence, and release packaging; a -single-compositor demonstration remains insufficient. - -## 12. P8 — Playback Feature Parity - -**Objective:** make the mpv backend replace current player behavior rather than -merely play a file. - -### Core controls - -- [x] play, pause, and toggle; -- [x] absolute and relative seek; -- [x] seek preview/drag throttling without flooding libmpv; -- [x] volume and mute; -- [x] playback speed; -- [x] contain/cover/fill mapping with documented mpv properties; -- [x] confirmed fullscreen state; -- [x] loading, buffering, seeking, and error UI; and -- [x] controls visibility without frame-driven redraw. - -Desktop and 10-foot playback surfaces now derive static loading, buffering -(with bounded percentage), seeking, stopping, presenter-readiness, and -structured failure plates from `PlaybackSnapshot`/`PresenterState`. The -pre-session loading route and terminal error route remain shell-owned, while an -active backend no longer depends on adapter-specific UI state for those -transitions. Pure projection tests cover transient states, integrated presenter -readiness, native-window non-presentation, and structured-error fallback text; -the plates do not request animation or video-frame redraws. - -The native-window content-fit implementation maps contain/cover/fill to -`keepaspect`, `video-unscaled`, and `panscan`; native-size and scale-down modes -are mapped at the same boundary. The exact table is now normative in the -specification, and pure mapping tests cover every mode. Seek preview dispatch -is limited to one command per 100 ms at the UI boundary. The mpv adapter also -allows only one absolute seek request in flight and replaces its single queued -position with the newest drag target; stop, replacement load, and shutdown -clear that queue so late replies cannot seek a new lifecycle. Deterministic -tests cover the UI interval, latest-value coalescing, and late-reply rejection. -`SeekTo` now submits an immediate absolute command rather than changing only -the drag preview, and keyboard/episode relative seeks remain signed -`PlaybackCommand::SeekRelative` operations after duration clamping. Explicit -pause intent now takes precedence over buffering and is not inferred from other -non-playing states, so toggle behavior remains correct during load/seek/cache -transitions. The real native-VO smoke confirms play, pause, and both seek forms. -Once an initial track catalog exists, backend-driven audio/subtitle selection changes -are now diffed during snapshot projection and use the same short-lived notice -as Iced-issued selections; initial discovery, duplicate confirmations, and -replacement-file loading remain quiet. A pure test covers simultaneous audio -change/subtitle disable, duplicate suppression, and initial-catalog -suppression. - -Chapter and edition catalogs now retain Ferrex-owned stable identities plus the -currently observed selection in `PlaybackSnapshot`. Capability-gated settings -pickers submit backend-neutral `SelectChapter`/`SelectEdition` commands; the mpv -adapter maps those to the standard `chapter` and `edition` properties while -Subwave reports the unsupported capability explicitly. Those serialized -capability flags introduced diagnostic schema version 3; the config trust -policy below advanced it to version 4, local extension capabilities advanced it -to version 5, and effective log policy advances the current schema to version -6. Replacement loads clear old catalogs before -the next demuxer identities arrive. Reducer/parser tests -cover catalog normalization, chronological chapter presentation versus native -indices, selection observations, and mpv's single-default-edition case where -the scalar property is unavailable. On 2026-07-12 the display-backed mpv 0.41 -multitrack smoke selected the second generated chapter and the generated default -edition through this path, in addition to its existing track/control lifecycle -checks. - -### Tracks and media structure - -- [x] stable audio track identities and selection; -- [x] subtitle off/on/selection and previous selection; -- [x] ASS, text, bitmap, and external subtitle coverage; -- [x] chapters; -- [x] editions; -- [ ] attached fonts; and -- [x] track-change notifications. - -On 2026-07-12 the expanded display-backed smoke also passed against the -`ass-animation-fonts.mkv` and `pgs-bitmap.mkv` generated fixtures, including -track discovery/selection and the full shader/screenshot/control lifecycle. A -separate run loaded `sources/english.srt` beside the H.264 fixture through the -capability-gated `AddExternalSubtitle` command and confirmed a newly observed, -selected Ferrex-owned track with `is_external=true`. Together with the embedded -SRT tracks in the multitrack smoke, this closes native-VO ASS/text/PGS/external -load and selection coverage. Visual attached-font substitution correctness -remains open. - -### Ferrex behavior - -- [x] authenticated direct play; -- [x] server transcode URL playback, including protected HLS generation, - publication, route authentication, source projection, and a display-backed - native-mpv load; -- [x] quality-profile switch and credential-preserving stream reload; -- [x] resume position; -- [x] progress heartbeat; -- [x] final progress on all terminal paths; -- [x] next/previous/restart episode behavior; -- [x] navigation back/home while playing; and -- [x] restore main window state after playback. - -The episode reducer preserves Internal, in-process mpv native-window, or -external-process mode across explicit next/previous and natural EOF -transitions, checkpoints progress before replacement, and applies the exact -five-percent Previous boundary (an unknown duration safely restarts). Final -progress, replacement/reset, and navigation messages now use serialized task -chains instead of parallel batches. Back and Home checkpoint and then enter the -common reset path. `ResetAfterStop` emits one backend-neutral host-exit event -after state reset; the UI window -controller's idempotent dismiss path closes an active dedicated overlay and -restores the retained main size, position, fullscreen mode, and focus. Pure -reducer tests cover all three backend modes, the restart boundary, Back/Home -ordering, and the host-exit event; window-controller tests prove exit dismissal -does not mutate player state. - -### mpv compatibility surface - -- [x] raw command UI/debug console or documented developer API; -- [x] arbitrary property observations for future features; -- [x] profile/config loading policy; -- [x] optional user scripts and input bindings; -- [x] user shader/profile passthrough; -- [x] screenshot behavior; and -- [x] capability diagnostics for unavailable options. - -The Starlight -[`Native mpv extension API`](../src/content/docs/developer/native-mpv-extension-api.md) -now documents the public `MpvSession`/`MpvWorker` ownership models, arbitrary -string/node commands, typed and node properties, stable arbitrary observations, -hooks/client messages/events, reply correlation, redaction rules, and the final -unsafe raw-handle boundary. Feature code must keep user-facing cross-backend -behavior in the neutral contract; a user command console is not required for -this gate. Existing fake-ABI tests exercise every documented value format, -observation registration/removal, command form, cancellation, copied event -lifetime, and teardown. - -`ferrex-player-mpv::MpvConfigPolicy` now makes configuration trust explicit. -The default deterministic profile disables standard user config, script -discovery, and external URL resolvers; native-window OSC and controlled input -bindings remain Ferrex-owned options. The developer-only -`FERREX_MPV_CONFIG_POLICY=trusted-user` opt-in enables standard mpv config, -`input.conf`, and scripts as trusted in-process code. Unknown or non-Unicode -values fail closed without being echoed into logs. Diagnostic schema version 4 -introduced the effective policy and high-level switches without config contents -or paths; schema version 5 added extension capability booleans and only the -observed active shader count, while schema version 6 reports only the effective -native message level and whether the bounded startup capture is active. -Fake-ABI and pure parser/diagnostic tests verify both profiles, fail-closed -selection, and continued `ytdl=no`; the player README documents the trust -boundary and invocation. A normal settings control remains rollout UX work and -does not change Auto or fallback selection. - -`PlaybackSession` now exposes capability-gated Ferrex commands for external -sidecar subtitles, named video profiles, an ordered local shader list, and -explicit-path screenshots. The mpv adapter maps them to argument-separated -`sub-add`, `apply-profile`, the `change-list` command for `glsl-shaders`, and -`screenshot-to-file`; named user profiles require the trusted-user policy. -Subwave reports `UnsupportedOperation` instead of a -no-op. Local paths and profile names have redacted `Debug` forms, invalid inputs -fail without echoing values, and diagnostics never include paths. Pure tests -cover every command/mode, policy-dependent capability reporting, unsupported -fallbacks, and redaction. On 2026-07-12 the display-backed mpv 0.41 smoke passed -again after applying and observing a temporary identity shader, writing a -non-empty screenshot, clearing the shader list, and removing both files. - -### External player migration - -- [x] Compare in-process native-window fallback with current external mpv - behavior. -- [x] Decide whether external process mode remains for crash isolation. -- [x] If retained, adapt it to the same playback contract and redaction rules. -- [-] Removal is not selected during the rollback window or while D-023 needs - an explicit X11 handoff. - -D-009 retains external mpv as an explicit, process-isolated compatibility -handoff, never an Auto candidate. In-process native-window mpv provides the -full command/event/track surface and header-authenticated transport but shares -the Ferrex process; external mpv provides crash isolation and user-installed -X11 VO availability, while intentionally advertising only its observed -progress, seek, fullscreen, terminal, and native-window capabilities. D-017 -keeps its credential-bearing URL out of argv and sends it through private IPC; -D-019 reduces copied IPC observations into the neutral snapshot used by -progress and episode policy. The same snapshot now also produces the redacted -backend/presentation/evidence summary used by diagnostics. Unit tests cover -snapshot lifecycle, heartbeat/final progress, episode transition after handle -teardown, and diagnostic projection; the real Linux smoke covers private IPC, -process observation, and argv non-disclosure. Reconsider removal in P11 after -the rollback window and only if X11 has another policy-approved handoff. - -### Exit criteria - -- [ ] Existing player integration tests pass against mpv where backend-neutral. -- [ ] Manual control/track/episode parity checklist passes. -- [ ] Unsupported mpv-native behavior is represented as a capability, not a - hidden no-op. - -## 13. P9 — Hardening, Performance, and Release Packaging - -### Automated verification - -- [x] Linux unit/integration suite with libmpv enabled. -- [~] Windows mpv/presenter build, focused tests, SDK staging, and closure audit - are defined in CI; the first target run and display smoke remain open. -- [~] macOS mpv/AppKit build, focused tests, pinned core source builds, - version/hash-recorded runtime inputs, bundle rewrite/sign/audit, and both - architectures are defined in CI; the first target run and display smoke - remain open. -- [x] Backend-disabled build remains valid during rollback window; Linux CI - and both target distribution workflows check it explicitly. -- [x] Fake presenter and FFI tests run without a display. -- [x] Lifecycle stress test is runnable in CI or a documented compositor job. -- [x] Secret-redaction tests cover URL, cookies, and headers. - -Linux CI now runs both the linked `ferrex-player-mpv` handle suite and the -`ferrex-player-playback --features mpv` contract/adapter suite, including fake -FFI/presenter lifecycle and source/log redaction coverage. It also performs an -explicit `ferrex-player --no-default-features` rollback build; the Windows and -macOS distribution workflows repeat that check for their target triples. -Display-backed -native-VO smoke tests remain explicitly ignored and are covered by the manual -fixture gate rather than silently using a software/headless VO in CI. The -ignored `linked_native_window_load_stop_lifecycle_stress` job now defaults to -100 fresh libmpv/native-window load, VO-ready, ordered-stop, and teardown -cycles; the fixture procedure documents the exact compositor command and -results location. On 2026-07-13 a full 100-cycle Wayland run with mpv 0.41.0 -passed in 25.81 seconds under explicit 64 MiB RSS-growth and four-FD budgets: -process RSS moved from an 80,680 KiB post-first-cycle baseline to 96,192 KiB -with a 103,976 KiB peak, while open FDs remained four. This closes the generic -Linux native-window harness gate; separate GPU/native-resource review and the -Windows/macOS presenter-specific stress gates remain open. - -### Performance - -- [ ] Compare startup-to-first-frame against baseline. -- [ ] Compare seek latency. -- [ ] Compare CPU/GPU usage for SDR/HDR. -- [ ] Verify Iced does not redraw at video frame rate when controls are idle. -- [ ] Inspect frame-drop/timing diagnostics under 60/120/144 Hz UI settings. -- [ ] Verify overlay visibility does not cause an unacceptable HDR or latency - regression. -- [ ] Confirm no readback/upload path in native-VO mode. - -### Release artifacts - -- [x] Nix package smoke test outside the development shell. -- [x] Flatpak bundle smoke test. -- [ ] Windows packaged install smoke test on a clean VM. -- [ ] macOS signed/bundled app smoke test on a clean machine. -- [ ] License and notices reviewed. -- [x] Upgrade/rollback behavior documented. - -On 2026-07-13, `nix build path:.#ferrex-player` produced the wrapped player -outside the development shell. The source filter now explicitly excludes -ignored local Flatpak, target, cache, and direnv roots so working-tree package -smokes cannot ingest unrelated vendored Cargo manifests. A clean-environment -launch with a temporary home completed `ferrex-player screenshot --help`; the -packaged ELF directly requires `libmpv.so.2`, its loader metadata contains no -build/developer path, and no developer home path remains in the binary. Nix -store references are expected and resolve through the pinned LGPL closure. - -The Flatpak release smoke is a real bundle install, not only a build-directory -check. It verifies the executable and pinned libmpv/FFmpeg/libplacebo closure, -the LGPL build-profile records, and then removes the test installation. The -same loader/profile smoke now runs in the Flatpak workflow before artifact -upload; display-backed playback remains part of the separate manual fixture -gate. - -### Documentation - -- [x] Update root README platform table based on measured capabilities. -- [x] Update `ferrex-player/README.md` prerequisites and diagnostics. -- [x] Update `docs/architecture.md` diagram. -- [x] Add mpv configuration and troubleshooting documentation. -- [x] Document backend selector and fallback order. -- [x] Document how to collect mpv and presenter diagnostics safely. - -The canonical Starlight architecture now shows the neutral session/reducer and -three concrete adapter/presentation paths. The new Desktop playback backends -guide documents build-time feature selection, current per-platform Auto -policy, deterministic exact-request fallback, trusted config, fixed native log -filters, evidence-qualified in-player diagnostics, authentication/package -troubleshooting, and the GStreamer/external rollback boundary. The player README -and operator configuration page link the same policy and commands; the legacy -`docs/architecture.md` remains a pointer to the canonical page. - -### Exit criteria - -- [ ] Every platform proposed for rollout passes its specification gate. -- [ ] Release packages work without developer-only paths. -- [ ] Performance does not regress beyond an explicitly accepted budget. - -## 14. P10 — Staged Default Rollout - -### Stage A — Developer-only - -- [ ] Backend available only through an explicit developer setting. -- [ ] Structured diagnostics are collected in issue reports. -- [ ] GStreamer remains default everywhere. - -### Stage B — User opt-in - -- [ ] Document experimental mpv integrated/native-window choices. -- [ ] Add visible fallback reason when integration fails. -- [ ] Collect a minimum soak period and issue inventory. -- [ ] Retain one-click/config rollback to GStreamer. - -### Stage C — Per-platform Auto - -For each platform independently: - -- [ ] platform acceptance gate signed off; -- [ ] release artifact verified; -- [ ] fallback verified; -- [ ] known limitations documented; -- [ ] Auto switched to mpv in one focused change; and -- [ ] release notes identify rollback setting. - -Under D-022, Wayland Auto remains on the integrated GStreamer path and an mpv -selection uses native-window presentation. Under D-023, X11 Auto also remains -integrated GStreamer, while the LGPL-only in-process mpv backend reports X11 -presentation unavailable and the external process remains an explicit handoff. -Windows and macOS retain independent per-platform Auto gates for their fully -integrated mpv presenters. Reopening either Linux HYBRID decision requires its -recorded re-entry evidence and a new decision. - -### Stage D — Primary backend - -- [ ] mpv is Auto on every platform approved by its gate. -- [ ] At least one release cycle retains and exercises GStreamer rollback. -- [ ] Crash/error/fallback reports are reviewed before cleanup. - -## 15. P11 — Legacy Cleanup and Optional Upstream Work - -### Player cleanup - -- [ ] Remove obsolete `video_opt` compatibility fields and duplicated state. -- [ ] Remove obsolete external-mpv messages if external mode is retired. -- [ ] Remove backend-specific UI branches superseded by capabilities. -- [x] Remove filename-based HDR provider selection. -- [x] Remove frame-driven progress polling. - -Provider selection no longer constructs an HDR hint from `2160p`, `UHD`, -`HDR`, or `DV` filename fragments. Player content labeling uses only server or -decoder color/bit-depth metadata, while native HDR output remains a separate -observed diagnostic. A pure metadata test covers PQ, HLG, BT.2020, 10-bit, and -8-bit SDR without a filename input. Snapshot synchronization is driven by the -bounded controls timer for Subwave and the coalesced copied-event signal for -mpv; views register no decoded-frame progress callback. - -### GStreamer/Subwave cleanup - -Only after rollout and rollback criteria: - -- [ ] Confirm no server/media-analysis use depends on playback GStreamer - packages. -- [ ] Remove unused appsink playback path. -- [ ] Remove unused Wayland playback surface code if mpv replaced it. -- [ ] Remove the GStreamer development-version pin from player packaging when - no remaining feature requires it. -- [ ] Preserve Subwave as a separate backend only if it has a documented, - tested capability. - -### Iced fork cleanup - -- [ ] Remove the playback-specific Wayland integration hook when no longer used. -- [ ] Keep batching/performance changes separate from media integration. -- [ ] Re-evaluate whether Ferrex can track upstream Iced more directly. -- [ ] Do not combine Iced cleanup with the mpv default-switch change. - -### Optional upstream proposal - -- [ ] Collect at least two non-media use cases for foreign parent-window - support. -- [ ] Open an Iced Discourse design discussion before writing a PR. -- [ ] Keep the proposed change backend-generic and map to winit semantics. -- [ ] Submit only after maintainer alignment. -- [ ] Do not include mpv, Wayland protocol objects, HDR policy, or persistent - pre-commit callbacks. - -### Exit criteria - -- [ ] No obsolete playback dependency remains in release packages. -- [ ] Architecture and platform docs match the shipped implementation. -- [ ] Rollback history and removed capability decisions are recorded. - -## 16. Cross-cutting Workstreams - -### 16.1 Diagnostics - -- [x] Define a serializable playback diagnostic snapshot. -- [x] Add backend/presenter lifecycle state. -- [x] Add versions and runtime capability list. -- [x] Add VO, GPU context, adapter, hwdec, and color parameters. -- [x] Add geometry and display scale. -- [x] Add fallback chain and reason. -- [x] Add opt-in verbose mpv and Wayland bridge traces. - -`FERREX_MPV_LOG_LEVEL` accepts only the fixed levels `none`, `fatal`, `error`, -`warn`, `info`, `verbose`, `debug`, and `trace`; invalid/non-Unicode values fail -closed to the bounded startup-verbose/steady-info policy and are never echoed. -Copied mpv messages still pass through both wrapper and active-source -redaction, and trace-severity messages remain trace-severity application logs. -Diagnostic schema version 6 reports the effective filter and bounded-startup -flag, never message contents. The deferred Wayland bridge has no runtime to -instrument under D-022; its opt-in W0 protocol harness already writes redacted, -mode-private traces through `native_playback_wayland_trace.py` and remains the -required re-entry diagnostic. - -### 16.2 Secret handling - -- [x] Introduce a redacted playback source debug representation. -- [x] Prefer HTTP headers/cookies over query-token URLs where server API permits. - Direct in-process streams and the typed `StreamingPlaybackSource` used by the - streaming/HLS service now carry a zeroizing bearer header on a - credential-free URI. Invalid/injectable values fail closed and `Debug` - redacts both path and authorization. Only the explicit external-process - compatibility boundary reconstructs a temporary query-ticket URL. -- [x] Ensure mpv logs are filtered before entering normal application logs. - Provider startup logging also uses `PlaybackSource`'s redacted formatter and - never emits the raw path, query, userinfo, headers, or cookies. -- [x] Ensure errors and panic diagnostics do not expose authorization data. -- [x] Remove URL-bearing external process arguments from the retained legacy - mode by submitting its media URL over the private mpv IPC socket. - -### 16.3 Capability UX - -- [x] Show selected backend and presentation mode in diagnostics/settings. -- [x] Explain why integrated presentation is unavailable. -- [x] Distinguish native HDR support from detected HDR content. -- [x] Distinguish expected/observed hardware decoding. -- [x] Never label a path zero-copy without observed evidence. - -The in-player settings panel now projects a redacted, evidence-qualified summary -from `PlaybackDiagnosticSnapshot`: requested and selected backend/presentation, -integrated-presenter status and fallback detail, input HDR metadata separately -from native-output HDR evidence, and configured hardware-decoder policy -separately from the observed decoder. Retained external-process snapshots now -produce the same summary after process exit. Pure projection tests cover the -labels and explicitly reject unobserved zero-copy wording; no URI, header, -cookie, local path, or configuration path enters the summary. - -## 17. Risk Register - -| ID | Risk | Impact | Mitigation / decision trigger | Status | -|---|---|---|---|---| -| R1 | mpv cannot be safely proxied onto Iced's Wayland connection | Blocks integrated mpv on Wayland | D-022 selects GStreamer integration plus mpv native-window rather than a framework/environment hack; reopen only for a maintainable per-session path | Mitigated by HYBRID; research deferred | -| R2 | Proxy misses evolving Wayland protocols used by mpv/driver | Playback/HDR failures by compositor or version | Retain the pinned trace fixture and W1–W5 re-entry matrix; no bridge ships under D-022 | Deferred under HYBRID | -| R3 | Transparent overlay breaks HDR, independent flip, or latency | Quality/performance regression | Measure overlay shown/hidden; native-window fallback; per-platform rollout | Open | -| R4 | macOS foreign Iced view fails fullscreen/Spaces/scale behavior | No integrated macOS controls | P6 spike; retain native-window mode; do not force OpenGL | Open | -| R5 | libmpv/AppKit/event-loop threading deadlocks | Application hang on load/exit | Serialized owner, callback rules, lifecycle stress, main-loop-aware teardown | Open | -| R6 | Native resource teardown races host window destruction | Crashes/leaks | Explicit generations, close ordering, 100-cycle tests | Open | -| R7 | Packaging differs from developer environment | Backend absent in releases | P2 packaging workstream and clean-machine smoke tests | Open | -| R8 | mpv config/scripts make behavior nondeterministic or unsafe | Support/security problems | Deterministic profile; explicit trusted-user config/scripts opt-in with diagnostics | Mitigated; settings UX pending | -| R9 | Player abstraction refactor changes progress/episode behavior | User-visible regressions | Adapter-first P1 with behavior tests before mpv | Open | -| R10 | Scope expands into a private graphics/render backend | Long-term maintenance burden | Native-VO invariant; private mpv patch requires spec amendment | Open | -| R11 | User input cannot reveal hidden Iced overlay reliably | Broken controls/focus | Explicit per-platform input policy and tests | Open | -| R12 | mpv API/version churn breaks wrapper | Build/runtime incompatibility | Bundle known version, runtime checks, raw API tests, update policy | Open | -| R13 | A distribution links Ferrex to mpv/FFmpeg built with GPL-only code | Release license incompatibility | Require per-platform LGPL build profiles and notices; Nix and Flatpak assert mpv's resolved `gpl=false` option plus FFmpeg's LGPL/no-`--enable-gpl` configuration | Mitigated on Nix/Flatpak; open elsewhere | -| R14 | mpv 0.41's X11 VO and `wid` implementation require its GPL build profile | Blocks bundled in-process mpv on X11 under D-005 | D-023 keeps X11 on integrated GStreamer and permits the external process boundary; reopen only for compatibly licensed upstream code or an explicit distribution-policy amendment | Mitigated by HYBRID; packaged fallback gate open | -| R15 | Server-side FFmpeg jobs can exhaust resources or expose incomplete/stale renditions | Availability or protected-stream integrity regression | Bound concurrency/timeouts/retention, write into per-job staging, validate output, atomically publish, authenticate ownership and every asset, and keep transport-only fixtures independently tested | Mitigated; live generated-rendition load passes, operational soak open | - -## 18. Decision Log - -| ID | Decision | State | Date | Notes | -|---|---|---|---|---| -| D-001 | Target libmpv native VO instead of forcing frames through wgpu | Accepted | 2026-07-11 | Maximizes current mpv VO/hwdec/HDR support | -| D-002 | Keep GStreamer as migration/failure fallback | Accepted | 2026-07-11 | Removal requires per-platform gates and rollback release | -| D-003 | Make the first integration without mpv-specific Iced changes | Accepted | 2026-07-11 | Use raw handles, `window::run`, redraw events, and widget tree state; the pinned Iced revision has no `Shell::window` host accessor. | -| D-004 | Use a thin Ferrex wrapper and fakeable function table over `libmpv2-sys` 4.0.1 | Accepted | 2026-07-11 | Full client API 2.5 coverage without adopting `libmpv2` ownership/event limitations. Isolated in `ferrex-player-mpv`; `linked` is opt-in and propagated by the player-level `mpv` feature; LGPL exception is explicit in `deny.toml`. Fake lifecycle/version tests and the Nix-linked create/initialize/destroy smoke test pass. Fallback impact: backend-disabled builds remain valid. | -| D-005 | Dynamically link a known LGPL-only mpv 0.41.0 shared library; minimum client API 2.2 | Accepted | 2026-07-11 | API 2.2 (mpv 0.37) contains every P3 client symbol; production remains pinned to mpv 0.41/API 2.5. The Nix profile uses `gpl=false`, LGPL FFmpeg, disabled GPL-only inputs, and an install-time feature/license assertion; its player package and real-handle smoke test pass. Flatpak/Windows/macOS must reproduce the LGPL profile and notices, not use default GPL builds. A bespoke runtime loader is deferred, not prohibited. Fallback impact: builds without `mpv`/`linked` retain GStreamer/external mpv. | -| D-006 | Non-Wayland native-root plus transparent Iced overlay | Accepted for handoff | 2026-07-13 | Windows and macOS now implement independently compile-gated native-root presenters behind the neutral lifecycle. Representative hardware approval remains per-platform in D-024/D-025; Auto is unchanged. | -| D-007 | Wayland Iced root plus proxied mpv subsurface | Deferred | 2026-07-12 | D-022 selects HYBRID. Retain this architecture only as re-entry criteria if a safe connection bootstrap becomes available. | -| D-008 | Wayland-only mpv connection redirection mechanism | Deferred | 2026-07-12 | W0 confirms stable libmpv has no per-context Wayland endpoint and delayed helper connections make temporary environment overrides unsafe. A process-lifetime startup proxy is the only race-free candidate found, but routing Iced too conflicts with the private mpv-only socket requirement. D-022 selects HYBRID until a better path exists. | -| D-009 | Retain external mpv as an explicit process-isolated compatibility handoff through the rollback window | Accepted | 2026-07-13 | In-process native-window mpv now has control/track/progress parity for the supported matrix, but the external process still supplies crash isolation and the D-023 X11 handoff without linking GPL-only X11 VO code into Ferrex. D-017 sends its credential-bearing source through private IPC rather than argv; D-019 projects copied state into `PlaybackSnapshot`, and the capability-UX tranche adds the same redacted diagnostic summary. Pure tests cover lifecycle/progress/episode/diagnostic behavior; the ignored real-mpv smoke verifies IPC load, observation, private socket cleanup, and `/proc//cmdline` non-disclosure. Fallback impact: external mpv remains explicit and is never selected by Auto; GStreamer and in-process native-window selection are unchanged. Revisit removal only in P11 after the rollback window and an approved X11 alternative. | -| D-010 | Optional upstream foreign-AppKit-view proposal | Deferred | 2026-07-24 | Ferrex now carries a pinned, generic winit 0.30.13 compatibility patch for a retained `WinitView` hosted by an external `NSWindow`; it does not add mpv-specific Iced API. An upstream proposal remains deferred until native Apple Silicon/Intel evidence proves the contract and the API can be stated independently of Ferrex. | -| D-011 | Keep the neutral contract in `ferrex-player-playback::contract` | Accepted | 2026-07-11 | `dev` already extracted the playback crate; contract/reducer/channel/fallback and adapter tests live there, with no second crate until another client needs it. Evidence: playback and UI unit suites plus workspace all-target check in the initial implementation change | -| D-012 | Serialize libmpv through a thread-affine `MpvSession`, with an optional owner-thread `MpvWorker` | Accepted | 2026-07-11 | The wakeup callback performs only atomic coalescing and `Thread::unpark`; native pointers stay on the owner, and event payloads are bounded/copied before the next wait. Fake ABI tests cover nodes, replies, cancellation, hooks, logs, wake storms, and 50 teardown cycles; linked tests cover a real property reply and ordered stop. macOS can use the local owner until its AppKit model is proven. Fallback impact: none when the `mpv` feature is disabled. | -| D-013 | Route the existing explicit “Play in MPV” action to in-process native-window libmpv when the `mpv` feature is enabled | Accepted | 2026-07-11 | `mpv_adapter.rs`, `video::open_requested_session`, and a coalesced copied-event readiness subscription provide the vertical slice without frame uploads or periodic event polling. Auto remains Subwave; async mpv load failure resumes through Auto/Subwave, the separate external-process handoff remains available, and backend-disabled builds retain the historical external action. Unit tests cover source/log redaction, property/event/track mapping, close-versus-EOF terminal policy, version/VO/GPU/hwdec/frame diagnostic serialization, and load/seek/EOF stop order; the expanded local real-VO smoke test passes with mpv 0.41.0 and verifies public runtime diagnostics, confirmed fullscreen enter/exit, stop/reload, and native close/quit, while the earlier authenticated HTTP range variant separately proves header/cookie/query-ticket transport. | -| D-014 | Keep native presenter resources UI-thread-local behind a generation-scoped lifecycle reducer | Accepted | 2026-07-11 | `ferrex-player-playback::presenter` owns pure readiness, geometry, visibility, suspension, fullscreen-confirmation, teardown, and failure transitions while `NativePresenter` deliberately has no `Send` bound. Accepted/cleared geometry now crosses the neutral presenter-event boundary into diagnostic schema v2, including logical/visible bounds and display scale; fallback requests form an ordered deduplicated chain. Fake lifecycle/reducer tests cover both readiness orders, one attach per generation, clipping/zero size, duplicate geometry, scale/window recreation, stale rejection, explicit detach-before-drop, deterministic fallback, geometry clearing, and fallback history. Fallback impact: presenter failure emits `PresenterFailed` from integrated mpv to native-window mpv after detach; no platform presenter or Auto default is enabled yet. | -| D-015 | Acquire Iced native hosts with a `window::run` handshake and event-loop-local lease | Accepted | 2026-07-11 | The pinned Iced API does not expose a window through widget `Shell`. `native_video_slot.rs` therefore requests capture once, returns only a pointer-free result through `Task`, and keeps copied raw handles in a thread-local borrow registry used by generation-scoped presenter callbacks. `Tree::State` revisions geometry only on redraw and detaches on replacement/drop/close. Unit tests cover host capture, clipping/scale revisions, capture deferral, and teardown. Fallback impact: none; no integrated presenter or Auto selection is enabled. | -| D-016 | Decouple player snapshot updates from decoded-frame callbacks | Accepted | 2026-07-12 | Desktop and 10-foot views construct the backend presentation widget without `on_new_frame`. Native mpv wakes Iced only through its coalesced copied-event signal; legacy Subwave synchronization reuses the bounded controls timer, and progress persistence keeps its existing ten-second heartbeat. The player/UI feature suite and backend-disabled check pass. Fallback impact: Subwave remains fully available, but its UI position refresh is intentionally bounded instead of frame-rate-driven. | -| D-017 | Carry in-process playback tickets in typed source headers | Accepted | 2026-07-13 | `resolve_playback_stream_source`, `PlayerDomainState::current_source`, and `SetStreamSource` keep direct Ferrex stream URIs credential-free and store the playback-scoped token in a redacted, zeroizing `Authorization` header. The formerly string-only streaming/HLS service now returns a typed `StreamingPlaybackSource` with the same constraints and projects it into `PlaybackSource`; embedded query/userinfo credentials and header injection fail closed. Subwave and libmpv pass source headers in process; the legacy external path creates a temporary zeroizing query URL only at its compatibility boundary and sends it through private mpv IPC rather than the child argument vector. Unit tests verify direct and streaming-service header transport, injection rejection, redacted source/state/error diagnostics, fail-closed ticket errors, legacy conversion, and `0700` IPC-directory cleanup. Real native-mpv smokes pass against a bearer-header range server, a real Ferrex router direct stream, and a router-backed HLS manifest whose four segments require the same ticket; the separate external-mpv smoke verifies query-ticket IPC load plus `/proc//cmdline` non-disclosure. Fallback impact: in-process mpv-to-Subwave fallback retains the same authenticated source; only the explicit external compatibility handoff reconstructs a query ticket. | -| D-018 | Allocate a dedicated Iced player host hidden and activate its presented surface only after explicit native attachment | Accepted, amended for macOS | 2026-07-24 | `WindowKind::PlayerOverlay`, the daemon window controller, player-only root view routing, and the explicit transparent theme implement a generation-independent staging shell without platform objects. Attachment occurs while hidden; an explicit `Activating` state covers the serialized retained-main hide, and `Active` is recorded only after the presenter synchronously reveals the native-hosted surface. On macOS the Iced `NSWindow` remains unordered and the presenter makes only its reparented `NSView` first responder in mpv's root; no Iced focus command can activate the staging owner. No stale main resize/move is applied after attachment. Close handling calls `prepare_iced_native_host_close` before `window::close`, detaching all registered slot generations before releasing the raw-host lease; the retained main window is restored after activating/active-host teardown. Manager, settings, theme, viewport, and native-slot tests cover hidden/activating/active/closing order and detach-before-release. P5/P6 still own native root relationship, z-order, and taskbar identity validation. Fallback impact: the host is dormant until an attachment confirmation explicitly activates it, and presenter fallback can dismiss it without stopping playback; current Subwave, mpv native-window, and external modes are unchanged. | -| D-019 | Project retained external-mpv process observations into the backend-neutral snapshot | Accepted | 2026-07-12 | `PlayerDomainState::external_mpv_snapshot` is reduced from copied private-IPC observations while `ExternalMpvHandle` owns only process resources. Desktop/10-foot views, progress heartbeat, navigation, and episode start-mode policy now consume the same snapshot/progress projection as in-process backends; only process polling and external seek remain explicit compatibility branches. Final observations are captured before handle drop, and tests prove terminal episode advancement and progress persistence without a surviving handle. Fallback impact: external process mode remains available and D-009 remains pending; Subwave and in-process mpv selection are unchanged. | -| D-020 | Keep mpv user config and scripts disabled unless trusted-code policy is explicit | Accepted | 2026-07-12 | `ferrex-player-mpv::MpvConfigPolicy` defaults to Ferrex's deterministic native-window profile. `FERREX_MPV_CONFIG_POLICY=trusted-user` is the developer-only opt-in for standard mpv config, `input.conf`, and scripts; invalid values fail closed and are not logged. Diagnostic schema v4 reports policy and effective high-level switches. Fake session tests verify pre-initialization config/script options and retained external-resolver disablement; playback parser/diagnostic tests and backend-disabled compilation pass. Fallback impact: none—Auto remains Subwave, exact mpv selection is unchanged, and builds without mpv do not read the policy. | -| D-021 | Expose local video extensions as capability-gated Ferrex commands | Accepted | 2026-07-12 | `PlaybackCommand`/`PlaybackSession` model external sidecar subtitle loading, named profile application, ordered local shader replacement, and explicit-path screenshots without exposing the mpv owner. `PlaybackFilePath` and `VideoProfileName` redact debug output; adapter validation and copied-log filtering never echo values. mpv uses argument-separated standard commands, while Subwave returns structured `UnsupportedOperation`; user profiles are available only under D-020's trusted policy. Diagnostic schema v5 adds the four support booleans and only an observed shader count. Pure mapping/redaction/policy tests pass; display-backed mpv 0.41 native-VO smokes passed with a real external SRT track, identity shader, and non-empty screenshot on 2026-07-12. Fallback impact: unsupported backends remain selected and report the unavailable operation rather than changing backend or silently doing nothing. | -| D-022 | Use a HYBRID Wayland backend until a safe integrated mpv connection path exists | Accepted | 2026-07-12 | W0 traces on SDR, HDR10/PQ, and HLG prove ordinary mpv 0.41 `gpu-next`/Vulkan native VO and identify one shell candidate, but stable libmpv cannot direct a session to the private bridge without a process-global race; the only race-free startup proxy candidate would also proxy Iced and violates the current boundary. Wayland therefore keeps GStreamer/Subwave for integrated presentation and offers mpv in native-window mode. Windows P5 and macOS P6 remain fully integrated native-VO targets. Fallback impact: no Wayland Auto change, no CPU/wgpu mpv frame path, and no change to Windows/macOS rollout gates. | -| D-023 | Keep X11 on integrated GStreamer under the LGPL release profile | Accepted | 2026-07-12 | mpv 0.41's Meson graph requires `gpl=true` for the X11 VO and therefore for native-window/overlay/`wid` presentation. The reviewed D-005 profile cannot ship that code. The Flatpak build asserts `gpl=false` and `x11=disabled`, retains Wayland Vulkan/dmabuf/VA-API, and its built/installed bundle resolves the pinned libmpv/FFmpeg/libplacebo closure from `/app`; Nix uses the same mpv license option. Both package builds compile `FERREX_MPV_X11=disabled`; `open_requested_session` preflights an X11-only environment into a structured `UnsupportedPlatform` fallback before creating libmpv, with a pure display/profile matrix test. Re-entry requires compatibly licensed upstream X11 support or an explicit distribution-policy/specification amendment, followed by the retained P5 matrix. Fallback impact: X11 Auto remains integrated GStreamer, in-process mpv is reported unavailable, and the separate external mpv process may remain an explicit handoff; Windows/macOS gates are unchanged. | -| D-024 | Use mpv's Win32 HWND as the video root and an owned, taskbar-suppressed Iced HWND as the controls overlay | Accepted for handoff | 2026-07-13 | The compile-time `FERREX_MPV_WINDOWS_PRESENTER=spike` path observes the full pointer-width `window-id`, validates both HWNDs, synchronizes client geometry/DPI/minimize/visibility at an independent native-root cadence, delegates fullscreen to mpv, and restores owner/style state on detach. Display-free presenter tests, pinned LGPL libmpv SDK/import-library tooling, exhaustive provenance for the staged runtime DLL closure, and a reviewed GStreamer PE/GIO/TLS closure with HLS/HTTPS smoke are defined; floating Rust/MSYS2 build tools remain identified in workflow logs rather than covered by that runtime-closure claim. Fallback impact: any preflight/attach failure dismisses the hidden overlay and selects mpv native-window; Auto, native HDR, and production capability remain closed until the Windows hardware/package/100-cycle matrix passes. | -| D-025 | Use mpv's AppKit `NSWindow` as the sole visible root and reparent the Iced controls `NSView` into its content hierarchy | Accepted for spike | 2026-07-25 | The compile-time `FERREX_MPV_MACOS_PRESENTER=spike` path treats `window-id` only as mpv's read-only `NSWindow` observation, retains the Iced view and its permanently unordered staging owner behind a non-`Send` main-thread marker, uses root-local content bounds and autoresizing, and never creates, orders, positions, or focuses a controls `NSWindow`. A pinned winit 0.30.13 AppKit patch retains the renderer view directly, preserves donor `WindowId` event routing, follows the actual host for scale/focus/cursor/IME, suppresses donor lifecycle mutations while hosted, and removes foreign-root observers before restoration or close. Detach restores the view before native leases are released, and the player-domain arbiter withholds shell restoration, navigation, and replacement launch until teardown completes. Display-free hierarchy, rollback, and lifecycle tests are present; native mixed-scale input, fullscreen/Spaces, generic third-party window-manager behavior, VideoToolbox, HDR/EDR, and clean-host execution remain hardware gates. Fallback impact: any preflight or attachment failure dismisses the hidden staging host and selects mpv native-window; Auto and production packaging remain closed until the native matrix passes. | - -When resolving a pending/proposed decision, add the implementation reference, -test evidence, and fallback impact to its Notes field. - -## 19. Definition of Done - -The migration is complete when: - -- [ ] Ferrex-owned playback commands/events/snapshots are the only player-domain - backend contract. -- [ ] libmpv native-window mode is complete and release-packaged on all desktop - targets. -- [ ] each platform has either a passing integrated presenter or a documented - native-window fallback. -- [x] Wayland has a recorded HYBRID decision backed by P7 W0 evidence. -- [ ] mpv feature parity covers current controls, tracks, subtitles, progress, - episodes, direct play, and transcoding output. -- [ ] no native-VO path transfers decoded frames through CPU/wgpu. -- [ ] HDR and hardware-decoding claims are backed by diagnostics and manual test - evidence. -- [ ] lifecycle and 100-cycle stress gates pass. -- [ ] Nix, Flatpak, Windows, and macOS release artifacts are verified. -- [ ] fallback and rollback paths are documented and tested. -- [ ] platform Auto defaults match recorded rollout decisions. -- [ ] obsolete GStreamer/external/fork code is removed only after the rollback - window, or retained with a documented capability. -- [ ] README, architecture, configuration, and troubleshooting docs describe the - shipped system. - -## 20. Immediate Next Actions - -1. [x] Complete the P0 direct-use and behavior inventory. -2. [x] Draft the P1 Ferrex-owned playback contract and fake backend tests. -3. [x] Run the P2 FFI comparison and record D-004/D-005. -4. [x] Build the P3 native-window vertical slice before any graphics embedding - work. -5. [x] In parallel, assemble the Wayland protocol/media test matrix needed for - the P7 W1 feasibility gate. -6. [x] Make episode/navigation exit policy and local mpv extensions explicit, - capability-gated, redacted, and covered by display-free tests. -7. [~] Complete the remaining UI quality-picker/episode gate against a live - local Ferrex server. Authenticated direct play, protected fixture HLS, and a - real server-generated `360p` rendition all pass display-backed through the - network-bound router with header-only tickets, including cache reuse, - shader, screenshot, seek, and ordered-stop coverage. The manual quality - selection run and UI episode transition remain open. -8. [x] Record the P7 HYBRID decision without changing Wayland Auto. -9. [x] Implement compile-gated Windows P5 and macOS P6 native presenter handoff - builds independently of the deferred Wayland bridge. -10. [~] Run the documented Windows/macOS representative-system package, - fullscreen/focus/scale/HDR/hwdec, fallback, and 100-cycle matrices before - changing either platform's production or Auto capability. diff --git a/docs/specs/native-mpv-playback.md b/docs/specs/native-mpv-playback.md index ab02f9d0..bfc4b310 100644 --- a/docs/specs/native-mpv-playback.md +++ b/docs/specs/native-mpv-playback.md @@ -1,978 +1,230 @@ -# Native mpv Playback Integration - -- **Status:** Accepted; implementation in progress -- **Scope:** `ferrex-player` desktop playback -- **Last updated:** 2026-07-24 -- **Tracking plan:** [Native mpv Playback Migration Plan](../plans/native-mpv-playback-migration.md) - -## 1. Purpose - -This specification defines the target architecture for making mpv the primary -playback engine in Ferrex while keeping the Iced user experience native and -integrated. It is the design reference for implementation, review, testing, and -the staged migration away from GStreamer as the default player backend. - -The central decision is to use libmpv as the playback control plane while mpv -retains ownership of decoding and native video presentation. Video frames do -not normally pass through Iced or wgpu. Iced owns application layout, controls, -input, and non-video UI. - -This document is normative for the target design. The accompanying plan tracks -incremental delivery and may change sequencing without changing the -architecture defined here. - -## 2. Decision Summary - -1. **mpv is the target primary desktop playback backend.** GStreamer remains a - supported migration and failure fallback until the mpv path satisfies the - acceptance gates in this specification. -2. **Use libmpv without `mpv_render_context` for the primary path.** mpv uses its - normal native VO, preferably `gpu-next`, and owns frame timing, hardware - decoding, subtitles, color conversion, swapchain negotiation, and HDR - signaling. -3. **Treat presentation as a platform capability.** A platform presenter joins - mpv's native surface/window with the Iced player UI without copying decoded - frames into wgpu. -4. **Keep the control API open-ended.** Typed Ferrex operations are conveniences - over mpv commands and properties, not a replacement for them. -5. **Do not add mpv-specific APIs to Iced.** The first implementation uses - current generic Iced raw-window access and custom-widget lifecycle. Any - upstream Iced proposal must be independently useful and discussed upstream - before code is submitted. -6. **Wayland currently uses a HYBRID backend policy.** W0 found no safe way for - stable in-process libmpv to direct only its delayed VO/driver connections to - a private bridge without a process-global race. GStreamer therefore remains - the integrated Wayland backend and mpv uses ordinary native-window - presentation until a maintainable path exists. -7. **Windows and macOS remain full integration targets.** The Wayland decision - does not reduce their requirement for an embedded Ferrex player experience - using mpv's native VO and platform presenter without routing decoded frames - through wgpu. -8. **A normal mpv window is always an acceptable fallback.** Integration - failure must not force a CPU-copy or SDR path when native mpv presentation - remains available. - -## 3. Context - -### 3.1 Current playback paths - -At the migration baseline, the extracted `ferrex-player-playback` crate stored -`SubwaveVideo` directly in `PlayerDomainState::video_opt` and called backend -methods from playback update and view code. Subwave supplies two materially -different paths: - -- a Wayland GStreamer sink rendered through custom subsurfaces, including the - current zero-copy/HDR effort; and -- an appsink path that uploads decoded image data into a custom Iced/wgpu - primitive and is effectively the cross-platform fallback. - -Ferrex also has -`crates/ferrex-player-playback/src/external_mpv.rs`, which starts an external -mpv process and uses JSON IPC for progress and limited lifecycle control. It is -a handoff rather than an integrated player. - -The current arrangement has important strengths: the Wayland sink can accept a -host-provided display and surface, and that path already demonstrates HDR -playback in Ferrex's primary environment. It also has structural costs: - -- behavior differs substantially between Wayland and other platforms; -- the Wayland path depends on a development GStreamer series and a narrow known - working version; -- appsink cannot expose the full native HDR and presentation behavior; -- Ferrex owns subtitle, color, sink, and pipeline behavior that mpv already - implements across platforms; and -- the Iced fork contains platform-specific surface hooks that are difficult to - propose upstream. - -### 3.2 Why not render libmpv into wgpu now - -The stable libmpv render API primarily exposes OpenGL and software rendering. -A seamless OpenGL implementation is possible when a GUI toolkit owns and -exposes the OpenGL context, as demonstrated by Switchfin. That does not map -cleanly to Iced's portable wgpu renderer: - -- wgpu intentionally hides the native graphics context and swapchain; -- `wgpu-hal` interop is unsafe, backend-specific, and not a stable application - contract; -- Windows would require D3D/Vulkan/DX interop or a private mpv render backend; -- macOS OpenGL is deprecated and is not the desired HDR path; -- software rendering introduces a full CPU path; and -- the host becomes responsible for HDR target selection, metadata, frame - timing, and synchronization. - -Relevant upstream mpv efforts are still open as of the date above, including -issues `#6575` and `#11031` and pull requests `#16818` and `#17828`. The target -architecture therefore allows a future render-API presenter, but does not make -unmerged work a production dependency. - -## 4. Goals - -The implementation MUST: - -- preserve mpv's broad demuxer, decoder, subtitle, audio, filter, script, and - protocol compatibility; -- permit mpv's normal hardware-decoding and native presentation paths; -- preserve native HDR and color-management behavior where mpv and the platform - support it; -- present Iced controls as part of one coherent player experience; -- support Wayland, X11, Windows, and macOS with explicit capability reporting; -- expose arbitrary mpv commands, options, properties, observations, events, - and node values in addition to typed Ferrex conveniences; -- avoid per-frame CPU readback or upload in the primary path; -- let mpv render independently of Iced's redraw cadence; -- fail deterministically to a documented fallback; -- keep watch progress, episode navigation, stream selection, and Ferrex server - behavior independent of the selected playback backend; -- keep unsafe native-window and Wayland protocol code outside Iced; and -- support incremental rollout without changing the current default until its - replacement passes platform gates. - -The implementation SHOULD: - -- bundle a known compatible libmpv build in release artifacts; -- allow an opt-in user mpv configuration while retaining deterministic Ferrex - defaults; -- make presentation mode and capabilities visible in diagnostics; -- support a normal mpv-native window mode for compatibility and debugging; and -- isolate presenter failures from the player domain state machine. - -## 5. Non-goals - -The initial migration does not attempt to: - -- make native video behave like an arbitrary Iced texture under transforms, - rounded clipping, scrolling, or nested opacity; -- guarantee zero-copy for every codec, format, driver, or hardware decoder; -- implement a new stable Vulkan, D3D, Metal, or libplacebo render API for mpv; -- maintain a permanent private mpv graphics backend; -- make Iced itself understand mpv, GStreamer, HDR metadata, or Wayland - subsurfaces; -- remove GStreamer before the Wayland and release-packaging gates pass; -- change server-side transcoding or media analysis solely because the desktop - playback backend changes; -- cover Android, iOS, or console playback in the first implementation; or -- promise that every mpv script that assumes direct ownership of native input - will work unchanged in integrated-Iced mode. - -## 6. Terminology - -- **Control plane:** libmpv commands, options, properties, observations, and - events. -- **Native VO:** mpv's normal video output path, such as `gpu-next`, creating or - using a platform-native presentation surface. -- **Presenter:** UI-thread platform code that joins the mpv native output with - an Iced-owned player surface/window and synchronizes geometry and lifecycle. -- **Integrated mode:** Iced controls and input are visually integrated with the - native mpv video surface. -- **Native-window mode:** mpv owns an ordinary top-level player window and may - use its native input/OSC behavior. -- **Surface slot:** an axis-aligned logical rectangle reserved by Iced for - native video. It is not an Iced texture. -- **Host:** the Iced view/window or native overlay surface participating in the - presenter relationship. - -## 7. Architectural Invariants - -The following invariants apply across all platforms: - -1. A decoded video frame MUST NOT cross into Iced/wgpu in the primary native-VO - path. -2. `mpv_render_context` MUST NOT be created for the native-VO session. -3. The libmpv wakeup callback MUST only signal Ferrex. It MUST NOT call back - into libmpv. -4. Normal libmpv calls and render/presenter operations MUST not form cyclic - lock or wait dependencies. -5. Native presenter objects MUST be created, mutated, and destroyed on the - platform-appropriate UI/event-loop thread unless the platform API explicitly - permits otherwise. -6. Player domain logic MUST consume Ferrex-owned commands, events, snapshots, - and track models instead of branching on `SubwaveVideo` versus mpv. -7. Presentation failure MUST be reported as a capability/error transition; it - MUST NOT be inferred from a missing frame timer. -8. Geometry synchronization MUST happen at most once per host redraw/layout - revision, not once per decoded frame. -9. The native presenter MUST be detached before its host window is destroyed. -10. Backend selection and every fallback transition MUST be logged with a - machine-readable reason. -11. A retained shell MUST NOT be restored and a replacement native session - MUST NOT launch until prior native teardown has reported positive - completion. Teardown failure MUST keep later native launches closed for the - remainder of that process. - -## 8. Target Component Model +# Native mpv playback design + +This document records the durable design of Ferrex's desktop mpv integration. +It describes ownership, platform behavior, fallback, and validation. The Rust +types and tests remain the source of truth for implementation details. + +## Scope + +Ferrex uses libmpv as a playback control plane while mpv retains ownership of +decoding and native video presentation. Iced owns application layout, controls, +input, and non-video UI. Decoded video does not pass through Iced or wgpu in the +native-VO path. + +Selection is platform-specific. On macOS, mpv is the default and only supported +playback engine; GStreamer is unavailable. Other platforms retain their +existing selection policy and may use mpv's ordinary native window or the +external mpv compatibility path. + +## Durable decisions + +1. **Use mpv's native VO.** Ferrex does not build its primary playback path on + `mpv_render_context` or transfer decoded frames through wgpu. +2. **Keep the player domain backend-neutral.** Ferrex-owned commands, events, + snapshots, and track models isolate UI and persistence behavior from the + selected playback engine. +3. **Use a pinned LGPL libmpv profile.** The supported in-process build is mpv + 0.41 with GPL-only components disabled. Runtime compatibility is checked + before a session starts. +4. **Make fallback explicit and deterministic.** Presenter failure can fall + back to mpv native-window playback. GStreamer and external mpv remain + separately selectable where platform policy permits them; macOS never + falls back to GStreamer. +5. **Keep Wayland hybrid.** GStreamer remains the integrated Wayland backend. + Explicit mpv playback uses an ordinary native window because stable libmpv + cannot safely direct only its delayed Wayland connections to Ferrex's host + connection. +6. **Keep X11 hybrid under the LGPL profile.** mpv 0.41 requires its GPL build + option for the X11 VO and `wid`, so the reviewed in-process build does not + claim X11 presentation. +7. **Use native platform presenters on Windows and macOS.** mpv owns the native + video root while Ferrex attaches Iced controls without copying video frames. +8. **Treat configuration and credentials as security boundaries.** User mpv + config is opt-in, media credentials stay out of normal logs and process + arguments, and diagnostic output is redacted. + +## Component and ownership model ```text -+--------------------------------------------------------------+ -| ferrex-player | -| | -| Player domain | -| +----------------------+ +--------------------------+ | -| | PlaybackSnapshot |<------| PlaybackEvent reducer | | -| | Ferrex track models | +--------------------------+ | -| +----------+-----------+ ^ | -| | | | -| v | | -| Iced player controls ---- PlaybackCommand --+ | -| | | -| v | -| NativeVideoSlot / dedicated player overlay | -+-------------+-------------------------------+----------------+ - | geometry/lifecycle | control/events - v v -+-----------------------------+ +-----------------------------+ -| Platform presenter | | MpvSession | -| UI-thread native resources | | libmpv handle + event pump | -+-------------+---------------+ +---------------+-------------+ - | native surface/window relationship | - +--------------------+-----------------+ - v - mpv native VO / gpu-next +Iced player UI + | + | PlaybackCommand / PlaybackSnapshot + v +Ferrex playback domain + | | + | control and events | geometry and lifecycle + v v +libmpv session owner platform presenter + | | + +------------+------------+ + v + mpv native video output ``` -### 8.1 Playback domain contract - -Ferrex MUST own a backend-neutral contract. Its initial implementation lives -under `ferrex-player-playback::contract`; the exact Rust layout may evolve, but -it should have the following shape: - -```rust -pub enum PlaybackCommand { - Load(PlaybackSource), - SetPaused(bool), - SeekAbsolute(Duration), - SeekRelative(DurationDelta), - SetVolume(f64), - SetMuted(bool), - SetSpeed(f64), - SelectAudio(TrackId), - SelectSubtitle(Option), - SelectChapter(ChapterId), - SelectEdition(EditionId), - SetContentFit(ContentFit), - SetFullscreen(bool), - Stop, -} - -pub enum PlaybackEvent { - StateChanged(PlaybackState), - PositionChanged(Duration), - DurationChanged(Option), - BufferChanged(BufferState), - TracksChanged(TrackCatalog), - ChaptersChanged(Vec), - ChapterChanged(Option), - EditionsChanged(Vec), - EditionChanged(Option), - VideoParametersChanged(VideoParameters), - Ended(EndReason), - Error(PlaybackError), - Presenter(PresenterEvent), -} - -pub struct PlaybackSnapshot { - pub state: PlaybackState, - pub position: Duration, - pub duration: Option, - pub tracks: TrackCatalog, - pub chapters: Vec, - pub current_chapter: Option, - pub editions: Vec, - pub current_edition: Option, - pub video: Option, - pub capabilities: PlaybackCapabilities, -} -``` - -Commands are asynchronous messages to the backend owner. Events update one -snapshot in the Iced application state. The view reads the snapshot and never -polls libmpv or GStreamer directly. - -The GStreamer/Subwave adapter and mpv adapter MUST implement the same behavioral -contract during migration. Backend-specific diagnostics may be attached to a -structured diagnostic payload, but must not leak into normal player messages. - -### 8.2 Mpv control API - -`MpvSession` owns one libmpv core and provides: - -- pre-initialization option setting; -- async command submission with request identifiers; -- typed and node-valued property get/set; -- property observation with stable observation identifiers; -- event draining; -- log forwarding with secret redaction; -- API/version and compiled-capability reporting; and -- an explicitly unsafe/raw escape hatch where the safe wrapper cannot represent - a supported libmpv operation. - -Typed Ferrex behavior maps to standard properties and commands, including at -least: - -- `pause`, `time-pos`, `duration`, `speed`, `volume`, and `mute`; -- `track-list`, `aid`, `sid`, `chapter-list`, `chapter`, `edition-list`, and - `edition`; -- `demuxer-cache-state`, `core-idle`, `eof-reached`, and `seeking`; -- `video-params`, `video-out-params`, `hwdec-current`, and `vo-configured`; -- `loadfile`, `seek`, `stop`, and playlist commands; and -- `fullscreen` where mpv owns the top-level window. - -Content fit uses one deterministic native-VO property set so it does not depend -on renderer geometry or decoded-frame uploads: - -| Ferrex fit | `keepaspect` | `video-unscaled` | `panscan` | -|---|---:|---|---:| -| Contain | `yes` | `no` | `0.0` | -| Cover | `yes` | `no` | `1.0` | -| Fill | `no` | `no` | `0.0` | -| None/native size | `yes` | `yes` | `0.0` | -| Scale down only | `yes` | `downscale-big` | `0.0` | - -These properties are submitted asynchronously through the same serialized -owner as other controls. Ferrex updates the requested fit in its snapshot only -after the serialized owner accepts all three submissions. - -The wrapper MUST preserve arbitrary command/property access so future mpv -features do not require a new Ferrex release merely to become reachable. - -### 8.3 Presentation contract - -The native presenter is separate from `MpvSession` because its resources and -threading rules differ. Conceptually it provides: - -```rust -pub struct SurfaceGeometry { - pub logical_bounds: Rectangle, - pub visible_bounds: Option, - pub scale_factor: f64, -} - -pub trait NativePresenter { - fn attach(&mut self, host: HostWindow<'_>) -> Result<(), PresenterError>; - fn synchronize(&mut self, geometry: SurfaceGeometry) - -> Result<(), PresenterError>; - fn set_visible(&mut self, visible: bool) -> Result<(), PresenterError>; - fn set_fullscreen(&mut self, fullscreen: bool) - -> Result<(), PresenterError>; - fn detach(&mut self); - fn capabilities(&self) -> PresenterCapabilities; -} -``` - -This trait is illustrative: the implementation may need a command handle around -a UI-thread-owned state machine instead of a Rust trait object. It MUST NOT -impose `Send` on platform resources that are correctly event-loop-local. - -Capabilities include at least: - -- integrated overlay support; -- embedded surface support; -- native HDR signaling support; -- fractional scaling support; -- native-window fallback availability; -- whether mpv or Iced owns fullscreen; and -- any known compositor requirement. - -## 9. Session and Presenter Lifecycle - -The implementation MUST model lifecycle explicitly. A suggested state model is: - -```text -Uninitialized - -> Initializing - -> Idle - -> Loading - -> AwaitingHost / AwaitingVoSurface - -> Presenting - -> Hidden or Suspended - -> Stopping - -> Idle - -> Terminating - -> Terminated -``` - -Host and VO readiness can arrive in either order outside Wayland. Every attach -attempt carries a monotonically increasing generation so late native or libmpv -events from an old load cannot attach to a new player. - -Required lifecycle behavior: - -1. Entering the player creates or acquires a playback session. -2. Integrated mode waits for both host and VO readiness. -3. The presenter attaches once per generation. -4. Zero-sized or fully clipped geometry hides/unmaps presentation without - destroying the playback core. -5. Window occlusion/suspension is forwarded as a power/performance hint. -6. Leaving the player detaches native presentation before closing its Iced - window. -7. `stop` is issued and relevant final position is captured before the core is - destroyed. -8. Presenter resources are destroyed before libmpv terminates when they refer - to mpv-owned native objects. -9. On macOS, termination must keep the AppKit main loop serviceable while mpv's - VO tears down. - -The implementation MUST tolerate repeated load, stop, backend switch, and -window recreation cycles without retaining native surfaces or callbacks. - -## 10. Threading and Event Delivery - -### 10.1 Control plane - -A dedicated owner serializes normal libmpv access. It may be a worker thread or -a strictly serialized executor, subject to platform constraints discovered in -the initial spike. - -- `mpv_set_wakeup_callback` only wakes the owner/Iced subscription. -- The owner drains `mpv_wait_event(handle, 0)` until `MPV_EVENT_NONE`. -- Blocking property calls are not performed in Iced `view` or native callbacks. -- Prefer async commands and async property updates for user operations. -- Event payloads are copied into Ferrex-owned data before the next libmpv event - call invalidates pointers. -- Log callbacks redact stream credentials and authorization headers. - -### 10.2 Presenter plane - -Presenter changes execute on the native UI/event-loop thread. Cross-thread mpv -signals are converted into presenter commands and wake Iced; they do not mutate -window-system objects directly. - -The native VO renders at its own cadence. Iced redraws only for UI animation, -input, snapshot changes, or geometry changes. Playback MUST NOT create a -continuous Iced redraw loop merely to poll position. Periodic progress -persistence remains timer-driven at a substantially lower rate. - -## 11. Iced Integration - -### 11.1 Surface widget - -An external `NativeVideoSlot` custom widget reserves layout and owns presenter -attachment state in `iced::advanced::widget::Tree::State`. - -The pinned Iced revision does not expose a host on `Shell`. Before the first -attachment, the widget therefore emits one host-capture request. The -application services it with `window::run`, copies the raw window/display -handles into an event-loop thread-local lease, and returns only a pointer-free -result through the Iced task channel. The lease is detached/released before -window destruction and never imposes `Send` on the presenter or native host. - -On `Window::RedrawRequested`, the widget: - -1. verifies that the generation's event-loop-local host lease is ready; -2. reads its current layout bounds and inherited viewport; -3. computes visibility and the scale-aware geometry revision; -4. attaches the presenter if needed; -5. synchronizes geometry only if it changed; and -6. requests a follow-up redraw only when the presenter reports pending host - work. - -Its normal `draw` implementation does not draw video. It may draw a fallback -poster, loading state, or black rectangle before native presentation attaches. - -When the widget leaves the tree, its state detaches the presenter. Window-close -handling MUST also perform explicit teardown so correctness does not depend -only on drop order. - -### 11.2 Transparency and layers - -In an integrated player, the Iced surface over the video region must preserve -alpha. Normal controls are rendered as SDR UI over a separately managed video -surface. This is intentional: - -- mpv owns video color conversion and HDR description; -- the compositor/window system can blend SDR UI and HDR video as separate - surfaces; and -- Iced does not need to select a 10-bit surface merely because video is HDR. - -The implementation MUST verify that the Iced window does not advertise an -opaque region over transparent video pixels. - -### 11.3 Iced upstream policy - -The first implementation requires no new Iced API on the pinned fork: - -- `Window` exposes raw window and display handle traits; -- `window::run` supports event-loop-local native-window setup callbacks; -- custom-widget `Tree::State` and redraw events provide slot lifecycle and - geometry; and -- custom wgpu primitives remain available for a future render presenter. - -Ferrex MUST NOT upstream its current platform-specific Wayland hook. If -prototype experience demonstrates a generic missing facility, the smallest -candidate is pass-through support for a foreign parent window in -`window::Settings`, corresponding to functionality already modeled by winit. -The proposal must use non-media examples such as webviews, terminals, and -camera surfaces, and must be discussed with Iced maintainers before a PR. - -A one-shot generic pre-present action may be considered only if the Wayland -prototype proves synchronous staging during redraw is insufficient. Persistent -callback registries are out of scope. - -## 12. Platform Presentation - -### 12.1 Wayland - -#### Current HYBRID decision - -D-022 records HYBRID as the current Wayland release architecture. Integrated -playback uses the proven GStreamer/Subwave surface path; an mpv selection uses -mpv's ordinary native window. The bridge design below is retained as normative -re-entry criteria, not an active release commitment. Reopening it requires a -new decision backed by a safe per-session connection bootstrap or an explicit -amendment for another maintainable topology. - -This Wayland-only decision does not alter the Windows or macOS integrated mpv -presenter requirements in sections 12.2 and 12.4. - -#### Target ownership for bridge re-entry - -Iced owns the real `xdg_toplevel`, decorations, input, application identity, -and fullscreen state. mpv owns a native Wayland video surface that becomes a -desynchronized subsurface below the transparent Iced surface. - -This direction preserves Iced's existing input and application window while -allowing mpv to retain its normal Wayland VO, Vulkan/EGL WSI, dmabuf, -hardware-decoding, and color-management behavior. - -#### Required bridge behavior - -mpv does not support `wid` on Wayland. The presenter therefore uses a private -in-process protocol bridge inspired by Jellyfin Desktop's `wl-proxy` work. The -bridge MUST: - -1. expose a private Wayland socket used only by mpv's VO; -2. forward ordinary requests, events, and file descriptors; -3. map mpv-created upstream objects onto the same upstream Wayland connection - as the Iced parent; -4. capture the first relevant mpv `wl_surface`; -5. suppress forwarding of its `xdg_surface` and `xdg_toplevel` role requests; -6. assign the upstream surface a `wl_subsurface` role under Iced's parent; -7. use desynchronized child commits for independent video cadence; -8. synthesize `xdg_surface.configure` and `xdg_toplevel.configure` events from - the surface slot's logical size and state; -9. translate mpv fullscreen, minimize, close, move, and resize requests into - host actions where meaningful; -10. keep native pointer, keyboard, and touch ownership with Iced in integrated - mode; -11. forward output, fractional-scale, viewporter, presentation-time, tearing, - content-type, idle-inhibit, dmabuf, explicit-sync, and color-management - behavior needed by the selected VO; and -12. destroy the subsurface role before the Iced parent is destroyed. - -The bridge MUST NOT create its upstream child on an unrelated Wayland -connection. Wayland forbids constructing a subsurface relationship across -clients. `xdg-foreign` supplies relationship metadata, not reparenting, and is -not a substitute. - -The bridge MUST NOT create a second viewport or color-management role on mpv's -surface when mpv already owns one. It virtualizes shell ownership while leaving -video-specific surface extensions with mpv. - -#### Configure and geometry - -The slot sends logical size, clipping visibility, scale, and host state to the -bridge. Subsurface position changes are staged before the next Iced parent -commit. mpv receives a configure matching the logical video extent and remains -responsible for buffer scale, viewport destination, and render size. - -A geometry command must have a defined acknowledgment/order boundary before the -host presents. If current Iced redraw ordering is sufficient, no framework -change is made. If not, the generic Iced discussion described in section 11.3 -is required before adding hooks. - -#### Connection bootstrap risk - -Stable libmpv does not accept an application-provided Wayland display for its -normal VO. W0 also observed delayed libmpv/libplacebo/driver connection -activity, so a temporary `WAYLAND_DISPLAY`/`WAYLAND_SOCKET` override cannot -safely direct only mpv to the bridge. A process-lifetime startup proxy avoids -the race only by proxying Iced too, which violates the current private mpv-only -boundary and greatly expands ownership risk. Symbol interposition and scoped -environment overrides are rejected. This blocker is the basis for D-022 and -must be resolved by a new architecture decision before bridge work resumes. - -#### Wayland backend and fallback - -Under D-022: - -1. Auto/integrated playback uses the proven GStreamer Wayland path; -2. an explicit mpv selection uses ordinary mpv native-window mode; and -3. failure reaches the other policy-approved backend or an explicit playback - error. - -Integrated mpv is reported as unavailable with the connection-bootstrap reason. -Ferrex MUST NOT silently choose appsink/software presentation for HDR content. - -### 12.2 Windows - -The preferred full-player arrangement is: - -- mpv owns its normal top-level HWND and gpu-next/D3D presentation; -- a transparent undecorated Iced playback window is attached as an owned or - child overlay above the mpv content area; -- only one taskbar entry and one apparent player window are exposed; and -- Iced owns integrated input while fullscreen/window state is delegated through - the presenter to the mpv root. - -The presenter synchronizes content rectangle, DPI, visibility, z-order, focus, -minimize, and teardown. The overlay is created hidden, attached using raw -window handles, then shown to prevent startup flicker. - -For an inline surface with no overlapping Iced controls, a host child HWND may -be passed as mpv's `wid`; mpv creates its own child and fills the host. This is -an alternate capability, not the required full-player arrangement. - -Native-window mode leaves mpv's HWND independent and may enable mpv's OSC. - -### 12.3 X11 - -mpv 0.41 compiles its X11 VO and `wid` support only when Meson's `gpl` option -is enabled. D-005 requires Ferrex release artifacts to link the reviewed -LGPL-only libmpv profile, so those paths are not present in the bundled -library. D-023 therefore makes X11 a licensing-gated hybrid: integrated -playback remains on GStreamer, and the separately launched external mpv action -may remain available as an explicit process boundary. In-process mpv MUST be -reported as unavailable rather than failing into a headless or CPU-copy VO. - -If a future mpv release provides X11 native VO under a compatible profile, or -Ferrex adopts a different reviewed distribution policy, the preferred re-entry -arrangement mirrors Windows: - -- mpv owns its normal X11 window; -- an ARGB Iced overlay is attached/stacked above it; -- geometry, focus, and visibility follow the mpv root; and -- Iced owns integrated input. - -A compositing manager is required for that overlay. Non-composited X11 would -use a proven inline `wid` host with non-overlapping controls or a normal -native-window fallback, but neither path is claimed for the current LGPL-only -bundle. - -### 12.4 macOS - -The modern mpv Cocoa/Swift path owns its native `NSWindow` and video layer. The -presenter obtains the native mpv window when available and attaches the -transparent Iced controls `NSView` inside mpv's content hierarchy. The Iced -staging window remains unordered and is never a visible controls overlay. The -presenter synchronizes backing scale, content bounds, focus, -Spaces/fullscreen transitions, occlusion, and teardown on the AppKit main -thread. - -The pinned winit 0.30.13 implementation is not natively reparent-safe: upstream -recovers its renderer view by casting the staging window's current content -view and resolves host-sensitive state through that staging window. Ferrex -therefore carries a narrow AppKit compatibility patch that retains the exact -`WinitView`, preserves its original logical `WindowId`, follows `[view window]` -for metrics/input state, observes an external root without replacing mpv's -delegate, suppresses donor lifecycle/fullscreen mutations while hosted, and -removes those observations before detach or close. This is a generic -foreign-view correction, not an mpv-specific Iced API. Native Apple -Silicon/Intel evidence remains mandatory before the patch is production -qualified or proposed upstream. - -Ferrex MUST NOT make macOS depend on `wid`. Although generic libmpv header text -still mentions macOS, current mpv source does not consume `WinID` in the modern -macOS window backend. Native-root composition and ordinary native-window mode -are the supported strategies. - -In-root view behavior across native fullscreen and Spaces must be proven in the -platform spike. If the foreign-view relationship cannot be made reliable, -normal mpv native-window mode remains the release fallback; an OpenGL render -path is not promoted merely to emulate embedding. - -## 13. Input, Focus, and Window Ownership - -Integrated mode routes keyboard, pointer, touch, and controller gestures -through Iced. Player actions produce `PlaybackCommand`s or raw mpv input -commands. Ferrex remains responsible for its current shortcuts and controls. - -On macOS native-root playback, a background press is routed through the -canonical player message path to `performWindowDragWithEvent:` on mpv's root -window. Actual control and menu surfaces consume the press first; titlebar and -resize-frame behavior remains AppKit-owned. The hidden staging window MUST NOT -be the target of an Iced window-drag, focus, mode, minimize, maximize, or -fullscreen action. - -The integration SHOULD expose a mapping layer for mpv key names so scripts and -bindings can be invoked intentionally. It does not need to forward every host -input event by default. - -Window ownership differs by presenter: - -- Wayland: Iced owns top-level state and sends synthetic state/configures to - mpv. -- Windows and macOS native-root mode: mpv owns top-level state and the Iced - overlay follows it. -- X11 remains GStreamer-integrated under D-023; the native-root rule is a - re-entry requirement for a future compatible libmpv profile. - -The application-level fullscreen command goes through the presenter and is -updated from the resulting native state. `PlayerDomainState` MUST not toggle an -optimistic fullscreen boolean without confirmation. - -When the overlay is hidden, input policy must be explicit. Ferrex may keep a -transparent input target to reveal controls, or temporarily return input to -mpv and use mpv input bindings to reveal the overlay. The selected policy must -be tested for focus and power impact on each platform. - -## 14. Color, HDR, Subtitles, and Frame Pacing - -mpv owns: - -- source color interpretation; -- hardware-decoder image import; -- scaling, tone mapping, dithering, and user shaders; -- native swapchain/surface format selection; -- native color-space and HDR metadata signaling; -- ASS, text, bitmap/PGS, and external subtitle rendering; and -- display synchronization and frame scheduling. - -Iced owns SDR UI and lets the compositor/window system combine surfaces. -Ferrex MUST not infer HDR solely from filenames or force an HDR output profile -without mpv/native output evidence. - -Capabilities and diagnostics should surface at least: - -- `video-params` and `video-out-params`; -- `hwdec-current`; -- selected VO and graphics context; -- detected output color characteristics where mpv exposes them; and -- presenter/compositor color-management support. - -"Zero-copy" is reported only as an observed diagnostic with evidence; it is not -a universal capability promise. Some decode formats and driver paths may -legitimately copy while still using native presentation. - -## 15. Backend Selection and Fallback - -Ferrex exposes conceptual backend choices: - -- **Auto:** release-policy default with capability-based fallback. -- **mpv integrated:** require the native presenter; report a clear error or - policy-approved fallback if unavailable. -- **mpv native window:** use ordinary mpv presentation and native OSC/input as - configured. -- **GStreamer:** use the existing Subwave adapter during migration. -- **External mpv:** optional process-isolated fallback while it remains - maintained. - -Under D-022, Auto continues selecting integrated GStreamer on Wayland while -Windows and macOS proceed through independent gates toward integrated mpv. An -explicit Wayland mpv selection uses native-window presentation. Moving Wayland -Auto to integrated mpv requires a new GO decision and all deferred Wayland exit -criteria in the tracking plan. - -Fallback selection MUST consider content requirements. For example, an -integrated presenter failure during HDR playback should prefer mpv -native-window mode over an SDR appsink path. The user-facing diagnostics must -state the selected backend and reason. - -## 16. mpv Configuration and Compatibility Policy - -Ferrex supplies a deterministic built-in mpv profile and permits supported -user overrides. - -Default policy: - -- prefer `vo=gpu-next` where included, while retaining a tested fallback list; -- begin with a conservative hardware-decoding policy and expose user override; -- do not load arbitrary user config or scripts unless the user enables it; -- do not disable mpv capabilities merely because Ferrex lacks a typed UI for - them; -- pass authentication as headers/cookies where possible instead of embedding - secrets in URLs; and -- redact URLs, headers, cookies, and tokens in logs and error reports. - -Release builds SHOULD bundle libmpv, FFmpeg, libplacebo, and required runtime -assets at known versions. Runtime diagnostics include mpv, client API, FFmpeg, -and libplacebo versions. - -The minimum supported libmpv version and exact linking strategy are finalized -by the dependency/packaging spike. The wrapper must fail gracefully with a -clear capability result when a system libmpv is missing or incompatible in a -development configuration. - -## 17. Packaging and Platform Integration - -The migration includes, not postpones, release packaging: - -- Nix development and NixOS package inputs; -- Linux dynamic-library lookup and RPATH policy; -- Flatpak modules, permissions, GPU, audio, and Wayland socket behavior; -- Windows DLL discovery and bundling; -- macOS dylib/framework bundling, signing, and AppKit main-thread requirements; -- license inventory for the exact mpv/FFmpeg build options; and -- CI build coverage for every supported target. - -A development machine finding a system libmpv is insufficient evidence of a -shippable backend. - -## 18. Security - -libmpv and optional user scripts operate inside the Ferrex process. Therefore: - -- user config and scripts are opt-in and clearly described as trusted code; -- untrusted remote media does not control arbitrary mpv command execution; -- stream credentials are not placed in process arguments when avoidable; -- command and property names originating outside trusted Ferrex code are - validated against their intended use; -- URL/header logging uses the existing application redaction policy or adds one - before mpv rollout; and -- external tools such as `yt-dlp` are disabled by default unless explicitly - packaged and enabled. - -## 19. Observability - -A diagnostic snapshot MUST include: - -- requested and selected backend/presentation mode; -- every fallback decision and reason; -- libmpv and native presenter lifecycle state; -- VO, GPU API/context, adapter, and hardware decoder when exposed; -- current video and output color parameters; -- current surface logical/physical size and scale; -- dropped/delayed frame statistics exposed by mpv; -- Wayland bridge protocol/capability summary without sensitive object data; and -- the last structured playback/presenter error. - -Normal logs should remain concise. Protocol tracing and verbose mpv logs are -opt-in diagnostics. - -## 20. Verification and Acceptance Criteria - -### 20.1 Automated coverage - -The implementation requires tests for: - -- command serialization and async reply correlation; -- property node conversion and event payload ownership; -- snapshot reduction under reordered or repeated property events; -- track identity and selection across reloads; -- lifecycle generation rejection of stale events; -- fallback policy decisions; -- final progress persistence on stop, EOF, error, and presenter failure; -- widget geometry, clipping, visibility, and drop behavior with a fake - presenter; -- platform capability parsing where testable without a display; and -- repeated session creation/termination without leaked callbacks. - -### 20.2 Platform matrix - -Manual/integration coverage includes: - -- Wayland: Hyprland/wlroots, KDE, and GNOME where available; -- Linux GPUs: Intel, AMD, and NVIDIA proprietary where available; -- X11 with and without a compositing manager; -- supported Windows versions with SDR and HDR displays; -- macOS Intel and Apple Silicon where supported; and -- fractional scaling and moving between displays with different scales. - -### 20.3 Media matrix - -At minimum: - -- H.264, HEVC, VP9, and AV1; -- 8-bit SDR, 10-bit SDR, HDR10/PQ, and HLG where test hardware permits; -- representative software- and hardware-decoding paths; -- ASS with fonts/animation, SRT/WebVTT, PGS/DVD bitmap subtitles, and external - subtitles; -- multiple audio/subtitle tracks, chapters, editions, and attachments; -- local files and authenticated HTTP range playback; and -- direct play plus Ferrex server transcoding output. - -### 20.4 Native presentation gates - -Before mpv becomes the default on a platform: - -- play/pause/seek/track controls and progress reporting are feature-complete; -- resize, DPI change, minimize, hide/show, fullscreen, suspend/resume, and close - are stable; -- no decoded frame enters the CPU/wgpu path in native-VO mode; -- expected hardware decoding is demonstrated through mpv diagnostics; -- HDR output and metadata are manually validated on supported hardware; -- Iced controls render and receive input without corrupting video color; -- 100 repeated load/stop/window cycles complete without native resource growth - or crashes; -- presenter failure reaches a documented fallback; and -- release packaging installs and starts without developer-only paths. - -### 20.5 Wayland-specific re-entry gate - -D-022 defers this gate while HYBRID is active. Any future integrated Wayland -mpv proposal must satisfy, in addition to the above: - -- the bridge proves parent and child are on the same upstream connection; -- no real `xdg_toplevel` role is assigned to mpv's video surface; -- configure/ack behavior remains valid across resize and fullscreen; -- fractional-scale, viewporter, dmabuf, explicit synchronization, presentation, - and color-management traffic required by mpv is preserved; -- Iced retains input and no duplicate seat consumes events; -- teardown never outlives or destroys the Iced display; and -- failure is clean on compositors missing optional protocols. - -## 21. Migration and Removal Policy - -Migration is adapter-first: - -1. Introduce Ferrex-owned playback commands, events, snapshots, and track - models. -2. Adapt the current Subwave/GStreamer path without changing behavior. -3. Add libmpv control and native-window mode. -4. Add Windows and macOS integrated presenters; retain Wayland HYBRID unless a - new bridge decision passes its re-entry gate. -5. Run both backends behind an opt-in selector and collect diagnostics. -6. Change per-platform Auto defaults only after that platform passes its gate. -7. Remove code only after at least one release retains a tested rollback. - -GStreamer may remain after mpv becomes default when it provides a documented -capability not yet replaced. It is removed from desktop playback only when: - -- no supported platform selects it in Auto; -- release and CI packaging no longer require it for playback; -- rollback data shows the mpv path is stable; and -- server/media-pipeline uses are confirmed independent. - -The current external mpv path is removed or demoted only after in-process -native-window mode provides equivalent fallback and progress behavior. - -## 22. Open Decisions and Required Spikes - -The following are implementation decisions, not reasons to weaken the target -architecture: - -1. Which Rust FFI foundation best exposes full libmpv while allowing a safe - Ferrex wrapper? -2. What minimum bundled mpv version and build options are required on each - platform? -3. Should development builds support runtime dynamic loading in addition to - release bundling? -4. What future libmpv/upstream or otherwise maintainable mechanism can direct - only mpv's Wayland connections to a private bridge without a process-global - race? This is deferred under D-022. -5. If D-022 is reopened, can the bridge safely multiplex onto winit's existing - display connection with independent event queues across all target - compositors? -6. Is staging Wayland subsurface state during redraw sufficient, or is a - generic one-shot pre-present facility demonstrably necessary? -7. Which transparent overlay relationship is most reliable for Windows and - X11 while preserving one taskbar/window identity? -8. Does an Iced child NSWindow survive macOS native fullscreen and Spaces - transitions reliably, or is a lower-level NSView target required? -9. What input policy best permits controls to appear when the transparent - overlay is otherwise hidden? -10. Which mpv configuration and scripts are enabled by default without making - behavior depend on a user's standalone mpv installation? - -Each spike must produce a short decision record in the tracking plan before the -related production phase begins. - -## 23. References - -- mpv client API: -- mpv render API: -- mpv Vulkan render request: -- mpv Vulkan/dmabuf request: -- mpv gpu-next render draft: -- mpv libplacebo render RFC: -- Jellyfin Desktop native-VO precedent: -- Switchfin direct-render precedent: -- Iced contribution guidance: +`ferrex-player-playback` owns the backend-neutral contract and lifecycle +reduction. Backend adapters translate that contract to GStreamer, in-process +mpv, or the external process boundary. Views read a `PlaybackSnapshot`; they do +not poll libmpv or GStreamer directly. + +`ferrex-player-mpv` owns the libmpv handle, command serialization, property +observation, event copying, compatibility checks, and log redaction. Native +callbacks only wake the owner. They do not call back into libmpv or mutate UI +state. + +The platform presenter is independent of the libmpv control owner. It keeps +window-system objects on the UI thread, joins the Iced controls host to mpv's +native root, and reports presentation changes through copied Ferrex events. + +## Lifecycle invariants + +- Host readiness and mpv-window readiness may arrive in either order. +- Every attachment belongs to a generation; stale events cannot attach an old + native surface to a replacement session. +- Geometry is synchronized on host revisions, not decoded-frame cadence. +- Zero-sized, clipped, hidden, or suspended hosts do not require destruction of + the playback core. +- The presenter detaches before its Iced host or mpv-owned native objects are + released. +- Final progress and terminal state are captured before the session is + destroyed. +- The retained application shell, navigation, and replacement playback wait + for positive native teardown completion. +- A teardown failure prevents another native presenter from starting in the + same process. + +These rules keep native presentation independent of Iced redraw timing and +prevent overlapping AppKit, Win32, or libmpv ownership during replacement. + +## Platform behavior + +| Platform | Integrated path | Explicit mpv path | Current decision | +|---|---|---|---| +| Wayland | GStreamer subsurface | mpv native window | Hybrid; no private in-process Wayland bridge | +| X11 | GStreamer | external mpv | Hybrid under the LGPL libmpv profile | +| Windows | GStreamer remains available | native mpv root with Iced controls presenter, then native-window fallback | Presenter remains explicitly gated | +| macOS on Apple Silicon | in-root AppKit mpv presenter | mpv native-window fallback | Default golden path; core path functionally validated | + +Intel/x86_64 Macs are legacy and outside the supported validation matrix. + +### Wayland + +Ferrex does not proxy mpv's Wayland objects into Iced. Stable libmpv has no +per-session mechanism for directing only its delayed VO connections to a +private display, and process-wide environment changes would be racy. The +integrated path therefore remains the existing GStreamer subsurface. Selecting +mpv uses its ordinary native window. + +### X11 + +The reviewed LGPL mpv 0.41 profile excludes the X11 VO and `wid`. Ferrex reports +in-process X11 mpv as unavailable instead of allowing a headless or CPU-copy +fallback. Integrated GStreamer and the external mpv process remain available. + +### Windows + +mpv owns the video HWND. A taskbar-suppressed Iced controls host follows its +content geometry, DPI, visibility, focus, minimize, fullscreen, and teardown. +Failure to attach dismisses the controls host and leaves mpv native-window +playback available. + +### macOS + +mpv owns the sole visible `NSWindow`. Ferrex reparents the Iced renderer +`NSView` into that window's content hierarchy; the Iced staging window remains +unordered and is never presented as an overlay. AppKit access stays on the main +thread, and blocking libmpv shutdown begins only after the view is detached. + +The presenter derives backing scale, focus, cursor, IME, visibility, +fullscreen, Spaces, and close events from local content bounds and the actual +host window. Ferrex does not use macOS `wid` and does not create, position, +focus, or continuously monitor a second controls window. + +This required a temporary macOS-only winit 0.30.13 compatibility patch. The +patch retains the exact renderer view independently of its donor window, +preserves its logical `WindowId`, follows the current host for window-sensitive +state, suppresses donor lifecycle mutations while foreign-hosted, and removes +external-root observers before detach or close. Although platform-scoped, it +spans substantial view, event, focus, IME, notification, scaling, and teardown +semantics. The correction is generic to foreign AppKit view hosting and +contains no mpv- or window-manager-specific policy. + +Native Apple Silicon validation confirmed normal `Auto` playback as one +mpv-owned window with working in-root Ferrex controls. It does not qualify +HDR/EDR output, a particular VideoToolbox decode path, or the remaining polish +items recorded in the +[desktop backend guide](../src/content/docs/developer/desktop-playback-backends.md#current-macos-follow-ups). + +## Winit fork exit + +The vendored winit patch is temporary. Ferrex will upstream or delete it rather +than maintain a permanent fork. + +The preferred exit is a generic upstream change that safely retains the actual +`WinitView`, defines window-sensitive behavior while that view is hosted by a +different `NSWindow`, and adds non-media regression coverage for embedded views. +Once a released winit version containing that support is consumed by Iced, +Ferrex removes the `[patch.crates-io]` override and the vendored tree in the same +upgrade. + +The next winit/Iced upgrade may not rebase or expand this fork. It must either +consume released upstream support and remove the override and vendored tree, +or select the contingency: a Ferrex-owned macOS Iced host built from +`iced_runtime` and `iced_wgpu`, with AppKit input and rendering implemented +inside the platform adapter beneath mpv's root window. + +Removing the vendored fork is an acceptance criterion for completing the macOS +integration. Intel macOS is not an additional prerequisite. + +## Backend selection and fallback + +Ferrex distinguishes these choices: + +- **Auto:** integrated mpv on macOS; the existing platform policy elsewhere. +- **mpv integrated:** require the platform presenter. +- **mpv native window:** use mpv's normal top-level window. +- **GStreamer:** use the existing adapter where supported; unavailable on macOS. +- **External mpv:** use the process-isolated compatibility boundary. + +An integrated-presenter failure is recorded before Ferrex selects the next +policy-approved path. On macOS that path can only be mpv's native window; an +mpv initialization failure ends playback instead of entering GStreamer. +Fallback never silently changes into a decoded-frame upload path to preserve +the appearance of integration. Diagnostics report the requested backend, +selected backend, presentation mode, and fallback reason. + +## Configuration, extensions, and security + +Ferrex starts mpv with a deterministic profile. Standard user configuration, +input bindings, and scripts are disabled unless the user explicitly selects +the trusted-user policy. Invalid policy values fail closed. + +Playback sources carry credentials as typed, redacted headers. In-process +backends receive those headers directly. The legacy external process receives +its source through private IPC rather than the child argument vector. Logs, +errors, diagnostics, and retained state do not expose media URLs, headers, +cookies, local paths, or tokens. + +Ferrex exposes common operations through typed playback commands. Optional mpv +features such as sidecar subtitles, profiles, shaders, and screenshots remain +capability-gated. Unsupported backends return a structured unsupported result +without changing the selected backend. + +## Diagnostics and validation + +Runtime diagnostics distinguish configured policy from observed behavior. They +may include libmpv version, VO, graphics API, hardware decoder, input and output +color parameters, presenter state, scale, geometry, and fallback history. +Ferrex reports HDR or zero-copy only when the native output provides supporting +evidence. + +Automated coverage exercises command and event ownership, compatibility +checks, source redaction, fallback reduction, stale-generation rejection, +geometry and visibility changes, detach ordering, progress persistence, and +repeated session teardown. Platform-specific tests cover the display-free +presenter state machines. + +## References + +- [mpv client API](https://github.com/mpv-player/mpv/blob/master/include/mpv/client.h) +- [mpv render API](https://github.com/mpv-player/mpv/blob/master/include/mpv/render.h) +- [Ferrex winit patch notes](../../third-party/winit-0.30.13-ferrex/FERREX-PATCH.md) diff --git a/docs/src/content/docs/developer/architecture.md b/docs/src/content/docs/developer/architecture.md index 41c40c15..b450f471 100644 --- a/docs/src/content/docs/developer/architecture.md +++ b/docs/src/content/docs/developer/architecture.md @@ -30,7 +30,7 @@ Related docs: - Phase 1 backend intelligence foundation: [Phase 1 intelligence foundation](/developer/intelligence-foundation/) - Player specifics and platform notes: [ferrex-player README](https://github.com/Lowband21/ferrex/blob/dev/crates/ferrex-player/README.md) - Player crate dependency boundaries: [Player dependency boundaries](/developer/player-dependency-boundaries/) -- Native mpv migration: [architecture specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md), [delivery plan](https://github.com/Lowband21/ferrex/blob/dev/docs/plans/native-mpv-playback-migration.md), [current playback baseline](/developer/native-playback-baseline/), [fixture/test matrix](/developer/native-playback-fixtures/), and [Wayland spike record](/developer/native-mpv-wayland-spike/) +- Native mpv design: [Native mpv playback](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md) - Demo mode: [Demo mode](/operator/demo-mode/) - UI testing workflow: [UI testing workflow](/developer/ui-testing-workflow/) @@ -46,29 +46,16 @@ nix develop .#ferrex-player --command cargo test -p ferrex-core --lib ## High‑Level Diagram ```text -+-------------------------------- ferrex-player --------------------------------+ -| Iced app shell, layout, controls, input, and dedicated controls overlay | -| | -| PlaybackCommand -> Ferrex-owned session/reducer -> PlaybackSnapshot | -| | ^ | -| v | PlaybackEvent | -| +---------------------------+------------+------------------+ | -| | | | | -| Subwave/GStreamer adapter in-process libmpv adapter external mpv IPC | -| | | | | -| integrated Wayland/X11 or mpv native VO (`gpu-next`) process-isolated | -| embedded-frame fallback (no frame enters Iced/wgpu) compatibility | -+----------+---------------------------+-------------------------------+----------+ - | | | - +---------------------------+-------------------------------+ - | - authenticated HTTP/range/HLS - | - +-------------v-------------+ - | ferrex-server | - | Axum + Postgres + Redis | - | tickets, media, progress | - +---------------------------+ ++---------------+ HTTP/WS +---------------+ +| Ferrex player | <---------------> | Ferrex server | +| (Iced UI) | | + storage | ++-------+-------+ +---------------+ + | ++-------v-------------------+ +| Backend-neutral playback | ++-------+-----------+-------+ + | | + GStreamer libmpv / external mpv ``` ## Components @@ -91,12 +78,8 @@ nix develop .#ferrex-player --command cargo test -p ferrex-core --lib - App shell: `ferrex-player-app` owns runtime bootstrap/config, state/domain composition, cross-domain routing surfaces, root update/view/subscription wiring, Iced daemon/application construction, presets, and logger/profiling hooks. - UI/presentation: `ferrex-player-ui` owns the Iced views/widgets, Iced task/subscription adapters, design tokens, shader widgets, WGSL assets, and 10-foot surfaces. - Data/API crates: `ferrex-player-auth`, `ferrex-player-repository`, `ferrex-player-library`, `ferrex-player-media`, `ferrex-player-metadata`, `ferrex-player-search`, `ferrex-player-settings`, `ferrex-player-user-admin`, and `ferrex-player-api` own player data-domain behavior and service contracts without pulling in Iced/subwave runtime code; settings only shares `iced_core` color/point DTOs. Async work crosses this boundary through `ferrex-player-foundation` domain tasks or dependency-light streams that the UI crate wraps. -- Playback/video: `ferrex-player-playback` owns Subwave/MPV playback state, controls, subscriptions, and overlay helpers behind explicit ports that `ferrex-player-ui` adapts. Its `contract` module is the migration boundary for Ferrex-owned backend-neutral commands, events, snapshots, track models, and fallback policy. -- Video: backend-neutral control with platform-optimized presentation. - - Wayland HYBRID (D-022): GStreamer/Subwave remains integrated; mpv uses its native window until a safe per-session bridge path exists. - - Windows and macOS: fully integrated native-VO mpv presenters remain active migration targets with independent rollout gates. - - All platforms retain backend-neutral watch status and deterministic fallback. - - Operational selection, diagnostics, and rollback: [Desktop playback backends](/developer/desktop-playback-backends/). +- Playback/video: `ferrex-player-playback` owns backend-neutral state and controls, with adapters for GStreamer, in-process libmpv, and external mpv. +- Platform behavior, selection, diagnostics, and fallback: [Desktop playback backends](/developer/desktop-playback-backends/). - Focus: smooth, low‑latency poster grids and animated navigation. ### Core (`ferrex-core`) @@ -104,9 +87,11 @@ nix develop .#ferrex-player --command cargo test -p ferrex-core --lib - Orchestration runtime primitives (QueueService, EventBus, leases, and backoff). - Long‑term: candidate surface for FFI (Swift/Kotlin) bindings. -### Video Backend (subwave) -- Evolved from `iced_video_player` toward a unified API for platform‑optimized rendering. -- Goals: frame pacing, zero‑copy where possible, and predictable latency under load. +### Playback backends +- Subwave/GStreamer provides integrated, platform-optimized surfaces, including + the Wayland HDR path. +- In-process and external mpv adapters preserve the same player-domain state + while using native video presentation. ### UI Stack (Iced fork) - Tracks upstream Iced with targeted changes: primitive batching and Wayland subsurface support. diff --git a/docs/src/content/docs/developer/desktop-playback-backends.md b/docs/src/content/docs/developer/desktop-playback-backends.md index 9526f7c2..5e23bf47 100644 --- a/docs/src/content/docs/developer/desktop-playback-backends.md +++ b/docs/src/content/docs/developer/desktop-playback-backends.md @@ -1,187 +1,135 @@ --- title: "Desktop playback backends" -description: "Build, select, diagnose, and troubleshoot Ferrex's GStreamer, in-process libmpv, and external mpv desktop playback paths." +description: "Select, diagnose, and troubleshoot Ferrex's GStreamer and mpv desktop playback paths." sidebar: order: 7 --- -Ferrex is migrating desktop playback behind a backend-neutral contract. The -player domain consumes Ferrex-owned commands, events, snapshots, tracks, and -capabilities; concrete GStreamer and libmpv objects stay inside their adapters. -The [architecture specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md) -defines the target, while the -[migration plan](https://github.com/Lowband21/ferrex/blob/dev/docs/plans/native-mpv-playback-migration.md) -records measured rollout gates. +Ferrex keeps playback state and controls independent of the selected engine. +GStreamer, in-process libmpv, and external mpv implement the same player-domain +contract while retaining their own presentation and lifecycle rules. See the +[native mpv design](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md) +for ownership and platform decisions. -## Shipped choices and current defaults +## Available backends -| Choice | Presentation | Current role | +| Choice | Presentation | Role | |---|---|---| -| Auto | Platform release policy | GStreamer remains first during the rollback/soak window. | -| GStreamer | Integrated Subwave surface or embedded fallback | Default migration backend; the integrated HDR path on Wayland and the LGPL-compatible X11 path. | -| mpv integrated | Platform native presenter | Explicit Windows/macOS handoff build; never selected by Auto until that platform's representative-system gate passes. | -| mpv native window | libmpv `gpu-next` native VO | Developer opt-in and compatibility path; no decoded frame enters Iced/wgpu. | -| External mpv | Separate process and private IPC | Explicit crash-isolated/X11 compatibility handoff; never selected by Auto. | +| Auto | Platform policy | Integrated mpv on macOS; existing policy elsewhere. | +| GStreamer | Integrated or embedded Subwave surface | Primary integrated path on Wayland and X11; unavailable on macOS. | +| mpv integrated | Platform-native presenter | Default macOS presenter and explicit Windows presenter. | +| mpv native window | libmpv native VO | In-process compatibility path with no decoded frames in Iced/wgpu. | +| External mpv | Separate process controlled through private IPC | Explicit process-isolated compatibility path. | -Wayland is intentionally HYBRID: integrated playback stays on GStreamer and an -explicit mpv request uses mpv's ordinary native window. Stable libmpv cannot -currently direct only its delayed Wayland connections through a private bridge -without a process-global race. X11 also stays on GStreamer in the reviewed -LGPL-only package because mpv 0.41 gates its X11 VO and `wid` code on the GPL -build profile. Neither policy silently selects a CPU-copy mpv path. +## Build and select mpv -Windows and macOS now have compile-gated Win32/AppKit presenter handoff builds, -with independent package and representative-system gates. Consult the -migration plan rather than assuming that a development machine finding libmpv -proves a release artifact or a production-ready integrated presenter. - -## Build and select the developer backend - -The default feature set remains buildable without linked libmpv. Enable the -in-process backend explicitly: +Normal macOS player builds enable the in-process backend automatically. On +other platforms, enable it explicitly: ```bash cargo run -p ferrex-player --features mpv ``` -Use **Play in MPV** in an mpv-enabled build. On Windows and macOS it requests -the compile-gated integrated presenter and falls back to mpv's native window -with a structured reason if preflight or attachment fails. On Wayland it -requests the ordinary native-window path; the reviewed X11 package retains the -external handoff. If the build omits the mpv feature, the historical action -retains its external-process behavior. Auto is deliberately unchanged during -the staged rollout. +In an mpv-enabled build, **Play in MPV** requests the platform's supported mpv +presentation. If integrated presentation is unavailable, Ferrex records the +reason and uses mpv's native window when policy permits. Without the `mpv` +feature, the action retains its external-process behavior. + +## Platform behavior + +| Platform | Integrated path | Explicit mpv behavior | +|---|---|---| +| Wayland | GStreamer subsurface | Ordinary mpv native window. | +| X11 | GStreamer | External mpv under the reviewed LGPL profile. | +| Windows | GStreamer remains available | Explicit native presenter with native-window fallback. | +| macOS on Apple Silicon | In-root AppKit mpv presenter | Default; functional one-window path validated, with follow-ups below. | + +Intel/x86_64 Macs are legacy and outside the supported validation matrix. -The exact spike build commands and hardware checklist are in the -[native playback fixture handoff](/developer/native-playback-fixtures/#windows-and-macos-integrated-presenter-handoff/). +## Current macOS follow-ups -Release packages must carry the pinned, LGPL-only libmpv/FFmpeg/libplacebo -closure. A system libmpv found through a developer package manager is not -release evidence. The Flatpak and Nix profiles additionally reject mpv's GPL -option and record their resolved feature/license profiles. +Native Apple Silicon validation confirmed normal `Auto` playback as one +mpv-owned window with working in-root Ferrex controls. Remaining polish: -## Deterministic fallback order +- window size and fullscreen state do not always remain consistent across + transitions; and +- automatic OSD reveal can wait for the next pointer click. -Every requested and selected target is recorded separately. During migration, -Auto tries integrated then embedded GStreamer. An unavailable exact request can -follow the policy-approved chain through mpv native-window, integrated or -embedded GStreamer, and finally the explicit external target. Duplicate targets -are removed, and the first rejection becomes the machine-readable fallback -reason. +HDR/EDR output, a particular VideoToolbox decode path, and extended stress +qualification remain unclaimed. -HDR-required selection rejects candidates that cannot preserve native HDR -signaling. A presenter failure should therefore prefer mpv native-window over -an SDR frame-upload path. Initialization/load failures checkpoint the last -observed position before returning to Auto/GStreamer. +## Fallback -Common reason codes include `backend_disabled`, `unsupported_platform`, -`requested_unavailable`, `missing_capability`, `presenter_failed`, and -`backend_failure`. A fallback is not inferred from a missing frame timer. +Ferrex records requested and selected backends separately. An integrated mpv +request may fall back to mpv native-window playback, then another +policy-approved backend where the platform allows one. On macOS, native-window +mpv is the end of the fallback chain; GStreamer is never selected. Duplicate +targets are removed from the chain, and the first rejection is retained as the +visible reason. + +Fallback does not silently choose a decoded-frame upload path to preserve the +appearance of integration. Playback position is checkpointed before returning +to another backend after an initialization or load failure. ## Configuration and trusted code -Ferrex's default libmpv profile disables standard user config, script -discovery, and external URL resolvers. Native-window compatibility enables -controlled OSC/input bindings; integrated presentation disables them because -Iced owns input. Trusted local development can opt into normal mpv config, -`input.conf`, and scripts: +Ferrex's deterministic libmpv profile disables standard user config, script +discovery, and external URL resolvers. Native-window mode enables only the +controlled input behavior required for that presentation. Trusted local use +can opt into normal mpv config, `input.conf`, and scripts: ```bash FERREX_MPV_CONFIG_POLICY=trusted-user \ cargo run -p ferrex-player --features mpv ``` -Those files execute inside the Ferrex process. Invalid or non-Unicode policy -values fail closed to `deterministic` and are not repeated in logs. +Those files execute inside the Ferrex process. Invalid policy values fail +closed to the deterministic profile and are not repeated in logs. `FERREX_MPV_PATH` applies only to discovery of the separately launched mpv -executable, primarily on Windows. It does not replace or locate the linked -libmpv used by the in-process feature. +executable. It does not locate or replace the libmpv linked by the in-process +feature. ## Safe diagnostics -Open the in-player settings panel during playback to see the requested and -selected backend, presentation mode, integrated-presenter state, fallback -reason, input HDR evidence, native-output HDR evidence, configured decoder -policy, and observed hardware decoder. These labels deliberately do not infer -HDR, hardware decoding, or zero-copy from the backend name. - -The serializable diagnostic snapshot also includes client/runtime versions, -VO/GPU context and adapter, color parameters, frame timing counters, presenter -geometry and scale, capability flags, and the ordered fallback chain. It never -contains the playback URI, headers, cookies, user config contents, shader -paths, screenshot paths, or copied log messages. +The in-player settings panel reports the requested and selected backend, +presentation mode, presenter state, fallback reason, and observed video output +information. HDR, hardware decoding, and zero-copy are reported only when +runtime evidence supports them. -For a fixed native-message filter, set `FERREX_MPV_LOG_LEVEL` to `none`, -`fatal`, `error`, `warn`, `info`, `verbose`, `debug`, or `trace`, and permit the -same Ferrex target with `RUST_LOG`: +Set the native mpv log filter with `FERREX_MPV_LOG_LEVEL` and enable the Ferrex +targets with `RUST_LOG`: ```bash -FERREX_MPV_LOG_LEVEL=trace \ - RUST_LOG=ferrex_player_playback=trace,ferrex_player_mpv=trace \ +FERREX_MPV_LOG_LEVEL=debug \ + RUST_LOG=ferrex_player_playback=debug,ferrex_player_mpv=debug \ cargo run -p ferrex-player --features mpv ``` -Without the override, Ferrex captures bounded verbose startup evidence and -returns to informational native messages after file initialization. Invalid -values fail closed without being echoed. Credential and active-source -redaction still applies, but diagnostic logs can reveal local filenames, -hardware names, or private network topology; review them before sharing. - -For deeper, redacted protocol evidence, use the documented -[fixture matrix](/developer/native-playback-fixtures/) and -[Wayland trace harness](/developer/native-mpv-wayland-spike/). Store generated -media and results only below the ignored `target/` locations described there. +Supported native levels are `none`, `fatal`, `error`, `warn`, `info`, +`verbose`, `debug`, and `trace`. Invalid values fail closed without being +echoed. Credentials and active media sources are redacted, but logs may still +identify local hardware or network topology; review them before sharing. ## Troubleshooting ### The in-process choice falls back immediately -Check that the player was built with `--features mpv`, then inspect the visible -fallback reason. `backend_disabled` means the feature is absent. -`unsupported_platform` on packaged X11 is expected under the LGPL profile. -`backend_failure` or `backend_initialization` usually indicates an incompatible -or missing runtime library; compare the reported client API with the minimum -API 2.2 requirement. - -### mpv opens but the video is not integrated +Outside macOS, confirm that the player was built with `--features mpv`, then +inspect the visible fallback reason. A disabled backend means the feature is +absent. An initialization failure usually means the linked runtime is missing +or incompatible. In-process mpv being unavailable on X11 is expected under +the LGPL profile. -Native-window presentation is the selected compatibility mode, not a hidden -render failure. Check the presenter build gate and structured fallback reason. -An integrated request selects the normal mpv window when preflight or -attachment fails; it must not leave the hidden controls host behind. Passing -one development run still does not approve the lifecycle, input, HDR, stress, -packaging, or Auto gates. +### mpv opens in its own window -### Playback works in development but not in a package - -Inspect the package's loader closure and license/build-profile record. It must -not depend on a Nix store or local package manager path. Flatpak libraries must -resolve from `/app`; Windows and macOS packages must carry their reviewed DLL -or dylib closure. Do not work around a missing package closure by silently -switching to a software/headless VO. +Native-window playback is the compatibility mode, not a hidden render failure. +Inspect the presenter state and fallback reason. An attachment failure must +dismiss the controls host before selecting the normal mpv window. ### Authenticated media fails -In-process backends receive a credential-free URL and playback-scoped bearer -header. Avoid putting a ticket in command arguments or logs. Run the -[playback authentication regression](/reference/qa/playback-auth-regression/) -to distinguish ticket issuance, HTTP range/HLS propagation, and decoder -failures. The external compatibility process receives its temporary query URL -over private IPC rather than argv. - -### Controls or episode transitions behave differently - -Capture the requested/selected targets and fallback chain, then reproduce with -the generated multitrack fixture. Progress, EOF/error handling, next/previous, -Back/Home, track selection, and resume are backend-neutral policy. A difference -between backends is a regression unless diagnostics explicitly report an -unsupported capability. - -## Rollback - -Auto remains the one-step rollback to the current GStreamer policy during the -migration. The external mpv action remains explicit and process-isolated. Do -not remove GStreamer, external mpv, or the pinned Iced/Subwave capability until -the platform gate, release artifact, fallback, soak period, and at least one -rollback release have been verified and recorded. +In-process backends receive a credential-free URL and playback-scoped header. +The external process receives its source through private IPC rather than its +argument vector. Inspect the server response, redacted source diagnostics, and +decoder error separately when narrowing a failure. diff --git a/docs/src/content/docs/developer/index.mdx b/docs/src/content/docs/developer/index.mdx index 2d8df991..7dd782b6 100644 --- a/docs/src/content/docs/developer/index.mdx +++ b/docs/src/content/docs/developer/index.mdx @@ -13,6 +13,7 @@ The developer guide explains how to change Ferrex with confidence. It connects a | Page | Use it for | | --- | --- | | [Architecture](/developer/architecture/) | Workspace layout, server/player/core responsibilities, data flow, and security/deployment notes. | +| [Desktop playback backends](/developer/desktop-playback-backends/) | Platform selection, mpv configuration, diagnostics, fallback, and current limitations. | | [Phase 1 intelligence foundation](/developer/intelligence-foundation/) | Backend-only intelligence read models, bounded DTO contracts, and audit storage for future LLM features. | | [Player dependency boundaries](/developer/player-dependency-boundaries/) | Crate layering rules, compatibility shims, and the player boundary guard. | | [UI testing workflow](/developer/ui-testing-workflow/) | Deterministic screenshots, `.ice` replay, visual QA matrices, and renderer troubleshooting. | diff --git a/docs/src/content/docs/developer/native-mpv-extension-api.md b/docs/src/content/docs/developer/native-mpv-extension-api.md deleted file mode 100644 index 7f7eafe1..00000000 --- a/docs/src/content/docs/developer/native-mpv-extension-api.md +++ /dev/null @@ -1,172 +0,0 @@ ---- -title: "Native mpv extension API" -description: "Safe ownership, arbitrary command/property access, and trust boundaries for extending Ferrex's libmpv backend." -sidebar: - order: 11 ---- - -Ferrex keeps ordinary playback behavior behind the backend-neutral contract in -`ferrex-player-playback`. Features that need mpv functionality not yet modeled -there can use the public `ferrex-player-mpv` control-plane crate. This is the -documented developer API for raw commands, node values, properties, -observations, hooks, client messages, and future native events; it is not a -user-facing command console. - -Use a typed `PlaybackCommand`/`PlaybackEvent` when behavior must also work with -Subwave or another backend. Keep an mpv-only operation at this extension -boundary when it is diagnostic, experimental, or inherently mpv-specific. - -## Choose one owner model - -`MpvSession` is a thread-affine, `!Send` owner. It is appropriate when the -platform event loop must create and service libmpv on the current thread. -`MpvWorker` owns the same session on one named thread and serializes requests -through bounded channels. Normal desktop playback uses `MpvWorker`; do not put -an `mpv_handle` in application state or call libmpv concurrently from Iced. - -The wakeup callback only coalesces an atomic signal and unparks the owner. -Drain events on the owner and consume only the owned `MpvEvent` values returned -by Ferrex. Native event pointers are invalidated by the next -`mpv_wait_event` call and must never escape the wrapper. - -## Commands, properties, and observations - -The public owner APIs provide: - -- `command_async` for standard string-vector commands; -- `command_node_async` for arbitrary array/map commands and nested `MpvNode` - values; -- `get_property_async` and `set_property_async` for string, flag, integer, - double, null, byte-array, array, and map values; -- `observe_property` and `unobserve_property` for arbitrary documented or - future properties; -- `add_hook`, `continue_hook`, `set_event_enabled`, and client-message events; -- correlated `MpvRequestId`/`MpvObservationId` values in copied replies; and -- bounded node conversion through `MpvNodeLimits`. - -A minimal owner-thread sketch is: - -```rust -use ferrex_player_mpv::{ - MpvEvent, MpvFormat, MpvFunctionTable, MpvSessionConfig, MpvWorker, - MpvWorkerConfig, -}; - -let mut worker = MpvWorker::spawn( - MpvFunctionTable::linked(), - MpvSessionConfig::native_window(), - MpvWorkerConfig::default(), -)?; -let observation = worker.observe_property("estimated-vf-fps", MpvFormat::Double)?; -let request = worker.command_async(["show-text", "Ferrex diagnostic"])?; - -for event in worker.drain_events() { - match event { - MpvEvent::PropertyChanged(change) if change.id == observation => { - // The value is an owned MpvNode; reduce it into feature-owned state. - } - MpvEvent::AsyncReply(reply) if reply.id == request => { - // Correlate success/failure without relying on event order. - } - _ => {} - } -} - -worker.unobserve_property(observation)?; -worker.shutdown()?; -``` - -Do not submit credentials through commands intended for logs or a process -argument vector. Authenticated media must continue to use the redacted -`PlaybackSource` mapping, which applies headers/cookies as per-file options. -Never log arbitrary command arguments, node values, property values, or copied -client messages without applying the playback redaction policy. - -## Capability-gated playback extensions - -User-facing local extensions use Ferrex-owned commands instead of exposing the -mpv owner through application state: - -- `PlaybackSession::add_external_subtitle` maps a redacted local sidecar path - to `sub-add`, optionally selects it, and lets normal `track-list` reduction - expose the new stable identity and `is_external` flag; -- `PlaybackSession::capture_screenshot` maps to one `screenshot-to-file` - request and requires an explicit destination plus video-only, - video-with-subtitles, or full-window mode; -- `PlaybackSession::set_video_shaders` replaces mpv's ordered `glsl-shaders` - list using argument-separated `change-list` commands; an empty list clears - it; and -- `PlaybackSession::apply_video_profile` maps to `apply-profile` only when the - effective policy enables trusted user configuration. - -Inspect `PlaybackSnapshot::capabilities` before presenting these actions. -Subwave and other unsupported backends return a structured -`UnsupportedOperation` error rather than silently ignoring them. Explicit -shader files and screenshot destinations use `PlaybackFilePath`, whose debug -form is redacted. Diagnostic schema version 6 reports support booleans, the -observed active shader count, and the effective native log filter; it never -reports profile names, local paths, or copied log contents. Command arguments -remain separate libmpv values, and empty, multiline, overlong, or non-Unicode -paths fail before submission. - -On 2026-07-12 the display-backed mpv 0.41 native-VO smokes loaded and selected -an external SRT sidecar, applied a temporary identity shader, confirmed the -observed shader count, wrote a non-empty screenshot, cleared the shader list, -and removed both temporary files. Separate runs covered the generated -multitrack text, animated ASS/attached-font, and PGS fixtures. Normal tests -verify command shapes and redaction without a display. - -## Initialization options and trusted config - -`MpvSessionConfig::with_option` is the pre-initialization option escape hatch. -Options are applied in order before `mpv_initialize`; they are not runtime -properties. Production behavior should start from a Ferrex profile rather than -constructing an unreviewed option list. - -`MpvConfigPolicy::Deterministic` is the default. It disables standard user -config, script discovery, and external URL resolvers. The developer-only -`FERREX_MPV_CONFIG_POLICY=trusted-user` player switch selects -`MpvConfigPolicy::TrustedUser`, which enables standard mpv config, -`input.conf`, and scripts. Those files are trusted code running inside Ferrex. -Invalid policy values fail closed and diagnostics expose only the effective -high-level switches, never config contents or paths. - -## Native logging and protocol traces - -Normal playback briefly requests verbose libmpv messages during file startup -to discover version, VO, GPU, and adapter evidence, then returns to the `info` -filter. For an opt-in diagnostic run, set `FERREX_MPV_LOG_LEVEL` to one of -`none`, `fatal`, `error`, `warn`, `info`, `verbose`, `debug`, or `trace` to keep -that fixed native filter for the session. Use `RUST_LOG` separately to permit -the corresponding Ferrex log target, for example: - -```bash -FERREX_MPV_LOG_LEVEL=trace \ - RUST_LOG=ferrex_player_playback=trace,ferrex_player_mpv=trace \ - cargo run -p ferrex-player --features mpv -``` - -Invalid or non-Unicode values fail closed and are not repeated in logs. -Messages still pass through generic credential redaction and the active -playback-source redactor before reaching the application logger. Review traces -for private filenames or server topology before sharing them. Wayland bridge -protocol capture is currently the redacted opt-in W0 harness documented in the -[Wayland spike](./native-mpv-wayland-spike/); no bridge runtime ships under the -HYBRID decision. - -## Raw native escape hatch - -`MpvSession::with_raw_handle` is the final unsafe boundary for a client API -symbol that the wrapper does not yet represent. Its callback must not retain or -destroy the handle, drain events, replace the wakeup callback, or race the -serialized owner. Prefer extending the fakeable function table and safe wrapper -instead. Any graphics/render-context work also requires an architecture review: -the native-VO migration must not become a private decoded-frame path. - -## Verification expectations - -Add fake-ABI coverage for every new command/property/event shape, including -reply correlation, cancellation, copied lifetimes, and teardown. Tests that -need a real VO belong in the ignored display-backed smoke gate and must use the -schema-generated fixtures. Keep normal unit tests display-free and ensure the -player still compiles without the `mpv` feature. diff --git a/docs/src/content/docs/developer/native-mpv-wayland-spike.md b/docs/src/content/docs/developer/native-mpv-wayland-spike.md deleted file mode 100644 index 66fa6e83..00000000 --- a/docs/src/content/docs/developer/native-mpv-wayland-spike.md +++ /dev/null @@ -1,196 +0,0 @@ ---- -title: "Native mpv Wayland spike" -description: "Pinned protocol-trace fixture, observed mpv protocol inventory, and W0 bridge-boundary findings." -sidebar: - order: 12 ---- - -This page records W0 research for the [native mpv integration specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md) and the resulting D-022 **HYBRID** decision. GStreamer remains the integrated Wayland backend and an mpv selection uses ordinary native-window presentation until a safer connection path exists. W1–W5 are retained as re-entry criteria rather than active implementation work. This Wayland-only decision does not change the plan to deliver fully integrated native-VO mpv presentation on Windows and macOS. - -## Pinned trace fixture - -The spike is pinned to **mpv 0.41.0**. The versioned capture tool refuses a different release so protocol drift is explicit: - -```bash -./scripts/qa/native_playback_fixtures.py verify -./scripts/qa/native_playback_wayland_trace.py \ - --environment-id wl-wlroots-amd \ - --fixture h264-sdr-8bit.mkv - -./scripts/qa/native_playback_wayland_trace.py \ - --environment-id wl-wlroots-amd \ - --fixture hdr10-pq.mkv - -./scripts/qa/native_playback_wayland_trace.py \ - --environment-id wl-wlroots-amd \ - --fixture hlg.mkv -``` - -The tool starts mpv with `--no-config`, `gpu-next`, Vulkan, `waylandvk`, and a private JSON IPC socket. `WAYLAND_DEBUG=client` captures ordinary native-VO traffic while IPC applies these operation markers in order: - -1. initial map and first configured VO; -2. pause, exact seek, and resume; -3. resize; -4. fullscreen enter and exit; -5. stop and replacement-file VO reload; and -6. quit and bounded teardown. - -This is a native mpv baseline, not bridge traffic. It does not transfer a decoded frame into Iced or wgpu. - -Each run is written under `target/native-playback-results///` with mode `0700`. `summary.json` contains the fixture hash, safe mpv diagnostics, operation timing, and protocol inventory. `wayland-client.log` contains the operation-correlated trace. Before retention, the tool replaces workspace/home/runtime paths, window titles, output connector/name/description/make/model strings, and seat names. Both artifacts are mode `0600` and remain ignored. Review traces before sharing them even after automatic redaction. - -The parser distinguishes: - -- **advertised globals**, which describe the compositor rather than mpv usage; -- **bound globals**, including the negotiated version mpv actually requested; and -- **used object interfaces and methods**, including non-global child objects. - -Its display-free unit tests cover parsing, path-segment validation, mpv version parsing, and trace redaction. - -## Initial wlroots/AMD observation - -Three local `wl-wlroots-amd` runs completed against the generated SDR, HDR10/PQ, and HLG fixtures. All selected mpv 0.41.0 `gpu-next`, Vulkan hardware decoding, and the expected input color parameters. Each trace had ten `wl_display.get_registry` requests across mpv/libplacebo/driver activity but exactly one `xdg_surface.get_toplevel` candidate. SDR, PQ, and HLG used the same protocol-interface set; their color-description values differed in the retained trace and mpv diagnostics. - -The exact bound-global set on this environment was: - -```text -ext_data_control_manager_v1 -wl_compositor -wl_data_device_manager -wl_output -wl_seat -wl_shm -wl_subcompositor -wp_color_manager_v1 -wp_commit_timing_manager_v1 -wp_content_type_manager_v1 -wp_cursor_shape_manager_v1 -wp_fifo_manager_v1 -wp_fractional_scale_manager_v1 -wp_linux_drm_syncobj_manager_v1 -wp_presentation -wp_single_pixel_buffer_manager_v1 -wp_tearing_control_manager_v1 -wp_viewporter -xdg_activation_v1 -xdg_wm_base -zwp_idle_inhibit_manager_v1 -zwp_linux_dmabuf_v1 -zwp_tablet_manager_v2 -zwp_text_input_manager_v3 -zxdg_decoration_manager_v1 -``` - -The complete observed object-interface set was: - -```text -ext_data_control_device_v1 -ext_data_control_manager_v1 -ext_data_control_offer_v1 -wl_buffer -wl_callback -wl_compositor -wl_data_device -wl_data_device_manager -wl_data_offer -wl_display -wl_keyboard -wl_output -wl_pointer -wl_region -wl_registry -wl_seat -wl_subcompositor -wl_subsurface -wl_surface -wp_color_management_surface_feedback_v1 -wp_color_management_surface_v1 -wp_color_manager_v1 -wp_commit_timer_v1 -wp_commit_timing_manager_v1 -wp_content_type_manager_v1 -wp_content_type_v1 -wp_cursor_shape_device_v1 -wp_cursor_shape_manager_v1 -wp_fifo_manager_v1 -wp_fifo_v1 -wp_fractional_scale_manager_v1 -wp_fractional_scale_v1 -wp_image_description_creator_params_v1 -wp_image_description_info_v1 -wp_image_description_v1 -wp_linux_drm_syncobj_manager_v1 -wp_linux_drm_syncobj_surface_v1 -wp_linux_drm_syncobj_timeline_v1 -wp_presentation -wp_presentation_feedback -wp_single_pixel_buffer_manager_v1 -wp_tearing_control_manager_v1 -wp_viewport -wp_viewporter -xdg_activation_v1 -xdg_surface -xdg_toplevel -xdg_wm_base -zwp_idle_inhibit_manager_v1 -zwp_idle_inhibitor_v1 -zwp_linux_buffer_params_v1 -zwp_linux_dmabuf_feedback_v1 -zwp_linux_dmabuf_v1 -zwp_tablet_manager_v2 -zwp_tablet_seat_v2 -zwp_text_input_manager_v3 -zwp_text_input_v3 -zxdg_decoration_manager_v1 -zxdg_toplevel_decoration_v1 -``` - -`summary.json` is the source of truth for per-method requests/events and negotiated versions. This inventory is initial evidence only: KDE, GNOME, NVIDIA, EGL fallback, missing optional globals, and later mpv versions can change it. - -## VO surface identification - -Connection order is not a safe identity. The trace contains registry activity from the ordinary Wayland client, Vulkan/libplacebo queries, and driver helper queues. Any future bridge must forward auxiliary connections normally and identify a VO candidate by protocol behavior: - -1. a private downstream client creates a `wl_surface`; -2. that client asks `xdg_wm_base.get_xdg_surface` for the surface; and -3. its `xdg_surface.get_toplevel` request establishes the candidate that would be virtualized. - -The bridge must accept exactly one candidate for the active presenter generation. Zero candidates time out to a structured fallback; a second candidate before explicit VO replacement is an ambiguity failure. Cursor/subsurface creation, registry order, process ID, title, and app ID are not sufficient identities. VO restart first tears down the prior virtual role and advances the generation. - -## `wl-proxy` evaluation - -Research was anchored to: - -- [`mahkoh/wl-proxy` 0.1.3 at `5874a0d`](https://github.com/mahkoh/wl-proxy/commit/5874a0d3d55ad6abfdb53a7e5a635951a9909a86), checked 2026-07-12; and -- [Jellyfin Desktop at `8722abd`](https://github.com/jellyfin/jellyfin-desktop/commit/8722abd2ce0f54e163a75928b72ca79e9b36b550), whose `jfn-wlproxy` wrapper demonstrates mpv shell-role interception. - -The upstream `wl-proxy` **crate** is MIT OR Apache-2.0 despite the repository-level GPL license used by its example applications. It has broad generated protocol coverage, file-descriptor forwarding, object handlers, a private acceptor, and current color-management/color-representation, dmabuf, syncobj, presentation, fractional-scale, viewport, content-type, tearing, and FIFO definitions. These are strong reasons to prefer a pinned upstream spike over writing a raw wire parser. - -It does not attach its upstream endpoint to an already established `wl_display` object namespace. `StateBuilder` opens/owns an upstream socket or file descriptor. Jellyfin directs mpv to the proxy, interposes `wl_display_connect` to capture mpv's foreign display, and creates its browser-overlay Wayland surfaces on that same mpv-owned downstream connection before handing their object IDs back to the proxy. Its existing browser connection is not the parent Ferrex needs. The application wrapper also changes `WAYLAND_DISPLAY` before mpv creation and leaves listener cleanup to process exit. Ferrex cannot copy those lifecycle and process-global assumptions: Iced already owns its renderer connection, input, and window lifecycle; playback sessions must tear down repeatedly; and changing process environment while another thread may connect is forbidden. - -A future re-entry spike may reuse the permissively licensed upstream crate only behind a Linux-only, non-default feature. Ferrex will not copy or fork Jellyfin's GPL application wrapper. A fork is justified only if a small, reviewable upstream-endpoint or teardown change is proven necessary; protocol virtualization remains Ferrex-owned. Pinning and license review are required before adding the dependency. - -## Connection-redirection blocker - -Stable libmpv has no per-context option that supplies a `wl_display` or a private Wayland socket to the native VO. `WAYLAND_DISPLAY` and `WAYLAND_SOCKET` are process-global, while the VO and Vulkan helper connections may be opened after initialization on internal threads. A temporary environment override around `mpv_initialize` is therefore not safe. - -The only candidate currently found that avoids that race is a **startup proxy topology**: - -1. start a private, mode-restricted proxy before Iced opens its first Wayland connection; -2. keep the process Wayland endpoint fixed for the application lifetime; -3. unlike Jellyfin's wrapper, accept Iced and all libmpv/driver helper clients into one shared proxy state and upstream namespace; -4. identify and virtualize only the mpv shell-role candidate by the sequence above; and -5. leave every other Iced/mpv protocol object transparently forwarded. - -This is broader than directing only mpv to a private socket and therefore conflicts with the current boundary; it is not selected. D-008 is deferred and D-022 records HYBRID rather than introducing thread-local environment tricks, symbol interposition, or an unrelated upstream connection. - -## Recorded outcome - -For Wayland releases under D-022: - -- Auto/integrated playback remains on GStreamer/Subwave; -- explicit mpv playback uses the ordinary native window; -- integrated mpv reports the connection-bootstrap limitation rather than silently selecting a CPU/wgpu frame path; and -- W1–W5 remain deferred until a compliant mechanism or explicit specification amendment establishes a maintainable topology. - -The decision is platform-specific. Windows P5 and macOS P6 continue toward fully integrated native-VO mpv presentation with Iced controls and their independent acceptance gates. diff --git a/docs/src/content/docs/developer/native-playback-baseline.md b/docs/src/content/docs/developer/native-playback-baseline.md deleted file mode 100644 index c45ede98..00000000 --- a/docs/src/content/docs/developer/native-playback-baseline.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -title: "Native playback migration baseline" -description: "Code inventory and behavior baseline captured before the libmpv migration." -sidebar: - order: 9 ---- - -This inventory anchors P0 of the [native mpv integration specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md) and its [migration plan](https://github.com/Lowband21/ferrex/blob/dev/docs/plans/native-mpv-playback-migration.md). It describes the code at `dev` commit `23c5715b0c73234bc4d2708d7f2068685730dde7` before the backend-neutral contract is wired into the existing player. - -## Ownership boundary - -The current desktop playback implementation is already extracted from the UI shell: - -- `crates/ferrex-player-playback` owns playback state, update policy, Subwave loading, controls, the external-mpv process, and the desktop player view. -- `crates/ferrex-player-ui` adapts the playback ports, contributes the 10-foot player overlay, chooses transparent window theming, and starts playback-related subscriptions. -- `crates/ferrex-player-playback/src/contract/` is the selected initial boundary for Ferrex-owned backend-neutral commands, events, snapshots, track models, channels, and fallback policy. A further crate split is not needed until a second client consumes this contract. - -## Concrete backend coupling inventory - -### `SubwaveVideo` - -| Location | Direct coupling | -|---|---| -| `ferrex-player-playback/src/state.rs` | Imports and stores `Option` as `video_opt`; derives playing/video presence by polling it; pauses and drops it during teardown. | -| `ferrex-player-playback/src/video.rs` | Constructs `SubwaveVideo::open_at_seconds`, queries initial duration, and pauses/drops the old instance. | -| `ferrex-player-playback/src/update.rs` | Polls position, duration, and paused state; directly issues pause, seek, volume, mute, speed, track, and diagnostic backend-switch operations. | -| `ferrex-player-playback/src/track_selection.rs` | Copies Subwave track DTOs into state and selects tracks by Subwave integer index. | -| `ferrex-player-playback/src/view.rs` | Accepts `&SubwaveVideo` and calls `video.widget(...)`; frame callbacks also drive snapshot-like polling. | -| `ferrex-player-playback/src/controls.rs` | Reads `video.backend()` to label the Wayland/AppSink diagnostic toggle. | -| `ferrex-player-ui/src/domains/ui/views/tenfoot/player_overlay.rs` | Calls `video.widget(...)` directly for the 10-foot player and branches on `video_opt`. | -| `ferrex-player-ui/src/domains/ui/theme.rs` | Polls the Subwave backend preference to decide whether the main Iced window is transparent. | -| UI streaming/media/player subscriptions | Treat `video_opt.is_some()` as the internal-session and playback-ready signal. | - -The streaming domain also uses `video_opt` presence to decide whether HLS may start, whether transcoding status polling should continue, and whether a discovered source duration can be copied into player state. These are session-policy checks rather than presentation concerns and must move to backend-neutral state. - -### External mpv process - -`external_mpv_active` is read by the playback view, playback and media subscriptions, episode navigation, the 10-foot overlay, and keyboard gating. `external_mpv_handle` is owned by `PlayerDomainState` and is directly polled or cleared by `update.rs`. The two fields jointly represent backend selection, liveness, progress, fullscreen restoration, and process ownership; there is no single external-player snapshot. - -Both playback and media subscription composers currently add an external-mpv poll when active. Root subscription composition must be checked for duplicate one-second polls during migration. - -## Message-to-behavior map - -| `PlayerMessage` | Current backend/state effect | -|---|---| -| `PlayMedia` | Synthesizes a random movie ID and delegates to `PlayMediaWithId`; watch tracking therefore has a placeholder identity. | -| `PlayMediaWithId` | Stores media/ID, consumes pending resume data, seeds duration/HDR heuristics, clears stale URLs, and asynchronously requests a playback ticket. | -| `SetStreamUrl` | Parses and stores the ticketed URL, closes an existing Subwave provider during episode replacement, and calls `load_video`. | -| `StreamUrlResolutionFailed` | Clears URL/loading flags and enters the video-error view; it does not persist terminal progress. | -| `VideoReadyToPlay` | Calls the internal Subwave loader. | -| `VideoLoaded(true)` | Copies Subwave audio/subtitle state and enters the player view. | -| `VideoLoaded(false)` | Enters the video-error view. | -| `Play` / `Pause` | Calls `set_paused`, then immediately sends progress using direct position/duration polling. Both paths unwrap `current_media_id` when a video exists. | -| `PlayPause` | Polls `paused`, toggles it, sends progress, and reveals controls. It also unwraps `current_media_id`. | -| `Stop` | Sends final progress from `last_valid_position`/`last_valid_duration`, then queues reset and back navigation. It does not query the backend at stop time. | -| `ResetAfterStop` | Clears media, URL, Subwave handle, progress cache, tracks, and transient playback state. External-mpv fields are not reset by `PlayerDomainState::reset`; callers clear them separately. | -| `NavigateBack` / `NavigateHome` | Polls internal position/duration when possible, sends final progress if a media ID exists, then queues reset and navigation. | -| `Seek` | Updates drag/UI position only. | -| `SeekBarPressed` | Starts a drag only when the last mouse-derived seek position is valid. | -| `MouseMoved` | Computes seek position, updates UI immediately, and sends a direct seek at most every 100 ms while dragging; one pending value is retained. | -| `SeekRelease` | Sends the pending/final absolute seek, marks seeking, clears drag throttling fields, and persists the UI-side position. | -| `SeekDone` | Polls backend position, clears seeking, and persists progress; no current producer was found in the repository. | -| `SeekRelative` | Polls current position, clamps against source/known duration, issues an absolute Subwave seek, and updates UI optimistically. | -| `SeekTo` | Converts to seconds and delegates to `Seek`, so it changes drag/UI state rather than immediately seeking. | -| `SetVolume` | Interprets `1.1`/`0.9` as keyboard increments, clamps 0–1, stores state, and calls Subwave. | -| `ToggleMute` | Optimistically toggles state and calls Subwave. | -| `SetPlaybackSpeed` | Stores speed and calls Subwave; backend errors are discarded. | -| `SetContentFit` | Stores Iced `ContentFit`; the Subwave widget consumes it during view construction. External mpv is unaffected. | -| `ToggleFullscreen` | Optimistically flips `is_fullscreen` and emits an Iced window-mode event. | -| `DisableFullscreen` | Emits windowed mode if the boolean is true but does not clear the boolean locally. | -| `VideoClicked` | Single-click toggles play; a second click within 300 ms toggles fullscreen. | -| `VideoDoubleClicked` | Toggles fullscreen directly. | -| `ShowControls` / `CheckControlsVisibility` | Reveals controls or hides them after three seconds; the timer also expires track notifications. | -| Settings/menu toggles | Mutate only overlay visibility and mutually close selected menus. | -| `AudioTrackSelected` | Selects a Subwave integer index and updates a toast. | -| `SubtitleTrackSelected` | Selects an optional Subwave integer index, updates enabled state, and closes the menu. | -| `ToggleSubtitles` | Selects the current/first index when enabling, or `None` when disabling. | -| `CycleAudioTrack` | Increments the integer index modulo track count. | -| `CycleSubtitleTrack` | Cycles `None -> 0..N-1 -> None`. | -| `CycleSubtitleSimple` | Implements the existing off/first/last-used behavior with integer indices. | -| `TracksLoaded` | Only advances notification timeout; no current producer was found. | -| `ToggleAppsinkBackend` | Wayland diagnostic that switches Subwave between forced AppSink and forced Wayland; non-Wayland forces AppSink. | -| `ToggleShuffle` / `ToggleRepeat` | Toggle UI booleans only; no backend playlist command is issued. | -| `NextEpisode` | Persists current progress, resolves the next ordered episode, and preserves internal-vs-external mode. | -| `PreviousEpisode` | At or after 5% seeks/restarts the current episode; before 5% persists progress and opens the prior episode, preserving mode. | -| `EndOfStream` | Persists direct backend progress, auto-opens the next episode when present, otherwise resets and navigates back. No current producer was found in the repository. | -| `NewFrame` | Polls duration/position, clears a one-second seek timeout, lazily refreshes tracks, and updates notification state. | -| `ProgressHeartbeat` | Every ten seconds while internally playing, polls valid position/duration and sends watch progress. | -| `Reload` | No-op in the playback reducer; its comment refers to obsolete main-level handling. | -| `PlayExternal` | Waits for URL resolution, captures internal resume position, stops Subwave, and starts external mpv. Launch failure falls back to `load_video`. | -| `ExternalPlaybackStarted` | Log-only acknowledgement. | -| `PollExternalMpv` | Polls process liveness and JSON IPC state; emits update/end handling and fullscreen restoration. | -| `ExternalPlaybackUpdate` | Copies position/duration into state and advances `last_progress_sent`. | -| `ExternalPlaybackEnded` | Captures final position/fullscreen, persists progress, auto-advances episodes in external mode, or resets/navigates/restores the app window. | - -## Lifecycle and persistence baseline - -- Internal loading is synchronous on the UI thread once the ticket URL resolves. -- `video_opt.is_some()` is overloaded as session existence, rendering readiness, and streaming-start gating. -- Position and duration are copied into `last_valid_*` primarily from `NewFrame`; values at exactly `0.0` are generally treated as unavailable. -- The normal heartbeat interval is ten seconds. Frame callbacks and an additional ten-second `NewFrame` media subscription also poll backend state. -- Final progress is attempted on stop, back/home navigation, EOF, external process exit, and episode transitions. Internal load/auth errors do not have a common terminal-progress path. -- Internal teardown pauses and drops `SubwaveVideo`; there is no generation token, explicit event-channel close, or stale-callback rejection. -- External teardown depends on process-handle polling and per-branch field clearing. - -## Track identity baseline - -Subwave `AudioTrack` and `SubtitleTrack` values escape into `PlayerDomainState` and controls. Selection identity is an `i32` index. The same number is used both as a vector offset and as the backend selection argument. Reloads replace the vectors without preserving a Ferrex-owned stable identity; a prior subtitle index is retained separately for the simple toggle behavior. - -## Content fit and presentation baseline - -- The UI exposes `Contain`, `Cover`, `Fill`, `None`, and `ScaleDown` through Iced `ContentFit`. -- Fit is passed to the Subwave widget and is not represented as a backend capability. -- Wayland transparency is inferred from process environment plus Subwave backend preference. -- Desktop and 10-foot views each call `SubwaveVideo::widget`, so both must migrate to one presentation boundary. -- Fullscreen belongs to the Iced window in internal mode and to mpv in external mode. Internal state is optimistic; only external process teardown reports a final native fullscreen value for restoration. - -## Dependency and packaging pins - -| Input | Baseline | -|---|---| -| Iced fork | `Lowband21/iced-ferrex` commit `577abb7fa132ecd160adb5c8dfaf5c187b4f888d` | -| iced_aw fork | `Lowband21/iced_aw_ferrex` commit `6ebb6e587d2312bef9ca8c7f8acdf4e0f6384148` | -| Subwave | `Lowband21/subwave` `main` commit `4de8fd485a8077d17fd0f25e7b426988ac0da116` | -| gstreamer-rs | `main` commit `7922e962b267bdb645443615a5ae84239c71f19c` (`0.26.0-alpha`) | -| Nix GStreamer | Source overlay `1.28.4`; Rust toolchain `1.92.0` | -| Flatpak | Freedesktop `24.08`, Rust `1.92.0`, GStreamer core/base/good/bad/ugly/libav `1.28.4` | -| Windows CI | Official MSVC GStreamer `1.28.4` | -| Windows dist | Official MSVC GStreamer `1.28.4` | -| macOS handoff | Homebrew GStreamer exact gate `1.28.5`; pinned custom FFmpeg commit `38b88335f99e76ed89ff3c93f877fdefce736c13`; macOS `15.0` floor | -| Nix inputs | nixpkgs `9ae611a455b90cf061d8f332b977e387bda8e1ca`; rust-overlay `06f25b8e40805beb2121a4dae4cc37d6f981800f`; crane `59a82a1222dd3b2080b5cc52a1a2e8d5f1b77f37` | - -Nix wraps the player with the pinned plugin paths and Linux graphics libraries. -Flatpak builds the media stack from source. Windows distribution starts from -the hash-pinned official SDK but stages only a reviewed plugin/PE/GIO/TLS -closure; OpenH264 and Media Foundation avoid a second FFmpeg ABI. macOS builds -FFmpeg and the mpv dependency core from exact sources, while Homebrew -GStreamer/build-support inputs are version/hash recorded and fail closed on -profile drift. Those rolling Homebrew/MSYS2/Rust inputs make the handoff paths -canonical and provenance-recorded, not bit-for-bit reproducible release -inputs. - -## Baseline test gaps retained as P0 work - -The reproducible synthetic media, authenticated range/HLS transport, initial platform inventory, Wayland operation matrix, and ignored results location are defined in [Native playback fixtures and test matrix](/developer/native-playback-fixtures/). The generator validates codecs, color signaling, HDR side data, subtitles, tracks, chapters, attachments, and malformed inputs without committing generated media. - -Startup, seek, CPU/GPU, memory-cycle, hardware-decoder, compositor/HDR, and protocol-trace measurements still require runs on the physical environments in that matrix. EOF and seek-completion producer wiring also needs an explicit reproduction test before behavior is frozen. diff --git a/docs/src/content/docs/developer/native-playback-fixtures.md b/docs/src/content/docs/developer/native-playback-fixtures.md deleted file mode 100644 index ba616079..00000000 --- a/docs/src/content/docs/developer/native-playback-fixtures.md +++ /dev/null @@ -1,675 +0,0 @@ ---- -title: "Native playback fixtures and test matrix" -description: "Reproducible synthetic media, authenticated transport, and environment inventory for desktop playback migration testing." -sidebar: - order: 10 ---- - -This page defines the P0 media and environment inputs for the [native mpv integration specification](https://github.com/Lowband21/ferrex/blob/dev/docs/specs/native-mpv-playback.md). Generated media and run results stay outside the repository; the generator, validator, and transport server are versioned. - -## Generate and verify - -The complete set requires Python 3, `ffmpeg`, `ffprobe`, Fontconfig/DejaVu Sans (or an explicitly supplied redistributable TrueType font), and these FFmpeg encoders: - -- `libx264`; -- `libx265`; -- `libvpx-vp9`; -- `libaom-av1`; -- AAC; and -- ASS. - -From the repository root: - -```bash -./scripts/qa/native_playback_fixtures.py generate -./scripts/qa/native_playback_fixtures.py verify -``` - -The default output is `target/native-playback-fixtures/`, which is already ignored with the rest of `target/`. Regeneration is explicit: - -```bash -./scripts/qa/native_playback_fixtures.py generate --force -``` - -The generator refuses to replace a directory without its Ferrex fixture marker. It writes a schema-versioned `manifest.json` and `SHA256SUMS`; verification checks the hashes, codecs, color signaling, HDR side data, tracks, chapters, attachments, subtitles, HLS structure, and expected malformed-input rejection. - -Use `--font /path/to/font.ttf` when DejaVu Sans is unavailable. The selected font is embedded into generated Matroska files, not copied into the repository. Confirm that a replacement font permits this use. - -## Fixture inventory - -All fixtures are four-second, 640×360 synthetic patterns with a low-level synthetic audio tone unless noted otherwise. - -| Path | Coverage and required observation | -|---|---| -| `h264-sdr-8bit.mkv` | H.264, `yuv420p`, BT.709 SDR baseline. Keyframes are spaced for one-second HLS segmentation. | -| `hevc-main10-sdr.mkv` | HEVC Main10, `yuv420p10le`, BT.709 SDR. This distinguishes bit depth from HDR. | -| `hdr10-pq.mkv` | HEVC Main10 with BT.2020/PQ, mastering-display metadata, MaxCLL 1000, and MaxFALL 400. | -| `hlg.mkv` | HEVC Main10 with BT.2020 and ARIB STD-B67/HLG transfer signaling. | -| `vp9-sdr.mkv` | VP9 SDR decode path. | -| `av1-sdr.mkv` | AV1 SDR decode path. | -| `ass-animation-fonts.mkv` | Two animated/karaoke ASS events and an attached DejaVu Sans font. | -| `pgs-bitmap.mkv` | A locally constructed HDMV PGS bitmap object, palette, show, and clear sequence. No external PGS encoder or copyrighted subtitle is required. | -| `multitrack-structure.mkv` | Two named/language-tagged audio tracks, two text subtitle tracks (default and forced), two chapters, and a font attachment. | -| `transcoded-hls/index.m3u8` | VOD HLS playlist and MPEG-TS segments derived from the H.264 baseline. This is a deterministic transport-format fixture, not evidence of Ferrex transcoding parity. | -| `malformed-truncated.mkv` | Truncated EBML input that `ffprobe` must reject. | -| `unsupported.txt` | Non-media input that `ffprobe` must reject. | -| `sources/` | Generated ASS, SRT, chapter metadata, and raw SUP inputs for inspection and external-subtitle tests. | - -The HDR10 and HLG files test metadata handling and native-output behavior. Their synthetic pattern is **not** a mastering-quality visual reference and cannot establish display accuracy by itself. - -## Authenticated HTTP range and HLS transport - -Generate the fixtures, choose an ephemeral secret, and start the loopback-only server. The token is read from the environment rather than a command-line argument. - -```bash -export FERREX_FIXTURE_TOKEN="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" -./scripts/qa/native_playback_fixture_server.py --port 8000 -``` - -The default is bearer authentication. It supports `HEAD`, `GET`, and a single standards-style byte range, including open-ended and suffix ranges. It does not list directories and refuses to serve an unmarked root. - -Verify authorization and range handling: - -```bash -curl --fail \ - -H "Authorization: Bearer ${FERREX_FIXTURE_TOKEN}" \ - -H 'Range: bytes=0-1023' \ - http://127.0.0.1:8000/h264-sdr-8bit.mkv \ - --output /tmp/ferrex-range.bin -``` - -The server selects an ephemeral port by default. For automation, use a private ready file: - -```bash -ready_dir="$(mktemp -d)" -chmod 700 "$ready_dir" -port_file="$ready_dir/port" -FERREX_FIXTURE_TOKEN="$FERREX_FIXTURE_TOKEN" \ - ./scripts/qa/native_playback_fixture_server.py --port-file "$port_file" & -server_pid=$! -cleanup_fixture_server() { - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - rm -rf "$ready_dir" -} -trap cleanup_fixture_server EXIT -for _ in {1..100}; do - [[ -s "$port_file" ]] && break - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" 2>/dev/null || true - echo "fixture server exited before publishing its port" >&2 - exit 1 - fi - sleep 0.1 -done -[[ -s "$port_file" ]] || { - echo "fixture server did not publish its port within 10 seconds" >&2 - exit 1 -} -port="$(<"$port_file")" -[[ "$port" =~ ^[0-9]+$ ]] || { - echo "fixture server published an invalid port" >&2 - exit 1 -} -``` - -For the real libmpv native-window smoke (requires a working desktop VO): - -```bash -FERREX_MPV_SMOKE_URL="http://127.0.0.1:${port}/h264-sdr-8bit.mkv" \ -FERREX_MPV_SMOKE_AUTHORIZATION="Bearer ${FERREX_FIXTURE_TOKEN}" \ -cargo test -p ferrex-player-playback --features mpv \ - mpv_adapter::tests::linked_native_window_load_control_fullscreen_stop_and_close_smoke \ - -- --ignored --exact --nocapture -``` - -Use `transcoded-hls/index.m3u8` as the URL to exercise bearer propagation -across playlist and segment requests. To exercise the complete local -track/chapter/edition control path, run the same ignored test with -`FERREX_MPV_SMOKE_MEDIA=target/native-playback-fixtures/multitrack-structure.mkv`; -selectors are tested only when the loaded fixture advertises the corresponding -catalog. The smoke also applies an identity native-VO shader, confirms its -redacted observed count, writes/removes a screenshot, and clears the shader. - -Run subtitle formats separately with `ass-animation-fonts.mkv` and -`pgs-bitmap.mkv`. To add and select a local sidecar through the public contract, -use: - -```bash -FERREX_MPV_SMOKE_MEDIA=target/native-playback-fixtures/h264-sdr-8bit.mkv \ -FERREX_MPV_SMOKE_EXTERNAL_SUBTITLE=target/native-playback-fixtures/sources/english.srt \ -cargo test -p ferrex-player-playback --features mpv \ - mpv_adapter::tests::linked_native_window_load_control_fullscreen_stop_and_close_smoke \ - -- --ignored --exact --nocapture -``` - -The test requires the new track to be selected, text-kind, and marked external; -local paths and temporary screenshot/shader paths are filtered from copied mpv -logs. `--auth query` exists only for the retained legacy compatibility path; -query credentials do not automatically propagate into relative HLS segment -URLs. Logs omit query strings and redact the configured token. - -## Local Ferrex server acceptance - -The loopback fixture server isolates client transport behavior. It does **not** -satisfy the Ferrex-server direct-play or transcode acceptance gates. - -For server-backed acceptance: - -1. mount or copy `target/native-playback-fixtures/` into a local test library; -2. scan the library and record the resulting media IDs; -3. request the normal playback ticket/source through the Ferrex API; -4. test direct play with `h264-sdr-8bit.mkv`, `multitrack-structure.mkv`, and - both HDR-signaled files; -5. force the local server's transcode profile and verify its returned manifest - and every segment use the same playback-scoped authorization policy; and -6. run the UI/native-window path through next episode, stop, EOF, and navigation - while retaining redacted client and server diagnostics. - -Feature-gated ignored tests provide reproducible direct-play, -generated-transcode, and protected-HLS transport paths through isolated -PostgreSQL databases and real network-bound Ferrex routers. The direct test -issues the normal account session and playback-scoped ticket, opens the -protected stream through the -backend-neutral native-mpv session, and confirms metadata/resume, pause, -authenticated range seek, shader and screenshot commands, diagnostics -redaction, and ordered stop: - -```bash -./scripts/dev/sqlx-db.sh start -set -a -source .env.sqlx -set +a -DATABASE_URL="$DATABASE_URL_ADMIN" \ - cargo test -p ferrex-server --features native-mpv-e2e \ - --test playback_stream_failures \ - playback_ticket_drives_display_backed_native_mpv_through_ferrex_router \ - -- --ignored --exact --nocapture --test-threads=1 -``` - -The default input is `h264-sdr-8bit.mkv`; set -`FERREX_MPV_SERVER_SMOKE_MEDIA` to another generated file. - -The HLS transport test rewrites only the generated fixture's local segment -references to credential-free protected Ferrex stream routes. It requires one -header-carried playback ticket on the manifest and every segment, checks -unauthenticated rejection and HLS MIME types, and then runs the same real -native-mpv lifecycle: - -```bash -./scripts/dev/sqlx-db.sh start -set -a -source .env.sqlx -set +a -DATABASE_URL="$DATABASE_URL_ADMIN" \ - cargo test -p ferrex-server --features native-mpv-e2e \ - --test playback_stream_failures \ - playback_ticket_propagates_to_every_router_backed_hls_segment \ - -- --ignored --exact --nocapture --test-threads=1 -``` - -Set `FERREX_MPV_SERVER_SMOKE_HLS` to use another local VOD manifest. Relative -media references must remain beneath its directory; remote, absolute, and -traversal references fail closed. - -The generated-transcode test submits the `360p` profile to the real bounded -FFmpeg job provider, waits for atomic publication, checks unauthenticated -rejection and the playback ticket on the manifest and every segment, verifies -cached reuse, and loads the resulting protected playlist through native mpv: - -```bash -./scripts/dev/sqlx-db.sh start -set -a -source .env.sqlx -set +a -DATABASE_URL="$DATABASE_URL_ADMIN" \ - cargo test -p ferrex-server --features native-mpv-e2e \ - --test playback_stream_failures \ - server_generated_transcode_plays_through_display_backed_native_mpv \ - -- --ignored --exact --nocapture --test-threads=1 -``` - -Set `FERREX_MPV_SERVER_TRANSCODE_MEDIA` to transcode another generated local -fixture. The test requires FFmpeg with `libx264` and AAC encoders in addition -to the direct-play test's requirements. - -These tests use the production router, ticket service, transcode manager, -stream handlers, and real HTTP/libmpv boundary. The pre-generated HLS run -isolates protected manifest/segment transport, while the generated run covers -bounded FFmpeg generation, atomic publication, cached reuse, protected assets, -and display-backed loading as one lifecycle. The normal display-free route and -manager tests continue to cover job ownership and failure behavior. A manual -player quality-picker run and UI episode-transition acceptance remain in the -live-server gate above. - -Follow the [playback authentication regression procedure](/reference/qa/playback-auth-regression/) -for ticket lifecycle and retained-artifact checks. Record server revision, -profile, source media ID, returned container/codecs, and whether playback was -direct or transcoded. Never retain the raw ticket, authorization header, cookie, -or unredacted mpv log. - -## Native-window episode replacement - -The ignored playback-domain smoke lets the first synthetic episode reach real -native-mpv EOF, verifies final progress and the backend-preserving next-episode -request, then drives the normal `SetStreamSource` close/reopen path and requires -the second episode to start in a newer mpv session generation: - -```bash -FERREX_MPV_SMOKE_MEDIA="$PWD/target/native-playback-fixtures/h264-sdr-8bit.mkv" \ - cargo test -p ferrex-player-playback --features mpv \ - update::tests::linked_native_window_eof_reloads_next_episode_with_same_backend \ - -- --ignored --exact --nocapture --test-threads=1 -``` - -This combines the real native-VO lifecycle with the backend-neutral episode -reducer. The outer repository selection, ticket-resolution task, and visible -player-shell transition remain part of the manual live-server UI gate. - -## Native-window lifecycle stress - -The ignored linked-libmpv stress test creates a fresh in-process mpv core and -native window for every cycle, waits for playback and native-VO configuration, -issues an ordered stop, confirms the terminal event, and tears the owner down. -It defaults to the 100 cycles required by the native-presentation gate: - -```bash -FERREX_MPV_SMOKE_MEDIA="$PWD/target/native-playback-fixtures/h264-sdr-8bit.mkv" \ -FERREX_MPV_STRESS_CYCLES=100 \ -FERREX_MPV_STRESS_MAX_RSS_GROWTH_MIB=64 \ -FERREX_MPV_STRESS_MAX_FD_GROWTH=4 \ - cargo test -p ferrex-player-playback --features mpv \ - mpv_adapter::tests::linked_native_window_load_stop_lifecycle_stress \ - -- --ignored --exact --nocapture --test-threads=1 -``` - -On Linux the test reports first-cycle baseline, final, and peak resident memory -and open file descriptors. The two optional limit variables make excessive -final growth fail the run; choose and record a reviewed platform budget rather -than silently loosening it. Use a smaller `FERREX_MPV_STRESS_CYCLES` only to -validate the harness. A platform gate still requires one uninterrupted -100-cycle run while separately monitoring native/GPU memory and window-system -resources; save its redacted output and resource samples under the environment's ignored -`target/native-playback-results/` run directory. This generic native-window -job does not replace the Windows HWND or macOS AppKit presenter-specific -100-cycle gates. - -## Windows and macOS integrated-presenter handoff - -Windows and macOS retain explicit `spike` build gates so the presenters can be -exercised on representative systems without changing the Auto backend policy. -**Play in MPV** requests `mpv-integrated`: mpv owns the native video window and -Iced attaches its transparent controls surface after both native identities -are ready. A failed preflight or attachment records a structured reason and -returns to `mpv-native-window` while GStreamer remains available for rollback. - -"Handoff ready" here means the target code, deterministic fallback, source -builders, package staging, and display-free tests are present. It does **not** -mean the production/Auto gate has passed. Keep that gate closed until the -hardware observations below and the clean-package smoke have been recorded. - -### Windows test build - -The canonical, provenance-recorded path is the `Windows Dist` workflow. It -builds the pinned LGPL libmpv SDK, generates the MSVC import library, compiles -the selected presenter mode, stages only the reviewed GStreamer 1.28.4 plugin -roots plus their recursive PE/GIO/TLS closure, and audits H.264/AAC HLS and -strict HTTPS from the clean stage before uploading the zip. Runtime DLL owners, -versions, hashes, and notices are recorded; the floating Rust/MSYS2 build-tool -selection remains visible in workflow logs rather than pinned as a bit-for-bit -reproducible toolchain. - -Use the uploaded artifact for the clean-host handoff run. From a PowerShell -shell with GitHub CLI authentication: - -```powershell -$ref = '' -$expectedSha = (git rev-parse $ref).Trim() -gh workflow run windows-dist.yml --ref $ref ` - -f profile=release -f presenter_mode=spike -gh run list --workflow windows-dist.yml --event workflow_dispatch ` - --commit $expectedSha --limit 5 -# Select the spike run dispatched above, not merely the newest repository run. -$run = '' -$actualSha = (gh run view $run --json headSha | ConvertFrom-Json).headSha -if ($actualSha -ne $expectedSha) { throw "Run revision mismatch: $actualSha" } -gh run watch $run --exit-status -$spikeDir = Join-Path 'target\windows-handoff' $run -if (Test-Path -LiteralPath $spikeDir) { - throw "Refusing to reuse handoff directory: $spikeDir" -} -$null = New-Item -ItemType Directory -Path $spikeDir -gh run download $run --name ferrex-player-windows-spike ` - --dir $spikeDir -$zips = @(Get-ChildItem -LiteralPath $spikeDir -File -Filter '*.zip') -if ($zips.Count -ne 1) { - throw "Expected exactly one zip in $spikeDir, found $($zips.Count)" -} -$zip = $zips[0] -Get-FileHash $zip.FullName -Algorithm SHA256 -$appDir = Join-Path $spikeDir 'app' -Expand-Archive -LiteralPath $zip.FullName -DestinationPath $appDir -$localAppData = if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { - $env:TEMP -} else { - $env:LOCALAPPDATA -} -$registry = Join-Path $localAppData 'Ferrex\gstreamer-1.0\registry.bin' -if (Test-Path -LiteralPath $registry) { - throw "Clean-user prerequisite failed; archive or remove $registry first" -} -cmd /c "$appDir\run-ferrex.bat" "http://your-ferrex-server:3000" -``` - -Run that launcher, rather than the executable or downloaded PowerShell script -directly: the batch launcher is not subject to PowerShell execution policy and -selects only the bundled plugins, GIO TLS modules, registry, CA bundle, and -`gst-plugin-scanner`. Perform the artifact run from a clean Windows user or VM -without another GStreamer or mpv directory on `PATH`, and retain the displayed -zip hash, run ID, and verified `headSha` with the result record. - -For the required disabled-presenter control, dispatch the same revision again -and retain its separately closure-audited artifact: - -```powershell -gh workflow run windows-dist.yml --ref $ref ` - -f profile=release -f presenter_mode=disabled -gh run list --workflow windows-dist.yml --event workflow_dispatch ` - --commit $expectedSha --limit 5 -$disabledRun = '' -$disabledSha = (gh run view $disabledRun --json headSha | ConvertFrom-Json).headSha -if ($disabledSha -ne $expectedSha) { throw "Run revision mismatch: $disabledSha" } -gh run watch $disabledRun --exit-status -$disabledDir = Join-Path 'target\windows-disabled-handoff' $disabledRun -if (Test-Path -LiteralPath $disabledDir) { - throw "Refusing to reuse handoff directory: $disabledDir" -} -$null = New-Item -ItemType Directory -Path $disabledDir -gh run download $disabledRun --name ferrex-player-windows-disabled ` - --dir $disabledDir -$disabledZips = @( - Get-ChildItem -LiteralPath $disabledDir -File -Filter '*.zip' -) -if ($disabledZips.Count -ne 1) { - throw "Expected exactly one zip in $disabledDir, found $($disabledZips.Count)" -} -$disabledZip = $disabledZips[0] -Get-FileHash $disabledZip.FullName -Algorithm SHA256 -$disabledAppDir = Join-Path $disabledDir 'app' -Expand-Archive -LiteralPath $disabledZip.FullName ` - -DestinationPath $disabledAppDir -$modeFile = Join-Path $disabledAppDir ` - 'share\ferrex-player\PRESENTER_BUILD_MODE' -$mode = (Get-Content -LiteralPath $modeFile -Raw).Trim() -if ($mode -ne 'disabled') { throw "Unexpected presenter mode: $mode" } -if (Test-Path -LiteralPath $registry) { - $evidenceDir = Join-Path $disabledDir 'evidence' - $null = New-Item -ItemType Directory -Path $evidenceDir - Move-Item -LiteralPath $registry ` - -Destination (Join-Path $evidenceDir 'registry-after-spike.bin') -} -if (Test-Path -LiteralPath $registry) { - throw "Spike GStreamer registry was not isolated: $registry" -} -cmd /c "$disabledAppDir\run-ferrex.bat" "http://your-ferrex-server:3000" -``` - -Complete the spike cases below and quit the app before running the disabled -block. The commands refuse stale download/extraction directories, require one -archive, and move the spike GStreamer registry out of the fixed per-user path -before the disabled launch. A separate clean user is also acceptable. Do not -reuse either extraction tree or registry evidence. Tag-triggered Windows -artifacts force `disabled`; the unapproved spike cannot become a release -attachment. - -For a local MSVC test, first build the SDK from an MSYS2 UCRT64 shell as -documented in `utils/build-windows/build-libmpv-lgpl.sh`, then run this from a -PowerShell developer shell. The installer helper downloads only the pinned -official GStreamer 1.28.4 SDK and verifies its recorded SHA-256: - -```powershell -$root = 'C:\ferrex-libmpv-sdk' -& .\utils\build-windows\new-libmpv-import-library.ps1 -SdkRoot $root -$dll = Get-ChildItem (Join-Path $root 'bin') -File | - Where-Object { $_.Name -in @('libmpv-2.dll', 'mpv-2.dll', 'mpv.dll') } | - Select-Object -First 1 -if (-not $dll) { throw "libmpv runtime DLL is missing from $root\bin" } - -$env:LIBMPV_ROOT = $root -$env:LIBMPV_LIB_DIR = Join-Path $root 'lib' -$env:LIBMPV_INCLUDE_DIR = Join-Path $root 'include' -$env:LIBMPV_DLL_DIR = Join-Path $root 'bin' -$env:LIBMPV_DLL = $dll.FullName -$gst = Join-Path (Get-Location) 'target\gstreamer-msvc-x86_64' -& .\utils\build-windows\install-gstreamer.ps1 -Destination $gst -$env:GSTREAMER_1_0_ROOT_MSVC_X86_64 = $gst -$env:PKG_CONFIG = Join-Path $gst 'bin\pkg-config.exe' -$env:PKG_CONFIG_PATH = Join-Path $gst 'lib\pkgconfig' -$env:FERREX_MPV_WINDOWS_PRESENTER = 'spike' -$env:PATH = "$(Join-Path $root 'bin');$(Join-Path $gst 'bin');$env:PATH" -cargo run -p ferrex-player --features mpv -``` - -The environment value is consumed at compile time. Rebuild after changing it. -Do not substitute a locally installed default mpv build for release evidence. - -### macOS test build - -The canonical platform test path is the `macOS App Bundle` workflow. Dispatch -the same revision once with `presenter_mode=spike` and once with -`presenter_mode=disabled`, then run the architecture-matched artifacts on Apple -Silicon and Intel macOS 15+ systems. The workflow builds the pinned LGPL mpv -profile, stages the reviewed dylib/GStreamer/GIO/trust closure, signs the app, -and verifies the bundle before upload. Tag builds keep the presenter disabled; -production signing, notarization, and release promotion remain separate -packaging work. - -Use a clean macOS account or host without external GStreamer or mpv paths in -the environment. Preserve the workflow revision and archive checksum with the -result, and use the same generic window-manager configuration throughout each -declared run. - -Normal AppKit object access stays on the Iced/AppKit main thread; blocking -libmpv teardown is handed to the named background reaper only after the -in-root view has detached. Retained-shell restoration, navigation, and -replacement native launch wait for a positive completion signal from that -reaper. A failed teardown keeps later native launches closed for the rest of -the process instead of allowing a second native owner to overlap the first. - -### Representative-system procedure - -Generate and verify the fixtures first. Test a normal direct Ferrex stream and -at least `h264-sdr-8bit.mkv`, `multitrack-structure.mkv`, -`ass-animation-fonts.mkv`, `pgs-bitmap.mkv`, `hdr10-pq.mkv`, and `hlg.mkv`. -Run the SDR cases on every environment and the HDR/EDR cases only on a capable -display with the OS HDR state recorded. - -For every run: - -1. Start Ferrex from the `spike` artifact and use **Play in MPV**. Confirm - diagnostics report requested/selected `mpv-integrated`, then - `presenter_state=attached`, with no fallback reason. Retain the pointer-free - `native player overlay handoff:` debug transition log plus a native-window - trace or screen recording proving this order: hidden controls host - allocated; presenter attached and positioned to mpv's content rectangle; - retained main window hide completed; presenter host made visible; overlay - focus confirmed. The serialized diagnostic snapshot supplies presenter - state/geometry; it does not by itself prove Iced hide/focus delivery. Any early - `ShowWindow`/`orderFront`, stale main-window resize/move after attachment, or - visible flash fails the run. -2. Confirm there is one active player identity in the Windows taskbar and - Alt-Tab list, or one Ferrex application identity in the macOS Dock and app - switcher. No second blank or permanently hidden window may remain after - stop. -3. Move and resize continuously, cross monitors, change Windows per-monitor - DPI or macOS backing scale, minimize/restore, hide/unhide the app, and cover - then uncover it. The overlay must follow the video content rectangle without - drift, stale controls, focus theft, or visible startup flash. Include one - independent-viewport case: retain a 1280×720 main-window snapshot while the - native content/overlay becomes a materially different size and aspect (for - example 1024×768) and scale. Control layout, focus geometry, progress-bar - bounds, and pointer hit targets must follow the live overlay viewport rather - than the retained main size. -4. Exercise mouse, keyboard, seek, pause, volume, track/subtitle selection, - settings, next/previous episode, and Back/Home. In integrated mode Iced is - the input owner; mpv OSC/default bindings must not compete with it. -5. Enter and leave native fullscreen repeatedly. mpv owns the transition and - Ferrex must wait for the observed fullscreen property before changing its - snapshot. On macOS, repeat across Spaces and during the native animation. -6. Compare the HDR/EDR fixture with controls continuously visible, controls - hidden, and repeated overlay show/hide. Record the reported input/output - color parameters rather than inferring HDR from the filename or backend. -7. Record `current-vo`, GPU context/API, adapter, `hwdec-current`, frame/drop - counters, and the presenter geometry/scale. VideoToolbox on macOS and - D3D11VA/DXVA2 on Windows are observations, not assumptions. -8. Test normal stop, EOF, native window close, overlay close, app quit, reload, - and an immediate second playback. Native relationships must detach before - either host is destroyed, and shutdown must not hang the AppKit main loop. -9. Validate fallback with a build whose presenter gate is `disabled`, and - capture the structured transition to `mpv-native-window`. Playback must - remain controllable and the hidden Iced overlay must be dismissed. From the - same qualification candidate and disabled control artifacts, select Auto - and actually play the direct - Ferrex SDR stream and `h264-sdr-8bit.mkv` through EOF with seek, pause, - audio, and stop working. Then select a non-original quality profile so the - local Ferrex server generates its protected HLS rendition; wait for the job - to complete and verify Auto reloads the credential-free manifest URI with - its playback-scoped header and plays every protected segment. Repeat that - application path through an HTTPS Ferrex base URL whose hostname and - certificate chain are trusted by the bundled Mozilla CA set (a loopback - self-signed certificate is not this test). Keep the authenticated-HTTP and - strict-HTTPS results separate. Confirm Auto selects GStreamer, and correlate - the run with the same artifact's closure-audit output proving its packaged - H.264/AAC, demux, network/TLS, audio-sink, and appsink factories; runtime - diagnostics do not enumerate factories. Selection or the helper-only HTTPS - smoke alone is not a rollback pass. -10. After exploratory checks pass, complete one uninterrupted 100-cycle - load/attach/fullscreen/stop/close run. Monitor process, native-window, and - GPU resources against the provisional budget below; the lower-level - native-window stress test above does not exercise the attached overlay. - -### Presenter stress budget - -Declare the following provisional budget before the 100-cycle run. Establish -the baseline after cycle 10 and 30 seconds of quiescence. Sample again after -cycles 20–80 in ten-cycle increments, after every cycle from 81–100, and after -a final 30-second quiescence. Do not restart the process between samples. - -- Final working set/RSS, private committed bytes, and virtual address-space - size must each be no more than 64 MiB above the stabilized baseline; the - post-baseline peak must be no more than 128 MiB above it. Windows - `VirtualMemorySize64` and macOS VSZ are address-space measurements, not - committed-memory measurements. -- Windows handle count must finish at no more than baseline +8. macOS open file - descriptors must finish at no more than baseline +4. -- GPU process memory must return to no more than baseline +64 MiB after final - quiescence, with no live decoder, swapchain, or video texture from a stopped - generation. -- Native player window count and presenter view ancestry must return exactly - to baseline after every stop. Any orphan view/window, child controls window, - second taskbar/Dock identity, or stale native relationship fails - immediately. -- The final 20 quiescent samples must not be monotonically non-decreasing with - a net increase of at least 1 MiB, one handle/FD, or one native/GPU object. - Any limit breach, crash, hang, fallback, or diagnostics from a stale - generation fails the run. - -On Windows, capture the process counters at each sample with this PowerShell -snippet and save the objects as CSV; use Task Manager, Process Explorer, PIX, -or an equivalent reviewed tool for GPU and HWND relationship evidence: - -```powershell -$p = Get-Process ferrex-player -ErrorAction Stop -[pscustomobject]@{ - Utc = [DateTime]::UtcNow.ToString('o') - WorkingSet64 = $p.WorkingSet64 - PrivateMemorySize64 = $p.PrivateMemorySize64 - VirtualMemorySize64 = $p.VirtualMemorySize64 - HandleCount = $p.HandleCount - MainWindowHandle = $p.MainWindowHandle -} -``` - -On macOS, record RSS/VSZ and open descriptors at the same cadence, and use -Activity Monitor plus Instruments/Quartz Debug (or equivalent reviewed tools) -for GPU and in-root `NSView` ancestry evidence: - -```bash -pid="$(pgrep -n ferrex-player)" -date -u +%Y-%m-%dT%H:%M:%SZ -ps -o pid=,rss=,vsz= -p "$pid" -lsof -a -p "$pid" -Ff | awk '/^f[0-9]+$/ { count++ } END { print count+0 }' -``` - -### Result record and pass boundary - -Create one directory per run beneath -`target/native-playback-results///`. Save a short -`summary.md`, redacted Ferrex output, package/fixture hashes, resource samples, -and screenshots or screen recordings only when they contain no private media, -server address, account name, token, header, cookie, or machine-identifying -path. A useful diagnostic launch is: - -```bash -FERREX_MPV_LOG_LEVEL=trace \ -RUST_LOG=ferrex_player_playback=trace,ferrex_player_mpv=trace,ferrex_player_ui=debug \ - cargo run -p ferrex-player --features mpv -``` - -Use the equivalent PowerShell environment variables on Windows. The run passes -the **handoff validation** when all required operations succeed, the package -closure audit passes on the same artifact, no pointer/handle or credential is -present in retained diagnostics, and every predeclared 100-cycle resource -budget above passes. Only then update the P5/P6 production decision; HDR/EDR, -hardware-decoding, and Auto capability flags must reflect the recorded -evidence instead of the build target. - -## Initial platform and protocol inventory - -Use stable environment IDs in results instead of hostnames or user names. The initial inventory requires at least these classes before an Auto rollout decision: - -| Environment ID | Window system/compositor | GPU/driver class | Display gate | Primary purpose | -|---|---|---|---|---| -| `wl-wlroots-amd` | wlroots/Hyprland family | AMD Mesa | SDR + HDR-capable output when available | Wayland bridge, dmabuf, explicit sync, color management | -| `wl-kde-intel` | KDE Wayland | Intel Mesa | SDR and fractional scale | configure/scale/output transitions | -| `wl-gnome-intel` | GNOME Wayland | Intel Mesa | SDR | protocol compatibility and fallback | -| `wl-nvidia` | supported Wayland compositor | NVIDIA proprietary | SDR + HDR when available | interop and explicit-sync behavior | -| `x11-composited` | X11 with compositor | any supported GPU | SDR | transparent overlay presenter | -| `x11-uncomposited` | X11 without compositor | any supported GPU | SDR | deterministic `wid`/native-window fallback | -| `windows-sdr` | supported Windows | Intel/AMD/NVIDIA | SDR | HWND, DPI, focus, taskbar, gpu-next | -| `windows-hdr` | supported Windows | HDR-capable adapter | HDR enabled | overlay-visible/hidden HDR gate | -| `macos-apple` | macOS 15+ | Apple Silicon | SDR + EDR when available | AppKit, Spaces, fullscreen, VideoToolbox | -| `macos-intel` | macOS 15+ | Intel | SDR | fallback and teardown compatibility | - -A run record must include: - -- Ferrex revision and fixture `manifest.json` plus `SHA256SUMS` hashes; -- mpv/client API, FFmpeg, libplacebo, VO, GPU context/API, adapter, and `hwdec-current`; -- OS, kernel/build, window system, compositor, GPU, driver, monitor, refresh rate, scale, and HDR state; -- direct versus transcoded source and selected backend/presenter; -- pass/fail for load, first frame, pause, seek, tracks, subtitles, chapters, resize, hide/show, fullscreen, stop, close, and fallback; and -- links to redacted logs, performance samples, and protocol traces. - -Store local run artifacts under `target/native-playback-results///`. Keep a small redacted summary in review documentation when it supports a rollout decision; do not commit large media, traces, or machine-identifying dumps. - -### Wayland trace matrix - -Capture only on a dedicated test environment and strip sensitive titles/paths. The P7 spike must correlate these fixture operations with protocol traffic: - -| Operation | Required protocol evidence | -|---|---| -| Initial map and first frame | registry bindings, surface creation, shell-role virtualization, configure/ack, buffer attach, frame/presentation callback | -| Resize and fractional scale | parent geometry revision, synthetic configure, viewport/buffer scale, output enter/leave | -| HDR10/PQ and HLG | mpv-selected VO/hwdec plus compositor color-management/color-representation traffic | -| Pause/seek | independent native-VO cadence without Iced frame polling; explicit-sync/dmabuf release remains live | -| Fullscreen | Iced top-level transition and synthesized mpv state/configure, with no second real toplevel | -| Stop/VO reload | child role/object teardown before parent destruction and clean generation replacement | -| Missing optional global | explicit capability/fallback reason; no silent CPU frame path | - -A single successful compositor run is spike evidence only. D-022 currently selects HYBRID, so W1–W5 are deferred; any future P7 GO still requires the complete matrix and release packaging. - -Use the versioned `native_playback_wayland_trace.py` harness to capture the -initial map/control/fullscreen/VO-reload sequence against pinned mpv 0.41.0. -The [native mpv Wayland spike record](/developer/native-mpv-wayland-spike/) -documents the command, redacted artifact schema, initial protocol inventory, -and the current connection-redirection blocker. diff --git a/docs/src/content/docs/operator/configuration.md b/docs/src/content/docs/operator/configuration.md index ab6af6aa..df2273bd 100644 --- a/docs/src/content/docs/operator/configuration.md +++ b/docs/src/content/docs/operator/configuration.md @@ -19,7 +19,9 @@ Back up `.env` if you keep long‑lived credentials. The generator creates stron ## Core Environment Variables -These are the most commonly used variables. See `.env.example` for the authoritative list. +These are commonly used variables. `.env.example` is authoritative for the +generated server/stack environment; player-only variables may instead be set +in the player's launch environment. - `TMDB_API_KEY` – Required for metadata lookups. - `SERVER_HOST` / `SERVER_PORT` – Bind address and port (defaults: `0.0.0.0` / `3000`). @@ -29,12 +31,10 @@ These are the most commonly used variables. See `.env.example` for the authorita - `RUST_LOG` – Server logging filter, e.g. `sqlx=trace,ferrex=debug`. - `FERREX_MPV_PATH` – Optional override for the separate external mpv executable, primarily on Windows. It does not locate in-process libmpv. -- `FERREX_MPV_CONFIG_POLICY` – In an mpv-enabled developer build, either +- `FERREX_MPV_CONFIG_POLICY` – In an mpv-enabled build, either `deterministic` (default) or `trusted-user`. Trusted-user config and scripts execute inside the Ferrex process. -- `FERREX_MPV_LOG_LEVEL` – Optional fixed in-process libmpv message filter: - `none`, `fatal`, `error`, `warn`, `info`, `verbose`, `debug`, or `trace`. - Invalid values fail closed without being echoed. +- `FERREX_MPV_LOG_LEVEL` – Optional fixed in-process libmpv message filter. - TLS options – Paths can be provided via env (if you terminate TLS at the app). If you use a reverse proxy, terminate TLS there instead. - Player URL – Run the player against a custom server with `FERREX_SERVER_URL=https://host:port`. @@ -235,8 +235,7 @@ Desktop playback has an additional native-message filter. `RUST_LOG` controls whether Ferrex emits a target, while `FERREX_MPV_LOG_LEVEL` controls which messages libmpv sends to Ferrex. See [Desktop playback backends](/developer/desktop-playback-backends/) for the -backend selector, fallback order, safe diagnostic procedure, and rollback -policy. +supported levels and safe diagnostic procedure. ## Demo Mode (Optional) diff --git a/docs/src/content/docs/operator/faq.md b/docs/src/content/docs/operator/faq.md index 09478189..c1f8f807 100644 --- a/docs/src/content/docs/operator/faq.md +++ b/docs/src/content/docs/operator/faq.md @@ -15,11 +15,17 @@ Ferrex’s native zero‑copy HDR path depends on Wayland subsurfaces and recent ## Will HDR come to Windows/macOS? -That’s a goal. A cross‑platform native HDR path will require platform‑specific work and maturing dependencies. Until then, Windows/macOS can use the cross‑platform backend or the “Open with MPV” hand‑off. +That remains a goal. macOS on Apple Silicon now uses native in-process mpv, +but Ferrex does not yet claim HDR/EDR output or a particular VideoToolbox decode +path. Windows likewise has no current HDR qualification. -## How does MPV hand‑off preserve position and status? +## How does mpv playback preserve position and status? -The player communicates with mpv via IPC (Unix) or a named pipe (Windows) and keeps watch state synchronized with the server. You can override the mpv path on Windows using `FERREX_MPV_PATH` if auto‑detection fails. +In-process libmpv implements the same backend-neutral state contract as the +other player engines. The explicit external mpv action uses private IPC on +Unix or a named pipe on Windows. Both keep watch state synchronized with the +server. `FERREX_MPV_PATH` overrides only the external executable, primarily on +Windows. ## What’s the default server port? diff --git a/docs/src/content/docs/reference/qa/playback-auth-regression.md b/docs/src/content/docs/reference/qa/playback-auth-regression.md index 76f24d79..b4362a91 100644 --- a/docs/src/content/docs/reference/qa/playback-auth-regression.md +++ b/docs/src/content/docs/reference/qa/playback-auth-regression.md @@ -16,10 +16,10 @@ This packet records the playback-auth regression gate for the Ferrex server, des | Area | Disposition | Evidence / gap | | --- | --- | --- | | Rust format/check/core | Pass | `cargo fmt`, workspace `cargo check --all-targets`, and `ferrex-core` lib tests passed. Workspace check still emits existing `ferrexctl` missing-doc warnings. | -| Server playback auth integration | Pass | `crates/ferrex-server/tests/playback_stream_failures.rs` passed against a local Nix Postgres with `pg_uuidv7`: ticket issue, query and scoped-bearer range streaming, missing/invalid token rejection, account API scope rejection, and typed media recovery headers. | +| Server playback auth integration | Pass | `crates/ferrex-server/tests/playback_stream_failures.rs` passed against a local Nix Postgres with `pg_uuidv7`: ticket issue, range streaming, missing/invalid token rejection, account API scope rejection, and typed media recovery headers. | | Desktop GStreamer smoke | Pass | `scripts/qa/playback-auth-smoke.sh` served a local protected WAV fixture, verified bad tickets return 401, then completed `gst-launch-1.0 playbin` with fakesinks using the ticketed URL. | | Desktop MPV smoke | Pass | The same smoke completed `mpv --no-config --ao=null` against the ticketed URL, redacted the MPV log, and verified retained artifacts do not contain the raw ticket. | -| Desktop ticket source / redaction unit coverage | Pass | `ferrex-player-playback` tests cover header-authenticated direct-stream source resolution, fail-closed ticket errors, legacy external-player URL conversion, and credential redaction in playback/mpv diagnostics. | +| Desktop ticket URL / redaction unit coverage | Pass | `ferrex-player-playback` tests passed for ticketed URL resolution, fail-closed ticket errors, and access-token redaction in playback/MPV log lines. | | Android mobile / TV auth-expiry retry unit coverage | Pass | Focused Gradle unit command for `PlaybackFoundationTest.ticketAuthFailuresRetryThenInvalidateSession` returned `BUILD SUCCESSFUL` for mobile and TV tasks. | | Android mobile / TV manual playback | Blocked | `adb devices -l` returned no attached devices. The SDK emulator binary cannot start in this NixOS workspace because of the stub-ld dynamic-loader error. Physical phone/TV or a runnable emulator is still required for manual playback evidence. | | Android full assemble/unit/lint | Not rerun for LOW-415 | No Android source code changed in this packet. The most recent full Android evidence is recorded in [Android / Android TV final QA acceptance packet](/reference/qa/android-final-qa/); rerun the full Gradle gate if Android code changes. | @@ -109,46 +109,13 @@ FERREX_QA_KEEP_ARTIFACTS=1 nix develop .#ferrex-player --command bash scripts/qa Do not attach `mpv.raw.log`; the script deletes it after producing `mpv.redacted.log`. -### Desktop ticket source and redaction units - -The 2026-07-12 native-playback migration moved direct in-process ticket -transport from the query string to a redacted `PlaybackSource` Authorization -header. The explicit legacy external-player handoff still converts that source -to a temporary query-ticket URL at its compatibility boundary. A focused -2026-07-12 server integration rerun also passed after adding the exact -in-process transport case: a playback-scoped ticket in the bearer header -returned the requested `206` byte range, independently of the existing full -account-session bearer check. +### Desktop ticket URL and redaction units ```bash -nix develop .#ferrex-player --command env cargo test -p ferrex-player-playback resolve_playback_stream_source +nix develop .#ferrex-player --command env cargo test -p ferrex-player-playback resolve_playback_stream_url ``` -Result: pass; both header-transport and fail-closed source-resolution tests -passed. - -The formerly string-only streaming/HLS API source now uses a typed, -credential-free URI plus a zeroizing bearer header. Its focused tests cover -header transport, redacted diagnostics, query/userinfo rejection, header -injection rejection, and fail-closed ticket errors: - -```bash -cargo test -p ferrex-player-api services::streaming::tests -cargo test -p ferrex-player-ui --features unimplemented \ - domains::streaming::update_handlers::transcoding::tests::transcoded_source_reload_keeps_ticket_in_redacted_header_transport \ - -- --exact -``` - -On 2026-07-13 the ignored display-backed server acceptance also passed with the -generated transcoded-HLS fixture routed through protected real Ferrex stream -URLs. One playback-scoped bearer ticket authorized the manifest and all four -segments, unauthenticated segment reads returned `401`, manifest URLs remained -credential-free, and native mpv completed playback controls, seek, shader, -screenshot, diagnostics-redaction, and stop. This isolates router/HLS -transport from the separately tested bounded FFmpeg job provider. Current -start/status/assets route tests cover job ownership, cached publication, and a -playback-scoped ticket on every generated rendition asset; the representative -display-backed generated-HLS run remains a separate gate. +Result: pass; `2 passed; 0 failed; 4 filtered out`. ```bash nix develop .#ferrex-player --command env cargo test -p ferrex-player-playback redacts_access_token @@ -199,8 +166,8 @@ Results: Record device model, OS/API level, display mode, server build, media fixture, and redacted logs/screenshots for each manual run. -1. **Desktop UI, GStreamer path:** launch the player against a QA server, start playback from a detail page, verify the in-process source URI has no `access_token` query value and the protected stream succeeds through its bearer header, pause/seek/exit, and confirm no raw ticket appears in Ferrex logs. -2. **Desktop UI, MPV hand-off:** use the same media item and launch “Play in MPV.” In a build with the `mpv` feature, confirm the in-process libmpv native window opens the ticketed stream, pause/seek/stop work, and closing the native window returns control to Ferrex. In a backend-disabled build, confirm the action retains the external-process fallback. Also exercise the explicit external-player hand-off from the player controls. Confirm all retained Ferrex/mpv logs are redacted. +1. **Desktop UI, GStreamer path:** launch the player against a QA server, start playback from a detail page, verify the resolved stream URL is ticketed, pause/seek/exit, and confirm no raw ticket appears in Ferrex logs. +2. **Desktop UI, MPV hand-off:** use the same media item, launch "Play in MPV", confirm MPV opens the ticketed stream, close MPV, and confirm Ferrex/MPV retained logs are redacted. 3. **Short ticket expiry:** configure a short playback-ticket TTL or use an expired ticket fixture. Verify server returns 401/403, clients retry where supported, and recovery remains actionable after retry limits without clearing app data. 4. **Android phone:** install `app-mobile-debug.apk`, sign in, play/resume/start-over, force ticket/session expiry, and verify retry then sign-in/change-server recovery without OS app-data wipe. 5. **Android TV:** install `app-tv-debug.apk`, repeat the phone playback/expiry cases using D-pad/OK/Back, and verify focus remains reachable on playback recovery actions. @@ -216,8 +183,6 @@ Record device model, OS/API level, display mode, server build, media fixture, an - `crates/ferrex-server/tests/playback_stream_failures.rs` - `crates/ferrex-player-playback/src/update.rs` - `crates/ferrex-player-playback/src/diagnostics.rs` -- `crates/ferrex-player-playback/src/external_mpv.rs` -- `crates/ferrex-player-playback/src/mpv_adapter.rs` - `mobile/android/app/src/test/kotlin/com/ferrex/android/core/playback/PlaybackFoundationTest.kt` - [Android / Android TV final QA acceptance packet](/reference/qa/android-final-qa/) - [Android playback QA matrix](/reference/qa/android-playback-matrix/) diff --git a/docs/src/content/docs/release/flathub-submission.md b/docs/src/content/docs/release/flathub-submission.md index 32a3a684..319dd37f 100644 --- a/docs/src/content/docs/release/flathub-submission.md +++ b/docs/src/content/docs/release/flathub-submission.md @@ -16,35 +16,6 @@ This guide documents how to submit Ferrex Player to Flathub for distribution. Th - Desktop entry: `flatpak/io.github.lowband21.FerrexPlayer.desktop` - Icons: 128x128, 192x192, 512x512 in `flatpak/icons/` -## Native playback packaging profile - -The manifest builds Ferrex with the `mpv` feature and bundles pinned mpv -0.41.0, FFmpeg 8.1.2, libplacebo, libass, and LuaJIT. Its configure checks fail -the build if mpv resolves `gpl=true`, if FFmpeg enables GPL/nonfree/version-3 -code, or if the required Wayland/Vulkan/dmabuf feature set is missing. The final -binary must have a direct `libmpv.so.2` dependency, and the exact license files -and effective build profiles are installed below -`/app/share/licenses/io.github.lowband21.FerrexPlayer/`. - -mpv 0.41 gates its X11 VO on GPL sources. The reviewed Flatpak profile does not -ship those sources: Wayland can use the explicit native-window mpv backend, -while X11 retains the integrated GStreamer backend or the separate external -player action. Do not enable mpv's GPL option merely to make the X11 feature -check pass. - -Validate the finished artifact locally with: - -```bash -flatpak-builder --user --force-clean --repo=target/flatpak-repo \ - target/flatpak-build flatpak/io.github.lowband21.FerrexPlayer.yml -flatpak build-bundle target/flatpak-repo target/ferrex-player.flatpak \ - io.github.lowband21.FerrexPlayer -``` - -Install the bundle and verify that `ldd /app/bin/ferrex-player` resolves -`libmpv.so.2`, FFmpeg, libplacebo, libass, and LuaJIT from `/app/lib` before -publishing it. - ## Submission Steps ### 1. Fork the Flathub Repository diff --git a/third-party/winit-0.30.13-ferrex/FERREX-PATCH.md b/third-party/winit-0.30.13-ferrex/FERREX-PATCH.md index 2a6a9c8b..678f259f 100644 --- a/third-party/winit-0.30.13-ferrex/FERREX-PATCH.md +++ b/third-party/winit-0.30.13-ferrex/FERREX-PATCH.md @@ -4,60 +4,44 @@ This directory is a source copy of crates.io `winit` 0.30.13 (`a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d`). The upstream license is retained in `LICENSE`. -Ferrex carries a narrow AppKit patch because its macOS native-mpv presenter -keeps winit's window identity in an unordered staging `NSWindow` while moving -the associated `WinitView` into mpv's externally owned root `NSWindow`. -Upstream 0.30.13 assumes that `WinitView` always remains the staging window's -content view. That assumption makes raw-handle recreation unsafe and leaves -move, scale, focus, cursor, IME, and resize behavior tied to the wrong window +## Why it exists + +Ferrex's macOS presenter keeps winit's logical window in an unordered donor +`NSWindow` while hosting its renderer `WinitView` inside an externally owned +`NSWindow`. Upstream winit 0.30.13 assumes that the renderer view remains the +donor's content view, leaving window-sensitive state tied to the wrong host after reparenting. -The Ferrex delta is intentionally limited to macOS implementation files. It -must: - -- retain the `WinitView` directly instead of recovering it through an unsafe - cast of the staging window's current content view; -- preserve the staging `WindowId` for event routing; -- use the view's actual host `NSWindow` for host-sensitive position, geometry, - scale, focus, cursor, IME, and drag behavior; -- defer host-transition scale/resize delivery to the next main-run-loop turn - so AppKit reparent callbacks cannot re-enter winit's borrowed event handler; -- snapshot foreign-root movement in physical coordinates at notification time - and deliver the newest position exactly once even if detach completes before - the deferred callback, before any donor scale-factor transition can change - how Iced converts that physical position; -- retain the captured foreign scale and view size so a coalesced backing-scale - change is replayed before the final move, keep its size writer valid without - applying that request to the donor, and defer `Resized` until authoritative - donor reconciliation; -- mirror relevant host-window notifications while the view is foreign-hosted; -- remove those observations automatically when the view returns to staging; -- leave staging-window visibility, destruction, and application identity under - normal winit ownership. - -Changed source files: - -- `src/platform_impl/macos/view.rs` splits stable event identity from the - effective AppKit host, binds external-root notifications, preserves a final - host move across notification-to-detach races, reports view-local metrics, - and owns observer/IME/focus cleanup; -- `src/platform_impl/macos/window_delegate.rs` retains the exact view, returns - effective-host queries, and prevents window/lifecycle setters from mutating - either mpv's root or the hidden donor while foreign-hosted; -- `src/platform_impl/macos/window.rs` detaches a foreign-hosted view before the - donor closes. - -Notification registration is object-scoped to the current external root. -Cleanup is deliberately name-scoped with `object: nil`: this removes a stale -registration even when the previous root has already deallocated, while -leaving the independent view-frame notification untouched. - -The donor remains a winit-owned, unordered staging object so Iced keeps its -normal logical window and renderer lifecycle. It is not a presented overlay; -all externally visible window operations remain owned by mpv's root. Ferrex -must route player fullscreen/lifecycle commands through libmpv and native -background drag through the retained root, never through generic donor-window -actions. - -Apple Silicon and Intel acceptance evidence is required before this patch can -be treated as production-qualified. +The macOS-only patch: + +- retains the actual `WinitView` instead of recovering it from the donor; +- preserves the donor `WindowId` for event routing while using the view's + effective host for geometry, scale, focus, cursor, IME, and notifications; +- prevents donor window operations from mutating the foreign host; and +- removes foreign-host observations and restores donor ownership before close. + +The changed implementation files are: + +- `src/platform_impl/macos/view.rs`; +- `src/platform_impl/macos/window_delegate.rs`; and +- `src/platform_impl/macos/window.rs`. + +Detailed notification ordering, event coalescing, and teardown behavior is +documented beside the implementation and its regression tests. + +Although limited to macOS, this is not a small semantic delta: it spans view +retention, event identity, host metrics, focus, IME, notifications, scaling, +and teardown. Ferrex will not carry or expand that surface indefinitely. + +## Validation + +The foreign-view presenter passed functional one-window validation on Apple +Silicon. Intel macOS is legacy and outside the supported validation matrix. + +## Exit contract + +This is a temporary fork. The next winit/Iced upgrade must consume released +generic foreign-view support or select the Ferrex-owned AppKit host +contingency; rebasing or expanding this fork is not an accepted outcome. The +full upstream-or-delete decision is in the +[native mpv design](../../docs/specs/native-mpv-playback.md#winit-fork-exit). diff --git a/utils/build-windows/README.txt b/utils/build-windows/README.txt index c8fb0736..fb6aebe0 100644 --- a/utils/build-windows/README.txt +++ b/utils/build-windows/README.txt @@ -21,16 +21,5 @@ Advanced Usage: - Set GST_DEBUG=3 for GStreamer debugging - Check logs in %APPDATA%\ferrex-player\ -Native mpv presenter status: -- The package's presenter mode is recorded in - share\ferrex-player\PRESENTER_BUILD_MODE -- "spike" contains the developer-only Win32 owned-overlay presenter; "disabled" - is the control/release build and falls back without attempting attachment -- The production/Auto gate remains disabled until the Windows hardware, HDR, - focus, fullscreen, taskbar, and lifecycle test matrix passes -- Developers rebuilding the package must set - FERREX_MPV_WINDOWS_PRESENTER=spike before cargo build -- Presenter failure falls back deterministically to mpv's native window - Support: Visit https://github.com/Lowband21/ferrex for issues and discussions From c25ef18ec732fe0bdadb890a8298311812d59431 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 15:26:33 -0600 Subject: [PATCH 25/56] fix(ci): install libmpv for SQLx checks --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40c691d4..757b8936 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,7 @@ jobs: libglib2.0-dev \ libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ + libmpv-dev \ libavcodec-dev \ libavformat-dev \ libavutil-dev \ From a1a178dcb9e578b8a2701c9f66c301b910844808 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 15:26:37 -0600 Subject: [PATCH 26/56] fix(deps): resolve current advisory failures --- Cargo.lock | 28 ++++++++++++++-------------- deny.toml | 2 ++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b25a964d..05c049d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1298,7 +1298,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.2", ] [[package]] @@ -1541,9 +1541,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -3145,7 +3145,7 @@ dependencies = [ "vec_map", "wasm-bindgen", "web-sys", - "windows 0.57.0", + "windows 0.61.3", ] [[package]] @@ -6185,9 +6185,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.39.2" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -7719,7 +7719,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "subwave_appsink" version = "0.1.0" -source = "git+https://github.com/Lowband21/subwave.git?branch=main#4de8fd485a8077d17fd0f25e7b426988ac0da116" +source = "git+https://github.com/Lowband21/subwave.git?branch=main#8d21fb298c293301e959f1de81b62fae5f757009" dependencies = [ "gstreamer", "gstreamer-app", @@ -7733,7 +7733,7 @@ dependencies = [ [[package]] name = "subwave_core" version = "0.1.0" -source = "git+https://github.com/Lowband21/subwave.git?branch=main#4de8fd485a8077d17fd0f25e7b426988ac0da116" +source = "git+https://github.com/Lowband21/subwave.git?branch=main#8d21fb298c293301e959f1de81b62fae5f757009" dependencies = [ "gstreamer", "thiserror 2.0.18", @@ -7743,7 +7743,7 @@ dependencies = [ [[package]] name = "subwave_unified" version = "0.1.0" -source = "git+https://github.com/Lowband21/subwave.git?branch=main#4de8fd485a8077d17fd0f25e7b426988ac0da116" +source = "git+https://github.com/Lowband21/subwave.git?branch=main#8d21fb298c293301e959f1de81b62fae5f757009" dependencies = [ "gstreamer", "iced", @@ -7758,7 +7758,7 @@ dependencies = [ [[package]] name = "subwave_wayland" version = "0.1.0" -source = "git+https://github.com/Lowband21/subwave.git?branch=main#4de8fd485a8077d17fd0f25e7b426988ac0da116" +source = "git+https://github.com/Lowband21/subwave.git?branch=main#8d21fb298c293301e959f1de81b62fae5f757009" dependencies = [ "ab_glyph", "gstreamer", @@ -8361,7 +8361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5f7c95348f20c1c913d72157b3c6dee6ea3e30b3d19502c5a7f6d3f160dacbf" dependencies = [ "cc", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -8936,9 +8936,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.9" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -9187,7 +9187,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/deny.toml b/deny.toml index a02fb191..ae8e484b 100644 --- a/deny.toml +++ b/deny.toml @@ -79,7 +79,9 @@ ignore = [ { id = "RUSTSEC-2022-0081", reason = "`json` is a transitive build-dependency of `hsluv` used by `ferrex-player`; it is not linked into runtime artifacts and there is no upstream upgrade path yet" }, { id = "RUSTSEC-2024-0384", reason = "`instant` is required by `notify` v7; upstream replacement not yet available" }, { id = "RUSTSEC-2024-0436", reason = "`paste` comes from `wgpu` transitive deps; awaiting ecosystem migration" }, + { id = "RUSTSEC-2025-0141", reason = "`bincode` is transitively required by Iced profiling and hot-reload tooling; the advisory has no safe upgrade path" }, { id = "RUSTSEC-2026-0105", reason = "`core2` is pulled in transitively through `image`/`ravif`; no safe upgrade path is available yet" }, + { id = "RUSTSEC-2026-0192", reason = "`ttf-parser` is required by the Iced font stack; the advisory has no safe upgrade path" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. From 6230ecb6650a85f9364149374ccdbe28531af6df Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 15:35:30 -0600 Subject: [PATCH 27/56] fix(deps): update anyhow past unsound release --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 05c049d2..e8f1cee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,9 +208,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" From f5e93e87065dcf3ceeb59347647256d7a5ae9591 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 15:44:39 -0600 Subject: [PATCH 28/56] fix(macos): link iconv in pinned ffmpeg build --- scripts/release/macos-build-libmpv.sh | 1 + scripts/release/test_macos_build_profile.py | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/release/macos-build-libmpv.sh b/scripts/release/macos-build-libmpv.sh index 54ec7bb8..43a0bc28 100644 --- a/scripts/release/macos-build-libmpv.sh +++ b/scripts/release/macos-build-libmpv.sh @@ -140,6 +140,7 @@ mkdir -p "$ffmpeg_build" --enable-videotoolbox \ --enable-securetransport \ --enable-iconv \ + --extra-libs=-liconv \ --enable-zlib make -j"$jobs" make install diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 957c0a38..4dd92685 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -52,6 +52,7 @@ def test_ffmpeg_profile_is_source_built_and_lgpl(self) -> None: "--disable-nonfree", "--disable-version3", "--enable-videotoolbox", + "--extra-libs=-liconv", ): self.assertIn(flag, BUILD) self.assertNotIn("brew install ffmpeg", BUILD) From d865c67d8fe1a1a05a43e25ec90af6f6ecca2287 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 15:54:42 -0600 Subject: [PATCH 29/56] fix(macos): keep mpv shaderc on win32 path --- scripts/release/macos-build-libmpv.sh | 10 ++++++---- scripts/release/test_macos_build_profile.py | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/release/macos-build-libmpv.sh b/scripts/release/macos-build-libmpv.sh index 43a0bc28..0f9f337d 100644 --- a/scripts/release/macos-build-libmpv.sh +++ b/scripts/release/macos-build-libmpv.sh @@ -264,7 +264,8 @@ PY # mpv's native macOS VO and VideoToolbox/libplacebo path are required. The # cplayer is not shipped: Ferrex links the shared client library and owns the -# application bundle. Optional GPL and external-script surfaces stay disabled. +# application bundle. Shader compilation stays in libplacebo; mpv's shaderc +# option is for its Win32/D3D11 path. Optional GPL surfaces stay disabled. meson setup "$build_directory" "$source_directory" \ --wrap-mode=nofallback \ --buildtype=release \ @@ -285,7 +286,7 @@ meson setup "$build_directory" "$source_directory" \ -Dgl=enabled \ -Dgl-cocoa=enabled \ -Dvulkan=enabled \ - -Dshaderc=enabled \ + -Dshaderc=disabled \ -Dlcms2=disabled \ -Dlibarchive=disabled \ -Dlibbluray=disabled \ @@ -332,7 +333,7 @@ required = { "gl": "enabled", "gl-cocoa": "enabled", "vulkan": "enabled", - "shaderc": "enabled", + "shaderc": "disabled", "lua": "lua52", } wrong = {name: (values.get(name), expected) for name, expected in required.items() if values.get(name) != expected} @@ -396,13 +397,14 @@ fi printf 'mpv_videotoolbox_pl=enabled\n' printf 'mpv_gl=enabled\n' printf 'mpv_vulkan=enabled\n' + printf 'mpv_shaderc=disabled\n' printf 'mpv_lua=lua52\n' printf 'ffmpeg_commit=%s\n' "$FFMPEG_COMMIT" printf 'ffmpeg_version=%s\n' "$(pkg-config --modversion libavcodec)" printf 'ffmpeg_gpl=false\nffmpeg_nonfree=false\nffmpeg_version3=false\n' printf 'libplacebo_commit=%s\n' "$LIBPLACEBO_COMMIT" printf 'libplacebo_version=%s\n' "$(pkg-config --modversion libplacebo)" - printf 'libplacebo_opengl=enabled\nlibplacebo_vulkan=enabled\n' + printf 'libplacebo_opengl=enabled\nlibplacebo_vulkan=enabled\nlibplacebo_shaderc=enabled\n' printf 'libass_commit=%s\n' "$LIBASS_COMMIT" printf 'libass_version=%s\n' "$(pkg-config --modversion libass)" printf 'libass_coretext=enabled\n' diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 4dd92685..169797e8 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -66,7 +66,7 @@ def test_mpv_profile_keeps_required_macos_paths(self) -> None: "-Dvideotoolbox-gl=enabled", "-Dgl-cocoa=enabled", "-Dvulkan=enabled", - "-Dshaderc=enabled", + "-Dshaderc=disabled", "-Dlua=lua52", ): self.assertIn(flag, BUILD) @@ -85,6 +85,7 @@ def test_libass_and_libplacebo_profiles_are_explicit(self) -> None: self.assertIn("-Dfontconfig=disabled", BUILD) self.assertIn("-Dvk-proc-addr=enabled", BUILD) self.assertIn("-Dglslang=disabled", BUILD) + self.assertIn("-Dshaderc=enabled", BUILD) def test_homebrew_trust_is_transitive_but_not_prefix_wide(self) -> None: self.assertIn('brew deps --union "${homebrew_direct_formulae[@]}"', BUILD) From afb464a45043fe307dd2d6306c1530a135b1a521 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 15:58:51 -0600 Subject: [PATCH 30/56] fix(ci): retry GStreamer SDK downloads --- utils/build-windows/install-gstreamer.ps1 | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/utils/build-windows/install-gstreamer.ps1 b/utils/build-windows/install-gstreamer.ps1 index 2365407b..2d489891 100644 --- a/utils/build-windows/install-gstreamer.ps1 +++ b/utils/build-windows/install-gstreamer.ps1 @@ -20,7 +20,20 @@ $pkgConfig = Join-Path $Destination 'lib\pkgconfig\gstreamer-1.0.pc' if (-not (Test-Path $pkgConfig -PathType Leaf)) { $url = "https://gstreamer.freedesktop.org/pkg/windows/$version/$abi/$installerName" - Invoke-WebRequest -Uri $url -OutFile $installer + $downloadAttempts = 3 + for ($attempt = 1; $attempt -le $downloadAttempts; $attempt++) { + Remove-Item $installer -Force -ErrorAction SilentlyContinue + try { + Invoke-WebRequest -Uri $url -OutFile $installer + break + } catch { + if ($attempt -eq $downloadAttempts) { + throw + } + Write-Warning "GStreamer download attempt $attempt failed; retrying" + Start-Sleep -Seconds (5 * $attempt) + } + } $actualSha256 = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualSha256 -ne $expectedSha256) { From daf571e828910f08cbf9f2dd86e5159af838c515 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 16:15:38 -0600 Subject: [PATCH 31/56] fix(test): decouple shutdown proof from media runtime --- crates/ferrex-player-playback/src/update.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ferrex-player-playback/src/update.rs b/crates/ferrex-player-playback/src/update.rs index 96a36618..14b8c46d 100644 --- a/crates/ferrex-player-playback/src/update.rs +++ b/crates/ferrex-player-playback/src/update.rs @@ -3883,7 +3883,9 @@ mod tests { request: Some(owner) }] if *owner == retired )); - assert!(playback.video_opt.is_some()); + assert!(!playback.root_shutdown_in_progress); + assert_eq!(playback.root_shutdown_retired_request, None); + assert!(ui.player); } #[test] From 84e4ace4102447e3f20fdff5dd117001bdb3e48c Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 16:23:22 -0600 Subject: [PATCH 32/56] fix(ci): install autotools for Windows libass --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 757b8936..bbffe3ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,8 +207,11 @@ jobs: update: true install: >- base-devel + autoconf autoconf-archive + automake git + libtool mingw-w64-ucrt-x86_64-toolchain mingw-w64-ucrt-x86_64-meson mingw-w64-ucrt-x86_64-ninja From be6706abac8758f67a0af88971c37e25e9f807c2 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 16:36:02 -0600 Subject: [PATCH 33/56] fix(windows): configure libass after bootstrap --- utils/build-windows/build-libmpv-lgpl.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utils/build-windows/build-libmpv-lgpl.sh b/utils/build-windows/build-libmpv-lgpl.sh index 7c84d228..e67fc977 100755 --- a/utils/build-windows/build-libmpv-lgpl.sh +++ b/utils/build-windows/build-libmpv-lgpl.sh @@ -57,7 +57,8 @@ checkout_exact mpv https://github.com/mpv-player/mpv.git "$MPV_COMMIT" # libass 0.17.4: exact shared build used for text/ASS subtitle rendering. pushd "$WORK/src/libass" >/dev/null -./autogen.sh \ +./autogen.sh +./configure \ --prefix="$PREFIX" \ --disable-static \ --enable-shared \ From 92180ee671e732e3bcad15b64f59dc2d672071b4 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 16:52:24 -0600 Subject: [PATCH 34/56] fix(ci): pin macOS deployment runner --- .github/workflows/ci.yml | 4 ++-- scripts/release/test_macos_build_profile.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbffe3ce..4c76b391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,13 +102,13 @@ jobs: name: Build strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-15, windows-latest] include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - os: windows-latest target: x86_64-pc-windows-msvc - - os: macos-latest + - os: macos-15 target: aarch64-apple-darwin max-parallel: 3 runs-on: ${{ matrix.os }} diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 169797e8..362c40ce 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -161,6 +161,10 @@ def test_gstreamer_version_and_allowlist_are_exact(self) -> None: self.assertIn('MACOSX_DEPLOYMENT_TARGET:=15.0', BUILD) self.assertIn('MACOSX_DEPLOYMENT_TARGET="15.0"', WORKFLOW) + def test_ci_pins_the_apple_silicon_runner_to_the_deployment_target(self) -> None: + self.assertIn("os: [ubuntu-latest, macos-15, windows-latest]", WORKFLOW) + self.assertNotIn("macos-latest", WORKFLOW) + if __name__ == "__main__": unittest.main() From fa426b855f111bef0e02309fe4b50fbdbd0cbda4 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 17:07:48 -0600 Subject: [PATCH 35/56] fix(ci): link macOS smoke against staged dylibs --- .github/workflows/ci.yml | 6 ++++-- scripts/release/test_macos_build_profile.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c76b391..360b3693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -407,9 +407,11 @@ jobs: -f hls -hls_time 1 -hls_list_size 0 "$fixture_dir/stream.m3u8" cc scripts/release/macos_gstreamer_bundle_smoke.c \ $(pkg-config --cflags gstreamer-1.0 gio-2.0) \ - -L"$app/Contents/Frameworks" \ -Wl,-rpath,"$app/Contents/Frameworks" \ - $(pkg-config --libs-only-l gstreamer-1.0 gio-2.0) \ + "$app/Contents/Frameworks/libgstreamer-1.0.0.dylib" \ + "$app/Contents/Frameworks/libgio-2.0.0.dylib" \ + "$app/Contents/Frameworks/libgobject-2.0.0.dylib" \ + "$app/Contents/Frameworks/libglib-2.0.0.dylib" \ -o "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" if otool -L "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" | \ grep -E '/opt/homebrew/|/usr/local/|/nix/store/' >/dev/null; then diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 362c40ce..79dd57ec 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -132,8 +132,13 @@ def test_workflows_stage_moltenvk_and_clean_gstreamer_runtime(self) -> None: "Contents/Resources/gio/modules", "libgiognutls", "https_test_server.py", + "libgstreamer-1.0.0.dylib", + "libgio-2.0.0.dylib", + "libgobject-2.0.0.dylib", + "libglib-2.0.0.dylib", ): self.assertIn(expected, WORKFLOW) + self.assertNotIn("pkg-config --libs-only-l", WORKFLOW) self.assertNotIn("export DYLD_LIBRARY_PATH", WORKFLOW) self.assertNotIn("Contents/PlugIns/gstreamer-1.0", WORKFLOW) self.assertNotIn("Contents/PlugIns/gio/modules", WORKFLOW) From 9ed6ed628e27d6b2537a937bc787ef3af2f60a76 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 17:24:13 -0600 Subject: [PATCH 36/56] fix(ci): initialize AppKit in macOS runtime smoke --- .github/workflows/ci.yml | 3 ++- scripts/release/macos_gstreamer_bundle_smoke.c | 2 ++ scripts/release/test_macos_build_profile.py | 9 ++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 360b3693..8cb69e61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -405,13 +405,14 @@ jobs: -f lavfi -i sine=frequency=440:sample_rate=48000:duration=2 \ -c:v libx264 -pix_fmt yuv420p -c:a aac \ -f hls -hls_time 1 -hls_list_size 0 "$fixture_dir/stream.m3u8" - cc scripts/release/macos_gstreamer_bundle_smoke.c \ + cc -x objective-c scripts/release/macos_gstreamer_bundle_smoke.c \ $(pkg-config --cflags gstreamer-1.0 gio-2.0) \ -Wl,-rpath,"$app/Contents/Frameworks" \ "$app/Contents/Frameworks/libgstreamer-1.0.0.dylib" \ "$app/Contents/Frameworks/libgio-2.0.0.dylib" \ "$app/Contents/Frameworks/libgobject-2.0.0.dylib" \ "$app/Contents/Frameworks/libglib-2.0.0.dylib" \ + -framework AppKit \ -o "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" if otool -L "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" | \ grep -E '/opt/homebrew/|/usr/local/|/nix/store/' >/dev/null; then diff --git a/scripts/release/macos_gstreamer_bundle_smoke.c b/scripts/release/macos_gstreamer_bundle_smoke.c index 58175224..4d8a9e7c 100644 --- a/scripts/release/macos_gstreamer_bundle_smoke.c +++ b/scripts/release/macos_gstreamer_bundle_smoke.c @@ -2,6 +2,7 @@ #include #include +#import #include #include @@ -51,6 +52,7 @@ int main(int argc, char **argv) { fprintf(stderr, "usage: macos_gstreamer_bundle_smoke URL LIBSOUP CA_PEM\n"); return 2; } + [NSApplication sharedApplication]; if (dlopen(argv[2], RTLD_LAZY | RTLD_GLOBAL) == NULL) { fprintf(stderr, "could not preload bundled libsoup: %s\n", dlerror()); return 1; diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 79dd57ec..57101e4c 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -136,13 +136,20 @@ def test_workflows_stage_moltenvk_and_clean_gstreamer_runtime(self) -> None: "libgio-2.0.0.dylib", "libgobject-2.0.0.dylib", "libglib-2.0.0.dylib", + "-x objective-c", + "-framework AppKit", ): self.assertIn(expected, WORKFLOW) self.assertNotIn("pkg-config --libs-only-l", WORKFLOW) self.assertNotIn("export DYLD_LIBRARY_PATH", WORKFLOW) self.assertNotIn("Contents/PlugIns/gstreamer-1.0", WORKFLOW) self.assertNotIn("Contents/PlugIns/gio/modules", WORKFLOW) - for expected in ("avdec_h264", "atdec", "g_tls_file_database_new"): + for expected in ( + "avdec_h264", + "atdec", + "g_tls_file_database_new", + "[NSApplication sharedApplication]", + ): self.assertIn(expected, SMOKE) def test_ca_bundle_is_immutable_and_provenance_recorded(self) -> None: From add11fea7d2927fa65d74ff967b1dfc3ab116773 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 18:51:29 -0600 Subject: [PATCH 37/56] fix(windows): install LuaJIT import library --- utils/build-windows/build-libmpv-lgpl.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/utils/build-windows/build-libmpv-lgpl.sh b/utils/build-windows/build-libmpv-lgpl.sh index e67fc977..c0a0a037 100755 --- a/utils/build-windows/build-libmpv-lgpl.sh +++ b/utils/build-windows/build-libmpv-lgpl.sh @@ -108,6 +108,19 @@ popd >/dev/null pushd "$WORK/src/luajit" >/dev/null make -j"$JOBS" BUILDMODE=dynamic TARGET_SYS=Windows make install BUILDMODE=dynamic TARGET_SYS=Windows PREFIX="$PREFIX" +# LuaJIT's top-level installer is POSIX-oriented and does not copy the MinGW +# DLL or import library produced by src/Makefile. Install both explicitly so +# luajit.pc's `-lluajit-5.1` resolves and the runtime closure contains lua51. +install -m 0755 src/lua51.dll "$PREFIX/bin/lua51.dll" +install -m 0644 src/libluajit-5.1.dll.a "$PREFIX/lib/libluajit-5.1.dll.a" +[[ -f "$PREFIX/bin/lua51.dll" ]] || { + echo "error: LuaJIT did not install lua51.dll" >&2 + exit 1 +} +[[ -f "$PREFIX/lib/libluajit-5.1.dll.a" ]] || { + echo "error: LuaJIT did not install libluajit-5.1.dll.a" >&2 + exit 1 +} popd >/dev/null # libplacebo 7.360.1 provides gpu-next's D3D11 renderer. Vulkan/OpenGL are not From 839c2611f251dde54cbc3841ab4b0e19c76eccfe Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 18:51:35 -0600 Subject: [PATCH 38/56] ci: let platform validation finish independently --- .github/workflows/ci.yml | 1 + scripts/release/test_macos_build_profile.py | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cb69e61..fcbbf46f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,7 @@ jobs: build: name: Build strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-15, windows-latest] include: diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 57101e4c..598ce520 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -174,6 +174,7 @@ def test_gstreamer_version_and_allowlist_are_exact(self) -> None: self.assertIn('MACOSX_DEPLOYMENT_TARGET="15.0"', WORKFLOW) def test_ci_pins_the_apple_silicon_runner_to_the_deployment_target(self) -> None: + self.assertIn("fail-fast: false", WORKFLOW) self.assertIn("os: [ubuntu-latest, macos-15, windows-latest]", WORKFLOW) self.assertNotIn("macos-latest", WORKFLOW) From edfb2e78cb8856b0cc178518f9bf900db98620b3 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 19:16:25 -0600 Subject: [PATCH 39/56] fix(ci): separate macOS smoke compile and link --- .github/workflows/ci.yml | 4 +++- scripts/release/test_macos_build_profile.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcbbf46f..720a1784 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -406,8 +406,10 @@ jobs: -f lavfi -i sine=frequency=440:sample_rate=48000:duration=2 \ -c:v libx264 -pix_fmt yuv420p -c:a aac \ -f hls -hls_time 1 -hls_list_size 0 "$fixture_dir/stream.m3u8" - cc -x objective-c scripts/release/macos_gstreamer_bundle_smoke.c \ + cc -x objective-c -c scripts/release/macos_gstreamer_bundle_smoke.c \ $(pkg-config --cflags gstreamer-1.0 gio-2.0) \ + -o "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke.o" + cc "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke.o" \ -Wl,-rpath,"$app/Contents/Frameworks" \ "$app/Contents/Frameworks/libgstreamer-1.0.0.dylib" \ "$app/Contents/Frameworks/libgio-2.0.0.dylib" \ diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 598ce520..884f75cd 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -136,7 +136,8 @@ def test_workflows_stage_moltenvk_and_clean_gstreamer_runtime(self) -> None: "libgio-2.0.0.dylib", "libgobject-2.0.0.dylib", "libglib-2.0.0.dylib", - "-x objective-c", + "-x objective-c -c", + "ferrex-gstreamer-bundle-smoke.o", "-framework AppKit", ): self.assertIn(expected, WORKFLOW) From 960257d6503af4ca848029bf42e79fa294908033 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 19:30:42 -0600 Subject: [PATCH 40/56] fix(windows): accept native GIO module names --- utils/build-windows/stage-gstreamer-runtime.ps1 | 14 ++++++++++++-- utils/build-windows/verify-runtime-closure.ps1 | 12 +++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/utils/build-windows/stage-gstreamer-runtime.ps1 b/utils/build-windows/stage-gstreamer-runtime.ps1 index e746626c..c6160864 100644 --- a/utils/build-windows/stage-gstreamer-runtime.ps1 +++ b/utils/build-windows/stage-gstreamer-runtime.ps1 @@ -97,8 +97,18 @@ if (-not $scanner) { } Copy-FromGStreamer $scanner (Join-Path $stageLibexec 'gst-plugin-scanner.exe') -foreach ($gioModule in @('libgioopenssl.dll', 'libgiolibproxy.dll')) { - Copy-FromGStreamer (Join-Path $sourceGioModules $gioModule) (Join-Path $stageGioModules $gioModule) +foreach ($gioModuleRoot in @('gioopenssl', 'giolibproxy')) { + $matches = @( + @( + (Join-Path $sourceGioModules "$gioModuleRoot.dll"), + (Join-Path $sourceGioModules "lib$gioModuleRoot.dll") + ) | Where-Object { Test-Path $_ -PathType Leaf } + ) + if ($matches.Count -ne 1) { + throw "Expected exactly one official GIO module for '$gioModuleRoot'; found $($matches.Count)" + } + $gioModule = [System.IO.Path]::GetFileName($matches[0]) + Copy-FromGStreamer $matches[0] (Join-Path $stageGioModules $gioModule) } $gioModuleCache = Join-Path $sourceGioModules 'giomodule.cache' if (Test-Path $gioModuleCache -PathType Leaf) { diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index a063f809..48899409 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -48,9 +48,15 @@ if (-not (Test-Path $gstInspect -PathType Leaf)) { if (-not (Test-Path $gstLaunch -PathType Leaf)) { throw "Bundled GStreamer launch smoke tool is missing: $gstLaunch" } -foreach ($gioModule in @('libgioopenssl.dll', 'libgiolibproxy.dll')) { - if (-not (Test-Path (Join-Path $gioModuleDir $gioModule) -PathType Leaf)) { - throw "Bundled GIO network module is missing: $gioModule" +foreach ($gioModuleRoot in @('gioopenssl', 'giolibproxy')) { + $matches = @( + @( + (Join-Path $gioModuleDir "$gioModuleRoot.dll"), + (Join-Path $gioModuleDir "lib$gioModuleRoot.dll") + ) | Where-Object { Test-Path $_ -PathType Leaf } + ) + if ($matches.Count -ne 1) { + throw "Expected exactly one bundled GIO network module for '$gioModuleRoot'; found $($matches.Count)" } } if (-not (Test-Path $certificateBundle -PathType Leaf)) { From 3df6715cd302f477a1e98538b8486dcbf7dab1d6 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 19:58:35 -0600 Subject: [PATCH 41/56] fix(macos): ship mpv-only runtime bundle --- .github/workflows/ci.yml | 124 +------------- .../ferrex-player/src/macos_bundle_runtime.rs | 159 +----------------- scripts/release/https_test_server.py | 33 ---- scripts/release/macos-build-libmpv.sh | 46 ----- scripts/release/macos-gstreamer-plugins.txt | 30 ---- .../release/macos_gstreamer_bundle_smoke.c | 122 -------------- scripts/release/test_macos_build_profile.py | 89 ++-------- 7 files changed, 24 insertions(+), 579 deletions(-) delete mode 100644 scripts/release/https_test_server.py delete mode 100644 scripts/release/macos-gstreamer-plugins.txt delete mode 100644 scripts/release/macos_gstreamer_bundle_smoke.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 720a1784..2d818f64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,7 @@ jobs: pkg-config meson ninja nasm \ freetype fribidi harfbuzz \ molten-vk shaderc vulkan-headers vulkan-loader \ - ca-certificates glib glib-networking gstreamer libsoup openssl@3 + gstreamer - name: Build pinned LGPL libmpv (macOS) if: runner.os == 'macOS' @@ -331,37 +331,7 @@ jobs: search_roots+=(--search-root "$formula_lib") fi done <"$prefix/share/ferrex/native-mpv/homebrew-formulae.txt" - gstreamer_prefix="$(brew --prefix gstreamer)" molten_vk_prefix="$(brew --prefix molten-vk)" - libsoup_prefix="$(brew --prefix libsoup)" - gio_module="" - for candidate in "$(brew --prefix glib-networking)/lib/gio/modules"/libgiognutls.{so,dylib}; do - if [[ -f "$candidate" ]]; then - gio_module="$candidate" - break - fi - done - if [[ -z "$gio_module" ]]; then - echo "glib-networking TLS module was not installed" >&2 - exit 1 - fi - ca_bundle="$(brew --prefix ca-certificates)/share/ca-certificates/cacert.pem" - if [[ ! -f "$ca_bundle" ]]; then - echo "immutable Homebrew Mozilla CA bundle is missing" >&2 - exit 1 - fi - expected_ca_hash="$(sed -n 's/^ca_certificates_bundle_sha256=//p' "$prefix/share/ferrex/native-mpv/build-profile.txt")" - actual_ca_hash="$(shasum -a 256 "$ca_bundle" | awk '{ print $1 }')" - if [[ -z "$expected_ca_hash" || "$actual_ca_hash" != "$expected_ca_hash" ]]; then - echo "immutable Homebrew Mozilla CA bundle does not match the build profile" >&2 - exit 1 - fi - gstreamer_plugin_args=() - while IFS= read -r plugin; do - if [[ -n "$plugin" && "$plugin" != \#* ]]; then - gstreamer_plugin_args+=(--gstreamer-plugin "$gstreamer_prefix/lib/gstreamer-1.0/$plugin") - fi - done /dev/null; then - echo "clean-bundle smoke helper links a developer/package-manager path" >&2 - exit 1 - fi - python3 -m http.server 18765 --bind 127.0.0.1 --directory "$fixture_dir" & - http_server_pid=$! - openssl="$(brew --prefix openssl@3)/bin/openssl" - "$openssl" req -x509 -newkey rsa:2048 -nodes -days 1 \ - -subj /CN=FerrexTestCA \ - -addext basicConstraints=critical,CA:TRUE \ - -addext keyUsage=critical,keyCertSign,cRLSign \ - -keyout "${RUNNER_TEMP}/ferrex-ca.key" \ - -out "${RUNNER_TEMP}/ferrex-ca.crt" >/dev/null 2>&1 - "$openssl" req -newkey rsa:2048 -nodes \ - -subj /CN=127.0.0.1 \ - -addext subjectAltName=IP:127.0.0.1 \ - -keyout "${RUNNER_TEMP}/ferrex-test.key" \ - -out "${RUNNER_TEMP}/ferrex-test.csr" >/dev/null 2>&1 - "$openssl" x509 -req -days 1 \ - -in "${RUNNER_TEMP}/ferrex-test.csr" \ - -CA "${RUNNER_TEMP}/ferrex-ca.crt" \ - -CAkey "${RUNNER_TEMP}/ferrex-ca.key" \ - -CAcreateserial -copy_extensions copy \ - -out "${RUNNER_TEMP}/ferrex-test.crt" >/dev/null 2>&1 - python3 scripts/release/https_test_server.py \ - --directory "$fixture_dir" \ - --certificate "${RUNNER_TEMP}/ferrex-test.crt" \ - --key "${RUNNER_TEMP}/ferrex-test.key" \ - --port 18766 & - https_server_pid=$! - trap 'kill "$http_server_pid" "$https_server_pid" 2>/dev/null || true' EXIT - curl --fail --retry 20 --retry-connrefused --retry-delay 1 \ - http://127.0.0.1:18765/stream.m3u8 >/dev/null - curl --insecure --fail --retry 20 --retry-connrefused --retry-delay 1 \ - https://127.0.0.1:18766/stream.m3u8 >/dev/null - "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ - http://127.0.0.1:18765/stream.m3u8 \ - "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ - "$app/Contents/Resources/tls/cacert.pem" - if "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ - https://127.0.0.1:18766/stream.m3u8 \ - "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ - "$app/Contents/Resources/tls/cacert.pem"; then - echo "strict HTTPS smoke unexpectedly trusted the unbundled test CA" >&2 - exit 1 - fi - "${RUNNER_TEMP}/ferrex-gstreamer-bundle-smoke" \ - https://127.0.0.1:18766/stream.m3u8 \ - "$app/Contents/Frameworks/libsoup-3.0.0.dylib" \ - "${RUNNER_TEMP}/ferrex-ca.crt" - - name: libmpv control-plane smoke (non-Windows) if: runner.os != 'Windows' run: cargo test -p ferrex-player-mpv --features linked --target ${{ matrix.target }} -- --test-threads=1 diff --git a/crates/ferrex-player/src/macos_bundle_runtime.rs b/crates/ferrex-player/src/macos_bundle_runtime.rs index 5c70b2c5..1eeb391e 100644 --- a/crates/ferrex-player/src/macos_bundle_runtime.rs +++ b/crates/ferrex-player/src/macos_bundle_runtime.rs @@ -2,23 +2,9 @@ use std::path::{Path, PathBuf}; -#[cfg(target_os = "macos")] -use std::ffi::CString; -#[cfg(target_os = "macos")] -use std::os::raw::{c_char, c_int, c_void}; -#[cfg(target_os = "macos")] -use std::os::unix::ffi::OsStrExt; -#[cfg(target_os = "macos")] -use std::sync::OnceLock; - #[derive(Debug, Clone, PartialEq, Eq)] struct BundleRuntimePaths { - gstreamer_plugins: PathBuf, - gstreamer_scanner: PathBuf, - gio_modules: PathBuf, - libsoup: PathBuf, - ca_bundle: PathBuf, - vulkan_icd: Option, + vulkan_icd: PathBuf, } fn bundle_runtime_paths(executable: &Path) -> Option { @@ -31,28 +17,11 @@ fn bundle_runtime_paths(executable: &Path) -> Option { return None; } - let gstreamer_plugins = contents.join("Resources/gstreamer-1.0"); - let gstreamer_scanner = contents.join("Helpers/gst-plugin-scanner"); - let gio_modules = contents.join("Resources/gio/modules"); - let libsoup = contents.join("Frameworks/libsoup-3.0.0.dylib"); - let ca_bundle = contents.join("Resources/tls/cacert.pem"); - if !gstreamer_plugins.is_dir() - || !gstreamer_scanner.is_file() - || !gio_modules.is_dir() - || !libsoup.is_file() - || !ca_bundle.is_file() - { + let vulkan_icd = contents.join("Resources/vulkan/icd.d/MoltenVK_icd.json"); + if !vulkan_icd.is_file() { return None; } - let icd = contents.join("Resources/vulkan/icd.d/MoltenVK_icd.json"); - Some(BundleRuntimePaths { - gstreamer_plugins, - gstreamer_scanner, - gio_modules, - libsoup, - ca_bundle, - vulkan_icd: icd.is_file().then_some(icd), - }) + Some(BundleRuntimePaths { vulkan_icd }) } /// Configure dynamic runtime discovery before any worker threads are created. @@ -67,101 +36,12 @@ pub fn configure() { let Some(paths) = bundle_runtime_paths(&executable) else { return; }; - let registry_root = std::env::var_os("HOME") - .map(PathBuf::from) - .map(|home| { - home.join("Library/Caches/io.github.lowband21.FerrexPlayer") - }) - .unwrap_or_else(|| { - std::env::temp_dir().join("io.github.lowband21.FerrexPlayer") - }); - if std::fs::create_dir_all(®istry_root).is_err() { - return; - } - let registry = registry_root.join("gstreamer-registry-1.0.bin"); - // SAFETY: `main` calls this as its first operation, before application, - // logging, GStreamer, or mpv threads exist. No concurrent environment - // access can have been initiated by Ferrex at this point. + // logging, or mpv threads exist. No concurrent environment access can + // have been initiated by Ferrex at this point. unsafe { - std::env::set_var( - "GST_PLUGIN_SYSTEM_PATH_1_0", - &paths.gstreamer_plugins, - ); - std::env::set_var("GST_PLUGIN_PATH_1_0", &paths.gstreamer_plugins); - std::env::set_var("GST_PLUGIN_SCANNER_1_0", &paths.gstreamer_scanner); - std::env::set_var("GST_PLUGIN_SCANNER", &paths.gstreamer_scanner); - std::env::set_var("GST_REGISTRY_1_0", registry); - std::env::set_var("GIO_EXTRA_MODULES", &paths.gio_modules); - if let Some(icd) = paths.vulkan_icd { - std::env::set_var("VK_ICD_FILENAMES", icd); - } + std::env::set_var("VK_ICD_FILENAMES", paths.vulkan_icd); } - configure_bundle_tls(&paths.ca_bundle); - preload_bundled_libsoup(&paths.libsoup); -} - -#[cfg(target_os = "macos")] -fn configure_bundle_tls(path: &Path) { - static DATABASE: OnceLock = OnceLock::new(); - let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { - return; - }; - // SAFETY: GIO is linked into the macOS player; the path is a live, - // NUL-terminated bundle resource. The database reference is intentionally - // retained for process lifetime after becoming the backend default. - let database = - unsafe { g_tls_file_database_new(path.as_ptr(), std::ptr::null_mut()) }; - let backend = unsafe { g_tls_backend_get_default() }; - if database.is_null() || backend.is_null() { - eprintln!( - "bundled CA database initialization failed; bundled GStreamer HTTPS runtime is unavailable" - ); - return; - } - unsafe { g_tls_backend_set_default_database(backend, database) }; - let _ = DATABASE.set(database as usize); -} - -#[cfg(target_os = "macos")] -fn preload_bundled_libsoup(path: &Path) { - const RTLD_LAZY: c_int = 0x1; - const RTLD_GLOBAL: c_int = 0x8; - static HANDLE: OnceLock = OnceLock::new(); - - let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { - return; - }; - // SAFETY: the path points at the closure-audited bundled dylib, the C - // string is NUL-terminated, and the returned handle is intentionally kept - // alive for the process so GStreamer's later bare-leaf dlopen coalesces it. - let handle = unsafe { dlopen(path.as_ptr(), RTLD_LAZY | RTLD_GLOBAL) }; - if !handle.is_null() { - let _ = HANDLE.set(handle as usize); - } else { - eprintln!( - "bundled libsoup preload failed; bundled GStreamer network runtime is unavailable" - ); - } -} - -#[cfg(target_os = "macos")] -unsafe extern "C" { - fn dlopen(path: *const c_char, mode: c_int) -> *mut c_void; -} - -#[cfg(target_os = "macos")] -#[link(name = "gio-2.0")] -unsafe extern "C" { - fn g_tls_file_database_new( - anchors: *const c_char, - error: *mut *mut c_void, - ) -> *mut c_void; - fn g_tls_backend_get_default() -> *mut c_void; - fn g_tls_backend_set_default_database( - backend: *mut c_void, - database: *mut c_void, - ); } #[cfg(test)] @@ -190,37 +70,14 @@ mod tests { assert_eq!(bundle_runtime_paths(&executable), None); - let plugins = contents.join("Resources/gstreamer-1.0"); - let scanner = contents.join("Helpers/gst-plugin-scanner"); - let gio_modules = contents.join("Resources/gio/modules"); - let libsoup = contents.join("Frameworks/libsoup-3.0.0.dylib"); - let ca_bundle = contents.join("Resources/tls/cacert.pem"); let icd = contents.join("Resources/vulkan/icd.d/MoltenVK_icd.json"); - std::fs::create_dir_all(&plugins).expect("create plugins"); - std::fs::create_dir_all(scanner.parent().expect("scanner parent")) - .expect("create helpers"); - std::fs::write(&scanner, []).expect("create scanner"); - std::fs::create_dir_all(&gio_modules).expect("create GIO modules"); - std::fs::create_dir_all(libsoup.parent().expect("Frameworks parent")) - .expect("create Frameworks"); - std::fs::write(&libsoup, []).expect("create libsoup"); - std::fs::create_dir_all(ca_bundle.parent().expect("CA parent")) - .expect("create CA directory"); - std::fs::write(&ca_bundle, b"certificate").expect("create CA bundle"); std::fs::create_dir_all(icd.parent().expect("ICD parent")) .expect("create ICD dir"); std::fs::write(&icd, b"{}").expect("create ICD"); assert_eq!( bundle_runtime_paths(&executable), - Some(BundleRuntimePaths { - gstreamer_plugins: plugins, - gstreamer_scanner: scanner, - gio_modules, - libsoup, - ca_bundle, - vulkan_icd: Some(icd), - }) + Some(BundleRuntimePaths { vulkan_icd: icd }) ); std::fs::remove_dir_all(root).expect("remove fixture"); } diff --git a/scripts/release/https_test_server.py b/scripts/release/https_test_server.py deleted file mode 100644 index ed6e401a..00000000 --- a/scripts/release/https_test_server.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -"""Serve a directory over loopback HTTPS for clean-bundle runtime smoke.""" - -from __future__ import annotations - -import argparse -import functools -import http.server -import ssl -from pathlib import Path - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--directory", type=Path, required=True) - parser.add_argument("--certificate", type=Path, required=True) - parser.add_argument("--key", type=Path, required=True) - parser.add_argument("--port", type=int, required=True) - arguments = parser.parse_args() - - handler = functools.partial( - http.server.SimpleHTTPRequestHandler, - directory=str(arguments.directory), - ) - server = http.server.ThreadingHTTPServer(("127.0.0.1", arguments.port), handler) - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - context.load_cert_chain(arguments.certificate, arguments.key) - server.socket = context.wrap_socket(server.socket, server_side=True) - server.serve_forever() - - -if __name__ == "__main__": - main() diff --git a/scripts/release/macos-build-libmpv.sh b/scripts/release/macos-build-libmpv.sh index 0f9f337d..d0c4ce5b 100644 --- a/scripts/release/macos-build-libmpv.sh +++ b/scripts/release/macos-build-libmpv.sh @@ -20,12 +20,9 @@ readonly LIBASS_COMMIT="bbb3c7f1570a4a021e52683f3fbdf74fe492ae84" readonly LUA_VERSION="5.2.4" readonly LUA_ARCHIVE_SHA256="b9e2e4aad6789b3b63a056d442f7b39f0ecfca3ae0f1fc0ae4e9614401b69f4b" readonly LUA_ARCHIVE_URL="https://www.lua.org/ftp/lua-${LUA_VERSION}.tar.gz" -readonly GSTREAMER_VERSION="1.28.5" -readonly GSTREAMER_SOURCE_SHA256="c53f71b93aefda6864ea09bc845295d89c57afd4d9335f9f8c9a98c969b7693f" readonly FFMPEG_REPOSITORY="https://github.com/FFmpeg/FFmpeg.git" readonly LIBPLACEBO_REPOSITORY="https://github.com/haasn/libplacebo.git" readonly LIBASS_REPOSITORY="https://github.com/libass/libass.git" -script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ $# -lt 1 || $# -gt 2 ]]; then echo "usage: $0 OUTPUT_PREFIX [WORK_DIRECTORY]" >&2 @@ -115,11 +112,6 @@ for module in freetype2 fribidi harfbuzz vulkan shaderc; do exit 1 fi done -if ! pkg-config --exact-version="$GSTREAMER_VERSION" gstreamer-1.0; then - echo "Ferrex macOS releases require reviewed GStreamer $GSTREAMER_VERSION" >&2 - exit 1 -fi - ffmpeg_build="$dependency_build_directory/ffmpeg" mkdir -p "$ffmpeg_build" ( @@ -347,7 +339,6 @@ profile_directory="$prefix/share/ferrex/native-mpv" mkdir -p "$profile_directory" homebrew_direct_formulae=( freetype fribidi harfbuzz molten-vk shaderc vulkan-headers vulkan-loader - ca-certificates glib glib-networking gstreamer libsoup openssl@3 ) homebrew_formulae="$profile_directory/homebrew-formulae.txt" { @@ -358,33 +349,6 @@ xargs brew list --versions <"$homebrew_formulae" \ >"$profile_directory/homebrew-build-inputs.txt" xargs brew info --json=v2 <"$homebrew_formulae" \ >"$profile_directory/homebrew-build-inputs.json" -ca_certificates_bundle="$(brew --prefix ca-certificates)/share/ca-certificates/cacert.pem" -if [[ ! -f "$ca_certificates_bundle" ]]; then - echo "immutable Homebrew Mozilla CA bundle is missing: $ca_certificates_bundle" >&2 - exit 1 -fi -ca_certificates_version="$(brew list --versions ca-certificates | awk 'NF >= 2 { print $2; exit }')" -IFS=$'\t' read -r ca_certificates_source ca_certificates_source_sha256 < <( - python3 - "$profile_directory/homebrew-build-inputs.json" <<'PY' -import json -import sys - -metadata = json.load(open(sys.argv[1], encoding="utf-8")) -formula = next( - item for item in metadata["formulae"] if item["name"] == "ca-certificates" -) -stable = formula["urls"]["stable"] -print(stable["url"], stable["checksum"], sep="\t") -PY -) -ca_certificates_bundle_sha256="$(shasum -a 256 "$ca_certificates_bundle" | awk '{ print $1 }')" -if [[ -z "$ca_certificates_version" \ - || -z "$ca_certificates_source" \ - || -z "$ca_certificates_source_sha256" \ - || "$ca_certificates_bundle_sha256" != "$ca_certificates_source_sha256" ]]; then - echo "Homebrew Mozilla CA bundle does not match its recorded formula source" >&2 - exit 1 -fi { printf 'mpv_version=%s\n' "$MPV_VERSION" printf 'mpv_client_api=%s\n' "$actual_client_api" @@ -416,18 +380,8 @@ fi printf 'lua_version=%s\n' "$LUA_VERSION" printf 'lua_source=%s\n' "$LUA_ARCHIVE_URL" printf 'lua_source_sha256=%s\n' "$LUA_ARCHIVE_SHA256" - printf 'gstreamer_version=%s\n' "$GSTREAMER_VERSION" - printf 'gstreamer_source_sha256=%s\n' "$GSTREAMER_SOURCE_SHA256" - printf 'gstreamer_profile=homebrew-bottle-curated-lgpl-runtime\n' - printf 'gstreamer_plugins_sha256=%s\n' "$(shasum -a 256 "$script_directory/macos-gstreamer-plugins.txt" | awk '{print $1}')" - printf 'ca_certificates_version=%s\n' "$ca_certificates_version" - printf 'ca_certificates_source=%s\n' "$ca_certificates_source" - printf 'ca_certificates_source_sha256=%s\n' "$ca_certificates_source_sha256" - printf 'ca_certificates_bundle_sha256=%s\n' "$ca_certificates_bundle_sha256" printf 'macos_deployment_target=%s\n' "$MACOSX_DEPLOYMENT_TARGET" } >"$profile_directory/build-profile.txt" -cp "$script_directory/macos-gstreamer-plugins.txt" \ - "$profile_directory/gstreamer-plugin-allowlist.txt" printf '%s\n' "$ffmpeg_configuration" \ >"$profile_directory/ffmpeg-build-configuration.txt" cp "$source_directory/Copyright" \ diff --git a/scripts/release/macos-gstreamer-plugins.txt b/scripts/release/macos-gstreamer-plugins.txt deleted file mode 100644 index c95d4a49..00000000 --- a/scripts/release/macos-gstreamer-plugins.txt +++ /dev/null @@ -1,30 +0,0 @@ -# Reviewed macOS GStreamer runtime surface. Homebrew's current monolithic -# formula also ships GPL/codec plugins and a libav plugin that conflicts with -# the pinned FFmpeg SONAME closure; release packaging stages only this set. -libgstcoreelements.dylib -libgstplayback.dylib -libgstapp.dylib -libgstvideoconvertscale.dylib -libgstaudioconvert.dylib -libgstaudioresample.dylib -libgstvolume.dylib -libgsttypefindfunctions.dylib -libgstautodetect.dylib -libgstosxaudio.dylib -libgstapplemedia.dylib -libgstisomp4.dylib -libgstmatroska.dylib -libgstmpegtsdemux.dylib -libgstadaptivedemux2.dylib -libgstvideoparsersbad.dylib -libgstaudioparsers.dylib -libgstsoup.dylib -libgstaudiofx.dylib -libgstgio.dylib -libgstrawparse.dylib -libgstflac.dylib -libgstmpg123.dylib -libgstopus.dylib -libgstvorbis.dylib -libgstaom.dylib -libgstvpx.dylib diff --git a/scripts/release/macos_gstreamer_bundle_smoke.c b/scripts/release/macos_gstreamer_bundle_smoke.c deleted file mode 100644 index 4d8a9e7c..00000000 --- a/scripts/release/macos_gstreamer_bundle_smoke.c +++ /dev/null @@ -1,122 +0,0 @@ -/* Exercise the staged GStreamer closure without linking to Homebrew dylibs. */ - -#include -#include -#import -#include -#include - -static int audit_factories(void) { - static const char *required[] = { - "playbin3", - "appsink", - "videoconvertscale", - "scaletempo", - "vtdec", - "atdec", - "qtdemux", - "h264parse", - "souphttpsrc", - "osxaudiosink", - }; - - for (size_t index = 0; index < G_N_ELEMENTS(required); index++) { - GstElementFactory *factory = gst_element_factory_find(required[index]); - if (factory == NULL) { - fprintf(stderr, "required clean-bundle factory is missing: %s\n", required[index]); - return 1; - } - gst_object_unref(factory); - } - - GstElementFactory *forbidden = gst_element_factory_find("avdec_h264"); - if (forbidden != NULL) { - fprintf(stderr, "excluded gst-libav factory leaked into the bundle: avdec_h264\n"); - gst_object_unref(forbidden); - return 1; - } - return 0; -} - -int main(int argc, char **argv) { - GstElement *pipeline; - GstElement *audio_sink; - GstElement *video_sink; - GstBus *bus; - GstMessage *message; - GTlsDatabase *database; - GError *tls_error = NULL; - int result = 1; - - if (argc != 4) { - fprintf(stderr, "usage: macos_gstreamer_bundle_smoke URL LIBSOUP CA_PEM\n"); - return 2; - } - [NSApplication sharedApplication]; - if (dlopen(argv[2], RTLD_LAZY | RTLD_GLOBAL) == NULL) { - fprintf(stderr, "could not preload bundled libsoup: %s\n", dlerror()); - return 1; - } - database = g_tls_file_database_new(argv[3], &tls_error); - if (database == NULL) { - fprintf(stderr, "could not load test CA database: %s\n", tls_error->message); - g_clear_error(&tls_error); - return 1; - } - g_tls_backend_set_default_database(g_tls_backend_get_default(), database); - g_object_unref(database); - gst_init(&argc, &argv); - if (audit_factories() != 0) { - return 1; - } - pipeline = gst_element_factory_make("playbin3", NULL); - audio_sink = gst_element_factory_make("fakesink", NULL); - video_sink = gst_element_factory_make("fakesink", NULL); - if (pipeline == NULL || audio_sink == NULL || video_sink == NULL) { - fprintf(stderr, "required clean-bundle GStreamer factories are missing\n"); - goto cleanup_elements; - } - g_object_set(audio_sink, "sync", FALSE, NULL); - g_object_set(video_sink, "sync", FALSE, NULL); - g_object_set( - pipeline, - "uri", argv[1], - "audio-sink", audio_sink, - "video-sink", video_sink, - NULL - ); - if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { - fprintf(stderr, "clean-bundle HLS pipeline could not start\n"); - goto cleanup_pipeline; - } - - bus = gst_element_get_bus(pipeline); - message = gst_bus_timed_pop_filtered( - bus, - 30 * GST_SECOND, - GST_MESSAGE_ERROR | GST_MESSAGE_EOS - ); - if (message == NULL) { - fprintf(stderr, "clean-bundle HLS pipeline timed out\n"); - } else if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) { - GError *error = NULL; - gchar *debug = NULL; - gst_message_parse_error(message, &error, &debug); - fprintf(stderr, "clean-bundle HLS pipeline failed: %s\n", error->message); - g_clear_error(&error); - g_free(debug); - gst_message_unref(message); - } else { - gst_message_unref(message); - result = 0; - } - gst_object_unref(bus); - -cleanup_pipeline: - gst_element_set_state(pipeline, GST_STATE_NULL); -cleanup_elements: - if (pipeline != NULL) gst_object_unref(pipeline); - if (audio_sink != NULL) gst_object_unref(audio_sink); - if (video_sink != NULL) gst_object_unref(video_sink); - return result; -} diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 884f75cd..97f62c5e 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -9,12 +9,6 @@ ROOT = Path(__file__).resolve().parents[2] BUILD = (ROOT / "scripts/release/macos-build-libmpv.sh").read_text(encoding="utf-8") -PLUGINS = (ROOT / "scripts/release/macos-gstreamer-plugins.txt").read_text( - encoding="utf-8" -) -SMOKE = (ROOT / "scripts/release/macos_gstreamer_bundle_smoke.c").read_text( - encoding="utf-8" -) WORKFLOW = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") RELEASE_WORKFLOW = (ROOT / ".github/workflows/release.yml").read_text( encoding="utf-8" @@ -93,84 +87,29 @@ def test_homebrew_trust_is_transitive_but_not_prefix_wide(self) -> None: self.assertIn("homebrew-allowed-roots.txt", BUILD) self.assertNotIn('"$brew_prefix"/*', BUILD) - def test_curated_plugins_avoid_conflicting_or_gpl_codec_surfaces(self) -> None: - for required in ( - "libgstplayback.dylib", - "libgstapp.dylib", - "libgstvideoconvertscale.dylib", - "libgstapplemedia.dylib", - "libgstosxaudio.dylib", - "libgstaudiofx.dylib", - "libgstaom.dylib", - ): - self.assertIn(required, PLUGINS) - for forbidden in ( - "libgstlibav.dylib", - "libgstassrender.dylib", - "libgstx264.dylib", - "libgstx265.dylib", - "libgstscaletempo.dylib", - ): - self.assertNotIn(forbidden, PLUGINS) - self.assertNotIn("libgstdav1d.dylib", PLUGINS) - - def test_workflows_stage_moltenvk_and_clean_gstreamer_runtime(self) -> None: + def test_workflow_stages_mpv_runtime_without_gstreamer_runtime(self) -> None: for expected in ( "molten-vk shaderc vulkan-headers vulkan-loader", "--extra-library", "libMoltenVK.dylib", - "libsoup-3.0.0.dylib", - "--ca-bundle", - "ca-certificates", - "strict HTTPS smoke unexpectedly trusted", "MoltenVK_icd.json", - "macos-gstreamer-plugins.txt", - "gst-plugin-scanner", - "GST_REGISTRY_1_0", - "GIO_EXTRA_MODULES", - "Contents/Resources/gstreamer-1.0", - "Contents/Resources/gio/modules", - "libgiognutls", - "https_test_server.py", - "libgstreamer-1.0.0.dylib", - "libgio-2.0.0.dylib", - "libgobject-2.0.0.dylib", - "libglib-2.0.0.dylib", - "-x objective-c -c", - "ferrex-gstreamer-bundle-smoke.o", - "-framework AppKit", ): self.assertIn(expected, WORKFLOW) - self.assertNotIn("pkg-config --libs-only-l", WORKFLOW) - self.assertNotIn("export DYLD_LIBRARY_PATH", WORKFLOW) - self.assertNotIn("Contents/PlugIns/gstreamer-1.0", WORKFLOW) - self.assertNotIn("Contents/PlugIns/gio/modules", WORKFLOW) - for expected in ( - "avdec_h264", - "atdec", - "g_tls_file_database_new", - "[NSApplication sharedApplication]", - ): - self.assertIn(expected, SMOKE) - - def test_ca_bundle_is_immutable_and_provenance_recorded(self) -> None: - immutable_path = "share/ca-certificates/cacert.pem" - merged_path = "etc/ca-certificates/cert.pem" - self.assertIn(immutable_path, BUILD) - self.assertIn(immutable_path, WORKFLOW) - self.assertNotIn(merged_path, WORKFLOW) - for field in ( - "ca_certificates_version", - "ca_certificates_source", - "ca_certificates_source_sha256", - "ca_certificates_bundle_sha256", + for removed_runtime in ( + "--gstreamer-plugin", + "--gstreamer-scanner", + "--gio-module", + "--ca-bundle", + "libsoup-3.0.0.dylib", + "Smoke bundled GStreamer runtime closure (macOS)", ): - self.assertIn(field, BUILD) + self.assertNotIn(removed_runtime, WORKFLOW) + self.assertNotIn("export DYLD_LIBRARY_PATH", WORKFLOW) - def test_gstreamer_version_and_allowlist_are_exact(self) -> None: - self.assertIn('GSTREAMER_VERSION="1.28.5"', BUILD) - self.assertIn("pkg-config --exact-version=", BUILD) - self.assertIn("gstreamer_plugins_sha256", BUILD) + def test_native_profile_is_mpv_only(self) -> None: + self.assertNotIn("GSTREAMER_VERSION", BUILD) + self.assertNotIn("gstreamer_", BUILD) + self.assertNotIn("ca_certificates_", BUILD) self.assertIn('MACOSX_DEPLOYMENT_TARGET:=15.0', BUILD) self.assertIn('MACOSX_DEPLOYMENT_TARGET="15.0"', WORKFLOW) From 2f6fea3460d1f9b8c3fa75c0467b8d8016ddf2a0 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 20:07:37 -0600 Subject: [PATCH 42/56] fix(macos): disable foreign window systems --- scripts/release/macos-build-libmpv.sh | 6 ++++++ scripts/release/test_macos_build_profile.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/scripts/release/macos-build-libmpv.sh b/scripts/release/macos-build-libmpv.sh index d0c4ce5b..1e0762ca 100644 --- a/scripts/release/macos-build-libmpv.sh +++ b/scripts/release/macos-build-libmpv.sh @@ -277,6 +277,8 @@ meson setup "$build_directory" "$source_directory" \ -Dvideotoolbox-gl=enabled \ -Dgl=enabled \ -Dgl-cocoa=enabled \ + -Dwayland=disabled \ + -Dx11=disabled \ -Dvulkan=enabled \ -Dshaderc=disabled \ -Dlcms2=disabled \ @@ -324,6 +326,8 @@ required = { "videotoolbox-gl": "enabled", "gl": "enabled", "gl-cocoa": "enabled", + "wayland": "disabled", + "x11": "disabled", "vulkan": "enabled", "shaderc": "disabled", "lua": "lua52", @@ -360,6 +364,8 @@ xargs brew info --json=v2 <"$homebrew_formulae" \ printf 'mpv_macos_cocoa_cb=enabled\n' printf 'mpv_videotoolbox_pl=enabled\n' printf 'mpv_gl=enabled\n' + printf 'mpv_wayland=disabled\n' + printf 'mpv_x11=disabled\n' printf 'mpv_vulkan=enabled\n' printf 'mpv_shaderc=disabled\n' printf 'mpv_lua=lua52\n' diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index 97f62c5e..f4dd0792 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -59,6 +59,8 @@ def test_mpv_profile_keeps_required_macos_paths(self) -> None: "-Dvideotoolbox-pl=enabled", "-Dvideotoolbox-gl=enabled", "-Dgl-cocoa=enabled", + "-Dwayland=disabled", + "-Dx11=disabled", "-Dvulkan=enabled", "-Dshaderc=disabled", "-Dlua=lua52", From 8766cc97e9a64df3ed2ab03c63dec93f55df80b9 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 20:34:52 -0600 Subject: [PATCH 43/56] fix(windows): stage libproxy backend closure --- utils/build-windows/stage-gstreamer-runtime.ps1 | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/utils/build-windows/stage-gstreamer-runtime.ps1 b/utils/build-windows/stage-gstreamer-runtime.ps1 index c6160864..0a2d52c8 100644 --- a/utils/build-windows/stage-gstreamer-runtime.ps1 +++ b/utils/build-windows/stage-gstreamer-runtime.ps1 @@ -17,6 +17,7 @@ $pluginManifest = (Resolve-Path $PluginManifest).Path $sourceBin = Join-Path $sourceRoot 'bin' $sourcePlugins = Join-Path $sourceRoot 'lib\gstreamer-1.0' $sourceGioModules = Join-Path $sourceRoot 'lib\gio\modules' +$sourceLibproxy = Join-Path $sourceRoot 'lib\libproxy' $stageBin = Join-Path $stageRoot 'bin' $stagePlugins = Join-Path $stageRoot 'lib\gstreamer-1.0' $stageGioModules = Join-Path $stageRoot 'lib\gio\modules' @@ -26,7 +27,7 @@ $noticeRoot = Join-Path $stageRoot 'share\licenses\gstreamer' $system32 = Join-Path $env:SystemRoot 'System32' $dumpbin = (Get-Command dumpbin.exe -ErrorAction Stop).Source -foreach ($directory in @($sourceBin, $sourcePlugins, $sourceGioModules)) { +foreach ($directory in @($sourceBin, $sourcePlugins, $sourceGioModules, $sourceLibproxy)) { if (-not (Test-Path $directory -PathType Container)) { throw "GStreamer source directory is missing: $directory" } @@ -124,11 +125,13 @@ function Get-Imports([string]$Path) { } $availableByName = @{} -foreach ($file in Get-ChildItem $sourceBin -File -Filter '*.dll') { - if ($availableByName.ContainsKey($file.Name)) { - throw "Duplicate GStreamer runtime DLL basename: $($file.Name)" +foreach ($directory in @($sourceBin, $sourceLibproxy)) { + foreach ($file in Get-ChildItem $directory -File -Filter '*.dll') { + if ($availableByName.ContainsKey($file.Name)) { + throw "Duplicate GStreamer runtime DLL basename: $($file.Name)" + } + $availableByName[$file.Name] = $file.FullName } - $availableByName[$file.Name] = $file.FullName } $localByName = @{} From 098710c0a4811c75fbb71d2e41f2b8b0a65f0960 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 20:45:17 -0600 Subject: [PATCH 44/56] fix(macos): disable X11 clipboard probing --- scripts/release/macos-build-libmpv.sh | 3 +++ scripts/release/test_macos_build_profile.py | 1 + 2 files changed, 4 insertions(+) diff --git a/scripts/release/macos-build-libmpv.sh b/scripts/release/macos-build-libmpv.sh index 1e0762ca..05801f6c 100644 --- a/scripts/release/macos-build-libmpv.sh +++ b/scripts/release/macos-build-libmpv.sh @@ -279,6 +279,7 @@ meson setup "$build_directory" "$source_directory" \ -Dgl-cocoa=enabled \ -Dwayland=disabled \ -Dx11=disabled \ + -Dx11-clipboard=disabled \ -Dvulkan=enabled \ -Dshaderc=disabled \ -Dlcms2=disabled \ @@ -328,6 +329,7 @@ required = { "gl-cocoa": "enabled", "wayland": "disabled", "x11": "disabled", + "x11-clipboard": "disabled", "vulkan": "enabled", "shaderc": "disabled", "lua": "lua52", @@ -366,6 +368,7 @@ xargs brew info --json=v2 <"$homebrew_formulae" \ printf 'mpv_gl=enabled\n' printf 'mpv_wayland=disabled\n' printf 'mpv_x11=disabled\n' + printf 'mpv_x11_clipboard=disabled\n' printf 'mpv_vulkan=enabled\n' printf 'mpv_shaderc=disabled\n' printf 'mpv_lua=lua52\n' diff --git a/scripts/release/test_macos_build_profile.py b/scripts/release/test_macos_build_profile.py index f4dd0792..fc843d7e 100644 --- a/scripts/release/test_macos_build_profile.py +++ b/scripts/release/test_macos_build_profile.py @@ -61,6 +61,7 @@ def test_mpv_profile_keeps_required_macos_paths(self) -> None: "-Dgl-cocoa=enabled", "-Dwayland=disabled", "-Dx11=disabled", + "-Dx11-clipboard=disabled", "-Dvulkan=enabled", "-Dshaderc=disabled", "-Dlua=lua52", From c682160c4fd26c1306a2b649a2c4b53498c7bd83 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 21:09:06 -0600 Subject: [PATCH 45/56] fix(windows): accept normalized DLL manifest paths --- utils/build-windows/verify-runtime-closure.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index 48899409..4f548690 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -169,7 +169,7 @@ $manifestedNames = [System.Collections.Generic.HashSet[string]]::new( ) foreach ($line in Get-Content $manifest) { if ([string]::IsNullOrWhiteSpace($line)) { continue } - if ($line -notmatch '^([0-9A-Fa-f]{64})\s+\*?([^\\/]+\.dll)$') { + if ($line -notmatch '^([0-9A-Fa-f]{64})\s+\*?(?:\./)?([^\\/]+\.dll)$') { throw "Invalid libmpv runtime-DLL manifest entry: $line" } $expected = $Matches[1] From 4311eb87f70bc11a4e1c3a3d99458a2b53fcb503 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 21:46:11 -0600 Subject: [PATCH 46/56] fix(macos): compile out GStreamer playback --- Cargo.lock | 2 -- crates/ferrex-player-playback/Cargo.toml | 9 ++++-- crates/ferrex-player-playback/src/lib.rs | 1 + crates/ferrex-player-playback/src/session.rs | 21 ++++++++++++- crates/ferrex-player-playback/src/video.rs | 33 ++++++++++++-------- crates/ferrex-player-ui/Cargo.toml | 7 ----- 6 files changed, 47 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8f1cee0..3abe8af6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2538,8 +2538,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "subwave_core", - "subwave_unified", "sysinfo 0.37.2", "tempfile", "thiserror 2.0.18", diff --git a/crates/ferrex-player-playback/Cargo.toml b/crates/ferrex-player-playback/Cargo.toml index 09d6c657..40267b0e 100644 --- a/crates/ferrex-player-playback/Cargo.toml +++ b/crates/ferrex-player-playback/Cargo.toml @@ -42,9 +42,6 @@ ferrex-player-mpv = { workspace = true, optional = true } iced = { git = "https://github.com/Lowband21/iced-ferrex.git", default-features = false, features = ["tokio", "image", "canvas", "advanced", "wgpu", "wayland", "batch", "lazy", "crisp"] } iced_wgpu = { git = "https://github.com/Lowband21/iced-ferrex.git", features = ["image", "batch"], optional = true } lucide-icons = { git = "https://github.com/Lowband21/lucide-icons-iced-0.14.git", features = ["iced"], optional = true } -subwave_unified = { git = "https://github.com/Lowband21/subwave.git", branch = "main" } -subwave_core = { git = "https://github.com/Lowband21/subwave.git", branch = "main" } - futures.workspace = true log.workspace = true serde.workspace = true @@ -57,6 +54,12 @@ urlencoding = "2" dirs = "6" profiling = { version = "1.0", optional = true } +[target.'cfg(not(target_os = "macos"))'.dependencies] +# GStreamer playback remains available on Linux and Windows. macOS is an +# mpv-only platform until a supported fallback is deliberately reintroduced. +subwave_unified = { git = "https://github.com/Lowband21/subwave.git", branch = "main" } +subwave_core = { git = "https://github.com/Lowband21/subwave.git", branch = "main" } + [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.61.2", features = [ "Win32_Foundation", diff --git a/crates/ferrex-player-playback/src/lib.rs b/crates/ferrex-player-playback/src/lib.rs index 86ce655f..d4629d01 100644 --- a/crates/ferrex-player-playback/src/lib.rs +++ b/crates/ferrex-player-playback/src/lib.rs @@ -42,6 +42,7 @@ pub mod presenter; pub mod session; /// Playback state container and notification DTOs. pub mod state; +#[cfg(not(target_os = "macos"))] mod subwave_adapter; /// Playback UI theme helpers. #[cfg(feature = "ui")] diff --git a/crates/ferrex-player-playback/src/session.rs b/crates/ferrex-player-playback/src/session.rs index 8fb2fbc6..8b890b74 100644 --- a/crates/ferrex-player-playback/src/session.rs +++ b/crates/ferrex-player-playback/src/session.rs @@ -17,6 +17,8 @@ use crate::contract::PresenterEvent; any(target_os = "windows", target_os = "macos") ))] use crate::native_presentation::NativePresentation; +#[cfg(not(target_os = "macos"))] +use crate::subwave_adapter::SubwavePlaybackAdapter; use crate::{ contract::{ BackendRequest, ChapterId, EditionId, PlaybackCommand, PlaybackError, @@ -24,7 +26,6 @@ use crate::{ PlaybackSnapshot, TrackCatalog, TrackId, VideoProfileName, }, diagnostics::PlaybackDiagnosticSnapshot, - subwave_adapter::SubwavePlaybackAdapter, }; #[cfg(feature = "mpv")] use crate::{ @@ -77,6 +78,7 @@ impl PlaybackShutdownBarrier { } enum BackendSession { + #[cfg(not(target_os = "macos"))] Subwave(Box), #[cfg(feature = "mpv")] Mpv(Box), @@ -522,6 +524,7 @@ impl PlaybackSession { } } + #[cfg(not(target_os = "macos"))] pub(crate) fn from_subwave( adapter: SubwavePlaybackAdapter, requested_backend: BackendRequest, @@ -552,6 +555,7 @@ impl PlaybackSession { pub fn snapshot(&self) -> &PlaybackSnapshot { match &self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => adapter.snapshot(), #[cfg(feature = "mpv")] BackendSession::Mpv(adapter) => adapter.snapshot(), @@ -561,6 +565,7 @@ impl PlaybackSession { /// Redacted backend, lifecycle, version, and native-output observations. pub fn diagnostics(&self) -> PlaybackDiagnosticSnapshot { match &self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => { adapter.diagnostics(self.requested_backend) } @@ -576,6 +581,7 @@ impl PlaybackSession { command: PlaybackCommand, ) -> Result<(), PlaybackError> { match &mut self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => adapter.apply_command(command), #[cfg(feature = "mpv")] BackendSession::Mpv(adapter) => adapter.apply_command(command), @@ -588,6 +594,7 @@ impl PlaybackSession { &mut self, ) -> Result, PlaybackError> { match &mut self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => { adapter.apply_command(PlaybackCommand::Shutdown)?; Ok(None) @@ -604,6 +611,7 @@ impl PlaybackSession { pub fn synchronize_snapshot(&mut self) { match &mut self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => { adapter.synchronize_core_properties(); } @@ -614,6 +622,7 @@ impl PlaybackSession { pub fn refresh_tracks(&mut self) -> TrackCatalog { match &mut self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => adapter.refresh_tracks(), #[cfg(feature = "mpv")] BackendSession::Mpv(adapter) => adapter.refresh_tracks(), @@ -664,6 +673,7 @@ impl PlaybackSession { pub fn set_subtitles_enabled(&mut self, enabled: bool) { match &mut self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => { adapter.set_subtitles_enabled(enabled); } @@ -676,6 +686,7 @@ impl PlaybackSession { pub fn subtitles_enabled(&self) -> bool { match &self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => adapter.subtitles_enabled(), #[cfg(feature = "mpv")] BackendSession::Mpv(adapter) => adapter.subtitles_enabled(), @@ -760,6 +771,7 @@ impl PlaybackSession { pub fn has_video(&self) -> bool { match &self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => adapter.has_video(), #[cfg(feature = "mpv")] BackendSession::Mpv(adapter) => adapter.has_video(), @@ -770,6 +782,7 @@ impl PlaybackSession { /// represent a generic playback capability. pub fn is_appsink(&self) -> bool { match &self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => adapter.is_appsink(), #[cfg(feature = "mpv")] BackendSession::Mpv(_) => false, @@ -778,6 +791,7 @@ impl PlaybackSession { pub fn uses_wayland_surface(&self) -> bool { match &self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => adapter.uses_wayland_surface(), #[cfg(feature = "mpv")] BackendSession::Mpv(_) => false, @@ -786,6 +800,7 @@ impl PlaybackSession { pub fn toggle_diagnostic_backend(&mut self) -> Result<(), PlaybackError> { match &mut self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => { adapter.toggle_diagnostic_backend() } @@ -799,6 +814,7 @@ impl PlaybackSession { pub fn force_appsink(&mut self) -> Result<(), PlaybackError> { match &mut self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => adapter.force_appsink(), #[cfg(feature = "mpv")] BackendSession::Mpv(_) => Err(PlaybackError::new( @@ -811,6 +827,7 @@ impl PlaybackSession { /// Backend-neutral readiness signal for copied asynchronous events. pub fn event_signal(&self) -> Option { match &self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(_) => None, #[cfg(feature = "mpv")] BackendSession::Mpv(adapter) => Some(adapter.event_signal()), @@ -821,6 +838,7 @@ impl PlaybackSession { /// migration-only bounded legacy synchronization timer. pub fn uses_event_driven_snapshots(&self) -> bool { match &self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(_) => false, #[cfg(feature = "mpv")] BackendSession::Mpv(_) => true, @@ -954,6 +972,7 @@ impl PlaybackSession { Theme: 'a, { match &self.backend { + #[cfg(not(target_os = "macos"))] BackendSession::Subwave(adapter) => adapter.widget(content_fit), #[cfg(feature = "mpv")] BackendSession::Mpv(adapter) => { diff --git a/crates/ferrex-player-playback/src/video.rs b/crates/ferrex-player-playback/src/video.rs index 5a131d75..8a666e40 100644 --- a/crates/ferrex-player-playback/src/video.rs +++ b/crates/ferrex-player-playback/src/video.rs @@ -3,18 +3,21 @@ //! This module coordinates the unified video backend, stream URL redaction, //! loading flags, and playback state transitions around media file playback. +#[cfg(not(target_os = "macos"))] +use crate::contract::FallbackReason; #[cfg(feature = "mpv")] use crate::mpv_adapter::MpvPlaybackAdapter; +#[cfg(not(target_os = "macos"))] +use crate::subwave_adapter::SubwavePlaybackAdapter; use crate::{ PlayerMessage, contract::{ - BackendKind, BackendRequest, FallbackReason, FallbackReasonCode, - PlaybackCommand, PlaybackContentFit, PlaybackError, PlaybackErrorKind, - PlaybackSource, PlaybackTarget, SessionGeneration, + BackendKind, BackendRequest, FallbackReasonCode, PlaybackCommand, + PlaybackContentFit, PlaybackError, PlaybackErrorKind, PlaybackSource, + PlaybackTarget, SessionGeneration, }, session::{PlaybackSession, PlaybackShutdownBarrier}, state::PlayerDomainState, - subwave_adapter::SubwavePlaybackAdapter, update::{PlaybackUiShell, PlaybackUpdatePort}, }; @@ -400,16 +403,20 @@ pub fn open_playback_session( return Err(error); } - let mut adapter = SubwavePlaybackAdapter::open(source, start, generation)?; - if let Some((code, detail)) = fallback { - adapter.record_fallback(FallbackReason { - code, - from: requested_mpv_target, - to: adapter.snapshot().target, - detail, - }); + #[cfg(not(target_os = "macos"))] + { + let mut adapter = + SubwavePlaybackAdapter::open(source, start, generation)?; + if let Some((code, detail)) = fallback { + adapter.record_fallback(FallbackReason { + code, + from: requested_mpv_target, + to: adapter.snapshot().target, + detail, + }); + } + Ok(PlaybackSession::from_subwave(adapter, request)) } - Ok(PlaybackSession::from_subwave(adapter, request)) } fn requested_mpv_target( diff --git a/crates/ferrex-player-ui/Cargo.toml b/crates/ferrex-player-ui/Cargo.toml index b97f3020..155ebfdf 100644 --- a/crates/ferrex-player-ui/Cargo.toml +++ b/crates/ferrex-player-ui/Cargo.toml @@ -84,13 +84,6 @@ lucide-icons = { git = "https://github.com/Lowband21/lucide-icons-iced-0.14.git" "iced", ] } -# Unified video abstraction based on subwave -# subwave_unified = { git = "https://github.com/Lowband21/subwave.git" } -# subwave_core = { git = "https://github.com/Lowband21/subwave.git" } - -subwave_unified = { git = "https://github.com/Lowband21/subwave.git", branch = "main" } -subwave_core = { git = "https://github.com/Lowband21/subwave.git", branch = "main" } - # Async runtime tokio.workspace = true async-trait.workspace = true From d625f32a053e8d9fee4083a861f6b7fcfee34676 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 21:46:21 -0600 Subject: [PATCH 47/56] fix(windows): normalize local HLS fixture URIs --- utils/build-windows/verify-runtime-closure.ps1 | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index 4f548690..45189f25 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -426,6 +426,13 @@ try { if ($LASTEXITCODE -ne 0) { throw "Bundled GStreamer HLS fixture generation failed: $generateOutput" } + if (-not (Test-Path -LiteralPath $segment) -or (Get-Item -LiteralPath $segment).Length -eq 0) { + throw "Bundled GStreamer HLS fixture generation produced no segment" + } + # hlsdemux2's relative file-URI resolution on Windows can preserve a + # backslash in 8.3-style runner paths and then report the existing segment + # as HTTP 404. Use an explicit normalized file URI in the playlist. + $segmentUri = ([System.Uri]::new($segment)).AbsoluteUri.Replace('\', '/') $playlist = Join-Path $hlsFixtureDirectory 'stream.m3u8' @( '#EXTM3U', @@ -433,10 +440,10 @@ try { '#EXT-X-TARGETDURATION:3', '#EXT-X-MEDIA-SEQUENCE:0', '#EXTINF:2.048,', - 'segment.ts', + $segmentUri, '#EXT-X-ENDLIST' ) | Set-Content -Encoding ascii $playlist - $playlistUri = ([System.Uri]::new($playlist)).AbsoluteUri + $playlistUri = ([System.Uri]::new($playlist)).AbsoluteUri.Replace('\', '/') $playArgs = @( '-q', 'playbin3', "uri=$playlistUri", 'audio-sink=fakesink', 'video-sink=fakesink' From 2972aeaf8f16f4f76cd0b98779ec0db14b02fc26 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 21:58:20 -0600 Subject: [PATCH 48/56] fix(macos): make unavailable backend branch exhaustive --- crates/ferrex-player-playback/src/video.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ferrex-player-playback/src/video.rs b/crates/ferrex-player-playback/src/video.rs index 8a666e40..b3eb9f82 100644 --- a/crates/ferrex-player-playback/src/video.rs +++ b/crates/ferrex-player-playback/src/video.rs @@ -382,7 +382,8 @@ pub fn open_playback_session( fallback = Some((FallbackReasonCode::BackendDisabled, detail.into())); } - if cfg!(target_os = "macos") { + #[cfg(target_os = "macos")] + { let (code, detail) = fallback.unwrap_or(( FallbackReasonCode::RequestedUnavailable, "the macOS mpv backend is unavailable".to_string(), From f384787d4c76b9417b4145361ff8b4667a5a4a74 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 22:47:34 -0600 Subject: [PATCH 49/56] fix(windows): normalize generated HLS segment path --- utils/build-windows/verify-runtime-closure.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index 45189f25..227a165a 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -412,8 +412,9 @@ try { # and playbin selection without depending on a mutable public media URL. New-Item -ItemType Directory -Force -Path $hlsFixtureDirectory | Out-Null $segment = Join-Path $hlsFixtureDirectory 'segment.ts' + $segmentLocation = $segment.Replace('\', '/') $generateArgs = @( - '-q', 'mpegtsmux', 'name=mux', '!', 'filesink', "location=$segment", + '-q', 'mpegtsmux', 'name=mux', '!', 'filesink', "location=$segmentLocation", 'audiotestsrc', 'wave=sine', 'num-buffers=96', '!', 'audioconvert', '!', 'audioresample', '!', 'audio/x-raw,rate=48000,channels=2', '!', 'mfaacenc', '!', From 0a0fc38bae6539c6434a1b036a18aa120c0d8c01 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Sun, 26 Jul 2026 23:30:01 -0600 Subject: [PATCH 50/56] fix(windows): bound HLS runtime audit --- .github/workflows/ci.yml | 1 + .github/workflows/windows-dist.yml | 1 + utils/build-windows/verify-runtime-closure.ps1 | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d818f64..6e69fae6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -408,6 +408,7 @@ jobs: - name: Audit staged Windows runtime closure if: runner.os == 'Windows' + timeout-minutes: 5 shell: pwsh run: | $ErrorActionPreference = 'Stop' diff --git a/.github/workflows/windows-dist.yml b/.github/workflows/windows-dist.yml index 4b7d385a..730085c2 100644 --- a/.github/workflows/windows-dist.yml +++ b/.github/workflows/windows-dist.yml @@ -205,6 +205,7 @@ jobs: Copy-Item -Force -Recurse (Join-Path $env:LIBMPV_ROOT 'share\\licenses\\ferrex-libmpv\\*') $notices - name: Audit staged Windows runtime closure + timeout-minutes: 5 shell: pwsh run: | $ErrorActionPreference = 'Stop' diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index 227a165a..448b3a0a 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -423,6 +423,7 @@ try { 'video/x-raw,width=320,height=180,framerate=24/1', '!', 'videoconvertscale', '!', 'openh264enc', '!', 'h264parse', '!', 'mux.' ) + Write-Host 'Generating bundled GStreamer HLS fixture.' $generateOutput = (& $gstLaunch @generateArgs 2>&1) -join [Environment]::NewLine if ($LASTEXITCODE -ne 0) { throw "Bundled GStreamer HLS fixture generation failed: $generateOutput" @@ -440,6 +441,7 @@ try { '#EXT-X-VERSION:3', '#EXT-X-TARGETDURATION:3', '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-PLAYLIST-TYPE:VOD', '#EXTINF:2.048,', $segmentUri, '#EXT-X-ENDLIST' @@ -449,6 +451,7 @@ try { '-q', 'playbin3', "uri=$playlistUri", 'audio-sink=fakesink', 'video-sink=fakesink' ) + Write-Host 'Playing bundled GStreamer HLS fixture.' $playOutput = (& $gstLaunch @playArgs 2>&1) -join [Environment]::NewLine if ($LASTEXITCODE -ne 0) { throw "Bundled GStreamer HLS playback smoke failed: $playOutput" @@ -458,6 +461,7 @@ try { # clean stage; factory discovery alone does not prove HTTPS can connect. $httpsUrl = 'https://gstreamer.freedesktop.org/data/pkg/windows/1.28.4/msvc/gstreamer-1.0-msvc-x86_64-1.28.4.exe.sha256sum' $httpsArgs = @('-q', 'souphttpsrc', "location=$httpsUrl", '!', 'fakesink') + Write-Host 'Fetching bundled GStreamer HTTPS fixture.' $httpsOutput = (& $gstLaunch @httpsArgs 2>&1) -join [Environment]::NewLine if ($LASTEXITCODE -ne 0) { throw "Bundled GStreamer HTTPS smoke failed: $httpsOutput" From bb31c78785640bc986c35c9489085d3161a15c37 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Mon, 27 Jul 2026 00:03:59 -0600 Subject: [PATCH 51/56] fix(ci): avoid redundant Windows cache helper --- .github/workflows/ci.yml | 3 +++ .github/workflows/windows-dist.yml | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e69fae6..30d029ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -289,6 +289,9 @@ jobs: targets: ${{ matrix.target }} components: clippy,rustfmt profile: minimal + # The explicit actions/cache step below owns Cargo caching. Avoid + # setup-rust's cargo-cache helper and its external binary mirrors. + cache: false - name: Cache Rust/Cargo artifacts uses: actions/cache@v4 diff --git a/.github/workflows/windows-dist.yml b/.github/workflows/windows-dist.yml index 730085c2..254259cc 100644 --- a/.github/workflows/windows-dist.yml +++ b/.github/workflows/windows-dist.yml @@ -42,6 +42,7 @@ jobs: profile: minimal targets: ${{ env.TARGET }} components: clippy,rustfmt + cache: false - name: Cache pinned GStreamer MSVC SDK uses: actions/cache@v4 From 3e19b0d5d8791eabc4be29858d2274dcfcd2024b Mon Sep 17 00:00:00 2001 From: Lowband Date: Mon, 27 Jul 2026 11:21:24 -0600 Subject: [PATCH 52/56] fix(windows): bound HLS playback by decoded buffers Stop the playback smoke after both fake sinks receive decoded buffers instead of relying on Windows hlsdemux2 to propagate the one-segment VOD EOS. --- utils/build-windows/verify-runtime-closure.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index 448b3a0a..6903d3af 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -447,9 +447,13 @@ try { '#EXT-X-ENDLIST' ) | Set-Content -Encoding ascii $playlist $playlistUri = ([System.Uri]::new($playlist)).AbsoluteUri.Replace('\', '/') + # hlsdemux2 does not reliably propagate the one-segment VOD EOS through + # playbin3 on Windows. Bound both sinks by decoded buffers instead: their + # EOS proves audio and video reached the end of the playback graph. $playArgs = @( '-q', 'playbin3', "uri=$playlistUri", - 'audio-sink=fakesink', 'video-sink=fakesink' + 'audio-sink=fakesink sync=false num-buffers=8', + 'video-sink=fakesink sync=false num-buffers=8' ) Write-Host 'Playing bundled GStreamer HLS fixture.' $playOutput = (& $gstLaunch @playArgs 2>&1) -join [Environment]::NewLine From ef92d312a4ea2780dbdf09085a0726e09ee33fbf Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Mon, 27 Jul 2026 12:06:17 -0600 Subject: [PATCH 53/56] fix(windows): verify HLS streams with timed probes --- .../build-windows/verify-runtime-closure.ps1 | 65 +++++++++++++++---- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index 6903d3af..11a88964 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -374,6 +374,49 @@ $oldGioModules = $env:GIO_EXTRA_MODULES $oldSslCertFile = $env:SSL_CERT_FILE $registry = Join-Path $env:TEMP "ferrex-gst-registry-$PID.bin" $hlsFixtureDirectory = Join-Path $env:TEMP "ferrex-gst-hls-$PID" + +function Invoke-GstBufferProbe { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $gstLaunch + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $Arguments) { + [void]$startInfo.ArgumentList.Add($argument) + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw "Bundled GStreamer $Label probe did not start" + } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $exited = $process.WaitForExit(15000) + if (-not $exited) { + if (-not $process.HasExited) { + $process.Kill($true) + } + $process.WaitForExit() + } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + + if (($exited -and $process.ExitCode -ne 0) -or $stderr -match '(?m)^ERROR:') { + throw "Bundled GStreamer $Label probe failed: $stderr" + } + if ($stdout -notmatch '(?m)^00000000 ') { + throw "Bundled GStreamer $Label probe produced no decoded buffer" + } +} + try { # Do not let the installer SDK exported by the build job mask a missing # staged dependency. Windows' normal system DLL directories remain in the @@ -447,19 +490,19 @@ try { '#EXT-X-ENDLIST' ) | Set-Content -Encoding ascii $playlist $playlistUri = ([System.Uri]::new($playlist)).AbsoluteUri.Replace('\', '/') + Write-Host 'Playing bundled GStreamer HLS fixture.' # hlsdemux2 does not reliably propagate the one-segment VOD EOS through - # playbin3 on Windows. Bound both sinks by decoded buffers instead: their - # EOS proves audio and video reached the end of the playback graph. - $playArgs = @( - '-q', 'playbin3', "uri=$playlistUri", - 'audio-sink=fakesink sync=false num-buffers=8', - 'video-sink=fakesink sync=false num-buffers=8' + # playbin3 on Windows. Probe each stream independently, verify that a + # decoded buffer reached its sink, and stop the process after 15 seconds + # if the demuxer keeps waiting after successful playback. + Invoke-GstBufferProbe -Label 'HLS audio playback' -Arguments @( + '-q', 'playbin3', "uri=$playlistUri", 'flags=audio', + 'audio-sink=fakesink dump=true sync=false num-buffers=1' + ) + Invoke-GstBufferProbe -Label 'HLS video playback' -Arguments @( + '-q', 'playbin3', "uri=$playlistUri", 'flags=video', + 'video-sink=fakesink dump=true sync=false num-buffers=1' ) - Write-Host 'Playing bundled GStreamer HLS fixture.' - $playOutput = (& $gstLaunch @playArgs 2>&1) -join [Environment]::NewLine - if ($LASTEXITCODE -ne 0) { - throw "Bundled GStreamer HLS playback smoke failed: $playOutput" - } # Exercise the dynamically loaded GIO TLS backend and CA bundle from the # clean stage; factory discovery alone does not prove HTTPS can connect. From cebd0500a35a716b444d024c01632c7048ab6e9c Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Mon, 27 Jul 2026 12:40:12 -0600 Subject: [PATCH 54/56] fix(windows): decode HLS probes without playbin --- utils/build-windows/verify-runtime-closure.ps1 | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index 11a88964..fdbdcbcc 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -452,7 +452,7 @@ try { # Generate and then play a tiny HLS/AAC fixture using only the stage. This # exercises adaptive demux, MPEG-TS, the reviewed system/BSD codec path, - # and playbin selection without depending on a mutable public media URL. + # and decoded stream output without depending on a public media URL. New-Item -ItemType Directory -Force -Path $hlsFixtureDirectory | Out-Null $segment = Join-Path $hlsFixtureDirectory 'segment.ts' $segmentLocation = $segment.Replace('\', '/') @@ -491,17 +491,17 @@ try { ) | Set-Content -Encoding ascii $playlist $playlistUri = ([System.Uri]::new($playlist)).AbsoluteUri.Replace('\', '/') Write-Host 'Playing bundled GStreamer HLS fixture.' - # hlsdemux2 does not reliably propagate the one-segment VOD EOS through - # playbin3 on Windows. Probe each stream independently, verify that a - # decoded buffer reached its sink, and stop the process after 15 seconds - # if the demuxer keeps waiting after successful playback. + # Avoid playbin3's application-managed buffering state for this headless + # audit. Decode each HLS stream directly and require a rendered buffer. Invoke-GstBufferProbe -Label 'HLS audio playback' -Arguments @( - '-q', 'playbin3', "uri=$playlistUri", 'flags=audio', - 'audio-sink=fakesink dump=true sync=false num-buffers=1' + '-q', 'uridecodebin3', "uri=$playlistUri", '!', + 'audioconvert', '!', 'audioresample', '!', + 'fakesink', 'dump=true', 'sync=false', 'num-buffers=1' ) Invoke-GstBufferProbe -Label 'HLS video playback' -Arguments @( - '-q', 'playbin3', "uri=$playlistUri", 'flags=video', - 'video-sink=fakesink dump=true sync=false num-buffers=1' + '-q', 'uridecodebin3', "uri=$playlistUri", '!', + 'videoconvertscale', '!', + 'fakesink', 'dump=true', 'sync=false', 'num-buffers=1' ) # Exercise the dynamically loaded GIO TLS backend and CA bundle from the From c066c8bfdf07e547f6f492cbac115d1531a47b6b Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Mon, 27 Jul 2026 13:18:57 -0600 Subject: [PATCH 55/56] fix(windows): link both HLS decode streams --- .../build-windows/verify-runtime-closure.ps1 | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index fdbdcbcc..17c0b000 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -380,7 +380,8 @@ function Invoke-GstBufferProbe { [Parameter(Mandatory = $true)] [string]$Label, [Parameter(Mandatory = $true)] - [string[]]$Arguments + [string[]]$Arguments, + [int]$ExpectedBuffers = 1 ) $startInfo = [System.Diagnostics.ProcessStartInfo]::new() @@ -412,8 +413,9 @@ function Invoke-GstBufferProbe { if (($exited -and $process.ExitCode -ne 0) -or $stderr -match '(?m)^ERROR:') { throw "Bundled GStreamer $Label probe failed: $stderr" } - if ($stdout -notmatch '(?m)^00000000 ') { - throw "Bundled GStreamer $Label probe produced no decoded buffer" + $bufferCount = [regex]::Matches($stdout, '(?m)^00000000 ').Count + if ($bufferCount -lt $ExpectedBuffers) { + throw "Bundled GStreamer $Label probe produced $bufferCount of $ExpectedBuffers decoded buffers" } } @@ -492,15 +494,13 @@ try { $playlistUri = ([System.Uri]::new($playlist)).AbsoluteUri.Replace('\', '/') Write-Host 'Playing bundled GStreamer HLS fixture.' # Avoid playbin3's application-managed buffering state for this headless - # audit. Decode each HLS stream directly and require a rendered buffer. - Invoke-GstBufferProbe -Label 'HLS audio playback' -Arguments @( - '-q', 'uridecodebin3', "uri=$playlistUri", '!', - 'audioconvert', '!', 'audioresample', '!', - 'fakesink', 'dump=true', 'sync=false', 'num-buffers=1' - ) - Invoke-GstBufferProbe -Label 'HLS video playback' -Arguments @( - '-q', 'uridecodebin3', "uri=$playlistUri", '!', - 'videoconvertscale', '!', + # audit. Link both adaptive-demux outputs so neither can fail unlinked, + # then require one decoded audio buffer and one decoded video buffer. + Invoke-GstBufferProbe -Label 'HLS playback' -ExpectedBuffers 2 -Arguments @( + '-q', 'uridecodebin3', "uri=$playlistUri", 'name=decoder', + 'decoder.', '!', 'queue', '!', 'audioconvert', '!', 'audioresample', '!', + 'fakesink', 'dump=true', 'sync=false', 'num-buffers=1', + 'decoder.', '!', 'queue', '!', 'videoconvertscale', '!', 'fakesink', 'dump=true', 'sync=false', 'num-buffers=1' ) From 0d245d5971044de733e443770b096f074179be96 Mon Sep 17 00:00:00 2001 From: Grayson Hieb Date: Mon, 27 Jul 2026 13:49:58 -0600 Subject: [PATCH 56/56] fix(windows): constrain HLS decode pads --- .../build-windows/verify-runtime-closure.ps1 | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/utils/build-windows/verify-runtime-closure.ps1 b/utils/build-windows/verify-runtime-closure.ps1 index 17c0b000..98e7c8d7 100644 --- a/utils/build-windows/verify-runtime-closure.ps1 +++ b/utils/build-windows/verify-runtime-closure.ps1 @@ -409,13 +409,22 @@ function Invoke-GstBufferProbe { } $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() + $probeOutput = $stdout + [Environment]::NewLine + $stderr + $diagnostic = if ($probeOutput.Length -gt 4096) { + $probeOutput.Substring(0, 4096) + } else { + $probeOutput + } - if (($exited -and $process.ExitCode -ne 0) -or $stderr -match '(?m)^ERROR:') { - throw "Bundled GStreamer $Label probe failed: $stderr" + if (-not $exited) { + throw "Bundled GStreamer $Label probe timed out: $diagnostic" } - $bufferCount = [regex]::Matches($stdout, '(?m)^00000000 ').Count + if ($process.ExitCode -ne 0 -or $stderr -match '(?m)^ERROR:') { + throw "Bundled GStreamer $Label probe failed: $diagnostic" + } + $bufferCount = [regex]::Matches($probeOutput, '(?m)^\s*00000000\b').Count if ($bufferCount -lt $ExpectedBuffers) { - throw "Bundled GStreamer $Label probe produced $bufferCount of $ExpectedBuffers decoded buffers" + throw "Bundled GStreamer $Label probe produced $bufferCount of $ExpectedBuffers decoded buffers: $diagnostic" } } @@ -498,9 +507,10 @@ try { # then require one decoded audio buffer and one decoded video buffer. Invoke-GstBufferProbe -Label 'HLS playback' -ExpectedBuffers 2 -Arguments @( '-q', 'uridecodebin3', "uri=$playlistUri", 'name=decoder', - 'decoder.', '!', 'queue', '!', 'audioconvert', '!', 'audioresample', '!', + 'decoder.', '!', 'audio/x-raw', '!', 'queue', '!', + 'audioconvert', '!', 'audioresample', '!', 'fakesink', 'dump=true', 'sync=false', 'num-buffers=1', - 'decoder.', '!', 'queue', '!', 'videoconvertscale', '!', + 'decoder.', '!', 'video/x-raw', '!', 'queue', '!', 'videoconvertscale', '!', 'fakesink', 'dump=true', 'sync=false', 'num-buffers=1' )