feat(UI): enhance pause info display in system tray menu and reorganize menu item positions

https://github.com/safing/portmaster/issues/2050
This commit is contained in:
Alexandr Stelnykovych
2025-11-10 15:14:20 +02:00
parent 997f95698b
commit 139610b99d
6 changed files with 125 additions and 53 deletions

View File

@@ -220,10 +220,10 @@
<div *ngIf="isPaused">
<div class="flex flex-col p-4 text-xxs">
<span class="text-secondary">
Paused: <span class="text-primary">{{ pauseInfo }} </span>
<span class="text-secondary">{{ pauseInfo }} </span>
</span>
<span class="text-secondary">
Auto-resume at: <span class="text-primary">{{ pauseInfoTillTime }} </span>
Auto-resume at <span class="text-secondary">{{ pauseInfoTillTime }} </span>
</span>
</div>
<hr/>

View File

@@ -42,11 +42,11 @@ export class NavigationComponent implements OnInit {
get isPausedSPN(): boolean { return this.pauseState?.SPN===true; }
get pauseInfo(): string {
if (this.pauseState?.Interception===true && this.pauseState?.SPN===true)
return 'Portmaster and SPN';
return 'Portmaster and SPN are paused';
else if (this.pauseState?.Interception===true)
return 'Portmaster';
return 'Portmaster is paused';
else if (this.pauseState?.SPN===true)
return 'SPN';
return 'SPN is paused';
return '';
}
get pauseInfoTillTime(): string {

View File

@@ -859,8 +859,10 @@ checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -3949,6 +3951,7 @@ version = "2.0.25"
dependencies = [
"assert_matches",
"cached",
"chrono",
"clap_lex",
"ctor",
"dark-light",

View File

@@ -52,6 +52,7 @@ reqwest = { version = "0.12", features = ["cookies", "json"] }
rfd = { version = "*", default-features = false, features = [ "tokio", "gtk3", "common-controls-v6" ] }
open = "5.1.3"
chrono = { version = "0.4", features = ["serde"] }
dark-light = { path = "../rust-dark-light" }

View File

@@ -45,7 +45,7 @@ pub struct WorstState {
pub state: State,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SystemStatus {
#[serde(rename = "Modules")]
pub modules: Vec<StateUpdate>,
@@ -55,3 +55,27 @@ pub struct SystemStatus {
// add more fields when needed
// ...
}
// PauseInfo represents pause status data from "control:paused" state in Control module
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PauseInfo {
#[serde(rename = "Interception")]
pub interception: bool,
#[serde(rename = "SPN")]
pub spn: bool,
#[serde(rename = "TillTime")]
pub till_time: String, // time.Time serialized as string by GoLang
}
impl SystemStatus {
pub fn get_module_state(&self, module_name: &str, state_id: &str) -> Option<&State> {
if let Some(module) = self.modules.iter().find(|m| m.module == module_name) {
if let Some(states) = &module.states {
return states.iter().find(|s| s.id == state_id);
}
}
None
}
}

View File

@@ -2,6 +2,7 @@ use std::ops::Deref;
use std::sync::atomic::AtomicBool;
use std::sync::RwLock;
use std::{sync::atomic::Ordering};
use chrono::{DateTime};
use log::{debug, error};
use tauri::{
@@ -66,6 +67,8 @@ const PAUSE_PM_5_KEY: &str = "pause_pm_5";
const PAUSE_PM_15_KEY: &str = "pause_pm_15";
const PAUSE_PM_60_KEY: &str = "pause_pm_60";
const RESUME_KEY: &str = "resume_all";
const PAUSE_INFO_KEY: &str = "pause_info";
const PAUSE_INFO_TIME_KEY: &str = "pause_info_time";
// Icons
@@ -133,6 +136,7 @@ fn build_tray_menu(
app: &tauri::AppHandle,
status: &str,
spn_status_text: &str,
pause_info: &system_status_types::PauseInfo,
) -> core::result::Result<ContextMenu, Box<dyn std::error::Error>> {
load_theme(app);
@@ -140,22 +144,46 @@ fn build_tray_menu(
let exit_ui_btn = MenuItemBuilder::with_id(EXIT_UI_KEY, "Exit UI").build(app)?;
let shutdown_btn = MenuItemBuilder::with_id(SHUTDOWN_KEY, "Shut Down Portmaster").build(app)?;
let global_status = MenuItemBuilder::with_id(GLOBAL_STATUS_KEY, format!("Status: {}", status))
// Global status
let global_status_text = if pause_info.interception {
format!("Status: {} (PAUSED)", status)
} else {
format!("Status: {}", status)
};
let global_status = MenuItemBuilder::with_id(GLOBAL_STATUS_KEY, global_status_text)
.enabled(false)
.build(app)
.unwrap();
// Setup SPN status
let spn_status = MenuItemBuilder::with_id(SPN_STATUS_KEY, format!("SPN: {}", spn_status_text))
.enabled(false)
.build(app)
.unwrap();
// Pause items
let (pause_status_item, pause_status_time_item, resume_item) = if pause_info.interception || pause_info.spn {
let status_text = match (pause_info.interception, pause_info.spn) {
(true, true) => "Portmaster and SPN are paused",
(true, false) => "Portmaster is paused",
(false, true) => "SPN is paused",
_ => unreachable!(), // We already checked at least one is true
};
let status_item = MenuItemBuilder::with_id(PAUSE_INFO_KEY, status_text).enabled(false).build(app)?;
let formatted_time = DateTime::parse_from_rfc3339(&pause_info.till_time)
.map(|dt| dt.with_timezone(&chrono::Local).format("%H:%M:%S").to_string())
.unwrap_or_else(|_| pause_info.till_time.clone());
let time_item = MenuItemBuilder::with_id(PAUSE_INFO_TIME_KEY, format!("Auto-resume at {}", formatted_time)).enabled(false).build(app)?;
let resume_item = MenuItemBuilder::with_id(RESUME_KEY, "Resume now").build(app)?;
(Some(status_item), Some(time_item), Some(resume_item))
} else {
(None, None, None)
};
// Setup SPN button
let spn_button_text = match spn_status_text {
"disabled" => "Enable SPN",
_ => "Disable SPN",
};
let spn_status = MenuItemBuilder::with_id(SPN_STATUS_KEY, format!("SPN: {}", spn_status_text))
.enabled(false)
.build(app)
.unwrap();
let spn_button = MenuItemBuilder::with_id(SPN_BUTTON_KEY, spn_button_text)
.build(app)
.unwrap();
@@ -176,10 +204,10 @@ fn build_tray_menu(
// Setup Pause/Resume menu items
let disabled_spn = spn_status_text == "disabled";
let pause_spn_5min_item = MenuItemBuilder::with_id(PAUSE_SPN_5_KEY, "Pause SPN for 5 minutes").enabled(!disabled_spn).build(app)?;
let pause_spn_15min_item = MenuItemBuilder::with_id(PAUSE_SPN_15_KEY, "Pause SPN for 15 minutes").enabled(!disabled_spn).build(app)?;
let pause_spn_1hour_item = MenuItemBuilder::with_id(PAUSE_SPN_60_KEY, "Pause SPN for 1 hour").enabled(!disabled_spn).build(app)?;
let disabled_spn_pause = (spn_status_text == "disabled" && !pause_info.spn) || pause_info.interception;
let pause_spn_5min_item = MenuItemBuilder::with_id(PAUSE_SPN_5_KEY, "Pause SPN for 5 minutes").enabled(!disabled_spn_pause).build(app)?;
let pause_spn_15min_item = MenuItemBuilder::with_id(PAUSE_SPN_15_KEY, "Pause SPN for 15 minutes").enabled(!disabled_spn_pause).build(app)?;
let pause_spn_1hour_item = MenuItemBuilder::with_id(PAUSE_SPN_60_KEY, "Pause SPN for 1 hour").enabled(!disabled_spn_pause).build(app)?;
let pause_pm_5min_item = MenuItemBuilder::with_id(PAUSE_PM_5_KEY, "Pause for 5 minutes").build(app)?;
let pause_pm_15min_item = MenuItemBuilder::with_id(PAUSE_PM_15_KEY, "Pause for 15 minutes").build(app)?;
@@ -197,8 +225,6 @@ fn build_tray_menu(
])
.build()?;
let resume_item = MenuItemBuilder::with_id(RESUME_KEY, "Resume now").build(app)?;
/* DEV MENU
let force_show_window = MenuItemBuilder::with_id(FORCE_SHOW_KEY, "Force Show UI").build(app)?;
let reload_btn = MenuItemBuilder::with_id(RELOAD_KEY, "Reload User Interface").build(app)?;
@@ -207,24 +233,43 @@ fn build_tray_menu(
.build()?;
*/
// Assemble menu items
let s = PredefinedMenuItem::separator(app)?;
let mut items: Vec<&dyn tauri::menu::IsMenuItem<Wry>> = Vec::new();
items.push(&global_status);
items.push(&s);
if let Some(ref pause_status_item) = pause_status_item {
items.push(pause_status_item);
}
if let Some(ref pause_status_time_item) = pause_status_time_item {
items.push(pause_status_time_item);
}
if let Some(ref resume_item) = resume_item {
items.push(resume_item);
}
items.push(&pause_menu);
items.push(&s);
items.push(&spn_status);
items.push(&spn_button);
items.push(&s);
items.push(&theme_menu);
items.push(&s);
items.push(&open_btn);
items.push(&s);
items.push(&exit_ui_btn);
items.push(&shutdown_btn);
//items.push(&developer_menu);
let menu = MenuBuilder::with_id(app, PM_TRAY_MENU_ID)
.items(&[
&open_btn,
&PredefinedMenuItem::separator(app)?,
&global_status,
&PredefinedMenuItem::separator(app)?,
&pause_menu,
&resume_item,
&PredefinedMenuItem::separator(app)?,
&spn_status,
&spn_button,
&PredefinedMenuItem::separator(app)?,
&theme_menu,
&PredefinedMenuItem::separator(app)?,
&exit_ui_btn,
&shutdown_btn,
//&developer_menu,
])
.items(&items)
.build()?;
return Ok(menu);
@@ -233,7 +278,7 @@ fn build_tray_menu(
pub fn setup_tray_menu(
app: &mut tauri::App,
) -> core::result::Result<AppIcon, Box<dyn std::error::Error>> {
let menu = build_tray_menu(app.handle(), "Secured", "disabled")?;
let menu = build_tray_menu(app.handle(), "Secured", "disabled", &system_status_types::PauseInfo::default())?;
let icon = TrayIconBuilder::with_id(PM_TRAY_ICON_ID)
.icon(Image::from_bytes(get_red_icon()).unwrap())
@@ -325,15 +370,10 @@ pub fn setup_tray_menu(
pub fn update_icon(icon: AppIcon, system_status: SystemStatus, spn_status: String) {
// Extract the worst state type
let worst_state_type = match system_status.worst_state {
Some(ws) => {
match ws.state.state_type {
Some(s) => s,
None => system_status_types::StateType::Undefined
}
}
None => system_status_types::StateType::Undefined
};
let worst_state_type = system_status.worst_state
.as_ref()
.and_then(|ws| ws.state.state_type.clone())
.unwrap_or(system_status_types::StateType::Undefined);
// Determine status and icon color in a single match expression
let (status, icon_color) = match worst_state_type {
@@ -348,7 +388,15 @@ pub fn update_icon(icon: AppIcon, system_status: SystemStatus, spn_status: Strin
}
};
if let Ok(menu) = build_tray_menu(icon.app_handle(), status.as_ref(), spn_status.as_str()) {
// Extract pause info from system status
let pause_info = system_status
.get_module_state("Control", "control:paused")
.and_then(|state| state.data.as_ref())
.and_then(|data| serde_json::from_value::<system_status_types::PauseInfo>(data.clone()).ok())
.unwrap_or_default();
// Rebuild and set the tray menu
if let Ok(menu) = build_tray_menu(icon.app_handle(), status, spn_status.as_str(), &pause_info) {
if let Err(err) = icon.set_menu(Some(menu)) {
error!("failed to set menu on tray icon: {}", err.to_string());
}
@@ -432,11 +480,7 @@ pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) {
update_icon_color(&icon, IconColor::Blue);
let mut system_status = SystemStatus {
modules: Vec::new(),
worst_state: None,
};
let mut system_status = SystemStatus::default();
let mut spn_status: String = "".to_string();
loop {