desktop: open nostrconnect:// URI launches

Make nostrconnect:// links open Amber on Linux, mirroring the mobile
intent flow:

- UriLaunch: the OS handler launches the binary with the URI as an
  argument. A file lock makes the app single-instance — a second launch
  forwards its URI over a private unix socket in the runtime dir to the
  running instance and exits (the socket is only reachable from the same
  user, and the lock is released by the OS if the instance dies).
- Dev runs (./gradlew :desktop:run) are covered too: there is no stable
  binary to point a handler at, so registration generates
  ~/.local/bin/amber-desktop-dev, which drops the URI into a forward
  file (gradle cannot take it as an argument) before starting gradle;
  the second JVM reads that file and hands the URI over the socket.
  Both handlers self-register on every launch (user-level desktop entry
  + xdg-mime, idempotent, no root).
- App() processes pending URIs after the gates, so a link clicked while
  locked or before an account exists waits for unlock, then flows into
  the normal approval screen; the window raises itself out of the tray
  when a URI arrives.

Handler registration, desktop-entry generation and argv extraction are
unit-tested; the full chain (xdg-open -> handler -> forward -> running
instance) verified live on this machine.
This commit is contained in:
greenart7c3
2026-09-28 15:18:10 -03:00
parent 3746a45110
commit 8d8f0d34e6
4 changed files with 300 additions and 1 deletions
@@ -26,6 +26,7 @@ import com.greenart7c3.nostrsigner.desktop.core.Notifier
import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock
import com.greenart7c3.nostrsigner.desktop.core.SettingsStore
import com.greenart7c3.nostrsigner.desktop.core.Strings
import com.greenart7c3.nostrsigner.desktop.core.UriLaunch
import com.greenart7c3.nostrsigner.desktop.core.describe
import com.greenart7c3.nostrsigner.desktop.ui.App
import com.greenart7c3.nostrsigner.desktop.ui.NostrSignerTheme
@@ -121,7 +122,25 @@ private object DesktopTray {
var instance: NativeTray? = null
}
fun main() {
fun main(args: Array<String>) {
// Packaged handler: the URI arrives as an argument. Dev handler
// (gradle): it arrives via the drop file (gradle cannot take it as an
// argument).
val launchUri = UriLaunch.extract(args) ?: UriLaunch.readForwardedUri()
// Single instance: a second launch hands its nostrconnect:// URI to the
// running app and exits. The OS releases the lock if the first instance
// dies, so a failed acquire always means a live instance exists.
val primary = UriLaunch.tryAcquireSingleInstance()
if (!primary) {
if (launchUri != null && UriLaunch.forwardToRunningInstance(launchUri)) return
if (launchUri == null) return
// Could not forward (no listener): fall through and start anyway.
} else {
UriLaunch.registerSchemeHandler()
UriLaunch.startIpcServer()
}
if (launchUri != null) UriLaunch.pending.value = launchUri
// The dorkbox tray prefers to be created before Compose/AWT initializes
// GTK (dorkbox has to own GTK loading, otherwise the AppIndicator backend
// fails to start and SystemTray.get() returns null even when
@@ -164,6 +183,13 @@ fun main() {
// The tray's Quit routes here so we can exit the Compose app cleanly.
LaunchedEffect(quitRequested) { if (quitRequested) exitApplication() }
// A nostrconnect:// link explicitly targets Amber: raise the window,
// even when it was minimized to the tray.
LaunchedEffect(Unit) {
UriLaunch.pending.collect { uri ->
if (uri != null) DesktopTray.windowVisible.value = true
}
}
DisposableEffect(Unit) { onDispose { nativeTray?.shutdown() } }
LaunchedEffect(pending.size, windowVisible, lockStatus, language) {
nativeTray?.update(
@@ -0,0 +1,217 @@
package com.greenart7c3.nostrsigner.desktop.core
import java.io.File
import java.net.StandardProtocolFamily
import java.net.UnixDomainSocketAddress
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import java.nio.channels.ServerSocketChannel
import java.nio.channels.SocketChannel
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
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.
* - 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.
* - The running instance listens on that socket and exposes forwarded URIs
* as [pending]; the UI processes each one through `addNostrConnect` once
* an account is unlocked (a link clicked while locked waits for unlock).
*/
object UriLaunch {
private const val PREFIX = "nostrconnect://"
private const val SOCKET_NAME = "amber-nostrconnect.sock"
private const val HANDLER_DESKTOP_FILE = "amber-nostrconnect.desktop"
val isLinux: Boolean = System.getProperty("os.name").lowercase().let {
it.contains("linux") || it.contains("nix") || it.contains("nux")
}
private var lockFile: FileChannel? = null
private var instanceLock: java.nio.channels.FileLock? = null
/** Latest nostrconnect:// URI this process received but has not handled yet. */
val pending = MutableStateFlow<String?>(null)
/** First nostrconnect:// URI among the launch arguments, if any. */
fun extract(args: Array<String>): String? = args.firstOrNull { it.startsWith(PREFIX) }
private const val FORWARD_FILE_NAME = "amber-nostrconnect.forward"
/**
* URI dropped by the dev wrapper for this launch (see
* [handlerExecutable]). Read-and-delete: empty when absent or invalid.
*/
fun readForwardedUri(): String? = runCatching {
val file = Path.of(xdgRuntimeDir(), FORWARD_FILE_NAME).toFile()
val uri = if (file.exists()) file.readText().trim() else ""
if (file.exists()) file.delete()
if (uri.startsWith(PREFIX)) uri else null
}.getOrNull()
private fun xdgRuntimeDir(): String = System.getenv("XDG_RUNTIME_DIR")?.takeIf { it.isNotBlank() }
?: AppDirs.dataDir.absolutePath
/**
* Takes the single-instance lock. False means another instance is
* running (the OS releases the lock automatically if that instance dies).
*/
fun tryAcquireSingleInstance(): Boolean {
AppDirs.dataDir.mkdirs()
return runCatching {
val channel = FileChannel.open(
Path.of(AppDirs.dataDir.absolutePath, "amber.lock"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
)
val lock = channel.tryLock()
if (lock == null) {
runCatching { channel.close() }
false
} else {
lockFile = channel
instanceLock = lock
true
}
}.getOrDefault(false)
}
/** Hands [uri] to the running instance over the unix socket. */
fun forwardToRunningInstance(uri: String): Boolean = runCatching {
SocketChannel.open(StandardProtocolFamily.UNIX).use { channel ->
channel.connect(UnixDomainSocketAddress.of(socketPath()))
channel.write(ByteBuffer.wrap(uri.toByteArray(Charsets.UTF_8)))
true
}
}.getOrDefault(false)
/**
* Accept loop for URIs forwarded by second launches. Removes a stale
* socket file first (safe: the caller holds the single-instance lock).
*/
fun startIpcServer() {
if (!isLinux) return
Thread {
runCatching {
val path = socketPath()
Files.deleteIfExists(path)
ServerSocketChannel.open(StandardProtocolFamily.UNIX).use { server ->
server.bind(UnixDomainSocketAddress.of(path))
while (true) {
val client = server.accept() ?: continue
readUri(client)
runCatching { client.close() }
}
}
}
}.apply {
isDaemon = true
name = "amber-uri-ipc"
}.start()
}
private fun readUri(client: SocketChannel) {
val buffer = ByteBuffer.allocate(64 * 1024)
while (runCatching { client.read(buffer) }.getOrDefault(-1) > 0) {
if (buffer.position() >= buffer.capacity()) break
}
val uri = String(buffer.array(), 0, buffer.position(), Charsets.UTF_8).trim()
if (uri.startsWith(PREFIX)) pending.value = uri
}
/**
* Registers this binary as the `nostrconnect://` handler for the current
* user (no root): a private desktop entry plus the xdg default. Best-
* effort and idempotent — re-run on every launch so the path stays fresh.
*
* For a packaged image the handler is the image binary. For a dev run
* (`./gradlew :desktop:run`, bare `java` on the command line) there is no
* stable binary, so a small wrapper script is generated instead: it
* always runs the current code of the project the JVM was started from.
*/
fun registerSchemeHandler() {
if (!isLinux) return
runCatching {
val exe = handlerExecutable() ?: return
val appsDir = xdgAppsDir().apply { mkdirs() }
val entry = File(appsDir, HANDLER_DESKTOP_FILE)
entry.writeText(desktopEntry(exe))
ProcessBuilder("xdg-mime", "default", HANDLER_DESKTOP_FILE, "x-scheme-handler/nostrconnect")
.start()
.waitFor()
runCatching {
ProcessBuilder("update-desktop-database", appsDir.absolutePath).start().waitFor()
}
AmberLogger.d("UriLaunch", "Registered nostrconnect:// handler: $entry -> $exe")
}.onFailure {
AmberLogger.d("UriLaunch", "Scheme registration failed: ${it.message}")
}
}
/**
* The stable launch target for the handler: the packaged binary, or a
* generated `~/.local/bin/amber-desktop-dev` wrapper that re-runs gradle
* in the project the dev JVM was started from.
*
* The wrapper cannot pass the URI to gradle as an argument (it would be
* parsed as a task name), so it drops it into the forward file first; the
* second JVM picks it up via [readForwardedUri] and hands it to the
* running instance over the socket.
*/
internal fun handlerExecutable(): String? {
val commandLine = currentCommandLine() ?: return null
if (File(commandLine).name != "java") return commandLine
val projectDir = System.getProperty("user.dir") ?: return null
val gradlew = File(projectDir, "gradlew")
if (!gradlew.exists()) return null
val binDir = File(File(System.getProperty("user.home"), ".local"), "bin").apply { mkdirs() }
val wrapper = File(binDir, "amber-desktop-dev")
val forwardFile = Path.of(xdgRuntimeDir(), FORWARD_FILE_NAME)
wrapper.writeText(
"#!/bin/sh\n" +
"# Generated by Amber; runs the desktop app from the dev project.\n" +
"# gradle cannot take the URI as an argument, so drop it into the\n" +
"# forward file for the second JVM to pick up.\n" +
"[ -n \"\$1\" ] && printf '%s' \"\$1\" > " + shellQuote(forwardFile.toString()) + "\n" +
"cd " + shellQuote(projectDir) + " && exec " + shellQuote(gradlew.absolutePath) + " --quiet :desktop:run\n",
)
runCatching { wrapper.setExecutable(true) }
return wrapper.absolutePath
}
private fun shellQuote(value: String): String = "'" + value.replace("'", "'\\''") + "'"
private fun xdgAppsDir(): File {
val dataHome = System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() }
?: File(File(System.getProperty("user.home"), ".local"), "share").absolutePath
return File(dataHome, "applications")
}
/** The desktop entry pointing at [commandLine] (single binary, no args). */
internal fun desktopEntry(exePath: String): String = """
[Desktop Entry]
Type=Application
Name=Amber
NoDisplay=true
Exec=${quoteForDesktopEntry(exePath)} %u
MimeType=x-scheme-handler/nostrconnect;
""".trimIndent() + "\n"
/** Desktop-entry Exec quoting: double quotes with backslash escapes. */
internal fun quoteForDesktopEntry(value: String): String = if (value.none { it in " \t\"'\\" }) value else "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
private fun currentCommandLine(): String? = runCatching {
String(Files.readAllBytes(Path.of("/proc/self/cmdline")), Charsets.UTF_8)
.split('\u0000')
.firstOrNull { it.isNotBlank() }
}.getOrNull()
private fun socketPath(): Path = Path.of(xdgRuntimeDir(), SOCKET_NAME)
}
@@ -56,6 +56,7 @@ import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount
import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock
import com.greenart7c3.nostrsigner.desktop.core.Strings
import com.greenart7c3.nostrsigner.desktop.core.UriLaunch
import com.greenart7c3.nostrsigner.desktop.core.toShortenHex
import kotlinx.coroutines.launch
@@ -123,6 +124,19 @@ fun App() {
return
}
// A nostrconnect:// link opened Amber (launch argument, or forwarded by
// a second launch). Process it here — after the gates — so a link
// clicked while locked or before an account exists waits for unlock.
val launchUri by UriLaunch.pending.collectAsState()
LaunchedEffect(launchUri, acc) {
val uri = launchUri ?: return@LaunchedEffect
UriLaunch.pending.value = null
val error = AmberDesktop.engine.addNostrConnect(uri, acc)
if (error != null) {
Toaster.toast(error)
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
@@ -0,0 +1,42 @@
package com.greenart7c3.nostrsigner.desktop
import com.greenart7c3.nostrsigner.desktop.core.UriLaunch
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class UriLaunchTest {
@Test
fun extractsNostrConnectUriFromArgs() {
val uri = "nostrconnect://abc?relay=wss%3A%2F%2Frelay.example.com"
assertEquals(uri, UriLaunch.extract(arrayOf(uri)))
assertEquals(uri, UriLaunch.extract(arrayOf("--some-flag", uri)))
assertEquals(uri, UriLaunch.extract(arrayOf("bunker://other", uri)))
assertNull(UriLaunch.extract(arrayOf("bunker://only")))
assertNull(UriLaunch.extract(emptyArray()))
}
@Test
fun desktopEntryDeclaresTheSchemeHandler() {
val entry = UriLaunch.desktopEntry("/opt/Amber/bin/Amber")
assertTrue(entry.startsWith("[Desktop Entry]"))
assertTrue(entry.contains("Exec=/opt/Amber/bin/Amber %u"))
assertTrue(entry.contains("MimeType=x-scheme-handler/nostrconnect;"))
assertTrue(entry.contains("NoDisplay=true"))
}
@Test
fun desktopEntryQuotesPathsSafely() {
// Paths without separators stay bare; spaces and quotes get the
// desktop-entry double-quote treatment.
assertEquals(
"Exec=\"/opt/My Apps/Amber\" %u",
UriLaunch.desktopEntry("/opt/My Apps/Amber").lineSequence().first { it.startsWith("Exec=") },
)
assertEquals(
"Exec=\"/opt/a\\\"b/Amber\" %u",
UriLaunch.desktopEntry("/opt/a\"b/Amber").lineSequence().first { it.startsWith("Exec=") },
)
}
}