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}