feat(launcher): one-click client arming + modular preflight/services
Add a GUI "Arm client" button that reproduces client_arm.sh in a single pkexec batch: kernel.yama.ptrace_scope=0, DNAT of EA's hardcoded redirector IP to the OpenFUT server (+ MASQUERADE reply path), and /etc/hosts rewrites for every dead EA hostname (removing foreign shadow lines first, so glibc's first-match resolution can't land on a stale loopback entry). All steps are idempotent (delete-then-add) and injection-safe: config values are charset- validated and rejected on a surprising character, never shell-escaped. arm() returns the concrete change list, which the button logs line-by-line and echoes as an inline pass/fail status on the pre-launch tab (no tab jump, no reuse of the local-services toast). This necessarily lands the surrounding launcher modularization the arm feature is built on, extracted from the former monolithic app.rs/process.rs: - preflight: advisory pre-launch checks (ptrace, redirector DNAT, hostnames, backend reachability) that colour rows but never block Launch - local_services: launcher-owned LSX/autopatch child processes - game_launch, account_sync, health, netcheck helpers - openfut-common: dependency-free shared server-destination/port mapping, used by both the launcher and (separately) openfut_hook.dll openfut-hook RE changes are intentionally left uncommitted (separate concern). fmt + clippy -D warnings clean; 46 tests pass.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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);
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountSyncRequest<'a> {
|
||||
persona_id: u64,
|
||||
persona_name: &'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, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSummary {
|
||||
pub persona_id: u64,
|
||||
pub persona_name: String,
|
||||
pub level: u32,
|
||||
pub experience: u32,
|
||||
pub account_funds: 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 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(&AccountSyncRequest {
|
||||
persona_id: config.fut_persona_id,
|
||||
persona_name: config.fut_persona_name.trim(),
|
||||
level: config.fut_account_level,
|
||||
experience: config.fut_account_experience,
|
||||
experience_max: config.fut_account_experience_max,
|
||||
account_funds: config.fut_account_funds,
|
||||
account_funds_cap: config.fut_account_funds_cap,
|
||||
})
|
||||
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
|
||||
|
||||
let 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 body = &response[separator + 4..];
|
||||
if !(200..300).contains(&status) {
|
||||
let detail = String::from_utf8_lossy(body);
|
||||
return Err(format!(
|
||||
"account server rejected sync (HTTP {status}): {detail}"
|
||||
));
|
||||
}
|
||||
let envelope: AccountSyncResult = serde_json::from_slice(body)
|
||||
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
|
||||
if envelope.account.persona_id != config.fut_persona_id {
|
||||
return Err(format!(
|
||||
"account server selected persona {} instead of {}",
|
||||
envelope.account.persona_id, config.fut_persona_id
|
||||
));
|
||||
}
|
||||
Ok(envelope.account)
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user