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
|
/// 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
|
/// 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.
|
/// rather than try to escape it into an elevated shell command.
|
||||||
|
#[cfg(unix)]
|
||||||
fn safe_host(s: &str) -> anyhow::Result<&str> {
|
fn safe_host(s: &str) -> anyhow::Result<&str> {
|
||||||
let t = s.trim();
|
let t = s.trim();
|
||||||
if t.is_empty() {
|
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
|
/// Build the privileged arming script. Pure and unit-tested; the effectful part
|
||||||
/// ([`arm`]) only validates config and hands this to the elevated runner.
|
/// ([`arm`]) only validates config and hands this to the elevated runner.
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) fn arming_script(
|
pub(crate) fn arming_script(
|
||||||
server: &str,
|
server: &str,
|
||||||
redirector_port: u16,
|
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
|
/// 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
|
/// it. Logged by the UI so the user sees exactly what was set — not just that
|
||||||
/// "something" ran under `pkexec`.
|
/// "something" ran under `pkexec`.
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) fn arming_summary(
|
pub(crate) fn arming_summary(
|
||||||
server: &str,
|
server: &str,
|
||||||
redirector_port: u16,
|
redirector_port: u16,
|
||||||
@@ -103,6 +106,16 @@ pub(crate) fn arming_summary(
|
|||||||
/// Arm the client from config, under one elevated prompt. Requires the same
|
/// 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
|
/// fields preflight reads; a missing one is a clear error, never a silent
|
||||||
/// loopback fallback. Returns the applied changes for the UI to surface.
|
/// 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>> {
|
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
|
||||||
let server = cfg.openfut_server_host.trim();
|
let server = cfg.openfut_server_host.trim();
|
||||||
if server.is_empty() {
|
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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|||||||
+28
-18
@@ -54,13 +54,18 @@ pub struct GameProfile {
|
|||||||
impl GameProfile {
|
impl GameProfile {
|
||||||
/// Whether this profile is filled in enough to launch from.
|
/// Whether this profile is filled in enough to launch from.
|
||||||
pub fn configured(&self) -> bool {
|
pub fn configured(&self) -> bool {
|
||||||
!self.runner.trim().is_empty()
|
// Windows starts the executable directly (no runner); unix needs a
|
||||||
&& !self.executable.trim().is_empty()
|
// runner such as umu-run.
|
||||||
&& !self.game_dir.trim().is_empty()
|
#[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.
|
/// Reject a half-filled profile rather than launching something surprising.
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
#[cfg(unix)]
|
||||||
if self.runner.trim().is_empty() {
|
if self.runner.trim().is_empty() {
|
||||||
return Err("Game profile has no runner (e.g. umu-run).".into());
|
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() {
|
if self.game_dir.trim().is_empty() {
|
||||||
return Err("Game profile has no game directory.".into());
|
return Err("Game profile has no game directory.".into());
|
||||||
}
|
}
|
||||||
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
|
// Wine-prefix links and the DRM licence precondition only exist on the
|
||||||
return Err("Game profile defines prefix links but no wine_prefix.".into());
|
// unix/Proton launch path; native Windows has neither.
|
||||||
}
|
#[cfg(unix)]
|
||||||
for l in &self.prefix_links {
|
{
|
||||||
if l.link.trim().is_empty() || l.target.trim().is_empty() {
|
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
|
||||||
return Err("Game profile has a prefix link with an empty link or target.".into());
|
return Err("Game profile defines prefix links but no wine_prefix.".into());
|
||||||
}
|
}
|
||||||
if std::path::Path::new(&l.link).is_absolute() {
|
for l in &self.prefix_links {
|
||||||
return Err(format!(
|
if l.link.trim().is_empty() || l.target.trim().is_empty() {
|
||||||
"Prefix link {:?} must be relative to the Wine prefix.",
|
return Err("Game profile has a prefix link with an empty link or target.".into());
|
||||||
l.link
|
}
|
||||||
));
|
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 let Some(lic) = &self.license {
|
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
|
||||||
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
|
return Err("Game profile licence needs both a path and a generator.".into());
|
||||||
return Err("Game profile licence needs both a path and a generator.".into());
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+72
-2
@@ -24,11 +24,13 @@
|
|||||||
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::config::GameProfile;
|
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
|
/// Returns once the game process has been spawned; its output continues to
|
||||||
/// stream into `log` on background threads. `on_exit` fires when the process
|
/// stream into `log` on background threads. `on_exit` fires when the process
|
||||||
/// ends, which is how the launch state machine leaves its Running state.
|
/// ends, which is how the launch state machine leaves its Running state.
|
||||||
|
#[cfg(unix)]
|
||||||
pub fn launch(
|
pub fn launch(
|
||||||
profile: &GameProfile,
|
profile: &GameProfile,
|
||||||
log: &Log,
|
log: &Log,
|
||||||
@@ -96,6 +99,62 @@ pub fn launch(
|
|||||||
Ok(())
|
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.
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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";
|
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
|
||||||
|
#[cfg(unix)]
|
||||||
const HOOK_DLL_VALUE: &str = "version";
|
const HOOK_DLL_VALUE: &str = "version";
|
||||||
|
#[cfg(unix)]
|
||||||
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
|
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
|
||||||
|
|
||||||
/// `reg add` argv that persists the hook's DLL override, native-first with a 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
|
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
|
||||||
/// repairs a prefix a player has reset or replaced.
|
/// repairs a prefix a player has reset or replaced.
|
||||||
|
#[cfg(unix)]
|
||||||
fn dll_override_args() -> [&'static str; 10] {
|
fn dll_override_args() -> [&'static str; 10] {
|
||||||
[
|
[
|
||||||
"reg",
|
"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
|
/// 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
|
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
|
||||||
/// error, since the player cannot act on the latter.
|
/// error, since the player cannot act on the latter.
|
||||||
|
#[cfg(unix)]
|
||||||
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
||||||
if profile.wine_prefix.trim().is_empty() {
|
if profile.wine_prefix.trim().is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -163,7 +227,7 @@ fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
mod override_tests {
|
mod override_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -204,6 +268,7 @@ mod override_tests {
|
|||||||
///
|
///
|
||||||
/// A profile that already pins `version=` wins: an operator overriding the hijack
|
/// A profile that already pins `version=` wins: an operator overriding the hijack
|
||||||
/// deliberately must not be silently overruled.
|
/// deliberately must not be silently overruled.
|
||||||
|
#[cfg(unix)]
|
||||||
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
|
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
|
||||||
const HOOK: &str = "version=n,b";
|
const HOOK: &str = "version=n,b";
|
||||||
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
|
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>`:
|
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
|
||||||
/// an existing link is replaced, so re-running is harmless.
|
/// an existing link is replaced, so re-running is harmless.
|
||||||
|
#[cfg(unix)]
|
||||||
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||||
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
|
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
|
||||||
return Ok(());
|
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
|
/// 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
|
/// 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.
|
/// 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<()> {
|
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||||
let Some(lic) = &profile.license else {
|
let Some(lic) = &profile.license else {
|
||||||
return Ok(());
|
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,
|
/// 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
|
/// 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.)
|
/// 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) {
|
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
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
|
/// A relative licence path is taken as relative to the Wine prefix; an absolute
|
||||||
/// one is used as given.
|
/// one is used as given.
|
||||||
|
#[cfg(unix)]
|
||||||
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
|
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
if p.is_absolute() || prefix.trim().is_empty() {
|
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
|
/// 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
|
/// as useless as a missing one, and treating it as valid would skip the
|
||||||
/// regeneration that fixes it.
|
/// regeneration that fixes it.
|
||||||
|
#[cfg(unix)]
|
||||||
fn non_empty_file(path: &Path) -> bool {
|
fn non_empty_file(path: &Path) -> bool {
|
||||||
std::fs::metadata(path)
|
std::fs::metadata(path)
|
||||||
.map(|m| m.len() > 0)
|
.map(|m| m.len() > 0)
|
||||||
@@ -381,7 +451,7 @@ pub fn stream(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{LicenseCheck, PrefixLink};
|
use crate::config::{LicenseCheck, PrefixLink};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
use crate::fifa17_capability::{
|
use crate::fifa17_capability::{
|
||||||
@@ -433,6 +434,21 @@ impl ServiceSupervisor {
|
|||||||
/// Start `service` only if it is not already usable. Never restarts a healthy
|
/// Start `service` only if it is not already usable. Never restarts a healthy
|
||||||
/// service, and never adopts a foreign one as ours.
|
/// service, and never adopts a foreign one as ours.
|
||||||
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
|
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);
|
let runtime = self.observe(service);
|
||||||
if runtime.ready() {
|
if runtime.ready() {
|
||||||
self.log.lock().push(format!(
|
self.log.lock().push(format!(
|
||||||
@@ -460,6 +476,7 @@ impl ServiceSupervisor {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
*self.slot(service) = ManagedService::from_child(child);
|
*self.slot(service) = ManagedService::from_child(child);
|
||||||
Ok(Ensured::Started)
|
Ok(Ensured::Started)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop a service the launcher owns. A foreign process is reported, never
|
/// 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);
|
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
|
||||||
}
|
}
|
||||||
// Put each companion in its own process group for lifecycle isolation.
|
// Put each companion in its own process group for lifecycle isolation.
|
||||||
|
#[cfg(unix)]
|
||||||
cmd.process_group(0);
|
cmd.process_group(0);
|
||||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -31,6 +31,7 @@ use std::time::Duration;
|
|||||||
use crate::config::LauncherConfig;
|
use crate::config::LauncherConfig;
|
||||||
|
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
#[cfg(unix)]
|
||||||
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
|
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -86,6 +87,7 @@ impl Check {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run every applicable check. Order is the order the game exercises them.
|
/// Run every applicable check. Order is the order the game exercises them.
|
||||||
|
#[cfg(unix)]
|
||||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||||
vec![
|
vec![
|
||||||
ptrace_scope(),
|
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.
|
/// Checks that will stop the game working.
|
||||||
pub fn failures(checks: &[Check]) -> usize {
|
pub fn failures(checks: &[Check]) -> usize {
|
||||||
checks.iter().filter(|c| c.state == State::Fail).count()
|
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
|
/// Unconditional. autopatch is a workspace binary that ships alongside the
|
||||||
/// launcher, so there is no configuration that could make this inapplicable —
|
/// launcher, so there is no configuration that could make this inapplicable —
|
||||||
/// every launch runs it.
|
/// every launch runs it.
|
||||||
|
#[cfg(unix)]
|
||||||
fn ptrace_scope() -> Check {
|
fn ptrace_scope() -> Check {
|
||||||
const NAME: &str = "ptrace_scope (autopatch)";
|
const NAME: &str = "ptrace_scope (autopatch)";
|
||||||
match std::fs::read_to_string(PTRACE_SCOPE) {
|
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
|
/// 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"
|
/// suite rather than about this code — and left inline, "any value is fine"
|
||||||
/// was a mutation no test could catch.
|
/// was a mutation no test could catch.
|
||||||
|
#[cfg(unix)]
|
||||||
fn ptrace_verdict(raw: &str) -> Check {
|
fn ptrace_verdict(raw: &str) -> Check {
|
||||||
const NAME: &str = "ptrace_scope (autopatch)";
|
const NAME: &str = "ptrace_scope (autopatch)";
|
||||||
let v = raw.trim();
|
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
|
/// This tests the *effect* rather than reading firewall rules, so it needs no
|
||||||
/// privilege and stays honest about what the game will actually experience.
|
/// privilege and stays honest about what the game will actually experience.
|
||||||
|
#[cfg(unix)]
|
||||||
fn ea_redirect(cfg: &LauncherConfig) -> Check {
|
fn ea_redirect(cfg: &LauncherConfig) -> Check {
|
||||||
const NAME: &str = "EA redirector IP is redirected";
|
const NAME: &str = "EA redirector IP is redirected";
|
||||||
let ip = cfg.ea_redirect_probe_ip.trim();
|
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
|
/// 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
|
/// failure. Reporting it as fatal, and then being contradicted by a working
|
||||||
/// game, is how a checklist trains its user to ignore it.
|
/// game, is how a checklist trains its user to ignore it.
|
||||||
|
#[cfg(unix)]
|
||||||
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
||||||
const NAME: &str = "EA hostnames point at OpenFUT";
|
const NAME: &str = "EA hostnames point at OpenFUT";
|
||||||
if cfg.ea_hostnames.is_empty() {
|
if cfg.ea_hostnames.is_empty() {
|
||||||
@@ -320,7 +336,7 @@ fn join(ips: &[IpAddr]) -> String {
|
|||||||
.join(",")
|
.join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -299,5 +299,13 @@ fn install_style(ctx: &Context) {
|
|||||||
v.widgets.open.rounding = radius;
|
v.widgets.open.rounding = radius;
|
||||||
|
|
||||||
style.visuals = v;
|
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