i3status_rs/blocks/
external_ip.rs

1//! External IP address and various information about it
2//!
3//! # Configuration
4//!
5//! Key | Values | Default
6//! ----|--------|--------
7//! `format` | A string to customise the output of this block. See below for available placeholders. | `" $ip $country_flag "`
8//! `interval` | Interval in seconds for automatic updates | `300`
9//! `with_network_manager` | If 'true', listen for NetworkManager events and update the IP immediately if there was a change | `true`
10//! `use_ipv4` | If 'true', use IPv4 for obtaining all info | `false`
11//!
12//!  Key | Value | Type | Unit
13//! -----|-------|------|------
14//! `ip` | The external IP address, as seen from a remote server | Text | -
15//! `version` | IPv4 or IPv6 | Text | -
16//! `city` | City name, such as "San Francisco" | Text | -
17//! `region` | Region name, such as "California" | Text | -
18//! `region_code` | Region code, such as "CA" for California | Text | -
19//! `country` | Country code (2 letter, ISO 3166-1 alpha-2) | Text | -
20//! `country_name` | Short country name | Text | -
21//! `country_code` | Country code (2 letter, ISO 3166-1 alpha-2) | Text | -
22//! `country_code_iso3` | Country code (3 letter, ISO 3166-1 alpha-3) | Text | -
23//! `country_capital` | Capital of the country | Text | -
24//! `country_tld` | Country specific TLD (top-level domain) | Text | -
25//! `continent_code` | Continent code | Text | -
26//! `in_eu` | Region code, such as "CA" | Flag | -
27//! `postal` | ZIP / Postal code | Text | -
28//! `latitude` | Latitude | Number | - (TODO: make degrees?)
29//! `longitude` | Longitude | Number | - (TODO: make degrees?)
30//! `timezone` | City | Text | -
31//! `utc_offset` | UTC offset (with daylight saving time) as +HHMM or -HHMM (HH is hours, MM is minutes) | Text | -
32//! `country_calling_code` | Country calling code (dial in code, comma separated) | Text | -
33//! `currency` | Currency code (ISO 4217) | Text | -
34//! `currency_name` | Currency name | Text | -
35//! `languages` | Languages spoken (comma separated 2 or 3 letter ISO 639 code with optional hyphen separated country suffix) | Text | -
36//! `country_area` | Area of the country (in sq km) | Number | -
37//! `country_population` | Population of the country | Number | -
38//! `timezone` | Time zone | Text | -
39//! `org` | Organization | Text | -
40//! `asn` | Autonomous system (AS) | Text | -
41//! `country_flag` | Flag of the country | Text (glyph) | -
42//!
43//! # Example
44//!
45//! ```toml
46//! [[block]]
47//! block = "external_ip"
48//! format = " $ip $country_code "
49//! ```
50//!
51//! # Notes
52//! All the information comes from <https://ipapi.co/json/>
53//! Check their documentation here: <https://ipapi.co/api/#complete-location5>
54//!
55//! The IP is queried, 1) When i3status-rs starts, 2) When a signal is received
56//! on D-Bus about a network configuration change, 3) Every 5 minutes. This
57//! periodic refresh exists to catch IP updates that don't trigger a notification,
58//! for example due to a IP refresh at the router.
59//!
60//! Flags: They are not icons but unicode glyphs. You will need a font that
61//! includes them. Tested with: <https://www.babelstone.co.uk/Fonts/Flags.html>
62
63use zbus::MatchRule;
64
65use super::prelude::*;
66use crate::util::{country_flag_from_iso_code, new_system_dbus_connection};
67
68#[derive(Deserialize, Debug, SmartDefault)]
69#[serde(deny_unknown_fields, default)]
70pub struct Config {
71    pub format: FormatConfig,
72    #[default(300.into())]
73    pub interval: Seconds,
74    #[default(true)]
75    pub with_network_manager: bool,
76    #[default(false)]
77    pub use_ipv4: bool,
78}
79
80pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
81    let format = config.format.with_default(" $ip $country_flag ")?;
82
83    type UpdatesStream = Pin<Box<dyn Stream<Item = ()>>>;
84    let mut stream: UpdatesStream = if config.with_network_manager {
85        let dbus = new_system_dbus_connection().await?;
86        let proxy = zbus::fdo::DBusProxy::new(&dbus)
87            .await
88            .error("Failed to create DBusProxy")?;
89        proxy
90            .add_match_rule(
91                MatchRule::builder()
92                    .msg_type(zbus::message::Type::Signal)
93                    .path("/org/freedesktop/NetworkManager")
94                    .and_then(|x| x.interface("org.freedesktop.DBus.Properties"))
95                    .and_then(|x| x.member("PropertiesChanged"))
96                    .unwrap()
97                    .build(),
98            )
99            .await
100            .error("Failed to add match")?;
101        proxy
102            .add_match_rule(
103                MatchRule::builder()
104                    .msg_type(zbus::message::Type::Signal)
105                    .path_namespace("/org/freedesktop/NetworkManager/ActiveConnection")
106                    .and_then(|x| x.interface("org.freedesktop.DBus.Properties"))
107                    .and_then(|x| x.member("PropertiesChanged"))
108                    .unwrap()
109                    .build(),
110            )
111            .await
112            .error("Failed to add match")?;
113        proxy
114            .add_match_rule(
115                MatchRule::builder()
116                    .msg_type(zbus::message::Type::Signal)
117                    .path_namespace("/org/freedesktop/NetworkManager/IP4Config")
118                    .and_then(|x| x.interface("org.freedesktop.DBus.Properties"))
119                    .and_then(|x| x.member("PropertiesChanged"))
120                    .unwrap()
121                    .build(),
122            )
123            .await
124            .error("Failed to add match")?;
125        let stream: zbus::MessageStream = dbus.into();
126        Box::pin(stream.map(|_| ()))
127    } else {
128        Box::pin(futures::stream::empty())
129    };
130
131    let client = if config.use_ipv4 {
132        &REQWEST_CLIENT_IPV4
133    } else {
134        &REQWEST_CLIENT
135    };
136
137    loop {
138        let fetch_info = || api.find_ip_location(client, Duration::from_secs(0));
139        let info = fetch_info.retry(ExponentialBuilder::default()).await?;
140
141        let mut values = map! {
142            "ip" => Value::text(info.ip),
143            "city" => Value::text(info.city),
144            "latitude" => Value::number(info.latitude),
145            "longitude" => Value::number(info.longitude),
146        };
147
148        macro_rules! map_push_if_some { ($($key:ident: $type:ident),* $(,)?) => {
149            $({
150                let key = stringify!($key);
151                if let Some(value) = info.$key {
152                    values.insert(key.into(), Value::$type(value));
153                } else if format.contains_key(key) {
154                    return Err(Error::new(format!(
155                        "The format string contains '{key}', but the {key} field is not provided by {} (an api key may be required)",
156                        api.locator_name()
157                    )));
158                }
159            })*
160        } }
161
162        map_push_if_some!(
163            version: text,
164            region: text,
165            region_code: text,
166            country: text,
167            country_name: text,
168            country_code_iso3: text,
169            country_capital: text,
170            country_tld: text,
171            continent_code: text,
172            postal: text,
173            timezone: text,
174            utc_offset: text,
175            country_calling_code: text,
176            currency: text,
177            currency_name: text,
178            languages: text,
179            country_area: number,
180            country_population: number,
181            asn: text,
182            org: text,
183        );
184
185        if let Some(country_code) = info.country_code {
186            values.insert(
187                "country_flag".into(),
188                Value::text(country_flag_from_iso_code(&country_code)),
189            );
190            values.insert("country_code".into(), Value::text(country_code));
191        } else if format.contains_key("country_code") || format.contains_key("country_flag") {
192            return Err(Error::new(format!(
193                "The format string contains 'country_code' or 'country_flag', but the country_code field is not provided by {}",
194                api.locator_name()
195            )));
196        }
197
198        if let Some(in_eu) = info.in_eu {
199            if in_eu {
200                values.insert("in_eu".into(), Value::flag());
201            }
202        } else if format.contains_key("in_eu") {
203            return Err(Error::new(format!(
204                "The format string contains 'in_eu', but the in_eu field is not provided by {}",
205                api.locator_name()
206            )));
207        }
208
209        let mut widget = Widget::new().with_format(format.clone());
210        widget.set_values(values);
211        api.set_widget(widget)?;
212
213        select! {
214            _ = sleep(config.interval.0) => (),
215            _ = api.wait_for_update_request() => (),
216            _ = stream.next_debounced() => ()
217        }
218    }
219}