diff --git a/CLAUDE.md b/CLAUDE.md index 880b5546..f3a5de68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Git hooks are auto-installed via the root `build.gradle.kts` preBuild task — n ## Modules - `:app` — the Android app (everything below in Architecture refers to it) -- `:desktop` — Compose for Desktop (JVM) NIP-46 signer for Windows/macOS/Linux; standalone port that mirrors the Android permission model against `quartz-jvm` (no NIP-55). `./gradlew :desktop:run` to launch, `:desktop:packageDistributionForCurrentOs` to package. Its core mirrors `NotificationSubscription`/`EventNotificationConsumer`/`BunkerRequestUtils` in `desktop/.../core/BunkerEngine.kt` — behavior changes to the Android bunker flow should be ported there too. Keys are AES-encrypted via a PKCS12 Java KeyStore (`DesktopKeyStore`) whose password lives in the OS credential store (Keychain / Credential Manager / Secret Service via `java-keyring`, file fallback — see `KeystorePassword.resolve`); an opt-in `PassphraseLock` instead wraps the master key with Argon2id and adds a startup/auto-lock gate that evicts key material and gates the bunker engine — and, while set, encrypts the per-account database (apps/permissions/history/logs) at rest via `writeSecure`/`readSecure`. State is JSON files per account (no Room). See `desktop/README.md`. +- `:desktop` — Compose for Desktop (JVM) NIP-46 signer for Windows/macOS/Linux; standalone port that mirrors the Android permission model against `quartz-jvm` (no NIP-55). `./gradlew :desktop:run` to launch, `:desktop:packageDistributionForCurrentOs` to package. Its core mirrors `NotificationSubscription`/`EventNotificationConsumer`/`BunkerRequestUtils` in `desktop/.../core/BunkerEngine.kt` — behavior changes to the Android bunker flow should be ported there too. Keys are AES-encrypted via a PKCS12 Java KeyStore (`DesktopKeyStore`) whose password lives in the OS credential store (Keychain / Credential Manager / Secret Service via `java-keyring`, file fallback — see `KeystorePassword.resolve`); an opt-in `PassphraseLock` instead wraps the master key with Argon2id and adds a startup/auto-lock gate that evicts key material and gates the bunker engine — and, while set, encrypts the per-account database (apps/permissions/history/logs) at rest via `writeSecure`/`readSecure`. State is JSON files per account (no Room). Desktop notifications go through the OS-native channel (`core/Notifier.kt`: freedesktop `notify-send`/`gdbus` on Linux incl. Wayland/Hyprland, `osascript` on macOS, AWT tray on Windows), decoupled from the tray. The tray itself uses the dorkbox SystemTray library on Linux (`NativeTray.kt`) so it publishes a StatusNotifierItem/AppIndicator that shows on Wayland compositors, and the AWT/Compose `Tray` on Windows/macOS; `AMBER_DISABLE_TRAY=1` skips the native tray. See `desktop/README.md`. ## Architecture diff --git a/desktop/README.md b/desktop/README.md index 2ea253e4..d2d28bbb 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -23,7 +23,17 @@ for the JVM) and mirrors the mobile UI and permission model. - System tray: closing the window minimizes Amber to the tray so it keeps answering requests (with Open / Lock now / Quit menu), and new approval requests raise a system notification and bring the window back — both - configurable under Settings → Desktop + configurable under Settings → Desktop. The tray uses the freedesktop + StatusNotifierItem / AppIndicator protocol on Linux (via the dorkbox + SystemTray library), so the icon shows on Wayland compositors such as + Hyprland/Sway (through waybar's tray module) and on GNOME/KDE, not just + X11. On Wayland this needs an SNI host and, for AppIndicator, the + `libayatana-appindicator` runtime library. Set `AMBER_DISABLE_TRAY=1` to + skip the tray entirely if the native stack misbehaves. +- Notifications go through the OS-native channel: the freedesktop + notification daemon (mako, dunst, swaync, GNOME Shell, …) via `notify-send` + or `gdbus` on Linux — so they work on Hyprland/Wayland — `osascript` on + macOS, and the AWT tray notification on Windows - Optional passphrase lock (see Key storage below) - Native desktop layout: sidebar navigation with an account switcher, dense list views, and keyboard shortcuts diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 2dec8e3e..59cc5068 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -23,6 +23,10 @@ dependencies { // OS credential stores (macOS Keychain, Windows Credential Manager, // freedesktop Secret Service) for the keystore password. implementation(libs.java.keyring) + // Cross-platform system tray. Unlike AWT's SystemTray it speaks the + // freedesktop StatusNotifierItem / AppIndicator protocol, so a tray icon + // shows on Wayland compositors (Hyprland, Sway, GNOME) via waybar etc. + implementation(libs.dorkbox.systemtray) runtimeOnly(libs.slf4j.nop) // Argon2id for the optional passphrase lock. diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt index 4148822f..5b92ddf7 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -1,5 +1,6 @@ package com.greenart7c3.nostrsigner.desktop +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -110,11 +111,52 @@ fun main() { val pending by AmberDesktop.engine.pending.collectAsState() val settings by SettingsStore.settings.collectAsState() val language by Strings.currentLanguage.collectAsState() - var windowVisible by remember { mutableStateOf(true) } + val lockStatus by PassphraseLock.state.collectAsState() + // Window visibility is a flow so the native tray's menu callbacks (which + // fire on the tray library's own thread) can toggle it safely. + val windowVisibleState = remember { MutableStateFlow(true) } + val windowVisible by windowVisibleState.collectAsState() val trayState = rememberTrayState() - // AWT may claim support but fail to actually add the icon (e.g. bare - // X servers without a notification area); treat that as unsupported. - val trayUsable = remember { isTraySupported && runCatching { java.awt.SystemTray.getSystemTray() }.isSuccess } + + val isLinux = remember { + System.getProperty("os.name").lowercase().let { it.contains("linux") || it.contains("nix") || it.contains("nux") } + } + // AWT's tray only works with an XEmbed host, which excludes Wayland; on + // Linux we use the dorkbox tray (StatusNotifierItem/AppIndicator) + // instead, and keep AWT for Windows/macOS where it works well. + val awtTrayUsable = remember { + !isLinux && isTraySupported && runCatching { java.awt.SystemTray.getSystemTray() }.isSuccess + } + val nativeTray = remember { + // Escape hatch for environments where the native GTK/tray stack + // misbehaves: set AMBER_DISABLE_TRAY to skip it (the app then just + // exits on window close, and notifications still work). + if (isLinux && System.getenv("AMBER_DISABLE_TRAY") == null) { + NativeTray.create( + iconStream = { NativeTray::class.java.getResourceAsStream("/icon.png") }, + tooltip = Strings.get("d_tray_tooltip", language), + openLabel = Strings.get("d_tray_open", language), + lockLabel = Strings.get("d_lock_now", language), + quitLabel = Strings.get("d_tray_quit", language), + onToggle = { windowVisibleState.value = !windowVisibleState.value }, + onLock = { PassphraseLock.lock() }, + onQuit = { java.awt.EventQueue.invokeLater { exitApplication() } }, + ) + } else { + null + } + } + DisposableEffect(Unit) { onDispose { nativeTray?.shutdown() } } + LaunchedEffect(pending.size, windowVisible, lockStatus, language) { + nativeTray?.update( + tooltip = if (pending.isEmpty()) Strings.get("d_tray_tooltip", language) else Strings.format("d_tray_pending", pending.size, language = language), + openLabel = if (windowVisible) Strings.get("d_tray_hide", language) else Strings.get("d_tray_open", language), + lockLabel = Strings.get("d_lock_now", language), + showLock = lockStatus == PassphraseLock.Status.UNLOCKED, + ) + } + + val trayUsable = awtTrayUsable || nativeTray != null val trayActive = trayUsable && settings.closeToTray // Notify and surface the window whenever a new approval request arrives. @@ -129,7 +171,7 @@ fun main() { // macOS). Only fall back to the AWT tray notification — which // needs a usable system tray — when there is no native channel. val delivered = withContext(Dispatchers.IO) { Notifier.notify("Amber", message) } - if (!delivered && trayUsable) { + if (!delivered && awtTrayUsable) { trayState.sendNotification( Notification( title = "Amber", @@ -139,22 +181,21 @@ fun main() { ) } } - windowVisible = true + windowVisibleState.value = true } seenPending = pending.size } - if (trayUsable) { - val lockStatus by PassphraseLock.state.collectAsState() + if (awtTrayUsable) { Tray( icon = painterResource("icon.png"), state = trayState, tooltip = if (pending.isEmpty()) Strings.get("d_tray_tooltip", language) else Strings.format("d_tray_pending", pending.size, language = language), - onAction = { windowVisible = true }, + onAction = { windowVisibleState.value = true }, menu = { Item( if (windowVisible) Strings.get("d_tray_hide", language) else Strings.get("d_tray_open", language), - onClick = { windowVisible = !windowVisible }, + onClick = { windowVisibleState.value = !windowVisibleState.value }, ) if (lockStatus == PassphraseLock.Status.UNLOCKED) { Item(Strings.get("d_lock_now", language), onClick = { PassphraseLock.lock() }) @@ -168,7 +209,7 @@ fun main() { Window( onCloseRequest = { if (trayActive) { - windowVisible = false + windowVisibleState.value = false } else { exitApplication() } @@ -181,7 +222,7 @@ fun main() { handleShortcut( event, hideWindow = { - if (trayActive) windowVisible = false else exitApplication() + if (trayActive) windowVisibleState.value = false else exitApplication() }, quit = ::exitApplication, ) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt new file mode 100644 index 00000000..be5ac350 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt @@ -0,0 +1,79 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.AmberLogger +import dorkbox.systemTray.MenuItem +import dorkbox.systemTray.Separator +import dorkbox.systemTray.SystemTray +import java.awt.event.ActionListener + +/** + * A tray icon backed by the dorkbox SystemTray library, used on Linux. + * + * Unlike AWT's `SystemTray` (XEmbed only), dorkbox publishes a freedesktop + * StatusNotifierItem / AppIndicator, which is what modern Wayland compositors + * expose — so the icon and menu show up in waybar on Hyprland/Sway and in + * GNOME/KDE, as well as on X11. It is imperative and lives outside Compose: + * build it once, call [update] when state changes, and [shutdown] on exit. + */ +class NativeTray private constructor( + private val tray: SystemTray, + private val openItem: MenuItem, + private val lockItem: MenuItem, +) { + /** Reflect the current pending count, window visibility and lock state. */ + fun update(tooltip: String, openLabel: String, lockLabel: String, showLock: Boolean) { + runCatching { + tray.setTooltip(tooltip) + openItem.text = openLabel + lockItem.text = lockLabel + lockItem.enabled = showLock + }.onFailure { AmberLogger.d("NativeTray", "update failed: ${it.message}") } + } + + fun shutdown() { + runCatching { tray.shutdown() } + } + + companion object { + /** + * Try to create the tray. Returns null when no tray backend is + * available (dorkbox found neither AppIndicator/SNI nor GtkStatusIcon + * nor a Swing tray), so the caller can fall back gracefully. + */ + fun create( + iconStream: () -> java.io.InputStream?, + tooltip: String, + openLabel: String, + lockLabel: String, + quitLabel: String, + onToggle: () -> Unit, + onLock: () -> Unit, + onQuit: () -> Unit, + ): NativeTray? { + val tray = try { + SystemTray.get() + } catch (t: Throwable) { + AmberLogger.e("NativeTray", "SystemTray.get() failed", t as? Exception) + null + } ?: return null + + return try { + iconStream()?.use { tray.setImage(it) } + tray.setTooltip(tooltip) + val menu = tray.menu + val openItem = MenuItem(openLabel, ActionListener { onToggle() }) + val lockItem = MenuItem(lockLabel, ActionListener { onLock() }) + val quitItem = MenuItem(quitLabel, ActionListener { onQuit() }) + menu.add(openItem) + menu.add(lockItem) + menu.add(Separator()) + menu.add(quitItem) + NativeTray(tray, openItem, lockItem) + } catch (t: Throwable) { + AmberLogger.e("NativeTray", "Failed to build the tray", t as? Exception) + runCatching { tray.shutdown() } + null + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b1c1513..354eca0f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -90,6 +90,7 @@ kmptor-resource-exec = { module = "io.matthewnelson.kmp-tor:resource-exec-tor", secp256k1-jni-jvm = { module = "fr.acinq.secp256k1:secp256k1-kmp-jni-jvm", version.ref = "secp256k1Jni" } leakcanary = { group = "com.android.tools.studio.leakcanary", name = "leakcanary", version.ref = "leakcanary" } java-keyring = { module = "com.github.javakeyring:java-keyring", version = "1.0.4" } +dorkbox-systemtray = { module = "com.dorkbox:SystemTray", version = "4.4" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version = "2.0.7" } bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.84" }