Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 357501f549 |
@@ -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"
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
//! target the UI re-points when the server config changes, and a shared state
|
//! target the UI re-points when the server config changes, and a shared state
|
||||||
//! snapshot the UI renders each frame.
|
//! snapshot the UI renders each frame.
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::{
|
use std::{
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc, Mutex,
|
Arc,
|
||||||
},
|
},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
@@ -68,15 +69,15 @@ impl AccountMonitor {
|
|||||||
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 => {
|
||||||
// No server configured — reset to the idle prompt state.
|
// No server configured — reset to the idle prompt state.
|
||||||
*t_state.lock().unwrap() = AccountState::default();
|
*t_state.lock() = AccountState::default();
|
||||||
}
|
}
|
||||||
Some(config) => {
|
Some(config) => {
|
||||||
let result = account_sync::sync(&config);
|
let result = account_sync::sync(&config);
|
||||||
let mut state = t_state.lock().unwrap();
|
let mut state = t_state.lock();
|
||||||
state.configured = true;
|
state.configured = true;
|
||||||
state.last_checked = Some(Instant::now());
|
state.last_checked = Some(Instant::now());
|
||||||
match result {
|
match result {
|
||||||
@@ -107,11 +108,11 @@ impl AccountMonitor {
|
|||||||
/// Point the monitor at a new server/account. `None` (no server configured)
|
/// Point the monitor at a new server/account. `None` (no server configured)
|
||||||
/// puts it back into the idle prompt state.
|
/// puts it back into the idle prompt state.
|
||||||
pub fn set_target(&self, target: Option<LauncherConfig>) {
|
pub fn set_target(&self, target: Option<LauncherConfig>) {
|
||||||
*self.target.lock().unwrap() = target;
|
*self.target.lock() = target;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn snapshot(&self) -> AccountState {
|
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 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,
|
||||||
@@ -54,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)
|
||||||
@@ -71,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",
|
||||||
@@ -107,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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>> {
|
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)?;
|
||||||
|
|||||||
+47
-6
@@ -359,10 +359,10 @@ impl LauncherConfig {
|
|||||||
/// this ensures required user configuration is never silently invented.
|
/// this ensures required user configuration is never silently invented.
|
||||||
pub fn validate_local_services(&self) -> Result<(), String> {
|
pub fn validate_local_services(&self) -> Result<(), String> {
|
||||||
if self.fifa17_tools_dir.trim().is_empty() {
|
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() {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -380,7 +380,7 @@ 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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -389,10 +389,10 @@ impl LauncherConfig {
|
|||||||
|
|
||||||
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());
|
||||||
@@ -408,6 +408,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).
|
||||||
///
|
///
|
||||||
@@ -696,12 +711,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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -23,10 +23,11 @@
|
|||||||
//! `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::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,7 +36,7 @@ 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.
|
||||||
@@ -226,7 +227,7 @@ 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(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 +235,13 @@ 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());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-19
@@ -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,
|
||||||
process::{Child, Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
sync::{mpsc, Arc, Mutex},
|
sync::{mpsc, Arc},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,22 +139,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 +166,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
|
||||||
@@ -189,9 +186,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
|
||||||
@@ -245,7 +240,7 @@ pub fn spawn(
|
|||||||
let dir = Path::new(tools_dir);
|
let dir = Path::new(tools_dir);
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
anyhow::bail!(
|
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()
|
dir.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -283,7 +278,7 @@ pub fn spawn(
|
|||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
log.lock().unwrap().push(format!(
|
log.lock().push(format!(
|
||||||
"[launcher] starting {label}: {} {}",
|
"[launcher] starting {label}: {} {}",
|
||||||
python,
|
python,
|
||||||
script_path.display(),
|
script_path.display(),
|
||||||
@@ -304,7 +299,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 +312,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 +331,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,7 +344,7 @@ 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}"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -364,7 +357,6 @@ pub fn spawn(
|
|||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
mod account_sync;
|
|
||||||
mod account_monitor;
|
mod account_monitor;
|
||||||
|
mod account_sync;
|
||||||
mod app;
|
mod app;
|
||||||
mod arm;
|
mod arm;
|
||||||
mod config;
|
mod config;
|
||||||
|
|||||||
+95
-4
@@ -92,6 +92,7 @@ pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
|||||||
ea_redirect(cfg),
|
ea_redirect(cfg),
|
||||||
hostname_mapping(cfg),
|
hostname_mapping(cfg),
|
||||||
backend_reachable(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 {
|
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,12 +334,13 @@ 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".
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
// `default()` points this at a conventional path whose existence varies
|
// `default()` points these at conventional paths whose existence varies
|
||||||
// by machine. Pin it so the assertion is about the code, not this box.
|
// by machine. Pin them so the assertion is about the code, not this box.
|
||||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||||
|
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
|
||||||
let checks = run(&c);
|
let checks = run(&c);
|
||||||
assert!(
|
assert!(
|
||||||
checks.iter().all(|k| k.state == State::Skipped),
|
checks.iter().all(|k| k.state == State::Skipped),
|
||||||
@@ -395,4 +441,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-8
@@ -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,13 @@ 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>>,
|
||||||
) -> 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 +157,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 +165,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,7 +173,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(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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -172,7 +183,6 @@ pub fn launch_game(
|
|||||||
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());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-6
@@ -131,12 +131,7 @@ pub fn status_pill(ui: &mut egui::Ui, label: &str, status: Status) {
|
|||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.spacing_mut().item_spacing.x = 6.0;
|
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(status.glyph()).color(color).size(11.0));
|
||||||
ui.label(
|
ui.label(egui::RichText::new(label).color(color).size(12.0).strong());
|
||||||
egui::RichText::new(label)
|
|
||||||
.color(color)
|
|
||||||
.size(12.0)
|
|
||||||
.strong(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user