diff --git a/CLAUDE.md b/CLAUDE.md index 31acc39b..32ad8f9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -433,6 +433,67 @@ The `qrc://` handler did `arg.split("login_successful.html?").collect:: v0.2.1 upgrade regression + ; where view-64 leaked into HKCR writes and left 32-bit consumers + ; (Titanfall2.exe, Origin emitting link2ea://) looking at stale or + ; missing handlers. + SetRegView default !insertmacro BackupProtocol "qrc" !insertmacro BackupProtocol "link2ea" !insertmacro BackupProtocol "origin2" ; Origin compatibility: Point EA games to maxima-bootstrap.exe + ; (back under view 64 - same store as the BackupValue above). + SetRegView 64 WriteRegStr HKLM "SOFTWARE\WOW6432Node\Origin" "ClientPath" "$INSTDIR\maxima-bootstrap.exe" ; EA Desktop flag WriteRegStr HKLM "SOFTWARE\Electronic Arts\EA Desktop" "InstallSuccessful" "true" ; ---- Protocol Handlers ---- + ; Reset view for HKCR writes - see comment above the BackupProtocol calls. + SetRegView default ; qrc:// protocol (EA login redirection) WriteRegStr HKCR "qrc" "" "URL:Maxima Protocol" @@ -272,11 +334,14 @@ Section "Uninstall" ; (typically EA Desktop / Origin Launcher). If they didn't exist pre-install ; the macro just deletes the key, which leaves the system in a clean state ; ready for EA Launcher to register itself on its next run. + ; + ; RestoreProtocol forces SetRegView default internally so HKCR + ; operations target the same store the installer's BackupProtocol used. !insertmacro RestoreProtocol "qrc" !insertmacro RestoreProtocol "link2ea" !insertmacro RestoreProtocol "origin2" - ; Restore Origin compatibility values + ; Restore Origin compatibility values (view 64 - matches install-time) SetRegView 64 !insertmacro RestoreValue HKLM "SOFTWARE\WOW6432Node\Origin" "ClientPath" "Origin_ClientPath" !insertmacro RestoreValue HKLM "SOFTWARE\Electronic Arts\EA Desktop" "InstallSuccessful" "EADesktop_InstallSuccessful" diff --git a/maxima-bootstrap/src/main.rs b/maxima-bootstrap/src/main.rs index 9b161d0b..f9b33628 100644 --- a/maxima-bootstrap/src/main.rs +++ b/maxima-bootstrap/src/main.rs @@ -268,7 +268,20 @@ async fn run(args: &[String]) -> Result { } child.args(["launch", offer_id]); - child.spawn()?.wait().await?; + let status = child.spawn()?.wait().await?; + + // Propagate non-zero exits as errors so handle_launch_args logs + // them to maxima_execution.log and maxima_bootstrap_error.log via + // the existing centralized error-reporting path. Previously we + // logged manually and still returned Ok(true), which made failures + // look like successes in the log. + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("maxima-cli (link2ea) exited non-zero: code={:?}", status.code()), + ) + .into()); + } return Ok(true); } @@ -313,7 +326,15 @@ async fn run(args: &[String]) -> Result { } child.args(["launch", offer_id]); - child.spawn()?.wait().await?; + let status = child.spawn()?.wait().await?; + + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("maxima-cli (origin2) exited non-zero: code={:?}", status.code()), + ) + .into()); + } return Ok(true); } diff --git a/maxima-cli/Cargo.toml b/maxima-cli/Cargo.toml index 7bbedf68..5c31e218 100644 --- a/maxima-cli/Cargo.toml +++ b/maxima-cli/Cargo.toml @@ -19,7 +19,7 @@ inquire = "0.6.2" futures = "0.3.30" [target.'cfg(windows)'.dependencies] -winapi = { version = "0.3.9", features = [ "memoryapi", "handleapi", "synchapi", "wincon", "consoleapi" ] } +winapi = { version = "0.3.9", features = [ "memoryapi", "handleapi", "synchapi", "wincon", "consoleapi", "processenv", "fileapi", "winbase", "winnt" ] } winreg = "0.50.0" is_elevated = "0.1.2" diff --git a/maxima-cli/src/main.rs b/maxima-cli/src/main.rs index 4bdec1ff..223a026a 100644 --- a/maxima-cli/src/main.rs +++ b/maxima-cli/src/main.rs @@ -126,37 +126,169 @@ struct Args { login: Option, } -/// Ensure a console window exists so log output is visible. +/// Ensure a console window exists AND that Rust's stdio is wired up to it. /// -/// When `maxima-cli` is spawned by `maxima-bootstrap` (which is built as a -/// Windows GUI app via `#![windows_subsystem = "windows"]`), the bootstrap -/// has no console to inherit and the subprocess inherits the same: log -/// output goes nowhere. We call `AllocConsole()` ourselves so the user can -/// actually watch the launch progress. +/// When `maxima-cli` is spawned by `maxima-bootstrap` (built as a Windows GUI +/// app via `#![windows_subsystem = "windows"]`), the child process inherits +/// the parent's stdio — which is null/invalid because bootstrap has no +/// console. Two things break: /// -/// Idempotent: if a console is already attached (e.g. `cmd.exe` invocation), -/// `AllocConsole` fails harmlessly and we keep using the existing one. +/// 1. No console window appears at all (until we call `AllocConsole`). +/// 2. Even after `AllocConsole`, Rust's `println!` / `eprintln!` still write +/// to the invalid handles they inherited. `AllocConsole` does NOT +/// automatically redirect existing std handles — it only creates the +/// console window. We have to point `STD_OUTPUT_HANDLE` / `STD_ERROR_HANDLE` +/// / `STD_INPUT_HANDLE` at `CONOUT$` / `CONIN$` ourselves. +/// +/// Without step 2 the v0.2.1 fix is decorative: the console window pops up +/// but stays blank because the logger writes go nowhere. +/// +/// Idempotent: if a console is already attached (`cmd.exe` invocation), +/// `AllocConsole` fails harmlessly and we still rewire the std handles to +/// `CONOUT$` (which resolves to the existing console). #[cfg(windows)] fn ensure_console_attached() { + use std::ptr::null_mut; use winapi::um::consoleapi::AllocConsole; + use winapi::um::fileapi::{CreateFileA, OPEN_EXISTING}; + use winapi::um::handleapi::INVALID_HANDLE_VALUE; + use winapi::um::processenv::SetStdHandle; use winapi::um::wincon::GetConsoleWindow; + use winapi::um::winbase::{STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE}; + use winapi::um::winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE}; + unsafe { if GetConsoleWindow().is_null() { - // Return value ignored on purpose — failure means "couldn't create", - // which we can't do anything useful about. File logging still works. + // Failure here means we already had a console (rare given the null + // check) or the OS refused to give us one; either way, file + // logging still works as a fallback. AllocConsole(); } + + // Rewire std handles to the (possibly freshly allocated) console. + // Each `CreateFileA` opens an independent handle; passing the same + // handle to multiple `SetStdHandle` calls is technically allowed but + // fragile (closing one closes them all). + let open_console = |name: &[u8]| -> *mut winapi::ctypes::c_void { + CreateFileA( + name.as_ptr() as *const i8, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + null_mut(), + OPEN_EXISTING, + 0, + null_mut(), + ) + }; + + let stdout = open_console(b"CONOUT$\0"); + if stdout != INVALID_HANDLE_VALUE { + SetStdHandle(STD_OUTPUT_HANDLE, stdout); + } + + let stderr = open_console(b"CONOUT$\0"); + if stderr != INVALID_HANDLE_VALUE { + SetStdHandle(STD_ERROR_HANDLE, stderr); + } + + let stdin = open_console(b"CONIN$\0"); + if stdin != INVALID_HANDLE_VALUE { + SetStdHandle(STD_INPUT_HANDLE, stdin); + } } } #[cfg(not(windows))] fn ensure_console_attached() {} -#[tokio::main] -async fn main() { +/// Install a panic hook that writes the panic message to a dedicated file +/// before unwinding. Without this, panics that happen *before* the regular +/// logger is initialized (or that hit `eprintln!` when stderr is unattached) +/// disappear silently — exactly the failure mode that made the v0.2.1 +/// "nothing shows" bug so hard to diagnose. +/// +/// File location matches the rest of the file logging: +/// - Windows: %LOCALAPPDATA%\Maxima\Logs\maxima-cli.panic.log +/// - Unix: $XDG_DATA_HOME/maxima/logs/maxima-cli.panic.log (or ~/.local/share/...) +fn install_panic_hook() { + let log_path: Option = { + #[cfg(windows)] + { + std::env::var_os("LOCALAPPDATA") + .or_else(|| std::env::var_os("APPDATA")) + .map(std::path::PathBuf::from) + .map(|p| p.join("Maxima").join("Logs").join("maxima-cli.panic.log")) + } + #[cfg(unix)] + { + std::env::var_os("XDG_DATA_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".local").join("share")) + }) + .map(|p| p.join("maxima").join("logs").join("maxima-cli.panic.log")) + } + }; + + std::panic::set_hook(Box::new(move |info| { + // Best-effort: never let the panic hook itself panic. + if let Some(ref path) = log_path { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + use std::io::Write; + let _ = writeln!( + file, + "\n===== PANIC at {:?} (pid={}) =====\n{}", + std::time::SystemTime::now(), + std::process::id(), + info + ); + let _ = file.flush(); + } + } + // Also try stderr (works once stdio is reattached to the console). + eprintln!("FATAL: {}", info); + })); +} + +/// Plain (non-tokio) `main`. The order is load-bearing: +/// +/// 1. Panic hook BEFORE anything fallible so a panic in any subsequent step +/// is captured on disk. +/// 2. Console + stdio reattach BEFORE any println / clap output so error +/// messages reach the user. +/// 3. Logger init BEFORE `Args::parse()` so clap's exit-on-error path can +/// hit the file sink. +/// 4. Argument parsing. +/// 5. Tokio runtime constructed manually so a panic in runtime setup (e.g. +/// IOCP init under Wine) is caught by the panic hook above — `#[tokio::main]` +/// would construct the runtime *before* user code, defeating step 1. +fn main() { + install_panic_hook(); ensure_console_attached(); + init_logger_named("maxima-cli"); - let result = startup().await; + let args = Args::parse(); + + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + error!("Failed to build tokio runtime: {}", e); + std::process::exit(1); + } + }; + + let result = runtime.block_on(startup(args)); if let Some(e) = result.err() { match std::env::var("RUST_BACKTRACE") { @@ -242,10 +374,10 @@ pub async fn login_flow(login_override: Option) -> Result Ok(token_res?) } -async fn startup() -> Result<()> { - let args = Args::parse(); - - init_logger_named("maxima-cli"); +async fn startup(args: Args) -> Result<()> { + // Args parsing and logger initialization happen in `main()` so a clap + // exit hits the file sink and the panic hook is already installed by + // the time the runtime is built. info!("Starting Maxima..."); diff --git a/maxima-lib/src/lsx/connection.rs b/maxima-lib/src/lsx/connection.rs index ec7b6839..8b208bd6 100644 --- a/maxima-lib/src/lsx/connection.rs +++ b/maxima-lib/src/lsx/connection.rs @@ -209,48 +209,61 @@ impl Connection { stream.set_nonblocking(true)?; stream.set_read_timeout(Some(Duration::from_secs(1)))?; + let mut pid: Result = Ok(0); + let maxima: MutexGuard<'_, Maxima> = maxima_arc.lock().await; - let context: &ActiveGameContext = match maxima.playing() { - Some(context) => context, + match maxima.playing() { None => { - stream.shutdown(std::net::Shutdown::Both)?; - return Err(LSXConnectionError::GameContext); + // Game was launched externally (e.g. Steam Northstar mode via + // `steam.exe -applaunch 1237970 -northstar`) rather than + // through `maxima-cli launch`. Accept the connection anyway — + // LSX only needs the TCP socket; the PID/Kyber path is skipped + // because there is no ActiveGameContext to interrogate. + // + // Without this, TF2 + Northstar launched via Steam would have + // its LSX connection rejected immediately, preventing online + // play even when Maxima is running in the background. + // + // Ported from catornot/Maxima@patch-external-lsx, which itself + // originated as upstream PR #42 (p0358). + warn!("External LSX connection (the game was not started through Maxima)"); } - }; + Some(context) => { + // The PID system is mainly for Kyber injection + pid = get_os_pid(context); + if cfg!(unix) { + if let Ok(os_pid) = pid { + let sys = System::new_all(); + if let Some(process) = sys.process(Pid::from_u32(os_pid)) { + let filename = PathBuf::from( + process.cmd()[0] + .to_owned() + .replace("Z:", "") + .replace('\\', "/"), + ) + .file_name() + .ok_or(NativeError::FileName)? + .to_str() + .ok_or(NativeError::Stringify)? + .to_owned(); + + pid = get_wine_pid(&context.launch_id(), &filename).await; + } else { + warn!( + "Failed to find game process while looking for PID {}", + os_pid + ); + } + } + } - // The PID system is mainly for Kyber injection - let mut pid = get_os_pid(context); - if cfg!(unix) { - if let Ok(os_pid) = pid { - let sys = System::new_all(); - if let Some(process) = sys.process(Pid::from_u32(os_pid)) { - let filename = PathBuf::from( - process.cmd()[0] - .to_owned() - .replace("Z:", "") - .replace('\\', "/"), - ) - .file_name() - .ok_or(NativeError::FileName)? - .to_str() - .ok_or(NativeError::Stringify)? - .to_owned(); - - pid = get_wine_pid(&context.launch_id(), &filename).await; - } else { - warn!( - "Failed to find game process while looking for PID {}", - os_pid - ); + match &pid { + Err(err) => warn!("Error while finding game PID: {}", err), + Ok(0) => warn!("Failed to find PID through launch ID, things may not work!"), + _ => {} } } - } - - if let Err(ref err) = pid { - warn!("Error while finding game PID: {}", err); - } else if pid.as_ref().unwrap() == &0 { - warn!("Failed to find PID through launch ID, things may not work!"); - } + }; let state = Arc::new(RwLock::new(ConnectionState { maxima: maxima_arc.clone(), diff --git a/maxima-lib/src/lsx/request/profile.rs b/maxima-lib/src/lsx/request/profile.rs index e8759cd6..d55a9139 100644 --- a/maxima-lib/src/lsx/request/profile.rs +++ b/maxima-lib/src/lsx/request/profile.rs @@ -90,7 +90,13 @@ pub async fn handle_set_presence_request( let arc = state.write().await.maxima_arc(); let mut maxima = arc.lock().await; - let playing = maxima.playing().as_ref().unwrap(); + // When LSX connects from an externally-launched game (e.g. Steam Northstar + // mode), maxima.playing() is None because the game wasn't started through + // maxima-cli. Return a harmless success response so the connection stays + // alive rather than panicking. (Upstream PR #42 / catornot patch-external-lsx) + let Some(playing) = maxima.playing().as_ref() else { + return make_lsx_handler_response!(Response, ErrorSuccess, { attr_Code: 0, attr_Description: String::new() }); + }; if playing.mode().is_online_offline() { return make_lsx_handler_response!(Response, ErrorSuccess, { attr_Code: 0, attr_Description: String::new() }); }