Skip to content

Commit 68edb86

Browse files
committed
Add block "external_ip"
1 parent 64fcdeb commit 68edb86

File tree

5 files changed

+265
-1
lines changed

5 files changed

+265
-1
lines changed

doc/blocks.md

+50
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ You may find that the block you desire is not in the list below. In that case, f
2727
- [Custom DBus](#custom-dbus)
2828
- [Disk Space](#disk-space)
2929
- [Docker](#docker)
30+
- [ExternalIP](#external-ip)
3031
- [Focused Window](#focused-window)
3132
- [GitHub](#github)
3233
- [Hueshift](#hueshift)
@@ -649,6 +650,55 @@ Key | Value | Type
649650

650651
###### [↥ back to top](#list-of-available-blocks)
651652

653+
## ExternalIP
654+
655+
Creates a block which displays the external IP address and various information about it.
656+
657+
#### Examples
658+
659+
```toml
660+
[[block]]
661+
block = "externalip"
662+
format = "{address} {country_code}"
663+
```
664+
665+
#### Options
666+
667+
Key | Values | Required | Default
668+
----|--------|----------|--------
669+
`format` | AA 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}"`
670+
671+
#### Available Format Keys
672+
673+
Key | Value | Type
674+
-----|-------|-----
675+
`{address}` | The external IP address, as seen from a remote server | String
676+
`{country}` | Full country name, such as "Spain" | Integer
677+
`{country_code}` | Two characters country code, such as "ES" | String
678+
`{region}` | Region name, such as "California" | String
679+
`{region_name}` | Region code, such as "CA" | String
680+
`{city}` | City | String
681+
`{zip}` | ZIP / Postal code | String
682+
`{latitude}` | Latitude | Float
683+
`{longitude}` | Longitude | Float
684+
`{timezone}` | Time zone | String
685+
`{isp}` | Internet Service Provider | String
686+
`{org}` | Organization | String
687+
`{autonomous_system}` | Autonomous system (AS) | String
688+
`{country_flag}` | Flag of the country | String (glyph)
689+
690+
##### Notes
691+
The external IP address system uses the free tier of ip-api.com which is
692+
http, not https.
693+
The IP is queried, 1) When i3status-rs starts, 2) When a signal is received
694+
on D-Bus about a network configuration change, 3) Every 5 minutes. This
695+
periodic refresh exists to catch IP updates that don't trigger a notification,
696+
for example due to a IP refresh at the router.
697+
Flags: They are not icons but unicode glyphs. You will need a font that
698+
includes them. Tested with: https://www.babelstone.co.uk/Fonts/Flags.html
699+
700+
###### [↥ back to top](#list-of-available-blocks)
701+
652702
## Focused Window
653703

654704
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.

src/blocks.rs

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod custom_dbus;
99
pub mod disk_space;
1010
pub mod dnf;
1111
pub mod docker;
12+
pub mod external_ip;
1213
pub mod focused_window;
1314
pub mod github;
1415
pub mod hueshift;
@@ -52,6 +53,7 @@ use self::custom_dbus::*;
5253
use self::disk_space::*;
5354
use self::dnf::*;
5455
use self::docker::*;
56+
use self::external_ip::*;
5557
use self::focused_window::*;
5658
use self::github::*;
5759
use self::hueshift::*;
@@ -234,6 +236,7 @@ pub fn create_block(
234236
"disk_space" => block!(DiskSpace, id, block_config, shared_config, update_request),
235237
"dnf" => block!(Dnf, id, block_config, shared_config, update_request),
236238
"docker" => block!(Docker, id, block_config, shared_config, update_request), ///////
239+
"external_ip" => block!(ExternalIP, id, block_config, shared_config, update_request),
237240
"focused_window" => block!(
238241
FocusedWindow,
239242
id,

src/blocks/external_ip.rs

+181
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
use std::thread;
2+
use std::time::Instant;
3+
4+
use crossbeam_channel::Sender;
5+
use dbus::ffidisp::{BusType, Connection, ConnectionItem};
6+
use serde::{Deserialize as des, Serialize as ser};
7+
use serde_derive::Deserialize;
8+
9+
use crate::blocks::{Block, ConfigBlock, Update};
10+
use crate::config::SharedConfig;
11+
use crate::errors::*;
12+
use crate::formatting::value::Value;
13+
use crate::formatting::FormatTemplate;
14+
use crate::http;
15+
use crate::scheduler::Task;
16+
use crate::util::country_flag_from_iso_code;
17+
use crate::widgets::text::TextWidget;
18+
use crate::widgets::{I3BarWidget, State};
19+
use crate::Duration;
20+
21+
const API_ENDPOINT: &str = "http://ip-api.com/json";
22+
23+
#[derive(ser, des, Default)]
24+
struct IPAddressInfo {
25+
#[serde(rename = "query")]
26+
address: String,
27+
status: String,
28+
country: String,
29+
#[serde(rename = "countryCode")]
30+
country_code: String,
31+
region: String,
32+
#[serde(rename = "regionName")]
33+
region_name: String,
34+
city: String,
35+
zip: String,
36+
lat: f64,
37+
lon: f64,
38+
timezone: String,
39+
isp: String,
40+
org: String,
41+
#[serde(rename = "as")]
42+
autonomous_system: String,
43+
}
44+
45+
pub struct ExternalIP {
46+
id: usize,
47+
output: TextWidget,
48+
format: FormatTemplate,
49+
}
50+
51+
#[derive(Deserialize, Debug, Clone)]
52+
#[serde(deny_unknown_fields, default)]
53+
pub struct ExternalIPConfig {
54+
/// External IP formatter.
55+
pub format: FormatTemplate,
56+
}
57+
58+
impl Default for ExternalIPConfig {
59+
fn default() -> Self {
60+
Self {
61+
format: FormatTemplate::default(),
62+
}
63+
}
64+
}
65+
66+
impl ConfigBlock for ExternalIP {
67+
type Config = ExternalIPConfig;
68+
69+
fn new(
70+
id: usize,
71+
block_config: Self::Config,
72+
shared_config: SharedConfig,
73+
send: Sender<Task>,
74+
) -> Result<Self> {
75+
thread::Builder::new()
76+
.name("externalip".into())
77+
.spawn(move || {
78+
let c = Connection::get_private(BusType::System).unwrap();
79+
c.add_match(
80+
"type='signal',\
81+
path='/org/freedesktop/NetworkManager',\
82+
interface='org.freedesktop.DBus.Properties',\
83+
member='PropertiesChanged'",
84+
)
85+
.unwrap();
86+
c.add_match(
87+
"type='signal',\
88+
path_namespace='/org/freedesktop/NetworkManager/ActiveConnection',\
89+
interface='org.freedesktop.DBus.Properties',\
90+
member='PropertiesChanged'",
91+
)
92+
.unwrap();
93+
c.add_match(
94+
"type='signal',\
95+
path_namespace='/org/freedesktop/NetworkManager/IP4Config',\
96+
interface='org.freedesktop.DBus',\
97+
member='PropertiesChanged'",
98+
)
99+
.unwrap();
100+
101+
loop {
102+
let timeout = 300_000;
103+
104+
for event in c.iter(timeout) {
105+
match event {
106+
ConnectionItem::Nothing => (),
107+
_ => {
108+
send.send(Task {
109+
id,
110+
update_time: Instant::now(),
111+
})
112+
.unwrap();
113+
}
114+
}
115+
}
116+
}
117+
})
118+
.unwrap();
119+
120+
Ok(ExternalIP {
121+
id,
122+
output: TextWidget::new(id, 0, shared_config),
123+
format: block_config
124+
.format
125+
.with_default("{address} {country_flag}")?,
126+
})
127+
}
128+
}
129+
130+
impl Block for ExternalIP {
131+
fn id(&self) -> usize {
132+
self.id
133+
}
134+
135+
fn update(&mut self) -> Result<Option<Update>> {
136+
let external_ip = {
137+
let ip_info =
138+
match http::http_get_json(API_ENDPOINT, Some(Duration::from_secs(3)), vec![]) {
139+
Ok(ip_info_json) => serde_json::from_value(ip_info_json.content).unwrap(),
140+
_ => IPAddressInfo::default(),
141+
};
142+
match ip_info.status.as_ref() {
143+
"success" => {
144+
self.output.set_state(State::Idle);
145+
let flag = country_flag_from_iso_code(ip_info.country_code.as_str());
146+
let values = map!(
147+
"address" => Value::from_string (ip_info.address),
148+
"country" => Value::from_string (ip_info.country),
149+
"country_code" => Value::from_string (ip_info.country_code),
150+
"region" => Value::from_string (ip_info.region),
151+
"region_name" => Value::from_string (ip_info.region_name),
152+
"city" => Value::from_string (ip_info.city),
153+
"zip" => Value::from_string (ip_info.zip),
154+
"latitude" => Value::from_float (ip_info.lat),
155+
"longitude" => Value::from_float (ip_info.lon),
156+
"timezone" => Value::from_string (ip_info.timezone),
157+
"isp" => Value::from_string (ip_info.isp),
158+
"org" => Value::from_string (ip_info.org),
159+
"autonomous_system" => Value::from_string (ip_info.autonomous_system),
160+
"country_flag" => Value::from_string(flag),
161+
);
162+
let s = self.format.render(&values)?;
163+
s.0
164+
}
165+
_ => {
166+
self.output.set_state(State::Critical);
167+
"Request to IP service failed".to_string()
168+
}
169+
}
170+
};
171+
self.output.set_text(external_ip);
172+
/* The external IP address can change without triggering a
173+
* notification (for example a refresh between the router and
174+
* the ISP) so check from time to time */
175+
Ok(Some(Duration::from_secs(300).into()))
176+
}
177+
178+
fn view(&self) -> Vec<&dyn I3BarWidget> {
179+
vec![&self.output]
180+
}
181+
}

src/formatting.rs

+5
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ impl FormatTemplate {
6161
Self::format_contains(&self.full, var) || Self::format_contains(&self.short, var)
6262
}
6363

64+
pub fn has_tokens(&self) -> bool {
65+
!self.full.as_ref().map(Vec::is_empty).unwrap_or(true)
66+
|| !self.short.as_ref().map(Vec::is_empty).unwrap_or(true)
67+
}
68+
6469
fn format_contains(format: &Option<Vec<Token>>, var: &str) -> bool {
6570
if let Some(tokens) = format {
6671
for token in tokens {

src/util.rs

+26-1
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,27 @@ pub fn format_vec_to_bar_graph(content: &[f64], min: Option<f64>, max: Option<f6
210210
}
211211
}
212212

213+
// Convert 2 letter country code to Unicode
214+
pub fn country_flag_from_iso_code(country_code: &str) -> String {
215+
if country_code.len() != 2 || !country_code.chars().all(|c| c.is_ascii_uppercase()) {
216+
return country_code.to_string();
217+
}
218+
let bytes = country_code.as_bytes(); // Sane as we verified before that it's ASCII
219+
220+
// Each char is encoded as 1F1E6 to 1F1FF for A-Z
221+
let c1 = bytes[0] + 0xa5;
222+
let c2 = bytes[1] + 0xa5;
223+
// The last byte will always start with 101 (0xa0) and then the 5 least
224+
// significant bits from the previous result
225+
let b1 = 0xa0 | (c1 & 0x1f);
226+
let b2 = 0xa0 | (c2 & 0x1f);
227+
// Get the flag string from the UTF-8 representation of our Unicode characters.
228+
String::from_utf8(vec![0xf0, 0x9f, 0x87, b1, 0xf0, 0x9f, 0x87, b2]).unwrap()
229+
}
230+
213231
#[cfg(test)]
214232
mod tests {
215-
use crate::util::has_command;
233+
use crate::util::{country_flag_from_iso_code, has_command};
216234

217235
#[test]
218236
// we assume sh is always available
@@ -231,4 +249,11 @@ mod tests {
231249
let has_command = has_command.unwrap();
232250
assert!(!has_command)
233251
}
252+
253+
#[test]
254+
fn test_flags() {
255+
assert!(country_flag_from_iso_code("ES") == "🇪🇸");
256+
assert!(country_flag_from_iso_code("US") == "🇺🇸");
257+
assert!(country_flag_from_iso_code("USA") == "USA");
258+
}
234259
}

0 commit comments

Comments
 (0)