desktop: Windows start-on-boot, installer shortcuts, window fit, request expiry

- Start on boot on Windows via a per-user HKCU\...\Run entry (JNA
  Advapi32Util); offered only for installed builds (jpackage.app-path)
- MSI/EXE: Start Menu entry, desktop shortcut and a proper .ico
- Window opens centered and fitted to the usable screen area (was hidden
  under the taskbar on 1280x800), and remembers size/maximized state
- Pending relay requests expire after 10 minutes or at their NIP-40
  expiration; already-expired requests are dropped on arrival (mirrors
  EventNotificationConsumer). nostrconnect:// connects never expire

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
greenart7c3
2026-09-30 13:38:03 -03:00
co-authored by Claude Opus 5.5
parent adc873b391
commit 072240663f
12 changed files with 237 additions and 19 deletions
+14 -5
View File
@@ -40,11 +40,20 @@ for the JVM) and mirrors the mobile UI and permission model.
or `gdbus` on Linux — so they work on Hyprland/Wayland — `osascript` on
macOS, and the AWT tray notification on Windows
- Mandatory passphrase lock (see Key storage below)
- Optional start-on-boot (Settings → Desktop): installs and enables a
hardened systemd **user** service that starts Amber with the desktop
session — always locked, passphrase required before anything signs. No
`MemoryDenyWriteExecute` (the JVM's JIT cannot run under it); the unit
still gets `ProtectSystem=strict`, seccomp, `NoNewPrivileges` and friends
- 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)
- 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
`ProtectSystem=strict`, seccomp, `NoNewPrivileges` and friends
- Windows installer (MSI/EXE) adds a Start Menu entry and a desktop shortcut
- The window opens fitted to the screen's usable area (never under the
taskbar) and remembers its size and maximized state
- Pending requests expire after 10 minutes (or at the request's NIP-40
`expiration`, if sooner), since NIP-46 clients stop waiting long before
- Native desktop layout: sidebar navigation with an account switcher, dense
list views, and keyboard shortcuts
- Light/dark theme using the Amber palette
+10
View File
@@ -47,6 +47,8 @@ dependencies {
// freedesktop StatusNotifierItem / AppIndicator protocol, so a tray icon
// shows on Wayland compositors (Hyprland, Sway, GNOME) via waybar etc.
implementation(libs.dorkbox.systemtray)
// Windows registry access for start-on-boot (HKCU\...\Run).
implementation(libs.jna.platform)
runtimeOnly(libs.slf4j.nop)
// Argon2id for the optional passphrase lock.
@@ -76,6 +78,14 @@ compose.desktop {
iconFile.set(rootProject.file("assets/android-icon-hires.png"))
menuGroup = "Network"
}
windows {
iconFile.set(project.file("icons/amber.ico"))
// Start Menu entry + desktop shortcut; without these the MSI
// only adds an uninstall entry and Amber.exe is hard to find.
menu = true
menuGroup = "Amber"
shortcut = true
}
macOS {
bundleID = "com.greenart7c3.nostrsigner"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

@@ -7,16 +7,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import androidx.compose.ui.window.isTraySupported
import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import com.greenart7c3.nostrsigner.desktop.core.AccountManager
import com.greenart7c3.nostrsigner.desktop.core.AccountsStore
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
@@ -32,8 +34,12 @@ import com.greenart7c3.nostrsigner.desktop.core.describe
import com.greenart7c3.nostrsigner.desktop.ui.App
import com.greenart7c3.nostrsigner.desktop.ui.NostrSignerTheme
import com.greenart7c3.nostrsigner.desktop.ui.handleShortcut
import com.greenart7c3.nostrsigner.desktop.ui.initialWindowSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -142,7 +148,7 @@ fun main(args: Array<String>) {
}
if (launchUri != null) UriLaunch.pending.value = launchUri
// Keep the autostart unit fresh (binary path can change between builds).
// Keep the autostart entry fresh (binary path can change between builds/installs).
if (SettingsStore.settings.value.startOnBoot) {
AutoStart.setEnabled(true)
}
@@ -169,7 +175,14 @@ fun main(args: Array<String>) {
Session.boot()
application {
val windowState = rememberWindowState(size = DpSize(1100.dp, 780.dp))
val windowState = remember {
val saved = SettingsStore.settings.value
WindowState(
placement = if (saved.windowMaximized) WindowPlacement.Maximized else WindowPlacement.Floating,
position = WindowPosition(Alignment.Center),
size = initialWindowSize(saved.windowWidth, saved.windowHeight),
)
}
val pending by AmberDesktop.engine.pending.collectAsState()
val settings by SettingsStore.settings.collectAsState()
val language by Strings.currentLanguage.collectAsState()
@@ -187,6 +200,29 @@ fun main(args: Array<String>) {
!isLinux && isTraySupported && runCatching { java.awt.SystemTray.getSystemTray() }.isSuccess
}
// Remember maximized state and the floating size across launches. The
// size is only saved while floating, so un-maximizing restores it.
LaunchedEffect(windowState) {
snapshotFlow { windowState.placement to windowState.size }
.drop(1)
.collectLatest { (placement, size) ->
delay(500)
SettingsStore.update {
if (placement == WindowPlacement.Floating) {
it.copy(windowMaximized = false, windowWidth = size.width.value.toInt(), windowHeight = size.height.value.toInt())
} else {
it.copy(windowMaximized = placement == WindowPlacement.Maximized)
}
}
}
}
// Drop requests whose client has most likely given up (see PendingBunkerRequest.expiresAt).
LaunchedEffect(Unit) {
while (true) {
delay(15_000)
AmberDesktop.engine.pruneExpired()
}
}
// 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,
@@ -1,11 +1,18 @@
package com.greenart7c3.nostrsigner.desktop.core
import com.sun.jna.platform.win32.Advapi32Util
import com.sun.jna.platform.win32.WinReg
import java.io.File
/**
* Optional start-on-boot: installs and enables a hardened systemd user unit
* (Opal-style) that starts Amber with the desktop session. Amber always
* comes up locked — the passphrase is still required before anything signs.
* Optional start-on-boot. Amber always comes up locked — the passphrase is
* still required before anything signs.
*
* - 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).
* - Linux: installs and enables a hardened systemd user unit (Opal-style)
* that starts Amber with the desktop session.
*
* Hardening mirrors Opal's unit with one deliberate exception: no
* MemoryDenyWriteExecute, which the JVM cannot survive (the JIT needs
@@ -18,22 +25,29 @@ import java.io.File
*/
object AutoStart {
private const val UNIT_NAME = "amber.service"
private const val RUN_KEY = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
private const val RUN_VALUE = "Amber"
val isLinux: Boolean = System.getProperty("os.name").lowercase().let {
it.contains("linux") || it.contains("nix") || it.contains("nux")
}
val isWindows: Boolean = System.getProperty("os.name").lowercase().contains("win")
/** 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() }
private fun currentExecutable(): String? = runCatching {
String(java.nio.file.Files.readAllBytes(java.nio.file.Path.of("/proc/self/cmdline")), Charsets.UTF_8)
.split('\u0000')
.firstOrNull { it.isNotBlank() }
}.getOrNull()
/** True when the current launch can be supervised by systemd. */
fun isSupported(): Boolean {
if (!isLinux) return false
val exe = currentExecutable() ?: return false
return File(exe).name != "java"
/** True when the current launch has a stable binary the OS can start at login. */
fun isSupported(): Boolean = when {
isWindows -> packagedExecutable() != null
isLinux -> currentExecutable()?.let { File(it).name != "java" } ?: false
else -> false
}
private fun unitDir(): File = File(System.getProperty("user.home"), ".config/systemd/user")
@@ -47,6 +61,10 @@ object AutoStart {
*/
fun setEnabled(enabled: Boolean) {
if (!isSupported()) return
if (isWindows) {
setWindowsRunEntry(enabled)
return
}
runCatching {
// systemd requires an absolute ExecStart: /proc/self/cmdline
// records the path exactly as invoked (it can be relative).
@@ -67,6 +85,24 @@ object AutoStart {
}
}
/**
* Writes (or removes) the Run entry. Like the systemd path, enabling never
* launches a second instance and disabling never kills the running one.
*/
private fun setWindowsRunEntry(enabled: Boolean) {
runCatching {
if (enabled) {
val exe = packagedExecutable()?.let { File(it).canonicalFile.path } ?: return
Advapi32Util.registrySetStringValue(WinReg.HKEY_CURRENT_USER, RUN_KEY, RUN_VALUE, windowsRunCommand(exe))
} else if (Advapi32Util.registryValueExists(WinReg.HKEY_CURRENT_USER, RUN_KEY, RUN_VALUE)) {
Advapi32Util.registryDeleteValue(WinReg.HKEY_CURRENT_USER, RUN_KEY, RUN_VALUE)
}
}.onFailure { AmberLogger.e("AutoStart", "Failed to update the Windows Run entry", it) }
}
/** The Run value is a command line: always quote the path (e.g. `C:\Program Files\...`). */
internal fun windowsRunCommand(exePath: String): String = "\"$exePath\""
private fun systemctl(vararg args: String): Boolean = runCatching {
ProcessBuilder("systemctl", "--user", *args).start().waitFor() == 0
}.getOrDefault(false)
@@ -20,6 +20,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
@@ -37,6 +38,7 @@ import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -67,6 +69,13 @@ data class PendingBunkerRequest(
val encryptionType: EncryptionType = EncryptionType.NIP44,
val isNostrConnectUri: Boolean = false,
val signerPrivKey: String = "",
/**
* Unix seconds after which the request is dropped from the queue, or null
* to keep it until answered. NIP-46 gives no signal when a client stops
* waiting, so relay requests get a generous TTL (or their NIP-40
* expiration, if sooner) instead of lingering forever.
*/
val expiresAt: Long? = null,
)
/**
@@ -284,6 +293,12 @@ class BunkerEngine(
if (event.kind != NostrConnectEvent.KIND) return
if (!event.verify()) return
if (event.content.isEmpty()) return
// Mirrors EventNotificationConsumer: drop NIP-40-expired requests.
val expiration = event.expiration()
if (expiration != null && expiration < TimeUtils.now()) {
AmberLogger.d("BunkerEngine", "Event ${event.id} has expired")
return
}
val alreadySeen = synchronized(seenEvents) {
if (seenEvents.containsKey(event.id)) {
@@ -535,6 +550,7 @@ class BunkerEngine(
result = computed.result,
encryptionType = encryptionType,
signerPrivKey = effectivePrivKey,
expiresAt = minOf(TimeUtils.now() + PENDING_TTL_SECONDS, event.expiration() ?: Long.MAX_VALUE),
),
)
}
@@ -629,6 +645,11 @@ class BunkerEngine(
pending.value = pending.value.filter { it.request.id != id }
}
/** Drops requests whose [PendingBunkerRequest.expiresAt] has passed; the UI calls this periodically. */
fun pruneExpired(now: Long = TimeUtils.now()) {
pending.update { list -> list.filter { it.expiresAt == null || it.expiresAt > now } }
}
/**
* Mirrors `BunkerRequestUtils.sendResult`. Runs on the engine's
* application scope (a SupervisorJob), never the caller's — approving
@@ -1049,6 +1070,9 @@ class BunkerEngine(
}
companion object {
/** How long a relay request waits for a decision before it is dropped. */
const val PENDING_TTL_SECONDS = 10 * 60L
fun typeFromMethod(method: String): SignerType = when (method) {
"connect" -> SignerType.CONNECT
"sign_event" -> SignerType.SIGN_EVENT
@@ -181,10 +181,15 @@ data class DesktopSettings(
val closeToTray: Boolean = true,
/** Show a system notification when a request needs approval. */
val showNotifications: Boolean = true,
/** Start automatically with the desktop session (systemd user service). */
/** Start automatically at login (systemd user service on Linux, HKCU Run entry on Windows). */
val startOnBoot: Boolean = false,
/** UI language tag (matches Strings.supportedLanguages); null = follow the OS. */
val language: String? = null,
/** Last floating window size in dp; null = default (fitted to the screen). */
val windowWidth: Int? = null,
val windowHeight: Int? = null,
/** Reopen maximized when the window was maximized at last change. */
val windowMaximized: Boolean = false,
) {
fun normalizedDefaultRelays(): List<NormalizedRelayUrl> = defaultRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
}
@@ -0,0 +1,37 @@
package com.greenart7c3.nostrsigner.desktop.ui
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import java.awt.GraphicsEnvironment
/** Preferred window size when nothing was saved yet. */
val DEFAULT_WINDOW_SIZE = DpSize(1100.dp, 780.dp)
/** Smallest size the layout (sidebar + content) stays usable at. */
private val MIN_WINDOW_SIZE = DpSize(720.dp, 480.dp)
/** Space kept free around the window so its edges and title bar stay reachable. */
private const val SCREEN_MARGIN_DP = 32
/**
* Clamps [desired] to the usable screen area ([usableWidth] x [usableHeight],
* in dp — i.e. AWT logical pixels, which already exclude the taskbar/dock), so
* the window never opens with its bottom (and the account switcher) hidden
* under the Windows taskbar on small or scaled displays.
*/
fun fitWindowSize(desired: DpSize, usableWidth: Int?, usableHeight: Int?): DpSize {
if (usableWidth == null || usableHeight == null) return desired
val maxWidth = (usableWidth - SCREEN_MARGIN_DP).coerceAtLeast(MIN_WINDOW_SIZE.width.value.toInt())
val maxHeight = (usableHeight - SCREEN_MARGIN_DP).coerceAtLeast(MIN_WINDOW_SIZE.height.value.toInt())
return DpSize(
desired.width.value.toInt().coerceIn(MIN_WINDOW_SIZE.width.value.toInt(), maxWidth).dp,
desired.height.value.toInt().coerceIn(MIN_WINDOW_SIZE.height.value.toInt(), maxHeight).dp,
)
}
/** Initial window size: the saved one (or the default), fitted to the primary screen's usable area. */
fun initialWindowSize(savedWidth: Int?, savedHeight: Int?): DpSize {
val desired = if (savedWidth != null && savedHeight != null) DpSize(savedWidth.dp, savedHeight.dp) else DEFAULT_WINDOW_SIZE
val usable = runCatching { GraphicsEnvironment.getLocalGraphicsEnvironment().maximumWindowBounds }.getOrNull()
return fitWindowSize(desired, usable?.width, usable?.height)
}
@@ -1,6 +1,7 @@
package com.greenart7c3.nostrsigner.desktop
import com.greenart7c3.nostrsigner.desktop.core.AutoStart
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -31,4 +32,11 @@ class AutoStartTest {
val unit = AutoStart.unitContent("/opt/My Apps/Amber/bin/Amber")
assertTrue(unit.contains("ExecStart=\"/opt/My Apps/Amber/bin/Amber\""))
}
@Test
fun windowsRunCommandQuotesPath() {
// The Run value is parsed as a command line: an unquoted
// "C:\Program Files\..." would try to launch "C:\Program".
assertEquals("\"C:\\Program Files\\Amber\\Amber.exe\"", AutoStart.windowsRunCommand("C:\\Program Files\\Amber\\Amber.exe"))
}
}
@@ -36,6 +36,27 @@ class UiStateTest {
UiState.rememberChoices.value = emptyMap()
}
@Test
fun pruneExpiredDropsOnlyStaleRequests() = runBlocking {
val account = com.greenart7c3.nostrsigner.desktop.core.AccountManager.addAccount(
com.vitorpamplona.quartz.nip01Core.crypto.KeyPair(),
)
fun req(id: String, expiresAt: Long?) = PendingBunkerRequest(
request = BunkerRequest(id, "sign_event", arrayOf()),
type = SignerType.SIGN_EVENT,
account = account,
localKey = "k$id",
relays = emptyList(),
expiresAt = expiresAt,
)
AmberDesktop.engine.pending.value = listOf(req("stale", 100), req("fresh", 200), req("uri", null))
AmberDesktop.engine.pruneExpired(now = 150)
// Expired relay request goes; unexpired and never-expiring (nostrconnect://) stay.
assertEquals(listOf("fresh", "uri"), AmberDesktop.engine.pending.value.map { it.request.id })
}
@Test
fun upDownSelectionClampsAtEnds() = runBlocking {
// Needs a real account for PendingBunkerRequest; create one.
@@ -0,0 +1,31 @@
package com.greenart7c3.nostrsigner.desktop.ui
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Test
class WindowGeometryTest {
@Test
fun defaultSizeFitsLargeScreenUnchanged() {
assertEquals(DEFAULT_WINDOW_SIZE, fitWindowSize(DEFAULT_WINDOW_SIZE, 1920, 1040))
}
@Test
fun shrinksToUsableAreaAboveTaskbar() {
// 1280x800 display with a 48px Windows taskbar: the 780dp default used
// to hide the window's bottom (and the account switcher) under it.
assertEquals(DpSize(1100.dp, 720.dp), fitWindowSize(DEFAULT_WINDOW_SIZE, 1280, 752))
}
@Test
fun neverGoesBelowMinimumUsableSize() {
assertEquals(DpSize(720.dp, 480.dp), fitWindowSize(DEFAULT_WINDOW_SIZE, 640, 400))
assertEquals(DpSize(720.dp, 480.dp), fitWindowSize(DpSize(100.dp, 100.dp), 1920, 1040))
}
@Test
fun unknownScreenKeepsDesiredSize() {
assertEquals(DpSize(900.dp, 600.dp), fitWindowSize(DpSize(900.dp, 600.dp), null, null))
}
}
+1
View File
@@ -40,6 +40,7 @@ composeMultiplatform = "1.11.1"
[libraries]
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "biometricKtx" }