diff --git a/Cargo.lock b/Cargo.lock index b9847e5e..ffd42b48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,7 @@ dependencies = [ "infer", "jni 0.21.1", "jwalk", + "libc", "md5", "memmap2", "mimalloc", @@ -143,6 +144,7 @@ dependencies = [ "webview2-com", "windows 0.62.2", "ws-protocol", + "x11rb", "zip 8.6.0", ] diff --git a/README-CN.md b/README-CN.md index 3959c94f..526aa818 100644 --- a/README-CN.md +++ b/README-CN.md @@ -39,6 +39,31 @@ pnpm tauri build # 发行构建 pnpm tauri dev # 开发模式 ``` +### Linux 显示后端 + +首次在可使用包装层的 NVIDIA Wayland 会话中启动时,AMLL Player 会询问是否切换到嵌套的 `Xephyr + Openbox` WebView 会话。Xephyr 会作为普通应用窗口交给桌面环境管理,Openbox 则负责内层 X11 WebView,从而绕过 WebKitGTK 显式同步崩溃,并恢复宿主侧的缩放、最大化、全屏、聚焦和关闭行为。 + +在 Arch Linux 上安装包装层依赖: + +```bash +sudo pacman -S openbox xorg-server-xephyr +``` + +如果缺少任一依赖或包装层启动失败,AMLL Player 会自动回退到现有的 XWayland 显示。Xephyr 不会向嵌套客户端提供 DRI3,因此包装层会增加一次 CPU 拷贝/合成;WebKitGTK 自身仍可使用 GPU 渲染。直接 `x11` 回退可避免这层嵌套服务器开销。 + +包装层默认让 Xephyr 使用 60 FPS,避免不必要的嵌套合成开销。需要高刷新率时可设置不高于宿主刷新率的 `AMLL_XEPHYR_FPS`,例如 `AMLL_XEPHYR_FPS=120`;WebKitGTK 在嵌套 X11 会话中仍可能把动画限制在约 60 FPS,因此不保证应用渲染帧率能够穿透。 + +可以通过 `AMLL_LINUX_WEBVIEW_BACKEND` 覆盖自动选择: + +- `auto`:NVIDIA Wayland 环境依次尝试 Openbox 包装、直接 XWayland 和软件 Wayland +- `system`:保留桌面环境和 `GDK_BACKEND` 选择的后端 +- `openbox`:请求 Xephyr + Openbox 包装,并保留相同的自动回退 +- `x11`:优先使用 X11;没有可用 X 显示时回退到软件 Wayland +- `wayland`:强制使用原生 Wayland 渲染 +- `wayland-software`:强制使用 Wayland,并禁用 WebKitGTK DMA-BUF 渲染器 + +首次选择会保存到 `~/.config/net.stevexmh.amllplayer/linux-webview.json`(或 `$XDG_CONFIG_HOME/net.stevexmh.amllplayer/linux-webview.json`)。可直接修改其中的 `backend` 为上述任一选项;`AMLL_LINUX_WEBVIEW_BACKEND` 的优先级高于该配置。在 `auto` 模式下,NVIDIA Wayland 会话可能覆盖 `GDK_BACKEND=wayland` 以绕过已知崩溃,其他显式配置的 GDK 后端仍会保留。 + ### 鸣谢 - [woshizja/sound-processor](https://github.com/woshizja/sound-processor) diff --git a/README.md b/README.md index 839052fa..9f0dcc8f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,31 @@ pnpm tauri build # Production build pnpm tauri dev # Development mode ``` +### Linux display backend + +On the first NVIDIA Wayland launch where the wrapper is available, AMLL Player asks before switching its webview to a nested `Xephyr + Openbox` session. Xephyr is exposed to the desktop as a normal application window while Openbox manages the inner X11 webview. This avoids the WebKitGTK explicit-sync crash and restores host-side resize, maximize, fullscreen, focus, and close behavior. + +Install the wrapper dependencies on Arch Linux with: + +```bash +sudo pacman -S openbox xorg-server-xephyr +``` + +If either dependency cannot be found or the wrapper fails during startup, AMLL Player automatically falls back to the existing XWayland display. Xephyr does not expose DRI3 to its nested clients, so the wrapper adds a CPU copy/compositing step; WebKitGTK can still use the GPU for its own rendering. The direct `x11` fallback avoids that nested-server overhead. + +The wrapper defaults Xephyr to 60 FPS to avoid unnecessary nested compositing work. Set `AMLL_XEPHYR_FPS` to a rate no higher than the host refresh rate to opt into high refresh, for example `AMLL_XEPHYR_FPS=120`; WebKitGTK may still limit animation to about 60 FPS in a nested X11 session, so this does not guarantee rendering-rate passthrough. + +Set `AMLL_LINUX_WEBVIEW_BACKEND` to override the automatic selection: + +- `auto`: Openbox wrapper on NVIDIA Wayland, then direct XWayland, then software Wayland +- `system`: keep the backend selected by the desktop environment and `GDK_BACKEND` +- `openbox`: request the Xephyr + Openbox wrapper with the same automatic fallbacks +- `x11`: prefer X11, with a software Wayland fallback when no X display is available +- `wayland`: force native Wayland rendering +- `wayland-software`: force Wayland and disable the WebKitGTK DMA-BUF renderer + +The first-run choice is stored in `~/.config/net.stevexmh.amllplayer/linux-webview.json` (or `$XDG_CONFIG_HOME/net.stevexmh.amllplayer/linux-webview.json`). Edit its `backend` value to any option above. `AMLL_LINUX_WEBVIEW_BACKEND` takes precedence over the saved value. In `auto` mode, an NVIDIA Wayland session may override `GDK_BACKEND=wayland` to avoid the known crash; other explicitly configured GDK backends are preserved. + ### Acknowledgements - [woshizja/sound-processor](https://github.com/woshizja/sound-processor) diff --git a/packages/player-core/src/player.rs b/packages/player-core/src/player.rs index c0f36e6c..dd4e5903 100644 --- a/packages/player-core/src/player.rs +++ b/packages/player-core/src/player.rs @@ -246,6 +246,7 @@ impl AudioPlayer { pub async fn run(mut self) { let mut check_end_interval = tokio::time::interval(Duration::from_millis(50)); + let mut media_controls_events_open = true; loop { tokio::select! { @@ -258,11 +259,16 @@ impl AudioPlayer { } } else { break; } }, - msg = self.npc_event_rx.recv() => { + msg = self.npc_event_rx.recv(), if media_controls_events_open => { if let Some(event) = msg { self.media_manager .handle_event(event, &self.handler(), &self.evt_sender) .await; + } else { + // A failed MPRIS/media-controls initialization drops its sender. + // Polling the closed channel would otherwise keep this biased select! + // loop runnable and consume a CPU core. + media_controls_events_open = false; } }, _ = check_end_interval.tick() => { diff --git a/packages/player/index.html b/packages/player/index.html index e25a2b52..2a4d69c6 100644 --- a/packages/player/index.html +++ b/packages/player/index.html @@ -103,6 +103,8 @@ #system-titlebar-buttons { display: flex; + position: relative; + z-index: 10002; gap: 2px; transition: opacity 0.3s ease-in-out, @@ -192,6 +194,106 @@ display: none; } + #openbox-resize-handles { + display: none; + } + + html[data-amll-openbox-wrapper="true"][data-amll-openbox-maximized="false"] + #openbox-resize-handles { + display: block; + } + + html[data-amll-openbox-wrapper="true"][data-amll-openbox-maximized="true"] + #openbox-resize-handles { + display: none; + } + + .openbox-resize-handle { + position: fixed; + z-index: 10001; + } + + .openbox-resize-handle[data-direction="top"], + .openbox-resize-handle[data-direction="bottom"] { + left: 6px; + width: calc(100% - 12px); + height: 5px; + } + + .openbox-resize-handle[data-direction="top"] { + right: 148px; + width: auto; + } + + .openbox-resize-handle[data-direction="left"], + .openbox-resize-handle[data-direction="right"] { + top: 6px; + width: 5px; + height: calc(100% - 12px); + } + + .openbox-resize-handle[data-direction="top"] { + top: 0; + cursor: ns-resize; + } + + .openbox-resize-handle[data-direction="right"] { + top: 32px; + right: 0; + height: calc(100% - 38px); + cursor: ew-resize; + } + + .openbox-resize-handle[data-direction="bottom"] { + bottom: 0; + cursor: ns-resize; + } + + .openbox-resize-handle[data-direction="left"] { + left: 0; + cursor: ew-resize; + } + + .openbox-resize-handle[data-direction="top-left"], + .openbox-resize-handle[data-direction="top-right"], + .openbox-resize-handle[data-direction="bottom-left"], + .openbox-resize-handle[data-direction="bottom-right"] { + width: 8px; + height: 8px; + } + + .openbox-resize-handle[data-direction^="top"] { + top: 0; + } + + .openbox-resize-handle[data-direction="top-right"] { + right: 140px; + } + + .openbox-resize-handle[data-direction^="bottom"] { + bottom: 0; + } + + .openbox-resize-handle[data-direction$="left"] { + left: 0; + } + + .openbox-resize-handle[data-direction$="right"]:not( + [data-direction="top-right"] + ) { + right: 0; + } + + .openbox-resize-handle[data-direction="top-left"], + .openbox-resize-handle[data-direction="bottom-right"] { + cursor: nwse-resize; + } + + .openbox-resize-handle[data-direction="top-right"], + .openbox-resize-handle[data-direction="bottom-left"] { + cursor: nesw-resize; + } + @media (prefers-color-scheme: dark) { #system-titlebar.windows .system-button:not(#system-titlebar-close):hover { @@ -237,6 +339,23 @@ resizeBtn.addEventListener("click", () => { window.dispatchEvent(new Event("on-system-titlebar-click-resize")); }); + if (document.documentElement.dataset.amllOpenboxWrapper === "true") { + titlebar.removeAttribute("data-tauri-drag-region"); + titlebar.addEventListener("mousedown", (event) => { + if (event.button === 0 && !event.target.closest(".system-button")) { + event.preventDefault(); + window.__TAURI__.core.invoke("start_openbox_drag"); + } + }); + document.addEventListener("mousedown", (event) => { + const handle = event.target.closest(".openbox-resize-handle"); + if (!handle || event.button !== 0) return; + event.preventDefault(); + window.__TAURI__.core.invoke("start_openbox_resize", { + direction: handle.dataset.direction, + }); + }); + } window.SystemTitlebarAppearance = { Windows: "windows", MacOS: "macos", @@ -350,6 +469,16 @@ } +
diff --git a/packages/player/src-tauri/Cargo.toml b/packages/player/src-tauri/Cargo.toml index 8d2ea878..ea020e35 100644 --- a/packages/player/src-tauri/Cargo.toml +++ b/packages/player/src-tauri/Cargo.toml @@ -111,6 +111,10 @@ windows = { version = "0.62", features = [ "Win32_System_Threading", ] } +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" +x11rb = { version = "0.13", features = ["randr"] } + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-global-shortcut = { version = "2" } tauri-plugin-updater = { version = "2" } diff --git a/packages/player/src-tauri/src/lib.rs b/packages/player/src-tauri/src/lib.rs index 847ac993..275afeb6 100644 --- a/packages/player/src-tauri/src/lib.rs +++ b/packages/player/src-tauri/src/lib.rs @@ -17,6 +17,8 @@ use crate::server::AMLLWebSocketServerWrapper; mod db; mod db_events; +#[cfg(target_os = "linux")] +mod linux_webview; mod logging; mod music_info; mod player; @@ -48,7 +50,11 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box> { let log_guard = logging::init_logging(&log_dir); app.manage(log_guard); info!("AMLL Player is starting!"); - + #[cfg(target_os = "linux")] + info!( + "Configured Linux webview backend: {}", + linux_webview::selected_backend().as_str() + ); #[cfg(target_os = "ios")] { use objc2::msg_send; @@ -189,6 +195,11 @@ fn handle_window_event(_window: &tauri::Window, _event: &tauri::WindowEvent) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + #[cfg(target_os = "linux")] + if let Some(exit_code) = linux_webview::prepare() { + std::process::exit(exit_code); + } + // Install ring as the default crypto provider for rustls, because multiple providers // (aws-lc-rs and ring) might be enabled in our dependency tree and rustls demands one to be explicitly chosen. #[cfg(target_os = "android")] @@ -245,6 +256,15 @@ pub fn run() { server::ws_broadcast_payload, server::ws_close_connection, window::open_screenshot_window, + window::get_linux_webview_startup_prompt, + window::set_linux_webview_backend, + window::restart_linux_webview_with_system_backend, + window::toggle_openbox_fullscreen, + window::is_openbox_maximized, + window::toggle_openbox_maximize, + window::minimize_openbox_wrapper, + window::start_openbox_drag, + window::start_openbox_resize, screen_capture::take_screenshot, player::local_player_send_msg, player::set_media_controls_enabled, diff --git a/packages/player/src-tauri/src/linux_webview.rs b/packages/player/src-tauri/src/linux_webview.rs new file mode 100644 index 00000000..5acfd519 --- /dev/null +++ b/packages/player/src-tauri/src/linux_webview.rs @@ -0,0 +1,1518 @@ +use std::{ + collections::HashSet, + env, fs, + fs::{File, OpenOptions}, + ops::{Deref, DerefMut}, + os::unix::process::CommandExt, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{ + OnceLock, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use x11rb::{ + connection::Connection, + protocol::{ + randr::{ConnectionExt as _, Mode, ModeFlag, ModeInfo, Output, Rotation, SetConfig}, + xproto::{ + Atom, AtomEnum, ClientMessageEvent, ConfigureWindowAux, ConnectionExt as _, EventMask, + PropMode, Window, + }, + }, + rust_connection::RustConnection, + wrapper::ConnectionExt as _, +}; + +const BACKEND_ENV: &str = "AMLL_LINUX_WEBVIEW_BACKEND"; +const XEPHYR_FRAME_RATE_ENV: &str = "AMLL_XEPHYR_FPS"; +const CONFIG_DIRECTORY: &str = "net.stevexmh.amllplayer"; +const CONFIG_FILE: &str = "linux-webview.json"; +const FIRST_RUN_PROMPT_ENV: &str = "AMLL_OPENBOX_FIRST_RUN_PROMPT"; +const DMABUF_ENV: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; +const WRAPPER_CHILD_ENV: &str = "AMLL_OPENBOX_WRAPPER_CHILD"; +const HOST_DISPLAY_ENV: &str = "AMLL_OPENBOX_HOST_DISPLAY"; +const HOST_WINDOW_ENV: &str = "AMLL_OPENBOX_HOST_WINDOW"; +const DISPLAY_START: u16 = 100; +const DISPLAY_END: u16 = 199; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(5); +const POLL_INTERVAL: Duration = Duration::from_millis(50); +const FALLBACK_FRAME_RATE: u16 = 60; +const INITIAL_WIDTH: u16 = 1280; +const INITIAL_HEIGHT: u16 = 800; +const RESTART_SYSTEM_EXIT_CODE: i32 = 64; + +#[derive(Clone, Copy, Debug)] +struct HostDisplayMode { + width: u16, + height: u16, + frame_rate: u16, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LinuxWebviewBackend { + System, + Openbox, + X11, + Wayland, + WaylandSoftware, +} + +impl LinuxWebviewBackend { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::System => "system", + Self::Openbox => "openbox", + Self::X11 => "x11", + Self::Wayland => "wayland", + Self::WaylandSoftware => "wayland-software", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BackendPreference { + Auto, + System, + Openbox, + X11, + Wayland, + WaylandSoftware, +} + +#[derive(Deserialize, Serialize)] +struct LinuxWebviewConfig { + backend: String, +} + +#[derive(Clone, Copy, Debug)] +struct LinuxDisplayContext { + wayland_session: bool, + nvidia_gpu: bool, + x11_available: bool, + openbox_available: bool, + configured_gdk_backend: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ConfiguredGdkBackend { + Wayland, + Other, +} + +static SELECTED_BACKEND: OnceLock = OnceLock::new(); +static SHOW_FIRST_RUN_PROMPT: AtomicBool = AtomicBool::new(false); + +/// Configures the current process, or supervises a nested Openbox child and returns its exit code. +pub(crate) fn prepare() -> Option { + if env::var_os(WRAPPER_CHILD_ENV).is_some() { + SHOW_FIRST_RUN_PROMPT.store( + env::var_os(FIRST_RUN_PROMPT_ENV).is_some() + && load_saved_preference() + .map(|preference| preference.is_none()) + .unwrap_or(true), + Ordering::Release, + ); + apply_backend(LinuxWebviewBackend::Openbox); + return None; + } + + let context = LinuxDisplayContext { + wayland_session: is_wayland_session(), + nvidia_gpu: has_nvidia_gpu(), + x11_available: x11_display_available(), + openbox_available: command_available("Xephyr") && command_available("openbox"), + configured_gdk_backend: configured_gdk_backend(), + }; + let environment_preference = env::var(BACKEND_ENV) + .ok() + .and_then(|value| parse_preference_or_log(BACKEND_ENV, &value)); + let saved_preference = if environment_preference.is_none() { + match load_saved_preference() { + Ok(preference) => preference, + Err(error) => { + eprintln!("Failed to read Linux webview configuration: {error:#}"); + None + } + } + } else { + None + }; + let first_run_prompt = environment_preference.is_none() + && saved_preference.is_none() + && context.wayland_session + && context.nvidia_gpu + && context.x11_available + && context.openbox_available; + SHOW_FIRST_RUN_PROMPT.store(first_run_prompt, Ordering::Release); + if first_run_prompt { + match supervise_openbox(true) { + Ok(exit_code) => return Some(exit_code), + Err(error) => { + eprintln!("Failed to start Linux webview compatibility prompt: {error:#}"); + apply_backend(LinuxWebviewBackend::System); + return None; + } + } + } + let preference = environment_preference + .or(saved_preference) + .unwrap_or(BackendPreference::Auto); + let mut backend = select_backend(preference, context); + + if backend == LinuxWebviewBackend::Openbox { + match supervise_openbox(false) { + Ok(exit_code) => return Some(exit_code), + Err(error) => { + backend = fallback_backend(context); + eprintln!( + "Openbox wrapper unavailable ({error:#}); falling back to {}", + backend.as_str() + ); + } + } + } + + apply_backend(backend); + None +} + +pub(crate) fn selected_backend() -> LinuxWebviewBackend { + SELECTED_BACKEND + .get() + .copied() + .unwrap_or(LinuxWebviewBackend::System) +} + +pub(crate) fn is_openbox_wrapper() -> bool { + env::var_os(WRAPPER_CHILD_ENV).is_some() +} + +pub(crate) fn first_run_prompt() -> Option { + if !SHOW_FIRST_RUN_PROMPT.load(Ordering::Acquire) { + return None; + } + linux_webview_config_path().ok() +} + +pub(crate) fn save_preference(value: &str) -> Result { + let preference = parse_preference(value).context("invalid Linux webview backend")?; + let path = linux_webview_config_path()?; + let parent = path + .parent() + .context("Linux webview configuration has no parent")?; + fs::create_dir_all(parent).context("failed to create Linux webview configuration directory")?; + let contents = serde_json::to_string_pretty(&LinuxWebviewConfig { + backend: preference.as_str().to_owned(), + })?; + fs::write(&path, format!("{contents}\n")) + .context("failed to save Linux webview configuration")?; + SHOW_FIRST_RUN_PROMPT.store(false, Ordering::Release); + Ok(path) +} + +pub(crate) fn restart_with_system_backend() -> ! { + std::process::exit(RESTART_SYSTEM_EXIT_CODE); +} + +pub(crate) fn toggle_openbox_fullscreen() -> Result, String> { + if !is_openbox_wrapper() { + return Ok(None); + } + + toggle_host_fullscreen() + .map(Some) + .map_err(|error| error.to_string()) +} + +pub(crate) fn is_openbox_maximized() -> Result, String> { + if !is_openbox_wrapper() { + return Ok(None); + } + + host_is_maximized() + .map(Some) + .map_err(|error| error.to_string()) +} + +pub(crate) fn toggle_openbox_maximize() -> Result, String> { + if !is_openbox_wrapper() { + return Ok(None); + } + + toggle_host_maximize() + .map(Some) + .map_err(|error| error.to_string()) +} + +pub(crate) fn minimize_openbox_wrapper() -> Result { + if !is_openbox_wrapper() { + return Ok(false); + } + + minimize_host() + .map(|()| true) + .map_err(|error| error.to_string()) +} + +pub(crate) fn start_openbox_drag() -> Result { + if !is_openbox_wrapper() { + return Ok(false); + } + + start_host_drag() + .map(|()| true) + .map_err(|error| error.to_string()) +} + +pub(crate) fn start_openbox_resize(direction: &str) -> Result { + if !is_openbox_wrapper() { + return Ok(false); + } + + let direction = match direction { + "top-left" => 0, + "top" => 1, + "top-right" => 2, + "right" => 3, + "bottom-right" => 4, + "bottom" => 5, + "bottom-left" => 6, + "left" => 7, + _ => return Err(format!("invalid resize direction: {direction}")), + }; + start_host_manual_resize(direction) + .map(|()| true) + .map_err(|error| error.to_string()) +} + +fn apply_backend(backend: LinuxWebviewBackend) { + // SAFETY: run() calls prepare() before Tauri initializes GTK/WebKit or starts worker threads. + unsafe { + match backend { + LinuxWebviewBackend::System => {} + LinuxWebviewBackend::Openbox | LinuxWebviewBackend::X11 => { + env::set_var("GDK_BACKEND", "x11"); + } + LinuxWebviewBackend::Wayland => env::set_var("GDK_BACKEND", "wayland"), + LinuxWebviewBackend::WaylandSoftware => { + env::set_var("GDK_BACKEND", "wayland"); + if env::var_os(DMABUF_ENV).is_none() { + env::set_var(DMABUF_ENV, "1"); + } + } + } + } + + let _ = SELECTED_BACKEND.set(backend); +} + +fn parse_preference(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Some(BackendPreference::Auto), + "system" => Some(BackendPreference::System), + "openbox" => Some(BackendPreference::Openbox), + "x11" => Some(BackendPreference::X11), + "wayland" => Some(BackendPreference::Wayland), + "wayland-software" => Some(BackendPreference::WaylandSoftware), + _ => None, + } +} + +impl BackendPreference { + const fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::System => "system", + Self::Openbox => "openbox", + Self::X11 => "x11", + Self::Wayland => "wayland", + Self::WaylandSoftware => "wayland-software", + } + } +} + +fn parse_preference_or_log(source: &str, value: &str) -> Option { + parse_preference(value).or_else(|| { + eprintln!( + "Ignoring invalid {source}={value:?}; expected auto, system, openbox, x11, wayland, or wayland-software" + ); + None + }) +} + +fn linux_webview_config_path() -> Result { + let config_home = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .context("XDG_CONFIG_HOME and HOME are not configured")?; + Ok(config_home.join(CONFIG_DIRECTORY).join(CONFIG_FILE)) +} + +fn load_saved_preference() -> Result> { + let path = linux_webview_config_path()?; + let contents = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let config: LinuxWebviewConfig = serde_json::from_str(&contents) + .with_context(|| format!("invalid JSON in {}", path.display()))?; + Ok(parse_preference_or_log( + "linux-webview.json backend", + &config.backend, + )) +} + +fn select_backend( + preference: BackendPreference, + context: LinuxDisplayContext, +) -> LinuxWebviewBackend { + if !context.wayland_session || preference == BackendPreference::System { + return LinuxWebviewBackend::System; + } + + if preference == BackendPreference::Auto + && context.configured_gdk_backend == Some(ConfiguredGdkBackend::Other) + { + return LinuxWebviewBackend::System; + } + + match preference { + BackendPreference::Auto if !context.nvidia_gpu => LinuxWebviewBackend::System, + BackendPreference::Auto | BackendPreference::Openbox + if context.x11_available && context.openbox_available => + { + LinuxWebviewBackend::Openbox + } + BackendPreference::Auto | BackendPreference::Openbox | BackendPreference::X11 + if context.x11_available => + { + LinuxWebviewBackend::X11 + } + BackendPreference::Auto | BackendPreference::Openbox | BackendPreference::X11 => { + LinuxWebviewBackend::WaylandSoftware + } + BackendPreference::System => LinuxWebviewBackend::System, + BackendPreference::Wayland => LinuxWebviewBackend::Wayland, + BackendPreference::WaylandSoftware => LinuxWebviewBackend::WaylandSoftware, + } +} + +fn fallback_backend(context: LinuxDisplayContext) -> LinuxWebviewBackend { + if context.x11_available { + LinuxWebviewBackend::X11 + } else { + LinuxWebviewBackend::WaylandSoftware + } +} + +fn supervise_openbox(first_run_prompt: bool) -> Result { + let host_display = env::var("DISPLAY").context("DISPLAY is not configured")?; + let host_windows = client_windows(&host_display).unwrap_or_default(); + let display = DisplayReservation::acquire()?; + let nested_display = format!(":{}", display.number); + let config_dir = tempfile::tempdir().context("failed to create Openbox config directory")?; + let config_path = config_dir.path().join("rc.xml"); + fs::write(&config_path, OPENBOX_CONFIG).context("failed to write Openbox configuration")?; + let host_mode = host_display_mode(&host_display).unwrap_or(HostDisplayMode { + width: 1920, + height: 1200, + frame_rate: FALLBACK_FRAME_RATE, + }); + let frame_rate = xephyr_frame_rate( + env::var(XEPHYR_FRAME_RATE_ENV).ok().as_deref(), + host_mode.frame_rate, + ); + let frame_rate_arg = frame_rate.to_string(); + let screen_arg = format!( + "{}x{}", + host_mode.width.max(INITIAL_WIDTH), + host_mode.height.max(INITIAL_HEIGHT) + ); + + eprintln!( + "Starting AMLL Player in Xephyr + Openbox on {nested_display} with a {frame_rate} Hz Xephyr mode" + ); + let mut xephyr_command = child_command("Xephyr"); + let mut xephyr = ManagedChild::new( + xephyr_command + .args([ + nested_display.as_str(), + "-screen", + screen_arg.as_str(), + "-resizeable", + "-fakescreenfps", + frame_rate_arg.as_str(), + "-title", + "AMLL Player", + "-name", + "amll-player", + "-nolisten", + "tcp", + "-noreset", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("failed to start Xephyr")?, + ); + + wait_for_display(&nested_display, &mut xephyr)?; + if let Err(error) = configure_nested_display_mode( + &nested_display, + host_mode.width.max(INITIAL_WIDTH), + host_mode.height.max(INITIAL_HEIGHT), + frame_rate, + ) { + eprintln!( + "Failed to reserve the host-sized RandR mode in Xephyr ({error:#}); large-window input may be limited" + ); + } + let host_window = wait_for_host_window(&host_display, &host_windows, &mut xephyr)?; + configure_host_window(&host_display, host_window, std::process::id()); + resize_host_window(&host_display, host_window, INITIAL_WIDTH, INITIAL_HEIGHT)?; + wait_for_root_size( + &nested_display, + (INITIAL_WIDTH, INITIAL_HEIGHT), + &mut xephyr, + )?; + if let Err(error) = + configure_nested_display_mode(&nested_display, INITIAL_WIDTH, INITIAL_HEIGHT, frame_rate) + { + eprintln!( + "Failed to configure initial {frame_rate} Hz RandR mode in Xephyr ({error:#}); continuing with its default mode" + ); + } + + let mut openbox_command = child_command("openbox"); + let mut openbox = ManagedChild::new( + openbox_command + .arg("--config-file") + .arg(&config_path) + .env("DISPLAY", &nested_display) + .env_remove("WAYLAND_DISPLAY") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("failed to start Openbox")?, + ); + + wait_for_window_manager(&nested_display, &mut openbox)?; + let current_exe = env::current_exe().context("failed to locate the AMLL executable")?; + let mut app_command = child_command(current_exe); + if first_run_prompt { + app_command.env(FIRST_RUN_PROMPT_ENV, "1"); + } else { + app_command.env_remove(FIRST_RUN_PROMPT_ENV); + } + let mut app = ManagedChild::new( + app_command + .args(env::args_os().skip(1)) + .env(WRAPPER_CHILD_ENV, "1") + .env(HOST_DISPLAY_ENV, &host_display) + .env(HOST_WINDOW_ENV, host_window.to_string()) + .env("DISPLAY", &nested_display) + .env("GDK_BACKEND", "x11") + .env("XDG_SESSION_TYPE", "x11") + .env_remove("WAYLAND_DISPLAY") + .stdin(Stdio::null()) + .spawn() + .context("failed to start AMLL inside Openbox")?, + ); + + configure_host_window(&host_display, host_window, app.id()); + + let mut nested_window = None; + let mut last_root_size = None; + let mut last_dynamic_mode = None; + loop { + if let Some(status) = app.try_wait().context("failed to inspect AMLL process")? { + terminate(&mut openbox); + terminate(&mut xephyr); + if status.code() == Some(RESTART_SYSTEM_EXIT_CODE) { + restart_with_saved_system_environment()?; + return Ok(0); + } + return Ok(status.code().unwrap_or(1)); + } + + if let Some(status) = xephyr.try_wait().context("failed to inspect Xephyr")? { + terminate(&mut app); + terminate(&mut openbox); + if status.success() { + return Ok(0); + } + bail!("Xephyr exited unexpectedly with {status}"); + } + + if let Some(status) = openbox.try_wait().context("failed to inspect Openbox")? { + terminate(&mut app); + terminate(&mut xephyr); + bail!("Openbox exited unexpectedly with {status}"); + } + + if let Err(error) = sync_nested_main_window( + &nested_display, + &mut nested_window, + &mut last_root_size, + &mut last_dynamic_mode, + frame_rate, + ) { + eprintln!("Openbox window synchronization failed: {error:#}"); + } + thread::sleep(POLL_INTERVAL); + } +} + +fn wait_for_display(display: &str, child: &mut Child) -> Result<()> { + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + if x11rb::connect(Some(display)).is_ok() { + return Ok(()); + } + if let Some(status) = child.try_wait()? { + bail!("Xephyr exited during startup with {status}"); + } + thread::sleep(POLL_INTERVAL); + } + terminate(child); + bail!("timed out waiting for Xephyr display {display}") +} + +fn wait_for_root_size(display: &str, expected: (u16, u16), child: &mut Child) -> Result<()> { + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + let (conn, screen_num) = x11rb::connect(Some(display))?; + let root = conn.setup().roots[screen_num].root; + let geometry = conn.get_geometry(root)?.reply()?; + if (geometry.width, geometry.height) == expected { + return Ok(()); + } + if let Some(status) = child.try_wait()? { + bail!("Xephyr exited while resizing its host window with {status}"); + } + thread::sleep(POLL_INTERVAL); + } + bail!( + "timed out waiting for Xephyr root size {}x{}", + expected.0, + expected.1 + ) +} + +fn wait_for_host_window( + display: &str, + previous_windows: &HashSet, + child: &mut Child, +) -> Result { + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + let (conn, screen_num) = x11rb::connect(Some(display))?; + for window in client_windows_with_connection(&conn, screen_num)? { + if !previous_windows.contains(&window) && is_xephyr_window(&conn, window)? { + return Ok(window); + } + } + if let Some(status) = child.try_wait()? { + bail!("Xephyr exited before creating its host window with {status}"); + } + thread::sleep(POLL_INTERVAL); + } + bail!("timed out waiting for the Xephyr host window") +} + +fn wait_for_window_manager(display: &str, child: &mut Child) -> Result<()> { + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + if window_manager_ready(display).unwrap_or(false) { + return Ok(()); + } + if let Some(status) = child.try_wait()? { + bail!("Openbox exited during startup with {status}"); + } + thread::sleep(POLL_INTERVAL); + } + terminate(child); + bail!("timed out waiting for Openbox") +} + +fn window_manager_ready(display: &str) -> Result { + let (conn, screen_num) = x11rb::connect(Some(display))?; + let root = conn.setup().roots[screen_num].root; + let atom = intern_atom(&conn, b"_NET_SUPPORTING_WM_CHECK")?; + let reply = conn + .get_property(false, root, atom, AtomEnum::WINDOW, 0, 1)? + .reply()?; + Ok(reply.value_len > 0) +} + +fn client_windows(display: &str) -> Result> { + let (conn, screen_num) = x11rb::connect(Some(display))?; + client_windows_with_connection(&conn, screen_num) +} + +fn host_display_mode(display: &str) -> Result { + let (conn, screen_num) = x11rb::connect(Some(display))?; + let root = conn.setup().roots[screen_num].root; + let resources = conn.randr_get_screen_resources_current(root)?.reply()?; + let primary = conn.randr_get_output_primary(root)?.reply()?.output; + let output = if primary != 0 { + primary + } else { + *resources + .outputs + .first() + .context("XRandR reported no outputs")? + }; + let output_info = conn + .randr_get_output_info(output, resources.config_timestamp)? + .reply()?; + if output_info.crtc == 0 { + bail!("primary XRandR output has no active CRTC"); + } + let crtc = conn + .randr_get_crtc_info(output_info.crtc, resources.config_timestamp)? + .reply()?; + let mode = resources + .modes + .iter() + .find(|mode| mode.id == crtc.mode) + .context("active XRandR mode was not found")?; + if mode.dot_clock == 0 || mode.htotal == 0 || mode.vtotal == 0 { + bail!("active XRandR mode has no refresh timing"); + } + + let mut frame_rate = + f64::from(mode.dot_clock) / (f64::from(mode.htotal) * f64::from(mode.vtotal)); + let flags = u32::from(mode.mode_flags); + if flags & u32::from(ModeFlag::INTERLACE) != 0 { + frame_rate *= 2.0; + } + if flags & u32::from(ModeFlag::DOUBLE_SCAN) != 0 { + frame_rate /= 2.0; + } + let (width, height) = resources + .crtcs + .iter() + .filter_map(|crtc| { + conn.randr_get_crtc_info(*crtc, resources.config_timestamp) + .ok()? + .reply() + .ok() + .map(|info| (info.width, info.height)) + }) + .fold((crtc.width, crtc.height), |largest, current| { + (largest.0.max(current.0), largest.1.max(current.1)) + }); + Ok(HostDisplayMode { + width, + height, + frame_rate: frame_rate.round().clamp(30.0, 1000.0) as u16, + }) +} + +fn xephyr_frame_rate(requested: Option<&str>, host_frame_rate: u16) -> u16 { + let host_frame_rate = host_frame_rate.clamp(30, 1000); + let default_frame_rate = FALLBACK_FRAME_RATE.min(host_frame_rate); + requested + .and_then(|value| value.trim().parse::().ok()) + .filter(|frame_rate| (30..=host_frame_rate).contains(frame_rate)) + .unwrap_or(default_frame_rate) +} + +fn configure_nested_display_mode( + display: &str, + width: u16, + height: u16, + frame_rate: u16, +) -> Result<()> { + let (conn, screen_num) = x11rb::connect(Some(display))?; + let root = conn.setup().roots[screen_num].root; + configure_nested_display_mode_with_connection(&conn, root, width, height, frame_rate) + .map(|_| ()) +} + +fn configure_nested_display_mode_with_connection( + conn: &RustConnection, + root: Window, + width: u16, + height: u16, + frame_rate: u16, +) -> Result<(Output, Mode, bool)> { + let resources = conn.randr_get_screen_resources(root)?.reply()?; + let output = *resources + .outputs + .first() + .context("Xephyr XRandR reported no outputs")?; + let output_info = conn + .randr_get_output_info(output, resources.config_timestamp)? + .reply()?; + let crtc = output_info.crtc; + if crtc == 0 { + bail!("Xephyr XRandR output has no active CRTC"); + } + + let horizontal_blank = 160; + let hsync_start = width + .checked_add(48) + .context("requested Xephyr horizontal timing is too large")?; + let hsync_end = width + .checked_add(80) + .context("requested Xephyr horizontal timing is too large")?; + let htotal = width + .checked_add(horizontal_blank) + .context("requested Xephyr horizontal timing is too large")?; + let vertical_blank = 30; + let vsync_start = height + .checked_add(3) + .context("requested Xephyr vertical timing is too large")?; + let vsync_end = height + .checked_add(9) + .context("requested Xephyr vertical timing is too large")?; + let vtotal = height + .checked_add(vertical_blank) + .context("requested Xephyr vertical timing is too large")?; + let frame_pixels = u32::from(htotal) * u32::from(vtotal); + let maximum_frame_rate = (u32::MAX / frame_pixels).max(1).min(u32::from(u16::MAX)) as u16; + let mode_frame_rate = frame_rate.min(maximum_frame_rate); + let dot_clock = frame_pixels * u32::from(mode_frame_rate); + let (mode, created) = if let Some(mode) = resources.modes.iter().find(|mode| { + mode.width == width + && mode.height == height + && mode.dot_clock == dot_clock + && mode.htotal == htotal + && mode.vtotal == vtotal + }) { + (mode.id, false) + } else { + let name = format!("{width}x{height}_{mode_frame_rate}.00"); + let mode = conn + .randr_create_mode( + root, + ModeInfo { + id: 0, + width, + height, + dot_clock, + hsync_start, + hsync_end, + htotal, + hskew: 0, + vsync_start, + vsync_end, + vtotal, + name_len: name + .len() + .try_into() + .context("RandR mode name is too long")?, + mode_flags: ModeFlag::HSYNC_NEGATIVE | ModeFlag::VSYNC_POSITIVE, + }, + name.as_bytes(), + )? + .reply()? + .mode; + conn.randr_add_output_mode(output, mode)?.check()?; + (mode, true) + }; + let result = conn + .randr_set_crtc_config( + crtc, + 0, + resources.config_timestamp, + 0, + 0, + mode, + Rotation::ROTATE0, + &[output], + )? + .reply()?; + if result.status != SetConfig::SUCCESS { + bail!( + "Xephyr rejected the RandR mode with status {}", + u8::from(result.status) + ); + } + conn.flush()?; + Ok((output, mode, created)) +} + +fn destroy_nested_display_mode(conn: &RustConnection, output: Output, mode: Mode) -> Result<()> { + conn.randr_delete_output_mode(output, mode)?.check()?; + conn.randr_destroy_mode(mode)?.check()?; + conn.flush()?; + Ok(()) +} + +fn resize_host_window(display: &str, window: Window, width: u16, height: u16) -> Result<()> { + let (conn, _) = x11rb::connect(Some(display))?; + conn.configure_window( + window, + &ConfigureWindowAux::new() + .width(u32::from(width)) + .height(u32::from(height)), + )? + .check()?; + conn.flush()?; + Ok(()) +} + +fn client_windows_with_connection( + conn: &RustConnection, + screen_num: usize, +) -> Result> { + let root = conn.setup().roots[screen_num].root; + let atom = intern_atom(&conn, b"_NET_CLIENT_LIST")?; + let reply = conn + .get_property(false, root, atom, AtomEnum::WINDOW, 0, u32::MAX)? + .reply()?; + Ok(reply.value32().into_iter().flatten().collect()) +} + +fn is_xephyr_window(conn: &RustConnection, window: Window) -> Result { + let reply = conn + .get_property(false, window, AtomEnum::WM_CLASS, AtomEnum::STRING, 0, 1024)? + .reply()?; + let classes = reply + .value + .split(|byte| *byte == 0) + .filter_map(|value| std::str::from_utf8(value).ok()); + Ok(classes.into_iter().any(|class| class == "Xephyr")) +} + +fn configure_host_window(display: &str, window: Window, app_pid: u32) { + let result = (|| -> Result<()> { + let (conn, _) = x11rb::connect(Some(display))?; + conn.change_property8( + PropMode::REPLACE, + window, + AtomEnum::WM_CLASS, + AtomEnum::STRING, + b"amll-player\0Amll-player\0", + )? + .check()?; + let pid_atom = intern_atom(&conn, b"_NET_WM_PID")?; + conn.change_property32( + PropMode::REPLACE, + window, + pid_atom, + AtomEnum::CARDINAL, + &[app_pid], + )? + .check()?; + let window_type_atom = intern_atom(&conn, b"_NET_WM_WINDOW_TYPE")?; + let normal_atom = intern_atom(&conn, b"_NET_WM_WINDOW_TYPE_NORMAL")?; + conn.change_property32( + PropMode::REPLACE, + window, + window_type_atom, + AtomEnum::ATOM, + &[normal_atom], + )? + .check()?; + let motif_hints_atom = intern_atom(&conn, b"_MOTIF_WM_HINTS")?; + conn.change_property32( + PropMode::REPLACE, + window, + motif_hints_atom, + motif_hints_atom, + // KWin treats partial Motif decorations as a full frame, so disable them entirely. + &[2, 0, 0, 0, 0], + )? + .check()?; + conn.flush()?; + Ok(()) + })(); + + if let Err(error) = result { + eprintln!("Failed to configure Xephyr host window: {error:#}"); + } +} + +fn sync_nested_main_window( + display: &str, + cached_window: &mut Option, + last_root_size: &mut Option<(u16, u16)>, + last_dynamic_mode: &mut Option<(Output, Mode)>, + frame_rate: u16, +) -> Result<()> { + let (conn, screen_num) = x11rb::connect(Some(display))?; + let root = conn.setup().roots[screen_num].root; + let root_geometry = conn.get_geometry(root)?.reply()?; + let root_size = (root_geometry.width, root_geometry.height); + + if *last_root_size != Some(root_size) { + match configure_nested_display_mode_with_connection( + &conn, + root, + root_size.0, + root_size.1, + frame_rate, + ) { + Ok((output, mode, created)) => { + if let Some((previous_output, previous_mode)) = last_dynamic_mode.take() + && previous_mode != mode + && let Err(error) = + destroy_nested_display_mode(&conn, previous_output, previous_mode) + { + eprintln!("Failed to remove stale Xephyr RandR mode: {error:#}"); + } + if created { + *last_dynamic_mode = Some((output, mode)); + } + } + Err(error) => { + eprintln!( + "Failed to update the Xephyr RandR mode for {}x{} ({error:#}); continuing with window synchronization", + root_size.0, root_size.1 + ); + } + } + } + + let current_window = find_window_by_title(&conn, root, "AMLL Player")?; + if *cached_window != current_window { + *cached_window = current_window; + *last_root_size = None; + } + let Some(window) = *cached_window else { + return Ok(()); + }; + + let geometry = conn.get_geometry(window)?.reply()?; + let coordinates = conn.translate_coordinates(window, root, 0, 0)?.reply()?; + let state_atom = intern_atom(&conn, b"_NET_WM_STATE")?; + let max_vert = intern_atom(&conn, b"_NET_WM_STATE_MAXIMIZED_VERT")?; + let max_horz = intern_atom(&conn, b"_NET_WM_STATE_MAXIMIZED_HORZ")?; + let inner_maximized = window_has_state(&conn, window, state_atom, max_vert)? + || window_has_state(&conn, window, state_atom, max_horz)?; + let geometry_matches = coordinates.dst_x == 0 + && coordinates.dst_y == 0 + && geometry.width == root_size.0 + && geometry.height == root_size.1; + + if *last_root_size == Some(root_size) && geometry_matches && !inner_maximized { + return Ok(()); + } + + remove_inner_maximize(&conn, root, window)?; + conn.configure_window( + window, + &ConfigureWindowAux::new() + .x(0) + .y(0) + .width(u32::from(root_size.0)) + .height(u32::from(root_size.1)), + )? + .check()?; + conn.flush()?; + *last_root_size = Some(root_size); + Ok(()) +} + +fn find_window_by_title( + conn: &RustConnection, + root: Window, + expected_title: &str, +) -> Result> { + let clients_atom = intern_atom(conn, b"_NET_CLIENT_LIST")?; + let name_atom = intern_atom(conn, b"_NET_WM_NAME")?; + let utf8_atom = intern_atom(conn, b"UTF8_STRING")?; + let clients = conn + .get_property(false, root, clients_atom, AtomEnum::WINDOW, 0, u32::MAX)? + .reply()?; + + for window in clients.value32().into_iter().flatten() { + let title = conn + .get_property(false, window, name_atom, utf8_atom, 0, 1024)? + .reply()?; + if title.value == expected_title.as_bytes() { + return Ok(Some(window)); + } + } + Ok(None) +} + +fn remove_inner_maximize(conn: &RustConnection, root: Window, window: Window) -> Result<()> { + let state_atom = intern_atom(conn, b"_NET_WM_STATE")?; + let max_vert = intern_atom(conn, b"_NET_WM_STATE_MAXIMIZED_VERT")?; + let max_horz = intern_atom(conn, b"_NET_WM_STATE_MAXIMIZED_HORZ")?; + send_wm_state(conn, root, window, state_atom, 0, max_vert, max_horz) +} + +fn toggle_host_fullscreen() -> Result { + let (conn, root, window) = connect_to_host()?; + let state_atom = intern_atom(&conn, b"_NET_WM_STATE")?; + let fullscreen_atom = intern_atom(&conn, b"_NET_WM_STATE_FULLSCREEN")?; + let fullscreen = window_has_state(&conn, window, state_atom, fullscreen_atom)?; + let next = !fullscreen; + send_wm_state( + &conn, + root, + window, + state_atom, + u32::from(next), + fullscreen_atom, + 0, + )?; + Ok(next) +} + +fn host_is_maximized() -> Result { + let (conn, _, window) = connect_to_host()?; + let state_atom = intern_atom(&conn, b"_NET_WM_STATE")?; + let max_vert = intern_atom(&conn, b"_NET_WM_STATE_MAXIMIZED_VERT")?; + let max_horz = intern_atom(&conn, b"_NET_WM_STATE_MAXIMIZED_HORZ")?; + Ok(window_has_state(&conn, window, state_atom, max_vert)? + && window_has_state(&conn, window, state_atom, max_horz)?) +} + +fn toggle_host_maximize() -> Result { + let (conn, root, window) = connect_to_host()?; + let state_atom = intern_atom(&conn, b"_NET_WM_STATE")?; + let max_vert = intern_atom(&conn, b"_NET_WM_STATE_MAXIMIZED_VERT")?; + let max_horz = intern_atom(&conn, b"_NET_WM_STATE_MAXIMIZED_HORZ")?; + let next = !(window_has_state(&conn, window, state_atom, max_vert)? + && window_has_state(&conn, window, state_atom, max_horz)?); + send_wm_state( + &conn, + root, + window, + state_atom, + u32::from(next), + max_vert, + max_horz, + )?; + Ok(next) +} + +fn minimize_host() -> Result<()> { + let (conn, root, window) = connect_to_host()?; + let change_state_atom = intern_atom(&conn, b"WM_CHANGE_STATE")?; + let event = ClientMessageEvent::new(32, window, change_state_atom, [3, 0, 0, 0, 0]); + conn.send_event( + false, + root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )? + .check()?; + conn.flush()?; + Ok(()) +} + +fn start_host_drag() -> Result<()> { + start_host_move_resize(8) +} + +fn start_host_manual_resize(direction: u32) -> Result<()> { + // The outer Xephyr window is managed by KWin. Let the window manager own + // the interactive resize instead of sending client-side ConfigureWindow + // requests that KWin may ignore or constrain. + start_host_move_resize(direction) +} + +fn start_host_move_resize(direction: u32) -> Result<()> { + let (conn, root, window) = connect_to_host()?; + let pointer = conn.query_pointer(root)?.reply()?; + let move_resize_atom = intern_atom(&conn, b"_NET_WM_MOVERESIZE")?; + let event = ClientMessageEvent::new( + 32, + window, + move_resize_atom, + [ + i32::from(pointer.root_x) as u32, + i32::from(pointer.root_y) as u32, + direction, + 1, + 1, + ], + ); + conn.send_event( + false, + root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )? + .check()?; + conn.flush()?; + Ok(()) +} + +fn connect_to_host() -> Result<(RustConnection, Window, Window)> { + let display = env::var(HOST_DISPLAY_ENV).context("missing Openbox host display")?; + let window = env::var(HOST_WINDOW_ENV) + .context("missing Openbox host window")? + .parse::() + .context("invalid Openbox host window")?; + let (conn, screen_num) = x11rb::connect(Some(&display))?; + let root = conn.setup().roots[screen_num].root; + Ok((conn, root, window)) +} + +fn window_has_state( + conn: &RustConnection, + window: Window, + state_atom: Atom, + expected_state: Atom, +) -> Result { + let states = conn + .get_property(false, window, state_atom, AtomEnum::ATOM, 0, u32::MAX)? + .reply()?; + Ok(states + .value32() + .is_some_and(|mut states| states.any(|state| state == expected_state))) +} + +fn send_wm_state( + conn: &RustConnection, + root: Window, + window: Window, + state_atom: Atom, + action: u32, + first: Atom, + second: Atom, +) -> Result<()> { + let event = ClientMessageEvent::new(32, window, state_atom, [action, first, second, 1, 0]); + conn.send_event( + false, + root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )? + .check()?; + conn.flush()?; + Ok(()) +} + +fn intern_atom(conn: &RustConnection, name: &[u8]) -> Result { + Ok(conn.intern_atom(false, name)?.reply()?.atom) +} + +fn terminate(child: &mut Child) { + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + let _ = child.wait(); + } +} + +struct ManagedChild(Child); + +impl ManagedChild { + fn new(child: Child) -> Self { + Self(child) + } +} + +impl Deref for ManagedChild { + type Target = Child; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ManagedChild { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Drop for ManagedChild { + fn drop(&mut self) { + terminate(&mut self.0); + } +} + +fn child_command(program: impl AsRef) -> Command { + let mut command = Command::new(program); + let parent_pid = std::process::id() as libc::pid_t; + // SAFETY: pre_exec only calls async-signal-safe libc functions before exec. + unsafe { + command.pre_exec(move || { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::getppid() != parent_pid { + libc::raise(libc::SIGTERM); + } + Ok(()) + }); + } + command +} + +fn restart_with_saved_system_environment() -> Result<()> { + let current_exe = env::current_exe().context("failed to locate the AMLL executable")?; + Command::new(current_exe) + .args(env::args_os().skip(1)) + .stdin(Stdio::null()) + .spawn() + .context("failed to restart AMLL with the system webview backend")?; + Ok(()) +} + +struct DisplayReservation { + number: u16, + _lock_file: File, +} + +impl DisplayReservation { + fn acquire() -> Result { + for number in DISPLAY_START..=DISPLAY_END { + let socket_path = Path::new("/tmp/.X11-unix").join(format!("X{number}")); + let lock_path = PathBuf::from(format!("/tmp/amll-player-openbox-{number}.lock")); + if socket_path.exists() { + continue; + } + let lock_file = OpenOptions::new() + .write(true) + .create(true) + .open(&lock_path)?; + match lock_file.try_lock() { + Ok(()) => { + return Ok(Self { + number, + _lock_file: lock_file, + }); + } + Err(std::fs::TryLockError::WouldBlock) => continue, + Err(std::fs::TryLockError::Error(error)) => return Err(error.into()), + } + } + Err(anyhow!( + "no free nested X11 display between :{DISPLAY_START} and :{DISPLAY_END}" + )) + } +} + +impl Drop for DisplayReservation { + fn drop(&mut self) { + let _ = self._lock_file.unlock(); + } +} + +fn configured_gdk_backend() -> Option { + env::var("GDK_BACKEND").ok().map(|value| { + if value + .split(',') + .next() + .is_some_and(|backend| backend.trim().eq_ignore_ascii_case("wayland")) + { + ConfiguredGdkBackend::Wayland + } else { + ConfiguredGdkBackend::Other + } + }) +} + +fn is_wayland_session() -> bool { + env::var_os("WAYLAND_DISPLAY").is_some() + || env::var("XDG_SESSION_TYPE").is_ok_and(|value| value.eq_ignore_ascii_case("wayland")) +} + +fn has_nvidia_gpu() -> bool { + if Path::new("/proc/driver/nvidia/version").exists() + || Path::new("/sys/module/nvidia_drm").exists() + { + return true; + } + + fs::read_dir("/sys/class/drm") + .map(|entries| { + entries.flatten().any(|entry| { + fs::read_to_string(entry.path().join("device/vendor")) + .is_ok_and(|vendor| vendor.trim().eq_ignore_ascii_case("0x10de")) + }) + }) + .unwrap_or(false) +} + +fn x11_display_available() -> bool { + let Ok(display) = env::var("DISPLAY") else { + return false; + }; + if display.is_empty() { + return false; + } + + let local_display = display + .strip_prefix(':') + .or_else(|| display.strip_prefix("unix:")); + let Some(local_display) = local_display else { + return true; + }; + let display_number = local_display.split('.').next().unwrap_or_default(); + if display_number.is_empty() || !display_number.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + + Path::new("/tmp/.X11-unix") + .join(format!("X{display_number}")) + .exists() +} + +fn command_available(command: &str) -> bool { + env::var_os("PATH").is_some_and(|paths| { + env::split_paths(&paths).any(|path| { + let candidate = path.join(command); + candidate.is_file() + }) + }) +} + +const OPENBOX_CONFIG: &str = r#" + + + yes + no + yes + + + Smart +
yes
+
+ + 1 + + + + no + 00 + 100%100% + + +
+"#; + +#[cfg(test)] +mod tests { + use super::*; + + fn context( + nvidia_gpu: bool, + x11_available: bool, + openbox_available: bool, + ) -> LinuxDisplayContext { + LinuxDisplayContext { + wayland_session: true, + nvidia_gpu, + x11_available, + openbox_available, + configured_gdk_backend: None, + } + } + + #[test] + fn auto_prefers_openbox_for_nvidia_wayland() { + assert_eq!( + select_backend(BackendPreference::Auto, context(true, true, true)), + LinuxWebviewBackend::Openbox + ); + } + + #[test] + fn auto_falls_back_to_x11_without_wrapper_dependencies() { + assert_eq!( + select_backend(BackendPreference::Auto, context(true, true, false)), + LinuxWebviewBackend::X11 + ); + } + + #[test] + fn auto_falls_back_to_software_wayland_without_x11() { + assert_eq!( + select_backend(BackendPreference::Auto, context(true, false, true)), + LinuxWebviewBackend::WaylandSoftware + ); + } + + #[test] + fn auto_keeps_native_backend_for_other_gpus() { + assert_eq!( + select_backend(BackendPreference::Auto, context(false, true, true)), + LinuxWebviewBackend::System + ); + } + + #[test] + fn explicit_openbox_has_safe_fallbacks() { + assert_eq!( + select_backend(BackendPreference::Openbox, context(false, true, false)), + LinuxWebviewBackend::X11 + ); + assert_eq!( + select_backend(BackendPreference::Openbox, context(false, false, false)), + LinuxWebviewBackend::WaylandSoftware + ); + } + + #[test] + fn explicit_wayland_modes_are_preserved() { + assert_eq!( + select_backend(BackendPreference::Wayland, context(true, true, true)), + LinuxWebviewBackend::Wayland + ); + assert_eq!( + select_backend( + BackendPreference::WaylandSoftware, + context(true, true, true) + ), + LinuxWebviewBackend::WaylandSoftware + ); + } + + #[test] + fn auto_overrides_wayland_gdk_for_nvidia() { + let mut display = context(true, true, true); + display.configured_gdk_backend = Some(ConfiguredGdkBackend::Wayland); + assert_eq!( + select_backend(BackendPreference::Auto, display), + LinuxWebviewBackend::Openbox + ); + } + + #[test] + fn auto_respects_other_gdk_backends() { + let mut display = context(true, true, true); + display.configured_gdk_backend = Some(ConfiguredGdkBackend::Other); + assert_eq!( + select_backend(BackendPreference::Auto, display), + LinuxWebviewBackend::System + ); + } + + #[test] + fn system_preference_restores_original_behavior() { + assert_eq!( + select_backend(BackendPreference::System, context(true, true, true)), + LinuxWebviewBackend::System + ); + } + + #[test] + fn parser_accepts_openbox_override() { + assert_eq!( + parse_preference("OPENBOX"), + Some(BackendPreference::Openbox) + ); + } + + #[test] + fn xephyr_defaults_to_sixty_hz() { + assert_eq!(xephyr_frame_rate(None, 260), 60); + } + + #[test] + fn xephyr_accepts_a_high_refresh_override_within_host_limit() { + assert_eq!(xephyr_frame_rate(Some("120"), 260), 120); + assert_eq!(xephyr_frame_rate(Some("300"), 260), 60); + } +} diff --git a/packages/player/src-tauri/src/window.rs b/packages/player/src-tauri/src/window.rs index b0e7810f..135e606f 100644 --- a/packages/player/src-tauri/src/window.rs +++ b/packages/player/src-tauri/src/window.rs @@ -1,14 +1,26 @@ +use serde::Serialize; use tauri::{AppHandle, Manager, WebviewWindowBuilder}; #[cfg(desktop)] use tauri::{PhysicalSize, Size, utils::config::WindowEffectsConfig, window::Effect}; use tracing::*; +#[derive(Serialize)] +pub struct LinuxWebviewPrompt { + config_path: String, +} + pub async fn create_common_win<'a>( app: &'a AppHandle, url: tauri::WebviewUrl, label: &str, ) -> WebviewWindowBuilder<'a, tauri::Wry, AppHandle> { let win = WebviewWindowBuilder::new(app, label, url); + #[cfg(target_os = "linux")] + let win = if label == "main" && crate::linux_webview::is_openbox_wrapper() { + win.initialization_script("document.documentElement.dataset.amllOpenboxWrapper = 'true';") + } else { + win + }; #[cfg(target_os = "windows")] let win = win.transparent(true); #[cfg(not(desktop))] @@ -93,7 +105,11 @@ pub async fn recreate_window(app: &AppHandle, label: &str, path: Option<&str>) { #[cfg(desktop)] { let _ = win.set_focus(); - if let Ok(orig_size) = win.inner_size() { + #[cfg(target_os = "linux")] + let apply_redraw_workaround = !crate::linux_webview::is_openbox_wrapper(); + #[cfg(not(target_os = "linux"))] + let apply_redraw_workaround = true; + if apply_redraw_workaround && let Ok(orig_size) = win.inner_size() { let _ = win.set_size(Size::Physical(PhysicalSize::new(0, 0))); let _ = win.set_size(orig_size); } @@ -109,6 +125,120 @@ pub async fn open_screenshot_window(app: AppHandle) { recreate_window(&app, "screenshot", Some("screenshot.html")).await; } +#[tauri::command] +pub fn get_linux_webview_startup_prompt() -> Option { + #[cfg(target_os = "linux")] + { + crate::linux_webview::first_run_prompt().map(|path| LinuxWebviewPrompt { + config_path: path.display().to_string(), + }) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[tauri::command] +pub fn set_linux_webview_backend(backend: String) -> Result { + #[cfg(target_os = "linux")] + { + crate::linux_webview::save_preference(&backend) + .map(|path| path.display().to_string()) + .map_err(|error| error.to_string()) + } + #[cfg(not(target_os = "linux"))] + { + let _ = backend; + Err("Linux webview settings are only available on Linux".to_string()) + } +} + +#[tauri::command] +pub fn restart_linux_webview_with_system_backend() -> Result<(), String> { + #[cfg(target_os = "linux")] + { + crate::linux_webview::restart_with_system_backend() + } + #[cfg(not(target_os = "linux"))] + { + Err("Linux webview settings are only available on Linux".to_string()) + } +} + +#[tauri::command] +pub fn toggle_openbox_fullscreen() -> Result, String> { + #[cfg(target_os = "linux")] + { + crate::linux_webview::toggle_openbox_fullscreen() + } + #[cfg(not(target_os = "linux"))] + { + Ok(None) + } +} + +#[tauri::command] +pub fn is_openbox_maximized() -> Result, String> { + #[cfg(target_os = "linux")] + { + crate::linux_webview::is_openbox_maximized() + } + #[cfg(not(target_os = "linux"))] + { + Ok(None) + } +} + +#[tauri::command] +pub fn toggle_openbox_maximize() -> Result, String> { + #[cfg(target_os = "linux")] + { + crate::linux_webview::toggle_openbox_maximize() + } + #[cfg(not(target_os = "linux"))] + { + Ok(None) + } +} + +#[tauri::command] +pub fn minimize_openbox_wrapper() -> Result { + #[cfg(target_os = "linux")] + { + crate::linux_webview::minimize_openbox_wrapper() + } + #[cfg(not(target_os = "linux"))] + { + Ok(false) + } +} + +#[tauri::command] +pub fn start_openbox_drag() -> Result { + #[cfg(target_os = "linux")] + { + crate::linux_webview::start_openbox_drag() + } + #[cfg(not(target_os = "linux"))] + { + Ok(false) + } +} + +#[tauri::command] +pub fn start_openbox_resize(direction: String) -> Result { + #[cfg(target_os = "linux")] + { + crate::linux_webview::start_openbox_resize(&direction) + } + #[cfg(not(target_os = "linux"))] + { + let _ = direction; + Ok(false) + } +} + #[cfg(target_os = "windows")] #[tauri::command] pub fn set_window_always_on_top(enabled: bool, app: AppHandle) -> Result<(), String> { diff --git a/packages/player/src/App.tsx b/packages/player/src/App.tsx index c65187f3..344100a3 100644 --- a/packages/player/src/App.tsx +++ b/packages/player/src/App.tsx @@ -8,6 +8,7 @@ import styles from "./App.module.css"; import { AppContainer } from "./components/AppContainer/index.tsx"; import { ExtensionInjectPoint } from "./components/ExtensionInjectPoint/index.tsx"; import { LocalMusicContext } from "./components/LocalMusicContext/index.tsx"; +import { LinuxWebviewStartupPrompt } from "./components/LinuxWebviewStartupPrompt/index.tsx"; import { MigrationDialog } from "./components/MigrationDialog/index.tsx"; import { NowPlayingBar } from "./components/NowPlayingBar/index.tsx"; import { ShotcutContext } from "./components/ShotcutContext/index.tsx"; @@ -75,6 +76,7 @@ function App() { hasBackground={hasBackground} className={styles.radixTheme} > + { { + const wrapperFullscreen = await invoke( + "toggle_openbox_fullscreen", + ); + if (wrapperFullscreen !== null) { + setSystemTitlebarFullscreen(wrapperFullscreen); + return; + } const win = getCurrentWindow(); const isFullscreen = await win.isFullscreen(); setSystemTitlebarFullscreen(!isFullscreen); diff --git a/packages/player/src/components/LinuxWebviewStartupPrompt/index.tsx b/packages/player/src/components/LinuxWebviewStartupPrompt/index.tsx new file mode 100644 index 00000000..af31e2a5 --- /dev/null +++ b/packages/player/src/components/LinuxWebviewStartupPrompt/index.tsx @@ -0,0 +1,101 @@ +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { Box, Button, Callout, Dialog, Flex, Text } from "@radix-ui/themes"; +import { invoke } from "@tauri-apps/api/core"; +import { type FC, useEffect, useState } from "react"; + +interface LinuxWebviewPrompt { + configPath: string; +} + +export const LinuxWebviewStartupPrompt: FC = () => { + const [prompt, setPrompt] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + invoke("get_linux_webview_startup_prompt") + .then((result) => { + if (active) setPrompt(result); + }) + .catch((reason) => + console.error("Failed to get Linux webview prompt:", reason), + ); + return () => { + active = false; + }; + }, []); + + const saveChoice = async (backend: "system" | "openbox") => { + setSaving(true); + setError(null); + try { + await invoke("set_linux_webview_backend", { backend }); + if (backend === "openbox") return setPrompt(null); + await invoke("restart_linux_webview_with_system_backend"); + } catch (reason) { + setError(String(reason)); + } finally { + setSaving(false); + } + }; + + if (!prompt) return null; + + return ( + + event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + > + 检测到可能不稳定的 Linux 显示环境 + + 当前会话使用 NVIDIA GPU 和 Wayland。部分 WebKitGTK + 版本在该组合下可能出现显式同步崩溃、黑屏或背景丢失。 + + + + + + + + 此确认框已在临时 Xephyr + Openbox + 会话中安全显示。选择保留当前环境会重启到原生后端。 + + + + + + 选择会保存到:{prompt.configPath} + + + 可自行修改其中的 backendopenbox、 + systemx11wayland 或{" "} + wayland-software。 + + + + {error && ( + + {error} + + )} + + + + + + + + ); +}; diff --git a/packages/player/src/main.tsx b/packages/player/src/main.tsx index ae7d9428..103aa3ff 100644 --- a/packages/player/src/main.tsx +++ b/packages/player/src/main.tsx @@ -1,5 +1,6 @@ import "./styles.css"; import "react-toastify/dist/ReactToastify.css"; +import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { Provider } from "jotai"; import { createRoot } from "react-dom/client"; @@ -33,6 +34,20 @@ addEventListener("on-system-titlebar-click-close", async () => { }); addEventListener("on-system-titlebar-click-resize", async () => { + const wrapperMaximized = await invoke( + "toggle_openbox_maximize", + ); + if (wrapperMaximized !== null) { + document.documentElement.dataset.amllOpenboxMaximized = + wrapperMaximized.toString(); + setSystemTitlebarResizeAppearance( + wrapperMaximized + ? SystemTitlebarResizeAppearance.Restore + : SystemTitlebarResizeAppearance.Maximize, + ); + return; + } + const win = getCurrentWindow(); if (await win.isMaximizable()) { if (await win.isMaximized()) { @@ -49,7 +64,13 @@ addEventListener("on-system-titlebar-click-resize", async () => { const win = getCurrentWindow(); async function checkWindow() { - if (await win.isMaximized()) { + const wrapperMaximized = await invoke("is_openbox_maximized"); + if (wrapperMaximized !== null) { + document.documentElement.dataset.amllOpenboxMaximized = + wrapperMaximized.toString(); + } + const maximized = wrapperMaximized ?? (await win.isMaximized()); + if (maximized) { setSystemTitlebarResizeAppearance(SystemTitlebarResizeAppearance.Restore); } else { setSystemTitlebarResizeAppearance(SystemTitlebarResizeAppearance.Maximize); @@ -59,6 +80,7 @@ checkWindow(); win.onResized(checkWindow); addEventListener("on-system-titlebar-click-minimize", async () => { + if (await invoke("minimize_openbox_wrapper")) return; const win = getCurrentWindow(); await win.minimize(); }); diff --git a/packages/player/vite.config.ts b/packages/player/vite.config.ts index cc543cf3..3dd66154 100644 --- a/packages/player/vite.config.ts +++ b/packages/player/vite.config.ts @@ -86,7 +86,13 @@ const GitMetadataPlugin = (): Plugin => { }; // https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig(({ command }) => { + // Vite uses NODE_ENV to decide whether to inject the React Refresh preamble. + // `vite` itself is always a development server, even when its parent process + // happens to export NODE_ENV=production. + if (command === "serve") process.env.NODE_ENV = "development"; + + return { build: { chunkSizeWarningLimit: 2000, rolldownOptions: { @@ -150,4 +156,5 @@ export default defineConfig({ // 3. to make use of `TAURI_DEBUG` and other env variables // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand envPrefix: ["VITE_", "TAURI_"], + }; });