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:
+14
-1
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user