Compare commits
1 Commits
c2772132c1
...
357501f549
| Author | SHA1 | Date | |
|---|---|---|---|
| 357501f549 |
@@ -13,3 +13,6 @@ serde_json = "1"
|
||||
dirs = "5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
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"
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
//! 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, Mutex,
|
||||
Arc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
@@ -68,15 +69,15 @@ impl AccountMonitor {
|
||||
let t_running = Arc::clone(&running);
|
||||
thread::spawn(move || {
|
||||
while t_running.load(Ordering::Relaxed) {
|
||||
let target = t_target.lock().unwrap().clone();
|
||||
let target = t_target.lock().clone();
|
||||
match target {
|
||||
None => {
|
||||
// No server configured — reset to the idle prompt state.
|
||||
*t_state.lock().unwrap() = AccountState::default();
|
||||
*t_state.lock() = AccountState::default();
|
||||
}
|
||||
Some(config) => {
|
||||
let result = account_sync::sync(&config);
|
||||
let mut state = t_state.lock().unwrap();
|
||||
let mut state = t_state.lock();
|
||||
state.configured = true;
|
||||
state.last_checked = Some(Instant::now());
|
||||
match result {
|
||||
@@ -107,11 +108,11 @@ impl AccountMonitor {
|
||||
/// 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().unwrap() = target;
|
||||
*self.target.lock() = target;
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> AccountState {
|
||||
self.state.lock().unwrap().clone()
|
||||
self.state.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+175
-21
@@ -7,11 +7,18 @@ use std::time::Duration;
|
||||
const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync";
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountSyncRequest<'a> {
|
||||
persona_id: u64,
|
||||
persona_name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
persona_id: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
persona_name: Option<&'a str>,
|
||||
level: u32,
|
||||
experience: u32,
|
||||
experience_max: u32,
|
||||
@@ -54,7 +61,64 @@ pub struct AccountSummary {
|
||||
pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||
config.validate_server()?;
|
||||
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 port = config.openfut_account_sync_port;
|
||||
let address = (host, port)
|
||||
@@ -71,16 +135,8 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||
.set_write_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
|
||||
|
||||
let payload = serde_json::to_vec(&AccountSyncRequest {
|
||||
persona_id: config.fut_persona_id,
|
||||
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 payload = serde_json::to_vec(body)
|
||||
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
|
||||
|
||||
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",
|
||||
@@ -107,21 +163,15 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.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) {
|
||||
let detail = String::from_utf8_lossy(body);
|
||||
let detail = String::from_utf8_lossy(response_body);
|
||||
return Err(format!(
|
||||
"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}"))?;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -185,4 +235,108 @@ mod tests {
|
||||
assert_eq!(selected.unopened_packs, 1);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+607
-135
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>> {
|
||||
let server = cfg.openfut_server_host.trim();
|
||||
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();
|
||||
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() {
|
||||
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 script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
|
||||
|
||||
+47
-6
@@ -359,10 +359,10 @@ impl LauncherConfig {
|
||||
/// 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());
|
||||
return Err("No FIFA 17 tools dir configured. Set it in Settings.".into());
|
||||
}
|
||||
if self.fifa17_python.trim().is_empty() {
|
||||
return Err("No Python interpreter configured. Set it in the Config tab.".into());
|
||||
return Err("No Python interpreter configured. Set it in Settings.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -380,7 +380,7 @@ impl LauncherConfig {
|
||||
} else if self.game_launch_command.trim().is_empty() {
|
||||
return Err(
|
||||
"No game configured. Fill in the game profile, or set a launch command, \
|
||||
in the Config tab."
|
||||
in Settings."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
@@ -389,10 +389,10 @@ impl LauncherConfig {
|
||||
|
||||
pub fn validate_account(&self) -> Result<(), String> {
|
||||
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() {
|
||||
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 {
|
||||
return Err("EA account level must be at least 1.".into());
|
||||
@@ -408,6 +408,21 @@ impl LauncherConfig {
|
||||
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
|
||||
/// server isn't validly configured (never emits a loopback fallback).
|
||||
///
|
||||
@@ -696,12 +711,38 @@ mod tests {
|
||||
#[test]
|
||||
fn launch_requires_a_valid_ea_account() {
|
||||
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;
|
||||
assert!(
|
||||
!c.account_configured(),
|
||||
"an id without a name is not an account"
|
||||
);
|
||||
assert!(c.validate_account().unwrap_err().contains("persona name"));
|
||||
c.fut_persona_name = "TEST_USER".into();
|
||||
assert!(c.account_configured());
|
||||
assert!(c.validate_account().is_ok());
|
||||
c.fut_account_experience = 1001;
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -23,10 +23,11 @@
|
||||
//! `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.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::GameProfile;
|
||||
@@ -35,7 +36,7 @@ use crate::logs::LogBuffer;
|
||||
type Log = Arc<Mutex<LogBuffer>>;
|
||||
|
||||
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.
|
||||
@@ -226,7 +227,7 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
||||
let buf = Arc::clone(&log);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
buf.lock().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -234,13 +235,13 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
||||
let buf = Arc::clone(&log);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
buf.lock().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
log.lock().unwrap().push(exit_msg.to_string());
|
||||
log.lock().push(exit_msg.to_string());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -6,11 +6,12 @@
|
||||
//! stops, or assumes anything about how the server is hosted; it only asks
|
||||
//! "can the FIFA client reach it right now?".
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
net::{TcpStream, ToSocketAddrs},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
Arc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
@@ -57,14 +58,14 @@ impl HealthMonitor {
|
||||
let t_running = Arc::clone(&running);
|
||||
thread::spawn(move || {
|
||||
while t_running.load(Ordering::Relaxed) {
|
||||
let target = t_target.lock().unwrap().clone();
|
||||
let target = t_target.lock().clone();
|
||||
match target {
|
||||
None => {
|
||||
*t_state.lock().unwrap() = HealthState::default();
|
||||
*t_state.lock() = HealthState::default();
|
||||
}
|
||||
Some((host, port)) => {
|
||||
let snapshot = probe(&host, port);
|
||||
*t_state.lock().unwrap() = snapshot;
|
||||
*t_state.lock() = snapshot;
|
||||
}
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
@@ -81,11 +82,11 @@ impl HealthMonitor {
|
||||
/// 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.
|
||||
pub fn set_target(&self, target: Option<(String, u16)>) {
|
||||
*self.target.lock().unwrap() = target;
|
||||
*self.target.lock() = target;
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> HealthState {
|
||||
self.state.lock().unwrap().clone()
|
||||
self.state.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-19
@@ -13,11 +13,12 @@
|
||||
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
|
||||
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
sync::{mpsc, Arc},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
@@ -138,22 +139,19 @@ impl ManagedService {
|
||||
if let Some(result) = self.stopping.as_ref() {
|
||||
match result.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] {label} stopped."));
|
||||
log.lock().push(format!("[launcher] {label} stopped."));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] failed to stop {label}: {error}"));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => return true,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
log.lock().unwrap().push(format!(
|
||||
log.lock().push(format!(
|
||||
"[launcher] {label} stop worker exited unexpectedly."
|
||||
));
|
||||
self.stopping = None;
|
||||
@@ -168,7 +166,6 @@ impl ManagedService {
|
||||
Ok(None) => true,
|
||||
Ok(Some(status)) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] {label} exited ({status})."));
|
||||
self.child = None;
|
||||
false
|
||||
@@ -189,9 +186,7 @@ impl ManagedService {
|
||||
}
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let label = service.label();
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] stopping {label}…"));
|
||||
log.lock().push(format!("[launcher] stopping {label}…"));
|
||||
|
||||
self.stopping = Some(dispatch_stop_work(move || {
|
||||
child
|
||||
@@ -245,7 +240,7 @@ pub fn spawn(
|
||||
let dir = Path::new(tools_dir);
|
||||
if !dir.is_dir() {
|
||||
anyhow::bail!(
|
||||
"FIFA 17 tools dir not found: {} (set it in the Config tab)",
|
||||
"FIFA 17 tools dir not found: {} (set it in Settings)",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
@@ -283,7 +278,7 @@ pub fn spawn(
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
log.lock().unwrap().push(format!(
|
||||
log.lock().push(format!(
|
||||
"[launcher] starting {label}: {} {}",
|
||||
python,
|
||||
script_path.display(),
|
||||
@@ -304,7 +299,7 @@ pub fn spawn(
|
||||
let mut registered = false;
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
// 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 {
|
||||
continue;
|
||||
@@ -317,9 +312,9 @@ pub fn spawn(
|
||||
};
|
||||
registered = true;
|
||||
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!(
|
||||
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
|
||||
));
|
||||
@@ -336,11 +331,9 @@ pub fn spawn(
|
||||
) {
|
||||
Ok(()) => buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("[fifa17] capability registered with backend".to_string()),
|
||||
Err(error) => buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("[fifa17] capability registration failed: {error}")),
|
||||
}
|
||||
}
|
||||
@@ -351,7 +344,7 @@ pub fn spawn(
|
||||
let lbl = label.to_string();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
|
||||
buf.lock().push(format!("[{lbl}] {line}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -364,7 +357,6 @@ pub fn spawn(
|
||||
return Err(error);
|
||||
}
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
mod account_sync;
|
||||
mod account_monitor;
|
||||
mod account_sync;
|
||||
mod app;
|
||||
mod arm;
|
||||
mod config;
|
||||
|
||||
+95
-4
@@ -92,6 +92,7 @@ pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||
ea_redirect(cfg),
|
||||
hostname_mapping(cfg),
|
||||
backend_reachable(cfg),
|
||||
hook_config(cfg),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -261,6 +262,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 {
|
||||
match (host, port).to_socket_addrs() {
|
||||
Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()),
|
||||
@@ -289,12 +334,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
||||
// The distinction that matters: a fresh config must not display four
|
||||
// green ticks. "Not checked" is not "checked and fine".
|
||||
// The distinction that matters: a fresh config must not display a column
|
||||
// of green ticks. "Not checked" is not "checked and fine".
|
||||
let mut c = cfg();
|
||||
// `default()` points this at a conventional path whose existence varies
|
||||
// by machine. Pin it so the assertion is about the code, not this box.
|
||||
// `default()` points these at conventional paths whose existence varies
|
||||
// by machine. Pin them 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);
|
||||
assert!(
|
||||
checks.iter().all(|k| k.state == State::Skipped),
|
||||
@@ -395,4 +441,49 @@ mod tests {
|
||||
assert_eq!(check.state, State::Fail, "{}", 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);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-8
@@ -67,6 +67,9 @@ pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
|
||||
|
||||
// ── 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
|
||||
/// openfut.cfg with the structured server configuration the hook reads.
|
||||
/// `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::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(())
|
||||
}
|
||||
|
||||
/// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the
|
||||
/// full structured `openfut.cfg` body.
|
||||
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() {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn remove_hook_dll(game_dir: &Path) -> anyhow::Result<()> {
|
||||
let dest = game_dir.join("version.dll");
|
||||
@@ -127,13 +139,13 @@ pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %comman
|
||||
pub fn launch_game(
|
||||
command: &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>>,
|
||||
) -> anyhow::Result<()> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
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");
|
||||
@@ -145,7 +157,6 @@ pub fn launch_game(
|
||||
|
||||
log_buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] launching game: {command}"));
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
@@ -154,7 +165,7 @@ pub fn launch_game(
|
||||
let buf = std::sync::Arc::clone(&log_buf);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
buf.lock().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -162,7 +173,7 @@ pub fn launch_game(
|
||||
let buf = std::sync::Arc::clone(&log_buf);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
buf.lock().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -172,7 +183,6 @@ pub fn launch_game(
|
||||
let _ = child.wait();
|
||||
log_buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("[launcher] game process exited.".to_string());
|
||||
});
|
||||
|
||||
|
||||
+1
-6
@@ -131,12 +131,7 @@ pub fn status_pill(ui: &mut egui::Ui, label: &str, status: Status) {
|
||||
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(),
|
||||
);
|
||||
ui.label(egui::RichText::new(label).color(color).size(12.0).strong());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user