mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
desktop: start-on-boot via hardened systemd user service
Settings gains a "Start on boot" toggle (Settings → Desktop, packaged runs only — a dev gradle run has no stable binary for systemd to supervise). Enabling installs and starts ~/.config/systemd/user/ amber.service: Opal-style hardening (NoNewPrivileges, ProtectSystem= strict, ProtectHome=read-only, seccomp @system-service, restrict namespaces/address families/kernel surfaces, LimitCORE=0, UMask=0077) with one deliberate exception — no MemoryDenyWriteExecute, which the JVM's JIT cannot survive. Amber always starts locked; the passphrase is still required before anything signs. Every packaged launch refreshes the unit, so the ExecStart path survives reinstalls and updates. Disabling autostart never stops a running instance. Two bugs were caught live while verifying this on the target machine and are pinned by AutoStartTest: systemd requires an absolute ExecStart (the launch path is now canonicalized), and PrivateTmp hides /tmp/.X11-unix, so the unit binds the real X11 socket directory back in — without it AWT can never reach the display and the service runs headless forever.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<String>) {
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -778,6 +778,8 @@
|
||||
<string name="d_keep_in_tray_sub">Beim Schließen des Fensters wird Amber in den Infobereich minimiert und beantwortet weiterhin Anfragen.</string>
|
||||
<string name="d_notifications">Benachrichtigungen</string>
|
||||
<string name="d_notifications_sub">Eine Systembenachrichtigung anzeigen, wenn eine Anfrage deine Genehmigung erfordert.</string>
|
||||
<string name="d_start_on_boot">Beim Start ausführen</string>
|
||||
<string name="d_start_on_boot_sub">Amber automatisch starten, wenn du dich anmeldest. Es startet gesperrt — gib deine Passphrase ein, um zu signieren.</string>
|
||||
<string name="d_active">Aktiv</string>
|
||||
<string name="d_switch">Wechseln</string>
|
||||
<string name="d_log_out">Abmelden</string>
|
||||
|
||||
@@ -789,6 +789,8 @@
|
||||
<string name="d_keep_in_tray_sub">Closing the window minimizes Amber to the system tray so it keeps answering requests.</string>
|
||||
<string name="d_notifications">Notifications</string>
|
||||
<string name="d_notifications_sub">Show a system notification when a request needs your approval.</string>
|
||||
<string name="d_start_on_boot">Start on boot</string>
|
||||
<string name="d_start_on_boot_sub">Start Amber automatically when you log in. It starts locked — enter your passphrase to sign.</string>
|
||||
<string name="d_active">Active</string>
|
||||
<string name="d_switch">Switch</string>
|
||||
<string name="d_log_out">Log out</string>
|
||||
|
||||
@@ -781,6 +781,8 @@
|
||||
<string name="d_keep_in_tray_sub">Al cerrar la ventana, Amber se minimiza a la bandeja del sistema para seguir respondiendo a las solicitudes.</string>
|
||||
<string name="d_notifications">Notificaciones</string>
|
||||
<string name="d_notifications_sub">Mostrar una notificación del sistema cuando una solicitud necesite tu aprobación.</string>
|
||||
<string name="d_start_on_boot">Iniciar al arrancar</string>
|
||||
<string name="d_start_on_boot_sub">Inicia Amber automáticamente al iniciar sesión. Arranca bloqueado; introduce tu frase de contraseña para firmar.</string>
|
||||
<string name="d_active">Activa</string>
|
||||
<string name="d_switch">Cambiar</string>
|
||||
<string name="d_log_out">Cerrar sesión</string>
|
||||
|
||||
@@ -778,6 +778,8 @@
|
||||
<string name="d_keep_in_tray_sub">Fermer la fenêtre réduit Amber dans la barre d'état système pour qu'il continue de répondre aux requêtes.</string>
|
||||
<string name="d_notifications">Notifications</string>
|
||||
<string name="d_notifications_sub">Afficher une notification système lorsqu'une requête nécessite votre approbation.</string>
|
||||
<string name="d_start_on_boot">Lancer au démarrage</string>
|
||||
<string name="d_start_on_boot_sub">Lance Amber automatiquement à l'ouverture de session. Il démarre verrouillé — saisissez votre phrase secrète pour signer.</string>
|
||||
<string name="d_active">Actif</string>
|
||||
<string name="d_switch">Changer</string>
|
||||
<string name="d_log_out">Se déconnecter</string>
|
||||
|
||||
@@ -781,6 +781,8 @@
|
||||
<string name="d_keep_in_tray_sub">Menutup jendela akan meminimalkan Amber ke tray sistem agar tetap menjawab permintaan.</string>
|
||||
<string name="d_notifications">Notifikasi</string>
|
||||
<string name="d_notifications_sub">Tampilkan notifikasi sistem saat ada permintaan yang perlu persetujuan Anda.</string>
|
||||
<string name="d_start_on_boot">Mulai saat boot</string>
|
||||
<string name="d_start_on_boot_sub">Mulai Amber secara otomatis saat Anda masuk. Terbuka dalam keadaan terkunci — masukkan frasa sandi Anda untuk menandatangani.</string>
|
||||
<string name="d_active">Aktif</string>
|
||||
<string name="d_switch">Ganti</string>
|
||||
<string name="d_log_out">Keluar</string>
|
||||
|
||||
@@ -781,6 +781,8 @@
|
||||
<string name="d_keep_in_tray_sub">Chiudendo la finestra Amber viene ridotto a icona nella system tray in modo da continuare a rispondere alle richieste.</string>
|
||||
<string name="d_notifications">Notifiche</string>
|
||||
<string name="d_notifications_sub">Mostra una notifica di sistema quando una richiesta necessita della tua approvazione.</string>
|
||||
<string name="d_start_on_boot">Avvia all'avvio</string>
|
||||
<string name="d_start_on_boot_sub">Avvia Amber automaticamente al login. Parte bloccato: inserisci la passphrase per firmare.</string>
|
||||
<string name="d_active">Attivo</string>
|
||||
<string name="d_switch">Cambia</string>
|
||||
<string name="d_log_out">Esci</string>
|
||||
|
||||
@@ -757,6 +757,8 @@
|
||||
<string name="d_keep_in_tray_sub">ウィンドウを閉じると Amber がシステムトレイに最小化され、リクエストへの応答を続けます。</string>
|
||||
<string name="d_notifications">通知</string>
|
||||
<string name="d_notifications_sub">承認が必要なリクエストがあるとき、システム通知を表示します。</string>
|
||||
<string name="d_start_on_boot">起動時に開始</string>
|
||||
<string name="d_start_on_boot_sub">ログイン時に Amber を自動的に起動します。ロックされた状態で起動するため、署名にはパスフレーズの入力が必要です。</string>
|
||||
<string name="d_active">有効</string>
|
||||
<string name="d_switch">切り替え</string>
|
||||
<string name="d_log_out">ログアウト</string>
|
||||
|
||||
@@ -781,6 +781,8 @@
|
||||
<string name="d_keep_in_tray_sub">창을 닫으면 Amber가 시스템 트레이로 최소화되어 계속 요청에 응답합니다.</string>
|
||||
<string name="d_notifications">알림</string>
|
||||
<string name="d_notifications_sub">요청에 승인이 필요할 때 시스템 알림을 표시합니다.</string>
|
||||
<string name="d_start_on_boot">부팅 시 시작</string>
|
||||
<string name="d_start_on_boot_sub">로그인할 때 Amber를 자동으로 시작합니다. 잠긴 상태로 시작되므로 서명하려면 암호를 입력하세요.</string>
|
||||
<string name="d_active">활성</string>
|
||||
<string name="d_switch">전환</string>
|
||||
<string name="d_log_out">로그아웃</string>
|
||||
|
||||
@@ -776,6 +776,8 @@
|
||||
<string name="d_keep_in_tray_sub">Fechar a janela minimiza o Amber para a bandeja do sistema para que ele continue respondendo às solicitações.</string>
|
||||
<string name="d_notifications">Notificações</string>
|
||||
<string name="d_notifications_sub">Mostrar uma notificação do sistema quando uma solicitação precisar da sua aprovação.</string>
|
||||
<string name="d_start_on_boot">Iniciar na inicialização</string>
|
||||
<string name="d_start_on_boot_sub">Inicia o Amber automaticamente quando você entra. Ele começa bloqueado — digite sua senha para assinar.</string>
|
||||
<string name="d_active">Ativo</string>
|
||||
<string name="d_switch">Trocar</string>
|
||||
<string name="d_log_out">Sair</string>
|
||||
|
||||
@@ -781,6 +781,8 @@
|
||||
<string name="d_keep_in_tray_sub">При закрытии окна Amber сворачивается в системный трей и продолжает отвечать на запросы.</string>
|
||||
<string name="d_notifications">Уведомления</string>
|
||||
<string name="d_notifications_sub">Показывать системное уведомление, когда запрос требует вашего одобрения.</string>
|
||||
<string name="d_start_on_boot">Запускать при включении</string>
|
||||
<string name="d_start_on_boot_sub">Запускает Amber автоматически при входе в систему. Он запускается заблокированным — введите пароль-фразу для подписи.</string>
|
||||
<string name="d_active">Активен</string>
|
||||
<string name="d_switch">Переключить</string>
|
||||
<string name="d_log_out">Выйти</string>
|
||||
|
||||
@@ -757,6 +757,8 @@
|
||||
<string name="d_keep_in_tray_sub">การปิดหน้าต่างจะย่อ Amber ลงในถาดระบบเพื่อให้ยังคงตอบรับคำขอต่อไป</string>
|
||||
<string name="d_notifications">การแจ้งเตือน</string>
|
||||
<string name="d_notifications_sub">แสดงการแจ้งเตือนของระบบเมื่อมีคำขอที่ต้องการการอนุมัติของคุณ</string>
|
||||
<string name="d_start_on_boot">เริ่มต้นเมื่อบูตเครื่อง</string>
|
||||
<string name="d_start_on_boot_sub">เริ่ม Amber โดยอัตโนมัติเมื่อคุณล็อกอิน มันจะเริ่มในสถานะล็อก — ป้อนวลีรหัสผ่านเพื่อลงนาม</string>
|
||||
<string name="d_active">ใช้งานอยู่</string>
|
||||
<string name="d_switch">สลับ</string>
|
||||
<string name="d_log_out">ออกจากระบบ</string>
|
||||
|
||||
@@ -777,6 +777,8 @@
|
||||
<string name="d_keep_in_tray_sub">Pencereyi kapatmak Amber'i sistem tepsisine küçültür, böylece istekleri yanıtlamaya devam eder.</string>
|
||||
<string name="d_notifications">Bildirimler</string>
|
||||
<string name="d_notifications_sub">Bir istek onayınızı gerektirdiğinde sistem bildirimi göster.</string>
|
||||
<string name="d_start_on_boot">Açılışta başlat</string>
|
||||
<string name="d_start_on_boot_sub">Oturum açtığınızda Amber'i otomatik başlatır. Kilitli başlar; imzalamak için parolanızı girin.</string>
|
||||
<string name="d_active">Etkin</string>
|
||||
<string name="d_switch">Değiştir</string>
|
||||
<string name="d_log_out">Çıkış yap</string>
|
||||
|
||||
@@ -757,6 +757,8 @@
|
||||
<string name="d_keep_in_tray_sub">Đó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.</string>
|
||||
<string name="d_notifications">Thông báo</string>
|
||||
<string name="d_notifications_sub">Hiển thị thông báo hệ thống khi có yêu cầu cần bạn phê duyệt.</string>
|
||||
<string name="d_start_on_boot">Khởi động cùng hệ thống</string>
|
||||
<string name="d_start_on_boot_sub">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ý.</string>
|
||||
<string name="d_active">Đang hoạt động</string>
|
||||
<string name="d_switch">Chuyển</string>
|
||||
<string name="d_log_out">Đăng xuất</string>
|
||||
|
||||
@@ -762,6 +762,8 @@
|
||||
<string name="d_keep_in_tray_sub">关闭窗口会将 Amber 最小化到系统托盘,以便它继续响应请求。</string>
|
||||
<string name="d_notifications">通知</string>
|
||||
<string name="d_notifications_sub">当请求需要你批准时显示系统通知。</string>
|
||||
<string name="d_start_on_boot">开机自动启动</string>
|
||||
<string name="d_start_on_boot_sub">登录时自动启动 Amber。启动时处于锁定状态——输入密码短语后才能签名。</string>
|
||||
<string name="d_active">当前</string>
|
||||
<string name="d_switch">切换</string>
|
||||
<string name="d_log_out">退出登录</string>
|
||||
|
||||
@@ -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\""))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user