mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 10:58:23 +00:00
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:
co-authored by
Claude Opus 5.5
parent
adc873b391
commit
072240663f
+14
-5
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user