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"))
+ }
}