launcher: guided first-run flow, server-owned settings, hook-config reconcile
Release-readiness pass on the launcher, driven by the end state "open it,
create an account, launch the game".
Fixes a silent correctness bug. `openfut.cfg` in the game dir is the only
server address the *game* can see, but it was written only by Setup's deploy
and its "Save & Update hook" button. Changing the server anywhere else left
FIFA connecting to the previous host while every panel in the launcher showed
the new one online. Now:
- `write_hook_config` reconciles the file from the live config, and runs
fail-closed before every launch, so the file and the UI cannot disagree at
the moment it matters;
- saving Settings pushes the address into the hook immediately;
- a `hook_config` preflight check reads the file back and warns, naming both
addresses, instead of leaving the drift invisible;
- Settings shows the same fact inline, and Save is enabled by drift alone —
a message saying "Save to update it" beside a disabled button is a dead end.
Account creation is now server-authoritative. `account_sync::discover` POSTs
`/openfut/account/sync` with the persona fields *omitted*, which makes the host
answer with the persona it was started with, its club, and the Core coin
balance. The launcher adopts that answer, so it never invents an identity and
the persona the game authenticates with is by construction the one the server
expects. Claiming is gated on the address being valid, NOT on the health pill:
that pill probes the HTTPS port while this talks to the account port, so gating
on it disabled the button on servers that answer it perfectly well.
UX consolidation:
- new Welcome ("Get started") tab: three numbered steps — connect, claim an
account, connect FIFA — each showing live state, ending in the launch CTA;
a fresh install opens on it and it leaves the nav rail once satisfied;
- Config renamed Settings, and made the single owner of the server address:
Setup's duplicate editors (same fields, different save semantics) are now a
read-only summary with actions;
- the dashboard offers account creation in place instead of naming a tab, and
the stale "set the host in the Setup tab" pointers are corrected.
Locks move to parking_lot per project rule (already the convention in
openfut-utas-host and openfut-identity); 47 poisoning unwraps go away.
Verified: 58 tests pass, fmt clean, clippy clean apart from one pre-existing
lint. Driven through the real UI under Xvfb as a fresh install — typed a server,
clicked Create my account, and the config on disk came back with persona
33068179/CAGE claimed from the live host; clicking Save rewrote a stale
`openfut.cfg` from host=10.10.0.99 to host=127.0.0.1.
This commit is contained in:
@@ -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