From 057cf92c3b9b1b4eb21130339a4b5881ffb82b98 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 20 Aug 2026 19:21:01 +0000 Subject: [PATCH] feat(launcher): native Windows support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the egui launcher to run natively on Windows (no Wine/Proton). The GUI, launch state machine, config, health/account monitors, and openfut.cfg writing are unchanged and cross-platform; only the effect layer is branched: - game_launch: cfg(windows) launch spawns the game executable directly with its working dir (the version.dll hijack loads from the game dir; no WINEDLLOVERRIDES, Wine prefix, or licence regen). Requires the launcher to run elevated so the child inherits admin. Linux Proton path gated cfg(unix). - arm: cfg(windows) is a no-op (routing is openfut.cfg, written by the client-files step; no ptrace_scope/DNAT/hosts). Linux arming gated cfg(unix). - local_services: on Windows LSX/autopatch are in-process (stp-origin_emu.dll + version.dll hook), so ensure_running reports ready without spawning. Gated the unix-only CommandExt/process_group. - preflight: cfg(windows) run() keeps only backend-reachable + hook-config checks. - config: GameProfile configured()/validate() accept a runner-less Windows profile. theme: fix a latent cross-platform panic — egui 0.29 keeps a Style per theme, so set_style only reached the active one and TextStyle::resolve("Hero") panicked when the other theme rendered. Install the full style into both themes and pin Dark. Cross-built for x86_64-pc-windows-gnu; Linux build + 75 tests unchanged. --- src/arm.rs | 15 ++++++++- src/config.rs | 46 ++++++++++++++++----------- src/game_launch.rs | 74 +++++++++++++++++++++++++++++++++++++++++-- src/local_services.rs | 18 +++++++++++ src/preflight.rs | 18 ++++++++++- src/theme.rs | 10 +++++- 6 files changed, 158 insertions(+), 23 deletions(-) diff --git a/src/arm.rs b/src/arm.rs index 9e0279e..4116fd0 100644 --- a/src/arm.rs +++ b/src/arm.rs @@ -25,6 +25,7 @@ use crate::config::LauncherConfig; /// Accept only hostname/IP characters. These values come from config fields that /// are ever only IPs or hostnames, so a surprising character is a bug — reject it /// rather than try to escape it into an elevated shell command. +#[cfg(unix)] fn safe_host(s: &str) -> anyhow::Result<&str> { let t = s.trim(); if t.is_empty() { @@ -41,6 +42,7 @@ fn safe_host(s: &str) -> anyhow::Result<&str> { /// Build the privileged arming script. Pure and unit-tested; the effectful part /// ([`arm`]) only validates config and hands this to the elevated runner. +#[cfg(unix)] pub(crate) fn arming_script( server: &str, redirector_port: u16, @@ -84,6 +86,7 @@ pub(crate) fn arming_script( /// Human-readable list of what [`arm`] changed, in the order the script applies /// it. Logged by the UI so the user sees exactly what was set — not just that /// "something" ran under `pkexec`. +#[cfg(unix)] pub(crate) fn arming_summary( server: &str, redirector_port: u16, @@ -103,6 +106,16 @@ pub(crate) fn arming_summary( /// Arm the client from config, under one elevated prompt. Requires the same /// fields preflight reads; a missing one is a clear error, never a silent /// loopback fallback. Returns the applied changes for the UI to surface. +/// On native Windows there is nothing to arm: routing is the `openfut.cfg` the +/// client-files step writes into the game directory (read by the version.dll +/// hook), and there is no `ptrace_scope`, DNAT, or `/etc/hosts` to set. Returns +/// no changes so the launch sequence treats client preparation as satisfied. +#[cfg(windows)] +pub fn arm(_cfg: &LauncherConfig) -> anyhow::Result> { + Ok(Vec::new()) +} + +#[cfg(unix)] pub fn arm(cfg: &LauncherConfig) -> anyhow::Result> { let server = cfg.openfut_server_host.trim(); if server.is_empty() { @@ -128,7 +141,7 @@ pub fn arm(cfg: &LauncherConfig) -> anyhow::Result> { )) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; diff --git a/src/config.rs b/src/config.rs index 6be3c54..1bc7b24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,13 +54,18 @@ pub struct GameProfile { impl GameProfile { /// Whether this profile is filled in enough to launch from. pub fn configured(&self) -> bool { - !self.runner.trim().is_empty() - && !self.executable.trim().is_empty() - && !self.game_dir.trim().is_empty() + // Windows starts the executable directly (no runner); unix needs a + // runner such as umu-run. + #[cfg(windows)] + let runner_ok = true; + #[cfg(unix)] + let runner_ok = !self.runner.trim().is_empty(); + runner_ok && !self.executable.trim().is_empty() && !self.game_dir.trim().is_empty() } /// Reject a half-filled profile rather than launching something surprising. pub fn validate(&self) -> Result<(), String> { + #[cfg(unix)] if self.runner.trim().is_empty() { return Err("Game profile has no runner (e.g. umu-run).".into()); } @@ -70,23 +75,28 @@ impl GameProfile { if self.game_dir.trim().is_empty() { return Err("Game profile has no game directory.".into()); } - if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() { - return Err("Game profile defines prefix links but no wine_prefix.".into()); - } - for l in &self.prefix_links { - if l.link.trim().is_empty() || l.target.trim().is_empty() { - return Err("Game profile has a prefix link with an empty link or target.".into()); + // Wine-prefix links and the DRM licence precondition only exist on the + // unix/Proton launch path; native Windows has neither. + #[cfg(unix)] + { + if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() { + return Err("Game profile defines prefix links but no wine_prefix.".into()); } - if std::path::Path::new(&l.link).is_absolute() { - return Err(format!( - "Prefix link {:?} must be relative to the Wine prefix.", - l.link - )); + for l in &self.prefix_links { + if l.link.trim().is_empty() || l.target.trim().is_empty() { + return Err("Game profile has a prefix link with an empty link or target.".into()); + } + if std::path::Path::new(&l.link).is_absolute() { + return Err(format!( + "Prefix link {:?} must be relative to the Wine prefix.", + l.link + )); + } } - } - if let Some(lic) = &self.license { - if lic.path.trim().is_empty() || lic.generator.trim().is_empty() { - return Err("Game profile licence needs both a path and a generator.".into()); + if let Some(lic) = &self.license { + if lic.path.trim().is_empty() || lic.generator.trim().is_empty() { + return Err("Game profile licence needs both a path and a generator.".into()); + } } } Ok(()) diff --git a/src/game_launch.rs b/src/game_launch.rs index a5a4a1f..c35c50f 100644 --- a/src/game_launch.rs +++ b/src/game_launch.rs @@ -24,11 +24,13 @@ //! falls back to it, so an existing working setup cannot be broken by upgrading. use parking_lot::Mutex; +#[cfg(unix)] use std::collections::BTreeMap; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Arc; +#[cfg(unix)] use std::time::{Duration, Instant}; use crate::config::GameProfile; @@ -45,6 +47,7 @@ fn say(log: &Log, msg: impl Into) { /// Returns once the game process has been spawned; its output continues to /// stream into `log` on background threads. `on_exit` fires when the process /// ends, which is how the launch state machine leaves its Running state. +#[cfg(unix)] pub fn launch( profile: &GameProfile, log: &Log, @@ -96,6 +99,62 @@ pub fn launch( Ok(()) } +/// Windows-native launch: no Wine prefix, no `WINEDLLOVERRIDES` (the game loads +/// the `version.dll` hook from its own directory through the normal search +/// order), and no licence regeneration (the native loader handles DRM). +/// Routing is the `openfut.cfg` that the client-files step already wrote into +/// the game directory. +/// +/// The launcher must itself be running elevated (its shortcut carries the +/// RunAsAdmin bit): the loader requires administrator rights, and a child +/// started with `CreateProcess` inherits the launcher's token instead of +/// raising its own UAC prompt. +#[cfg(windows)] +pub fn launch( + profile: &GameProfile, + log: &Log, + on_exit: impl FnOnce() + Send + 'static, +) -> anyhow::Result<()> { + profile.validate().map_err(anyhow::Error::msg)?; + + let game_dir = PathBuf::from(&profile.game_dir); + if !game_dir.is_dir() { + anyhow::bail!("game_dir does not exist: {}", game_dir.display()); + } + let exe = game_dir.join(&profile.executable); + if !exe.is_file() { + anyhow::bail!("game executable not found: {}", exe.display()); + } + + let mut cmd = Command::new(&exe); + cmd.current_dir(&game_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (k, v) in &profile.env { + cmd.env(k, v); + } + + say( + log, + format!( + "[launcher] launching {} (cwd {})", + exe.display(), + game_dir.display() + ), + ); + + let child = cmd + .spawn() + .map_err(|e| anyhow::anyhow!("could not start {}: {e}", exe.display()))?; + stream( + child, + log.clone(), + "[launcher] game process exited.", + on_exit, + ); + Ok(()) +} + /// The registry key Wine reads DLL overrides from, and the one value the hook needs. /// /// Wine loads its own builtin `version.dll` unless an override says otherwise, so the @@ -110,13 +169,17 @@ pub fn launch( /// survives restarts and applies to every launch path, including Steam. This mirrors /// what BepInEx documents for Proton (configure the proxy in winecfg rather than the /// environment) and what Proton itself already does in this prefix for other titles. +#[cfg(unix)] const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides"; +#[cfg(unix)] const HOOK_DLL_VALUE: &str = "version"; +#[cfg(unix)] const HOOK_DLL_OVERRIDE: &str = "native,builtin"; /// `reg add` argv that persists the hook's DLL override, native-first with a builtin /// fallback. `/f` makes it idempotent, so this is safe to run on every launch and /// repairs a prefix a player has reset or replaced. +#[cfg(unix)] fn dll_override_args() -> [&'static str; 10] { [ "reg", @@ -138,6 +201,7 @@ fn dll_override_args() -> [&'static str; 10] { /// Best-effort by design: a failure here is not fatal, because a launch we spawn also /// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine /// error, since the player cannot act on the latter. +#[cfg(unix)] fn ensure_dll_override(profile: &GameProfile, log: &Log) { if profile.wine_prefix.trim().is_empty() { return; @@ -163,7 +227,7 @@ fn ensure_dll_override(profile: &GameProfile, log: &Log) { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod override_tests { use super::*; @@ -204,6 +268,7 @@ mod override_tests { /// /// A profile that already pins `version=` wins: an operator overriding the hijack /// deliberately must not be silently overruled. +#[cfg(unix)] fn hook_dll_overrides(env: &BTreeMap) -> String { const HOOK: &str = "version=n,b"; match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) { @@ -217,6 +282,7 @@ fn hook_dll_overrides(env: &BTreeMap) -> String { /// /// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn `: /// an existing link is replaced, so re-running is harmless. +#[cfg(unix)] fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() { return Ok(()); @@ -257,6 +323,7 @@ fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { /// A crashed or failed launch deletes the licence, so this runs before every /// launch rather than only on first setup — that is the behaviour the shell /// script proved, and it is why a crash is normally self-healing on the next try. +#[cfg(unix)] fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { let Some(lic) = &profile.license else { return Ok(()); @@ -316,6 +383,7 @@ fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { /// and it is reproduced deliberately — the pattern is a Windows executable name, /// which cannot match the launcher or a shell running it. (A `pkill -f` pattern /// that *can* match its own caller is a real hazard; this one cannot.) +#[cfg(unix)] fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) { let _ = child.kill(); let _ = child.wait(); @@ -333,6 +401,7 @@ fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Lo /// A relative licence path is taken as relative to the Wine prefix; an absolute /// one is used as given. +#[cfg(unix)] fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf { let p = Path::new(path); if p.is_absolute() || prefix.trim().is_empty() { @@ -345,6 +414,7 @@ fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf { /// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is /// as useless as a missing one, and treating it as valid would skip the /// regeneration that fixes it. +#[cfg(unix)] fn non_empty_file(path: &Path) -> bool { std::fs::metadata(path) .map(|m| m.len() > 0) @@ -381,7 +451,7 @@ pub fn stream( }); } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use crate::config::{LicenseCheck, PrefixLink}; diff --git a/src/local_services.rs b/src/local_services.rs index 1aff885..4094628 100644 --- a/src/local_services.rs +++ b/src/local_services.rs @@ -22,6 +22,7 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(unix)] use std::os::unix::process::CommandExt; use crate::fifa17_capability::{ @@ -433,6 +434,21 @@ impl ServiceSupervisor { /// Start `service` only if it is not already usable. Never restarts a healthy /// service, and never adopts a foreign one as ours. pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result { + // On native Windows the "companion services" are NOT separate processes: + // LSX/Origin login and the ProtoSSL cert path are provided in-process by + // stp-origin_emu.dll and the version.dll hook once the game runs. There is + // nothing for the launcher to spawn or supervise, so report ready. + #[cfg(windows)] + { + let _ = spec; + self.log.lock().push(format!( + "[launcher] {} is in-process on Windows (stp/hook) — nothing to start.", + service.label() + )); + return Ok(Ensured::Reused); + } + #[cfg(unix)] + { let runtime = self.observe(service); if runtime.ready() { self.log.lock().push(format!( @@ -460,6 +476,7 @@ impl ServiceSupervisor { .map_err(|e| e.to_string())?; *self.slot(service) = ManagedService::from_child(child); Ok(Ensured::Started) + } } /// Stop a service the launcher owns. A foreign process is reported, never @@ -532,6 +549,7 @@ pub fn spawn( cmd.env("OPENFUT_AUTOPATCH_LOG", log_path); } // Put each companion in its own process group for lifecycle isolation. + #[cfg(unix)] cmd.process_group(0); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); diff --git a/src/preflight.rs b/src/preflight.rs index 205dc78..cd80d39 100644 --- a/src/preflight.rs +++ b/src/preflight.rs @@ -31,6 +31,7 @@ use std::time::Duration; use crate::config::LauncherConfig; const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +#[cfg(unix)] const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -86,6 +87,7 @@ impl Check { } /// Run every applicable check. Order is the order the game exercises them. +#[cfg(unix)] pub fn run(cfg: &LauncherConfig) -> Vec { vec![ ptrace_scope(), @@ -96,6 +98,16 @@ pub fn run(cfg: &LauncherConfig) -> Vec { ] } +/// On native Windows the client-preparation checks (ptrace_scope, the EA +/// redirector DNAT, `/etc/hosts`) do not apply: there is no host to arm and +/// routing is entirely the `openfut.cfg` the hook reads. Only the two the game +/// truly depends on remain: the backend is reachable and the deployed hook +/// config agrees with the launcher's settings. +#[cfg(windows)] +pub fn run(cfg: &LauncherConfig) -> Vec { + vec![backend_reachable(cfg), hook_config(cfg)] +} + /// Checks that will stop the game working. pub fn failures(checks: &[Check]) -> usize { checks.iter().filter(|c| c.state == State::Fail).count() @@ -113,6 +125,7 @@ pub fn warnings(checks: &[Check]) -> usize { /// Unconditional. autopatch is a workspace binary that ships alongside the /// launcher, so there is no configuration that could make this inapplicable — /// every launch runs it. +#[cfg(unix)] fn ptrace_scope() -> Check { const NAME: &str = "ptrace_scope (autopatch)"; match std::fs::read_to_string(PTRACE_SCOPE) { @@ -127,6 +140,7 @@ fn ptrace_scope() -> Check { /// Reading `/proc` in a test would assert facts about the machine running the /// suite rather than about this code — and left inline, "any value is fine" /// was a mutation no test could catch. +#[cfg(unix)] fn ptrace_verdict(raw: &str) -> Check { const NAME: &str = "ptrace_scope (autopatch)"; let v = raw.trim(); @@ -146,6 +160,7 @@ fn ptrace_verdict(raw: &str) -> Check { /// /// This tests the *effect* rather than reading firewall rules, so it needs no /// privilege and stays honest about what the game will actually experience. +#[cfg(unix)] fn ea_redirect(cfg: &LauncherConfig) -> Check { const NAME: &str = "EA redirector IP is redirected"; let ip = cfg.ea_redirect_probe_ip.trim(); @@ -184,6 +199,7 @@ fn ea_redirect(cfg: &LauncherConfig) -> Check { /// So this is a real misconfiguration worth fixing and not a reason to expect /// failure. Reporting it as fatal, and then being contradicted by a working /// game, is how a checklist trains its user to ignore it. +#[cfg(unix)] fn hostname_mapping(cfg: &LauncherConfig) -> Check { const NAME: &str = "EA hostnames point at OpenFUT"; if cfg.ea_hostnames.is_empty() { @@ -320,7 +336,7 @@ fn join(ips: &[IpAddr]) -> String { .join(",") } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; diff --git a/src/theme.rs b/src/theme.rs index 0cc7fc2..302f12f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -299,5 +299,13 @@ fn install_style(ctx: &Context) { v.widgets.open.rounding = radius; style.visuals = v; - ctx.set_style(style); + // egui 0.29 keeps a separate `Style` per theme (dark/light) and renders with + // whichever the theme preference resolves to. `set_style` touches only the + // currently-active theme, so a later switch to the other one would drop our + // named text styles ("Hero", "Subheading", …) and panic in `TextStyle::resolve`. + // Install the full style into BOTH themes and pin the preference to Dark so + // the branded look is stable regardless of the host's system theme. + ctx.set_style_of(egui::Theme::Dark, style.clone()); + ctx.set_style_of(egui::Theme::Light, style); + ctx.set_theme(egui::ThemePreference::Dark); }