Skip to main content

flatland_client_lib/
client_config.rs

1//! Persistent client settings (game host, API base) for installed play.
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8pub const DEFAULT_GAME_PORT: u16 = 7373;
9pub const DEFAULT_API_PORT: u16 = 7380;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct GfxWindowPrefs {
13    /// Last window width in logical pixels.
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub width: Option<u32>,
16    /// Last window height in logical pixels.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub height: Option<u32>,
19    /// Window X (platform coordinates; macOS AppKit bottom-left origin).
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub x: Option<u32>,
22    /// Window Y.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub y: Option<u32>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct ClientConfig {
29    /// Gateway host (IPv4, IPv6, or DNS name). Port defaults to 7373.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub game_host: Option<String>,
32    /// Control plane HTTP port when `game_host` is set. Defaults to 7380.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub api_port: Option<u16>,
35    /// Gateway TCP port when `game_host` is set. Defaults to 7373.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub game_port: Option<u16>,
38    /// Gfx play window geometry (`flatland3-gfx`).
39    #[serde(default, skip_serializing_if = "GfxWindowPrefs::is_empty")]
40    pub gfx_window: GfxWindowPrefs,
41}
42
43impl GfxWindowPrefs {
44    pub fn is_empty(&self) -> bool {
45        self.width.is_none()
46            && self.height.is_none()
47            && self.x.is_none()
48            && self.y.is_none()
49    }
50}
51
52impl ClientConfig {
53    pub fn path() -> anyhow::Result<PathBuf> {
54        let base = dirs::config_dir()
55            .ok_or_else(|| anyhow::anyhow!("could not resolve config directory"))?;
56        Ok(base.join("flatland").join("client.json"))
57    }
58
59    pub fn load() -> Self {
60        Self::path()
61            .ok()
62            .and_then(|path| std::fs::read(&path).ok())
63            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
64            .unwrap_or_default()
65    }
66
67    pub fn save(&self) -> anyhow::Result<()> {
68        let path = Self::path()?;
69        if let Some(parent) = path.parent() {
70            std::fs::create_dir_all(parent)?;
71        }
72        std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
73        Ok(())
74    }
75
76    pub fn save_gfx_window(&mut self, prefs: GfxWindowPrefs) -> anyhow::Result<()> {
77        self.gfx_window = prefs;
78        self.save()
79    }
80
81    pub fn set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
82        let host = host.into().trim().to_string();
83        if host.is_empty() {
84            anyhow::bail!("game host cannot be empty");
85        }
86        self.game_host = Some(host);
87        self.save()
88    }
89
90    pub fn game_server_addr(&self) -> SocketAddr {
91        if let Some(host) = self.game_host.as_deref() {
92            let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
93            return parse_host_port(host, port)
94                .unwrap_or_else(|| format!("{host}:{port}").parse().expect("valid host:port"));
95        }
96        "127.0.0.1:7373".parse().expect("valid default addr")
97    }
98
99    pub fn api_base_url(&self) -> String {
100        if let Some(host) = self.game_host.as_deref() {
101            let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
102            if host.starts_with("http://") || host.starts_with("https://") {
103                return host.to_string();
104            }
105            return format!("http://{host}:{port}");
106        }
107        "http://127.0.0.1:7380".into()
108    }
109}
110
111pub fn default_game_server_addr() -> SocketAddr {
112    if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
113        if let Ok(parsed) = addr.parse() {
114            return parsed;
115        }
116    }
117    ClientConfig::load().game_server_addr()
118}
119
120pub fn default_api_base_url() -> String {
121    if let Ok(url) = std::env::var("FLATLAND_API_URL") {
122        return url;
123    }
124    ClientConfig::load().api_base_url()
125}
126
127fn parse_host_port(host: &str, default_port: u16) -> Option<SocketAddr> {
128    if host.contains(':') {
129        host.parse().ok()
130    } else {
131        format!("{host}:{default_port}").parse().ok()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn default_is_localhost() {
141        let cfg = ClientConfig::default();
142        assert_eq!(
143            cfg.game_server_addr(),
144            "127.0.0.1:7373".parse().unwrap()
145        );
146        assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
147    }
148
149    #[test]
150    fn game_host_sets_both_endpoints() {
151        let mut cfg = ClientConfig::default();
152        cfg.game_host = Some("192.168.1.50".into());
153        assert_eq!(
154            cfg.game_server_addr(),
155            "192.168.1.50:7373".parse().unwrap()
156        );
157        assert_eq!(cfg.api_base_url(), "http://192.168.1.50:7380");
158    }
159}