Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ pub fn resolve_hostname(hostname: String) -> std::result::Result<String, String>
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};
Expand Down
26 changes: 16 additions & 10 deletions src-tauri/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,25 @@ fn create_overlay_window(app: &tauri::AppHandle, pid: i32) -> Option<u32> {
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);
Expand Down
31 changes: 29 additions & 2 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ mod ipc;
mod query;
mod samp;
mod validation;
#[cfg(windows)]
mod wine;

#[path = "deeplink/lib.rs"]
#[cfg(target_os = "windows")]
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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(()),
Expand All @@ -236,11 +242,32 @@ async fn run_tauri_app() -> Result<()> {
}
}

fn tauri_context() -> tauri::Context<tauri::utils::assets::EmbeddedAssets> {
#[allow(unused_mut)]
let mut context = tauri::generate_context!();

#[cfg(windows)]
if wine::is_wine() {
let args = wine::browser_args();
for window in &mut context.config_mut().tauri.windows {
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;
}
}

context
}

fn setup_tauri_app(app: &mut tauri::App) -> std::result::Result<(), Box<dyn std::error::Error>> {
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();
Expand Down
117 changes: 92 additions & 25 deletions src-tauri/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -39,7 +40,12 @@ static CACHED_QUERY: Lazy<tokio::sync::Mutex<Option<(Query, String)>>> =
pub struct Query {
address: Ipv4Addr,
port: i32,
socket: UdpSocket,
socket: QuerySocket,
}

enum QuerySocket {
Tokio(TokioUdpSocket),
Blocking(StdUdpSocket),
}

#[derive(Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String> {
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 {
Expand Down
111 changes: 111 additions & 0 deletions src-tauri/src/wine.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
Loading
Loading