1mod mullvad;
86use mullvad::MullvadDriver;
87mod nordvpn;
88use nordvpn::NordVpnDriver;
89mod tailscale;
90use tailscale::TailscaleDriver;
91mod warp;
92use warp::WarpDriver;
93
94use super::prelude::*;
95
96#[derive(Deserialize, Debug, SmartDefault)]
97#[serde(rename_all = "snake_case")]
98pub enum DriverType {
99 Mullvad,
100 #[default]
101 Nordvpn,
102 Tailscale,
103 Warp,
104}
105
106#[derive(Deserialize, Debug, SmartDefault)]
107#[serde(deny_unknown_fields, default)]
108pub struct Config {
109 pub driver: DriverType,
110 #[default(10.into())]
111 pub interval: Seconds,
112 pub format_connected: FormatConfig,
113 pub format_disconnected: FormatConfig,
114 pub format_connecting: FormatConfig,
115 pub state_connected: State,
116 pub state_disconnected: State,
117}
118
119enum Status {
120 Connected {
121 country: Option<String>,
122 country_flag: Option<String>,
123 profile: Option<String>,
124 },
125 Connecting {
126 profile: Option<String>,
127 },
128 Disconnected {
129 profile: Option<String>,
130 },
131 Error(Option<String>),
132}
133
134impl DriverType {
135 fn can_report_connecting(&self) -> bool {
139 matches!(self, DriverType::Mullvad)
140 }
141}
142
143pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
144 let mut outputs = vec![
145 OutputPlan::new(
146 "connected",
147 config.format_connected.with_default(" VPN: $icon ")?,
148 )
149 .icon("icon", IconChoices::one(icons::NET_VPN)),
150 OutputPlan::new(
151 "disconnected",
152 config.format_disconnected.with_default(" VPN: $icon ")?,
153 )
154 .icon("icon", IconChoices::one(icons::NET_WIRED)),
155 ];
156 if config.driver.can_report_connecting() {
157 outputs.push(
158 OutputPlan::new(
159 "connecting",
160 config.format_connecting.with_default(" VPN: $icon ")?,
161 )
162 .icon("icon", IconChoices::one(icons::NET_WIRELESS)),
163 );
164 }
165 outputs.push(
166 OutputPlan::new(
167 "error",
168 config.format_disconnected.with_default(" VPN: $icon ")?,
169 )
170 .icon("icon", IconChoices::one(icons::NET_DOWN)),
171 );
172 BlockPlan::new(outputs)
173}
174
175pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
176 let mut actions = api.get_actions()?;
177 api.set_default_actions(&[(MouseButton::Left, None, "toggle")])?;
178
179 let output_connected = plan.output("connected")?;
180 let output_disconnected = plan.output("disconnected")?;
181 let output_connecting = if config.driver.can_report_connecting() {
182 Some(plan.output("connecting")?)
183 } else {
184 None
185 };
186 let output_error = plan.output("error")?;
187
188 let driver: Box<dyn Driver> = match config.driver {
189 DriverType::Mullvad => Box::new(MullvadDriver::new().await),
190 DriverType::Nordvpn => Box::new(NordVpnDriver::new().await),
191 DriverType::Tailscale => Box::new(TailscaleDriver::new().await),
192 DriverType::Warp => Box::new(WarpDriver::new().await),
193 };
194
195 loop {
196 let status = driver.get_status().await?;
197
198 let output = match &status {
199 Status::Connected { .. } => &output_connected,
200 Status::Disconnected { .. } => &output_disconnected,
201 Status::Connecting { .. } => output_connecting.as_ref().unwrap_or_else(|| {
204 debug_assert!(false, "driver reported Connecting without the capability");
205 &output_disconnected
206 }),
207 Status::Error(_) => &output_error,
208 };
209 let mut widget = output.new_widget();
210 let icon = output.icon_value("icon")?;
211
212 widget.state = match &status {
213 Status::Connected {
214 country,
215 country_flag,
216 profile,
217 } => {
218 widget.set_values(map!(
219 "icon" => icon.clone(),
220 [if let Some(country) = country] "country" => Value::text(country.into()),
221 [if let Some(flag) = country_flag] "flag" => Value::text(flag.into()),
222 [if let Some(profile) = profile] "profile" => Value::text(profile.into()),
223 ));
224 config.state_connected
225 }
226 Status::Disconnected { profile } => {
227 widget.set_values(map! {
228 "icon" => icon.clone(),
229 [if let Some(profile) = profile] "profile" => Value::text(profile.into()),
230 });
231 config.state_disconnected
232 }
233 Status::Connecting { profile } => {
234 widget.set_values(map!(
235 "icon" => icon.clone(),
236 [if let Some(profile) = profile] "profile" => Value::text(profile.into()),
237 ));
238 State::Info
239 }
240 Status::Error(error) => {
241 widget.set_values(map!(
242 "icon" => icon.clone(),
243 [if let Some(error) = error] "error" => Value::text(error.into())
244 ));
245 State::Critical
246 }
247 };
248
249 api.set_widget(widget)?;
250
251 select! {
252 _ = sleep(config.interval.0) => (),
253 _ = api.wait_for_update_request() => (),
254 Some(action) = actions.recv() => match action.as_ref() {
255 "toggle" => driver.toggle_connection(&status).await?,
256 _ => (),
257 }
258 }
259 }
260}
261
262#[async_trait]
263trait Driver {
264 async fn get_status(&self) -> Result<Status>;
265 async fn toggle_connection(&self, status: &Status) -> Result<()>;
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn plan_declares_states_reachable_for_the_driver() {
274 let plan = prepare(&Config::default()).unwrap();
276 let declared: Vec<_> = plan.outputs().map(|o| o.id()).collect();
277 assert_eq!(declared, ["connected", "disconnected", "error"]);
278
279 let mullvad = Config {
280 driver: DriverType::Mullvad,
281 ..Config::default()
282 };
283 let plan = prepare(&mullvad).unwrap();
284 let declared: Vec<_> = plan.outputs().map(|o| o.id()).collect();
285 assert_eq!(
286 declared,
287 ["connected", "disconnected", "connecting", "error"]
288 );
289 for (id, icon) in [
290 ("connected", "net_vpn"),
291 ("disconnected", "net_wired"),
292 ("connecting", "net_wireless"),
293 ("error", "net_down"),
294 ] {
295 let output = plan.output(id).unwrap();
296 let choices = output.output().choices_for("icon").unwrap();
297 assert!(choices.permits(icon), "{id} must permit {icon}");
298 }
299 }
300
301 #[test]
302 fn each_output_declares_exactly_one_icon() {
303 let plan = prepare(&Config::default()).unwrap();
304 for (id, icon) in [
305 ("connected", "net_vpn"),
306 ("disconnected", "net_wired"),
307 ("error", "net_down"),
308 ] {
309 let output = plan.output(id).unwrap();
310 assert_eq!(output.single_icon("icon").unwrap(), icon);
311 }
312 }
313
314 #[test]
315 fn error_state_uses_disconnected_format() {
316 let config = Config {
317 format_disconnected: " off ".parse().unwrap(),
318 ..Config::default()
319 };
320 let plan = prepare(&config).unwrap();
321 let error = plan.output("error").unwrap();
322 assert!(!error.format().contains_key("icon"));
323 }
324}