- Notifications: drop osascript on macOS. It posts as Script Editor, which a fresh macOS never authorizes or prompts for, so banners were silently dropped while osascript exited 0. Use the AWT tray notification, posted as Amber's own bundle (allowed on first launch). - nostrconnect://: declare the scheme in the bundle's Info.plist and receive links via Desktop.setOpenURIHandler, installed only in the primary instance (initializing AWT keeps a losing second instance alive otherwise). - Start on boot: per-user LaunchAgent with RunAtLoad, no KeepAlive, Aqua only. - Account dirs and the instance lock file are now owner-only; existing 0755 account dirs are tightened on load. Verified in a macOS Sequoia 15.8.1 VM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.2 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Build & development commands
./gradlew assembleDebug # debug build
./gradlew assembleRelease # release build (requires signing keystore)
./gradlew ktlintCheck # Kotlin style check (also runs on every git commit via pre-commit hook)
./gradlew ktlintFormat # auto-fix Kotlin style issues
./gradlew lint # Android Lint, warnings fail the build (runs on commit and push via hooks)
./gradlew test --no-daemon # unit tests (also runs on every git push via pre-push hook)
./build.sh # builds both offline and free release variants to ~/release/
Git hooks are auto-installed via the root build.gradle.kts preBuild task — no manual setup needed.
Build flavors
| Flavor | Purpose |
|---|---|
free (default) |
Online variant with full networking (OkHttp, Coil, relay connectivity) |
offline |
No network stack; use BuildFlavorChecker.isOfflineFlavor() to guard network code |
Modules
:app— the Android app (everything below in Architecture refers to it):desktop— Compose for Desktop (JVM) NIP-46 signer for Windows/macOS/Linux; standalone port that mirrors the Android permission model againstquartz-jvm(no NIP-55)../gradlew :desktop:runto launch,:desktop:packageDistributionForCurrentOsto package. Its core mirrorsNotificationSubscription/EventNotificationConsumer/BunkerRequestUtilsindesktop/.../core/BunkerEngine.kt— behavior changes to the Android bunker flow should be ported there too. Keys are AES-encrypted via a PKCS12 Java KeyStore (DesktopKeyStore) whose password lives in the OS credential store (Keychain / Credential Manager / Secret Service viajava-keyring, file fallback — seeKeystorePassword.resolve); an opt-inPassphraseLockinstead wraps the master key with Argon2id and adds a startup/auto-lock gate that evicts key material and gates the bunker engine — and, while set, encrypts the per-account database (apps/permissions/history/logs) at rest viawriteSecure/readSecure. State is JSON files per account (no Room). Desktop notifications go through the OS-native channel (core/Notifier.kt: freedesktopnotify-send/gdbuson Linux incl. Wayland/Hyprland; AWT tray notification on Windows and macOS — neverosascripton macOS, it posts as Script Editor, which is never authorized, and is silently dropped), decoupled from the tray. The tray itself uses the dorkbox SystemTray library on Linux (NativeTray.kt) so it publishes a StatusNotifierItem/AppIndicator that shows on Wayland compositors, and the AWT/ComposeTrayon Windows/macOS;AMBER_DISABLE_TRAY=1skips the native tray. Seedesktop/README.md.
Architecture
Request ingestion — three paths
External apps and relays reach the signer through three distinct paths:
nostrsigner://Intent →SignerActivity→ parsed byIntentUtils→ shown as bottom sheet- ContentProvider IPC →
SignerProvider→ synchronous signing viarunBlocking - NIP-46 relay events (kind 24133) →
NotificationSubscription→EventNotificationConsumer→BunkerRequestUtils
All three paths converge on Account.sign() / encrypt/decrypt methods backed by NostrSignerInternal.
Global state — Amber.kt
Amber is the Application class and acts as the DI container. Key singletons it owns:
applicationIOScope—CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler), used for all background workclient: NostrClient— the Quartz Nostr relay clientnotificationSubscription— keeps the NIP-46 filter alive in the backgroundprofileSubscription— per-account, throttled one-shot metadata fetch; first fetches the user's NIP-65 relay list (kind 10002) and saves it locally, then fetches the metadata (kind 0) from the default profile relays plus the saved user relays; started/stopped by the composables that display each account viaProfileSubscriptionEffect(not app-wide)isStartingAppState: MutableStateFlow<Boolean>— set totrueduringrunMigrations(); code that must wait for startup callsisStartingAppState.first { !it }settings.killSwitch— when true, all relays are disconnected; checked before every relay operation
Per-account isolation
Every npub gets its own:
SharedPreferencesfile (prefs_${npub})AppDatabase(amber_db_${npub}) — apps + permissionsLogDatabase— operation logsHistoryDatabase— request history
All databases are lazy-loaded and cached in ConcurrentHashMaps in Amber. Account data (including decrypted keys) is loaded via LocalPreferences.loadFromEncryptedStorage() and cached in LargeCache.
Permission system
Permissions are stored in ApplicationEntity + ApplicationPermissionsEntity (Room). Each permission entry has:
rememberType— auto-accept, auto-reject, or always-askacceptUntil/rejectUntil— time-bound grantskind— event-kind-specific rules
Before showing the approval UI, all three ingestion paths query the database; if a matching auto-accept rule exists, signing proceeds silently.
Biometric / PIN lock is UI-only
The biometric/PIN prompt (useAuth / usePin in AmberSettings, configured in SecurityScreen) is only an app-launch UI gate, not a signing gate. It is rendered by BiometricAuthScreen, which is invoked exclusively from the two UI entry points — MainActivity and SignerActivity — to unlock the app's screens before any approval bottom sheet is shown.
It does not protect the signing operations themselves:
- ContentProvider IPC (
SignerProvider) and NIP-46 relay events (EventNotificationConsumer→BunkerRequestUtils) never touchBiometricAuthScreen,Biometrics,useAuth, orusePin. They sign in the background based purely on the permission database. - When an auto-accept rule matches, all three paths sign silently without triggering the biometric/PIN prompt — including
nostrsigner://intents, which auto-finish before the UI is interacted with.
In other words, the lock controls who can open and navigate the app UI; it does not stand between a request and Account.sign(). Authorization for automatic signing is governed solely by the permission system (rememberType / acceptUntil / kind).
Key files
| File | Purpose |
|---|---|
Amber.kt |
Application singleton, Nostr client, relay connectivity |
LocalPreferences.kt |
Account/settings persistence, encrypted key storage |
IntentUtils.kt |
Parses nostrsigner:// URIs, creates IntentData objects |
SignerProvider.kt |
ContentProvider IPC signing |
BunkerRequestUtils.kt |
NIP-46 protocol handling, relay responses |
AccountStateViewModel.kt |
Auth state, account switching, toast notifications |
ConnectivityService.kt |
Foreground service, network monitoring, relay reconnection |
BiometricAuthScreen.kt |
UI-only app-launch lock (biometric/PIN); not a signing gate |
Biometrics.kt |
Wraps BiometricPrompt / keyguard credential prompt |
SecurityScreen.kt |
Toggles useAuth / usePin and the re-prompt interval |
- For repository collaboration, use ngit and follow
.claude/skills/ngit/SKILL.md.