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 <noreply@anthropic.com>
This commit is contained in:
greenart7c3
2026-10-02 05:04:42 -03:00
co-authored by Claude Opus 5.5
parent 072240663f
commit eb622fbf40
10 changed files with 197 additions and 23 deletions
+1 -1
View File
@@ -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
+10 -3
View File
@@ -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
+17
View File
@@ -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 = """
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Nostr Connect</string>
<key>CFBundleURLSchemes</key>
<array>
<string>nostrconnect</string>
</array>
</dict>
</array>
""".trimIndent()
}
}
}
@@ -143,6 +143,13 @@ fun main(args: Array<String>) {
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<String>) {
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 })
}
@@ -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.
@@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LAUNCH_AGENT_LABEL</string>
<key>ProgramArguments</key>
<array>
<string>${xmlEscape(exePath)}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>ProcessType</key>
<string>Interactive</string>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
</dict>
</plist>
""".trimIndent() + "\n"
private fun xmlEscape(value: String): String = value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
/** The Run value is a command line: always quote the path (e.g. `C:\Program Files\...`). */
internal fun windowsRunCommand(exePath: String): String = "\"$exePath\""
@@ -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<String>): Boolean = run(*command.toTypedArray())
@@ -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 ->
@@ -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")))
}
}
@@ -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("<?xml"))
assertTrue(plist.contains("<string>com.greenart7c3.nostrsigner</string>"))
assertTrue(plist.contains("<string>/Applications/Amber.app/Contents/MacOS/Amber</string>"))
assertTrue(plist.contains("<key>RunAtLoad</key>\n <true/>"))
assertTrue(plist.contains("<string>Aqua</string>"))
// Quitting Amber must stick: launchd must not relaunch it.
assertFalse(plist.contains("KeepAlive"))
}
@Test
fun launchAgentEscapesXmlInPath() {
val plist = AutoStart.launchAgentContent("/Users/a&b/Apps/<Amber>.app/Contents/MacOS/Amber")
assertTrue(plist.contains("<string>/Users/a&amp;b/Apps/&lt;Amber&gt;.app/Contents/MacOS/Amber</string>"))
}
}