diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9e6c72e79..90f72a6b2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -88,6 +88,7 @@ jobs: TAURI_CONF_VERSION=$(node -p 'require("./src-tauri/tauri.conf.json").version') CARGO_VERSION=$(awk -F'"' '/^version =/ { print $2; exit }' ./src-tauri/Cargo.toml) PKG_VERSION=$(node -p 'require("./package.json").version') + PKGBUILD_VERSION=$(awk -F= '/^pkgver=/ { print $2; exit }' ./packaging/PKGBUILD) if [[ "$TAURI_CONF_VERSION" != "$TAG_VERSION" ]]; then echo "src-tauri/tauri.conf.json version ($TAURI_CONF_VERSION) != tag version ($TAG_VERSION)" @@ -101,6 +102,10 @@ jobs: echo "package.json version ($PKG_VERSION) != tag version ($TAG_VERSION)" exit 1 fi + if [[ "$PKGBUILD_VERSION" != "$TAG_VERSION" ]]; then + echo "packaging/PKGBUILD pkgver ($PKGBUILD_VERSION) != tag version ($TAG_VERSION)" + exit 1 + fi - uses: tauri-apps/tauri-action@v0 env: diff --git a/.gitignore b/.gitignore index b1124cb49..aa323ec02 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ openusage-cli_*.tar.gz src-tauri/binaries/ src-tauri/resources/bundled_plugins/* !src-tauri/resources/bundled_plugins/.gitkeep +/packaging/*.zst +/packaging/pkg/ diff --git a/Cargo.lock b/Cargo.lock index 8064aa7bd..bc44d9aef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,6 +202,20 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ashpd" +version = "0.13.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "tokio", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -3814,7 +3828,9 @@ dependencies = [ name = "openusage" version = "0.6.37" dependencies = [ + "ashpd", "dirs 6.0.0", + "futures-util", "gtk", "libc", "log", @@ -6460,8 +6476,10 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -8008,6 +8026,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", diff --git a/README.md b/README.md index ed86ae685..1ea215e70 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # OpenUsage Community - _Track all your AI coding subscriptions in one place_ +_Track all your AI coding subscriptions in one place_ @@ -130,7 +130,19 @@ chmod +x OpenUsage.AppImage ./OpenUsage.AppImage ``` -After installing via `.rpm` or `.deb`, launch **OpenUsage Community** from your app menu. The app starts in the system tray. +### Arch (and other Arch-based rolling-release distros) + +On rolling-release distros with a very recent Mesa, the portable AppImage may fail to open its window with `Could not create default EGL display: EGL_BAD_PARAMETER`. The AppImage bundles an older GTK/WebKit/GL stack that no longer matches a bleeding-edge host GPU driver. Build a native package instead — it links against your system's own `webkit2gtk-4.1` and Mesa, so it always matches: + +```sh +git clone https://github.com/openusage-community/openusage.git +cd openusage/packaging +makepkg -si +``` + +This builds and installs the `openusage` package; launch **OpenUsage** from your app menu. + +After installing via `.rpm`, `.deb` or `PKGBUILD`, launch **OpenUsage Community** from your app menu. The app starts in the system tray. If your desktop environment has no tray support, install an AppIndicator or StatusNotifier extension, or open the panel with the global shortcut. diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100644 index 000000000..7058a9fe5 --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,64 @@ +# Maintainer: OpenUsage community +pkgname=openusage +# Tauri's resource_dir() resolves to /usr/lib/; productName is "OpenUsage". +_appname=OpenUsage +pkgver=0.6.37 +pkgrel=1 +pkgdesc="Open source AI subscription limit tracker (native build for Arch-based systems)" +arch=('x86_64') +url="https://github.com/openusage-community/openusage" +license=('MIT') +# Runtime: linked against the host's WebKitGTK/GTK stack, so it always matches the +# host Mesa/Wayland/EGL. +depends=('webkit2gtk-4.1' 'gtk3' 'libayatana-appindicator' 'libsecret' 'openssl' + 'hicolor-icon-theme') +# rust>=1.85 is required (Cargo edition 2024). bun builds and embeds the frontend. +makedepends=('git' 'rust' 'cargo' 'bun') +options=('!lto') +source=("$pkgname-$pkgver::git+$url.git#tag=v$pkgver") +sha256sums=('SKIP') + +prepare() { + cd "$srcdir/$pkgname-$pkgver" + bun install --frozen-lockfile +} + +build() { + cd "$srcdir/$pkgname-$pkgver" + bun run tauri build --no-bundle +} + +package() { + cd "$srcdir/$pkgname-$pkgver" + + # Cargo workspace: the app binary lands in the repo-root target dir. The openusage-cli + # sidecar (externalBin) is built by the beforeBuildCommand into src-tauri/binaries. + install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname" + install -Dm755 "src-tauri/binaries/openusage-cli-x86_64-unknown-linux-gnu" "$pkgdir/usr/bin/openusage-cli" + + install -d "$pkgdir/usr/lib/$_appname/resources" + cp -r src-tauri/resources/bundled_plugins "$pkgdir/usr/lib/$_appname/resources/" + install -Dm644 src-tauri/icons/tray-icon.png "$pkgdir/usr/lib/$_appname/icons/tray-icon.png" + + # Application icons. + for size in 32 64 128; do + install -Dm644 "src-tauri/icons/${size}x${size}.png" \ + "$pkgdir/usr/share/icons/hicolor/${size}x${size}/apps/$pkgname.png" + done + install -Dm644 "src-tauri/icons/128x128@2x.png" \ + "$pkgdir/usr/share/icons/hicolor/256x256/apps/$pkgname.png" + + install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/$_appname.desktop" <<'DESKTOP' +[Desktop Entry] +Type=Application +Name=OpenUsage +Comment=OpenUsage is an open source AI subscription limit tracker +Exec=openusage +Icon=openusage +Terminal=false +Categories=Utility; +StartupWMClass=openusage +DESKTOP + + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 45910e9b1..3ec53d4e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,6 +42,8 @@ rust_xlsxwriter = "0.95.0" tauri-plugin-dialog = "2.7.1" [target.'cfg(target_os = "linux")'.dependencies] +ashpd = { version = "0.13", default-features = false, features = ["tokio", "global_shortcuts"] } +futures-util = { version = "0.3", default-features = false, features = ["std"] } gtk = "0.18" [target.'cfg(target_os = "windows")'.dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 391355d38..2ee0bd7c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,8 @@ mod panel_windows; #[cfg(any(target_os = "linux", target_os = "windows"))] mod popover_platform; mod tray; +#[cfg(target_os = "linux")] +mod wayland_global_shortcut; #[cfg(target_os = "macos")] mod webkit_config; @@ -478,6 +480,15 @@ fn update_global_shortcut( Some(trimmed) } }); + + // On Wayland, X11 key grabs never fire; register through the XDG GlobalShortcuts + // portal instead + #[cfg(target_os = "linux")] + if wayland_global_shortcut::is_wayland() { + wayland_global_shortcut::configure(&app_handle, normalized_shortcut); + return Ok(()); + } + let mut managed_shortcut = managed_shortcut_slot() .lock() .map_err(|e| format!("failed to lock managed shortcut state: {}", e))?; @@ -686,28 +697,40 @@ pub fn run() { { use tauri_plugin_store::StoreExt; - if let Ok(store) = app.handle().store("settings.json") { - if let Some(shortcut_value) = store.get(GLOBAL_SHORTCUT_STORE_KEY) { - if let Some(shortcut) = shortcut_value.as_str() { - let shortcut = shortcut.trim(); - if !shortcut.is_empty() { - let handle = app.handle().clone(); - log::info!("Registering initial global shortcut: {}", shortcut); - if let Err(e) = handle.global_shortcut().on_shortcut( - shortcut, - |app, _shortcut, event| { - handle_global_shortcut(app, event); - }, - ) { - log::warn!("Failed to register initial global shortcut: {}", e); - } else if let Ok(mut managed_shortcut) = - managed_shortcut_slot().lock() - { - *managed_shortcut = Some(shortcut.to_string()); - } else { - log::warn!("Failed to store managed shortcut in memory"); - } - } + let stored_shortcut = app + .handle() + .store("settings.json") + .ok() + .and_then(|store| store.get(GLOBAL_SHORTCUT_STORE_KEY)) + .and_then(|value| value.as_str().map(|s| s.trim().to_string())) + .filter(|s| !s.is_empty()); + + // On Wayland, register through the XDG portal (X11 grabs don't fire). + #[cfg(target_os = "linux")] + let handled_by_portal = if wayland_global_shortcut::is_wayland() { + log::info!("Registering global shortcut via Wayland portal"); + wayland_global_shortcut::configure(app.handle(), stored_shortcut.clone()); + true + } else { + false + }; + #[cfg(not(target_os = "linux"))] + let handled_by_portal = false; + + if !handled_by_portal { + if let Some(shortcut) = stored_shortcut { + log::info!("Registering initial global shortcut: {}", shortcut); + if let Err(e) = app.handle().global_shortcut().on_shortcut( + shortcut.as_str(), + |app, _shortcut, event| { + handle_global_shortcut(app, event); + }, + ) { + log::warn!("Failed to register initial global shortcut: {}", e); + } else if let Ok(mut managed_shortcut) = managed_shortcut_slot().lock() { + *managed_shortcut = Some(shortcut); + } else { + log::warn!("Failed to store managed shortcut in memory"); } } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 051b59907..057bf6675 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,10 +5,12 @@ fn prefer_x11_backend_for_panel_positioning() { if should_force_x11_backend( std::env::var_os("GDK_BACKEND").as_deref(), + std::env::var_os("WAYLAND_DISPLAY").as_deref(), + std::env::var_os("XDG_SESSION_TYPE").as_deref(), std::env::var_os("DISPLAY").as_deref(), ) { - // Wayland compositors usually ignore absolute window positioning. - // The tray panel needs X11/XWayland so it can open next to the tray icon. + // Wayland ignores client window positioning; route through XWayland so the tray + // panel can be placed under the tray icon. unsafe { std::env::set_var("GDK_BACKEND", "x11"); } @@ -18,9 +20,21 @@ fn prefer_x11_backend_for_panel_positioning() { #[cfg(target_os = "linux")] fn should_force_x11_backend( current_backend: Option<&std::ffi::OsStr>, + wayland_display: Option<&std::ffi::OsStr>, + session_type: Option<&std::ffi::OsStr>, display: Option<&std::ffi::OsStr>, ) -> bool { - display.is_some() && current_backend.and_then(|value| value.to_str()) != Some("x11") + // Never override an explicit GDK_BACKEND. + if current_backend.is_some() { + return false; + } + // The x11 backend needs a reachable X server. + if display.is_none() { + return false; + } + // Wayland detection: DISPLAY is also set under XWayland, so it can't distinguish X11 + // from Wayland. Key off WAYLAND_DISPLAY (GDK's own signal), then XDG_SESSION_TYPE. + wayland_display.is_some() || session_type.and_then(|value| value.to_str()) == Some("wayland") } fn main() { @@ -35,24 +49,57 @@ mod tests { use super::*; use std::ffi::OsStr; + fn os(value: &str) -> Option<&OsStr> { + Some(OsStr::new(value)) + } + #[test] - fn forces_x11_when_wayland_backend_is_inherited() { + fn forces_x11_on_wayland_session_with_xwayland() { + // unset backend, WAYLAND_DISPLAY + DISPLAY set. assert!(should_force_x11_backend( - Some(OsStr::new("wayland")), - Some(OsStr::new(":0")), + None, + os("wayland-0"), + os("wayland"), + os(":0"), )); } #[test] - fn does_not_force_x11_without_display() { - assert!(!should_force_x11_backend(Some(OsStr::new("wayland")), None,)); + fn forces_x11_when_only_session_type_signals_wayland() { + // no WAYLAND_DISPLAY, session_type=wayland, DISPLAY set. + assert!(should_force_x11_backend(None, None, os("wayland"), os(":0"))); + } + + #[test] + fn does_not_force_on_pure_x11_session() { + // no Wayland signals, DISPLAY set: already X11. + assert!(!should_force_x11_backend(None, None, os("x11"), os(":0"))); + } + + #[test] + fn does_not_force_without_xwayland() { + // Wayland session, no DISPLAY: stay on Wayland. + assert!(!should_force_x11_backend(None, os("wayland-0"), os("wayland"), None)); + } + + #[test] + fn respects_explicit_wayland_backend() { + // explicit GDK_BACKEND=wayland. + assert!(!should_force_x11_backend( + os("wayland"), + os("wayland-0"), + os("wayland"), + os(":0"), + )); } #[test] fn leaves_existing_x11_backend_alone() { assert!(!should_force_x11_backend( - Some(OsStr::new("x11")), - Some(OsStr::new(":0")), + os("x11"), + os("wayland-0"), + os("wayland"), + os(":0"), )); } } diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index aaab9c4fc..0a9072194 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -8,8 +8,8 @@ use tauri_nspanel::{ }; use crate::panel_geometry::{ - LogicalAnchor, LogicalMonitorBounds, PanelAnchorPosition, compute_anchor_position, - fallback_anchor_for_monitor, + LogicalAnchor, LogicalMonitorBounds, PanelAnchorPosition, bottom_right_fallback_position, + compute_anchor_position, }; const PANEL_ANCHOR_OFFSET_EVENT: &str = "panel:anchor-offset"; @@ -242,26 +242,21 @@ fn compute_fallback_panel_position(app_handle: &AppHandle) -> Option LogicalAnchor { - LogicalAnchor { - center_x: monitor.x + monitor.width - FALLBACK_ANCHOR_RIGHT_INSET_PX, - bottom_y: monitor.y + FALLBACK_TOP_PANEL_BOTTOM_Y_PX, +/// Panel position when the tray icon rect is unavailable (Linux SNI, Windows): +/// bottom-right, above the taskbar. +pub(crate) fn bottom_right_fallback_position( + monitor: &LogicalMonitorBounds, + panel_width: f64, + panel_height: f64, +) -> PanelAnchorPosition { + let x = (monitor.x + monitor.width - panel_width - FALLBACK_RIGHT_MARGIN_PX).max(monitor.x); + let y = + (monitor.y + monitor.height - panel_height - FALLBACK_BOTTOM_MARGIN_PX).max(monitor.y); + PanelAnchorPosition { + x, + y, + arrow_offset_px: 0.0, } } @@ -111,7 +124,7 @@ mod tests { } #[test] - fn fallback_anchor_places_panel_near_top_right() { + fn bottom_right_fallback_places_panel_above_taskbar() { let monitor = LogicalMonitorBounds { x: 0.0, y: 0.0, @@ -119,12 +132,28 @@ mod tests { height: 1080.0, }; - let anchor = fallback_anchor_for_monitor(&monitor); - let result = compute_anchor_position(&monitor, anchor, 400.0, 500.0); + let result = bottom_right_fallback_position(&monitor, 400.0, 500.0); - assert_eq!(result.x, 1520.0); - assert_eq!(result.y, 26.0); - assert_eq!(result.arrow_offset_px, 152.0); + // right: 1920 - 400 - 8 = 1512 + // bottom: 1080 - 500 - 56 = 524 + assert_eq!(result.x, 1512.0); + assert_eq!(result.y, 524.0); + assert_eq!(result.arrow_offset_px, 0.0); + } + + #[test] + fn bottom_right_fallback_clamps_into_small_monitor() { + let monitor = LogicalMonitorBounds { + x: 0.0, + y: 0.0, + width: 300.0, + height: 300.0, + }; + + let result = bottom_right_fallback_position(&monitor, 400.0, 500.0); + + assert_eq!(result.x, 0.0); + assert_eq!(result.y, 0.0); } #[test] diff --git a/src-tauri/src/panel_non_macos.rs b/src-tauri/src/panel_non_macos.rs index 5d6425b11..bd92bf8b7 100644 --- a/src-tauri/src/panel_non_macos.rs +++ b/src-tauri/src/panel_non_macos.rs @@ -14,8 +14,6 @@ use crate::panel::{ const CLICK_CATCHER_LABEL: &str = "panel-click-catcher"; const CLICK_CATCHER_URL: &str = "index.html?overlay=panel-click-catcher"; -#[cfg(target_os = "linux")] -static LINUX_FOCUS_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); static PANEL_IS_OPEN: AtomicBool = AtomicBool::new(false); #[derive(Clone, Copy)] @@ -34,6 +32,9 @@ fn register_panel_closed() { PANEL_IS_OPEN.store(false, Ordering::SeqCst); } +#[cfg(target_os = "linux")] +static LINUX_FOCUS_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); + fn should_hide_for_focus_loss(is_visible: bool, is_open: bool) -> bool { is_visible && is_open } @@ -126,6 +127,14 @@ fn get_or_create_click_catcher(app_handle: &AppHandle) -> Option bool { + // The always-on-top click-catcher can stack above the panel on some compositors + // (KDE/XWayland) and intercept panel clicks; dismiss via focus loss instead. + false +} + +#[cfg(not(target_os = "linux"))] fn should_show_click_catcher() -> bool { true } @@ -199,9 +208,12 @@ pub(crate) fn apply_panel_position( /// No NSPanel on non-macOS; the regular window is configured via tauri.conf.json. pub fn init(app_handle: &AppHandle) -> tauri::Result<()> { + // Dismiss on focus loss instead of the click-catcher (see should_show_click_catcher): + // clicks inside the panel keep the toplevel focused, so interaction doesn't dismiss it. #[cfg(target_os = "linux")] init_linux_focus_loss_handler(app_handle)?; - + #[cfg(not(target_os = "linux"))] + let _ = app_handle; Ok(()) } @@ -342,8 +354,8 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn linux_uses_click_catcher_overlay() { - assert!(should_show_click_catcher()); + fn linux_dismisses_via_focus_loss_not_click_catcher() { + assert!(!should_show_click_catcher()); } #[test] diff --git a/src-tauri/src/wayland_global_shortcut.rs b/src-tauri/src/wayland_global_shortcut.rs new file mode 100644 index 000000000..aee3d765b --- /dev/null +++ b/src-tauri/src/wayland_global_shortcut.rs @@ -0,0 +1,107 @@ +//! Wayland global shortcuts via the XDG `org.freedesktop.portal.GlobalShortcuts` portal. +//! +//! `tauri-plugin-global-shortcut` uses X11 `XGrabKey`, which never fires on Wayland (the +//! compositor owns the keyboard). The portal registers a named shortcut with a preferred +//! trigger; the desktop owns the final binding and emits `Activated` on press. + +use std::sync::Mutex; + +use tauri::AppHandle; + +const SHORTCUT_ID: &str = "toggle-panel"; + +static PORTAL_TASK: Mutex>> = Mutex::new(None); + +pub fn is_wayland() -> bool { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + return true; + } + matches!(std::env::var("XDG_SESSION_TYPE"), Ok(value) if value.eq_ignore_ascii_case("wayland")) +} + +/// (Re)register the global shortcut through the portal. Pass `None` to disable it. +pub fn configure(app_handle: &AppHandle, shortcut: Option) { + if let Some(handle) = PORTAL_TASK.lock().expect("portal task lock poisoned").take() { + handle.abort(); + } + + let Some(shortcut) = shortcut else { + log::info!("wayland global shortcut disabled"); + return; + }; + + let app_handle = app_handle.clone(); + let handle = tauri::async_runtime::spawn(async move { + if let Err(error) = run_portal(app_handle, shortcut).await { + log::warn!("wayland global shortcut portal failed: {error}"); + } + }); + *PORTAL_TASK.lock().expect("portal task lock poisoned") = Some(handle); +} + +async fn run_portal(app_handle: AppHandle, shortcut: String) -> Result<(), ashpd::Error> { + use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut}; + use futures_util::StreamExt; + + let global_shortcuts = GlobalShortcuts::new().await?; + let session = global_shortcuts.create_session(Default::default()).await?; + + let preferred = to_portal_trigger(&shortcut); + let new_shortcut = NewShortcut::new(SHORTCUT_ID, "Show / hide the OpenUsage panel") + .preferred_trigger(Some(preferred.as_str())); + + global_shortcuts + .bind_shortcuts(&session, &[new_shortcut], None, Default::default()) + .await? + .response()?; + log::info!( + "wayland global shortcut registered via portal (preferred trigger '{}')", + preferred + ); + + let mut activated = global_shortcuts.receive_activated().await?; + while let Some(activation) = activated.next().await { + if activation.shortcut_id() != SHORTCUT_ID { + continue; + } + log::debug!("wayland global shortcut activated"); + let app = app_handle.clone(); + if let Err(error) = app_handle.run_on_main_thread(move || { + crate::panel::toggle_panel(&app); + }) { + log::warn!("failed to toggle panel from global shortcut: {error}"); + } + } + + // Session must outlive the listener loop. + drop(session); + Ok(()) +} + +/// Map a Tauri accelerator to the XDG shortcuts trigger syntax. Only a hint; the desktop +/// may override it. +fn to_portal_trigger(shortcut: &str) -> String { + shortcut + .split('+') + .map(|part| match part.trim().to_ascii_lowercase().as_str() { + "commandorcontrol" | "cmdorctrl" | "control" | "ctrl" => "CTRL".to_string(), + "shift" => "SHIFT".to_string(), + "alt" | "option" => "ALT".to_string(), + "super" | "meta" | "command" | "cmd" | "win" | "logo" => "LOGO".to_string(), + other => other.to_uppercase(), + }) + .collect::>() + .join("+") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_common_accelerators_to_portal_triggers() { + assert_eq!(to_portal_trigger("CommandOrControl+Shift+U"), "CTRL+SHIFT+U"); + assert_eq!(to_portal_trigger("Control+Alt+K"), "CTRL+ALT+K"); + assert_eq!(to_portal_trigger("Super+Space"), "LOGO+SPACE"); + } +} diff --git a/src/hooks/app/use-tray-icon.ts b/src/hooks/app/use-tray-icon.ts index 3bf011ead..2f624d9e7 100644 --- a/src/hooks/app/use-tray-icon.ts +++ b/src/hooks/app/use-tray-icon.ts @@ -246,6 +246,9 @@ export function useTrayIcon({ const style = menubarIconStyleRef.current const sizePx = getTrayIconSizePx(window.devicePixelRatio) const foregroundColor = trayForegroundColorRef.current + // Linux/SNI tray slots are square; render a square icon instead of the wide + // macOS-menu-bar layout (which the compositor scales down until it's tiny). + const square = isLinuxTrayRef.current const nextActiveView = activeViewRef.current const activeProviderId = nextActiveView !== "home" && nextActiveView !== "settings" ? nextActiveView : null @@ -312,6 +315,7 @@ export function useTrayIcon({ sizePx, style: "bars", foregroundColor, + square, }) .then(async (img) => { if (!isCurrentUpdate()) return @@ -342,6 +346,7 @@ export function useTrayIcon({ style: "donut", providerIconUrl, foregroundColor, + square, }) .then(async (img) => { if (!isCurrentUpdate()) return @@ -366,6 +371,7 @@ export function useTrayIcon({ percentText: supportsNativeTrayTitle ? undefined : providerPercentText, providerIconUrl, foregroundColor, + square, }) .then(async (img) => { if (!isCurrentUpdate()) return diff --git a/src/lib/tray-bars-icon.ts b/src/lib/tray-bars-icon.ts index 556978200..2063a098d 100644 --- a/src/lib/tray-bars-icon.ts +++ b/src/lib/tray-bars-icon.ts @@ -150,6 +150,7 @@ function getSvgLayout(args: { sizePx: number style: MenubarIconStyle percentText?: string + square?: boolean }): { width: number height: number @@ -161,7 +162,7 @@ function getSvgLayout(args: { textY: number fontSize: number } { - const { sizePx, style, percentText } = args + const { sizePx, style, percentText, square } = args const hasPercentText = typeof percentText === "string" && percentText.length > 0 const verticalNudgePx = 1 const pad = Math.max(1, Math.round(sizePx * 0.08)) // ~2px at 24–36px @@ -175,6 +176,22 @@ function getSvgLayout(args: { // Optical correction + global nudge down to align with the tray slot center. const textY = Math.round(sizePx / 2) + 1 + verticalNudgePx + if (square) { + // Linux/SNI tray slots are square and fixed-size; a wide macOS-menu-bar layout gets + // scaled down to fit and looks tiny. Every style is drawn in a sizePx × sizePx box. + return { + width: sizePx, + height, + pad, + gap, + barsX, + barsWidth, + textX: 0, + textY, + fontSize, + } + } + if (style === "donut") { const donutGap = Math.max(1, Math.round(sizePx * 0.06)) return { @@ -236,6 +253,7 @@ export function makeTrayBarsSvg(args: { percentText?: string providerIconUrl?: string foregroundColor?: string + square?: boolean }): string { const { bars, @@ -244,16 +262,20 @@ export function makeTrayBarsSvg(args: { percentText, providerIconUrl, foregroundColor = "black", + square = false, } = args const fg = foregroundColor.trim().length > 0 ? foregroundColor : "black" const barsForStyle = getBarsForStyle(style, bars) // Keep bars visually stable during loading and with a single provider. const n = Math.max(1, Math.min(4, barsForStyle.length || 1)) - const text = style === "bars" ? undefined : normalizePercentText(percentText) + // Square (Linux) icons never bake the percent text — there is no horizontal room in a + // square slot; the percentage lives in the tooltip instead. + const text = square || style === "bars" ? undefined : normalizePercentText(percentText) const layout = getSvgLayout({ sizePx, style, percentText: text, + square, }) const width = layout.width @@ -286,6 +308,39 @@ export function makeTrayBarsSvg(args: { `` ) } + } else if (style === "donut" && square) { + // Square (Linux) donut: a single centered ring gauge with the provider logo inside, + // filling the square slot instead of the macOS icon-beside-ring layout. + const chartSize = Math.max(6, sizePx - 2 * layout.pad) + const cx = sizePx / 2 + const cy = height / 2 + 1 + const strokeW = Math.max(2, Math.round(chartSize * 0.16)) + const radius = Math.max(1, Math.floor(chartSize / 2 - strokeW / 2) + 0.5) + + const href = + typeof providerIconUrl === "string" ? themeSvgDataUrl(providerIconUrl.trim(), fg) : "" + if (href.length > 0) { + const innerIcon = Math.max(6, Math.round(radius * 1.25)) + parts.push( + `` + ) + } + + parts.push( + `` + ) + + const fraction = barsForStyle[0]?.fraction + if (typeof fraction === "number" && Number.isFinite(fraction) && fraction >= 0) { + const clamped = Math.max(0, Math.min(1, fraction)) + if (clamped > 0) { + const circumference = 2 * Math.PI * radius + const dash = circumference * clamped + parts.push( + `` + ) + } + } } else if (style === "donut") { const iconSize = Math.max(6, Math.round(sizePx - 2 * layout.pad * 0.5)) const iconX = layout.barsX @@ -451,9 +506,18 @@ export async function renderTrayBarsIcon(args: { percentText?: string providerIconUrl?: string foregroundColor?: string + square?: boolean }): Promise { - const { bars, sizePx, style = "provider", percentText, providerIconUrl, foregroundColor } = args - const text = style === "bars" ? undefined : normalizePercentText(percentText) + const { + bars, + sizePx, + style = "provider", + percentText, + providerIconUrl, + foregroundColor, + square = false, + } = args + const text = square || style === "bars" ? undefined : normalizePercentText(percentText) const svg = makeTrayBarsSvg({ bars, sizePx, @@ -461,13 +525,18 @@ export async function renderTrayBarsIcon(args: { percentText: text, providerIconUrl, foregroundColor, + square, }) const layout = getSvgLayout({ sizePx, style, percentText: text, + square, }) - const canvasWidth = Math.max(layout.width, getStableTrayImageWidthPx(sizePx)) + // On macOS we pad every icon to a stable width so the menu-bar item doesn't jitter as + // the percentage changes. A square (Linux) slot must not be padded — that is exactly + // what makes the icon look tiny — so we keep the natural square width. + const canvasWidth = square ? layout.width : Math.max(layout.width, getStableTrayImageWidthPx(sizePx)) const rgba = await rasterizeSvgToRgba({ svg, svgWidthPx: layout.width,