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 18a26fc8..24a61652 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -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) { + // 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( 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 new file mode 100644 index 00000000..6cc45b5c --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt @@ -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(null) + + /** First nostrconnect:// URI among the launch arguments, if any. */ + fun extract(args: Array): 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) +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt index 5bd76812..316521f0 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt @@ -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 -> diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UriLaunchTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UriLaunchTest.kt new file mode 100644 index 00000000..92e70c58 --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UriLaunchTest.kt @@ -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=") }, + ) + } +}