diff --git a/desktop/README.md b/desktop/README.md index fa817e03..70ebe34d 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -40,6 +40,11 @@ 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 - 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/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt index 24a61652..aece64b8 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -20,6 +20,7 @@ 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 +import com.greenart7c3.nostrsigner.desktop.core.AutoStart import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount import com.greenart7c3.nostrsigner.desktop.core.NetworkConnectivity import com.greenart7c3.nostrsigner.desktop.core.Notifier @@ -141,6 +142,11 @@ fun main(args: Array) { } if (launchUri != null) UriLaunch.pending.value = launchUri + // Keep the autostart unit fresh (binary path can change between builds). + if (SettingsStore.settings.value.startOnBoot) { + AutoStart.setEnabled(true) + } + // 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 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 new file mode 100644 index 00000000..60171002 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AutoStart.kt @@ -0,0 +1,117 @@ +package com.greenart7c3.nostrsigner.desktop.core + +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. + * + * Hardening mirrors Opal's unit with one deliberate exception: no + * MemoryDenyWriteExecute, which the JVM cannot survive (the JIT needs + * writable executable memory). The unit needs write access to: the data dir, + * the runtime dir (nostrconnect socket) and the applications dir (scheme + * handler registration). + * + * Only packaged runs can be supervised: a dev run's command line is a bare + * gradle invocation with no stable binary ([isSupported] gates the UI). + */ +object AutoStart { + private const val UNIT_NAME = "amber.service" + + val isLinux: Boolean = System.getProperty("os.name").lowercase().let { + it.contains("linux") || it.contains("nix") || it.contains("nux") + } + + 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" + } + + private fun unitDir(): File = File(System.getProperty("user.home"), ".config/systemd/user") + + private fun unitFile(): File = File(unitDir(), UNIT_NAME) + + /** + * Installs (or refreshes) the unit and enables it, then starts the + * service. Starting while a manual instance is already running is + * harmless: that instance forwards a raise to it and exits cleanly. + */ + fun setEnabled(enabled: Boolean) { + if (!isSupported()) return + runCatching { + // systemd requires an absolute ExecStart: /proc/self/cmdline + // records the path exactly as invoked (it can be relative). + val exe = currentExecutable() + ?.let { File(it).canonicalFile.path } + ?: return + unitDir().mkdirs() + unitFile().writeText(unitContent(exe)) + systemctl("daemon-reload") + if (enabled) { + systemctl("enable", UNIT_NAME) + systemctl("start", UNIT_NAME) + } else { + // No --now on purpose: disabling autostart must not kill an + // app the user is currently using. + systemctl("disable", UNIT_NAME) + } + } + } + + private fun systemctl(vararg args: String): Boolean = runCatching { + ProcessBuilder("systemctl", "--user", *args).start().waitFor() == 0 + }.getOrDefault(false) + + private fun quote(value: String): String = if (value.none { it == ' ' || it == '\t' }) value else "\"$value\"" + + internal fun unitContent(exePath: String): String = """ + [Unit] + Description=Amber Nostr signer + PartOf=graphical-session.target + After=graphical-session.target + + [Service] + Type=simple + ExecStart=${quote(exePath)} + Restart=on-failure + RestartSec=3 + # JVM exception: no MemoryDenyWriteExecute — the JIT needs W+X memory. + NoNewPrivileges=yes + PrivateTmp=yes + # PrivateTmp hides /tmp, and with it the XWayland socket — bind the + # real X11 socket dir back in or AWT cannot reach the display. + BindPaths=-/tmp/.X11-unix + ProtectSystem=strict + ProtectHome=read-only + ReadWritePaths=-%h/.local/share/amber -%h/.local/share/applications %t + ProtectKernelTunables=yes + ProtectKernelModules=yes + ProtectControlGroups=yes + RestrictRealtime=yes + RestrictSUIDSGID=yes + LockPersonality=yes + SystemCallArchitectures=native + SystemCallFilter=@system-service + SystemCallErrorNumber=EPERM + RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK + RestrictNamespaces=yes + ProtectClock=yes + ProtectHostname=yes + ProtectKernelLogs=yes + # Decrypted keys live in this process: never write core dumps. + LimitCORE=0 + UMask=0077 + + [Install] + WantedBy=graphical-session.target + """.trimIndent() + "\n" +} 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 2308f9e2..fe6d70e8 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,6 +181,8 @@ 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). */ + val startOnBoot: Boolean = false, /** UI language tag (matches Strings.supportedLanguages); null = follow the OS. */ val language: String? = null, ) { diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt index a3930cfd..2fcf9939 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt @@ -40,6 +40,7 @@ import com.greenart7c3.nostrsigner.desktop.Session import com.greenart7c3.nostrsigner.desktop.core.AccountManager import com.greenart7c3.nostrsigner.desktop.core.AccountsStore import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.AutoStart import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount import com.greenart7c3.nostrsigner.desktop.core.DesktopKeyStore import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock @@ -149,6 +150,17 @@ fun SettingsScreen(account: DesktopAccount) { checked = settings.showNotifications, onCheckedChange = { value -> SettingsStore.update { it.copy(showNotifications = value) } }, ) + if (AutoStart.isSupported()) { + SettingSwitch( + title = Strings.get("d_start_on_boot", language), + description = Strings.get("d_start_on_boot_sub", language), + checked = settings.startOnBoot, + onCheckedChange = { value -> + SettingsStore.update { it.copy(startOnBoot = value) } + AutoStart.setEnabled(value) + }, + ) + } Spacer(Modifier.height(16.dp)) SectionTitle(Strings.get("accounts", language)) diff --git a/desktop/src/main/resources/i18n/strings_de.xml b/desktop/src/main/resources/i18n/strings_de.xml index f3e39f7d..ca11e305 100644 --- a/desktop/src/main/resources/i18n/strings_de.xml +++ b/desktop/src/main/resources/i18n/strings_de.xml @@ -778,6 +778,8 @@ Beim Schließen des Fensters wird Amber in den Infobereich minimiert und beantwortet weiterhin Anfragen. Benachrichtigungen Eine Systembenachrichtigung anzeigen, wenn eine Anfrage deine Genehmigung erfordert. + Beim Start ausführen + Amber automatisch starten, wenn du dich anmeldest. Es startet gesperrt — gib deine Passphrase ein, um zu signieren. Aktiv Wechseln Abmelden diff --git a/desktop/src/main/resources/i18n/strings_en.xml b/desktop/src/main/resources/i18n/strings_en.xml index ae8dc226..35e1eb89 100644 --- a/desktop/src/main/resources/i18n/strings_en.xml +++ b/desktop/src/main/resources/i18n/strings_en.xml @@ -789,6 +789,8 @@ Closing the window minimizes Amber to the system tray so it keeps answering requests. Notifications Show a system notification when a request needs your approval. + Start on boot + Start Amber automatically when you log in. It starts locked — enter your passphrase to sign. Active Switch Log out diff --git a/desktop/src/main/resources/i18n/strings_es.xml b/desktop/src/main/resources/i18n/strings_es.xml index a5a3cdcb..a025bb0a 100644 --- a/desktop/src/main/resources/i18n/strings_es.xml +++ b/desktop/src/main/resources/i18n/strings_es.xml @@ -781,6 +781,8 @@ Al cerrar la ventana, Amber se minimiza a la bandeja del sistema para seguir respondiendo a las solicitudes. Notificaciones Mostrar una notificación del sistema cuando una solicitud necesite tu aprobación. + Iniciar al arrancar + Inicia Amber automáticamente al iniciar sesión. Arranca bloqueado; introduce tu frase de contraseña para firmar. Activa Cambiar Cerrar sesión diff --git a/desktop/src/main/resources/i18n/strings_fr.xml b/desktop/src/main/resources/i18n/strings_fr.xml index 519784cd..3631dcae 100644 --- a/desktop/src/main/resources/i18n/strings_fr.xml +++ b/desktop/src/main/resources/i18n/strings_fr.xml @@ -778,6 +778,8 @@ Fermer la fenêtre réduit Amber dans la barre d'état système pour qu'il continue de répondre aux requêtes. Notifications Afficher une notification système lorsqu'une requête nécessite votre approbation. + Lancer au démarrage + Lance Amber automatiquement à l'ouverture de session. Il démarre verrouillé — saisissez votre phrase secrète pour signer. Actif Changer Se déconnecter diff --git a/desktop/src/main/resources/i18n/strings_in.xml b/desktop/src/main/resources/i18n/strings_in.xml index d95462f0..489b9760 100644 --- a/desktop/src/main/resources/i18n/strings_in.xml +++ b/desktop/src/main/resources/i18n/strings_in.xml @@ -781,6 +781,8 @@ Menutup jendela akan meminimalkan Amber ke tray sistem agar tetap menjawab permintaan. Notifikasi Tampilkan notifikasi sistem saat ada permintaan yang perlu persetujuan Anda. + Mulai saat boot + Mulai Amber secara otomatis saat Anda masuk. Terbuka dalam keadaan terkunci — masukkan frasa sandi Anda untuk menandatangani. Aktif Ganti Keluar diff --git a/desktop/src/main/resources/i18n/strings_it.xml b/desktop/src/main/resources/i18n/strings_it.xml index f4411c2b..68482b29 100644 --- a/desktop/src/main/resources/i18n/strings_it.xml +++ b/desktop/src/main/resources/i18n/strings_it.xml @@ -781,6 +781,8 @@ Chiudendo la finestra Amber viene ridotto a icona nella system tray in modo da continuare a rispondere alle richieste. Notifiche Mostra una notifica di sistema quando una richiesta necessita della tua approvazione. + Avvia all'avvio + Avvia Amber automaticamente al login. Parte bloccato: inserisci la passphrase per firmare. Attivo Cambia Esci diff --git a/desktop/src/main/resources/i18n/strings_ja.xml b/desktop/src/main/resources/i18n/strings_ja.xml index 77793868..a9784c17 100644 --- a/desktop/src/main/resources/i18n/strings_ja.xml +++ b/desktop/src/main/resources/i18n/strings_ja.xml @@ -757,6 +757,8 @@ ウィンドウを閉じると Amber がシステムトレイに最小化され、リクエストへの応答を続けます。 通知 承認が必要なリクエストがあるとき、システム通知を表示します。 + 起動時に開始 + ログイン時に Amber を自動的に起動します。ロックされた状態で起動するため、署名にはパスフレーズの入力が必要です。 有効 切り替え ログアウト diff --git a/desktop/src/main/resources/i18n/strings_ko.xml b/desktop/src/main/resources/i18n/strings_ko.xml index 91607c5a..e13ed666 100644 --- a/desktop/src/main/resources/i18n/strings_ko.xml +++ b/desktop/src/main/resources/i18n/strings_ko.xml @@ -781,6 +781,8 @@ 창을 닫으면 Amber가 시스템 트레이로 최소화되어 계속 요청에 응답합니다. 알림 요청에 승인이 필요할 때 시스템 알림을 표시합니다. + 부팅 시 시작 + 로그인할 때 Amber를 자동으로 시작합니다. 잠긴 상태로 시작되므로 서명하려면 암호를 입력하세요. 활성 전환 로그아웃 diff --git a/desktop/src/main/resources/i18n/strings_pt-BR.xml b/desktop/src/main/resources/i18n/strings_pt-BR.xml index 41798ba4..10835a9e 100644 --- a/desktop/src/main/resources/i18n/strings_pt-BR.xml +++ b/desktop/src/main/resources/i18n/strings_pt-BR.xml @@ -776,6 +776,8 @@ Fechar a janela minimiza o Amber para a bandeja do sistema para que ele continue respondendo às solicitações. Notificações Mostrar uma notificação do sistema quando uma solicitação precisar da sua aprovação. + Iniciar na inicialização + Inicia o Amber automaticamente quando você entra. Ele começa bloqueado — digite sua senha para assinar. Ativo Trocar Sair diff --git a/desktop/src/main/resources/i18n/strings_ru.xml b/desktop/src/main/resources/i18n/strings_ru.xml index a0ce206b..44301569 100644 --- a/desktop/src/main/resources/i18n/strings_ru.xml +++ b/desktop/src/main/resources/i18n/strings_ru.xml @@ -781,6 +781,8 @@ При закрытии окна Amber сворачивается в системный трей и продолжает отвечать на запросы. Уведомления Показывать системное уведомление, когда запрос требует вашего одобрения. + Запускать при включении + Запускает Amber автоматически при входе в систему. Он запускается заблокированным — введите пароль-фразу для подписи. Активен Переключить Выйти diff --git a/desktop/src/main/resources/i18n/strings_th.xml b/desktop/src/main/resources/i18n/strings_th.xml index ca2880c5..02aaa055 100644 --- a/desktop/src/main/resources/i18n/strings_th.xml +++ b/desktop/src/main/resources/i18n/strings_th.xml @@ -757,6 +757,8 @@ การปิดหน้าต่างจะย่อ Amber ลงในถาดระบบเพื่อให้ยังคงตอบรับคำขอต่อไป การแจ้งเตือน แสดงการแจ้งเตือนของระบบเมื่อมีคำขอที่ต้องการการอนุมัติของคุณ + เริ่มต้นเมื่อบูตเครื่อง + เริ่ม Amber โดยอัตโนมัติเมื่อคุณล็อกอิน มันจะเริ่มในสถานะล็อก — ป้อนวลีรหัสผ่านเพื่อลงนาม ใช้งานอยู่ สลับ ออกจากระบบ diff --git a/desktop/src/main/resources/i18n/strings_tr.xml b/desktop/src/main/resources/i18n/strings_tr.xml index 2346c881..f1328d44 100644 --- a/desktop/src/main/resources/i18n/strings_tr.xml +++ b/desktop/src/main/resources/i18n/strings_tr.xml @@ -777,6 +777,8 @@ Pencereyi kapatmak Amber'i sistem tepsisine küçültür, böylece istekleri yanıtlamaya devam eder. Bildirimler Bir istek onayınızı gerektirdiğinde sistem bildirimi göster. + Açılışta başlat + Oturum açtığınızda Amber'i otomatik başlatır. Kilitli başlar; imzalamak için parolanızı girin. Etkin Değiştir Çıkış yap diff --git a/desktop/src/main/resources/i18n/strings_vi.xml b/desktop/src/main/resources/i18n/strings_vi.xml index 9fedbbe8..3ebf0be2 100644 --- a/desktop/src/main/resources/i18n/strings_vi.xml +++ b/desktop/src/main/resources/i18n/strings_vi.xml @@ -757,6 +757,8 @@ Đóng cửa sổ sẽ thu nhỏ Amber vào khay hệ thống để tiếp tục phản hồi các yêu cầu. Thông báo Hiển thị thông báo hệ thống khi có yêu cầu cần bạn phê duyệt. + Khởi động cùng hệ thống + Khởi động Amber tự động khi bạn đăng nhập. Nó khởi động ở trạng thái khóa — nhập cụm mật khẩu để ký. Đang hoạt động Chuyển Đăng xuất diff --git a/desktop/src/main/resources/i18n/strings_zh.xml b/desktop/src/main/resources/i18n/strings_zh.xml index f6196a3c..4717d284 100644 --- a/desktop/src/main/resources/i18n/strings_zh.xml +++ b/desktop/src/main/resources/i18n/strings_zh.xml @@ -762,6 +762,8 @@ 关闭窗口会将 Amber 最小化到系统托盘,以便它继续响应请求。 通知 当请求需要你批准时显示系统通知。 + 开机自动启动 + 登录时自动启动 Amber。启动时处于锁定状态——输入密码短语后才能签名。 当前 切换 退出登录 diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt new file mode 100644 index 00000000..46507d91 --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AutoStartTest.kt @@ -0,0 +1,34 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.AutoStart +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AutoStartTest { + @Test + fun unitIsHardenedButJvmCompatible() { + val unit = AutoStart.unitContent("/opt/Amber/bin/Amber") + assertTrue(unit.startsWith("[Unit]")) + assertTrue(unit.contains("ExecStart=/opt/Amber/bin/Amber")) + assertTrue(unit.contains("PartOf=graphical-session.target")) + assertTrue(unit.contains("WantedBy=graphical-session.target")) + assertTrue(unit.contains("Restart=on-failure")) + assertTrue(unit.contains("NoNewPrivileges=yes")) + assertTrue(unit.contains("ProtectSystem=strict")) + assertTrue(unit.contains("ProtectHome=read-only")) + assertTrue(unit.contains("SystemCallFilter=@system-service")) + assertTrue(unit.contains("LimitCORE=0")) + assertTrue(unit.contains("ReadWritePaths=-%h/.local/share/amber -%h/.local/share/applications %t")) + assertTrue(unit.contains("BindPaths=-/tmp/.X11-unix")) + // The JVM's JIT needs writable executable memory: this hardening flag + // would kill the service instantly and must never be emitted. + assertFalse(unit.contains("MemoryDenyWriteExecute=yes")) + } + + @Test + fun unitQuotesExecStartWithSpaces() { + val unit = AutoStart.unitContent("/opt/My Apps/Amber/bin/Amber") + assertTrue(unit.contains("ExecStart=\"/opt/My Apps/Amber/bin/Amber\"")) + } +}