From eb622fbf407518b8aa8ac262f2b5ed492f4247c8 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Fri, 2 Oct 2026 05:04:42 -0300 Subject: [PATCH] desktop: macOS notifications, nostrconnect links, start-on-boot, private account dirs - Notifications: drop osascript on macOS. It posts as Script Editor, which a fresh macOS never authorizes or prompts for, so banners were silently dropped while osascript exited 0. Use the AWT tray notification, posted as Amber's own bundle (allowed on first launch). - nostrconnect://: declare the scheme in the bundle's Info.plist and receive links via Desktop.setOpenURIHandler, installed only in the primary instance (initializing AWT keeps a losing second instance alive otherwise). - Start on boot: per-user LaunchAgent with RunAtLoad, no KeepAlive, Aqua only. - Account dirs and the instance lock file are now owner-only; existing 0755 account dirs are tightened on load. Verified in a macOS Sequoia 15.8.1 VM. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- desktop/README.md | 13 +++- desktop/build.gradle.kts | 17 +++++ .../greenart7c3/nostrsigner/desktop/Main.kt | 15 +++-- .../nostrsigner/desktop/core/AppDirs.kt | 8 ++- .../nostrsigner/desktop/core/AutoStart.kt | 63 +++++++++++++++++++ .../nostrsigner/desktop/core/Notifier.kt | 22 +++---- .../nostrsigner/desktop/core/UriLaunch.kt | 33 +++++++++- .../nostrsigner/desktop/AppDirsTest.kt | 29 +++++++++ .../nostrsigner/desktop/AutoStartTest.kt | 18 ++++++ 10 files changed, 197 insertions(+), 23 deletions(-) create mode 100644 desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppDirsTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index f3a5de68..e661c2cf 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). 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`. +- `: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; AWT tray notification on Windows and macOS — never `osascript` on macOS, it posts as Script Editor, which is never authorized, and is silently dropped), 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 61cf8ea9..b7f10f87 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -14,7 +14,9 @@ for the JVM) and mirrors the mobile UI and permission model. `nip44v3_encrypt/decrypt`, `decrypt_zap_event`, `sign_psbt`, `switch_relays`, `logout` - Connect applications with a `nostrconnect://` URI or by generating a - `bunker://` URI (with QR code) — each connection gets its own local key + `bunker://` URI (with QR code) — each connection gets its own local key. + Clicking a `nostrconnect://` link opens Amber (Linux: registered per user + via `xdg-mime`; macOS: declared in the app bundle's Info.plist) - The same permission model as mobile: auto-accept / auto-reject rules per request type and event kind, time-bound grants (5 minutes … always), and per-application sign policies (basic / manual / sign everything) @@ -37,14 +39,19 @@ for the JVM) and mirrors the mobile UI and permission model. forces the backend and `AMBER_DISABLE_TRAY=1` skips the tray entirely. - 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 + or `gdbus` on Linux — so they work on Hyprland/Wayland — and the AWT tray + notification on Windows and macOS (on macOS it is posted as Amber itself; + allow it when macOS asks on first launch, or later under System Settings → + Notifications → Amber) - Mandatory passphrase lock (see Key storage below) - Optional start-on-boot (Settings → Desktop), always starting locked — passphrase required before anything signs. Only offered for installed builds (not `:desktop:run`): - Windows: a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry (no admin rights; also listed under Task Manager → Startup apps) + - macOS: a per-user LaunchAgent (`~/Library/LaunchAgents/com.greenart7c3.nostrsigner.plist`) + that runs Amber at login (listed under System Settings → General → Login + Items & Extensions) - Linux: installs and enables a hardened systemd **user** service that starts Amber with the desktop session. No `MemoryDenyWriteExecute` (the JVM's JIT cannot run under it); the unit still gets diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 5799921f..7a88b04c 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -88,6 +88,23 @@ compose.desktop { } macOS { bundleID = "com.greenart7c3.nostrsigner" + infoPlist { + // Claim nostrconnect:// so LaunchServices routes those + // links to Amber (delivered via Desktop.setOpenURIHandler). + extraKeysRawXml = """ + CFBundleURLTypes + + + CFBundleURLName + Nostr Connect + CFBundleURLSchemes + + nostrconnect + + + + """.trimIndent() + } } } 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 5f6574f5..a29c40dc 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -143,6 +143,13 @@ fun main(args: Array) { if (launchUri == null) return // Could not forward (no listener): fall through and start anyway. } else { + // macOS delivers nostrconnect:// links as an Apple event, not as an + // argument (also to an already-running Amber). Install the handler + // before Compose starts so the event that launched the app is not + // missed — but only in the primary instance: it initializes AWT, and + // once AWT is up returning from main no longer ends the process, so + // a second instance would linger invisibly instead of exiting. + UriLaunch.installMacOpenUriHandler() UriLaunch.registerSchemeHandler() UriLaunch.startIpcServer() } @@ -261,10 +268,10 @@ fun main(args: Array) { var notified = false if (settings.showNotifications) { val message = "${request.appName} ${request.type.describe(request.kind, language)}" - // Prefer the OS-native notification channel (freedesktop / - // notify-send on Linux, incl. Wayland/Hyprland; osascript on - // macOS). Only fall back to the AWT tray notification — which - // needs a usable system tray — when there is no native channel. + // Prefer the OS-native notification channel on Linux + // (freedesktop / notify-send, incl. Wayland/Hyprland). On + // Windows and macOS the AWT tray notification is the native + // channel (on macOS it posts as Amber's own bundle). notified = withContext(Dispatchers.IO) { Notifier.notify("Amber", message, onActivate = { DesktopTray.windowVisible.value = true }) } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppDirs.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppDirs.kt index a6548997..190590df 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppDirs.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppDirs.kt @@ -23,7 +23,13 @@ object AppDirs { dir } - fun accountDir(npub: String): File = File(dataDir, npub).apply { mkdirs() } + fun accountDir(npub: String): File = File(dataDir, npub).apply { + mkdirs() + // mkdirs() honors the umask (typically 0755); the account's apps, + // permissions and history live here, so keep it owner-only. Also + // tightens directories created by older versions. + restrictToOwner(this) + } /** * Best-effort restriction of a file/directory to the current user. diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt index e9406696..ec390b20 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt @@ -11,6 +11,9 @@ import java.io.File * - Windows: a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` * entry pointing at the installed `Amber.exe` (no admin rights needed; shows * up under Task Manager → Startup apps, where the user can also disable it). + * - macOS: a per-user LaunchAgent in `~/Library/LaunchAgents` that runs the + * app bundle's launcher at login (no admin rights needed; listed under + * System Settings → General → Login Items & Extensions). * - Linux: installs and enables a hardened systemd user unit (Opal-style) * that starts Amber with the desktop session. * @@ -34,6 +37,11 @@ object AutoStart { val isWindows: Boolean = System.getProperty("os.name").lowercase().contains("win") + val isMac: Boolean = System.getProperty("os.name").lowercase().contains("mac") + + /** launchd label; also the plist file name. Matches the bundle id. */ + private const val LAUNCH_AGENT_LABEL = "com.greenart7c3.nostrsigner" + /** Set by the jpackage launcher to the installed binary; absent in dev (gradle) runs. */ private fun packagedExecutable(): String? = System.getProperty("jpackage.app-path")?.takeIf { it.isNotBlank() } @@ -46,6 +54,7 @@ object AutoStart { /** True when the current launch has a stable binary the OS can start at login. */ fun isSupported(): Boolean = when { isWindows -> packagedExecutable() != null + isMac -> packagedExecutable() != null isLinux -> currentExecutable()?.let { File(it).name != "java" } ?: false else -> false } @@ -65,6 +74,10 @@ object AutoStart { setWindowsRunEntry(enabled) return } + if (isMac) { + setMacLaunchAgent(enabled) + return + } runCatching { // systemd requires an absolute ExecStart: /proc/self/cmdline // records the path exactly as invoked (it can be relative). @@ -100,6 +113,56 @@ object AutoStart { }.onFailure { AmberLogger.e("AutoStart", "Failed to update the Windows Run entry", it) } } + /** + * Writes (or removes) the LaunchAgent. launchd picks it up at the next + * login; it is deliberately not loaded with launchctl, so enabling never + * launches a second instance and disabling never kills the running one. + */ + private fun setMacLaunchAgent(enabled: Boolean) { + runCatching { + val plist = File(System.getProperty("user.home"), "Library/LaunchAgents/$LAUNCH_AGENT_LABEL.plist") + if (enabled) { + val exe = packagedExecutable()?.let { File(it).canonicalFile.path } ?: return + plist.parentFile.mkdirs() + plist.writeText(launchAgentContent(exe)) + } else { + plist.delete() + } + }.onFailure { AmberLogger.e("AutoStart", "Failed to update the macOS LaunchAgent", it) } + } + + /** + * RunAtLoad starts Amber once per login. No KeepAlive: quitting Amber + * must stick. LimitLoadToSessionType=Aqua keeps it out of SSH/background + * sessions, where there is no window server to show it on. + */ + internal fun launchAgentContent(exePath: String): String = """ + + + + + Label + $LAUNCH_AGENT_LABEL + ProgramArguments + + ${xmlEscape(exePath)} + + RunAtLoad + + ProcessType + Interactive + LimitLoadToSessionType + Aqua + + + """.trimIndent() + "\n" + + private fun xmlEscape(value: String): String = value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + /** The Run value is a command line: always quote the path (e.g. `C:\Program Files\...`). */ internal fun windowsRunCommand(exePath: String): String = "\"$exePath\"" diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Notifier.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Notifier.kt index 9b703279..119561e3 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Notifier.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Notifier.kt @@ -12,9 +12,15 @@ import java.util.concurrent.TimeUnit * freedesktop.org notification daemon (mako, dunst, swaync, GNOME Shell, …). * * [notify] delivers through the native channel for the current OS and returns - * `true` when it dispatched a notification. It returns `false` only when no - * native channel is available (notably Windows, where the caller should fall - * back to the AWT tray notification) so the caller can decide what to do next. + * `true` when it dispatched a notification. It returns `false` when there is + * no external channel to use, so the caller falls back to the AWT tray + * notification: + * - Windows: the tray balloon/toast is the native channel. + * - macOS: AWT posts the notification as Amber's own bundle, which macOS asks + * the user to allow on first launch. `osascript display notification` is + * attributed to Script Editor instead, which a fresh macOS never authorizes + * and never prompts for — the notification is silently dropped while + * osascript still exits 0, so it cannot be detected and must not be used. */ object Notifier { private val os = System.getProperty("os.name").lowercase() @@ -28,8 +34,7 @@ object Notifier { */ fun notify(title: String, message: String, onActivate: (() -> Unit)? = null): Boolean = when { os.contains("linux") || os.contains("nix") || os.contains("nux") -> linux(title, message, onActivate) - os.contains("mac") || os.contains("darwin") -> mac(title, message) - else -> false // Windows: let the caller use the AWT tray notification. + else -> false // Windows/macOS: let the caller use the AWT tray notification. } /** @@ -91,13 +96,6 @@ object Notifier { false } - private fun mac(title: String, message: String): Boolean { - val script = "display notification ${appleScriptString(message)} with title ${appleScriptString(title)}" - return run("osascript", "-e", script) - } - - private fun appleScriptString(s: String): String = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" - /** Test hook: exercises the process plumbing without depending on the OS. */ internal fun tryCommand(command: List): Boolean = run(*command.toTypedArray()) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt index 6cc45b5c..eebe7602 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt @@ -1,5 +1,6 @@ package com.greenart7c3.nostrsigner.desktop.core +import java.awt.Desktop import java.io.File import java.net.StandardProtocolFamily import java.net.UnixDomainSocketAddress @@ -15,8 +16,11 @@ import kotlinx.coroutines.flow.MutableStateFlow /** * Makes `nostrconnect://` links open Amber (desktop). * - * - The OS handler (see [registerSchemeHandler]) launches the Amber binary - * with the URI as an argument. + * - Linux: the OS handler (see [registerSchemeHandler]) launches the Amber + * binary with the URI as an argument. + * - macOS: the scheme is declared in the app bundle's Info.plist and + * LaunchServices delivers the URI as an Apple event, to the running app if + * there is one (see [installMacOpenUriHandler]). * - A file lock makes the app single-instance: a second launch forwards the * URI over a private unix socket in the runtime dir to the running * instance and exits. @@ -33,6 +37,8 @@ object UriLaunch { it.contains("linux") || it.contains("nix") || it.contains("nux") } + val isMac: Boolean = System.getProperty("os.name").lowercase().contains("mac") + private var lockFile: FileChannel? = null private var instanceLock: java.nio.channels.FileLock? = null @@ -70,6 +76,7 @@ object UriLaunch { StandardOpenOption.CREATE, StandardOpenOption.WRITE, ) + AppDirs.restrictToOwner(File(AppDirs.dataDir, "amber.lock")) val lock = channel.tryLock() if (lock == null) { runCatching { channel.close() } @@ -82,6 +89,28 @@ object UriLaunch { }.getOrDefault(false) } + /** + * Receives `nostrconnect://` links on macOS. LaunchServices routes a + * clicked link to the running Amber (starting it if needed) as an Apple + * event; AWT queues that event until a handler is installed, so calling + * this before Compose starts also catches the link that launched the app. + * Call it only in the primary instance (see main). + */ + fun installMacOpenUriHandler() { + if (!isMac) return + runCatching { + if (!Desktop.isDesktopSupported()) return + val desktop = Desktop.getDesktop() + if (!desktop.isSupported(Desktop.Action.APP_OPEN_URI)) return + desktop.setOpenURIHandler { event -> + val uri = event.uri.toString() + if (uri.startsWith(PREFIX)) pending.value = uri + } + }.onFailure { + AmberLogger.d("UriLaunch", "Could not install the macOS URI handler: ${it.message}") + } + } + /** Hands [uri] to the running instance over the unix socket. */ fun forwardToRunningInstance(uri: String): Boolean = runCatching { SocketChannel.open(StandardProtocolFamily.UNIX).use { channel -> diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppDirsTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppDirsTest.kt new file mode 100644 index 00000000..c8e2bd34 --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppDirsTest.kt @@ -0,0 +1,29 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.AppDirs +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.Test + +class AppDirsTest { + private fun perms(file: File): String = PosixFilePermissions.toString(Files.getPosixFilePermissions(file.toPath())) + + @Test + fun accountDirIsOwnerOnly() { + assumeTrue(!System.getProperty("os.name").lowercase().contains("win")) + val dir = AppDirs.accountDir("npub1appdirstest") + assertEquals("rwx------", perms(dir)) + } + + @Test + fun accountDirTightensExistingWorldReadableDir() { + assumeTrue(!System.getProperty("os.name").lowercase().contains("win")) + // A directory left behind by an older version with the umask default. + val dir = File(AppDirs.dataDir, "npub1appdirslegacy").apply { mkdirs() } + Files.setPosixFilePermissions(dir.toPath(), PosixFilePermissions.fromString("rwxr-xr-x")) + assertEquals("rwx------", perms(AppDirs.accountDir("npub1appdirslegacy"))) + } +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt index d8c78d0f..ee4de4a3 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt @@ -39,4 +39,22 @@ class AutoStartTest { // "C:\Program Files\..." would try to launch "C:\Program". assertEquals("\"C:\\Program Files\\Amber\\Amber.exe\"", AutoStart.windowsRunCommand("C:\\Program Files\\Amber\\Amber.exe")) } + + @Test + fun launchAgentRunsBundleLauncherAtLogin() { + val plist = AutoStart.launchAgentContent("/Applications/Amber.app/Contents/MacOS/Amber") + assertTrue(plist.startsWith("com.greenart7c3.nostrsigner")) + assertTrue(plist.contains("/Applications/Amber.app/Contents/MacOS/Amber")) + assertTrue(plist.contains("RunAtLoad\n ")) + assertTrue(plist.contains("Aqua")) + // Quitting Amber must stick: launchd must not relaunch it. + assertFalse(plist.contains("KeepAlive")) + } + + @Test + fun launchAgentEscapesXmlInPath() { + val plist = AutoStart.launchAgentContent("/Users/a&b/Apps/.app/Contents/MacOS/Amber") + assertTrue(plist.contains("/Users/a&b/Apps/<Amber>.app/Contents/MacOS/Amber")) + } }