use crate::{HeadlessShell, Shell, ShellError};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ShellBackend {
Wayland,
X11,
Win32,
AppKit,
Web,
Headless,
}
impl ShellBackend {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Wayland => "wayland",
Self::X11 => "x11",
Self::Win32 => "win32",
Self::AppKit => "appkit",
Self::Web => "web",
Self::Headless => "headless",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"wayland" | "wl" => Some(Self::Wayland),
"x11" | "xcb" => Some(Self::X11),
"win32" | "windows" => Some(Self::Win32),
"appkit" | "macos" | "cocoa" => Some(Self::AppKit),
"web" | "canvas" => Some(Self::Web),
"headless" | "null" => Some(Self::Headless),
_ => None,
}
}
}
impl core::fmt::Display for ShellBackend {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
pub const BACKEND_ENV_VAR: &str = "CRCBL_SHELL";
struct Registration {
backend: ShellBackend,
auto: bool,
open: fn() -> Result<Box<dyn Shell>, ShellError>,
}
static REGISTRY: &[Registration] = &[
#[cfg(desktop_unix)]
Registration {
backend: ShellBackend::Wayland,
auto: true,
open: || Ok(Box::new(crate::wayland::WaylandShell::open()?)),
},
#[cfg(desktop_unix)]
Registration {
backend: ShellBackend::X11,
auto: true,
open: || Ok(Box::new(crate::x11::X11Shell::open()?)),
},
#[cfg(target_os = "windows")]
Registration {
backend: ShellBackend::Win32,
auto: true,
open: || Ok(Box::new(crate::win32::Win32Shell::open()?)),
},
#[cfg(target_os = "macos")]
Registration {
backend: ShellBackend::AppKit,
auto: true,
open: || Ok(Box::new(crate::appkit::AppKitShell::open()?)),
},
Registration {
backend: ShellBackend::Headless,
auto: false,
open: || Ok(Box::new(HeadlessShell::new())),
},
Registration {
backend: ShellBackend::Web,
auto: false,
open: web_open,
},
];
fn registry_names(entries: impl Iterator<Item = ShellBackend>) -> String {
let names: Vec<&str> = entries.map(ShellBackend::as_str).collect();
if names.is_empty() {
"none".to_string()
} else {
names.join(", ")
}
}
pub fn open() -> Result<Box<dyn Shell>, ShellError> {
match std::env::var(BACKEND_ENV_VAR) {
Ok(value) if !value.trim().is_empty() => {
let Some(requested) = ShellBackend::from_name(&value) else {
return Err(ShellError::UnknownBackend {
requested: value,
available: registry_names(REGISTRY.iter().map(|entry| entry.backend)),
});
};
open_backend(requested)
}
_ => open_auto(),
}
}
pub fn open_backend(backend: ShellBackend) -> Result<Box<dyn Shell>, ShellError> {
match REGISTRY.iter().find(|entry| entry.backend == backend) {
Some(entry) => (entry.open)(),
None => Err(ShellError::UnknownBackend {
requested: backend.as_str().to_string(),
available: registry_names(REGISTRY.iter().map(|entry| entry.backend)),
}),
}
}
fn web_open() -> Result<Box<dyn Shell>, ShellError> {
#[cfg(target_arch = "wasm32")]
{
Ok(Box::new(crate::web::open(crate::web::canvas_id())?))
}
#[cfg(not(target_arch = "wasm32"))]
{
Err(ShellError::Unsupported {
backend: ShellBackend::Web,
what: "the web backend outside a browser — build for wasm32-unknown-unknown",
})
}
}
fn open_auto() -> Result<Box<dyn Shell>, ShellError> {
let mut tried = Vec::new();
for entry in REGISTRY.iter().filter(|entry| entry.auto) {
tried.push(entry.backend);
match (entry.open)() {
Ok(shell) => {
crcbl_core::log::info!("opened the {} shell backend", entry.backend);
return Ok(shell);
}
Err(error) => {
crcbl_core::log::debug!("{} shell backend unavailable: {error}", entry.backend)
}
}
}
Err(ShellError::NoBackend {
tried: registry_names(tried.into_iter()),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_names_round_trip() {
let backends = [
ShellBackend::Wayland,
ShellBackend::X11,
ShellBackend::Win32,
ShellBackend::AppKit,
ShellBackend::Web,
ShellBackend::Headless,
];
for backend in backends {
assert_eq!(ShellBackend::from_name(backend.as_str()), Some(backend));
assert_eq!(backend.to_string(), backend.as_str());
}
assert_eq!(ShellBackend::from_name("XCB"), Some(ShellBackend::X11));
assert_eq!(
ShellBackend::from_name(" WaYlAnD "),
Some(ShellBackend::Wayland)
);
assert_eq!(ShellBackend::from_name("mir"), None);
}
#[test]
fn the_table_matches_the_platform_and_headless_is_never_automatic() {
let backends: Vec<ShellBackend> = REGISTRY.iter().map(|entry| entry.backend).collect();
if cfg!(desktop_unix) {
assert_eq!(
backends,
[
ShellBackend::Wayland,
ShellBackend::X11,
ShellBackend::Headless,
ShellBackend::Web,
]
);
} else if cfg!(target_os = "windows") {
assert_eq!(
backends,
[
ShellBackend::Win32,
ShellBackend::Headless,
ShellBackend::Web,
]
);
} else if cfg!(target_os = "macos") {
assert_eq!(
backends,
[
ShellBackend::AppKit,
ShellBackend::Headless,
ShellBackend::Web,
]
);
} else {
assert_eq!(backends, [ShellBackend::Headless, ShellBackend::Web]);
}
let headless = REGISTRY
.iter()
.find(|entry| entry.backend == ShellBackend::Headless)
.expect("headless is always compiled in");
assert!(!headless.auto, "a game must never silently run headless");
assert!(
REGISTRY
.iter()
.filter(|entry| entry.auto)
.all(|entry| entry.backend != ShellBackend::Headless)
);
}
#[test]
fn opening_by_name_works_and_unknown_backends_are_rejected() {
let shell = open_backend(ShellBackend::Headless).expect("headless is registered");
assert_eq!(shell.backend(), ShellBackend::Headless);
if !cfg!(target_arch = "wasm32") {
let error = open_backend(ShellBackend::Web).expect_err("no browser here");
let ShellError::Unsupported { backend, what } = error else {
panic!("wrong variant");
};
assert_eq!(backend, ShellBackend::Web);
assert!(what.contains("wasm32"), "{what}");
}
let registered: Vec<ShellBackend> = REGISTRY.iter().map(|entry| entry.backend).collect();
let absent: Vec<ShellBackend> = [
ShellBackend::Wayland,
ShellBackend::X11,
ShellBackend::Win32,
ShellBackend::AppKit,
ShellBackend::Web,
ShellBackend::Headless,
]
.into_iter()
.filter(|backend| !registered.contains(backend))
.collect();
assert!(
!absent.is_empty(),
"every backend is registered on this platform, so nothing here checks the \
UnknownBackend path: {registered:?}"
);
for backend in absent {
let error = open_backend(backend).expect_err("not in this build");
let ShellError::UnknownBackend {
requested,
available,
} = error
else {
panic!("{backend} is not registered here, so it must answer UnknownBackend");
};
assert_eq!(requested, backend.as_str());
assert!(available.contains("headless"), "{available}");
}
#[cfg(target_os = "windows")]
{
let shell = open_backend(ShellBackend::Win32).expect("registered on Windows");
assert_eq!(shell.backend(), ShellBackend::Win32);
}
#[cfg(target_os = "macos")]
{
match open_backend(ShellBackend::AppKit) {
Ok(shell) => assert_eq!(shell.backend(), ShellBackend::AppKit),
Err(error) => assert!(
!matches!(error, ShellError::UnknownBackend { .. }),
"AppKit is registered on macOS, so this must be its own failure: {error}"
),
}
}
}
#[test]
fn automatic_selection_reports_what_it_tried() {
match open_auto() {
Ok(shell) => assert!(
matches!(
shell.backend(),
ShellBackend::Wayland
| ShellBackend::X11
| ShellBackend::Win32
| ShellBackend::AppKit
),
"the auto-selectable backends are the two Linux ones — Wayland \
first — and the single Windows and macOS ones: {}",
shell.backend()
),
Err(ShellError::NoBackend { tried }) => {
let expected = if cfg!(desktop_unix) {
"wayland, x11"
} else if cfg!(target_os = "windows") {
"win32"
} else if cfg!(target_os = "macos") {
"appkit"
} else {
"none"
};
assert_eq!(tried, expected, "every attempt is named, in order");
}
Err(error) => panic!("unexpected error: {error}"),
}
}
}