feat(launcher): native Windows support

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.
This commit is contained in:
funman300
2026-08-20 19:21:01 +00:00
parent 8d5bb6202a
commit 057cf92c3b
6 changed files with 158 additions and 23 deletions
+14 -1
View File
@@ -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<Vec<String>> {
Ok(Vec::new())
}
#[cfg(unix)]
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
let server = cfg.openfut_server_host.trim();
if server.is_empty() {
@@ -128,7 +141,7 @@ pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
))
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
+28 -18
View File
@@ -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(())
+72 -2
View File
@@ -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<String>) {
/// 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, String>) -> 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, String>) -> String {
///
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
/// 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};
+18
View File
@@ -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<Ensured, String> {
// 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());
+17 -1
View File
@@ -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<Check> {
vec![
ptrace_scope(),
@@ -96,6 +98,16 @@ pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
]
}
/// 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<Check> {
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::*;
+9 -1
View File
@@ -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);
}