From 4035879aae1f8d12d92aa30cdf693876181f7ffa Mon Sep 17 00:00:00 2001 From: itsneufox Date: Thu, 22 Oct 2026 23:54:51 +0100 Subject: [PATCH 1/2] Add Wine support with various enhancements and fixes --- src-tauri/Cargo.toml | 2 + src-tauri/src/commands.rs | 8 ++ src-tauri/src/ipc.rs | 26 +++--- src-tauri/src/main.rs | 27 +++++- src-tauri/src/query.rs | 117 +++++++++++++++++++------ src-tauri/src/wine.rs | 111 +++++++++++++++++++++++ src-tauri/tauri.conf.json | 1 + src/App.tsx | 13 ++- src/containers/LoadingScreen/index.tsx | 7 +- src/hooks/wine.ts | 67 ++++++++++++++ 10 files changed, 336 insertions(+), 43 deletions(-) create mode 100644 src-tauri/src/wine.rs create mode 100644 src/hooks/wine.ts diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8c6aeb77..808915be 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,6 +45,8 @@ tauri-utils = { version = "1" } interprocess = { version = "1.2.1", default-features = false } windows-sys = { version = "0.52.0", features = [ "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dce2f87d..f0eeb322 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -95,6 +95,14 @@ pub fn resolve_hostname(hostname: String) -> std::result::Result helpers::resolve_hostname_to_ipv4(&hostname) } +#[tauri::command] +pub fn is_wine() -> bool { + #[cfg(windows)] + return crate::wine::is_wine(); + #[cfg(not(windows))] + false +} + #[tauri::command] pub fn is_process_alive(pid: u32) -> bool { use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 3c09153e..257ada14 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -92,19 +92,25 @@ fn create_overlay_window(app: &tauri::AppHandle, pid: i32) -> Option { return overlay_window_id(&window); } - match WindowBuilder::new( + let mut builder = WindowBuilder::new( app, label.as_str(), WindowUrl::App(format!("index.html?attached_id={}", pid).into()), - ) - .transparent(true) - .decorations(false) - .always_on_top(false) - .resizable(false) - .visible(false) - .position(-10000.0, -10000.0) - .skip_taskbar(true) - .build() + ); + if let Some(args) = crate::wine::browser_args() { + builder = builder.additional_browser_args(&args); + } + + match builder + .transparent(true) + .decorations(false) + .always_on_top(false) + .resizable(false) + .visible(false) + .position(-10000.0, -10000.0) + .skip_taskbar(true) + .disable_file_drop_handler() + .build() { Ok(window) => { let _ = window.set_title(&label); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3925e6e3..c8b1c403 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -11,6 +11,8 @@ mod ipc; mod query; mod samp; mod validation; +#[cfg(windows)] +mod wine; #[path = "deeplink/lib.rs"] #[cfg(target_os = "windows")] @@ -113,7 +115,10 @@ async fn main() { } if let Err(e) = run_tauri_app().await { - error!("Failed to run Tauri app: {}", e); + let message = e.to_string(); + #[cfg(windows)] + wine::show_webview_startup_error(&message); + error!("Failed to run Tauri app: {}", message); exit(1); } } @@ -216,6 +221,7 @@ async fn run_tauri_app() -> Result<()> { commands::rerun_as_admin, commands::resolve_hostname, commands::is_process_alive, + commands::is_wine, commands::log_info, commands::log_warn, commands::log_error, @@ -225,7 +231,7 @@ async fn run_tauri_app() -> Result<()> { query::query_server, ipc::send_message_to_game ]) - .run(tauri::generate_context!()); + .run(tauri_context()); match builder_result { Ok(_) => Ok(()), @@ -236,11 +242,28 @@ async fn run_tauri_app() -> Result<()> { } } +fn tauri_context() -> tauri::Context { + #[allow(unused_mut)] + let mut context = tauri::generate_context!(); + + #[cfg(windows)] + if let Some(args) = wine::browser_args() { + for window in &mut context.config_mut().tauri.windows { + window.additional_browser_args = Some(args.clone()); + } + } + + context +} + fn setup_tauri_app(app: &mut tauri::App) -> std::result::Result<(), Box> { let handle = app.handle(); if let Some(main_window) = app.get_window("main") { main_window.set_min_size(Some(PhysicalSize::new(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)))?; + + #[cfg(windows)] + wine::round_window_corners(&main_window); } let config = handle.config(); diff --git a/src-tauri/src/query.rs b/src-tauri/src/query.rs index 6485d1c2..86cdc337 100644 --- a/src-tauri/src/query.rs +++ b/src-tauri/src/query.rs @@ -4,10 +4,11 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::{Cursor, Read}; +use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}; use std::sync::Mutex; +use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; -use std::{net::Ipv4Addr, time::Duration}; -use tokio::net::{lookup_host, UdpSocket}; +use tokio::net::{lookup_host, UdpSocket as TokioUdpSocket}; use tokio::time::timeout_at; use tokio::time::Instant; @@ -39,7 +40,12 @@ static CACHED_QUERY: Lazy>> = pub struct Query { address: Ipv4Addr, port: i32, - socket: UdpSocket, + socket: QuerySocket, +} + +enum QuerySocket { + Tokio(TokioUdpSocket), + Blocking(StdUdpSocket), } #[derive(Serialize, Deserialize, Default)] @@ -103,14 +109,36 @@ impl Query { })? }; - let socket = UdpSocket::bind("0.0.0.0:0") - .await - .map_err(|e| LauncherError::Network(format!("Failed to bind socket: {}", e)))?; + let socket_port = u16::try_from(port) + .map_err(|_| LauncherError::InvalidInput("Invalid server port".to_string()))?; + let socket_address = SocketAddr::new(parsed_address.into(), socket_port); - socket - .connect(format!("{}:{}", addr, port)) - .await - .map_err(|e| LauncherError::Network(format!("Failed to connect to server: {}", e)))?; + #[cfg(windows)] + let use_blocking_socket = crate::wine::is_wine(); + #[cfg(not(windows))] + let use_blocking_socket = false; + + let socket = if use_blocking_socket { + let socket = StdUdpSocket::bind("0.0.0.0:0") + .map_err(|e| LauncherError::Network(format!("Failed to bind socket: {}", e)))?; + socket.connect(socket_address).map_err(|e| { + LauncherError::Network(format!("Failed to connect to server: {}", e)) + })?; + socket + .set_read_timeout(Some(Duration::from_secs(QUERY_TIMEOUT_SECS))) + .map_err(|e| { + LauncherError::Network(format!("Failed to set socket timeout: {}", e)) + })?; + QuerySocket::Blocking(socket) + } else { + let socket = TokioUdpSocket::bind("0.0.0.0:0") + .await + .map_err(|e| LauncherError::Network(format!("Failed to bind socket: {}", e)))?; + socket.connect(socket_address).await.map_err(|e| { + LauncherError::Network(format!("Failed to connect to server: {}", e)) + })?; + QuerySocket::Tokio(socket) + }; let data = Self { address: parsed_address, @@ -139,25 +167,64 @@ impl Query { packet.extend_from_slice(&payload); } - let amt = self - .socket - .send(&packet) - .await - .map_err(|e| LauncherError::Network(format!("Failed to send packet: {}", e)))?; + let amt = match &self.socket { + QuerySocket::Tokio(socket) => socket + .send(&packet) + .await + .map_err(|e| LauncherError::Network(format!("Failed to send packet: {}", e)))?, + QuerySocket::Blocking(socket) => socket + .send(&packet) + .map_err(|e| LauncherError::Network(format!("Failed to send packet: {}", e)))?, + }; Ok(amt) } pub async fn recv(&self) -> Result { - let mut buf = [0; UDP_BUFFER_SIZE]; - let amt = match timeout_at( - Instant::now() + Duration::from_secs(QUERY_TIMEOUT_SECS), - self.socket.recv(&mut buf), - ) - .await - .map_err(|_| LauncherError::Network("Query timeout".to_string()))? - { - Ok(n) => n, - Err(e) => return Err(LauncherError::from(e)), + let (buf, amt) = match &self.socket { + QuerySocket::Tokio(socket) => { + let mut buf = [0; UDP_BUFFER_SIZE]; + let amt = match timeout_at( + Instant::now() + Duration::from_secs(QUERY_TIMEOUT_SECS), + socket.recv(&mut buf), + ) + .await + .map_err(|_| LauncherError::Network("Query timeout".to_string()))? + { + Ok(amt) => amt, + Err(error) => return Err(LauncherError::from(error)), + }; + (buf, amt) + } + QuerySocket::Blocking(socket) => { + // A blocking read on Tokio's blocking pool avoids Wine's UDP + // readiness notifications repeatedly waking an async recv that + // can only return WouldBlock. The read timeout bounds the task. + let socket = socket.try_clone().map_err(|e| { + LauncherError::Network(format!("Failed to clone query socket: {}", e)) + })?; + let (buf, received) = tokio::task::spawn_blocking(move || { + let mut buf = [0; UDP_BUFFER_SIZE]; + let received = socket.recv(&mut buf); + (buf, received) + }) + .await + .map_err(|e| { + LauncherError::InternalError(format!("Query receive task failed: {}", e)) + })?; + let amt = match received { + Ok(amt) => amt, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) => + { + return Err(LauncherError::Network("Query timeout".to_string())); + } + Err(error) => return Err(LauncherError::from(error)), + }; + (buf, amt) + } }; if amt < 11 { diff --git a/src-tauri/src/wine.rs b/src-tauri/src/wine.rs new file mode 100644 index 00000000..fd238790 --- /dev/null +++ b/src-tauri/src/wine.rs @@ -0,0 +1,111 @@ +use tauri::{Window, WindowEvent}; +use windows_sys::Win32::Graphics::Gdi::{CreateRoundRectRgn, DeleteObject, SetWindowRgn}; +use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress}; +use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; + +// Matches the 10px border radius the frontend uses outside Wine. +const CORNER_RADIUS: f64 = 10.0; + +pub fn is_wine() -> bool { + let module_name = to_wide("ntdll.dll"); + let module = unsafe { GetModuleHandleW(module_name.as_ptr()) }; + if module == 0 { + return false; + } + + unsafe { GetProcAddress(module, b"wine_get_version\0".as_ptr()).is_some() } +} + +// wry's default when no browser arguments are given. +const DEFAULT_BROWSER_ARGS: &str = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection"; + +// WebView2 ignores WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS once the app passes +// arguments, which wry always does, so under Wine they come from +// OMP_WEBVIEW2_ARGS instead. It defaults to software rendering, which paints +// input faster than WebView2's GPU path through Wine; set it empty to use +// the GPU. +pub fn browser_args() -> Option { + if !is_wine() { + return None; + } + + let extra = std::env::var("OMP_WEBVIEW2_ARGS").unwrap_or_else(|_| "--disable-gpu".to_string()); + let extra = extra.trim(); + if extra.is_empty() { + return None; + } + + Some(format!("{} {}", DEFAULT_BROWSER_ARGS, extra)) +} + +// Wine cannot show the transparent window's rounded corners, so the frontend +// drops them there and the window itself is cut to a rounded shape instead. +pub fn round_window_corners(window: &Window) { + if !is_wine() { + return; + } + + apply_rounded_region(window); + + let window_clone = window.clone(); + window.on_window_event(move |event| { + if matches!( + event, + WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } + ) { + apply_rounded_region(&window_clone); + } + }); +} + +fn apply_rounded_region(window: &Window) { + let Ok(hwnd) = window.hwnd() else { + return; + }; + let Ok(size) = window.outer_size() else { + return; + }; + + if window.is_maximized().unwrap_or(false) { + unsafe { + SetWindowRgn(hwnd.0, 0, 1); + } + return; + } + + let scale = window.scale_factor().unwrap_or(1.0); + let diameter = (CORNER_RADIUS * 2.0 * scale).round() as i32; + let width = size.width as i32; + let height = size.height as i32; + + unsafe { + let region = CreateRoundRectRgn(0, 0, width + 1, height + 1, diameter, diameter); + if region == 0 { + return; + } + // On success the system owns the region. + if SetWindowRgn(hwnd.0, region, 1) == 0 { + DeleteObject(region); + } + } +} + +pub fn show_webview_startup_error(error: &str) { + let error = error.to_ascii_lowercase(); + if !error.contains("webview2 error") || !error.contains("0x80070002") { + return; + } + + let title = to_wide("Open Multiplayer Launcher"); + let message = to_wide( + "The launcher could not find the Microsoft Edge WebView2 Runtime.\n\nIf you are running under Wine, install the x86 Evergreen Runtime into the same Wine prefix, then start the launcher again.\n\nDownload: https://go.microsoft.com/fwlink/p/?LinkId=2124701", + ); + + unsafe { + MessageBoxW(0, message.as_ptr(), title.as_ptr(), MB_OK | MB_ICONERROR); + } +} + +fn to_wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cbbc70ed..f551d918 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -85,6 +85,7 @@ "decorations": false, "transparent": true, "userAgent": "omp-launcher", + "fileDropEnabled": false, "center": true } ], diff --git a/src/App.tsx b/src/App.tsx index 0644bcc1..cef8208d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { import { StyleSheet, View } from "react-native"; import { DEBUG_MODE, IN_GAME, IN_GAME_PROCESS_ID } from "./constants/app"; import LoadingScreen from "./containers/LoadingScreen"; +import { useIsWine, useWineCursorReset } from "./hooks/wine"; import WindowTitleBar from "./containers/WindowTitleBar"; import { changeLanguage } from "./locales"; import { useGenericPersistentState } from "./states/genericStates"; @@ -54,6 +55,8 @@ const SettingsModal = lazy(() => import("./containers/Settings")); const App = memo(() => { const [loading, setLoading] = useState(!IN_GAME); const [maximized, setMaximized] = useState(false); + const isWine = useIsWine(); + useWineCursorReset(isWine && !IN_GAME); const { theme } = useTheme(); const { language } = useGenericPersistentState(); const windowSize = useRef(); @@ -218,20 +221,22 @@ const App = memo(() => { endTimer(); }, []); + const borderless = maximized || IN_GAME || isWine; + const appStyle = useMemo( - () => [styles.app, { padding: maximized || IN_GAME ? 0 : 4 }], - [maximized] + () => [styles.app, { padding: borderless ? 0 : 4 }], + [borderless] ); const appViewStyle = useMemo( () => [ styles.appView, { - borderRadius: maximized || IN_GAME ? 0 : 10, + borderRadius: borderless ? 0 : 10, backgroundColor: theme.secondary, }, ], - [maximized, theme.secondary] + [borderless, theme.secondary] ); if (loading) { diff --git a/src/containers/LoadingScreen/index.tsx b/src/containers/LoadingScreen/index.tsx index 62aff0d3..aa7c6a5a 100644 --- a/src/containers/LoadingScreen/index.tsx +++ b/src/containers/LoadingScreen/index.tsx @@ -7,6 +7,7 @@ import { getUpdateInfo } from "../../api/apis"; import Icon from "../../components/Icon"; import Text from "../../components/Text"; import { validFileChecksums } from "../../constants/app"; +import { useIsWine } from "../../hooks/wine"; import { images } from "../../constants/images"; import i18n from "../../locales"; import { UpdateInfo, useAppState } from "../../states/app"; @@ -44,6 +45,7 @@ const INITIAL_DOWNLOAD_STATE: DownloadProgress = { const LoadingScreen = ({ onEnd }: LoadingScreenProps) => { const { theme, themeType } = useTheme(); const { language } = useGenericPersistentState(); + const isWine = useIsWine(); const [loadingStage, setLoadingStage] = useState( LoadingStage.INITIALIZING ); @@ -444,6 +446,7 @@ const LoadingScreen = ({ onEnd }: LoadingScreenProps) => { () => ({ appView: { backgroundColor: theme.secondary, + ...(isWine && { borderRadius: 0 }), }, progressBarContainer: { backgroundColor: theme.serverListItemBackgroundColor, @@ -453,7 +456,7 @@ const LoadingScreen = ({ onEnd }: LoadingScreenProps) => { backgroundColor: theme.primary, }, }), - [theme, downloadInfo.percent] + [theme, downloadInfo.percent, isWine] ); const progressBar = useMemo(() => { @@ -484,7 +487,7 @@ const LoadingScreen = ({ onEnd }: LoadingScreenProps) => { ]); return ( - +
| undefined; + +const getIsWine = () => { + if (!isWinePromise) { + isWinePromise = invoke("is_wine").catch(() => false); + } + return isWinePromise; +}; + +// Wine cannot show per-pixel transparency around the window, so the +// transparent margin and rounded corners render as a black box there. +// The launcher rounds the window itself under Wine (src-tauri/src/wine.rs). +export const useIsWine = (): boolean => { + const [isWine, setIsWine] = useState(false); + + useEffect(() => { + getIsWine().then(setIsWine); + }, []); + + return isWine; +}; + +// Wider than the borderless resize inset wry hit-tests against. +const RESIZE_EDGE = 8; + +// wry sets resize cursors near the window edges but leaves putting the +// arrow back to Chromium, which runs in another process and cannot change +// this window's cursor under Wine, so the resize cursor sticks. +export const useWineCursorReset = (enabled: boolean) => { + useEffect(() => { + if (!enabled) { + return; + } + + let nearEdge = false; + const onMouseMove = (event: MouseEvent) => { + const isNearEdge = + event.clientX < RESIZE_EDGE || + event.clientY < RESIZE_EDGE || + event.clientX > window.innerWidth - RESIZE_EDGE || + event.clientY > window.innerHeight - RESIZE_EDGE; + + if (nearEdge && !isNearEdge) { + appWindow.setCursorIcon("default").catch(() => {}); + } + nearEdge = isNearEdge; + }; + + // Leaving the window crosses an edge too. + const onMouseOut = (event: MouseEvent) => { + if (!event.relatedTarget) { + nearEdge = true; + } + }; + + window.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseout", onMouseOut); + return () => { + window.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseout", onMouseOut); + }; + }, [enabled]); +}; From 22e48574c394de4ad0d10926e6ff1305171e497a Mon Sep 17 00:00:00 2001 From: itsneufox Date: Fri, 23 Oct 2026 01:03:26 +0100 Subject: [PATCH 2/2] Hide the window under Wine until it is sized to avoid a white flash --- src-tauri/src/main.rs | 8 ++++++-- src/App.tsx | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c8b1c403..a0f09b40 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -247,9 +247,13 @@ fn tauri_context() -> tauri::Context { let mut context = tauri::generate_context!(); #[cfg(windows)] - if let Some(args) = wine::browser_args() { + if wine::is_wine() { + let args = wine::browser_args(); for window in &mut context.config_mut().tauri.windows { - window.additional_browser_args = Some(args.clone()); + window.additional_browser_args = args.clone(); + // Wine cannot show the transparent window before the page draws, + // so it would flash white. The frontend shows it once it is sized. + window.visible = false; } } diff --git a/src/App.tsx b/src/App.tsx index cef8208d..1745117c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -111,6 +111,11 @@ const App = memo(() => { appWindow.center(), ]); + // Under Wine the window starts hidden (src-tauri/src/main.rs) + if (!IN_GAME && !(await appWindow.isVisible())) { + await appWindow.show(); + } + // Reset favorite server list outdated cached data const { favorites, updateInFavoritesList } = usePersistentServers.getState();