mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
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:
co-authored by
Claude Opus 5.5
parent
072240663f
commit
eb622fbf40
@@ -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
@@ -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
|
||||
|
||||
@@ -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("&", "&")
|
||||
.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\""
|
||||
|
||||
|
||||
@@ -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&b/Apps/<Amber>.app/Contents/MacOS/Amber</string>"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user