diff --git a/doc/blocks.md b/doc/blocks.md index 38a4f16e47..db88d9c3d4 100644 --- a/doc/blocks.md +++ b/doc/blocks.md @@ -27,6 +27,7 @@ You may find that the block you desire is not in the list below. In that case, f - [Custom DBus](#custom-dbus) - [Disk Space](#disk-space) - [Docker](#docker) +- [ExternalIP](#external-ip) - [Focused Window](#focused-window) - [GitHub](#github) - [Hueshift](#hueshift) @@ -649,6 +650,74 @@ Key | Value | Type ###### [β†₯ back to top](#list-of-available-blocks) +## ExternalIP + +Creates a block which displays the external IP address and various information about it. + +#### Examples + +```toml +[[block]] +block = "externalip" +format = "{ip} {country_code}" +``` + +#### Options + +Key | Values | Required | Default +----|--------|----------|-------- +`format` | A string to customise the output of this block. See below for available placeholders. Text may need to be escaped, refer to [Escaping Text](#escaping-text). | No | `"{address} {country_flag}"` +`refresh_interval_success` | Interval in seconds for automatic updates when the previous update was successful | No | 300 +`refresh_interval_failure` | Interval in seconds for automatic updates when the previous update failed | No | 15 +`with_network_manager` | If 'true', listen for NetworkManager events and update the IP immediately if there was a change | No | "true" + +#### Available Format Keys + + Key | Value | Type +-----|-------|----- +`{ip}` | The external IP address, as seen from a remote server | String +`{version}` | IPv4 or IPv6 | String +`{city}` | City name, such as "San Francisco" | Integer +`{region}` | Region name, such as "California" | String +`{region_code}` | Region code, such as "CA" for California | String +`{country}` | Country code (2 letter, ISO 3166-1 alpha-2) | String +`{country_name}` | Short country name | String +`{country_code}` | Country code (2 letter, ISO 3166-1 alpha-2) | String +`{country_code_iso3}` | Country code (3 letter, ISO 3166-1 alpha-3) | String +`{country_capital}` | Capital of the country | String +`{country_tld}` | Country specific TLD (top-level domain) | String +`{continent_code}` | Continent code | String +`{in_eu}` | Region code, such as "CA" | String +`{postal}` | ZIP / Postal code | String +`{latitude}` | Latitude | Float +`{longitude}` | Longitude | Float +`{timezone}` | City | String +`{utc_offset}` | UTC offset (with daylight saving time) as +HHMM or -HHMM (HH is hours, MM is minutes) | String +`{country_calling_code}` | Country calling code (dial in code, comma separated) | String +`{currency}` | Currency code (ISO 4217) | String +`{currency_name}` | Currency name | String +`{languages}` | Languages spoken (comma separated 2 or 3 letter ISO 639 code with optional hyphen separated country suffix) | String +`{country_area}` | Area of the country (in sq km) | Float +`{country_population}` | Population of the country | Float +`{timezone}` | Time zone | String +`{org}` | Organization | String +`{asn}` | Autonomous system (AS) | String +`{country_flag}` | Flag of the country | String (glyph) + +##### Notes +All the information comes from https://ipapi.co/json/ +Check their documentation here: https://ipapi.co/api/#complete-location5 + +The IP is queried, 1) When i3status-rs starts, 2) When a signal is received +on D-Bus about a network configuration change, 3) Every 5 minutes. This +periodic refresh exists to catch IP updates that don't trigger a notification, +for example due to a IP refresh at the router. + +Flags: They are not icons but unicode glyphs. You will need a font that +includes them. Tested with: https://www.babelstone.co.uk/Fonts/Flags.html + +###### [β†₯ back to top](#list-of-available-blocks) + ## Focused Window Creates a block which displays the title or the active marks of the currently focused window. Uses push updates from i3 IPC, so no need to worry about resource usage. The block only updates when the focused window changes title or the focus changes. Also works with sway, due to it having compatibility with i3's IPC. diff --git a/src/blocks.rs b/src/blocks.rs index 74448a6465..7331a3a156 100644 --- a/src/blocks.rs +++ b/src/blocks.rs @@ -9,6 +9,7 @@ pub mod custom_dbus; pub mod disk_space; pub mod dnf; pub mod docker; +pub mod external_ip; pub mod focused_window; pub mod github; pub mod hueshift; @@ -52,6 +53,7 @@ use self::custom_dbus::*; use self::disk_space::*; use self::dnf::*; use self::docker::*; +use self::external_ip::*; use self::focused_window::*; use self::github::*; use self::hueshift::*; @@ -234,6 +236,7 @@ pub fn create_block( "disk_space" => block!(DiskSpace, id, block_config, shared_config, update_request), "dnf" => block!(Dnf, id, block_config, shared_config, update_request), "docker" => block!(Docker, id, block_config, shared_config, update_request), /////// + "external_ip" => block!(ExternalIP, id, block_config, shared_config, update_request), "focused_window" => block!( FocusedWindow, id, diff --git a/src/blocks/external_ip.rs b/src/blocks/external_ip.rs new file mode 100644 index 0000000000..50e305721d --- /dev/null +++ b/src/blocks/external_ip.rs @@ -0,0 +1,233 @@ +use std::thread; +use std::time::Instant; + +use crossbeam_channel::Sender; +use dbus::ffidisp::{BusType, Connection, ConnectionItem}; +use serde::{Deserialize as des, Serialize as ser}; +use serde_derive::Deserialize; + +use crate::blocks::{Block, ConfigBlock, Update}; +use crate::config::SharedConfig; +use crate::errors::*; +use crate::formatting::value::Value; +use crate::formatting::FormatTemplate; +use crate::http; +use crate::scheduler::Task; +use crate::util::country_flag_from_iso_code; +use crate::widgets::text::TextWidget; +use crate::widgets::{I3BarWidget, State}; +use crate::Duration; + +const API_ENDPOINT: &str = "https://ipapi.co/json/"; +const BLOCK_NAME: &str = "external_ip"; + +#[derive(ser, des, Default)] +#[serde(default)] +struct IPAddressInfo { + error: bool, + reason: String, + ip: String, + version: String, + city: String, + region: String, + region_code: String, + country: String, + country_name: String, + country_code: String, + country_code_iso3: String, + country_capital: String, + country_tld: String, + continent_code: String, + in_eu: bool, + postal: Option, + latitude: f64, + longitude: f64, + timezone: String, + utc_offset: String, + country_calling_code: String, + currency: String, + currency_name: String, + languages: String, + country_area: f64, + country_population: f64, + asn: String, + org: String, +} + +pub struct ExternalIP { + id: usize, + output: TextWidget, + format: FormatTemplate, + refresh_interval_success: u64, + refresh_interval_failure: u64, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields, default)] +pub struct ExternalIPConfig { + /// External IP formatter. + pub format: FormatTemplate, + pub refresh_interval_success: u64, + pub refresh_interval_failure: u64, + pub with_network_manager: bool, +} + +impl Default for ExternalIPConfig { + fn default() -> Self { + Self { + format: FormatTemplate::default(), + refresh_interval_success: 300, + refresh_interval_failure: 15, + with_network_manager: true, + } + } +} + +impl ConfigBlock for ExternalIP { + type Config = ExternalIPConfig; + + fn new( + id: usize, + block_config: Self::Config, + shared_config: SharedConfig, + send: Sender, + ) -> Result { + if block_config.with_network_manager { + thread::Builder::new() + .name("externalip".into()) + .spawn(move || { + let c = Connection::get_private(BusType::System).unwrap(); + c.add_match( + "type='signal',\ + path='/org/freedesktop/NetworkManager',\ + interface='org.freedesktop.DBus.Properties',\ + member='PropertiesChanged'", + ) + .unwrap(); + c.add_match( + "type='signal',\ + path_namespace='/org/freedesktop/NetworkManager/ActiveConnection',\ + interface='org.freedesktop.DBus.Properties',\ + member='PropertiesChanged'", + ) + .unwrap(); + c.add_match( + "type='signal',\ + path_namespace='/org/freedesktop/NetworkManager/IP4Config',\ + interface='org.freedesktop.DBus',\ + member='PropertiesChanged'", + ) + .unwrap(); + + loop { + let timeout = 300_000; + + for event in c.iter(timeout) { + match event { + ConnectionItem::Nothing => (), + _ => { + send.send(Task { + id, + update_time: Instant::now(), + }) + .unwrap(); + } + } + } + } + }) + .unwrap(); + } + Ok(ExternalIP { + id, + output: TextWidget::new(id, 0, shared_config), + format: block_config.format.with_default("{ip} {country_flag}")?, + refresh_interval_success: block_config.refresh_interval_success, + refresh_interval_failure: block_config.refresh_interval_failure, + }) + } +} + +impl Block for ExternalIP { + fn id(&self) -> usize { + self.id + } + + fn update(&mut self) -> Result> { + let (external_ip, success) = { + let ip_info: Result = + match http::http_get_json(API_ENDPOINT, Some(Duration::from_secs(3)), vec![]) { + Ok(ip_info_json) => serde_json::from_value(ip_info_json.content) + .block_error(BLOCK_NAME, "Failed to decode JSON"), + _ => Err(BlockError( + BLOCK_NAME.to_string(), + "Failed to contact API".to_string(), + )), + }; + match ip_info { + Ok(ip_info) => match ip_info.error { + false => { + self.output.set_state(State::Idle); + let flag = country_flag_from_iso_code(ip_info.country_code.as_str()); + let values = map!( + "ip" => Value::from_string (ip_info.ip), + "version" => Value::from_string (ip_info.version), + "city" => Value::from_string (ip_info.city), + "region" => Value::from_string (ip_info.region), + "region_code" => Value::from_string (ip_info.region_code), + "country" => Value::from_string (ip_info.country), + "country_name" => Value::from_string (ip_info.country_name), + "country_code" => Value::from_string (ip_info.country_code), + "country_code_iso3" => Value::from_string (ip_info.country_code_iso3), + "country_capital" => Value::from_string (ip_info.country_capital), + "country_tld" => Value::from_string (ip_info.country_tld), + "continent_code" => Value::from_string (ip_info.continent_code), + "in_eu" => Value::from_boolean (ip_info.in_eu), + "postal" => Value::from_string (ip_info.postal.unwrap_or_else(|| "No postal code".to_string())), + "latitude" => Value::from_float (ip_info.latitude), + "longitude" => Value::from_float (ip_info.longitude), + "timezone" => Value::from_string (ip_info.timezone), + "utc_offset" => Value::from_string (ip_info.utc_offset), + "country_calling_code" => Value::from_string (ip_info.country_calling_code), + "currency" => Value::from_string (ip_info.currency), + "currency_name" => Value::from_string (ip_info.currency_name), + "languages" => Value::from_string (ip_info.languages), + "country_area" => Value::from_float (ip_info.country_area), + "country_population" => Value::from_float (ip_info.country_population), + "asn" => Value::from_string (ip_info.asn), + "org" => Value::from_string (ip_info.org), + "country_flag" => Value::from_string(flag), + ); + let s = self.format.render(&values)?; + (s.0, true) + } + true => { + self.output.set_state(State::Critical); + (format!("Error: {}", ip_info.reason), false) + } + }, + Err(err) => { + self.output.set_state(State::Critical); + (err.to_string(), false) + } + } + }; + + self.output.set_text(external_ip); + match success { + /* The external IP address can change without triggering a + * notification (for example a refresh between the router and + * the ISP) so check from time to time even on success */ + true => Ok(Some( + Duration::from_secs(self.refresh_interval_success).into(), + )), + false => Ok(Some( + Duration::from_secs(self.refresh_interval_failure).into(), + )), + } + } + + fn view(&self) -> Vec<&dyn I3BarWidget> { + vec![&self.output] + } +} diff --git a/src/formatting.rs b/src/formatting.rs index 821581bde9..3b64470125 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -61,6 +61,11 @@ impl FormatTemplate { Self::format_contains(&self.full, var) || Self::format_contains(&self.short, var) } + pub fn has_tokens(&self) -> bool { + !self.full.as_ref().map(Vec::is_empty).unwrap_or(true) + || !self.short.as_ref().map(Vec::is_empty).unwrap_or(true) + } + fn format_contains(format: &Option>, var: &str) -> bool { if let Some(tokens) = format { for token in tokens { diff --git a/src/formatting/value.rs b/src/formatting/value.rs index a4a0af52ad..3a4c1302e5 100644 --- a/src/formatting/value.rs +++ b/src/formatting/value.rs @@ -17,6 +17,7 @@ enum InternalValue { Text(String), Integer(i64), Float(f64), + Boolean(bool), } fn format_number( @@ -150,6 +151,14 @@ impl Value { value: InternalValue::Float(value), } } + pub fn from_boolean(value: bool) -> Self { + Self { + icon: None, + min_width: 2, + unit: Unit::None, + value: InternalValue::Boolean(value), + } + } // Set options pub fn icon(mut self, icon: String) -> Self { @@ -248,6 +257,10 @@ impl Value { // Apply engineering notation (Float-only) format_number(value, min_width, var.min_prefix, unit, pad_with) } + InternalValue::Boolean(value) => match value { + true => String::from("T"), + false => String::from("F"), + }, }; // We prepend the resulting string with the icon if it is set diff --git a/src/util.rs b/src/util.rs index 4aa782b646..e93d11a8d1 100644 --- a/src/util.rs +++ b/src/util.rs @@ -210,9 +210,27 @@ pub fn format_vec_to_bar_graph(content: &[f64], min: Option, max: Option String { + if country_code.len() != 2 || !country_code.chars().all(|c| c.is_ascii_uppercase()) { + return country_code.to_string(); + } + let bytes = country_code.as_bytes(); // Sane as we verified before that it's ASCII + + // Each char is encoded as 1F1E6 to 1F1FF for A-Z + let c1 = bytes[0] + 0xa5; + let c2 = bytes[1] + 0xa5; + // The last byte will always start with 101 (0xa0) and then the 5 least + // significant bits from the previous result + let b1 = 0xa0 | (c1 & 0x1f); + let b2 = 0xa0 | (c2 & 0x1f); + // Get the flag string from the UTF-8 representation of our Unicode characters. + String::from_utf8(vec![0xf0, 0x9f, 0x87, b1, 0xf0, 0x9f, 0x87, b2]).unwrap() +} + #[cfg(test)] mod tests { - use crate::util::has_command; + use crate::util::{country_flag_from_iso_code, has_command}; #[test] // we assume sh is always available @@ -231,4 +249,11 @@ mod tests { let has_command = has_command.unwrap(); assert!(!has_command) } + + #[test] + fn test_flags() { + assert!(country_flag_from_iso_code("ES") == "πŸ‡ͺπŸ‡Έ"); + assert!(country_flag_from_iso_code("US") == "πŸ‡ΊπŸ‡Έ"); + assert!(country_flag_from_iso_code("USA") == "USA"); + } }