357501f549
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.
343 lines
14 KiB
Rust
343 lines
14 KiB
Rust
use crate::config::LauncherConfig;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::io::{Read, Write};
|
|
use std::net::{TcpStream, ToSocketAddrs};
|
|
use std::time::Duration;
|
|
|
|
const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync";
|
|
const TIMEOUT: Duration = Duration::from_secs(3);
|
|
|
|
/// The launcher's view of the account, sent on every sync.
|
|
///
|
|
/// `persona_id`/`persona_name` are `Option` because omitting them is meaningful:
|
|
/// the server then answers with the persona *it* is configured for, which is how
|
|
/// first-run account creation learns an identity instead of inventing one.
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct AccountSyncRequest<'a> {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
persona_id: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
persona_name: Option<&'a str>,
|
|
level: u32,
|
|
experience: u32,
|
|
experience_max: u32,
|
|
account_funds: u32,
|
|
account_funds_cap: u32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AccountSyncResult {
|
|
pub account: AccountSummary,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AccountSummary {
|
|
pub persona_id: u64,
|
|
pub persona_name: String,
|
|
/// Club identity for the account bar. Optional in older envelopes.
|
|
#[serde(default)]
|
|
pub club_name: String,
|
|
#[serde(default)]
|
|
pub club_abbr: String,
|
|
pub level: u32,
|
|
pub experience: u32,
|
|
/// XP required for the next level. Optional; 0 means "unknown".
|
|
#[serde(default)]
|
|
pub experience_max: u32,
|
|
pub account_funds: u32,
|
|
/// EASFC funds ceiling. Optional; 0 means "unknown".
|
|
#[serde(default)]
|
|
pub account_funds_cap: u32,
|
|
pub coins: i64,
|
|
pub unopened_packs: usize,
|
|
}
|
|
|
|
/// Select the persistent EA/FUT account before LSX and FIFA start.
|
|
///
|
|
/// This deliberately uses a tiny stdlib HTTP client so the launcher does not
|
|
/// acquire an async runtime solely for one bounded control-plane request.
|
|
pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
|
config.validate_server()?;
|
|
config.validate_account()?;
|
|
let account = post(
|
|
config,
|
|
&AccountSyncRequest {
|
|
persona_id: Some(config.fut_persona_id),
|
|
persona_name: Some(config.fut_persona_name.trim()),
|
|
level: config.fut_account_level,
|
|
experience: config.fut_account_experience,
|
|
experience_max: config.fut_account_experience_max,
|
|
account_funds: config.fut_account_funds,
|
|
account_funds_cap: config.fut_account_funds_cap,
|
|
},
|
|
)?;
|
|
// The server echoes the persona it selected. A different one means the two
|
|
// sides disagree about who is playing, which must never pass silently.
|
|
if account.persona_id != config.fut_persona_id {
|
|
return Err(format!(
|
|
"account server selected persona {} instead of {}",
|
|
account.persona_id, config.fut_persona_id
|
|
));
|
|
}
|
|
Ok(account)
|
|
}
|
|
|
|
/// Ask the server which account it serves, for first-run account creation.
|
|
///
|
|
/// Sending no persona makes the server fall back to the one it was started with
|
|
/// and answer with its real club and Core coin balance. That is the whole reason
|
|
/// the launcher never has to invent a persona id: the identity that matters is
|
|
/// the server's, and this is how it is claimed.
|
|
pub fn discover(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
|
config.validate_server()?;
|
|
let account = post(
|
|
config,
|
|
&AccountSyncRequest {
|
|
persona_id: None,
|
|
persona_name: None,
|
|
level: config.fut_account_level.max(1),
|
|
experience: config.fut_account_experience,
|
|
experience_max: config.fut_account_experience_max.max(1),
|
|
account_funds: config.fut_account_funds,
|
|
account_funds_cap: config.fut_account_funds_cap,
|
|
},
|
|
)?;
|
|
if account.persona_id == 0 {
|
|
return Err(
|
|
"account server returned no persona — is it configured with \
|
|
a persona id?"
|
|
.to_string(),
|
|
);
|
|
}
|
|
if account.persona_name.trim().is_empty() {
|
|
return Err("account server returned an empty persona name".to_string());
|
|
}
|
|
Ok(account)
|
|
}
|
|
|
|
/// One bounded POST to `/openfut/account/sync`, returning the account summary.
|
|
fn post(config: &LauncherConfig, body: &AccountSyncRequest<'_>) -> Result<AccountSummary, String> {
|
|
let host = config.openfut_server_host.trim();
|
|
let port = config.openfut_account_sync_port;
|
|
let address = (host, port)
|
|
.to_socket_addrs()
|
|
.map_err(|error| format!("cannot resolve account server {host}:{port}: {error}"))?
|
|
.next()
|
|
.ok_or_else(|| format!("account server {host}:{port} resolved to no addresses"))?;
|
|
let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)
|
|
.map_err(|error| format!("cannot connect to account server {host}:{port}: {error}"))?;
|
|
stream
|
|
.set_read_timeout(Some(TIMEOUT))
|
|
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
|
|
stream
|
|
.set_write_timeout(Some(TIMEOUT))
|
|
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
|
|
|
|
let payload = serde_json::to_vec(body)
|
|
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
|
|
|
|
let request = format!(
|
|
"POST {ACCOUNT_SYNC_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
payload.len()
|
|
);
|
|
stream
|
|
.write_all(request.as_bytes())
|
|
.and_then(|()| stream.write_all(&payload))
|
|
.map_err(|error| format!("cannot send account sync request: {error}"))?;
|
|
|
|
let mut response = Vec::new();
|
|
stream
|
|
.read_to_end(&mut response)
|
|
.map_err(|error| format!("cannot read account sync response: {error}"))?;
|
|
let separator = response
|
|
.windows(4)
|
|
.position(|window| window == b"\r\n\r\n")
|
|
.ok_or_else(|| "account server returned a malformed HTTP response".to_string())?;
|
|
let headers = std::str::from_utf8(&response[..separator])
|
|
.map_err(|_| "account server returned non-UTF-8 headers".to_string())?;
|
|
let status = headers
|
|
.lines()
|
|
.next()
|
|
.and_then(|line| line.split_whitespace().nth(1))
|
|
.and_then(|value| value.parse::<u16>().ok())
|
|
.ok_or_else(|| "account server returned a malformed status line".to_string())?;
|
|
let response_body = &response[separator + 4..];
|
|
if !(200..300).contains(&status) {
|
|
let detail = String::from_utf8_lossy(response_body);
|
|
return Err(format!(
|
|
"account server rejected sync (HTTP {status}): {detail}"
|
|
));
|
|
}
|
|
let envelope: AccountSyncResult = serde_json::from_slice(response_body)
|
|
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
|
|
Ok(envelope.account)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::net::TcpListener;
|
|
use std::thread;
|
|
|
|
#[test]
|
|
fn sync_posts_account_and_reads_selected_profile() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let server = 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;
|
|
}
|
|
}
|
|
}
|
|
let request = String::from_utf8_lossy(&request);
|
|
assert!(request.starts_with("POST /openfut/account/sync HTTP/1.1"));
|
|
assert!(request.contains("\"personaId\":12345678"));
|
|
assert!(request.contains("\"personaName\":\"TEST_USER\""));
|
|
let body = r#"{"status":"OK","account":{"personaId":12345678,"personaName":"TEST_USER","level":7,"experience":200,"accountFunds":50,"coins":15000,"unopenedPacks":1}}"#;
|
|
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();
|
|
});
|
|
|
|
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(),
|
|
fut_account_level: 7,
|
|
fut_account_experience: 200,
|
|
..LauncherConfig::default()
|
|
};
|
|
let selected = sync(&config).unwrap();
|
|
assert_eq!(selected.persona_name, "TEST_USER");
|
|
assert_eq!(selected.coins, 15000);
|
|
assert_eq!(selected.unopened_packs, 1);
|
|
server.join().unwrap();
|
|
}
|
|
|
|
/// Serve exactly one `/openfut/account/sync` POST, handing the decoded
|
|
/// request text to `inspect` and replying with `body`.
|
|
fn serve_once(
|
|
inspect: impl FnOnce(&str) + Send + 'static,
|
|
body: &'static str,
|
|
) -> (u16, thread::JoinHandle<()>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let handle = thread::spawn(move || {
|
|
let (mut socket, _) = listener.accept().unwrap();
|
|
let mut request = Vec::new();
|
|
loop {
|
|
let mut chunk = [0; 1024];
|
|
let count = socket.read(&mut chunk).unwrap();
|
|
assert!(count > 0);
|
|
request.extend_from_slice(&chunk[..count]);
|
|
if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") {
|
|
let headers = String::from_utf8_lossy(&request[..separator]);
|
|
let length = headers
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("Content-Length: "))
|
|
.unwrap()
|
|
.parse::<usize>()
|
|
.unwrap();
|
|
if request.len() >= separator + 4 + length {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
inspect(&String::from_utf8_lossy(&request));
|
|
write!(
|
|
socket,
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
)
|
|
.unwrap();
|
|
});
|
|
(port, handle)
|
|
}
|
|
|
|
#[test]
|
|
fn discover_omits_the_persona_so_the_server_names_its_own() {
|
|
// The point of first-run discovery: the launcher must not send a guessed
|
|
// persona, because the server would echo the guess straight back.
|
|
let (port, server) = serve_once(
|
|
|request| {
|
|
assert!(!request.contains("personaId"), "{request}");
|
|
assert!(!request.contains("personaName"), "{request}");
|
|
},
|
|
r#"{"status":"OK","account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC","level":1,"experience":0,"accountFunds":0,"coins":29876776,"unopenedPacks":0}}"#,
|
|
);
|
|
let config = LauncherConfig {
|
|
openfut_server_host: "127.0.0.1".into(),
|
|
openfut_account_sync_port: port,
|
|
..LauncherConfig::default()
|
|
};
|
|
// Deliberately an unconfigured account: discovery must work before one
|
|
// exists, which is the whole reason it does not call `validate_account`.
|
|
assert_eq!(config.fut_persona_id, 0);
|
|
let found = discover(&config).unwrap();
|
|
assert_eq!(found.persona_id, 33_068_179);
|
|
assert_eq!(found.persona_name, "CAGE");
|
|
assert_eq!(found.club_name, "OpenFUT");
|
|
assert_eq!(found.coins, 29_876_776);
|
|
server.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn discover_rejects_a_server_that_names_no_persona() {
|
|
// A zero persona would otherwise be written into the config as a real
|
|
// account and fail much later, at launch, as a mismatch.
|
|
let (port, server) = serve_once(
|
|
|_| {},
|
|
r#"{"status":"OK","account":{"personaId":0,"personaName":"","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
|
|
);
|
|
let config = LauncherConfig {
|
|
openfut_server_host: "127.0.0.1".into(),
|
|
openfut_account_sync_port: port,
|
|
..LauncherConfig::default()
|
|
};
|
|
let error = discover(&config).unwrap_err();
|
|
assert!(error.contains("no persona"), "{error}");
|
|
server.join().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn sync_refuses_a_server_that_selects_a_different_persona() {
|
|
let (port, server) = serve_once(
|
|
|_| {},
|
|
r#"{"status":"OK","account":{"personaId":999,"personaName":"OTHER","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
|
|
);
|
|
let config = LauncherConfig {
|
|
openfut_server_host: "127.0.0.1".into(),
|
|
openfut_account_sync_port: port,
|
|
fut_persona_id: 12345678,
|
|
fut_persona_name: "TEST_USER".into(),
|
|
..LauncherConfig::default()
|
|
};
|
|
let error = sync(&config).unwrap_err();
|
|
assert!(error.contains("999"), "{error}");
|
|
server.join().unwrap();
|
|
}
|
|
}
|