feat: initial OpenFUT Launcher

GUI desktop app (egui/eframe) that manages the openfut-core and
openfut-bridge services with a single window.

- Dashboard: start/stop each service independently or together; live
  status indicators; quick-status for cert and hosts
- Logs: real-time stdout/stderr from both processes, colour-coded by
  level, follow mode and manual clear
- Setup: add/remove EA hostname redirects in /etc/hosts (pkexec/sudo);
  install the bridge TLS cert into the system CA store
- Config: all binary paths and env vars editable in-app; persisted to
  ~/.config/openfut-launcher/config.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 20:02:02 -07:00
commit fc894a7f77
9 changed files with 5306 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
target/
Generated
+4393
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "openfut-launcher"
version = "0.1.0"
edition = "2021"
[dependencies]
eframe = "0.29"
egui = "0.29"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
chrono = { version = "0.4", features = ["serde"] }
+488
View File
@@ -0,0 +1,488 @@
use std::sync::{Arc, Mutex};
use egui::{
Color32, FontId, RichText, ScrollArea, Ui, Vec2,
};
use crate::{
config::LauncherConfig,
logs::LogBuffer,
process::{ServiceHandle, ServiceStatus},
setup,
};
#[derive(PartialEq)]
enum Tab {
Dashboard,
Logs,
Setup,
Config,
}
pub struct LauncherApp {
config: LauncherConfig,
config_dirty: bool,
core: ServiceHandle,
bridge: ServiceHandle,
core_logs: Arc<Mutex<LogBuffer>>,
bridge_logs: Arc<Mutex<LogBuffer>>,
active_tab: Tab,
log_tab: usize, // 0 = core, 1 = bridge
log_follow: bool,
// Setup state
hosts_configured: bool,
cert_path: Option<std::path::PathBuf>,
setup_message: Option<(bool, String)>, // (success, text)
}
impl LauncherApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
// Slightly larger default font
let mut style = (*cc.egui_ctx.style()).clone();
style.text_styles.insert(
egui::TextStyle::Body,
FontId::proportional(14.0),
);
style.text_styles.insert(
egui::TextStyle::Monospace,
FontId::monospace(13.0),
);
cc.egui_ctx.set_style(style);
let config = LauncherConfig::load();
let cert_path = setup::find_bridge_cert(&config.bridge_captures_dir);
let hosts_configured = setup::hosts_configured();
Self {
config,
config_dirty: false,
core: ServiceHandle::new(),
bridge: ServiceHandle::new(),
core_logs: Arc::new(Mutex::new(LogBuffer::new())),
bridge_logs: Arc::new(Mutex::new(LogBuffer::new())),
active_tab: Tab::Dashboard,
log_tab: 0,
log_follow: true,
hosts_configured,
cert_path,
setup_message: None,
}
}
fn start_core(&mut self) {
let env = self.config.core_env();
if let Err(e) = self.core.start(
&self.config.core_binary.clone(),
&env,
Arc::clone(&self.core_logs),
) {
self.core_logs
.lock()
.unwrap()
.push(format!("[launcher] Failed to start core: {e}"));
}
}
fn start_bridge(&mut self) {
let env = self.config.bridge_env();
if let Err(e) = self.bridge.start(
&self.config.bridge_binary.clone(),
&env,
Arc::clone(&self.bridge_logs),
) {
self.bridge_logs
.lock()
.unwrap()
.push(format!("[launcher] Failed to start bridge: {e}"));
}
}
fn stop_all(&mut self) {
self.bridge.stop();
self.core.stop();
}
// ── UI sections ───────────────────────────────────────────────────────────
fn ui_dashboard(&mut self, ui: &mut Ui) {
ui.add_space(8.0);
ui.heading("Services");
ui.add_space(6.0);
let core_running = self.core.is_running();
let bridge_running = self.bridge.is_running();
egui::Grid::new("svc_grid")
.num_columns(4)
.spacing([16.0, 8.0])
.show(ui, |ui| {
// Header
ui.strong("Service");
ui.strong("Status");
ui.label(""); // start btn
ui.label(""); // stop btn
ui.end_row();
// Core
ui.label("openfut-core");
self.status_badge(ui, &self.core.status());
ui.add_enabled_ui(!core_running, |ui| {
if ui.button("▶ Start").clicked() {
self.start_core();
}
});
ui.add_enabled_ui(core_running, |ui| {
if ui.button("■ Stop").clicked() {
self.core.stop();
}
});
ui.end_row();
// Bridge
ui.label("openfut-bridge");
self.status_badge(ui, &self.bridge.status());
ui.add_enabled_ui(!bridge_running, |ui| {
if ui.button("▶ Start").clicked() {
self.start_bridge();
}
});
ui.add_enabled_ui(bridge_running, |ui| {
if ui.button("■ Stop").clicked() {
self.bridge.stop();
}
});
ui.end_row();
});
ui.add_space(12.0);
ui.separator();
ui.add_space(8.0);
// Quick-launch buttons
ui.horizontal(|ui| {
if ui
.add_sized([120.0, 32.0], egui::Button::new("▶▶ Start All"))
.clicked()
{
self.start_core();
self.start_bridge();
}
ui.add_space(8.0);
if ui
.add_sized([120.0, 32.0], egui::Button::new("■■ Stop All"))
.clicked()
{
self.stop_all();
}
});
ui.add_space(16.0);
ui.separator();
ui.add_space(8.0);
ui.heading("Quick Status");
ui.add_space(4.0);
egui::Grid::new("quick_status")
.num_columns(2)
.spacing([12.0, 4.0])
.show(ui, |ui| {
ui.label("Hosts file:");
if self.hosts_configured {
ui.colored_label(Color32::from_rgb(80, 200, 120), "Configured");
} else {
ui.colored_label(Color32::from_rgb(220, 150, 0), "Not configured");
}
ui.end_row();
ui.label("Bridge cert:");
match &self.cert_path {
Some(p) => {
ui.colored_label(
Color32::from_rgb(80, 200, 120),
format!("Found ({})", p.file_name().unwrap_or_default().to_string_lossy()),
);
}
None => {
ui.colored_label(Color32::from_rgb(220, 150, 0), "Not generated yet — start bridge first");
}
}
ui.end_row();
ui.label("Core URL:");
ui.monospace(&self.config.core_listen_addr);
ui.end_row();
ui.label("Bridge URL:");
ui.monospace(&self.config.bridge_listen_addr);
ui.end_row();
});
}
fn ui_logs(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.selectable_value(&mut self.log_tab, 0, "Core");
ui.selectable_value(&mut self.log_tab, 1, "Bridge");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Clear").clicked() {
if self.log_tab == 0 {
self.core_logs.lock().unwrap().clear();
} else {
self.bridge_logs.lock().unwrap().clear();
}
}
ui.checkbox(&mut self.log_follow, "Follow");
});
});
ui.separator();
let buf = if self.log_tab == 0 {
Arc::clone(&self.core_logs)
} else {
Arc::clone(&self.bridge_logs)
};
let follow = self.log_follow;
ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(follow)
.show(ui, |ui| {
let guard = buf.lock().unwrap();
for line in guard.lines() {
let color = log_line_color(line);
ui.add(
egui::Label::new(
RichText::new(line).monospace().color(color).size(12.5),
)
.wrap(),
);
}
});
}
fn ui_setup(&mut self, ui: &mut Ui) {
ui.add_space(8.0);
ui.heading("System Setup");
ui.add_space(4.0);
ui.label("These steps route FIFA 23 traffic through the emulator. Both require elevated privileges (polkit/sudo).");
ui.add_space(12.0);
// ── Hosts file ────────────────────────────────────────────────────────
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.strong("Step 1 — Redirect EA hostnames");
ui.add_space(4.0);
ui.label("Adds entries to /etc/hosts so the game connects to the bridge instead of EA's servers.");
ui.add_space(6.0);
// Refresh status
self.hosts_configured = setup::hosts_configured();
ui.horizontal(|ui| {
if self.hosts_configured {
ui.colored_label(Color32::from_rgb(80, 200, 120), "✔ Configured");
ui.add_space(8.0);
if ui.button("Remove entries").clicked() {
match setup::remove_hosts_entries() {
Ok(()) => {
self.setup_message = Some((true, "Hosts entries removed.".into()));
self.hosts_configured = false;
}
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
}
}
} else {
ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not configured");
ui.add_space(8.0);
if ui.button("Add entries").clicked() {
match setup::add_hosts_entries() {
Ok(()) => {
self.setup_message = Some((true, "Hosts entries added.".into()));
self.hosts_configured = true;
}
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
}
}
}
});
});
ui.add_space(12.0);
// ── Cert install ──────────────────────────────────────────────────────
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.strong("Step 2 — Install TLS certificate");
ui.add_space(4.0);
ui.label("Installs the bridge's self-signed cert into the system CA store so the game accepts HTTPS connections.");
ui.add_space(6.0);
// Refresh cert path
self.cert_path = setup::find_bridge_cert(&self.config.bridge_captures_dir);
match &self.cert_path.clone() {
None => {
ui.colored_label(
Color32::from_rgb(220, 150, 0),
"⚠ Cert not found — start the bridge at least once to generate it.",
);
}
Some(cert) => {
ui.label(format!("Cert: {}", cert.display()));
ui.add_space(4.0);
if ui.button("Install cert (requires sudo)").clicked() {
match setup::install_cert(cert) {
Ok(()) => self.setup_message = Some((true, "Certificate installed.".into())),
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
}
}
}
}
});
ui.add_space(12.0);
// ── Status message ────────────────────────────────────────────────────
if let Some((ok, msg)) = &self.setup_message {
let color = if *ok {
Color32::from_rgb(80, 200, 120)
} else {
Color32::from_rgb(220, 60, 60)
};
ui.colored_label(color, msg);
}
}
fn ui_config(&mut self, ui: &mut Ui) {
ui.add_space(8.0);
ui.heading("Configuration");
ui.add_space(8.0);
let mut changed = false;
egui::Grid::new("config_grid")
.num_columns(2)
.spacing([12.0, 8.0])
.min_col_width(120.0)
.show(ui, |ui| {
ui.strong("Core");
ui.label("");
ui.end_row();
ui.label("Binary path:");
changed |= ui.text_edit_singleline(&mut self.config.core_binary).changed();
ui.end_row();
ui.label("Listen addr:");
changed |= ui.text_edit_singleline(&mut self.config.core_listen_addr).changed();
ui.end_row();
ui.label("Database URL:");
changed |= ui.text_edit_singleline(&mut self.config.core_database_url).changed();
ui.end_row();
ui.label("Data dir:");
changed |= ui.text_edit_singleline(&mut self.config.core_data_dir).changed();
ui.end_row();
ui.add_space(4.0);
ui.label("");
ui.end_row();
ui.strong("Bridge");
ui.label("");
ui.end_row();
ui.label("Binary path:");
changed |= ui.text_edit_singleline(&mut self.config.bridge_binary).changed();
ui.end_row();
ui.label("Listen addr:");
changed |= ui.text_edit_singleline(&mut self.config.bridge_listen_addr).changed();
ui.end_row();
ui.label("Core URL:");
changed |= ui.text_edit_singleline(&mut self.config.bridge_core_url).changed();
ui.end_row();
ui.label("Captures dir:");
changed |= ui.text_edit_singleline(&mut self.config.bridge_captures_dir).changed();
ui.end_row();
ui.label("TLS enabled:");
changed |= ui.checkbox(&mut self.config.bridge_tls_enabled, "").changed();
ui.end_row();
});
if changed {
self.config_dirty = true;
}
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui
.add_enabled(self.config_dirty, egui::Button::new("Save"))
.clicked()
{
self.config.save();
self.config_dirty = false;
}
if ui.button("Reset to defaults").clicked() {
self.config = LauncherConfig::default();
self.config_dirty = true;
}
});
}
fn status_badge(&self, ui: &mut Ui, status: &ServiceStatus) {
let color = status.color();
let label = status.label();
ui.colored_label(color, label);
}
}
impl eframe::App for LauncherApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Repaint regularly to keep log views and status fresh
ctx.request_repaint_after(std::time::Duration::from_millis(500));
egui::TopBottomPanel::top("tab_bar").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.selectable_value(&mut self.active_tab, Tab::Dashboard, "Dashboard");
ui.selectable_value(&mut self.active_tab, Tab::Logs, "Logs");
ui.selectable_value(&mut self.active_tab, Tab::Setup, "Setup");
ui.selectable_value(&mut self.active_tab, Tab::Config, "Config");
});
ui.add_space(2.0);
});
egui::CentralPanel::default().show(ctx, |ui| {
ui.set_min_size(Vec2::new(600.0, 400.0));
match self.active_tab {
Tab::Dashboard => self.ui_dashboard(ui),
Tab::Logs => self.ui_logs(ui),
Tab::Setup => self.ui_setup(ui),
Tab::Config => self.ui_config(ui),
}
});
}
}
fn log_line_color(line: &str) -> Color32 {
let lower = line.to_lowercase();
if lower.contains("error") || lower.contains("panic") {
Color32::from_rgb(220, 80, 80)
} else if lower.contains("warn") {
Color32::from_rgb(255, 200, 0)
} else if lower.contains("info") {
Color32::from_rgb(160, 210, 255)
} else {
Color32::from_rgb(210, 210, 210)
}
}
+92
View File
@@ -0,0 +1,92 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LauncherConfig {
pub core_binary: String,
pub bridge_binary: String,
pub core_database_url: String,
pub core_data_dir: String,
pub core_listen_addr: String,
pub bridge_listen_addr: String,
pub bridge_captures_dir: String,
pub bridge_core_url: String,
pub bridge_tls_enabled: bool,
}
impl Default for LauncherConfig {
fn default() -> Self {
let base = dirs::home_dir()
.map(|h| h.join("Documents/OpenFUT"))
.unwrap_or_default();
Self {
core_binary: base
.join("openfut-core/target/release/openfut-core")
.to_string_lossy()
.into(),
bridge_binary: base
.join("openfut-bridge/target/release/openfut-bridge")
.to_string_lossy()
.into(),
core_database_url: "sqlite://openfut.db".into(),
core_data_dir: base
.join("openfut-core/data")
.to_string_lossy()
.into(),
core_listen_addr: "127.0.0.1:8080".into(),
bridge_listen_addr: "0.0.0.0:8765".into(),
bridge_captures_dir: base
.join("openfut-bridge/captures")
.to_string_lossy()
.into(),
bridge_core_url: "http://127.0.0.1:8080".into(),
bridge_tls_enabled: true,
}
}
}
impl LauncherConfig {
pub fn config_path() -> std::path::PathBuf {
dirs::config_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("openfut-launcher")
.join("config.json")
}
pub fn load() -> Self {
let path = Self::config_path();
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) {
let path = Self::config_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(path, json);
}
}
pub fn core_env(&self) -> Vec<(String, String)> {
vec![
("DATABASE_URL".into(), self.core_database_url.clone()),
("DATA_DIR".into(), self.core_data_dir.clone()),
("LISTEN_ADDR".into(), self.core_listen_addr.clone()),
("RUST_LOG".into(), "openfut_core=info,tower_http=info".into()),
]
}
pub fn bridge_env(&self) -> Vec<(String, String)> {
vec![
("CORE_URL".into(), self.bridge_core_url.clone()),
("LISTEN_ADDR".into(), self.bridge_listen_addr.clone()),
("CAPTURES_DIR".into(), self.bridge_captures_dir.clone()),
("TLS_ENABLED".into(), self.bridge_tls_enabled.to_string()),
("RUST_LOG".into(), "openfut_bridge=info".into()),
]
}
}
+34
View File
@@ -0,0 +1,34 @@
use std::collections::VecDeque;
const MAX_LINES: usize = 2000;
pub struct LogBuffer {
lines: VecDeque<String>,
pub dirty: bool,
}
impl LogBuffer {
pub fn new() -> Self {
Self {
lines: VecDeque::with_capacity(MAX_LINES),
dirty: false,
}
}
pub fn push(&mut self, line: String) {
if self.lines.len() >= MAX_LINES {
self.lines.pop_front();
}
self.lines.push_back(line);
self.dirty = true;
}
pub fn lines(&self) -> impl Iterator<Item = &str> {
self.lines.iter().map(String::as_str)
}
pub fn clear(&mut self) {
self.lines.clear();
self.dirty = true;
}
}
+21
View File
@@ -0,0 +1,21 @@
mod app;
mod config;
mod logs;
mod process;
mod setup;
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("OpenFUT Launcher")
.with_inner_size([780.0, 560.0])
.with_min_inner_size([600.0, 400.0]),
..Default::default()
};
eframe::run_native(
"OpenFUT Launcher",
options,
Box::new(|cc| Ok(Box::new(app::LauncherApp::new(cc)))),
)
}
+134
View File
@@ -0,0 +1,134 @@
use std::{
io::{BufRead, BufReader},
process::{Child, Command, Stdio},
sync::{Arc, Mutex},
thread,
};
use crate::logs::LogBuffer;
#[derive(Debug, Clone, PartialEq)]
pub enum ServiceStatus {
Stopped,
Starting,
Running,
Failed(String),
}
impl ServiceStatus {
pub fn label(&self) -> &str {
match self {
ServiceStatus::Stopped => "Stopped",
ServiceStatus::Starting => "Starting…",
ServiceStatus::Running => "Running",
ServiceStatus::Failed(_) => "Failed",
}
}
pub fn color(&self) -> egui::Color32 {
match self {
ServiceStatus::Running => egui::Color32::from_rgb(80, 200, 120),
ServiceStatus::Starting => egui::Color32::from_rgb(255, 200, 0),
ServiceStatus::Failed(_) => egui::Color32::from_rgb(220, 60, 60),
ServiceStatus::Stopped => egui::Color32::from_rgb(150, 150, 150),
}
}
}
pub struct ServiceHandle {
child: Option<Child>,
pub status: Arc<Mutex<ServiceStatus>>,
}
impl ServiceHandle {
pub fn new() -> Self {
Self {
child: None,
status: Arc::new(Mutex::new(ServiceStatus::Stopped)),
}
}
pub fn start(
&mut self,
binary: &str,
env_pairs: &[(String, String)],
log_buf: Arc<Mutex<LogBuffer>>,
) -> anyhow::Result<()> {
if self.is_running() {
return Ok(());
}
*self.status.lock().unwrap() = ServiceStatus::Starting;
let mut cmd = Command::new(binary);
for (k, v) in env_pairs {
cmd.env(k, v);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
*self.status.lock().unwrap() = ServiceStatus::Failed(e.to_string());
e
})?;
// Drain stdout
if let Some(stdout) = child.stdout.take() {
let buf = Arc::clone(&log_buf);
let status = Arc::clone(&self.status);
thread::spawn(move || {
*status.lock().unwrap() = ServiceStatus::Running;
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
}
});
}
// Drain stderr
if let Some(stderr) = child.stderr.take() {
let buf = Arc::clone(&log_buf);
thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
}
});
}
self.child = Some(child);
Ok(())
}
pub fn stop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
*self.status.lock().unwrap() = ServiceStatus::Stopped;
}
pub fn is_running(&mut self) -> bool {
if let Some(child) = &mut self.child {
match child.try_wait() {
Ok(Some(_)) => {
// process exited
self.child = None;
*self.status.lock().unwrap() = ServiceStatus::Stopped;
false
}
Ok(None) => true,
Err(_) => false,
}
} else {
false
}
}
pub fn status(&self) -> ServiceStatus {
self.status.lock().unwrap().clone()
}
}
impl Drop for ServiceHandle {
fn drop(&mut self) {
self.stop();
}
}
+129
View File
@@ -0,0 +1,129 @@
use std::process::Command;
/// EA FUT hostnames the bridge needs to intercept.
const INTERCEPT_HOSTS: &[&str] = &[
"fut.ea.com",
"utas.mob.v4.fut.ea.com",
"utas.s2.fut.ea.com",
];
const HOSTS_MARKER_START: &str = "# BEGIN openfut-launcher";
const HOSTS_MARKER_END: &str = "# END openfut-launcher";
// ── Cert installation ─────────────────────────────────────────────────────────
/// Find the bridge cert in the given captures dir.
pub fn find_bridge_cert(captures_dir: &str) -> Option<std::path::PathBuf> {
let p = std::path::Path::new(captures_dir).join("bridge_cert.pem");
p.exists().then_some(p)
}
/// Install the bridge cert into the system CA store.
/// On Linux, copies to /usr/local/share/ca-certificates/ and runs
/// update-ca-certificates via pkexec (polkit graphical sudo).
pub fn install_cert(cert_src: &std::path::Path) -> anyhow::Result<()> {
let dest = std::path::Path::new("/usr/local/share/ca-certificates/openfut-bridge.crt");
// Write a small shell script that pkexec can run as root
let script = format!(
"cp '{}' '{}' && update-ca-certificates",
cert_src.display(),
dest.display()
);
let status = Command::new("pkexec")
.args(["sh", "-c", &script])
.status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => anyhow::bail!("pkexec exited with status {s}"),
Err(e) => {
// pkexec not available — try sudo
let status = Command::new("sudo")
.args(["sh", "-c", &script])
.status()?;
if status.success() {
Ok(())
} else {
anyhow::bail!("cert install failed: {e}")
}
}
}
}
// ── Hosts file management ─────────────────────────────────────────────────────
pub fn hosts_configured() -> bool {
let content = std::fs::read_to_string("/etc/hosts").unwrap_or_default();
content.contains(HOSTS_MARKER_START)
}
/// Add OpenFUT intercept entries to /etc/hosts (requires root).
pub fn add_hosts_entries() -> anyhow::Result<()> {
let current = std::fs::read_to_string("/etc/hosts")?;
if current.contains(HOSTS_MARKER_START) {
return Ok(());
}
let entries: String = INTERCEPT_HOSTS
.iter()
.map(|h| format!("127.0.0.1 {h}\n"))
.collect();
let addition = format!("\n{HOSTS_MARKER_START}\n{entries}{HOSTS_MARKER_END}\n");
let new_content = format!("{current}{addition}");
write_hosts(&new_content)
}
/// Remove OpenFUT intercept entries from /etc/hosts (requires root).
pub fn remove_hosts_entries() -> anyhow::Result<()> {
let current = std::fs::read_to_string("/etc/hosts")?;
if !current.contains(HOSTS_MARKER_START) {
return Ok(());
}
let mut out = String::new();
let mut skip = false;
for line in current.lines() {
if line.trim() == HOSTS_MARKER_START {
skip = true;
continue;
}
if line.trim() == HOSTS_MARKER_END {
skip = false;
continue;
}
if !skip {
out.push_str(line);
out.push('\n');
}
}
write_hosts(out.trim_end())
}
fn write_hosts(content: &str) -> anyhow::Result<()> {
// Write to a temp file, then mv into place with elevated privileges
let tmp = "/tmp/openfut_hosts_tmp";
std::fs::write(tmp, content)?;
let script = format!("mv '{tmp}' /etc/hosts && chmod 644 /etc/hosts");
let status = Command::new("pkexec").args(["sh", "-c", &script]).status();
match status {
Ok(s) if s.success() => Ok(()),
_ => {
let s = Command::new("sudo")
.args(["sh", "-c", &script])
.status()?;
if s.success() {
Ok(())
} else {
anyhow::bail!("failed to write /etc/hosts")
}
}
}
}