Compare commits

...
Author SHA1 Message Date
Ian 219765e4ff Open the folder of dated service logs from the tray 2026-10-22 16:20:11 -04:00
Ian 8b0d734f02 Show screening status in the Windows notification area
firewall-tray.exe shows a tray icon: whether the service is screening,
paused or not running, with Pause/Resume and Open log. The MSI
starts it at every sign-in and closes it before replacing or removing it.

The icon is the Strix logo, white on a dark taskbar and black on a light
one like the macOS template image, and dimmed when not screening.
tray.png is assets/logo-transparent.png scaled and padded to 32x32:
sips -Z 32 logo-transparent.png --out a.png && sips -p 32 32 a.png --out tray.png
2026-10-22 16:20:08 -04:00
7 changed files with 406 additions and 13 deletions

No files matched your search

+168 -1
View File
@@ -261,6 +261,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "borsh"
version = "1.8.1"
@@ -669,6 +678,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"objc2",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
@@ -680,6 +699,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "dpi"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "either"
version = "1.18.0"
@@ -751,6 +776,15 @@ dependencies = [
"getrandom 0.4.3",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
@@ -771,7 +805,7 @@ checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"libz-sys",
"miniz_oxide",
"miniz_oxide 0.9.1",
"zlib-rs",
]
@@ -1452,6 +1486,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]]
name = "konst"
version = "0.2.20"
@@ -1595,6 +1639,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -1636,6 +1690,24 @@ dependencies = [
"uuid",
]
[[package]]
name = "muda"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"png",
"thiserror",
"windows-sys 0.61.2",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1742,6 +1814,66 @@ dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -1864,6 +1996,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -3230,7 +3375,9 @@ dependencies = [
"clap",
"core-foundation 0.10.1",
"directories",
"muda",
"percent-encoding",
"png",
"rama",
"rand",
"rcgen",
@@ -3246,6 +3393,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"tray-icon",
"which",
"windows",
"windows-registry",
@@ -3652,6 +3800,25 @@ dependencies = [
"tracing-serde",
]
[[package]]
name = "tray-icon"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9"
dependencies = [
"crossbeam-channel",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"thiserror",
"windows-sys 0.61.2",
]
[[package]]
name = "try-lock"
version = "0.2.5"
+10 -11
View File
@@ -11,9 +11,9 @@ homepage = "https://github.com/usestrix/firewall"
# as an integration test; new tests/*.rs need an explicit [[test]] entry.
autotests = false
# Only Windows ships the service.
# Only Windows ships the service and tray.
[package.metadata.dist.binaries]
x86_64-pc-windows-msvc = ["firewall", "firewall-service"]
x86_64-pc-windows-msvc = ["firewall", "firewall-service", "firewall-tray"]
aarch64-apple-darwin = ["firewall"]
x86_64-apple-darwin = ["firewall"]
aarch64-unknown-linux-gnu = ["firewall"]
@@ -39,6 +39,10 @@ path = "src/main.rs"
name = "firewall-service"
path = "src/bin/firewall-service.rs"
[[bin]]
name = "firewall-tray"
path = "src/bin/firewall-tray.rs"
[[bin]]
name = "firewall-uninstall"
path = "src/bin/firewall-uninstall.rs"
@@ -107,19 +111,14 @@ security-framework = "3.7.0"
security-framework-sys = "2.17.0"
[target.'cfg(windows)'.dependencies]
muda = { version = "0.21.2", default-features = false }
png = "0.18.1"
rama = { version = "0.4.0", features = ["dns-hickory"] }
schannel = "0.1.29"
tokio = { version = "1.53.1", features = ["net"] }
tracing-appender = "0.2.5"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Security_Cryptography",
"Win32_System_Com",
"Win32_System_Environment",
"Win32_UI_Shell",
] }
tray-icon = { version = "0.26.1", default-features = false }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_System_Com", "Win32_System_Environment", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
+11
View File
@@ -0,0 +1,11 @@
//! The notification-area icon, started at sign-in. A GUI app, so no console opens with it.
#![windows_subsystem = "windows"]
#[cfg(windows)]
fn main() -> Result<(), rama::error::BoxError> {
firewall::os::windows::run_tray()
}
/// The tray is Windows-only.
#[cfg(not(windows))]
fn main() {}
+2
View File
@@ -4,10 +4,12 @@
mod ca;
mod service;
mod tpm;
mod tray;
mod uninstall;
pub use service::run as run_service;
pub use tpm::CaKey;
pub use tray::run as run_tray;
pub use uninstall::uninstall;
use rama::error::BoxError;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+195
View File
@@ -0,0 +1,195 @@
//! The notification-area icon, started at sign-in as the menu bar app is on macOS.
use super::*;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use std::io::Cursor;
use tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::*;
use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::UI::Shell::*;
use windows::Win32::UI::WindowsAndMessaging::*;
use windows_registry::CURRENT_USER;
use windows_service::service::{Service, ServiceAccess, ServiceState};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const MENU_PAUSE: &str = "pause";
const MENU_LOG: &str = "log";
const MENU_QUIT: &str = "quit";
const LOGO: &[u8] = include_bytes!("tray.png");
/// Runs until Quit, once per session.
pub fn run() -> Result<(), BoxError> {
// Held until we exit.
let _mutex = unsafe { CreateMutexW(None, false, w!(r"Local\StrixFirewallTray"))? };
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
return Ok(());
}
let status = MenuItem::new("", false, None);
let pause = MenuItem::with_id(MENU_PAUSE, "", false, None);
let menu = Menu::with_items(&[
&status,
&PredefinedMenuItem::separator(),
&pause,
&MenuItem::with_id(MENU_LOG, "Open logs", true, None),
&PredefinedMenuItem::separator(),
&MenuItem::with_id(MENU_QUIT, "Quit", true, None),
])?;
let (logo, logo_size) = logo_mask()?;
let icon = TrayIconBuilder::new().with_menu(Box::new(menu)).build()?;
let window = HWND(icon.window_handle());
let mut tray = Tray {
icon,
logo,
logo_size,
drawn: None,
status,
pause,
state: None,
};
tray.update();
unsafe { SetTimer(None, 0, 2000, None) };
let mut message = MSG::default();
while unsafe { GetMessageW(&mut message, None, 0, 0) }.0 > 0 {
unsafe { DispatchMessageW(&message) };
// WM_CLOSE, as the MSI sends, destroys the icon's window.
if !unsafe { IsWindow(Some(window)) }.as_bool() {
break;
}
// Ours, not the icon window's own hover timer.
if message.message == WM_TIMER && message.hwnd.is_invalid() {
tray.update();
}
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
// The menu opens on release, so this keeps it current.
if let TrayIconEvent::Click {
button_state: MouseButtonState::Down,
..
} = event
{
tray.update();
}
}
while let Ok(event) = MenuEvent::receiver().try_recv() {
if event.id == MENU_QUIT {
return Ok(());
}
tray.on_menu(event.id.as_ref());
}
}
Ok(())
}
struct Tray {
icon: TrayIcon,
/// The logo's alpha channel; it is square.
logo: Vec<u8>,
logo_size: u32,
/// Whether the taskbar was light and the service screening when the icon was last set.
drawn: Option<(bool, bool)>,
status: MenuItem,
pause: MenuItem,
/// As the menu last showed it.
state: Option<ServiceState>,
}
impl Tray {
fn update(&mut self) {
self.state = service_state();
let screening = self.state == Some(ServiceState::Running);
let light = light_taskbar();
if self.drawn != Some((light, screening)) {
let rgba = logo_rgba(&self.logo, light, screening);
if let Ok(icon) = Icon::from_rgba(rgba, self.logo_size, self.logo_size) {
let _ = self.icon.set_icon(Some(icon));
}
self.drawn = Some((light, screening));
}
let text = status_text(self.state);
let tip = format!("Strix Firewall: {text}");
let _ = self.icon.set_tooltip(Some(tip));
self.status.set_text(text);
let pause = if screening { "Pause" } else { "Resume" };
self.pause.set_text(pause);
self.pause.set_enabled(self.state.is_some());
}
fn on_menu(&self, id: &str) {
let state = self.state;
let screening = state == Some(ServiceState::Running);
let action: fn() -> Result<(), BoxError> = match id {
MENU_PAUSE if screening => || Ok(open_service()?.pause().map(drop)?),
MENU_PAUSE if state == Some(ServiceState::Paused) => {
|| Ok(open_service()?.resume().map(drop)?)
}
MENU_PAUSE => || Ok(open_service()?.start::<&str>(&[])?),
MENU_LOG => {
let log = HSTRING::from(data_dir().as_path());
let none = PCWSTR::null();
unsafe { ShellExecuteW(None, w!("open"), &log, none, none, SW_SHOWNORMAL) };
return;
}
_ => return,
};
if let Err(error) = action() {
let text = HSTRING::from(error.to_string());
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
}
}
/// `None` when the service is not installed.
fn service_state() -> Option<ServiceState> {
let manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
.ok()?;
Some(service.query_status().ok()?.current_state)
}
fn light_taskbar() -> bool {
let personalize = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
let Ok(key) = CURRENT_USER.open(personalize) else {
return false;
};
key.get_u32("SystemUsesLightTheme").is_ok_and(|light| light == 1)
}
fn status_text(state: Option<ServiceState>) -> &'static str {
match state {
Some(ServiceState::Running) => "Screening downloads",
Some(ServiceState::Paused) => "Paused",
_ => "Not running",
}
}
fn open_service() -> Result<Service, BoxError> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let access = ServiceAccess::START | ServiceAccess::PAUSE_CONTINUE;
Ok(manager.open_service(SERVICE_NAME, access)?)
}
/// The alpha channel of the RGBA logo, and its width.
fn logo_mask() -> Result<(Vec<u8>, u32), BoxError> {
let mut reader = png::Decoder::new(Cursor::new(LOGO)).read_info()?;
let mut rgba = vec![0; reader.output_buffer_size().ok_or("logo too large")?];
let info = reader.next_frame(&mut rgba)?;
let mut alpha = Vec::with_capacity(rgba.len() / 4);
for pixel in rgba.chunks(4) {
alpha.push(pixel[3]);
}
Ok((alpha, info.width))
}
/// White on a dark taskbar and black on a light one, as macOS draws a template image; dimmed
/// when not screening.
fn logo_rgba(mask: &[u8], light: bool, screening: bool) -> Vec<u8> {
let shade = if light { 0 } else { 255 };
let mut rgba = Vec::with_capacity(mask.len() * 4);
for &alpha in mask {
let alpha = if screening { alpha } else { alpha / 2 };
rgba.extend([shade, shade, shade, alpha]);
}
rgba
}
+20 -1
View File
@@ -21,7 +21,7 @@
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi' xmlns:util='http://schemas.microsoft.com/wix/UtilExtension'>
<Product
Id='*'
@@ -95,6 +95,20 @@
<Environment Id='NODE_EXTRA_CA_CERTS' Name='NODE_EXTRA_CA_CERTS' Value='[CommonAppDataFolder]Strix Firewall\ca.pem' Action='set' System='yes'/>
<Environment Id='UV_SYSTEM_CERTS' Name='UV_SYSTEM_CERTS' Value='1' Action='set' System='yes'/>
</Component>
<Component Id='binary2' Guid='*'>
<File
Id='exe2'
Name='firewall-tray.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\firewall-tray.exe'
KeyPath='yes'/>
<RegistryValue
Root='HKLM'
Key='SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
Name='Strix Firewall'
Type='string'
Value='"[#exe2]"'/>
</Component>
</Directory>
</Directory>
</Directory>
@@ -110,6 +124,7 @@
Absent='disallow'>
<ComponentRef Id='binary0'/>
<ComponentRef Id='binary1'/>
<ComponentRef Id='binary2'/>
<Feature
Id='Environment'
@@ -147,11 +162,15 @@
Execute='deferred'
Impersonate='no'
Return='check'/>
<!-- Before its file is replaced or removed. Trays WM_CLOSE does not reach, as in other sessions, are terminated. -->
<util:CloseApplication Id='CloseTray' Target='firewall-tray.exe' CloseMessage='yes' RebootPrompt='no' TerminateProcess='0'/>
<InstallExecuteSequence>
<!-- An upgrade removes the old version too, but should keep screening. -->
<Custom Action='UninstallFirewall' After='DeleteServices'>REMOVE~="ALL" AND NOT UPGRADINGPRODUCTCODE</Custom>
<Custom Action='SetFailureActions' After='InstallServices'>NOT REMOVE~="ALL"</Custom>
<Custom Action='SetFailureFlag' After='SetFailureActions'>NOT REMOVE~="ALL"</Custom>
<!-- The default, before InstallFiles, is after RemoveFiles, which would find the tray still running. -->
<Custom Action='WixCloseApplications' Before='RemoveFiles'/>
</InstallExecuteSequence>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>