mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 11:28:22 +00:00
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:
@@ -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=") },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user