Skip to main content

crcbl_shell/
backend.rs

1//! Choosing a backend at runtime.
2//!
3//! # The decision: runtime selection, one registry, `Box<dyn Shell>`
4//!
5//! The windowing rules in `docs/notes/backends.md` require it in one sentence —
6//! "backend selection
7//! at runtime, not compile time, on Linux: try Wayland socket → fall back to
8//! X11 (both compiled in; `CRCBL_SHELL=x11` override)" — and that sentence has
9//! consequences the API has to absorb *now*, because retrofitting them costs a
10//! rewrite of every consumer:
11//!
12//! 1. **[`Shell`] must be object-safe.** A generic `Shell` would make the
13//!    backend a compile-time parameter, and "try Wayland, fall back to X11"
14//!    would then have to monomorphise every consumer twice and pick between the
15//!    two instantiations with an `enum` wrapper — which is a hand-written
16//!    vtable with extra steps. `crcbl-hal` reached the same conclusion for the
17//!    same reason and states the costs; they apply here in a milder form, since
18//!    the shell is called a handful of times per frame rather than per draw.
19//! 2. **The entry point cannot be a constructor.** `WaylandShell::new()` puts
20//!    the platform name in the caller's source, which is the regression the
21//!    whole seam exists to prevent. So the entry point is [`open`], and the
22//!    only concrete shell type a consumer ever names is
23//!    [`HeadlessShell`] — deliberately, because a test
24//!    asking for determinism is asking for a *specific* implementation.
25//!
26//! # What is registered today
27//!
28//! Wayland then X11 on Linux, Win32 on Windows, AppKit on macOS, and
29//! [`HeadlessShell`] everywhere — the last only when asked for by name. **P0.6
30//! confirmed the property this module was shaped to have and P5C confirmed it
31//! twice more**: adding a whole X11 backend touched [`REGISTRY`](self) and these
32//! tests, and no other file outside `src/x11/` (plus `src/linux/`, which is the
33//! evdev table and libxkbcommon moving up one level to be shared rather than
34//! copied); adding the Win32 backend touched this file, `src/lib.rs` and
35//! `src/win32/`, and adding the AppKit one touched this file, `src/lib.rs` and
36//! `src/appkit/`. No consumer changed, no trait method was added, and no
37//! `#[cfg]` appeared anywhere above this crate.
38//!
39//! **Every variant of [`ShellBackend`] is now registered somewhere**, which
40//! changes how the "a backend this build does not have" property is tested. It
41//! used to be asserted against whichever variant had not landed yet — X11 until
42//! P0.6, Win32 until P5C, AppKit until this slice — and there is no fourth
43//! candidate. The tests below now derive the absent set from the table itself
44//! and assert the property per platform, which is what was actually meant all
45//! along: *asking for a backend this build lacks is an honest
46//! [`UnknownBackend`](ShellError::UnknownBackend)*. It also cannot go stale
47//! again — a future backend is covered the day it is added, on the platforms
48//! that do not have it.
49//!
50//! Headless is registered with `auto: false`, so [`open`] never selects it
51//! implicitly. A game that silently ran headless because a compositor was
52//! missing would look like a hang; failing with
53//! [`ShellError::NoBackend`] names the actual problem.
54//!
55//! # Why both Linux entries are `dlopen`-backed, and the Windows one is not
56//!
57//! The registry is a fall-through list, so every entry has to be able to *fail*
58//! at runtime. A backend linked against `libwayland-client.so.0` — or
59//! `libxcb.so.1` — with `DT_NEEDED` cannot: the process dies in `ld.so` before
60//! `main`, on any machine without the library, and this list never runs.
61//! `src/wayland/ffi.rs` has the full argument and `src/x11/ffi.rs` sharpens it,
62//! since X11 is the *fallback* and is reached only after Wayland has already
63//! failed.
64//!
65//! The Win32 and AppKit entries link their libraries instead, and the
66//! difference is this list's shape rather than a change of mind: each of those
67//! platforms has one backend, so nothing falls through to anything, and neither
68//! `user32.dll` nor AppKit can be missing from a system that is running a
69//! process at all. `src/win32/ffi.rs` states it in full and `src/appkit/ffi.rs`
70//! repeats it for the frameworks.
71
72use crate::{HeadlessShell, Shell, ShellError};
73
74/// Which implementation is behind the [`Shell`] trait.
75///
76/// For logs, error messages and bug reports. Shell *behaviour* must key off
77/// [`ShellCaps`](crate::ShellCaps), never off this — that is the difference
78/// between a capability system and platform sniffing, and it is the same rule
79/// `crcbl-hal`'s `BackendKind` carries.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
81pub enum ShellBackend {
82    /// Wayland, through libwayland-client and our own protocol layer (P0.5).
83    Wayland,
84    /// X11, through libxcb and our own request/event layer (P0.6).
85    X11,
86    /// Win32, through hand-written `extern "system"` declarations (P5C).
87    Win32,
88    /// AppKit (P5C).
89    AppKit,
90    /// A browser canvas, through the hand-rolled JS shim (P5).
91    Web,
92    /// [`HeadlessShell`] — no window system at all.
93    Headless,
94}
95
96impl ShellBackend {
97    /// The lowercase name used in `CRCBL_SHELL` and in log lines.
98    #[must_use]
99    pub const fn as_str(self) -> &'static str {
100        match self {
101            Self::Wayland => "wayland",
102            Self::X11 => "x11",
103            Self::Win32 => "win32",
104            Self::AppKit => "appkit",
105            Self::Web => "web",
106            Self::Headless => "headless",
107        }
108    }
109
110    /// Parses a `CRCBL_SHELL` value.
111    ///
112    /// Accepts `x11` and its common misspelling `xcb`, because the backend is
113    /// built on libxcb and someone will type it.
114    #[must_use]
115    pub fn from_name(name: &str) -> Option<Self> {
116        match name.trim().to_ascii_lowercase().as_str() {
117            "wayland" | "wl" => Some(Self::Wayland),
118            "x11" | "xcb" => Some(Self::X11),
119            "win32" | "windows" => Some(Self::Win32),
120            "appkit" | "macos" | "cocoa" => Some(Self::AppKit),
121            "web" | "canvas" => Some(Self::Web),
122            "headless" | "null" => Some(Self::Headless),
123            _ => None,
124        }
125    }
126}
127
128impl core::fmt::Display for ShellBackend {
129    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
130        f.write_str(self.as_str())
131    }
132}
133
134/// The environment variable that overrides automatic backend selection.
135pub const BACKEND_ENV_VAR: &str = "CRCBL_SHELL";
136
137/// One entry in the backend table.
138struct Registration {
139    backend: ShellBackend,
140    /// Whether [`open`] may select this entry without being asked for it by
141    /// name. See the [module docs](self) for why headless is `false`.
142    auto: bool,
143    open: fn() -> Result<Box<dyn Shell>, ShellError>,
144}
145
146/// Every backend compiled into this build, in the order [`open`] tries them.
147///
148/// The order is the windowing rules' preference order: Wayland
149/// first, X11 as the fallback. On a session running both — which is every
150/// XWayland-capable compositor — Wayland wins, and `CRCBL_SHELL=x11` is how a
151/// developer reproduces an X11 bug without logging out.
152static REGISTRY: &[Registration] = &[
153    // `#[cfg]` on the element, not on the whole table: on macOS and Windows
154    // the entry simply is not there, so nothing else in this file mentions a
155    // platform and `open_auto` needs no conditional compilation of its own.
156    #[cfg(desktop_unix)]
157    Registration {
158        backend: ShellBackend::Wayland,
159        auto: true,
160        open: || Ok(Box::new(crate::wayland::WaylandShell::open()?)),
161    },
162    #[cfg(desktop_unix)]
163    Registration {
164        backend: ShellBackend::X11,
165        auto: true,
166        open: || Ok(Box::new(crate::x11::X11Shell::open()?)),
167    },
168    // Windows has exactly one backend, so nothing here falls through to
169    // anything — which is what lets `src/win32/ffi.rs` link its libraries
170    // instead of `dlopen`ing them. See that module for the asymmetry.
171    #[cfg(target_os = "windows")]
172    Registration {
173        backend: ShellBackend::Win32,
174        auto: true,
175        open: || Ok(Box::new(crate::win32::Win32Shell::open()?)),
176    },
177    // macOS likewise has exactly one backend, so `src/appkit/ffi.rs` links its
178    // frameworks for the same reason `src/win32/ffi.rs` links its DLLs.
179    #[cfg(target_os = "macos")]
180    Registration {
181        backend: ShellBackend::AppKit,
182        auto: true,
183        open: || Ok(Box::new(crate::appkit::AppKitShell::open()?)),
184    },
185    Registration {
186        backend: ShellBackend::Headless,
187        auto: false,
188        open: || Ok(Box::new(HeadlessShell::new())),
189    },
190    Registration {
191        backend: ShellBackend::Web,
192        auto: false,
193        open: web_open,
194    },
195];
196
197fn registry_names(entries: impl Iterator<Item = ShellBackend>) -> String {
198    let names: Vec<&str> = entries.map(ShellBackend::as_str).collect();
199    if names.is_empty() {
200        "none".to_string()
201    } else {
202        names.join(", ")
203    }
204}
205
206/// Opens the best available shell for this process.
207///
208/// Reads [`BACKEND_ENV_VAR`]: when it names a backend, that backend is opened
209/// and a failure is *not* fallen back from — someone who set the variable meant
210/// it, and quietly running on a different backend turns a debugging session
211/// into a wild goose chase. Otherwise the compiled-in backends are tried in
212/// preference order and the first one that connects wins.
213///
214/// # Errors
215///
216/// * [`ShellError::UnknownBackend`] if `CRCBL_SHELL` names something this build
217///   does not have.
218/// * The backend's own error if a named backend fails to open.
219/// * [`ShellError::NoBackend`] if nothing connected.
220///
221/// # Today
222///
223/// On Linux, Wayland is tried and then X11; `CRCBL_SHELL=wayland` or
224/// `CRCBL_SHELL=x11` forces one of them. On Windows and on macOS there is one
225/// backend each and it is selected automatically — although the AppKit one
226/// requires the process's **main thread** and returns
227/// [`ShellError::Backend`] naming that rule anywhere else, which is a failure
228/// this list falls through rather than one it hides. Every other target has
229/// only [`HeadlessShell`], which is not auto-selected, so this returns
230/// [`ShellError::NoBackend`] there unless `CRCBL_SHELL=headless` is set — the
231/// honest answer for a build with no window-system backend in it.
232pub fn open() -> Result<Box<dyn Shell>, ShellError> {
233    match std::env::var(BACKEND_ENV_VAR) {
234        Ok(value) if !value.trim().is_empty() => {
235            let Some(requested) = ShellBackend::from_name(&value) else {
236                return Err(ShellError::UnknownBackend {
237                    requested: value,
238                    available: registry_names(REGISTRY.iter().map(|entry| entry.backend)),
239                });
240            };
241            open_backend(requested)
242        }
243        _ => open_auto(),
244    }
245}
246
247/// Opens a specific backend, ignoring [`BACKEND_ENV_VAR`].
248///
249/// For `crcbl` CLI subcommands that must be headless whatever the environment
250/// says, and for tests.
251///
252/// # Errors
253///
254/// [`ShellError::UnknownBackend`] if this build has no such backend, or the
255/// backend's own error if it fails to open.
256pub fn open_backend(backend: ShellBackend) -> Result<Box<dyn Shell>, ShellError> {
257    match REGISTRY.iter().find(|entry| entry.backend == backend) {
258        Some(entry) => (entry.open)(),
259        None => Err(ShellError::UnknownBackend {
260            requested: backend.as_str().to_string(),
261            available: registry_names(REGISTRY.iter().map(|entry| entry.backend)),
262        }),
263    }
264}
265
266/// Opens the web backend, which exists only inside a browser.
267///
268/// Registered on every target — unlike the two Linux entries, which are
269/// `#[cfg]`ed out where they cannot work — because the failure it has to produce
270/// off wasm is a *diagnostic*. `CRCBL_SHELL=web` on a desktop used to hand back
271/// a shell that could never receive an event, so the application hung in its
272/// configure loop with nothing in the log; `UnknownBackend` would have been
273/// almost as bad, since "web is not in this build" is not what is wrong. The
274/// error names the actual problem instead. This is the same argument the module
275/// docs make for why headless is `auto: false`.
276///
277/// The canvas id comes from the shim's `__crcbl_web_canvas`, not from an
278/// environment variable: `std::env::var` on `wasm32-unknown-unknown` always
279/// answers `NotPresent`, so a `CRCBL_CANVAS_ID` lookup silently always produced
280/// the fallback.
281fn web_open() -> Result<Box<dyn Shell>, ShellError> {
282    #[cfg(target_arch = "wasm32")]
283    {
284        Ok(Box::new(crate::web::open(crate::web::canvas_id())?))
285    }
286    #[cfg(not(target_arch = "wasm32"))]
287    {
288        Err(ShellError::Unsupported {
289            backend: ShellBackend::Web,
290            what: "the web backend outside a browser — build for wasm32-unknown-unknown",
291        })
292    }
293}
294
295/// Tries every auto-selectable backend in preference order.
296fn open_auto() -> Result<Box<dyn Shell>, ShellError> {
297    let mut tried = Vec::new();
298    for entry in REGISTRY.iter().filter(|entry| entry.auto) {
299        tried.push(entry.backend);
300        match (entry.open)() {
301            Ok(shell) => {
302                crcbl_core::log::info!("opened the {} shell backend", entry.backend);
303                return Ok(shell);
304            }
305            // A backend that is compiled in but cannot connect is the normal
306            // case on a Wayland-only or X11-only session, so it is a debug line
307            // rather than a warning — but it is never silent, because "why did
308            // it pick X11?" is a question someone will ask.
309            Err(error) => {
310                crcbl_core::log::debug!("{} shell backend unavailable: {error}", entry.backend)
311            }
312        }
313    }
314    Err(ShellError::NoBackend {
315        tried: registry_names(tried.into_iter()),
316    })
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn backend_names_round_trip() {
325        let backends = [
326            ShellBackend::Wayland,
327            ShellBackend::X11,
328            ShellBackend::Win32,
329            ShellBackend::AppKit,
330            ShellBackend::Web,
331            ShellBackend::Headless,
332        ];
333        for backend in backends {
334            assert_eq!(ShellBackend::from_name(backend.as_str()), Some(backend));
335            assert_eq!(backend.to_string(), backend.as_str());
336        }
337        // Aliases someone will actually type.
338        assert_eq!(ShellBackend::from_name("XCB"), Some(ShellBackend::X11));
339        assert_eq!(
340            ShellBackend::from_name(" WaYlAnD "),
341            Some(ShellBackend::Wayland)
342        );
343        assert_eq!(ShellBackend::from_name("mir"), None);
344    }
345
346    #[test]
347    fn the_table_matches_the_platform_and_headless_is_never_automatic() {
348        // The tripwire for every backend slice: adding a registration must be a
349        // deliberate edit here too.
350        let backends: Vec<ShellBackend> = REGISTRY.iter().map(|entry| entry.backend).collect();
351        if cfg!(desktop_unix) {
352            assert_eq!(
353                backends,
354                [
355                    ShellBackend::Wayland,
356                    ShellBackend::X11,
357                    ShellBackend::Headless,
358                    ShellBackend::Web,
359                ]
360            );
361        } else if cfg!(target_os = "windows") {
362            assert_eq!(
363                backends,
364                [
365                    ShellBackend::Win32,
366                    ShellBackend::Headless,
367                    ShellBackend::Web,
368                ]
369            );
370        } else if cfg!(target_os = "macos") {
371            assert_eq!(
372                backends,
373                [
374                    ShellBackend::AppKit,
375                    ShellBackend::Headless,
376                    ShellBackend::Web,
377                ]
378            );
379        } else {
380            assert_eq!(backends, [ShellBackend::Headless, ShellBackend::Web]);
381        }
382        let headless = REGISTRY
383            .iter()
384            .find(|entry| entry.backend == ShellBackend::Headless)
385            .expect("headless is always compiled in");
386        assert!(!headless.auto, "a game must never silently run headless");
387        assert!(
388            REGISTRY
389                .iter()
390                .filter(|entry| entry.auto)
391                .all(|entry| entry.backend != ShellBackend::Headless)
392        );
393    }
394
395    #[test]
396    fn opening_by_name_works_and_unknown_backends_are_rejected() {
397        let shell = open_backend(ShellBackend::Headless).expect("headless is registered");
398        assert_eq!(shell.backend(), ShellBackend::Headless);
399
400        // Registered everywhere, but it only *works* in a browser — and off
401        // wasm it says so rather than handing back a shell that can never
402        // produce an event.
403        if !cfg!(target_arch = "wasm32") {
404            let error = open_backend(ShellBackend::Web).expect_err("no browser here");
405            let ShellError::Unsupported { backend, what } = error else {
406                panic!("wrong variant");
407            };
408            assert_eq!(backend, ShellBackend::Web);
409            assert!(what.contains("wasm32"), "{what}");
410        }
411
412        // **Every backend this build does not have**, derived from the table
413        // rather than named. X11 was the example until P0.6 registered it,
414        // Win32 until P5C did, and AppKit until the slice after that — at which
415        // point every variant is registered *somewhere* and there is no fourth
416        // candidate to promote. The property being asserted was never about a
417        // particular backend: it is that asking for one this build lacks is an
418        // honest `UnknownBackend` rather than a silent fallback onto something
419        // else. Reading the absent set out of `REGISTRY` says exactly that, and
420        // covers a future backend on the day it is added.
421        let registered: Vec<ShellBackend> = REGISTRY.iter().map(|entry| entry.backend).collect();
422        let absent: Vec<ShellBackend> = [
423            ShellBackend::Wayland,
424            ShellBackend::X11,
425            ShellBackend::Win32,
426            ShellBackend::AppKit,
427            ShellBackend::Web,
428            ShellBackend::Headless,
429        ]
430        .into_iter()
431        .filter(|backend| !registered.contains(backend))
432        .collect();
433        // A loop over an empty set asserts nothing, and this one would be empty
434        // if the `#[cfg]`s ever compiled every entry in at once.
435        assert!(
436            !absent.is_empty(),
437            "every backend is registered on this platform, so nothing here checks the \
438             UnknownBackend path: {registered:?}"
439        );
440        for backend in absent {
441            let error = open_backend(backend).expect_err("not in this build");
442            let ShellError::UnknownBackend {
443                requested,
444                available,
445            } = error
446            else {
447                panic!("{backend} is not registered here, so it must answer UnknownBackend");
448            };
449            assert_eq!(requested, backend.as_str());
450            assert!(available.contains("headless"), "{available}");
451        }
452
453        // And the other half, which is what makes the first half meaningful:
454        // this platform's own backend **is** registered, so asking for it by
455        // name gets a real shell or its own failure — never `UnknownBackend`.
456        #[cfg(target_os = "windows")]
457        {
458            let shell = open_backend(ShellBackend::Win32).expect("registered on Windows");
459            assert_eq!(shell.backend(), ShellBackend::Win32);
460        }
461        #[cfg(target_os = "macos")]
462        {
463            // Not `expect`: AppKit requires the process's main thread, and a
464            // test body never is one — see `appkit::app`. The point here is the
465            // *variant*, and the failure that is legal is the backend's own.
466            match open_backend(ShellBackend::AppKit) {
467                Ok(shell) => assert_eq!(shell.backend(), ShellBackend::AppKit),
468                Err(error) => assert!(
469                    !matches!(error, ShellError::UnknownBackend { .. }),
470                    "AppKit is registered on macOS, so this must be its own failure: {error}"
471                ),
472            }
473        }
474    }
475
476    #[test]
477    fn automatic_selection_reports_what_it_tried() {
478        // On a machine with a compositor this genuinely connects, which is the
479        // correct behaviour and not something to assert against; the point of
480        // the test is the failure path's message.
481        match open_auto() {
482            Ok(shell) => assert!(
483                matches!(
484                    shell.backend(),
485                    ShellBackend::Wayland
486                        | ShellBackend::X11
487                        | ShellBackend::Win32
488                        | ShellBackend::AppKit
489                ),
490                "the auto-selectable backends are the two Linux ones — Wayland \
491                 first — and the single Windows and macOS ones: {}",
492                shell.backend()
493            ),
494            Err(ShellError::NoBackend { tried }) => {
495                let expected = if cfg!(desktop_unix) {
496                    "wayland, x11"
497                } else if cfg!(target_os = "windows") {
498                    "win32"
499                } else if cfg!(target_os = "macos") {
500                    // Reached on every macOS test run, because a test body is
501                    // never the main thread and `AppKitShell::open` refuses
502                    // there — so this branch is the *normal* path on that
503                    // platform rather than the degraded one, and it is still
504                    // asserting the same thing: the failure names what it tried.
505                    "appkit"
506                } else {
507                    "none"
508                };
509                assert_eq!(tried, expected, "every attempt is named, in order");
510            }
511            Err(error) => panic!("unexpected error: {error}"),
512        }
513    }
514}