Compare commits
9 Commits
ca7ce267a0
...
1cd4f18e92
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cd4f18e92 | |||
| c5424158b9 | |||
| 3174fe4c1f | |||
| 504ceeec87 | |||
| cbf697bcd5 | |||
| 357501f549 | |||
| c2772132c1 | |||
| d1a71bd5a1 | |||
| 0d3f33cede |
@@ -13,3 +13,6 @@ serde_json = "1"
|
|||||||
dirs = "5"
|
dirs = "5"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
openfut-common = { path = "openfut-common" }
|
openfut-common = { path = "openfut-common" }
|
||||||
|
# parking_lot over std::sync: every lock here is taken and used immediately, so
|
||||||
|
# the poisoning unwrap at each call site is pure noise (project rule).
|
||||||
|
parking_lot = "0.12"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,9 @@
|
|||||||
|
# Standalone workspace root: this Windows-only version.dll proxy is deliberately
|
||||||
|
# NOT a member of the OpenFUT workspace (see that root's `exclude`) so its own
|
||||||
|
# [profile.release] below actually applies. An empty [workspace] table stops Cargo
|
||||||
|
# from walking up and re-attaching this crate to the parent workspace.
|
||||||
|
[workspace]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "openfut-hook"
|
name = "openfut-hook"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
|||||||
let mut len: i32 = 4;
|
let mut len: i32 = 4;
|
||||||
getsockopt(
|
getsockopt(
|
||||||
s,
|
s,
|
||||||
SOL_SOCKET as i32,
|
SOL_SOCKET,
|
||||||
SO_TYPE,
|
SO_TYPE,
|
||||||
&mut ty as *mut i32 as *mut u8,
|
&mut ty as *mut i32 as *mut u8,
|
||||||
&mut len,
|
&mut len,
|
||||||
@@ -265,11 +265,11 @@ pub unsafe extern "system" fn hooked_wsa_connect(
|
|||||||
pub unsafe fn install_inline_connect_hook() -> bool {
|
pub unsafe fn install_inline_connect_hook() -> bool {
|
||||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||||
|
|
||||||
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
|
let ws2 = GetModuleHandleA(c"ws2_32.dll".as_ptr().cast());
|
||||||
if ws2.is_null() {
|
if ws2.is_null() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let connect_fn = match GetProcAddress(ws2, b"connect\0".as_ptr()) {
|
let connect_fn = match GetProcAddress(ws2, c"connect".as_ptr().cast()) {
|
||||||
Some(f) => f as *mut u8,
|
Some(f) => f as *mut u8,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -152,11 +152,11 @@ pub unsafe extern "system" fn hooked_wsaioctl(
|
|||||||
|
|
||||||
pub unsafe fn install_wsaioctl_hook() -> bool {
|
pub unsafe fn install_wsaioctl_hook() -> bool {
|
||||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||||
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
|
let ws2 = GetModuleHandleA(c"ws2_32.dll".as_ptr().cast());
|
||||||
if ws2.is_null() {
|
if ws2.is_null() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let fn_ptr = match GetProcAddress(ws2, b"WSAIoctl\0".as_ptr()) {
|
let fn_ptr = match GetProcAddress(ws2, c"WSAIoctl".as_ptr().cast()) {
|
||||||
Some(f) => f as *mut u8,
|
Some(f) => f as *mut u8,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
|
|||||||
let redirect = REDIRECT_IP
|
let redirect = REDIRECT_IP
|
||||||
.get()
|
.get()
|
||||||
.map(|v| v.as_ptr())
|
.map(|v| v.as_ptr())
|
||||||
.unwrap_or(b"127.0.0.1\0".as_ptr());
|
.unwrap_or(c"127.0.0.1".as_ptr().cast());
|
||||||
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
|
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
|
||||||
return real(redirect, service_name, hints, result);
|
return real(redirect, service_name, hints, result);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -1,3 +1,12 @@
|
|||||||
|
// The `fifa17` feature compiles this shared crate but activates only the FIFA-17
|
||||||
|
// injection path (fifa17.rs + sbc_*): install_hooks() routes to fifa17::install()
|
||||||
|
// and the FIFA-23 hook modules are reached solely via install_hooks_fifa23(), which
|
||||||
|
// is itself `#[cfg(not(feature = "fifa17"))]`. Those modules are therefore compiled
|
||||||
|
// but unused under `fifa17` (the linker strips them from the cdylib). Scope the
|
||||||
|
// resulting dead-code/unused-import lints to that feature so both builds stay
|
||||||
|
// `-D warnings` clean without dropping code the default (FIFA-23) build needs.
|
||||||
|
#![cfg_attr(feature = "fifa17", allow(dead_code, unused_imports))]
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod connect_hook;
|
mod connect_hook;
|
||||||
mod connectex_hook;
|
mod connectex_hook;
|
||||||
@@ -54,6 +63,13 @@ pub(crate) fn flush_log() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// This is the DLL entry point invoked by the Windows loader; it MUST NOT be
|
||||||
|
/// called manually. `module` must be the valid `HMODULE` the loader passes for
|
||||||
|
/// this DLL. On `DLL_PROCESS_ATTACH` it installs process-wide inline detours
|
||||||
|
/// (raw memory patching), so it must run exactly once, on the loader thread,
|
||||||
|
/// before any hooked API is used.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL {
|
pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL {
|
||||||
if reason == DLL_PROCESS_ATTACH {
|
if reason == DLL_PROCESS_ATTACH {
|
||||||
@@ -73,7 +89,6 @@ unsafe fn install_hooks(module: HMODULE) {
|
|||||||
{
|
{
|
||||||
let _ = module;
|
let _ = module;
|
||||||
fifa17::install();
|
fifa17::install();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "fifa17"))]
|
#[cfg(not(feature = "fifa17"))]
|
||||||
install_hooks_fifa23(module)
|
install_hooks_fifa23(module)
|
||||||
|
|||||||
@@ -313,12 +313,12 @@ unsafe fn read_u16(ptr: usize) -> Option<u16> {
|
|||||||
/// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp
|
/// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp
|
||||||
/// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames.
|
/// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames.
|
||||||
unsafe fn resolve_cards_base() -> usize {
|
unsafe fn resolve_cards_base() -> usize {
|
||||||
let h = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr());
|
let h = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast());
|
||||||
if !h.is_null() {
|
if !h.is_null() {
|
||||||
return h as usize;
|
return h as usize;
|
||||||
}
|
}
|
||||||
// Also try the short form some tooling reports.
|
// Also try the short form some tooling reports.
|
||||||
let h2 = GetModuleHandleA(b"CardsDLL.dll\0".as_ptr());
|
let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr().cast());
|
||||||
if !h2.is_null() {
|
if !h2.is_null() {
|
||||||
return h2 as usize;
|
return h2 as usize;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ unsafe fn worker() {
|
|||||||
}
|
}
|
||||||
let mut base = 0usize;
|
let mut base = 0usize;
|
||||||
for _ in 0..600u32 {
|
for _ in 0..600u32 {
|
||||||
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
|
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||||
if base != 0 {
|
if base != 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -839,7 +839,7 @@ unsafe fn worker() {
|
|||||||
let _pending = CodeInstallerPending;
|
let _pending = CodeInstallerPending;
|
||||||
let mut base = 0usize;
|
let mut base = 0usize;
|
||||||
for _ in 0..600u32 {
|
for _ in 0..600u32 {
|
||||||
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
|
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||||
if base != 0 {
|
if base != 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -926,7 +926,7 @@ unsafe fn notifier_worker() {
|
|||||||
let _pending = CodeInstallerPending;
|
let _pending = CodeInstallerPending;
|
||||||
let mut base = 0usize;
|
let mut base = 0usize;
|
||||||
for _ in 0..600u32 {
|
for _ in 0..600u32 {
|
||||||
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
|
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||||
if base != 0 {
|
if base != 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1029,7 +1029,7 @@ unsafe fn controller_register_worker() {
|
|||||||
let _pending = CodeInstallerPending;
|
let _pending = CodeInstallerPending;
|
||||||
let mut base = 0usize;
|
let mut base = 0usize;
|
||||||
for _ in 0..600u32 {
|
for _ in 0..600u32 {
|
||||||
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
|
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||||
if base != 0 {
|
if base != 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ fn patch_module(module: isize, scan_bytes: usize) -> bool {
|
|||||||
|
|
||||||
/// Patch ProtoSSL cert-verify in EAWebKit.dll (call when EAWebKit is loaded).
|
/// Patch ProtoSSL cert-verify in EAWebKit.dll (call when EAWebKit is loaded).
|
||||||
pub unsafe fn patch_eawebkit_cert_verify() -> bool {
|
pub unsafe fn patch_eawebkit_cert_verify() -> bool {
|
||||||
let module = GetModuleHandleA(b"EAWebKit.dll\0".as_ptr()) as isize;
|
let module = GetModuleHandleA(c"EAWebKit.dll".as_ptr().cast()) as isize;
|
||||||
// EAWebKit.dll is ~22 MB
|
// EAWebKit.dll is ~22 MB
|
||||||
patch_module(module, 24 * 1024 * 1024)
|
patch_module(module, 24 * 1024 * 1024)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) {
|
|||||||
let mut len: i32 = 4;
|
let mut len: i32 = 4;
|
||||||
getsockopt(
|
getsockopt(
|
||||||
s,
|
s,
|
||||||
SOL_SOCKET as i32,
|
SOL_SOCKET,
|
||||||
SO_TYPE,
|
SO_TYPE,
|
||||||
&mut ty as *mut i32 as *mut u8,
|
&mut ty as *mut i32 as *mut u8,
|
||||||
&mut len,
|
&mut len,
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//! Read-only background polling of the OpenFUT account summary.
|
||||||
|
//!
|
||||||
|
//! The launcher already POSTs `/openfut/account/sync` once at launch time
|
||||||
|
//! (see [`crate::account_sync::sync`]) to select the active profile. This
|
||||||
|
//! module reuses that request in a background thread so the Dashboard can show
|
||||||
|
//! a live "Your Club" card — coins, level, packs — without ever blocking the UI
|
||||||
|
//! thread on the network. It mirrors [`crate::health::HealthMonitor`]: a shared
|
||||||
|
//! target the UI re-points when the server config changes, and a shared state
|
||||||
|
//! snapshot the UI renders each frame.
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::{
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::account_sync::{self, AccountSummary};
|
||||||
|
use crate::config::LauncherConfig;
|
||||||
|
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// A snapshot of the last account fetch, rendered by the dashboard.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct AccountState {
|
||||||
|
/// The most recently fetched summary, or None while none has succeeded.
|
||||||
|
pub summary: Option<AccountSummary>,
|
||||||
|
/// The error from the latest failed attempt (cleared on success).
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// Whether a server target is currently configured. `false` = idle: the
|
||||||
|
/// launcher has nothing to poll, so the UI shows the "connect" prompt.
|
||||||
|
pub configured: bool,
|
||||||
|
pub last_checked: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountState {
|
||||||
|
/// True when the latest error looks like a connectivity failure (server
|
||||||
|
/// down / unresolvable) rather than a protocol/validation error. Lets the
|
||||||
|
/// UI show the calm "offline" prompt for the common "server not up" case
|
||||||
|
/// and reserve the loud error state for genuinely broken responses.
|
||||||
|
pub fn unreachable(&self) -> bool {
|
||||||
|
self.error.as_deref().is_some_and(|e| {
|
||||||
|
e.contains("cannot connect")
|
||||||
|
|| e.contains("cannot resolve")
|
||||||
|
|| e.contains("resolved to no addresses")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background poller. Holds a shared target config the UI can update when the
|
||||||
|
/// user changes the server address/account, and a shared state the UI reads.
|
||||||
|
pub struct AccountMonitor {
|
||||||
|
pub state: Arc<Mutex<AccountState>>,
|
||||||
|
target: Arc<Mutex<Option<LauncherConfig>>>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountMonitor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let state = Arc::new(Mutex::new(AccountState::default()));
|
||||||
|
let target: Arc<Mutex<Option<LauncherConfig>>> = Arc::new(Mutex::new(None));
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
let t_state = Arc::clone(&state);
|
||||||
|
let t_target = Arc::clone(&target);
|
||||||
|
let t_running = Arc::clone(&running);
|
||||||
|
thread::spawn(move || {
|
||||||
|
while t_running.load(Ordering::Relaxed) {
|
||||||
|
let target = t_target.lock().clone();
|
||||||
|
match target {
|
||||||
|
None => {
|
||||||
|
// No server configured — reset to the idle prompt state.
|
||||||
|
*t_state.lock() = AccountState::default();
|
||||||
|
}
|
||||||
|
Some(config) => {
|
||||||
|
let result = account_sync::sync(&config);
|
||||||
|
let mut state = t_state.lock();
|
||||||
|
state.configured = true;
|
||||||
|
state.last_checked = Some(Instant::now());
|
||||||
|
match result {
|
||||||
|
Ok(summary) => {
|
||||||
|
state.summary = Some(summary);
|
||||||
|
state.error = None;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
// Drop the stale summary so the card never shows
|
||||||
|
// populated data alongside an error/offline pill.
|
||||||
|
state.summary = None;
|
||||||
|
state.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
target,
|
||||||
|
running,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point the monitor at a new server/account. `None` (no server configured)
|
||||||
|
/// puts it back into the idle prompt state.
|
||||||
|
pub fn set_target(&self, target: Option<LauncherConfig>) {
|
||||||
|
*self.target.lock() = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> AccountState {
|
||||||
|
self.state.lock().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AccountMonitor {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.running.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
-22
@@ -7,11 +7,18 @@ use std::time::Duration;
|
|||||||
const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync";
|
const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync";
|
||||||
const TIMEOUT: Duration = Duration::from_secs(3);
|
const TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// The launcher's view of the account, sent on every sync.
|
||||||
|
///
|
||||||
|
/// `persona_id`/`persona_name` are `Option` because omitting them is meaningful:
|
||||||
|
/// the server then answers with the persona *it* is configured for, which is how
|
||||||
|
/// first-run account creation learns an identity instead of inventing one.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct AccountSyncRequest<'a> {
|
struct AccountSyncRequest<'a> {
|
||||||
persona_id: u64,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
persona_name: &'a str,
|
persona_id: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
persona_name: Option<&'a str>,
|
||||||
level: u32,
|
level: u32,
|
||||||
experience: u32,
|
experience: u32,
|
||||||
experience_max: u32,
|
experience_max: u32,
|
||||||
@@ -24,14 +31,25 @@ pub struct AccountSyncResult {
|
|||||||
pub account: AccountSummary,
|
pub account: AccountSummary,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AccountSummary {
|
pub struct AccountSummary {
|
||||||
pub persona_id: u64,
|
pub persona_id: u64,
|
||||||
pub persona_name: String,
|
pub persona_name: String,
|
||||||
|
/// Club identity for the account bar. Optional in older envelopes.
|
||||||
|
#[serde(default)]
|
||||||
|
pub club_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub club_abbr: String,
|
||||||
pub level: u32,
|
pub level: u32,
|
||||||
pub experience: u32,
|
pub experience: u32,
|
||||||
|
/// XP required for the next level. Optional; 0 means "unknown".
|
||||||
|
#[serde(default)]
|
||||||
|
pub experience_max: u32,
|
||||||
pub account_funds: u32,
|
pub account_funds: u32,
|
||||||
|
/// EASFC funds ceiling. Optional; 0 means "unknown".
|
||||||
|
#[serde(default)]
|
||||||
|
pub account_funds_cap: u32,
|
||||||
pub coins: i64,
|
pub coins: i64,
|
||||||
pub unopened_packs: usize,
|
pub unopened_packs: usize,
|
||||||
}
|
}
|
||||||
@@ -43,7 +61,64 @@ pub struct AccountSummary {
|
|||||||
pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||||
config.validate_server()?;
|
config.validate_server()?;
|
||||||
config.validate_account()?;
|
config.validate_account()?;
|
||||||
|
let account = post(
|
||||||
|
config,
|
||||||
|
&AccountSyncRequest {
|
||||||
|
persona_id: Some(config.fut_persona_id),
|
||||||
|
persona_name: Some(config.fut_persona_name.trim()),
|
||||||
|
level: config.fut_account_level,
|
||||||
|
experience: config.fut_account_experience,
|
||||||
|
experience_max: config.fut_account_experience_max,
|
||||||
|
account_funds: config.fut_account_funds,
|
||||||
|
account_funds_cap: config.fut_account_funds_cap,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
// The server echoes the persona it selected. A different one means the two
|
||||||
|
// sides disagree about who is playing, which must never pass silently.
|
||||||
|
if account.persona_id != config.fut_persona_id {
|
||||||
|
return Err(format!(
|
||||||
|
"account server selected persona {} instead of {}",
|
||||||
|
account.persona_id, config.fut_persona_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the server which account it serves, for first-run account creation.
|
||||||
|
///
|
||||||
|
/// Sending no persona makes the server fall back to the one it was started with
|
||||||
|
/// and answer with its real club and Core coin balance. That is the whole reason
|
||||||
|
/// the launcher never has to invent a persona id: the identity that matters is
|
||||||
|
/// the server's, and this is how it is claimed.
|
||||||
|
pub fn discover(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||||
|
config.validate_server()?;
|
||||||
|
let account = post(
|
||||||
|
config,
|
||||||
|
&AccountSyncRequest {
|
||||||
|
persona_id: None,
|
||||||
|
persona_name: None,
|
||||||
|
level: config.fut_account_level.max(1),
|
||||||
|
experience: config.fut_account_experience,
|
||||||
|
experience_max: config.fut_account_experience_max.max(1),
|
||||||
|
account_funds: config.fut_account_funds,
|
||||||
|
account_funds_cap: config.fut_account_funds_cap,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if account.persona_id == 0 {
|
||||||
|
return Err(
|
||||||
|
"account server returned no persona — is it configured with \
|
||||||
|
a persona id?"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if account.persona_name.trim().is_empty() {
|
||||||
|
return Err("account server returned an empty persona name".to_string());
|
||||||
|
}
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bounded POST to `/openfut/account/sync`, returning the account summary.
|
||||||
|
fn post(config: &LauncherConfig, body: &AccountSyncRequest<'_>) -> Result<AccountSummary, String> {
|
||||||
let host = config.openfut_server_host.trim();
|
let host = config.openfut_server_host.trim();
|
||||||
let port = config.openfut_account_sync_port;
|
let port = config.openfut_account_sync_port;
|
||||||
let address = (host, port)
|
let address = (host, port)
|
||||||
@@ -60,16 +135,8 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
|||||||
.set_write_timeout(Some(TIMEOUT))
|
.set_write_timeout(Some(TIMEOUT))
|
||||||
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
|
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
|
||||||
|
|
||||||
let payload = serde_json::to_vec(&AccountSyncRequest {
|
let payload = serde_json::to_vec(body)
|
||||||
persona_id: config.fut_persona_id,
|
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
|
||||||
persona_name: config.fut_persona_name.trim(),
|
|
||||||
level: config.fut_account_level,
|
|
||||||
experience: config.fut_account_experience,
|
|
||||||
experience_max: config.fut_account_experience_max,
|
|
||||||
account_funds: config.fut_account_funds,
|
|
||||||
account_funds_cap: config.fut_account_funds_cap,
|
|
||||||
})
|
|
||||||
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
|
|
||||||
|
|
||||||
let request = format!(
|
let request = format!(
|
||||||
"POST {ACCOUNT_SYNC_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
"POST {ACCOUNT_SYNC_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
@@ -96,21 +163,15 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
|||||||
.and_then(|line| line.split_whitespace().nth(1))
|
.and_then(|line| line.split_whitespace().nth(1))
|
||||||
.and_then(|value| value.parse::<u16>().ok())
|
.and_then(|value| value.parse::<u16>().ok())
|
||||||
.ok_or_else(|| "account server returned a malformed status line".to_string())?;
|
.ok_or_else(|| "account server returned a malformed status line".to_string())?;
|
||||||
let body = &response[separator + 4..];
|
let response_body = &response[separator + 4..];
|
||||||
if !(200..300).contains(&status) {
|
if !(200..300).contains(&status) {
|
||||||
let detail = String::from_utf8_lossy(body);
|
let detail = String::from_utf8_lossy(response_body);
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"account server rejected sync (HTTP {status}): {detail}"
|
"account server rejected sync (HTTP {status}): {detail}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let envelope: AccountSyncResult = serde_json::from_slice(body)
|
let envelope: AccountSyncResult = serde_json::from_slice(response_body)
|
||||||
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
|
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
|
||||||
if envelope.account.persona_id != config.fut_persona_id {
|
|
||||||
return Err(format!(
|
|
||||||
"account server selected persona {} instead of {}",
|
|
||||||
envelope.account.persona_id, config.fut_persona_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(envelope.account)
|
Ok(envelope.account)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,4 +235,108 @@ mod tests {
|
|||||||
assert_eq!(selected.unopened_packs, 1);
|
assert_eq!(selected.unopened_packs, 1);
|
||||||
server.join().unwrap();
|
server.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serve exactly one `/openfut/account/sync` POST, handing the decoded
|
||||||
|
/// request text to `inspect` and replying with `body`.
|
||||||
|
fn serve_once(
|
||||||
|
inspect: impl FnOnce(&str) + Send + 'static,
|
||||||
|
body: &'static str,
|
||||||
|
) -> (u16, thread::JoinHandle<()>) {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut socket, _) = listener.accept().unwrap();
|
||||||
|
let mut request = Vec::new();
|
||||||
|
loop {
|
||||||
|
let mut chunk = [0; 1024];
|
||||||
|
let count = socket.read(&mut chunk).unwrap();
|
||||||
|
assert!(count > 0);
|
||||||
|
request.extend_from_slice(&chunk[..count]);
|
||||||
|
if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||||
|
let headers = String::from_utf8_lossy(&request[..separator]);
|
||||||
|
let length = headers
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("Content-Length: "))
|
||||||
|
.unwrap()
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap();
|
||||||
|
if request.len() >= separator + 4 + length {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inspect(&String::from_utf8_lossy(&request));
|
||||||
|
write!(
|
||||||
|
socket,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
(port, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_omits_the_persona_so_the_server_names_its_own() {
|
||||||
|
// The point of first-run discovery: the launcher must not send a guessed
|
||||||
|
// persona, because the server would echo the guess straight back.
|
||||||
|
let (port, server) = serve_once(
|
||||||
|
|request| {
|
||||||
|
assert!(!request.contains("personaId"), "{request}");
|
||||||
|
assert!(!request.contains("personaName"), "{request}");
|
||||||
|
},
|
||||||
|
r#"{"status":"OK","account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC","level":1,"experience":0,"accountFunds":0,"coins":29876776,"unopenedPacks":0}}"#,
|
||||||
|
);
|
||||||
|
let config = LauncherConfig {
|
||||||
|
openfut_server_host: "127.0.0.1".into(),
|
||||||
|
openfut_account_sync_port: port,
|
||||||
|
..LauncherConfig::default()
|
||||||
|
};
|
||||||
|
// Deliberately an unconfigured account: discovery must work before one
|
||||||
|
// exists, which is the whole reason it does not call `validate_account`.
|
||||||
|
assert_eq!(config.fut_persona_id, 0);
|
||||||
|
let found = discover(&config).unwrap();
|
||||||
|
assert_eq!(found.persona_id, 33_068_179);
|
||||||
|
assert_eq!(found.persona_name, "CAGE");
|
||||||
|
assert_eq!(found.club_name, "OpenFUT");
|
||||||
|
assert_eq!(found.coins, 29_876_776);
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_rejects_a_server_that_names_no_persona() {
|
||||||
|
// A zero persona would otherwise be written into the config as a real
|
||||||
|
// account and fail much later, at launch, as a mismatch.
|
||||||
|
let (port, server) = serve_once(
|
||||||
|
|_| {},
|
||||||
|
r#"{"status":"OK","account":{"personaId":0,"personaName":"","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
|
||||||
|
);
|
||||||
|
let config = LauncherConfig {
|
||||||
|
openfut_server_host: "127.0.0.1".into(),
|
||||||
|
openfut_account_sync_port: port,
|
||||||
|
..LauncherConfig::default()
|
||||||
|
};
|
||||||
|
let error = discover(&config).unwrap_err();
|
||||||
|
assert!(error.contains("no persona"), "{error}");
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_refuses_a_server_that_selects_a_different_persona() {
|
||||||
|
let (port, server) = serve_once(
|
||||||
|
|_| {},
|
||||||
|
r#"{"status":"OK","account":{"personaId":999,"personaName":"OTHER","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
|
||||||
|
);
|
||||||
|
let config = LauncherConfig {
|
||||||
|
openfut_server_host: "127.0.0.1".into(),
|
||||||
|
openfut_account_sync_port: port,
|
||||||
|
fut_persona_id: 12345678,
|
||||||
|
fut_persona_name: "TEST_USER".into(),
|
||||||
|
..LauncherConfig::default()
|
||||||
|
};
|
||||||
|
let error = sync(&config).unwrap_err();
|
||||||
|
assert!(error.contains("999"), "{error}");
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1837
-758
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -106,14 +106,16 @@ pub(crate) fn arming_summary(
|
|||||||
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() {
|
||||||
anyhow::bail!("Set the OpenFUT server host in the Config tab before arming.");
|
anyhow::bail!("Set the OpenFUT server host in Settings before arming.");
|
||||||
}
|
}
|
||||||
let ea_ip = cfg.ea_redirect_probe_ip.trim();
|
let ea_ip = cfg.ea_redirect_probe_ip.trim();
|
||||||
if ea_ip.is_empty() {
|
if ea_ip.is_empty() {
|
||||||
anyhow::bail!("Set the EA redirector IP (Config tab) before arming.");
|
anyhow::bail!("Set the EA redirector IP (Settings) before arming.");
|
||||||
}
|
}
|
||||||
if cfg.ea_hostnames.is_empty() {
|
if cfg.ea_hostnames.is_empty() {
|
||||||
anyhow::bail!("Add at least one EA hostname (e.g. easw.easports.com) in the Config tab before arming.");
|
anyhow::bail!(
|
||||||
|
"Add at least one EA hostname (e.g. easw.easports.com) in Settings before arming."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let redirector_port = cfg.openfut_blaze_redirector_port;
|
let redirector_port = cfg.openfut_blaze_redirector_port;
|
||||||
let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
|
let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
|
||||||
|
|||||||
+58
-80
@@ -168,26 +168,6 @@ pub struct LauncherConfig {
|
|||||||
/// Dead EA hostnames that must resolve to `openfut_server_host`.
|
/// Dead EA hostnames that must resolve to `openfut_server_host`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ea_hostnames: Vec<String>,
|
pub ea_hostnames: Vec<String>,
|
||||||
|
|
||||||
// ── FIFA 17 local companion services (client-side, run on THIS machine) ──
|
|
||||||
// FIFA 17's FUT flow needs two pieces that are inherently local to the game
|
|
||||||
// box and cannot move to the server: the LSX Origin emulator (the game dials
|
|
||||||
// it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches
|
|
||||||
// FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages
|
|
||||||
// both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run
|
|
||||||
// in the server container; these two stay here.
|
|
||||||
/// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`).
|
|
||||||
/// Empty means the local-services feature is unconfigured and its controls
|
|
||||||
/// stay disabled.
|
|
||||||
#[serde(default)]
|
|
||||||
pub fifa17_tools_dir: String,
|
|
||||||
/// Python interpreter used to run the local companion services.
|
|
||||||
#[serde(default = "default_python")]
|
|
||||||
pub fifa17_python: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_python() -> String {
|
|
||||||
"python3".to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_https_port() -> u16 {
|
fn default_https_port() -> u16 {
|
||||||
@@ -271,11 +251,6 @@ impl Default for LauncherConfig {
|
|||||||
game_profile: GameProfile::default(),
|
game_profile: GameProfile::default(),
|
||||||
ea_redirect_probe_ip: String::new(),
|
ea_redirect_probe_ip: String::new(),
|
||||||
ea_hostnames: Vec::new(),
|
ea_hostnames: Vec::new(),
|
||||||
fifa17_tools_dir: base
|
|
||||||
.join("fifa17-recon/tools")
|
|
||||||
.to_string_lossy()
|
|
||||||
.into(),
|
|
||||||
fifa17_python: default_python(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +293,17 @@ impl LauncherConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The config the account monitor should poll with, or None when no server
|
||||||
|
/// is configured. Returns a clone so the background thread owns its own
|
||||||
|
/// snapshot and never races the UI's live config.
|
||||||
|
pub fn account_target(&self) -> Option<LauncherConfig> {
|
||||||
|
if self.openfut_server_host.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the shared [`ServerConfig`] from the launcher's configured server
|
/// Build the shared [`ServerConfig`] from the launcher's configured server
|
||||||
/// host + destination ports. This is the single place the launcher turns UI
|
/// host + destination ports. This is the single place the launcher turns UI
|
||||||
/// fields into the canonical config consumed by the hook.
|
/// fields into the canonical config consumed by the hook.
|
||||||
@@ -343,19 +329,6 @@ impl LauncherConfig {
|
|||||||
self.server_config().validate().map_err(|e| e.to_string())
|
self.server_config().validate().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the client-local FIFA 17 service configuration. Filesystem
|
|
||||||
/// existence is checked by the process launcher immediately before spawn;
|
|
||||||
/// this ensures required user configuration is never silently invented.
|
|
||||||
pub fn validate_local_services(&self) -> Result<(), String> {
|
|
||||||
if self.fifa17_tools_dir.trim().is_empty() {
|
|
||||||
return Err("No FIFA 17 tools dir configured. Set it in the Config tab.".into());
|
|
||||||
}
|
|
||||||
if self.fifa17_python.trim().is_empty() {
|
|
||||||
return Err("No Python interpreter configured. Set it in the Config tab.".into());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate every configuration value required by the one-button FIFA 17
|
/// Validate every configuration value required by the one-button FIFA 17
|
||||||
/// launch path. Runtime state such as hook deployment is checked by the UI.
|
/// launch path. Runtime state such as hook deployment is checked by the UI.
|
||||||
pub fn validate_launch_config(&self) -> Result<(), String> {
|
pub fn validate_launch_config(&self) -> Result<(), String> {
|
||||||
@@ -369,19 +342,19 @@ impl LauncherConfig {
|
|||||||
} else if self.game_launch_command.trim().is_empty() {
|
} else if self.game_launch_command.trim().is_empty() {
|
||||||
return Err(
|
return Err(
|
||||||
"No game configured. Fill in the game profile, or set a launch command, \
|
"No game configured. Fill in the game profile, or set a launch command, \
|
||||||
in the Config tab."
|
in Settings."
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.validate_local_services()
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_account(&self) -> Result<(), String> {
|
pub fn validate_account(&self) -> Result<(), String> {
|
||||||
if self.fut_persona_id == 0 {
|
if self.fut_persona_id == 0 {
|
||||||
return Err("No EA persona ID configured. Set the account in the Config tab.".into());
|
return Err("No account yet. Create one from the Get started tab.".into());
|
||||||
}
|
}
|
||||||
if self.fut_persona_name.trim().is_empty() {
|
if self.fut_persona_name.trim().is_empty() {
|
||||||
return Err("No EA persona name configured. Set the account in the Config tab.".into());
|
return Err("Account has no persona name. Recreate it from Get started.".into());
|
||||||
}
|
}
|
||||||
if self.fut_account_level == 0 {
|
if self.fut_account_level == 0 {
|
||||||
return Err("EA account level must be at least 1.".into());
|
return Err("EA account level must be at least 1.".into());
|
||||||
@@ -397,6 +370,21 @@ impl LauncherConfig {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an account has been claimed from the server (see
|
||||||
|
/// [`crate::account_sync::discover`]). Distinct from
|
||||||
|
/// [`Self::validate_account`], which also polices the derived EASFC values:
|
||||||
|
/// this answers only "does this install know who is playing?".
|
||||||
|
pub fn account_configured(&self) -> bool {
|
||||||
|
self.fut_persona_id != 0 && !self.fut_persona_name.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the launcher should open on the guided first-run flow instead of
|
||||||
|
/// the dashboard. Keyed on the two things a new user cannot be expected to
|
||||||
|
/// guess: where the server is, and who they are.
|
||||||
|
pub fn needs_onboarding(&self) -> bool {
|
||||||
|
self.validate_server().is_err() || !self.account_configured()
|
||||||
|
}
|
||||||
|
|
||||||
/// The exact `openfut.cfg` bytes to write for the hook, or an error if the
|
/// The exact `openfut.cfg` bytes to write for the hook, or an error if the
|
||||||
/// server isn't validly configured (never emits a loopback fallback).
|
/// server isn't validly configured (never emits a loopback fallback).
|
||||||
///
|
///
|
||||||
@@ -482,31 +470,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_services_require_tools_dir_and_python() {
|
fn launch_config_requires_server_account_and_command() {
|
||||||
let mut c = LauncherConfig::default();
|
|
||||||
c.fifa17_tools_dir.clear();
|
|
||||||
assert!(c
|
|
||||||
.validate_local_services()
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("tools dir"));
|
|
||||||
|
|
||||||
c.fifa17_tools_dir = "/tmp/fifa17-tools".into();
|
|
||||||
c.fifa17_python.clear();
|
|
||||||
assert!(c.validate_local_services().unwrap_err().contains("Python"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_services_accept_explicit_configuration() {
|
|
||||||
let c = LauncherConfig {
|
|
||||||
fifa17_tools_dir: "/tmp/fifa17-tools".into(),
|
|
||||||
fifa17_python: "/usr/bin/python3".into(),
|
|
||||||
..LauncherConfig::default()
|
|
||||||
};
|
|
||||||
assert!(c.validate_local_services().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn launch_config_requires_server_local_services_and_command() {
|
|
||||||
let mut c = LauncherConfig::default();
|
let mut c = LauncherConfig::default();
|
||||||
assert!(c.validate_launch_config().is_err());
|
assert!(c.validate_launch_config().is_err());
|
||||||
|
|
||||||
@@ -519,14 +483,6 @@ mod tests {
|
|||||||
.contains("launch command"));
|
.contains("launch command"));
|
||||||
|
|
||||||
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
|
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
|
||||||
c.fifa17_tools_dir.clear();
|
|
||||||
assert!(c
|
|
||||||
.validate_launch_config()
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("tools dir"));
|
|
||||||
|
|
||||||
c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into();
|
|
||||||
c.fifa17_python = "/usr/bin/python3".into();
|
|
||||||
assert!(c.validate_launch_config().is_ok());
|
assert!(c.validate_launch_config().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,8 +510,6 @@ mod tests {
|
|||||||
openfut_server_host: "10.10.0.120".into(),
|
openfut_server_host: "10.10.0.120".into(),
|
||||||
fut_persona_id: 1,
|
fut_persona_id: 1,
|
||||||
fut_persona_name: "X".into(),
|
fut_persona_name: "X".into(),
|
||||||
fifa17_tools_dir: "/tmp/tools".into(),
|
|
||||||
fifa17_python: "/usr/bin/python3".into(),
|
|
||||||
..LauncherConfig::default()
|
..LauncherConfig::default()
|
||||||
};
|
};
|
||||||
c.game_launch_command.clear();
|
c.game_launch_command.clear();
|
||||||
@@ -583,8 +537,6 @@ mod tests {
|
|||||||
openfut_server_host: "10.10.0.120".into(),
|
openfut_server_host: "10.10.0.120".into(),
|
||||||
fut_persona_id: 1,
|
fut_persona_id: 1,
|
||||||
fut_persona_name: "X".into(),
|
fut_persona_name: "X".into(),
|
||||||
fifa17_tools_dir: "/tmp/tools".into(),
|
|
||||||
fifa17_python: "/usr/bin/python3".into(),
|
|
||||||
game_launch_command: "/home/u/launch.sh".into(),
|
game_launch_command: "/home/u/launch.sh".into(),
|
||||||
..LauncherConfig::default()
|
..LauncherConfig::default()
|
||||||
};
|
};
|
||||||
@@ -685,12 +637,38 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn launch_requires_a_valid_ea_account() {
|
fn launch_requires_a_valid_ea_account() {
|
||||||
let mut c = LauncherConfig::default();
|
let mut c = LauncherConfig::default();
|
||||||
assert!(c.validate_account().unwrap_err().contains("persona ID"));
|
// A fresh install has no account, and must say so rather than launching
|
||||||
|
// FIFA as persona 0.
|
||||||
|
assert!(!c.account_configured());
|
||||||
|
assert!(c.validate_account().is_err());
|
||||||
c.fut_persona_id = 12345678;
|
c.fut_persona_id = 12345678;
|
||||||
|
assert!(
|
||||||
|
!c.account_configured(),
|
||||||
|
"an id without a name is not an account"
|
||||||
|
);
|
||||||
assert!(c.validate_account().unwrap_err().contains("persona name"));
|
assert!(c.validate_account().unwrap_err().contains("persona name"));
|
||||||
c.fut_persona_name = "TEST_USER".into();
|
c.fut_persona_name = "TEST_USER".into();
|
||||||
|
assert!(c.account_configured());
|
||||||
assert!(c.validate_account().is_ok());
|
assert!(c.validate_account().is_ok());
|
||||||
c.fut_account_experience = 1001;
|
c.fut_account_experience = 1001;
|
||||||
assert!(c.validate_account().unwrap_err().contains("XP"));
|
assert!(c.validate_account().unwrap_err().contains("XP"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn onboarding_is_needed_until_both_server_and_account_are_known() {
|
||||||
|
// Drives which tab the launcher opens on, so the two halves must both
|
||||||
|
// count: a server with no account is still a dead end for a new user.
|
||||||
|
let mut c = LauncherConfig::default();
|
||||||
|
assert!(c.needs_onboarding());
|
||||||
|
c.openfut_server_host = "10.10.0.120".into();
|
||||||
|
assert!(
|
||||||
|
c.needs_onboarding(),
|
||||||
|
"a server alone cannot launch anything"
|
||||||
|
);
|
||||||
|
c.fut_persona_id = 33_068_179;
|
||||||
|
c.fut_persona_name = "CAGE".into();
|
||||||
|
assert!(!c.needs_onboarding());
|
||||||
|
c.openfut_server_host.clear();
|
||||||
|
assert!(c.needs_onboarding(), "losing the server reopens the flow");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-10
@@ -23,10 +23,12 @@
|
|||||||
//! `game_launch_command` remains as an escape hatch: an unconfigured profile
|
//! `game_launch_command` remains as an escape hatch: an unconfigured profile
|
||||||
//! 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 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, Mutex};
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::config::GameProfile;
|
use crate::config::GameProfile;
|
||||||
@@ -35,14 +37,19 @@ use crate::logs::LogBuffer;
|
|||||||
type Log = Arc<Mutex<LogBuffer>>;
|
type Log = Arc<Mutex<LogBuffer>>;
|
||||||
|
|
||||||
fn say(log: &Log, msg: impl Into<String>) {
|
fn say(log: &Log, msg: impl Into<String>) {
|
||||||
log.lock().unwrap().push(msg.into());
|
log.lock().push(msg.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prepare the prefix, satisfy the licence precondition, and start the game.
|
/// Prepare the prefix, satisfy the licence precondition, and start the game.
|
||||||
///
|
///
|
||||||
/// 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.
|
/// stream into `log` on background threads. `on_exit` fires when the process
|
||||||
pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
/// ends, which is how the launch state machine leaves its Running state.
|
||||||
|
pub fn launch(
|
||||||
|
profile: &GameProfile,
|
||||||
|
log: &Log,
|
||||||
|
on_exit: impl FnOnce() + Send + 'static,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
profile.validate().map_err(anyhow::Error::msg)?;
|
profile.validate().map_err(anyhow::Error::msg)?;
|
||||||
|
|
||||||
let game_dir = PathBuf::from(&profile.game_dir);
|
let game_dir = PathBuf::from(&profile.game_dir);
|
||||||
@@ -61,6 +68,7 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
|||||||
for (k, v) in &profile.env {
|
for (k, v) in &profile.env {
|
||||||
cmd.env(k, v);
|
cmd.env(k, v);
|
||||||
}
|
}
|
||||||
|
cmd.env("WINEDLLOVERRIDES", hook_dll_overrides(&profile.env));
|
||||||
if !profile.wine_prefix.trim().is_empty() {
|
if !profile.wine_prefix.trim().is_empty() {
|
||||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||||
}
|
}
|
||||||
@@ -78,10 +86,39 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
|||||||
let child = cmd
|
let child = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
|
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
|
||||||
stream(child, log.clone(), "[launcher] game process exited.");
|
stream(
|
||||||
|
child,
|
||||||
|
log.clone(),
|
||||||
|
"[launcher] game process exited.",
|
||||||
|
on_exit,
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `WINEDLLOVERRIDES` value the game must be started with.
|
||||||
|
///
|
||||||
|
/// The hook ships as a `version.dll` proxy inside the game directory, and Proton
|
||||||
|
/// prefers a local DLL over its own builtin ONLY when `WINEDLLOVERRIDES` names it
|
||||||
|
/// (see `setup::STEAM_LAUNCH_OPTIONS`). Steam users get that from their launch
|
||||||
|
/// options; when the launcher spawns the runner itself, nothing else supplies it.
|
||||||
|
///
|
||||||
|
/// Without it the failure is silent and badly misleading: the hook never loads, so
|
||||||
|
/// the `openfut.cfg` the launcher just wrote is inert, the game ignores the
|
||||||
|
/// configured Blaze ports, and `/etc/hosts` quietly routes it to whatever answers
|
||||||
|
/// on EA's real ports. It looks like a working launch against the configured
|
||||||
|
/// server while actually talking to a different one.
|
||||||
|
///
|
||||||
|
/// A profile that already pins `version=` wins: an operator overriding the hijack
|
||||||
|
/// deliberately must not be silently overruled.
|
||||||
|
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
|
||||||
|
const HOOK: &str = "version=n,b";
|
||||||
|
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
|
||||||
|
Some(existing) if existing.contains("version=") => existing.to_string(),
|
||||||
|
Some(existing) if !existing.is_empty() => format!("{existing};{HOOK}"),
|
||||||
|
_ => HOOK.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create the Wine prefix's `dosdevices` entries the profile asks for.
|
/// Create the Wine prefix's `dosdevices` entries the profile asks for.
|
||||||
///
|
///
|
||||||
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
|
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
|
||||||
@@ -221,12 +258,17 @@ fn non_empty_file(path: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pump a child's stdout and stderr into the log buffer and reap it.
|
/// Pump a child's stdout and stderr into the log buffer and reap it.
|
||||||
pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
pub fn stream(
|
||||||
|
mut child: Child,
|
||||||
|
log: Log,
|
||||||
|
exit_msg: &'static str,
|
||||||
|
on_exit: impl FnOnce() + Send + 'static,
|
||||||
|
) {
|
||||||
if let Some(out) = child.stdout.take() {
|
if let Some(out) = child.stdout.take() {
|
||||||
let buf = Arc::clone(&log);
|
let buf = Arc::clone(&log);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||||
buf.lock().unwrap().push(line);
|
buf.lock().push(line);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -234,13 +276,14 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
|||||||
let buf = Arc::clone(&log);
|
let buf = Arc::clone(&log);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||||
buf.lock().unwrap().push(line);
|
buf.lock().push(line);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
log.lock().unwrap().push(exit_msg.to_string());
|
log.lock().push(exit_msg.to_string());
|
||||||
|
on_exit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +486,35 @@ mod tests {
|
|||||||
game_dir: "/definitely/not/here".into(),
|
game_dir: "/definitely/not/here".into(),
|
||||||
..GameProfile::default()
|
..GameProfile::default()
|
||||||
};
|
};
|
||||||
let err = launch(&profile, &log()).unwrap_err().to_string();
|
let err = launch(&profile, &log(), || {}).unwrap_err().to_string();
|
||||||
assert!(err.contains("game_dir does not exist"), "{err}");
|
assert!(err.contains("game_dir does not exist"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_profile_without_overrides_still_gets_the_hook_hijack() {
|
||||||
|
// The regression this guards: FIFA launched from the launcher ignored the
|
||||||
|
// configured Blaze ports entirely, because Proton loaded its own builtin
|
||||||
|
// version.dll and the hook proxy never ran. The launch looked healthy.
|
||||||
|
assert_eq!(hook_dll_overrides(&BTreeMap::new()), "version=n,b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrelated_overrides_are_preserved_and_appended_to() {
|
||||||
|
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "d3d11=n".to_string())]);
|
||||||
|
assert_eq!(hook_dll_overrides(&env), "d3d11=n;version=n,b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_version_override_is_never_overruled() {
|
||||||
|
// An operator disabling the hijack on purpose must win, otherwise the
|
||||||
|
// setting is a lie.
|
||||||
|
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "version=b".to_string())]);
|
||||||
|
assert_eq!(hook_dll_overrides(&env), "version=b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_override_is_treated_as_absent_rather_than_appended_to() {
|
||||||
|
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), " ".to_string())]);
|
||||||
|
assert_eq!(hook_dll_overrides(&env), "version=n,b");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-6
@@ -6,11 +6,12 @@
|
|||||||
//! stops, or assumes anything about how the server is hosted; it only asks
|
//! stops, or assumes anything about how the server is hosted; it only asks
|
||||||
//! "can the FIFA client reach it right now?".
|
//! "can the FIFA client reach it right now?".
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::{
|
use std::{
|
||||||
net::{TcpStream, ToSocketAddrs},
|
net::{TcpStream, ToSocketAddrs},
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc, Mutex,
|
Arc,
|
||||||
},
|
},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
@@ -57,14 +58,14 @@ impl HealthMonitor {
|
|||||||
let t_running = Arc::clone(&running);
|
let t_running = Arc::clone(&running);
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
while t_running.load(Ordering::Relaxed) {
|
while t_running.load(Ordering::Relaxed) {
|
||||||
let target = t_target.lock().unwrap().clone();
|
let target = t_target.lock().clone();
|
||||||
match target {
|
match target {
|
||||||
None => {
|
None => {
|
||||||
*t_state.lock().unwrap() = HealthState::default();
|
*t_state.lock() = HealthState::default();
|
||||||
}
|
}
|
||||||
Some((host, port)) => {
|
Some((host, port)) => {
|
||||||
let snapshot = probe(&host, port);
|
let snapshot = probe(&host, port);
|
||||||
*t_state.lock().unwrap() = snapshot;
|
*t_state.lock() = snapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
thread::sleep(POLL_INTERVAL);
|
thread::sleep(POLL_INTERVAL);
|
||||||
@@ -81,11 +82,11 @@ impl HealthMonitor {
|
|||||||
/// Point the monitor at a new server address (host + bridge port). Passing
|
/// Point the monitor at a new server address (host + bridge port). Passing
|
||||||
/// None (e.g. no server configured) puts it back into the idle state.
|
/// None (e.g. no server configured) puts it back into the idle state.
|
||||||
pub fn set_target(&self, target: Option<(String, u16)>) {
|
pub fn set_target(&self, target: Option<(String, u16)>) {
|
||||||
*self.target.lock().unwrap() = target;
|
*self.target.lock() = target;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn snapshot(&self) -> HealthState {
|
pub fn snapshot(&self) -> HealthState {
|
||||||
self.state.lock().unwrap().clone()
|
self.state.lock().clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+940
@@ -0,0 +1,940 @@
|
|||||||
|
//! The launch sequence, as an explicit state machine.
|
||||||
|
//!
|
||||||
|
//! # Why this exists
|
||||||
|
//!
|
||||||
|
//! The launcher used to make the user perform OpenFUT's internal launch order by
|
||||||
|
//! hand: start LSX, start autopatch, run pre-launch checks, "Arm client", then
|
||||||
|
//! press a button called *Start Services & Launch Game*. Every one of those is an
|
||||||
|
//! implementation detail of how FIFA 17 is persuaded to talk to OpenFUT, and
|
||||||
|
//! getting the order wrong produced failures that surfaced much later as "the
|
||||||
|
//! game crashed" — autopatch started before `ptrace_scope` was 0 silently does
|
||||||
|
//! nothing at all.
|
||||||
|
//!
|
||||||
|
//! So the sequence lives here, once, and the UI renders it. One button.
|
||||||
|
//!
|
||||||
|
//! # Ordering, and where it deviates from the obvious
|
||||||
|
//!
|
||||||
|
//! Client preparation (`arm`) runs BEFORE autopatch, not after: autopatch writes
|
||||||
|
//! `/proc/<FIFA17.exe>/mem`, which Yama forbids until arming sets
|
||||||
|
//! `kernel.yama.ptrace_scope=0`. Starting autopatch first would "succeed" and
|
||||||
|
//! then quietly fail to patch anything.
|
||||||
|
//!
|
||||||
|
//! # Idempotence
|
||||||
|
//!
|
||||||
|
//! Every step asks what is already true before acting. A healthy service is
|
||||||
|
//! reused, never restarted; client preparation is skipped when the checks it
|
||||||
|
//! would repair already pass, which also avoids an unnecessary Polkit prompt.
|
||||||
|
//!
|
||||||
|
//! # Testability
|
||||||
|
//!
|
||||||
|
//! The effects — spawning services, elevating for arming, writing the hook
|
||||||
|
//! config, starting the game — sit behind [`LaunchOps`]. [`run_sequence`] is
|
||||||
|
//! therefore a pure decision procedure over observed state, and the sequencing
|
||||||
|
//! rules that matter (don't launch after a failed step, don't restart healthy
|
||||||
|
//! services, don't kill what we didn't start) are unit-testable without a FIFA
|
||||||
|
//! install, a Polkit agent, or root.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::config::LauncherConfig;
|
||||||
|
use crate::fifa17_capability::Fifa17ClientCapabilities;
|
||||||
|
use crate::local_services::{
|
||||||
|
CapabilityWiring, Ensured, Service, ServiceRuntime, ServiceSupervisor, SpawnSpec,
|
||||||
|
};
|
||||||
|
use crate::logs::LogBuffer;
|
||||||
|
use crate::preflight::{self, Check, State};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
/// Where the launch sequence is. Rendered directly by the UI; the UI never
|
||||||
|
/// coordinates services itself.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Phase {
|
||||||
|
/// Nothing in flight. Readiness still comes from observed state, not from
|
||||||
|
/// having been here.
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
/// Looking at the world: checks + service + hook state.
|
||||||
|
Checking,
|
||||||
|
/// Elevated client preparation in flight (this is what shows a password
|
||||||
|
/// prompt).
|
||||||
|
PreparingClient,
|
||||||
|
StartingServices,
|
||||||
|
/// Re-checking after repair, before committing to a launch.
|
||||||
|
Validating,
|
||||||
|
Launching,
|
||||||
|
/// FIFA is up. Left when the process exits.
|
||||||
|
Running,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Phase {
|
||||||
|
/// Whether a launch is under way, i.e. the primary button must not start a
|
||||||
|
/// second one.
|
||||||
|
pub fn busy(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Phase::Checking
|
||||||
|
| Phase::PreparingClient
|
||||||
|
| Phase::StartingServices
|
||||||
|
| Phase::Validating
|
||||||
|
| Phase::Launching
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One step of the sequence, in execution order.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Step {
|
||||||
|
Server,
|
||||||
|
ClientFiles,
|
||||||
|
ClientPreparation,
|
||||||
|
Lsx,
|
||||||
|
Autopatch,
|
||||||
|
FinalChecks,
|
||||||
|
Game,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Step {
|
||||||
|
/// User-facing name. Deliberately not the internal vocabulary: "arm" is
|
||||||
|
/// implementation terminology and never appears in the normal flow.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Step::Server => "OpenFUT server",
|
||||||
|
Step::ClientFiles => "Client files",
|
||||||
|
Step::ClientPreparation => "Client preparation",
|
||||||
|
Step::Lsx => "LSX",
|
||||||
|
Step::Autopatch => "Autopatch",
|
||||||
|
Step::FinalChecks => "Final checks",
|
||||||
|
Step::Game => "FIFA 17",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a step ended. `Skipped` is a success that did nothing — the state it
|
||||||
|
/// would have produced was already true.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
Done(String),
|
||||||
|
Skipped(String),
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
pub fn ok(&self) -> bool {
|
||||||
|
!matches!(self, Outcome::Failed(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Outcome::Done(d) | Outcome::Skipped(d) | Outcome::Failed(d) => d,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the UI needs to render the launch surface.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LaunchState {
|
||||||
|
pub phase: Phase,
|
||||||
|
/// Steps attempted by the most recent run, in order.
|
||||||
|
pub steps: Vec<(Step, Outcome)>,
|
||||||
|
/// One-line reason the run failed, for the top of the failure card. The
|
||||||
|
/// per-step detail carries the specifics.
|
||||||
|
pub failure: Option<String>,
|
||||||
|
/// The most recent preflight results and when they were taken. Cached
|
||||||
|
/// because the checks open sockets with timeouts and cannot run per frame.
|
||||||
|
pub checks: Option<Vec<Check>>,
|
||||||
|
pub checks_age: Option<std::time::Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchState {
|
||||||
|
fn begin(&mut self, phase: Phase) {
|
||||||
|
self.phase = phase;
|
||||||
|
self.steps.clear();
|
||||||
|
self.failure = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, step: Step, outcome: Outcome) {
|
||||||
|
if let Outcome::Failed(reason) = &outcome {
|
||||||
|
self.failure = Some(format!("{}: {reason}", step.label()));
|
||||||
|
}
|
||||||
|
self.steps.push((step, outcome));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effects the sequence performs. Implemented for real by [`RealOps`] and
|
||||||
|
/// substituted in tests.
|
||||||
|
pub trait LaunchOps {
|
||||||
|
/// Confirm the configured OpenFUT server is answering AND select the account
|
||||||
|
/// for this session. The server is remote by design, so this is a network
|
||||||
|
/// fact, never "is something local up". Returns a user-facing summary.
|
||||||
|
fn connect_server(&mut self) -> Result<String, String>;
|
||||||
|
/// Version.dll + a readable openfut.cfg. `Err` is a hard stop: without them
|
||||||
|
/// FIFA talks to EA, not OpenFUT.
|
||||||
|
fn ensure_client_files(&mut self) -> Result<String, String>;
|
||||||
|
/// Which of the arming-repairable checks are currently failing.
|
||||||
|
fn run_checks(&mut self) -> Vec<Check>;
|
||||||
|
/// Elevated client preparation (`arm`). Returns what it changed.
|
||||||
|
fn prepare_client(&mut self) -> Result<Vec<String>, String>;
|
||||||
|
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String>;
|
||||||
|
fn start_game(&mut self) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks that client preparation is able to repair. A failure in any of these
|
||||||
|
/// means "prepare the client", not "give up".
|
||||||
|
fn preparation_repairs(check: &Check) -> bool {
|
||||||
|
const REPAIRABLE: [&str; 3] = [
|
||||||
|
"ptrace_scope (autopatch)",
|
||||||
|
"EA redirector IP is redirected",
|
||||||
|
"EA hostnames point at OpenFUT",
|
||||||
|
];
|
||||||
|
REPAIRABLE.contains(&check.name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the whole sequence, publishing progress into `state` as it goes.
|
||||||
|
///
|
||||||
|
/// Returns whether FIFA was started. Stops at the first failed step: launching
|
||||||
|
/// into a known-broken client produces a session that fails minutes later with
|
||||||
|
/// no message naming the cause, which is precisely the failure mode this
|
||||||
|
/// launcher exists to prevent.
|
||||||
|
pub fn run_sequence(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) -> bool {
|
||||||
|
macro_rules! step {
|
||||||
|
($phase:expr, $step:expr, $body:expr) => {{
|
||||||
|
state.lock().phase = $phase;
|
||||||
|
let outcome: Outcome = $body;
|
||||||
|
let ok = outcome.ok();
|
||||||
|
state.lock().record($step, outcome);
|
||||||
|
if !ok {
|
||||||
|
state.lock().phase = Phase::Failed;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lock().begin(Phase::Checking);
|
||||||
|
|
||||||
|
// ── The server, which is remote and not ours to start ────────────────────
|
||||||
|
step!(Phase::Checking, Step::Server, {
|
||||||
|
match ops.connect_server() {
|
||||||
|
Ok(detail) => Outcome::Done(detail),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The hook the game loads, reconciled with the current settings ────────
|
||||||
|
step!(Phase::Checking, Step::ClientFiles, {
|
||||||
|
match ops.ensure_client_files() {
|
||||||
|
Ok(detail) => Outcome::Done(detail),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Client preparation, only if something it repairs is broken ───────────
|
||||||
|
let checks = ops.run_checks();
|
||||||
|
let broken: Vec<String> = checks
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.state == State::Fail && preparation_repairs(c))
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let mut guard = state.lock();
|
||||||
|
guard.checks = Some(checks);
|
||||||
|
guard.checks_age = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
step!(Phase::PreparingClient, Step::ClientPreparation, {
|
||||||
|
if broken.is_empty() {
|
||||||
|
Outcome::Skipped("already prepared".into())
|
||||||
|
} else {
|
||||||
|
match ops.prepare_client() {
|
||||||
|
Ok(changes) => Outcome::Done(format!("{} change(s) applied", changes.len())),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Companion services, in dependency order ─────────────────────────────
|
||||||
|
for (service, step) in [
|
||||||
|
(Service::Lsx, Step::Lsx),
|
||||||
|
(Service::Autopatch, Step::Autopatch),
|
||||||
|
] {
|
||||||
|
step!(Phase::StartingServices, step, {
|
||||||
|
match ops.ensure_service(service) {
|
||||||
|
Ok(Ensured::Reused) => Outcome::Skipped("already running".into()),
|
||||||
|
Ok(Ensured::Started) => Outcome::Done("started".into()),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate what the repairs were supposed to fix ──────────────────────
|
||||||
|
step!(Phase::Validating, Step::FinalChecks, {
|
||||||
|
let checks = ops.run_checks();
|
||||||
|
let failed: Vec<String> = checks
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.state == State::Fail)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let mut guard = state.lock();
|
||||||
|
guard.checks = Some(checks);
|
||||||
|
guard.checks_age = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
if failed.is_empty() {
|
||||||
|
Outcome::Done("all checks pass".into())
|
||||||
|
} else {
|
||||||
|
Outcome::Failed(format!("still failing: {}", failed.join(", ")))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
step!(Phase::Launching, Step::Game, {
|
||||||
|
match ops.start_game() {
|
||||||
|
Ok(()) => Outcome::Done("started".into()),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.lock().phase = Phase::Running;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe the world without changing it, for the status rows on open and after
|
||||||
|
/// a settings change. Shares [`run_sequence`]'s notion of what "ready" means so
|
||||||
|
/// the two cannot drift apart.
|
||||||
|
pub fn refresh_checks(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) {
|
||||||
|
state.lock().phase = Phase::Checking;
|
||||||
|
let checks = ops.run_checks();
|
||||||
|
let mut guard = state.lock();
|
||||||
|
guard.checks = Some(checks);
|
||||||
|
guard.checks_age = Some(std::time::Instant::now());
|
||||||
|
guard.phase = Phase::Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens to launcher-started services when FIFA exits.
|
||||||
|
///
|
||||||
|
/// Exists so the answer is a stated policy rather than an oversight. The shipped
|
||||||
|
/// value stops nothing:
|
||||||
|
///
|
||||||
|
/// * The companion services are reusable across launches — LSX has to be holding
|
||||||
|
/// :4216 before FIFA dials it, and the next launch would only start them again.
|
||||||
|
/// * A service the launcher did NOT start is never in the stop list under any
|
||||||
|
/// value of this policy.
|
||||||
|
///
|
||||||
|
/// Client preparation is deliberately absent, and is never reverted: it is host
|
||||||
|
/// state (`ptrace_scope`, a DNAT, `/etc/hosts`) that `client_arm.sh` also leaves
|
||||||
|
/// set and that every subsequent launch needs. A flag for it would be a flag
|
||||||
|
/// nothing honours.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct CleanupPolicy {
|
||||||
|
pub stop_launcher_started_services: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which services cleanup is allowed to stop after `FIFA` exits: only ones this
|
||||||
|
/// launcher started, and only if the policy says so.
|
||||||
|
pub fn services_to_stop(
|
||||||
|
policy: CleanupPolicy,
|
||||||
|
runtimes: &[(Service, ServiceRuntime)],
|
||||||
|
) -> Vec<Service> {
|
||||||
|
if !policy.stop_launcher_started_services {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
runtimes
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, r)| r.running && r.started_by_launcher)
|
||||||
|
.map(|(s, _)| *s)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary of one dependency for the main card.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Readiness {
|
||||||
|
Ready,
|
||||||
|
Busy,
|
||||||
|
Attention,
|
||||||
|
/// Never looked, or the answer is stale. Never rendered as Ready.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-integration readiness from the cached checks. `Unknown` until a run has
|
||||||
|
/// actually happened: "we did not look" must not look like "we looked and it was
|
||||||
|
/// fine".
|
||||||
|
pub fn client_integration(state: &LaunchState) -> Readiness {
|
||||||
|
if matches!(state.phase, Phase::PreparingClient) {
|
||||||
|
return Readiness::Busy;
|
||||||
|
}
|
||||||
|
match &state.checks {
|
||||||
|
None => Readiness::Unknown,
|
||||||
|
Some(checks) => {
|
||||||
|
let relevant: Vec<&Check> = checks.iter().filter(|c| preparation_repairs(c)).collect();
|
||||||
|
if relevant.iter().any(|c| c.state == State::Fail) {
|
||||||
|
Readiness::Attention
|
||||||
|
} else if relevant.iter().all(|c| c.state == State::Skipped) {
|
||||||
|
// Nothing configured to check, so nothing was verified.
|
||||||
|
Readiness::Unknown
|
||||||
|
} else {
|
||||||
|
Readiness::Ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overall readiness for the card's headline pill. Anything short of every
|
||||||
|
/// dependency being observed-good is not Ready.
|
||||||
|
pub fn overall(
|
||||||
|
phase: Phase,
|
||||||
|
server: Readiness,
|
||||||
|
integration: Readiness,
|
||||||
|
services: Readiness,
|
||||||
|
hook: Readiness,
|
||||||
|
) -> Readiness {
|
||||||
|
if phase == Phase::Running {
|
||||||
|
return Readiness::Ready;
|
||||||
|
}
|
||||||
|
if phase.busy() {
|
||||||
|
return Readiness::Busy;
|
||||||
|
}
|
||||||
|
let parts = [server, integration, services, hook];
|
||||||
|
if parts.contains(&Readiness::Attention) {
|
||||||
|
Readiness::Attention
|
||||||
|
} else if parts.contains(&Readiness::Unknown) {
|
||||||
|
Readiness::Unknown
|
||||||
|
} else {
|
||||||
|
Readiness::Ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`LaunchOps`] against the actual machine.
|
||||||
|
///
|
||||||
|
/// Holds a snapshot of the config: a launch must not change its mind halfway
|
||||||
|
/// through because the user edited a field while it ran.
|
||||||
|
pub struct RealOps {
|
||||||
|
config: LauncherConfig,
|
||||||
|
services: Arc<Mutex<ServiceSupervisor>>,
|
||||||
|
logs: Arc<Mutex<LogBuffer>>,
|
||||||
|
caps: Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
|
state: Arc<Mutex<LaunchState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealOps {
|
||||||
|
fn say(&self, message: impl Into<String>) {
|
||||||
|
self.logs.lock().push(message.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchOps for RealOps {
|
||||||
|
fn connect_server(&mut self) -> Result<String, String> {
|
||||||
|
self.config.validate_server()?;
|
||||||
|
if preflight::backend_reachable(&self.config).state == State::Fail {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is not answering — is the OpenFUT server running?",
|
||||||
|
self.config.openfut_server_host
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Selecting the account is part of connecting: LSX and FIFA both
|
||||||
|
// authenticate as this persona, and a launch with the wrong one produces
|
||||||
|
// a session that looks fine and belongs to nobody.
|
||||||
|
let account = crate::account_sync::sync(&self.config)?;
|
||||||
|
self.say(format!(
|
||||||
|
"[launcher] account synchronized: {}/{} FUT-coins={} unopened-packs={}",
|
||||||
|
account.persona_id, account.persona_name, account.coins, account.unopened_packs
|
||||||
|
));
|
||||||
|
Ok(format!(
|
||||||
|
"{} · {}",
|
||||||
|
self.config.openfut_server_host, account.persona_name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_client_files(&mut self) -> Result<String, String> {
|
||||||
|
let game_dir = std::path::PathBuf::from(&self.config.fifa_game_dir);
|
||||||
|
if !crate::setup::hook_dll_deployed(&game_dir) {
|
||||||
|
return Err("network hook is not deployed — use Setup to deploy it".into());
|
||||||
|
}
|
||||||
|
// The file the game reads is reconciled here, and only here: this is the
|
||||||
|
// one moment it is guaranteed to agree with the settings on screen.
|
||||||
|
let contents = self.config.hook_cfg_contents()?;
|
||||||
|
crate::setup::update_hook_config(&game_dir, &contents).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"cannot write {} in {}: {e}",
|
||||||
|
crate::setup::HOOK_CFG_FILE,
|
||||||
|
self.config.fifa_game_dir
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(format!(
|
||||||
|
"hook → {}:{}",
|
||||||
|
self.config.openfut_server_host, self.config.openfut_https_port
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_checks(&mut self) -> Vec<Check> {
|
||||||
|
preflight::run(&self.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
|
||||||
|
match crate::arm::arm(&self.config) {
|
||||||
|
Ok(changes) => {
|
||||||
|
for change in &changes {
|
||||||
|
self.say(format!("[launcher] prepared: {change}"));
|
||||||
|
}
|
||||||
|
Ok(changes)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
|
||||||
|
let spec = SpawnSpec {
|
||||||
|
persona_id: self.config.fut_persona_id,
|
||||||
|
persona_name: self.config.fut_persona_name.clone(),
|
||||||
|
// Only autopatch advertises the verified resolver guard, so only it
|
||||||
|
// receives the shared capability sink.
|
||||||
|
capability: match service {
|
||||||
|
Service::Autopatch => Some(CapabilityWiring {
|
||||||
|
server_host: self.config.openfut_server_host.clone(),
|
||||||
|
account_sync_port: self.config.openfut_account_sync_port,
|
||||||
|
sink: Arc::clone(&self.caps),
|
||||||
|
}),
|
||||||
|
Service::Lsx => None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.services.lock().ensure_running(service, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_game(&mut self) -> Result<(), String> {
|
||||||
|
// A new FIFA process starts with UNKNOWN capability: never inherit the
|
||||||
|
// previous launch's. The autopatch stdout reader repopulates it.
|
||||||
|
*self.caps.lock() = Default::default();
|
||||||
|
|
||||||
|
let state = Arc::clone(&self.state);
|
||||||
|
let logs = Arc::clone(&self.logs);
|
||||||
|
let services = Arc::clone(&self.services);
|
||||||
|
let on_exit = move || {
|
||||||
|
// Cleanup goes through the policy rather than through habit, so the
|
||||||
|
// list can never include a service this launcher did not start.
|
||||||
|
let runtimes: Vec<_> = {
|
||||||
|
let mut supervisor = services.lock();
|
||||||
|
[Service::Lsx, Service::Autopatch]
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| {
|
||||||
|
let runtime = supervisor.observe(s);
|
||||||
|
(s, runtime)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for service in services_to_stop(CleanupPolicy::default(), &runtimes) {
|
||||||
|
if let Err(e) = services.lock().stop(service) {
|
||||||
|
logs.lock().push(format!("[launcher] cleanup: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.lock().phase = Phase::Idle;
|
||||||
|
logs.lock()
|
||||||
|
.push("[launcher] FIFA exited; launcher back to Ready.".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prefer the native profile; fall back to the user's shell command so an
|
||||||
|
// existing working setup keeps working after an upgrade.
|
||||||
|
if self.config.game_profile.configured() {
|
||||||
|
crate::game_launch::launch(&self.config.game_profile, &self.logs, on_exit)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
crate::setup::launch_game(
|
||||||
|
&self.config.game_launch_command,
|
||||||
|
&self.config.game_launch_workdir,
|
||||||
|
Arc::clone(&self.logs),
|
||||||
|
on_exit,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives [`run_sequence`] on a worker thread. The UI thread never blocks on a
|
||||||
|
/// socket, a Polkit prompt or a process spawn.
|
||||||
|
pub struct Controller {
|
||||||
|
pub state: Arc<Mutex<LaunchState>>,
|
||||||
|
pub services: Arc<Mutex<ServiceSupervisor>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Controller {
|
||||||
|
pub fn new(logs: Arc<Mutex<LogBuffer>>) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Arc::new(Mutex::new(LaunchState::default())),
|
||||||
|
services: Arc::new(Mutex::new(ServiceSupervisor::new(logs))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> LaunchState {
|
||||||
|
self.state.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ops(
|
||||||
|
&self,
|
||||||
|
config: &LauncherConfig,
|
||||||
|
logs: &Arc<Mutex<LogBuffer>>,
|
||||||
|
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
|
) -> RealOps {
|
||||||
|
RealOps {
|
||||||
|
config: config.clone(),
|
||||||
|
services: Arc::clone(&self.services),
|
||||||
|
logs: Arc::clone(logs),
|
||||||
|
caps: Arc::clone(caps),
|
||||||
|
state: Arc::clone(&self.state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the full sequence. Ignored while one is already in flight or the
|
||||||
|
/// game is up — the button reflects that state rather than queueing work.
|
||||||
|
pub fn launch(
|
||||||
|
&self,
|
||||||
|
config: &LauncherConfig,
|
||||||
|
logs: &Arc<Mutex<LogBuffer>>,
|
||||||
|
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
let phase = self.state.lock().phase;
|
||||||
|
if phase.busy() || phase == Phase::Running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ops = self.ops(config, logs, caps);
|
||||||
|
let state = Arc::clone(&self.state);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
run_sequence(&mut ops, &state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-observe without changing anything, for startup and after a settings
|
||||||
|
/// change. Skipped while a launch owns the state.
|
||||||
|
pub fn refresh(
|
||||||
|
&self,
|
||||||
|
config: &LauncherConfig,
|
||||||
|
logs: &Arc<Mutex<LogBuffer>>,
|
||||||
|
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
let phase = self.state.lock().phase;
|
||||||
|
if phase.busy() || phase == Phase::Running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ops = self.ops(config, logs, caps);
|
||||||
|
let state = Arc::clone(&self.state);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
refresh_checks(&mut ops, &state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Records what the sequence asked for, and answers however the test wants.
|
||||||
|
#[derive(Default)]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
struct FakeOps {
|
||||||
|
server_up: bool,
|
||||||
|
client_files: Option<Result<String, String>>,
|
||||||
|
checks: Vec<Check>,
|
||||||
|
checks_after_prepare: Option<Vec<Check>>,
|
||||||
|
prepare_result: Option<Result<Vec<String>, String>>,
|
||||||
|
service_result: Vec<(Service, Result<Ensured, String>)>,
|
||||||
|
game_result: Option<Result<(), String>>,
|
||||||
|
// Observed calls
|
||||||
|
prepared: usize,
|
||||||
|
started: Vec<Service>,
|
||||||
|
game_started: usize,
|
||||||
|
check_runs: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(name: &str, state: State) -> Check {
|
||||||
|
Check {
|
||||||
|
name: name.into(),
|
||||||
|
state,
|
||||||
|
detail: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ready_ops() -> FakeOps {
|
||||||
|
FakeOps {
|
||||||
|
server_up: true,
|
||||||
|
client_files: Some(Ok("deployed".into())),
|
||||||
|
checks: vec![
|
||||||
|
check("ptrace_scope (autopatch)", State::Pass),
|
||||||
|
check("EA redirector IP is redirected", State::Pass),
|
||||||
|
check("EA hostnames point at OpenFUT", State::Pass),
|
||||||
|
],
|
||||||
|
prepare_result: Some(Ok(vec!["one".into()])),
|
||||||
|
game_result: Some(Ok(())),
|
||||||
|
..FakeOps::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchOps for FakeOps {
|
||||||
|
fn connect_server(&mut self) -> Result<String, String> {
|
||||||
|
if self.server_up {
|
||||||
|
Ok("connected".into())
|
||||||
|
} else {
|
||||||
|
Err("not reachable — is the OpenFUT server running?".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn ensure_client_files(&mut self) -> Result<String, String> {
|
||||||
|
self.client_files
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| Err("no client-files result configured".into()))
|
||||||
|
}
|
||||||
|
fn run_checks(&mut self) -> Vec<Check> {
|
||||||
|
self.check_runs += 1;
|
||||||
|
match (&self.checks_after_prepare, self.prepared) {
|
||||||
|
(Some(after), n) if n > 0 => after.clone(),
|
||||||
|
_ => self.checks.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
|
||||||
|
self.prepared += 1;
|
||||||
|
self.prepare_result
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| Err("no prepare configured".into()))
|
||||||
|
}
|
||||||
|
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
|
||||||
|
self.started.push(service);
|
||||||
|
self.service_result
|
||||||
|
.iter()
|
||||||
|
.find(|(s, _)| *s == service)
|
||||||
|
.map(|(_, r)| r.clone())
|
||||||
|
.unwrap_or(Ok(Ensured::Started))
|
||||||
|
}
|
||||||
|
fn start_game(&mut self) -> Result<(), String> {
|
||||||
|
self.game_started += 1;
|
||||||
|
self.game_result
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| Err("no game result configured".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state() -> Arc<Mutex<LaunchState>> {
|
||||||
|
Arc::new(Mutex::new(LaunchState::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cold_client_is_prepared_and_started_in_dependency_order() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
|
||||||
|
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Pass)]),
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(
|
||||||
|
ops.prepared, 1,
|
||||||
|
"a failing repairable check must be repaired"
|
||||||
|
);
|
||||||
|
// Preparation before autopatch: autopatch cannot write FIFA's memory
|
||||||
|
// until arming has set ptrace_scope, and would silently no-op.
|
||||||
|
assert_eq!(ops.started, vec![Service::Lsx, Service::Autopatch]);
|
||||||
|
assert_eq!(ops.game_started, 1);
|
||||||
|
assert_eq!(st.lock().phase, Phase::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_already_prepared_client_is_not_prepared_again() {
|
||||||
|
let mut ops = ready_ops();
|
||||||
|
let st = state();
|
||||||
|
assert!(run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.prepared, 0, "no password prompt for work already done");
|
||||||
|
let steps = &st.lock().steps;
|
||||||
|
let prep = steps
|
||||||
|
.iter()
|
||||||
|
.find(|(s, _)| *s == Step::ClientPreparation)
|
||||||
|
.expect("preparation step recorded")
|
||||||
|
.1
|
||||||
|
.clone();
|
||||||
|
assert!(matches!(prep, Outcome::Skipped(_)), "{prep:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn healthy_services_are_reused_rather_than_restarted() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
service_result: vec![
|
||||||
|
(Service::Lsx, Ok(Ensured::Reused)),
|
||||||
|
(Service::Autopatch, Ok(Ensured::Reused)),
|
||||||
|
],
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(run_sequence(&mut ops, &st));
|
||||||
|
for step in [Step::Lsx, Step::Autopatch] {
|
||||||
|
let outcome = st
|
||||||
|
.lock()
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|(s, _)| *s == step)
|
||||||
|
.expect("service step recorded")
|
||||||
|
.1
|
||||||
|
.clone();
|
||||||
|
assert!(
|
||||||
|
matches!(outcome, Outcome::Skipped(_)),
|
||||||
|
"{step:?} {outcome:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(ops.game_started, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreachable_server_stops_the_launch_before_anything_is_touched() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
server_up: false,
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.prepared, 0);
|
||||||
|
assert!(ops.started.is_empty(), "nothing may be started");
|
||||||
|
assert_eq!(ops.game_started, 0);
|
||||||
|
assert_eq!(st.lock().phase, Phase::Failed);
|
||||||
|
assert!(st.lock().failure.as_deref().unwrap().contains("server"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_service_that_fails_to_start_stops_the_launch() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
service_result: vec![(Service::Autopatch, Err("autopatch: boom".into()))],
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.game_started, 0, "FIFA must not start without autopatch");
|
||||||
|
let failure = st.lock().failure.clone().unwrap();
|
||||||
|
assert!(failure.contains("Autopatch"), "{failure}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_client_preparation_stops_the_launch() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
|
||||||
|
prepare_result: Some(Err("pkexec: dismissed".into())),
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert!(ops.started.is_empty());
|
||||||
|
assert_eq!(ops.game_started, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_check_still_failing_after_repair_stops_the_launch() {
|
||||||
|
// Preparation ran and claimed success, but the state it was supposed to
|
||||||
|
// fix is still broken. Launching here is how a session dies later with
|
||||||
|
// no message naming the cause.
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
|
||||||
|
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Fail)]),
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.game_started, 0);
|
||||||
|
let failure = st.lock().failure.clone().unwrap();
|
||||||
|
assert!(failure.contains("still failing"), "{failure}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_files_failure_stops_the_launch() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
client_files: Some(Err("cannot write openfut.cfg".into())),
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.game_started, 0);
|
||||||
|
assert!(ops.started.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_never_stops_a_service_the_launcher_did_not_start() {
|
||||||
|
let foreign = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: false,
|
||||||
|
pid: Some(4242),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
let ours = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: true,
|
||||||
|
pid: Some(99),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
let runtimes = [(Service::Lsx, foreign), (Service::Autopatch, ours)];
|
||||||
|
|
||||||
|
// Even under the most aggressive policy, a foreign service is untouched.
|
||||||
|
let aggressive = CleanupPolicy {
|
||||||
|
stop_launcher_started_services: true,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
services_to_stop(aggressive, &runtimes),
|
||||||
|
vec![Service::Autopatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the shipped policy keeps both alive for the next launch.
|
||||||
|
assert!(services_to_stop(CleanupPolicy::default(), &runtimes).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readiness_is_never_green_while_a_dependency_is_not() {
|
||||||
|
assert_eq!(
|
||||||
|
overall(
|
||||||
|
Phase::Idle,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Attention,
|
||||||
|
Readiness::Ready
|
||||||
|
),
|
||||||
|
Readiness::Attention
|
||||||
|
);
|
||||||
|
// Never checked is not the same as checked and fine.
|
||||||
|
assert_eq!(
|
||||||
|
overall(
|
||||||
|
Phase::Idle,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Unknown,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready
|
||||||
|
),
|
||||||
|
Readiness::Unknown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
overall(
|
||||||
|
Phase::Idle,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready
|
||||||
|
),
|
||||||
|
Readiness::Ready
|
||||||
|
);
|
||||||
|
// A running game reports Ready even though a launch is not in flight.
|
||||||
|
assert_eq!(
|
||||||
|
overall(
|
||||||
|
Phase::Running,
|
||||||
|
Readiness::Unknown,
|
||||||
|
Readiness::Unknown,
|
||||||
|
Readiness::Unknown,
|
||||||
|
Readiness::Unknown
|
||||||
|
),
|
||||||
|
Readiness::Ready
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_integration_is_unknown_until_checks_have_run() {
|
||||||
|
let mut st = LaunchState::default();
|
||||||
|
assert_eq!(client_integration(&st), Readiness::Unknown);
|
||||||
|
|
||||||
|
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Fail)]);
|
||||||
|
assert_eq!(client_integration(&st), Readiness::Attention);
|
||||||
|
|
||||||
|
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Pass)]);
|
||||||
|
assert_eq!(client_integration(&st), Readiness::Ready);
|
||||||
|
|
||||||
|
// Only skipped checks means nothing was actually verified.
|
||||||
|
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Skipped)]);
|
||||||
|
assert_eq!(client_integration(&st), Readiness::Unknown);
|
||||||
|
}
|
||||||
|
}
|
||||||
+428
-79
@@ -13,11 +13,12 @@
|
|||||||
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
|
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
|
||||||
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
|
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::{
|
use std::{
|
||||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
||||||
path::Path,
|
path::{Path, PathBuf},
|
||||||
process::{Child, Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
sync::{mpsc, Arc, Mutex},
|
sync::{mpsc, Arc},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ use crate::fifa17_capability::{
|
|||||||
};
|
};
|
||||||
use crate::logs::LogBuffer;
|
use crate::logs::LogBuffer;
|
||||||
|
|
||||||
|
/// The loopback endpoint LSX must own. FIFA dials this exact address and nothing
|
||||||
|
/// else, so "is LSX ready?" is answerable without asking LSX anything.
|
||||||
|
pub const LSX_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
struct CommandParts {
|
struct CommandParts {
|
||||||
program: String,
|
program: String,
|
||||||
@@ -35,7 +40,7 @@ struct CommandParts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Which companion service. The `str` values are used in log prefixes.
|
/// Which companion service. The `str` values are used in log prefixes.
|
||||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum Service {
|
pub enum Service {
|
||||||
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
||||||
Lsx,
|
Lsx,
|
||||||
@@ -51,25 +56,48 @@ impl Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The responder script filename inside the tools dir.
|
/// The companion's executable name.
|
||||||
fn script(self) -> &'static str {
|
///
|
||||||
|
/// These were Python responder scripts run through a configured interpreter. They
|
||||||
|
/// are now Rust binaries built from this workspace (`openfut-lsx`,
|
||||||
|
/// `openfut-autopatch`), which removes the interpreter and the tools directory
|
||||||
|
/// from the launch contract entirely: no `python3` to locate, no script path to
|
||||||
|
/// configure, and no chance of running a stale checkout's copy.
|
||||||
|
fn binary(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Service::Lsx => "lsx_responder_v2.py",
|
Service::Lsx => "openfut-lsx",
|
||||||
Service::Autopatch => "autopatch.py",
|
Service::Autopatch => "openfut-autopatch",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
|
/// Absolute path to a companion binary.
|
||||||
let mut args = vec![tools_dir
|
///
|
||||||
.join(service.script())
|
/// Prefers a sibling of the running launcher, which is what a workspace build and any
|
||||||
.to_string_lossy()
|
/// sane install layout both produce, and falls back to the bare name so a
|
||||||
.into_owned()];
|
/// PATH-installed binary still works. Returning the bare name rather than failing
|
||||||
|
/// keeps `spawn` responsible for reporting a missing binary, with one error message
|
||||||
|
/// instead of two.
|
||||||
|
fn resolve_binary(service: Service) -> PathBuf {
|
||||||
|
let name = service.binary();
|
||||||
|
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(Path::to_path_buf)) {
|
||||||
|
let sibling = dir.join(name);
|
||||||
|
if sibling.is_file() {
|
||||||
|
return sibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_parts(service: Service) -> CommandParts {
|
||||||
|
let mut args = Vec::new();
|
||||||
if service == Service::Autopatch {
|
if service == Service::Autopatch {
|
||||||
|
// autopatch exits when the launcher does, so it cannot outlive its owner and
|
||||||
|
// keep writing to a client the launcher no longer manages.
|
||||||
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
|
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
|
||||||
}
|
}
|
||||||
CommandParts {
|
CommandParts {
|
||||||
program: python.to_string(),
|
program: resolve_binary(service).to_string_lossy().into_owned(),
|
||||||
args,
|
args,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,22 +166,19 @@ impl ManagedService {
|
|||||||
if let Some(result) = self.stopping.as_ref() {
|
if let Some(result) = self.stopping.as_ref() {
|
||||||
match result.try_recv() {
|
match result.try_recv() {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
log.lock()
|
log.lock().push(format!("[launcher] {label} stopped."));
|
||||||
.unwrap()
|
|
||||||
.push(format!("[launcher] {label} stopped."));
|
|
||||||
self.stopping = None;
|
self.stopping = None;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
Ok(Err(error)) => {
|
Ok(Err(error)) => {
|
||||||
log.lock()
|
log.lock()
|
||||||
.unwrap()
|
|
||||||
.push(format!("[launcher] failed to stop {label}: {error}"));
|
.push(format!("[launcher] failed to stop {label}: {error}"));
|
||||||
self.stopping = None;
|
self.stopping = None;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
Err(mpsc::TryRecvError::Empty) => return true,
|
Err(mpsc::TryRecvError::Empty) => return true,
|
||||||
Err(mpsc::TryRecvError::Disconnected) => {
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
log.lock().unwrap().push(format!(
|
log.lock().push(format!(
|
||||||
"[launcher] {label} stop worker exited unexpectedly."
|
"[launcher] {label} stop worker exited unexpectedly."
|
||||||
));
|
));
|
||||||
self.stopping = None;
|
self.stopping = None;
|
||||||
@@ -168,7 +193,6 @@ impl ManagedService {
|
|||||||
Ok(None) => true,
|
Ok(None) => true,
|
||||||
Ok(Some(status)) => {
|
Ok(Some(status)) => {
|
||||||
log.lock()
|
log.lock()
|
||||||
.unwrap()
|
|
||||||
.push(format!("[launcher] {label} exited ({status})."));
|
.push(format!("[launcher] {label} exited ({status})."));
|
||||||
self.child = None;
|
self.child = None;
|
||||||
false
|
false
|
||||||
@@ -182,6 +206,11 @@ impl ManagedService {
|
|||||||
self.stopping.is_some()
|
self.stopping.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PID of the child this launcher owns, if it owns one.
|
||||||
|
pub fn pid(&self) -> Option<u32> {
|
||||||
|
self.child.as_ref().map(Child::id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Begin stopping the service without waiting on the egui UI thread.
|
/// Begin stopping the service without waiting on the egui UI thread.
|
||||||
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
|
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
|
||||||
if self.stopping.is_some() {
|
if self.stopping.is_some() {
|
||||||
@@ -189,9 +218,7 @@ impl ManagedService {
|
|||||||
}
|
}
|
||||||
if let Some(mut child) = self.child.take() {
|
if let Some(mut child) = self.child.take() {
|
||||||
let label = service.label();
|
let label = service.label();
|
||||||
log.lock()
|
log.lock().push(format!("[launcher] stopping {label}…"));
|
||||||
.unwrap()
|
|
||||||
.push(format!("[launcher] stopping {label}…"));
|
|
||||||
|
|
||||||
self.stopping = Some(dispatch_stop_work(move || {
|
self.stopping = Some(dispatch_stop_work(move || {
|
||||||
child
|
child
|
||||||
@@ -224,17 +251,248 @@ pub struct CapabilityWiring {
|
|||||||
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
|
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
|
/// What is actually true about one companion service right now.
|
||||||
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
|
///
|
||||||
/// Returns an error (without spawning) if the tools dir or script is missing.
|
/// Deliberately observed, never remembered: a button press is not evidence that
|
||||||
|
/// a service is up, and a service that died on its own must not keep showing
|
||||||
|
/// green because the launcher once started it successfully.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct ServiceRuntime {
|
||||||
|
pub running: bool,
|
||||||
|
/// True only while THIS launcher owns the live process. Decides whether
|
||||||
|
/// cleanup is allowed to touch it: a service someone started by hand for a
|
||||||
|
/// debugging session must survive a launch/exit cycle.
|
||||||
|
pub started_by_launcher: bool,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
/// Observed supporting detail for the Advanced panel. Only ever facts the
|
||||||
|
/// launcher actually established.
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceRuntime {
|
||||||
|
/// Whether this service is usable for a launch, as opposed to merely alive.
|
||||||
|
/// For LSX that means the port FIFA dials is genuinely held.
|
||||||
|
pub fn ready(&self) -> bool {
|
||||||
|
self.running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when something holds LSX's fixed loopback port.
|
||||||
|
pub fn lsx_port_busy() -> bool {
|
||||||
|
match TcpListener::bind(LSX_ADDR) {
|
||||||
|
Err(error) => error.kind() == std::io::ErrorKind::AddrInUse,
|
||||||
|
Ok(listener) => {
|
||||||
|
drop(listener);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PID of a process running `service`'s companion binary that this launcher does
|
||||||
|
/// not own, if there is one.
|
||||||
|
///
|
||||||
|
/// Scans `/proc` — no extra dependency, no privilege, and no guessing: a service
|
||||||
|
/// left running by a previous launcher instance or started by hand from a shell
|
||||||
|
/// is a real state the UI has to be able to report, and cleanup has to respect.
|
||||||
|
///
|
||||||
|
/// Matches argv entries rather than `comm`, because `comm` is truncated to 15
|
||||||
|
/// characters by the kernel and would misreport these names.
|
||||||
|
pub fn foreign_pid(service: Service, ours: Option<u32>) -> Option<u32> {
|
||||||
|
let binary = service.binary();
|
||||||
|
let self_pid = std::process::id();
|
||||||
|
let entries = std::fs::read_dir("/proc").ok()?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if pid == self_pid || Some(pid) == ours {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(cmdline) = std::fs::read(entry.path().join("cmdline")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if cmdline.split(|b| *b == 0).any(|arg| {
|
||||||
|
// Compare the file name, so `/path/to/openfut-lsx` matches while an
|
||||||
|
// unrelated argument that merely ends with the same text does not.
|
||||||
|
Path::new(&*String::from_utf8_lossy(arg))
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|n| n == binary)
|
||||||
|
}) {
|
||||||
|
return Some(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a stop request may touch this service.
|
||||||
|
///
|
||||||
|
/// Pure, so the ownership rule is testable without a process: refusing to kill
|
||||||
|
/// something the launcher did not start is the whole reason ownership is tracked,
|
||||||
|
/// and it must not depend on what happens to be running on the test machine.
|
||||||
|
pub fn stop_permitted(runtime: &ServiceRuntime, label: &str) -> Result<(), String> {
|
||||||
|
if runtime.running && !runtime.started_by_launcher {
|
||||||
|
return Err(format!(
|
||||||
|
"{label} was started outside this launcher{} — stop it where it was started.",
|
||||||
|
match runtime.pid {
|
||||||
|
Some(pid) => format!(" (pid {pid})"),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns both companion services and answers "what is running, and who started
|
||||||
|
/// it?" for the whole launcher.
|
||||||
|
///
|
||||||
|
/// Exists so the launch sequence and the Advanced panel act on the same objects.
|
||||||
|
/// Two independent copies of that state is how a UI ends up claiming Ready while
|
||||||
|
/// the process is dead.
|
||||||
|
pub struct ServiceSupervisor {
|
||||||
|
lsx: ManagedService,
|
||||||
|
autopatch: ManagedService,
|
||||||
|
log: Arc<Mutex<LogBuffer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [`ServiceSupervisor::ensure_running`] had to do anything.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Ensured {
|
||||||
|
/// Already up — left strictly alone.
|
||||||
|
Reused,
|
||||||
|
Started,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceSupervisor {
|
||||||
|
pub fn new(log: Arc<Mutex<LogBuffer>>) -> Self {
|
||||||
|
Self {
|
||||||
|
lsx: ManagedService::default(),
|
||||||
|
autopatch: ManagedService::default(),
|
||||||
|
log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot(&mut self, service: Service) -> &mut ManagedService {
|
||||||
|
match service {
|
||||||
|
Service::Lsx => &mut self.lsx,
|
||||||
|
Service::Autopatch => &mut self.autopatch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe one service: our own child first, then any foreign instance.
|
||||||
|
pub fn observe(&mut self, service: Service) -> ServiceRuntime {
|
||||||
|
let log = Arc::clone(&self.log);
|
||||||
|
let slot = self.slot(service);
|
||||||
|
if slot.stopping() {
|
||||||
|
return ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: true,
|
||||||
|
pid: None,
|
||||||
|
detail: Some("stopping".into()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let ours = slot.pid();
|
||||||
|
if slot.running(&log, service.label()) {
|
||||||
|
let mut runtime = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: true,
|
||||||
|
pid: ours,
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
if service == Service::Lsx {
|
||||||
|
runtime.detail = Some(if lsx_port_busy() {
|
||||||
|
format!("holding {LSX_ADDR}")
|
||||||
|
} else {
|
||||||
|
// Alive but not listening: real, and not "ready".
|
||||||
|
runtime.running = false;
|
||||||
|
format!("process alive but {LSX_ADDR} is not held")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
match foreign_pid(service, ours) {
|
||||||
|
Some(pid) => ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: false,
|
||||||
|
pid: Some(pid),
|
||||||
|
detail: Some("started outside this launcher".into()),
|
||||||
|
},
|
||||||
|
None if service == Service::Lsx && lsx_port_busy() => ServiceRuntime {
|
||||||
|
running: false,
|
||||||
|
started_by_launcher: false,
|
||||||
|
pid: None,
|
||||||
|
detail: Some(format!("{LSX_ADDR} is held by an unrelated process")),
|
||||||
|
},
|
||||||
|
None => ServiceRuntime::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
let runtime = self.observe(service);
|
||||||
|
if runtime.ready() {
|
||||||
|
self.log.lock().push(format!(
|
||||||
|
"[launcher] {} already running{} — reusing it.",
|
||||||
|
service.label(),
|
||||||
|
match runtime.pid {
|
||||||
|
Some(pid) => format!(" (pid {pid})"),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
return Ok(Ensured::Reused);
|
||||||
|
}
|
||||||
|
if let Some(detail) = runtime.detail.filter(|_| !runtime.running) {
|
||||||
|
// No service-name prefix: every caller already renders the service it
|
||||||
|
// asked about, and the launch card would print "LSX: LSX: …".
|
||||||
|
return Err(detail);
|
||||||
|
}
|
||||||
|
let child = spawn(
|
||||||
|
service,
|
||||||
|
spec.persona_id,
|
||||||
|
&spec.persona_name,
|
||||||
|
spec.capability,
|
||||||
|
Arc::clone(&self.log),
|
||||||
|
)
|
||||||
|
.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
|
||||||
|
/// killed: the launcher did not start it and does not know who needs it.
|
||||||
|
pub fn stop(&mut self, service: Service) -> Result<(), String> {
|
||||||
|
let runtime = self.observe(service);
|
||||||
|
stop_permitted(&runtime, service.label())?;
|
||||||
|
let log = Arc::clone(&self.log);
|
||||||
|
self.slot(service).stop(&log, service);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stopping(&mut self, service: Service) -> bool {
|
||||||
|
self.slot(service).stopping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything [`spawn`] needs, bundled so the launch sequence can hand it over
|
||||||
|
/// as one value per service.
|
||||||
|
pub struct SpawnSpec {
|
||||||
|
pub persona_id: u64,
|
||||||
|
pub persona_name: String,
|
||||||
|
pub capability: Option<CapabilityWiring>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a companion service and stream its stdout+stderr into `log`.
|
||||||
|
///
|
||||||
|
/// Returns an error without spawning if the binary is missing, which is the only
|
||||||
|
/// precondition left now that the companions are workspace binaries rather than
|
||||||
|
/// Python scripts run from a configured tools directory.
|
||||||
///
|
///
|
||||||
/// `capability` is the backend-registration wiring + shared per-FIFA-process
|
/// `capability` is the backend-registration wiring + shared per-FIFA-process
|
||||||
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
|
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
|
||||||
/// verified resolver guard) and `None` for LSX.
|
/// verified resolver guard) and `None` for LSX.
|
||||||
pub fn spawn(
|
pub fn spawn(
|
||||||
service: Service,
|
service: Service,
|
||||||
python: &str,
|
|
||||||
tools_dir: &str,
|
|
||||||
persona_id: u64,
|
persona_id: u64,
|
||||||
persona_name: &str,
|
persona_name: &str,
|
||||||
capability: Option<CapabilityWiring>,
|
capability: Option<CapabilityWiring>,
|
||||||
@@ -242,35 +500,28 @@ pub fn spawn(
|
|||||||
) -> anyhow::Result<Child> {
|
) -> anyhow::Result<Child> {
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
let dir = Path::new(tools_dir);
|
|
||||||
if !dir.is_dir() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"FIFA 17 tools dir not found: {} (set it in the Config tab)",
|
|
||||||
dir.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let script_path = dir.join(service.script());
|
|
||||||
if !script_path.exists() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"{} not found in tools dir: {}",
|
|
||||||
service.script(),
|
|
||||||
script_path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let label = service.label();
|
let label = service.label();
|
||||||
|
let parts = command_parts(service);
|
||||||
|
let program = Path::new(&parts.program);
|
||||||
|
// Only a resolved absolute path can be checked up front; a bare name is left to
|
||||||
|
// the OS to resolve through PATH, and a failure there is reported by spawn below.
|
||||||
|
if program.is_absolute() && !program.is_file() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"{label} binary not found: {} — build the workspace so it sits beside the launcher",
|
||||||
|
program.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Both services use the configured interpreter and absolute script path;
|
|
||||||
// neither invents a Python installation path. Autopatch receives launcher
|
|
||||||
// ownership and a per-user runtime log so stale root-owned /tmp files cannot
|
|
||||||
// block startup.
|
|
||||||
let parts = command_parts(service, python, dir);
|
|
||||||
let mut cmd = Command::new(&parts.program);
|
let mut cmd = Command::new(&parts.program);
|
||||||
cmd.args(&parts.args);
|
cmd.args(&parts.args);
|
||||||
if service == Service::Lsx {
|
if service == Service::Lsx {
|
||||||
|
// The persona LSX reports has to equal what Blaze returns in
|
||||||
|
// LoginResponse.SESS.PDTL and what UTAS serves as userInfo.personaId; the
|
||||||
|
// constraint is cross-layer agreement, not any particular value.
|
||||||
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
|
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
|
||||||
.env("FUT_PERSONA_NAME", persona_name);
|
.env("FUT_PERSONA_NAME", persona_name);
|
||||||
} else if service == Service::Autopatch {
|
} else if service == Service::Autopatch {
|
||||||
|
// A per-user runtime log, so a stale root-owned /tmp file cannot block startup.
|
||||||
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
|
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
|
||||||
.map(std::path::PathBuf::from)
|
.map(std::path::PathBuf::from)
|
||||||
.unwrap_or_else(std::env::temp_dir)
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
@@ -279,19 +530,24 @@ pub fn spawn(
|
|||||||
}
|
}
|
||||||
// Put each companion in its own process group for lifecycle isolation.
|
// Put each companion in its own process group for lifecycle isolation.
|
||||||
cmd.process_group(0);
|
cmd.process_group(0);
|
||||||
cmd.current_dir(dir)
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped());
|
|
||||||
|
|
||||||
log.lock().unwrap().push(format!(
|
log.lock().push(format!(
|
||||||
"[launcher] starting {label}: {} {}",
|
"[launcher] starting {label}: {}{}",
|
||||||
python,
|
parts.program,
|
||||||
script_path.display(),
|
parts
|
||||||
|
.args
|
||||||
|
.iter()
|
||||||
|
.fold(String::new(), |mut acc, a| {
|
||||||
|
acc.push(' ');
|
||||||
|
acc.push_str(a);
|
||||||
|
acc
|
||||||
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut child = cmd
|
let mut child = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?;
|
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.binary()))?;
|
||||||
|
|
||||||
if let Some(out) = child.stdout.take() {
|
if let Some(out) = child.stdout.take() {
|
||||||
let buf = Arc::clone(&log);
|
let buf = Arc::clone(&log);
|
||||||
@@ -304,7 +560,7 @@ pub fn spawn(
|
|||||||
let mut registered = false;
|
let mut registered = false;
|
||||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||||
// Every raw line is still mirrored into the log, as before.
|
// Every raw line is still mirrored into the log, as before.
|
||||||
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
|
buf.lock().push(format!("[{lbl}] {line}"));
|
||||||
|
|
||||||
let Some(wiring) = cap_wiring.as_ref() else {
|
let Some(wiring) = cap_wiring.as_ref() else {
|
||||||
continue;
|
continue;
|
||||||
@@ -317,9 +573,9 @@ pub fn spawn(
|
|||||||
};
|
};
|
||||||
registered = true;
|
registered = true;
|
||||||
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
|
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
|
||||||
wiring.sink.lock().unwrap().empty_mypacks_resolver = Some(version);
|
wiring.sink.lock().empty_mypacks_resolver = Some(version);
|
||||||
{
|
{
|
||||||
let mut log = buf.lock().unwrap();
|
let mut log = buf.lock();
|
||||||
log.push(format!(
|
log.push(format!(
|
||||||
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
|
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
|
||||||
));
|
));
|
||||||
@@ -336,11 +592,9 @@ pub fn spawn(
|
|||||||
) {
|
) {
|
||||||
Ok(()) => buf
|
Ok(()) => buf
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
|
||||||
.push("[fifa17] capability registered with backend".to_string()),
|
.push("[fifa17] capability registered with backend".to_string()),
|
||||||
Err(error) => buf
|
Err(error) => buf
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
|
||||||
.push(format!("[fifa17] capability registration failed: {error}")),
|
.push(format!("[fifa17] capability registration failed: {error}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,20 +605,19 @@ pub fn spawn(
|
|||||||
let lbl = label.to_string();
|
let lbl = label.to_string();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||||
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
|
buf.lock().push(format!("[{lbl}] {line}"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if service == Service::Lsx {
|
if service == Service::Lsx {
|
||||||
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
|
let address = LSX_ADDR;
|
||||||
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
|
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
log.lock()
|
log.lock()
|
||||||
.unwrap()
|
|
||||||
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
|
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,27 +629,38 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lsx_runs_python_directly() {
|
fn lsx_runs_its_own_binary_with_no_arguments() {
|
||||||
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools"));
|
let parts = command_parts(Service::Lsx);
|
||||||
assert_eq!(parts.program, "/usr/bin/python3");
|
assert_eq!(
|
||||||
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]);
|
Path::new(&parts.program).file_name().unwrap(),
|
||||||
|
"openfut-lsx"
|
||||||
|
);
|
||||||
|
assert!(parts.args.is_empty(), "{:?}", parts.args);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn autopatch_runs_python_directly_with_launcher_ownership() {
|
fn autopatch_runs_its_own_binary_with_launcher_ownership() {
|
||||||
let parts = command_parts(
|
let parts = command_parts(Service::Autopatch);
|
||||||
Service::Autopatch,
|
assert_eq!(
|
||||||
"/usr/bin/python3",
|
Path::new(&parts.program).file_name().unwrap(),
|
||||||
Path::new("/tmp/tools"),
|
"openfut-autopatch"
|
||||||
);
|
);
|
||||||
assert_eq!(parts.program, "/usr/bin/python3");
|
// The launcher pid is how autopatch learns to exit with its owner.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parts.args,
|
parts.args,
|
||||||
vec![
|
vec!["--launcher-pid", &std::process::id().to_string()]
|
||||||
"/tmp/tools/autopatch.py",
|
);
|
||||||
"--launcher-pid",
|
}
|
||||||
&std::process::id().to_string(),
|
|
||||||
]
|
#[test]
|
||||||
|
fn a_companion_binary_is_looked_up_by_file_name_not_a_suffix_match() {
|
||||||
|
// Guards the foreign-process scan: an argv entry that merely ends with the
|
||||||
|
// binary name (a log path, say) must not be mistaken for the service.
|
||||||
|
assert_eq!(Service::Lsx.binary(), "openfut-lsx");
|
||||||
|
assert_eq!(Service::Autopatch.binary(), "openfut-autopatch");
|
||||||
|
assert_eq!(
|
||||||
|
Path::new("/var/log/my-openfut-lsx").file_name().unwrap(),
|
||||||
|
"my-openfut-lsx"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,4 +690,89 @@ mod tests {
|
|||||||
.expect_err("exited child must not be reported ready");
|
.expect_err("exited child must not be reported ready");
|
||||||
assert!(error.to_string().contains("exited before becoming ready"));
|
assert!(error.to_string().contains("exited before becoming ready"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn supervisor() -> ServiceSupervisor {
|
||||||
|
ServiceSupervisor::new(Arc::new(Mutex::new(LogBuffer::new())))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_service_this_launcher_never_started_is_never_reported_as_ours() {
|
||||||
|
// The old model only knew about children it spawned, so it could not tell
|
||||||
|
// "stopped" from "running, but not mine". Note this box may genuinely have
|
||||||
|
// a foreign responder running — that is a real observation, and the
|
||||||
|
// invariant is about ownership, not about it being absent.
|
||||||
|
let mut sup = supervisor();
|
||||||
|
let runtime = sup.observe(Service::Autopatch);
|
||||||
|
assert!(
|
||||||
|
!runtime.started_by_launcher,
|
||||||
|
"nothing was spawned here, so nothing may claim launcher ownership"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_launcher_owned_child_is_observed_as_ours_and_reaped_when_it_dies() {
|
||||||
|
let mut sup = supervisor();
|
||||||
|
let child = Command::new("sh")
|
||||||
|
.args(["-c", "sleep 30"])
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn long-lived child");
|
||||||
|
let pid = child.id();
|
||||||
|
sup.autopatch = ManagedService::from_child(child);
|
||||||
|
|
||||||
|
let runtime = sup.observe(Service::Autopatch);
|
||||||
|
assert!(runtime.running);
|
||||||
|
assert!(runtime.started_by_launcher, "we spawned it");
|
||||||
|
assert_eq!(runtime.pid, Some(pid));
|
||||||
|
|
||||||
|
// Stopping is allowed precisely because it is ours.
|
||||||
|
sup.stop(Service::Autopatch).expect("ours to stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stopping_a_foreign_service_is_refused_rather_than_killing_it() {
|
||||||
|
// A service someone started by hand for a debugging session must survive a
|
||||||
|
// launch/exit cycle, and the refusal has to say where to stop it. Asserted
|
||||||
|
// on the pure rule so it holds regardless of what this machine is running.
|
||||||
|
let foreign = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: false,
|
||||||
|
pid: Some(4242),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
let error = stop_permitted(&foreign, "autopatch").unwrap_err();
|
||||||
|
assert!(error.contains("started outside this launcher"), "{error}");
|
||||||
|
assert!(error.contains("4242"), "{error}");
|
||||||
|
|
||||||
|
let ours = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: true,
|
||||||
|
pid: Some(99),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
assert!(stop_permitted(&ours, "autopatch").is_ok());
|
||||||
|
// Stopping something that is not running is a harmless no-op.
|
||||||
|
assert!(stop_permitted(&ServiceRuntime::default(), "autopatch").is_ok());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
crate::launch::services_to_stop(
|
||||||
|
crate::launch::CleanupPolicy {
|
||||||
|
stop_launcher_started_services: true,
|
||||||
|
},
|
||||||
|
&[(Service::Autopatch, foreign)],
|
||||||
|
)
|
||||||
|
.is_empty(),
|
||||||
|
"a foreign service is never in the stop list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_pid_ignores_the_launcher_process_itself() {
|
||||||
|
// The scan matches on the responder script name; this process is not one,
|
||||||
|
// and must never be reported as a service.
|
||||||
|
assert_ne!(foreign_pid(Service::Lsx, None), Some(std::process::id()));
|
||||||
|
assert_ne!(
|
||||||
|
foreign_pid(Service::Autopatch, None),
|
||||||
|
Some(std::process::id())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-2
@@ -1,3 +1,4 @@
|
|||||||
|
mod account_monitor;
|
||||||
mod account_sync;
|
mod account_sync;
|
||||||
mod app;
|
mod app;
|
||||||
mod arm;
|
mod arm;
|
||||||
@@ -5,18 +6,22 @@ mod config;
|
|||||||
mod fifa17_capability;
|
mod fifa17_capability;
|
||||||
mod game_launch;
|
mod game_launch;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod launch;
|
||||||
mod local_services;
|
mod local_services;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod netcheck;
|
mod netcheck;
|
||||||
mod preflight;
|
mod preflight;
|
||||||
mod setup;
|
mod setup;
|
||||||
|
mod theme;
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
fn main() -> eframe::Result<()> {
|
||||||
let options = eframe::NativeOptions {
|
let options = eframe::NativeOptions {
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_title("OpenFUT Launcher")
|
.with_title("OpenFUT Launcher")
|
||||||
.with_inner_size([780.0, 560.0])
|
.with_app_id("openfut-launcher")
|
||||||
.with_min_inner_size([600.0, 400.0]),
|
.with_icon(app_icon())
|
||||||
|
.with_inner_size([1040.0, 720.0])
|
||||||
|
.with_min_inner_size([880.0, 600.0]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,3 +31,88 @@ fn main() -> eframe::Result<()> {
|
|||||||
Box::new(|cc| Ok(Box::new(app::LauncherApp::new(cc)))),
|
Box::new(|cc| Ok(Box::new(app::LauncherApp::new(cc)))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The application / taskbar icon: the same "OF" monogram the header wordmark
|
||||||
|
/// shows, drawn white on the signature accent tile. Generated in code (no PNG
|
||||||
|
/// dependency) at 4x supersampling and box-downsampled to a crisp 64x64 RGBA —
|
||||||
|
/// scales cleanly to the 32x32 the WM typically renders. Colours come from the
|
||||||
|
/// theme palette so the icon never drifts from the in-app brand.
|
||||||
|
fn app_icon() -> egui::IconData {
|
||||||
|
const SIZE: usize = 64; // output edge
|
||||||
|
const SS: usize = 4; // supersampling factor
|
||||||
|
|
||||||
|
let accent = theme::ACCENT;
|
||||||
|
let fg = theme::ON_ACCENT;
|
||||||
|
|
||||||
|
// Rounded-square background: point inside the [0,SIZE]² square with corners
|
||||||
|
// rounded to `round_r` (transparent outside, so the icon reads as a tile).
|
||||||
|
let round_r = 13.0_f32;
|
||||||
|
let inside_bg = |x: f32, y: f32| -> bool {
|
||||||
|
let s = SIZE as f32;
|
||||||
|
let cx = x.clamp(round_r, s - round_r);
|
||||||
|
let cy = y.clamp(round_r, s - round_r);
|
||||||
|
let (dx, dy) = (x - cx, y - cy);
|
||||||
|
dx * dx + dy * dy <= round_r * round_r
|
||||||
|
};
|
||||||
|
|
||||||
|
// "O" — an elliptical ring on the left.
|
||||||
|
let inside_o = |x: f32, y: f32| -> bool {
|
||||||
|
let (cx, cy) = (21.0_f32, 32.0_f32);
|
||||||
|
let (dx, dy) = (x - cx, y - cy);
|
||||||
|
let outer = (dx / 9.0).powi(2) + (dy / 14.0).powi(2) <= 1.0;
|
||||||
|
let inner = (dx / 4.8).powi(2) + (dy / 9.5).powi(2) < 1.0;
|
||||||
|
outer && !inner
|
||||||
|
};
|
||||||
|
|
||||||
|
// "F" — a stem plus a top and middle bar on the right.
|
||||||
|
let inside_f = |x: f32, y: f32| -> bool {
|
||||||
|
let stem = (34.0..=39.0).contains(&x) && (18.0..=46.0).contains(&y);
|
||||||
|
let top = (34.0..=52.0).contains(&x) && (18.0..=23.0).contains(&y);
|
||||||
|
let mid = (34.0..=48.0).contains(&x) && (29.5..=34.0).contains(&y);
|
||||||
|
stem || top || mid
|
||||||
|
};
|
||||||
|
|
||||||
|
// Premultiplied-alpha accumulation per output pixel so antialiased edges
|
||||||
|
// (both the rounded tile and the letters) never fringe dark.
|
||||||
|
let mut rgba = vec![0u8; SIZE * SIZE * 4];
|
||||||
|
for oy in 0..SIZE {
|
||||||
|
for ox in 0..SIZE {
|
||||||
|
let (mut ar, mut ag, mut ab, mut aa) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
|
||||||
|
for sy in 0..SS {
|
||||||
|
for sx in 0..SS {
|
||||||
|
let x = ox as f32 + (sx as f32 + 0.5) / SS as f32;
|
||||||
|
let y = oy as f32 + (sy as f32 + 0.5) / SS as f32;
|
||||||
|
let (r, g, b, a) = if inside_o(x, y) || inside_f(x, y) {
|
||||||
|
(fg.r(), fg.g(), fg.b(), 255u16)
|
||||||
|
} else if inside_bg(x, y) {
|
||||||
|
(accent.r(), accent.g(), accent.b(), 255u16)
|
||||||
|
} else {
|
||||||
|
(0, 0, 0, 0)
|
||||||
|
};
|
||||||
|
let af = a as f32 / 255.0;
|
||||||
|
ar += r as f32 * af;
|
||||||
|
ag += g as f32 * af;
|
||||||
|
ab += b as f32 * af;
|
||||||
|
aa += af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let samples = (SS * SS) as f32;
|
||||||
|
let idx = (oy * SIZE + ox) * 4;
|
||||||
|
let (r, g, b) = if aa > 0.0 {
|
||||||
|
(ar / aa, ag / aa, ab / aa)
|
||||||
|
} else {
|
||||||
|
(0.0, 0.0, 0.0)
|
||||||
|
};
|
||||||
|
rgba[idx] = r.round() as u8;
|
||||||
|
rgba[idx + 1] = g.round() as u8;
|
||||||
|
rgba[idx + 2] = b.round() as u8;
|
||||||
|
rgba[idx + 3] = (aa / samples * 255.0).round() as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::IconData {
|
||||||
|
rgba,
|
||||||
|
width: SIZE as u32,
|
||||||
|
height: SIZE as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+122
-36
@@ -88,10 +88,11 @@ 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.
|
||||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||||
vec![
|
vec![
|
||||||
ptrace_scope(cfg),
|
ptrace_scope(),
|
||||||
ea_redirect(cfg),
|
ea_redirect(cfg),
|
||||||
hostname_mapping(cfg),
|
hostname_mapping(cfg),
|
||||||
backend_reachable(cfg),
|
backend_reachable(cfg),
|
||||||
|
hook_config(cfg),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,16 +109,12 @@ pub fn warnings(checks: &[Check]) -> usize {
|
|||||||
/// autopatch writes to FIFA's process memory; Yama blocks that unless
|
/// autopatch writes to FIFA's process memory; Yama blocks that unless
|
||||||
/// `ptrace_scope` is 0. At 1 the patch silently does nothing and the game fails
|
/// `ptrace_scope` is 0. At 1 the patch silently does nothing and the game fails
|
||||||
/// its TLS handshake much later, with no message naming the cause.
|
/// its TLS handshake much later, with no message naming the cause.
|
||||||
fn ptrace_scope(cfg: &LauncherConfig) -> Check {
|
///
|
||||||
|
/// 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.
|
||||||
|
fn ptrace_scope() -> Check {
|
||||||
const NAME: &str = "ptrace_scope (autopatch)";
|
const NAME: &str = "ptrace_scope (autopatch)";
|
||||||
// `fifa17_tools_dir` carries a conventional default, so a non-empty value
|
|
||||||
// does not mean the tools are installed. Key off the directory actually
|
|
||||||
// existing: that is what decides whether autopatch will run at all, and it
|
|
||||||
// keeps this from failing on a machine that never uses local services.
|
|
||||||
let tools = cfg.fifa17_tools_dir.trim();
|
|
||||||
if tools.is_empty() || !std::path::Path::new(tools).is_dir() {
|
|
||||||
return Check::skip(NAME, "no local services installed");
|
|
||||||
}
|
|
||||||
match std::fs::read_to_string(PTRACE_SCOPE) {
|
match std::fs::read_to_string(PTRACE_SCOPE) {
|
||||||
Ok(v) => ptrace_verdict(&v),
|
Ok(v) => ptrace_verdict(&v),
|
||||||
// Not every kernel has Yama. Absent means unenforced, which is what we want.
|
// Not every kernel has Yama. Absent means unenforced, which is what we want.
|
||||||
@@ -238,7 +235,7 @@ fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The server side of the same question: are the ports the game will use open?
|
/// The server side of the same question: are the ports the game will use open?
|
||||||
fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
pub(crate) fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
||||||
const NAME: &str = "OpenFUT server reachable";
|
const NAME: &str = "OpenFUT server reachable";
|
||||||
let host = cfg.openfut_server_host.trim();
|
let host = cfg.openfut_server_host.trim();
|
||||||
if host.is_empty() {
|
if host.is_empty() {
|
||||||
@@ -261,6 +258,50 @@ fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The deployed `openfut.cfg` is the only server address the *game* can see.
|
||||||
|
///
|
||||||
|
/// Every panel in this launcher reads the in-memory config, so a settings change
|
||||||
|
/// that never reached the file produces the worst possible failure: the UI shows
|
||||||
|
/// the new server online while FIFA connects to the old one. Compare the two.
|
||||||
|
fn hook_config(cfg: &LauncherConfig) -> Check {
|
||||||
|
const NAME: &str = "Hook server address";
|
||||||
|
let game_dir = cfg.fifa_game_dir.trim();
|
||||||
|
if game_dir.is_empty() {
|
||||||
|
return Check::skip(NAME, "no FIFA game dir configured");
|
||||||
|
}
|
||||||
|
let Some(body) = crate::setup::read_hook_config(std::path::Path::new(game_dir)) else {
|
||||||
|
return Check::skip(
|
||||||
|
NAME,
|
||||||
|
format!("no {} deployed yet", crate::setup::HOOK_CFG_FILE),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let deployed = match openfut_common::ServerConfig::parse(&body) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
// Unparseable means the hook cannot read it either, and nothing else in
|
||||||
|
// the stack recovers from that — so this one is a genuine failure.
|
||||||
|
Err(e) => {
|
||||||
|
return Check::fail(
|
||||||
|
NAME,
|
||||||
|
format!("{} is unreadable: {e}", crate::setup::HOOK_CFG_FILE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let wanted = cfg.server_config();
|
||||||
|
if deployed == wanted {
|
||||||
|
return Check::pass(NAME, format!("hook redirects to {}", wanted.host));
|
||||||
|
}
|
||||||
|
// Warn, not fail: the launch path rewrites this file before starting the
|
||||||
|
// game, so the drift is real but already covered. Naming both addresses is
|
||||||
|
// what makes it actionable.
|
||||||
|
Check::warn(
|
||||||
|
NAME,
|
||||||
|
format!(
|
||||||
|
"deployed hook still points at {} (settings say {}) — launching rewrites it",
|
||||||
|
deployed.host, wanted.host
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn connects(host: &str, port: u16) -> bool {
|
fn connects(host: &str, port: u16) -> bool {
|
||||||
match (host, port).to_socket_addrs() {
|
match (host, port).to_socket_addrs() {
|
||||||
Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()),
|
Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()),
|
||||||
@@ -289,13 +330,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
||||||
// The distinction that matters: a fresh config must not display four
|
// The distinction that matters: a fresh config must not display a column
|
||||||
// green ticks. "Not checked" is not "checked and fine".
|
// of green ticks. "Not checked" is not "checked and fine".
|
||||||
|
//
|
||||||
|
// `ptrace_scope` is excluded because it is no longer configuration
|
||||||
|
// dependent: it reads this machine's Yama setting and reports a real
|
||||||
|
// verdict either way. `only_ptrace_scope_zero_lets_autopatch_work`
|
||||||
|
// covers it.
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
// `default()` points this at a conventional path whose existence varies
|
// `default()` points this at a conventional path whose existence varies
|
||||||
// by machine. Pin it so the assertion is about the code, not this box.
|
// by machine. Pin it so the assertion is about the code, not this box.
|
||||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
|
||||||
let checks = run(&c);
|
let checks: Vec<Check> = run(&c)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|k| k.name != "ptrace_scope (autopatch)")
|
||||||
|
.collect();
|
||||||
assert!(
|
assert!(
|
||||||
checks.iter().all(|k| k.state == State::Skipped),
|
checks.iter().all(|k| k.state == State::Skipped),
|
||||||
"{checks:#?}"
|
"{checks:#?}"
|
||||||
@@ -314,16 +363,6 @@ mod tests {
|
|||||||
assert!(ptrace_verdict("1").detail.contains("Arm client"));
|
assert!(ptrace_verdict("1").detail.contains("Arm client"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ptrace_is_skipped_when_the_tools_dir_does_not_exist() {
|
|
||||||
// Regression: the gate used to be "is the field non-empty", and the
|
|
||||||
// field has a default — so this check ran (and failed) on machines that
|
|
||||||
// never use autopatch at all.
|
|
||||||
let mut c = cfg();
|
|
||||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
|
||||||
assert_eq!(ptrace_scope(&c).state, State::Skipped);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
|
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
@@ -356,15 +395,24 @@ mod tests {
|
|||||||
|
|
||||||
/// A shadowed hostname must not be counted as a reason to expect failure.
|
/// A shadowed hostname must not be counted as a reason to expect failure.
|
||||||
/// This is the exact case the first version got wrong.
|
/// This is the exact case the first version got wrong.
|
||||||
|
///
|
||||||
|
/// Asserts the hostname check itself rather than counting states across the
|
||||||
|
/// whole run: `backend_reachable` opens real sockets, so an aggregate count
|
||||||
|
/// silently asserts that THIS machine has the OpenFUT ports open. That made
|
||||||
|
/// the test pass only on the server host and fail on the game machine, which
|
||||||
|
/// is precisely where someone building the launcher runs the suite.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_shadowed_hostname_is_a_warning_not_a_failure() {
|
fn a_shadowed_hostname_is_a_warning_not_a_failure() {
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
c.openfut_server_host = "127.0.0.2".into();
|
c.openfut_server_host = "127.0.0.2".into();
|
||||||
c.ea_hostnames = vec!["localhost".into()];
|
c.ea_hostnames = vec!["localhost".into()];
|
||||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
let check = hostname_mapping(&c);
|
||||||
let checks = run(&c);
|
assert_eq!(check.state, State::Warn, "{}", check.detail);
|
||||||
assert_eq!(failures(&checks), 0, "must not be reported as fatal");
|
assert!(
|
||||||
assert_eq!(warnings(&checks), 1);
|
check.detail.contains("localhost"),
|
||||||
|
"the warning must name the shadowed host: {}",
|
||||||
|
check.detail
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -377,13 +425,6 @@ mod tests {
|
|||||||
assert_eq!(hostname_mapping(&c).state, State::Pass);
|
assert_eq!(hostname_mapping(&c).state, State::Pass);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ptrace_check_is_skipped_when_local_services_are_not_configured() {
|
|
||||||
let mut c = cfg();
|
|
||||||
c.fifa17_tools_dir.clear();
|
|
||||||
assert_eq!(ptrace_scope(&c).state, State::Skipped);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dead_backend_port_is_reported_as_a_failure() {
|
fn a_dead_backend_port_is_reported_as_a_failure() {
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
@@ -395,4 +436,49 @@ mod tests {
|
|||||||
assert_eq!(check.state, State::Fail, "{}", check.detail);
|
assert_eq!(check.state, State::Fail, "{}", check.detail);
|
||||||
assert!(check.detail.contains("no answer on"), "{}", check.detail);
|
assert!(check.detail.contains("no answer on"), "{}", check.detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A temp game dir holding one `openfut.cfg` body.
|
||||||
|
fn game_dir_with_cfg(tag: &str, body: &str) -> std::path::PathBuf {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("openfut-preflight-{tag}-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join(crate::setup::HOOK_CFG_FILE), body).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stale_hook_config_is_reported_and_names_both_addresses() {
|
||||||
|
// The silent failure this check exists for: settings changed, the file
|
||||||
|
// the game reads did not.
|
||||||
|
let mut c = cfg();
|
||||||
|
c.openfut_server_host = "10.0.0.2".into();
|
||||||
|
let old = openfut_common::ServerConfig {
|
||||||
|
host: "10.0.0.1".into(),
|
||||||
|
ports: c.server_config().ports,
|
||||||
|
};
|
||||||
|
let dir = game_dir_with_cfg("stale", &old.to_cfg_string());
|
||||||
|
c.fifa_game_dir = dir.to_string_lossy().into_owned();
|
||||||
|
let check = hook_config(&c);
|
||||||
|
assert_eq!(check.state, State::Warn, "{}", check.detail);
|
||||||
|
assert!(check.detail.contains("10.0.0.1"), "{}", check.detail);
|
||||||
|
assert!(check.detail.contains("10.0.0.2"), "{}", check.detail);
|
||||||
|
std::fs::remove_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hook_config_matching_settings_passes() {
|
||||||
|
let mut c = cfg();
|
||||||
|
c.openfut_server_host = "10.0.0.2".into();
|
||||||
|
let dir = game_dir_with_cfg("fresh", &c.server_config().to_cfg_string());
|
||||||
|
c.fifa_game_dir = dir.to_string_lossy().into_owned();
|
||||||
|
assert_eq!(hook_config(&c).state, State::Pass);
|
||||||
|
std::fs::remove_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_hook_config_is_skipped_not_passed() {
|
||||||
|
let mut c = cfg();
|
||||||
|
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
|
||||||
|
assert_eq!(hook_config(&c).state, State::Skipped);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-9
@@ -67,6 +67,9 @@ pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// ── DLL hook deployment ───────────────────────────────────────────────────────
|
// ── DLL hook deployment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The file the injected hook reads its server address from, in the game dir.
|
||||||
|
pub const HOOK_CFG_FILE: &str = "openfut.cfg";
|
||||||
|
|
||||||
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
|
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
|
||||||
/// openfut.cfg with the structured server configuration the hook reads.
|
/// openfut.cfg with the structured server configuration the hook reads.
|
||||||
/// `cfg_contents` must be the full `openfut.cfg` body (see
|
/// `cfg_contents` must be the full `openfut.cfg` body (see
|
||||||
@@ -85,14 +88,14 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> a
|
|||||||
}
|
}
|
||||||
std::fs::create_dir_all(game_dir)?;
|
std::fs::create_dir_all(game_dir)?;
|
||||||
std::fs::copy(dll_src, game_dir.join("version.dll"))?;
|
std::fs::copy(dll_src, game_dir.join("version.dll"))?;
|
||||||
std::fs::write(game_dir.join("openfut.cfg"), cfg_contents)?;
|
std::fs::write(game_dir.join(HOOK_CFG_FILE), cfg_contents)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the
|
/// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the
|
||||||
/// full structured `openfut.cfg` body.
|
/// full structured `openfut.cfg` body.
|
||||||
pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
|
pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
|
||||||
let cfg = game_dir.join("openfut.cfg");
|
let cfg = game_dir.join(HOOK_CFG_FILE);
|
||||||
if !cfg.exists() {
|
if !cfg.exists() {
|
||||||
anyhow::bail!("Hook DLL not deployed yet — deploy first.");
|
anyhow::bail!("Hook DLL not deployed yet — deploy first.");
|
||||||
}
|
}
|
||||||
@@ -100,6 +103,15 @@ pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the `openfut.cfg` the hook will actually load, if one is deployed.
|
||||||
|
///
|
||||||
|
/// The launcher's own health and account requests are built from the in-memory
|
||||||
|
/// config, but the *game* only ever sees this file. Reading it back is the only
|
||||||
|
/// way to tell whether the two agree.
|
||||||
|
pub fn read_hook_config(game_dir: &Path) -> Option<String> {
|
||||||
|
std::fs::read_to_string(game_dir.join(HOOK_CFG_FILE)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove the deployed hook DLL from the FIFA game directory.
|
/// Remove the deployed hook DLL from the FIFA game directory.
|
||||||
pub fn remove_hook_dll(game_dir: &Path) -> anyhow::Result<()> {
|
pub fn remove_hook_dll(game_dir: &Path) -> anyhow::Result<()> {
|
||||||
let dest = game_dir.join("version.dll");
|
let dest = game_dir.join("version.dll");
|
||||||
@@ -127,13 +139,14 @@ pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %comman
|
|||||||
pub fn launch_game(
|
pub fn launch_game(
|
||||||
command: &str,
|
command: &str,
|
||||||
workdir: &str,
|
workdir: &str,
|
||||||
log_buf: std::sync::Arc<std::sync::Mutex<crate::logs::LogBuffer>>,
|
log_buf: std::sync::Arc<parking_lot::Mutex<crate::logs::LogBuffer>>,
|
||||||
|
on_exit: impl FnOnce() + Send + 'static,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
if command.trim().is_empty() {
|
if command.trim().is_empty() {
|
||||||
anyhow::bail!("No game launch command configured (set it in the Config tab).");
|
anyhow::bail!("No game launch command configured (set it in Settings).");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cmd = Command::new("sh");
|
let mut cmd = Command::new("sh");
|
||||||
@@ -145,7 +158,6 @@ pub fn launch_game(
|
|||||||
|
|
||||||
log_buf
|
log_buf
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
|
||||||
.push(format!("[launcher] launching game: {command}"));
|
.push(format!("[launcher] launching game: {command}"));
|
||||||
|
|
||||||
let mut child = cmd.spawn()?;
|
let mut child = cmd.spawn()?;
|
||||||
@@ -154,7 +166,7 @@ pub fn launch_game(
|
|||||||
let buf = std::sync::Arc::clone(&log_buf);
|
let buf = std::sync::Arc::clone(&log_buf);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||||
buf.lock().unwrap().push(line);
|
buf.lock().push(line);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,18 +174,20 @@ pub fn launch_game(
|
|||||||
let buf = std::sync::Arc::clone(&log_buf);
|
let buf = std::sync::Arc::clone(&log_buf);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||||
buf.lock().unwrap().push(line);
|
buf.lock().push(line);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Reap the child in the background so a finished game doesn't linger as a
|
// Reap the child in the background so a finished game doesn't linger as a
|
||||||
// zombie; we don't block the UI on it.
|
// zombie; we don't block the UI on it. `on_exit` is how the launch state
|
||||||
|
// machine learns the game is gone — without it the UI would sit on
|
||||||
|
// "FIFA 17 Running" forever.
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
log_buf
|
log_buf
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
|
||||||
.push("[launcher] game process exited.".to_string());
|
.push("[launcher] game process exited.".to_string());
|
||||||
|
on_exit();
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
//! OpenFUT launcher visual system.
|
||||||
|
//!
|
||||||
|
//! A single place that owns the app's look: the semantic colour palette, the
|
||||||
|
//! type scale, embedded fonts, and the tuned egui [`Style`]/[`Visuals`]. UI code
|
||||||
|
//! composes *with* this system — it never hard-codes `Color32::from_rgb(...)` or
|
||||||
|
//! stray pixel radii. The palette is deliberately small: one signature accent
|
||||||
|
//! plus four status hues (success / warn / error / idle) and a tinted neutral
|
||||||
|
//! ramp. Nothing here changes launcher behaviour; it is presentation only.
|
||||||
|
|
||||||
|
use egui::{
|
||||||
|
Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Frame, Margin, Rounding,
|
||||||
|
Stroke, TextStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Semantic palette ────────────────────────────────────────────────────────
|
||||||
|
// Neutrals are always *tinted* (a hint of cool blue), never pure #000/#fff.
|
||||||
|
|
||||||
|
/// Window backdrop — the deepest surface.
|
||||||
|
pub const BG_DEEP: Color32 = Color32::from_rgb(0x10, 0x12, 0x18);
|
||||||
|
/// Standard panel fill (nav rail, central body).
|
||||||
|
pub const BG: Color32 = Color32::from_rgb(0x15, 0x18, 0x22);
|
||||||
|
/// Raised card / group surface.
|
||||||
|
pub const SURFACE: Color32 = Color32::from_rgb(0x1c, 0x20, 0x2e);
|
||||||
|
/// Hovered / interactive raised surface.
|
||||||
|
pub const SURFACE_HOVER: Color32 = Color32::from_rgb(0x24, 0x29, 0x3a);
|
||||||
|
/// Inset surface (text fields, console, code).
|
||||||
|
pub const INSET: Color32 = Color32::from_rgb(0x0e, 0x10, 0x17);
|
||||||
|
|
||||||
|
/// Hairline divider / card border.
|
||||||
|
pub const BORDER: Color32 = Color32::from_rgb(0x2a, 0x31, 0x45);
|
||||||
|
/// Stronger border for emphasis / hover.
|
||||||
|
pub const BORDER_STRONG: Color32 = Color32::from_rgb(0x3a, 0x43, 0x5e);
|
||||||
|
|
||||||
|
/// Primary text.
|
||||||
|
pub const TEXT: Color32 = Color32::from_rgb(0xe6, 0xe9, 0xf2);
|
||||||
|
/// Secondary / supporting text.
|
||||||
|
pub const TEXT_WEAK: Color32 = Color32::from_rgb(0x9a, 0xa3, 0xb8);
|
||||||
|
/// Tertiary / disabled-ish text.
|
||||||
|
pub const TEXT_FAINT: Color32 = Color32::from_rgb(0x6a, 0x73, 0x8a);
|
||||||
|
|
||||||
|
/// Signature OpenFUT accent — a confident royal blue used for the wordmark,
|
||||||
|
/// active navigation, and primary calls-to-action.
|
||||||
|
pub const ACCENT: Color32 = Color32::from_rgb(0x4c, 0x6f, 0xff);
|
||||||
|
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(0x6a, 0x87, 0xff);
|
||||||
|
pub const ACCENT_PRESSED: Color32 = Color32::from_rgb(0x3b, 0x5b, 0xe0);
|
||||||
|
/// Faint accent wash for active-nav backgrounds / selection.
|
||||||
|
pub const ACCENT_WASH: Color32 = Color32::from_rgb(0x22, 0x2c, 0x50);
|
||||||
|
/// Text drawn on top of the solid accent.
|
||||||
|
pub const ON_ACCENT: Color32 = Color32::from_rgb(0xf5, 0xf7, 0xff);
|
||||||
|
|
||||||
|
/// Status hues — distinct from the accent so "primary action" never reads as
|
||||||
|
/// "healthy" and vice-versa.
|
||||||
|
pub const SUCCESS: Color32 = Color32::from_rgb(0x3f, 0xcf, 0x8e);
|
||||||
|
pub const WARN: Color32 = Color32::from_rgb(0xf2, 0xb4, 0x4c);
|
||||||
|
pub const ERROR: Color32 = Color32::from_rgb(0xf2, 0x6d, 0x6d);
|
||||||
|
pub const IDLE: Color32 = Color32::from_rgb(0x7a, 0x83, 0x99);
|
||||||
|
/// Informational blue for log lines (lighter than the accent).
|
||||||
|
pub const INFO: Color32 = Color32::from_rgb(0x8f, 0xb6, 0xff);
|
||||||
|
|
||||||
|
// ── Type scale (custom named text styles) ───────────────────────────────────
|
||||||
|
|
||||||
|
/// Large branded wordmark.
|
||||||
|
pub const HERO: &str = "Hero";
|
||||||
|
/// Card / section titles.
|
||||||
|
pub const SUBHEADING: &str = "Subheading";
|
||||||
|
/// Small monospace (console meta, launch command).
|
||||||
|
pub const MONO_SM: &str = "MonoSm";
|
||||||
|
|
||||||
|
fn bold_family() -> FontFamily {
|
||||||
|
FontFamily::Name("openfut-bold".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`TextStyle`] handle for one of our custom scale steps.
|
||||||
|
pub fn text_style(name: &str) -> TextStyle {
|
||||||
|
TextStyle::Name(name.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status semantics ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A coarse health/activity state, mapped to one palette hue + glyph. Using an
|
||||||
|
/// enum keeps status rendering consistent everywhere (dashboard, preflight,
|
||||||
|
/// services) instead of ad-hoc colour+string pairs.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Status {
|
||||||
|
/// Healthy / online / running / passed.
|
||||||
|
Ok,
|
||||||
|
/// Advisory — worth attention, usually not fatal.
|
||||||
|
Warn,
|
||||||
|
/// Broken / unreachable / failed.
|
||||||
|
Error,
|
||||||
|
/// Not running / not configured / not checked.
|
||||||
|
Idle,
|
||||||
|
/// Transient (stopping / working).
|
||||||
|
Busy,
|
||||||
|
/// Unknown / not yet probed.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Status {
|
||||||
|
pub fn color(self) -> Color32 {
|
||||||
|
match self {
|
||||||
|
Status::Ok => SUCCESS,
|
||||||
|
Status::Warn => WARN,
|
||||||
|
Status::Error => ERROR,
|
||||||
|
Status::Idle => IDLE,
|
||||||
|
Status::Busy => WARN,
|
||||||
|
Status::Unknown => TEXT_FAINT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A consistent status glyph: filled ● for active/terminal states, hollow ○
|
||||||
|
/// for idle/unknown. (Kept to glyphs the bundled fonts render.)
|
||||||
|
pub fn glyph(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Status::Ok | Status::Error | Status::Warn | Status::Busy => "●",
|
||||||
|
Status::Idle | Status::Unknown => "○",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a compact status pill: a tinted, rounded chip with a status dot and
|
||||||
|
/// label. Used for the at-a-glance state on each dashboard card.
|
||||||
|
pub fn status_pill(ui: &mut egui::Ui, label: &str, status: Status) {
|
||||||
|
let color = status.color();
|
||||||
|
let bg = tint(color, 0.14);
|
||||||
|
Frame::none()
|
||||||
|
.fill(bg)
|
||||||
|
.rounding(Rounding::same(999.0))
|
||||||
|
.inner_margin(Margin::symmetric(10.0, 3.0))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = 6.0;
|
||||||
|
ui.label(egui::RichText::new(status.glyph()).color(color).size(11.0));
|
||||||
|
ui.label(egui::RichText::new(label).color(color).size(12.0).strong());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A raised card surface: rounded, hairline-bordered, generously padded. The
|
||||||
|
/// building block for the dashboard and setup sections.
|
||||||
|
pub fn card() -> Frame {
|
||||||
|
Frame::none()
|
||||||
|
.fill(SURFACE)
|
||||||
|
.stroke(Stroke::new(1.0_f32, BORDER))
|
||||||
|
.rounding(Rounding::same(12.0))
|
||||||
|
.inner_margin(Margin::same(18.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blend `color` toward the app background by `bg_weight` (0 = full colour,
|
||||||
|
/// 1 = pure background). Used for tinted chips and washes.
|
||||||
|
pub fn tint(color: Color32, weight: f32) -> Color32 {
|
||||||
|
let w = weight.clamp(0.0, 1.0);
|
||||||
|
let lerp = |c: u8, b: u8| ((c as f32) * w + (b as f32) * (1.0 - w)).round() as u8;
|
||||||
|
// Chips sit on card surfaces; lerp toward the surface, not the deep bg.
|
||||||
|
Color32::from_rgb(
|
||||||
|
lerp(color.r(), SURFACE.r()),
|
||||||
|
lerp(color.g(), SURFACE.g()),
|
||||||
|
lerp(color.b(), SURFACE.b()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Install ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Embed the bundled fonts and apply the OpenFUT style. Called once at startup.
|
||||||
|
pub fn install(ctx: &Context) {
|
||||||
|
install_fonts(ctx);
|
||||||
|
install_style(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_fonts(ctx: &Context) {
|
||||||
|
let mut fonts = FontDefinitions::default();
|
||||||
|
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"openfut-sans".to_owned(),
|
||||||
|
FontData::from_static(include_bytes!("../assets/fonts/LiberationSans-Regular.ttf")),
|
||||||
|
);
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"openfut-bold".to_owned(),
|
||||||
|
FontData::from_static(include_bytes!("../assets/fonts/LiberationSans-Bold.ttf")),
|
||||||
|
);
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"openfut-mono".to_owned(),
|
||||||
|
FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansMono.ttf")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Proportional & monospace default to the bundled faces so the UI looks
|
||||||
|
// identical regardless of the host's installed fonts.
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(FontFamily::Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, "openfut-sans".to_owned());
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(FontFamily::Monospace)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, "openfut-mono".to_owned());
|
||||||
|
|
||||||
|
// A dedicated bold family — egui does not synthesize weight, so headings
|
||||||
|
// reference this explicitly for a real type hierarchy.
|
||||||
|
fonts.families.insert(
|
||||||
|
FontFamily::Name("openfut-bold".into()),
|
||||||
|
vec!["openfut-bold".to_owned(), "openfut-sans".to_owned()],
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.set_fonts(fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_style(ctx: &Context) {
|
||||||
|
let mut style = (*ctx.style()).clone();
|
||||||
|
|
||||||
|
// ── Type scale ──────────────────────────────────────────────────────────
|
||||||
|
let bold = bold_family();
|
||||||
|
let prop = FontFamily::Proportional;
|
||||||
|
let mono = FontFamily::Monospace;
|
||||||
|
let ts = &mut style.text_styles;
|
||||||
|
ts.insert(text_style(HERO), FontId::new(28.0, bold.clone()));
|
||||||
|
ts.insert(TextStyle::Heading, FontId::new(19.0, bold.clone()));
|
||||||
|
ts.insert(text_style(SUBHEADING), FontId::new(15.0, bold));
|
||||||
|
ts.insert(TextStyle::Body, FontId::new(14.0, prop.clone()));
|
||||||
|
ts.insert(TextStyle::Button, FontId::new(14.0, prop.clone()));
|
||||||
|
ts.insert(TextStyle::Small, FontId::new(12.0, prop));
|
||||||
|
ts.insert(TextStyle::Monospace, FontId::new(13.0, mono.clone()));
|
||||||
|
ts.insert(text_style(MONO_SM), FontId::new(11.5, mono));
|
||||||
|
|
||||||
|
// ── Spacing scale (multiples of 4) ────────────────────────────────────────
|
||||||
|
let sp = &mut style.spacing;
|
||||||
|
sp.item_spacing = egui::vec2(8.0, 8.0);
|
||||||
|
sp.button_padding = egui::vec2(12.0, 7.0);
|
||||||
|
sp.menu_margin = Margin::same(8.0);
|
||||||
|
sp.indent = 18.0;
|
||||||
|
sp.interact_size.y = 30.0;
|
||||||
|
sp.scroll.bar_width = 9.0;
|
||||||
|
|
||||||
|
// ── Visuals ───────────────────────────────────────────────────────────────
|
||||||
|
let mut v = egui::Visuals::dark();
|
||||||
|
v.dark_mode = true;
|
||||||
|
v.override_text_color = Some(TEXT);
|
||||||
|
v.panel_fill = BG;
|
||||||
|
v.window_fill = BG;
|
||||||
|
v.extreme_bg_color = INSET;
|
||||||
|
v.faint_bg_color = SURFACE;
|
||||||
|
v.code_bg_color = INSET;
|
||||||
|
v.hyperlink_color = ACCENT_HOVER;
|
||||||
|
|
||||||
|
v.window_rounding = Rounding::same(12.0);
|
||||||
|
v.window_stroke = Stroke::new(1.0_f32, BORDER);
|
||||||
|
v.menu_rounding = Rounding::same(8.0);
|
||||||
|
v.window_shadow = egui::epaint::Shadow::NONE;
|
||||||
|
v.popup_shadow = egui::epaint::Shadow {
|
||||||
|
offset: egui::vec2(0.0, 6.0),
|
||||||
|
blur: 18.0,
|
||||||
|
spread: 0.0,
|
||||||
|
color: Color32::from_black_alpha(120),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selection uses the accent wash so highlighted text/nav reads as branded.
|
||||||
|
v.selection.bg_fill = ACCENT_WASH;
|
||||||
|
v.selection.stroke = Stroke::new(1.0_f32, ACCENT_HOVER);
|
||||||
|
|
||||||
|
// Separators / hairlines.
|
||||||
|
let radius = Rounding::same(8.0);
|
||||||
|
|
||||||
|
// Non-interactive widgets (labels, separators).
|
||||||
|
v.widgets.noninteractive.bg_fill = SURFACE;
|
||||||
|
v.widgets.noninteractive.weak_bg_fill = SURFACE;
|
||||||
|
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, BORDER);
|
||||||
|
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||||
|
v.widgets.noninteractive.rounding = radius;
|
||||||
|
|
||||||
|
// Inactive interactive widgets (idle buttons).
|
||||||
|
v.widgets.inactive.bg_fill = SURFACE_HOVER;
|
||||||
|
v.widgets.inactive.weak_bg_fill = SURFACE_HOVER;
|
||||||
|
v.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, BORDER);
|
||||||
|
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||||
|
v.widgets.inactive.rounding = radius;
|
||||||
|
|
||||||
|
// Hovered.
|
||||||
|
v.widgets.hovered.bg_fill = tint(ACCENT, 0.30);
|
||||||
|
v.widgets.hovered.weak_bg_fill = tint(ACCENT, 0.30);
|
||||||
|
v.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, BORDER_STRONG);
|
||||||
|
v.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||||
|
v.widgets.hovered.rounding = radius;
|
||||||
|
v.widgets.hovered.expansion = 1.0;
|
||||||
|
|
||||||
|
// Active / pressed.
|
||||||
|
v.widgets.active.bg_fill = ACCENT_PRESSED;
|
||||||
|
v.widgets.active.weak_bg_fill = ACCENT_PRESSED;
|
||||||
|
v.widgets.active.bg_stroke = Stroke::new(1.0_f32, ACCENT);
|
||||||
|
v.widgets.active.fg_stroke = Stroke::new(1.0_f32, ON_ACCENT);
|
||||||
|
v.widgets.active.rounding = radius;
|
||||||
|
v.widgets.active.expansion = 1.0;
|
||||||
|
|
||||||
|
// Open (combo boxes / menus).
|
||||||
|
v.widgets.open.bg_fill = SURFACE_HOVER;
|
||||||
|
v.widgets.open.weak_bg_fill = SURFACE_HOVER;
|
||||||
|
v.widgets.open.bg_stroke = Stroke::new(1.0_f32, BORDER_STRONG);
|
||||||
|
v.widgets.open.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||||
|
v.widgets.open.rounding = radius;
|
||||||
|
|
||||||
|
style.visuals = v;
|
||||||
|
ctx.set_style(style);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user