From 36d82ecc787dc1f031c3f7a4ddd2a9b25ab5aa95 Mon Sep 17 00:00:00 2001 From: SomeBigFace <342171007+SomeBigFace@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:50:02 -0700 Subject: [PATCH] Handle Windows elevation-required errors when launching the game Play failed with ERROR_ELEVATION_REQUIRED (740) for clients whose executable requires administrator rights, because Command::spawn shows no permission prompt. Launch normally first and, only on 740, start the game through ShellExecuteExW with the runas verb, keeping the client folder as the working directory and closing the returned process handle. Other errors, including a declined prompt (1223), are returned unchanged. The Manager itself stays unelevated. Co-Authored-By: Claude Opus 5.5 --- changelog.d/20261020-client-elevation.md | 7 +++ crates/coa-core/Cargo.toml | 2 +- crates/coa-core/src/client.rs | 61 +++++++++++++++++++++++- 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 changelog.d/20261020-client-elevation.md diff --git a/changelog.d/20261020-client-elevation.md b/changelog.d/20261020-client-elevation.md new file mode 100644 index 0000000..1e84aab --- /dev/null +++ b/changelog.d/20261020-client-elevation.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: players +title: Play can launch Windows clients that require administrator approval +--- +Windows now shows its normal permission prompt when the game requires elevation. Other launch errors retain their original details. diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index 113c946..8603104 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -32,7 +32,7 @@ rand_core = { version = "0.6", features = ["getrandom"] } ed25519-dalek = { version = "2", features = ["rand_core"] } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] } +windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_UI_Shell", "Win32_System_Registry", "Win32_UI_WindowsAndMessaging"] } [features] testkit = [] diff --git a/crates/coa-core/src/client.rs b/crates/coa-core/src/client.rs index 855e5ae..820a22e 100644 --- a/crates/coa-core/src/client.rs +++ b/crates/coa-core/src/client.rs @@ -313,14 +313,71 @@ pub fn launch(client: &Path) -> Result { if crate::platform::flavor() == crate::platform::Flavor::Docker { return crate::wine::launch(client, exe); } - let child = Command::new(client.join(exe)).current_dir(client).spawn()?; - Ok(child.id()) + let executable = client.join(exe); + let spawn = || Command::new(&executable).current_dir(client).spawn().map(|child| child.id()); + #[cfg(windows)] + { launch_with_elevation_fallback(spawn, || launch_elevated(&executable, client)).map_err(Into::into) } + #[cfg(not(windows))] + { spawn().map_err(Into::into) } +} + +/// Start normally first; ask Windows for elevation only when the client requires it (ERROR_ELEVATION_REQUIRED, 740). +/// Any other error, including the player declining the prompt (1223), is returned unchanged. +#[cfg(windows)] +fn launch_with_elevation_fallback( + spawn: impl FnOnce() -> std::io::Result, + elevate: impl FnOnce() -> std::io::Result, +) -> std::io::Result { + match spawn() { + Err(error) if error.raw_os_error() == Some(740) => elevate(), + result => result, + } +} + +/// Start only the game with the normal Windows permission prompt; the Manager itself stays unelevated. +#[cfg(windows)] +fn launch_elevated(executable: &Path, directory: &Path) -> std::io::Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::GetProcessId; + use windows_sys::Win32::UI::Shell::{ShellExecuteExW, SHELLEXECUTEINFOW, SEE_MASK_NOCLOSEPROCESS, SEE_MASK_NOASYNC}; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + let file: Vec = executable.as_os_str().encode_wide().chain(Some(0)).collect(); + let cwd: Vec = directory.as_os_str().encode_wide().chain(Some(0)).collect(); + let verb: Vec = "runas".encode_utf16().chain(Some(0)).collect(); + let mut info = SHELLEXECUTEINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC, + lpVerb: verb.as_ptr(), + lpFile: file.as_ptr(), + lpDirectory: cwd.as_ptr(), + nShow: SW_SHOWNORMAL, + ..Default::default() + }; + if unsafe { ShellExecuteExW(&mut info) } == 0 { return Err(std::io::Error::last_os_error()); } + if info.hProcess.is_null() { return Err(std::io::Error::other("Windows did not return the game process handle.")); } + let pid = unsafe { GetProcessId(info.hProcess) }; + let error = std::io::Error::last_os_error(); + unsafe { CloseHandle(info.hProcess); } + if pid == 0 { return Err(error); } + Ok(pid) } #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + #[test] + fn elevation_is_requested_only_when_windows_requires_it() { + assert_eq!(launch_with_elevation_fallback(|| Ok(17), || panic!("unneeded elevation")).unwrap(), 17); + assert_eq!(launch_with_elevation_fallback(|| Err(std::io::Error::from_raw_os_error(740)), || Ok(23)).unwrap(), 23); + let error = launch_with_elevation_fallback(|| Err(std::io::Error::from_raw_os_error(2)), || panic!("wrong error elevated")).unwrap_err(); + assert_eq!(error.raw_os_error(), Some(2)); + let cancelled = launch_with_elevation_fallback(|| Err(std::io::Error::from_raw_os_error(740)), || Err(std::io::Error::from_raw_os_error(1223))).unwrap_err(); + assert_eq!(cancelled.raw_os_error(), Some(1223)); + } + #[test] fn realm_selection_preserves_other_settings_bytes_and_line_endings() { let old = b"\xef\xbb\xbfSET realmName \"Wildcard\"\r\nSET accountName \"\xff\"\r\nset REALMNAME \"old\"\r\nSET realmList \"127.0.0.1\"";