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\"";