From 072240663f7c40c7e4d5b328afee4377503492f9 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Wed, 30 Sep 2026 13:38:03 -0300 Subject: [PATCH] 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 --- desktop/README.md | 19 +++++-- desktop/build.gradle.kts | 10 ++++ desktop/icons/amber.ico | Bin 0 -> 372526 bytes .../greenart7c3/nostrsigner/desktop/Main.kt | 46 ++++++++++++++-- .../nostrsigner/desktop/core/AutoStart.kt | 52 +++++++++++++++--- .../nostrsigner/desktop/core/BunkerEngine.kt | 24 ++++++++ .../nostrsigner/desktop/core/Models.kt | 7 ++- .../nostrsigner/desktop/ui/WindowGeometry.kt | 37 +++++++++++++ .../nostrsigner/desktop/AutoStartTest.kt | 8 +++ .../nostrsigner/desktop/UiStateTest.kt | 21 +++++++ .../desktop/ui/WindowGeometryTest.kt | 31 +++++++++++ gradle/libs.versions.toml | 1 + 12 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 desktop/icons/amber.ico create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometry.kt create mode 100644 desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometryTest.kt diff --git a/desktop/README.md b/desktop/README.md index 70ebe34d..61cf8ea9 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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 diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index d0df1dc9..5799921f 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -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" } diff --git a/desktop/icons/amber.ico b/desktop/icons/amber.ico new file mode 100644 index 0000000000000000000000000000000000000000..79beabce258aef3db12e522c2863b7cd6eb0dd39 GIT binary patch literal 372526 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 rVKB@c)HCw3XC7&$ANn) { } 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) { 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) { !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, diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt index 60171002..e9406696 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt @@ -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) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt index f866175c..2a2e4115 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt @@ -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 diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt index fe6d70e8..3e31a755 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt @@ -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 = defaultRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometry.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometry.kt new file mode 100644 index 00000000..b875af92 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometry.kt @@ -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) +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt index 46507d91..d8c78d0f 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt @@ -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")) + } } diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt index 6b688986..508a8b21 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt @@ -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. diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometryTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometryTest.kt new file mode 100644 index 00000000..e93373df --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/WindowGeometryTest.kt @@ -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)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 354eca0f..61d35d13 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }