Compare commits

..

No commits in common. "6bff1887145572b0752c9438aee9bd8363cdeebd" and "704addc773c9f573118b7f9777be20c73d142f5b" have entirely different histories.

26 changed files with 534 additions and 1969 deletions

View file

@ -1,192 +1,3 @@
# Checkpoint — permissions UI + updater fix + Workshop theme (2026-09-27 night)
## Where things are
- Project: `/home/avi/Projects/Keynctr`, branch `master` @ **`add956a`**
("feat(theme): add Workshop theme — Cybernetic Workshop identity from Moi
DESIGN.md"). Previous: `15fa331` (updater-run dependency bumps, clears the
high js-yaml advisory), `fa59b63` (updater PATH fix), `adbc7c2`
(permissions UI), `98593cb` (checkpoint), `c89b31a`.
- Working tree: clean for tracked files. Untracked intentionally NOT
committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`,
`deferred/`.
- Release binary rebuilt at add956a (2026-09-28, after 3m01s real build).
- NOTE: a running Electron app still serves the OLD backend + stale `dist/`
until relaunch; the running serve predates these commits. The user's
"Update problem: The Rust backend exited unexpectedly (code 1)" screenshot
came from that old build; `update_apply` verified green end-to-end on the
new binary (all three steps applied).
## What was completed
1. **Permissions are visible (Step 4, first slice).** `Nip46Status` now
carries the connection's declared `perms=` grant list and expiry. The
Signer Mode screen shows a **Permissions** panel on a live session: one
row per granted method ("Sign events — kinds 1, 30023"), or a plain
statement that the signer app (Amber) approves every request when no
grant list was declared. `frontend/src/lib/permissions.ts` holds the
shared label formatters.
2. **"Always allow" is now kind-scoped (was: method-wide, too broad).**
A `sign_event` grant records the kind of the request the user actually
approved; a kind-1 grant never covers a kind-3 request — uncovered kinds
fall back to the approval prompt. Legacy kind-less grants keep their
all-kinds meaning (stored vaults keep working; new grants are never
created kind-less). Enforced in BOTH `bunker.rs` (bunker mode) and
`nip46_client.rs` (client mode) via `Vault::has_signer_grant(peer,
method, event_kind)`. The Signer screen's grants list renders the human
label with kind scope.
3. **Check-for-updates fixed.** The Electron-spawned backend inherited the
desktop launcher's PATH (no `~/.cargo/bin`, no mise/asdf shims), so
`npm`/`cargo` "didn't exist". `updates.rs::run()` now appends the
well-known per-user tool dirs to the inherited PATH (inherited wins on
conflicts; missing dirs ignored). Verified live under
`env -i PATH=/usr/bin:/bin`: `update_check` returns a full report.
## Commits added
- `adbc7c2` feat(signer): permissions UI — declared grants surfaced, always-allow kind-scoped
- `fa59b63` fix(updates): augment spawned PATH so npm/cargo resolve from Electron
- `15fa331` chore(deps): dependency updates from the in-app updater run (clears high js-yaml advisory)
- `add956a` feat(theme): Workshop theme — Cybernetic Workshop identity from Moi DESIGN.md
- `c18f59a` feat(theme): Workshop — Dark, the Moi dark material
- `de804c8` feat(theme): Workshop atmosphere — Moi's graph-paper grid + mint/clay washes
- `4cc0481` feat(theme): white logo mark on workshop-dark
- `dcc701f` feat(updater): apply updates without restarting the app — `app:selfupdate` IPC rebuilds (npm + cargo, augmented PATH), kills the backend child so the next request spawns the NEW binary, reloads all windows. Electron shell stays up; main-process changes still need one manual relaunch; packaged builds report bundle replacement.
## Verification (all green at fa59b63)
- Rust: `cargo test` 219 unit + 6 e2e (NEW: `signer_grants_are_kind_scoped`,
two `augmented_path` tests); `cargo clippy --all-targets` 0; `cargo fmt
--check` clean; `cargo build --release` rebuilt 22:43.
- Frontend: `npm test` 135 (10 new: `permissions.test.ts` unit + 2
SignerModeScreen permission-panel tests), `typecheck`, `lint`,
`format:check`, `build`, `electron:build` all green.
## Deferred / next steps
- Live eyeball: relaunch the app, pair with a `perms=`-carrying client (or
Amber) and check the Permissions panel; approve kind-1 "Always allow",
then send a kind-3 request and confirm it PROMPTS (kind scope).
- Two-account live pass from the previous checkpoint still open (pair
account B in Amber, switch back and forth).
- Publish kind-0 to primal/damus (one Amber approval).
- Step 4 remainder (if wanted): interactive grant editing in the approval
modal (approve-with-narrowing UI); today the modal is Approve / Always
allow (kind-scoped) / Reject.
- Step 5 (KDF upgrade m=64MiB/t=3 + vault header versioning), Step 6 (undo
history), Step 7 (rename/hygiene incl. `homepage` URL).
---
# Checkpoint — multi-account signer switching (Option A) (2026-09-27 eve)
## Where things are
- Project: `/home/avi/Projects/Keynctr`, branch `master` @ **`c89b31a`**
("feat(nip46): pair a second signer account — park the live session,
switch re-dials it"). Previous: `1b4655c` (checkpoint), `332ab64`.
- Working tree: clean for tracked files. Untracked intentionally NOT
committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`,
`deferred/`.
## What was completed (user-facing)
1. **You can now add a second Amber account.** Pairing a new signer while
one is connected no longer says "Already connected — disconnect
first": the current session is PARKED (kept restorable, never revoked)
and the new account pairs.
2. **Switching profiles switches signer accounts.** Click a profile in
Profiles: if it has a saved signer session, the live one is parked and
that profile's session is re-dialed automatically (no scan, identity
guard still enforced). Local-key profiles leave the signer alone.
3. **Cancel on the QR is safe.** Leaving the QR view cancels only the
pairing attempt and brings the parked session back (new
`nip46_cancel_pairing` IPC; the old path revoked).
- One session is live at a time (Amber signs one active account anyway);
all others stay saved and switchable.
## Commits added
- `c89b31a` feat(nip46): pair a second signer account — park the live session, switch re-dials it
## Verification (all green at c89b31a)
- Rust: `cargo test` 216 unit + 6 e2e (NEW: two fake Ambers on one relay —
B's pairing parks A unrevoked with client key intact; switch back
re-dials A and signs; no-op switch; local profile untouched; B
restorable). `cargo clippy --all-targets` 0 warnings; `cargo fmt --check`
clean; `cargo build --release` rebuilt (binary mtime Sep 27 21:00).
- Frontend: `npm test` 125 passed; `typecheck`, `lint`, `format:check`
clean; `npm run build` + `electron:build` green.
## Resume / reproduce
- GUI: relaunch the app (or restart the backend) to pick up the new
release binary. Profiles -> click another paired profile -> log shows
`restoring session` + `identity check on restored session: PASS`.
- Add account B: Create Profile -> Sign in with Amber -> switch to
account B IN AMBER -> scan. Account A stays restorable.
- e2e: `cargo test --test nip46_e2e` (6 tests, loopback relay only).
## Outstanding
- LIVE two-account eyeball by the user (pair account B from a second
Amber profile, switch back and forth) — e2e proves the mechanics, a
real-device pass confirms it end-to-end.
- Live "Check for updates" failure is an ENVIRONMENT issue, not a bug:
the updater shells out to `npm outdated`/`cargo update` in the source
tree; the spawned backend's PATH lacks npm/cargo (Electron-launched
process), so it errors. Fix options: bake a login-shell PATH into the
updater or surface a clearer message. Not started.
- Publish kind-0 to primal/damus (one Amber approval); Step 4
permissions UI; KDF upgrade (Step 5); rename pass (Step 7).
---
# Checkpoint — forensics log cleaned + live publish confirmed (2026-09-26)
## Where things are
- Project: `/home/avi/Projects/Keynctr`
- Branch: `master` @ **`332ab64`** ("fix(nip46): gate remaining trace
writes to live relay sessions"). Previous: `704addc` (checkpoint),
`bc736ff` (auto-name retry), `b5c61de`, `fc2fe93`.
- Working tree clean except the standing untracked files
(COSMIC_THEME.md, icon jpeg, deferred/).
- Release binary rebuilt at 332ab64 (mtime Sep 26 20:34, real 22s
compile, not a cache hit).
## What was completed
1. **LIVE PUBLISH CONFIRMED (was the last open item)** — el.log shows
three sign_event responses ~19:50 Sep 25 and relay probes confirm
kind:1 notes (id 5191172d01f9…, fe9a256f597f…, text "test" +
image) from npub1qn0w4… accepted on primal/damus/snort/nos.lol at
exactly those timestamps. End-to-end Amber signing works.
2. **False alarms retired**: the repeated "restored signer answered as
a different account" and duplicate "auto-name attempt" lines in
pairing-trace.log were E2E TEST traffic (wrong-identity refusal test
+ loopback auto-name loop), not live Amber failures — each bogus
npub appeared exactly at test-run times (17:09 rebuild, 20:07 suite).
3. **Trace gating fix (332ab64)**: `fail()` and the background
auto-name traces now check `live_relays()` like every other site.
Verified by measurement: e2e suite run leaves pairing-trace.log
byte-identical (was 240 lines before, 240 after).
4. **Live vault pruned (not in git)**: dropped the legacy `fac852dc…`
connection row (15:37 pairing, predates client-key persistence,
superseded by 19:35 `4148a9a1…` pairing) so startup restore can't
waste a re-dial on a keyless row. Backup:
profiles_vault.json.backup-cron-20260926. Done with backend down.
## Verified this session
- cargo test: 216 unit + 5 e2e green. clippy --all-targets: 0 warnings.
cargo fmt --check clean. cargo build --release rebuilt at 332ab64.
- Frontend untouched this session (no npm run needed).
- Serve smoke test on the REAL vault (backend was down): startup
restore fires "restoring session: peer=4148a9a1… client
pubkey=64ea18e8…" (the persisted key from the 19:35 pairing), relays
connect, no errors. Full handshake needs Amber online — user test.
- kind-0 'web5osint' confirmed live on nos.lol; profile row already
carries the name + picture in the vault.
## Outstanding / next user steps
- One scanless restart check: launch the GUI with Amber online and
watch for "identity check on restored session: PASS" without
scanning (restore now targets only the restorable 4148a9a1 row).
- Optional: publish kind-0 to primal/damus too so naming doesn't
depend on nos.lol alone (needs one Amber signature).
- reminder: pkill patterns matching their own launch string kill the
cron shell — resolve PID by full binary path first.
---
# Checkpoint — auto-naming hardened (2026-09-25 eve)
## What changed since the label-step checkpoint

297
Cargo.lock generated
View file

@ -214,7 +214,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@ -292,12 +292,12 @@ dependencies = [
[[package]]
name = "bitcoin-consensus-encoding"
version = "1.3.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f"
checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec"
dependencies = [
"bitcoin-internals 0.7.0",
"hex-conservative 1.3.0",
"bitcoin-internals",
"hex-conservative 1.2.0",
"serde",
]
@ -307,12 +307,6 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5"
[[package]]
name = "bitcoin-internals"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2"
[[package]]
name = "bitcoin-io"
version = "0.1.101"
@ -329,7 +323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
dependencies = [
"bitcoin-io",
"hex-conservative 0.2.3",
"hex-conservative 0.2.2",
]
[[package]]
@ -339,22 +333,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e"
dependencies = [
"bitcoin-consensus-encoding",
"bitcoin-internals 0.6.0",
"hex-conservative 1.3.0",
"bitcoin-internals",
"hex-conservative 1.2.0",
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
@ -452,9 +440,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.5.1"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"shlex",
@ -462,9 +450,9 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.5"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
@ -574,9 +562,9 @@ dependencies = [
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-common"
@ -622,46 +610,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "defmt"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
dependencies = [
"defmt 1.1.1",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -693,7 +641,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@ -767,12 +715,10 @@ dependencies = [
[[package]]
name = "faster-hex"
version = "0.10.1"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed"
checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
dependencies = [
"autocfg",
"defmt 0.3.100",
"heapless",
"serde",
]
@ -785,9 +731,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "form_urlencoded"
@ -867,7 +813,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@ -1005,18 +951,18 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.2.3"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08"
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
dependencies = [
"arrayvec",
]
[[package]]
name = "hex-conservative"
version = "1.3.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8"
checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10"
dependencies = [
"arrayvec",
]
@ -1057,9 +1003,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
@ -1170,9 +1116,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.14.2"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
dependencies = [
"equivalent",
"hashbrown",
@ -1206,9 +1152,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.106"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
@ -1295,9 +1241,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.18.5"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
[[package]]
name = "memchr"
@ -1316,9 +1262,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.3"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
@ -1333,9 +1279,9 @@ checksum = "81c353b400a5503efdcf398f11a83fb7aa84f59f5d76fc4bf5bbc1e4f5366caa"
[[package]]
name = "nostr"
version = "0.45.5"
version = "0.45.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38ca5c25a6df8d4d78fdf39b42792438f6ce22b4809ccbfdb27582ed9ca45407"
checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a"
dependencies = [
"aes 0.8.4",
"base64",
@ -1347,7 +1293,7 @@ dependencies = [
"chacha20poly1305",
"faster-hex",
"opaquerr",
"rand 0.10.3",
"rand 0.10.2",
"secp256k1",
"serde",
"serde_json",
@ -1359,9 +1305,9 @@ dependencies = [
[[package]]
name = "nostr-database"
version = "0.45.2"
version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "309950383c9854ca413d195705400e78b1376aedc48f3256754a86c609c047e8"
checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf"
dependencies = [
"nostr",
"opaquerr",
@ -1369,9 +1315,9 @@ dependencies = [
[[package]]
name = "nostr-gossip"
version = "0.45.1"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea822fd48ff6b84b81ddf84169e6edf1dc0bc9b312c8e41685b2278debaac3d"
checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336"
dependencies = [
"nostr",
"opaquerr",
@ -1379,9 +1325,9 @@ dependencies = [
[[package]]
name = "nostr-sdk"
version = "0.45.4"
version = "0.45.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db717044f755b5ed9be53cacb59660c36efbe578bde870151a24d6bead3cb218"
checksum = "245f8642b10feecb0a40739a886ca1850e3be4e35dc6592b806ec437403ab9c9"
dependencies = [
"async-utility",
"async-wsocket",
@ -1393,7 +1339,7 @@ dependencies = [
"nostr-database",
"nostr-gossip",
"opaquerr",
"rand 0.10.3",
"rand 0.10.2",
"tokio",
"tokio-stream",
"tracing",
@ -1680,9 +1626,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.3"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"getrandom 0.4.3",
"rand_core 0.10.1",
@ -1738,7 +1684,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.2",
"bitflags",
]
[[package]]
@ -1797,21 +1743,21 @@ dependencies = [
[[package]]
name = "rtoolbox"
version = "0.0.6"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.1.5"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.2",
"bitflags",
"errno",
"libc",
"linux-raw-sys",
@ -1820,9 +1766,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
@ -1909,7 +1855,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc",
@ -1953,7 +1899,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@ -1977,7 +1923,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@ -2037,9 +1983,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.2"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
@ -2076,9 +2022,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.6"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
@ -2087,13 +2033,13 @@ dependencies = [
[[package]]
name = "synstructure"
version = "0.14.0"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 2.0.119",
]
[[package]]
@ -2120,11 +2066,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.21"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl 2.0.21",
"thiserror-impl 2.0.20",
]
[[package]]
@ -2140,13 +2086,13 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.21"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@ -2161,9 +2107,18 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.13.3"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
@ -2199,14 +2154,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
@ -2276,9 +2231,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.15+spec-1.1.0"
version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap",
"toml_datetime",
@ -2341,7 +2296,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.21",
"thiserror 2.0.20",
"utf-8",
]
@ -2364,9 +2319,9 @@ dependencies = [
[[package]]
name = "unicode-ident"
version = "1.0.26"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
@ -2426,9 +2381,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.26.1"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@ -2459,9 +2414,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
@ -2472,20 +2427,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.79"
version = "0.4.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -2493,31 +2447,31 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.106"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
dependencies = [
"js-sys",
"wasm-bindgen",
@ -2569,6 +2523,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@ -2676,13 +2639,13 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 2.0.119",
"synstructure",
]
@ -2741,7 +2704,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
"zbus_names",
"zvariant",
"zvariant_utils",
@ -2769,18 +2732,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.59"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.59"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
@ -2798,13 +2761,13 @@ dependencies = [
[[package]]
name = "zerofrom-derive"
version = "0.1.8"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 2.0.119",
"synstructure",
]
@ -2844,7 +2807,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
]
[[package]]
@ -2877,7 +2840,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.4",
"zvariant_utils",
]
@ -2890,6 +2853,6 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.6",
"syn 3.0.4",
"winnow",
]

View file

@ -394,94 +394,6 @@ function resolveWindowIcon(): string {
return path.join(base, 'icon.png');
}
/**
* Well-known per-user tool dirs appended to the inherited PATH so npm/cargo
* resolve when Electron launches us from a desktop launcher (mirrors the
* backend's augmented_path() in src/updates.rs).
*/
function augmentedPath(): string {
const home = process.env.HOME ?? '';
const extra = [
`${home}/.cargo/bin`,
`${home}/.local/bin`,
`${home}/.mise/shims`,
`${home}/.asdf/shims`,
'/usr/local/bin',
];
const inherited = process.env.PATH ?? '';
return [...inherited.split(':').filter(Boolean), ...extra].join(':');
}
/** Run a build command to completion, resolving with its combined output. */
function runBuild(
cwd: string,
program: string,
args: string[],
): Promise<{ ok: boolean; output: string }> {
return new Promise((resolve) => {
const child = spawn(program, args, {
cwd,
env: { ...process.env, PATH: augmentedPath() },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout?.on('data', (chunk: Buffer) => (output += chunk.toString()));
child.stderr?.on('data', (chunk: Buffer) => (output += chunk.toString()));
child.on('error', (err) => resolve({ ok: false, output: `${program}: ${err.message}` }));
child.on('close', (code) => resolve({ ok: code === 0, output }));
});
}
let selfUpdateInFlight: Promise<{ reloaded: boolean; note: string }> | null = null;
/**
* Rebuild the app from its source checkout and hot-swap the running parts:
* fresh `dist/` + a freshly spawned backend, then reload every window. The
* Electron shell keeps running, so the user does not restart the app.
* (A change to this main-process file itself still needs a manual relaunch.)
*/
async function selfUpdate(): Promise<{ reloaded: boolean; note: string }> {
if (selfUpdateInFlight) return selfUpdateInFlight;
selfUpdateInFlight = (async () => {
if (app.isPackaged) {
throw new Error('This build is packaged; updates are applied by replacing the app bundle.');
}
const frontendDir = app.getAppPath();
const projectRoot = path.join(frontendDir, '..');
const npmBuild = await runBuild(frontendDir, 'npm', ['run', 'build']);
if (!npmBuild.ok) {
throw new Error(`Frontend build failed:\n${npmBuild.output.slice(-2000)}`);
}
const cargoBuild = await runBuild(projectRoot, 'cargo', ['build', '--release']);
if (!cargoBuild.ok) {
throw new Error(`Rust build failed:\n${cargoBuild.output.slice(-2000)}`);
}
// Swap the backend: kill the old child; the next request spawns the new
// binary. startBackend() re-checks exitCode, so resetting the flag is
// enough once the process is actually gone.
if (backend && backend.exitCode === null) {
backend.kill();
await new Promise<void>((resolve) => {
if (!backend) return resolve();
backend.once('exit', () => resolve());
setTimeout(resolve, 3000);
});
}
backend = null;
backendStarted = false;
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.reloadIgnoringCache();
}
return { reloaded: true, note: 'Rebuilt and reloaded. The app stayed open.' };
})().finally(() => {
selfUpdateInFlight = null;
});
return selfUpdateInFlight;
}
function startBackend(): void {
if (backendStarted && backend && backend.exitCode === null) {
return;
@ -1073,8 +985,6 @@ app.whenReady().then(() => {
},
);
ipcMain.handle('app:selfupdate', () => selfUpdate());
ipcMain.handle('clipboard:write', (_event, text: string) => {
clipboard.writeText(String(text));
return true;

View file

@ -4,6 +4,4 @@ contextBridge.exposeInMainWorld('backend', {
request: (method: string, params?: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke('backend:request', { method, params }),
copyText: (text: string): Promise<void> => ipcRenderer.invoke('clipboard:write', text),
selfUpdate: (): Promise<{ reloaded: boolean; note: string }> =>
ipcRenderer.invoke('app:selfupdate'),
});

File diff suppressed because it is too large Load diff

View file

@ -25,8 +25,6 @@ declare global {
backend: {
request(method: string, params?: Record<string, unknown>): Promise<unknown>;
copyText(text: string): Promise<void>;
/** Rebuild + hot-reload the app without a manual restart (Electron only). */
selfUpdate?(): Promise<{ reloaded: boolean; note: string }>;
};
}
}
@ -123,7 +121,6 @@ export const api = {
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46CancelPairing: () => call<Nip46SignerStatus>('nip46_cancel_pairing'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean, always = false) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),

View file

@ -1,52 +0,0 @@
import type { Nip46Permissions, SignerGrant } from './types';
/** Human label for a NIP-46 method name. */
export function methodLabel(method: string): string {
switch (method) {
case 'sign_event':
return 'Sign events';
case 'nip44_encrypt':
return 'Encrypt messages (NIP-44)';
case 'nip44_decrypt':
return 'Decrypt messages (NIP-44)';
case 'get_public_key':
return 'Read your public key';
case 'get_relays':
return 'Read your relay list';
default:
return method;
}
}
/** One-line description of a permission grant, e.g.
* "Sign events (kinds 1, 30023)" or "Sign events (all kinds)". */
export function permissionLabel(method: string, allowedKinds?: number[]): string {
const base = methodLabel(method);
if (method === 'sign_event') {
if (!allowedKinds || allowedKinds.length === 0) return `${base} — all kinds`;
return `${base} — kinds ${allowedKinds.join(', ')}`;
}
return base;
}
/** Grant rows for the "always allow" list. */
export function grantLabel(grant: SignerGrant): string {
return permissionLabel(grant.method, grant.allowed_kinds);
}
/** Rows for the declared per-connection permission set. An absent set means
* there is no local grant list — the signer app approves each request. */
export function declaredPermissionRows(permissions?: Nip46Permissions): string[] | null {
if (!permissions) return null;
const granted = permissions.granted ?? [];
if (granted.length === 0) return [];
return granted.map((p) => permissionLabel(p.method, p.allowed_kinds));
}
/** Format a connection expiry for display. */
export function formatExpiry(expiresAt?: number): string | null {
if (!expiresAt) return null;
const date = new Date(expiresAt * 1000);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString();
}

View file

@ -1,13 +1,5 @@
export type Theme =
| 'light'
| 'dark'
| 'glass'
| 'neon'
| 'impeccable'
| 'impeccable-dark'
| 'cosmic'
| 'workshop'
| 'workshop-dark';
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
/** Active signer mode. */
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
@ -37,19 +29,6 @@ export interface PendingApproval {
details?: ApprovalDetails;
}
/** A single granted NIP-46 permission (mirrors the Rust Nip46Permission). */
export interface Nip46Permission {
/** The NIP-46 method this covers, e.g. `sign_event`. */
method: string;
/** Event-kind restrictions for `sign_event`; empty = all kinds. */
allowed_kinds?: number[];
}
/** Declared per-connection permission set (from a `perms=` connect URI). */
export interface Nip46Permissions {
granted?: Nip46Permission[];
}
/** A standing "always allow" grant: one app may use one method without a
* prompt. Created by choosing "Always allow" on an approval; revoked from
* the Signer screen. */
@ -58,8 +37,6 @@ export interface SignerGrant {
app_pubkey: string;
/** NIP-46 method that runs without prompting (e.g. "sign_event"). */
method: string;
/** Event kinds covered for `sign_event`; empty = all kinds (legacy). */
allowed_kinds?: number[];
}
/** Non-secret snapshot of the NIP-46 remote signer for display. */
@ -98,11 +75,6 @@ export interface Nip46SignerStatus {
pending_approvals: PendingApproval[];
/** nostrconnect:// pairing token while a QR pairing is in flight. */
pairing_uri?: string;
/** Declared per-connection permissions, when the connect URI carried a
* `perms=` grant list. Absent = the signer app enforces via its prompts. */
permissions?: Nip46Permissions;
/** Unix timestamp when the connection expires, if it has a deadline. */
expires_at?: number;
}
/** Union of all signer statuses. */

View file

@ -15,8 +15,7 @@ interface CreateProfileModalProps {
type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
const { state, createProfile, nip46PairStart, nip46Status, nip46CancelPairing, refresh } =
useApp();
const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
const [label, setLabel] = useState('');
const [phase, setPhase] = useState<Phase>('choice');
const [error, setError] = useState<string | null>(null);
@ -150,16 +149,13 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
// Leaving the QR view mid-pairing aborts the in-flight pairing; nothing
// was persisted yet, so teardown is safe at any point (same as Signer
// Mode's "Cancel pairing"). Cancel (not disconnect): when pairing a
// second signer account parked the first one, cancelling must restore
// the parked session rather than revoke anything.
// Mode's "Cancel pairing").
const cancelPairing = async () => {
setLivePairingUri(null);
setPairingQr(null);
setPhase('choice');
try {
await nip46CancelPairing();
void refresh();
await nip46Disconnect();
} catch {
// Best-effort abort; a dead pairing attempt expires on its own.
}

View file

@ -73,25 +73,7 @@ export function SettingsScreen() {
const result = await updateApply();
const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)];
if (result.restart_required) {
// Rebuild in place and hot-swap the backend + renderer. The Electron
// shell itself never restarts; only a change to the main process file
// (this code's own host) still needs a manual relaunch.
if (window.backend.selfUpdate) {
lines.push('Rebuilding and reloading the app…');
setApplyMessage(lines);
try {
const update = await window.backend.selfUpdate();
lines.push(update.note);
} catch (err) {
lines.push(
`Automatic reload failed: ${err instanceof Error ? err.message : String(err)}. Rebuild and restart manually to finish.`,
);
}
} else {
lines.push(
'Rebuild and restart the app (cargo build --release, then relaunch) to finish.',
);
}
lines.push('Rebuild and restart the app (cargo build --release, then relaunch) to finish.');
}
setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']);
} catch (err) {
@ -127,8 +109,6 @@ export function SettingsScreen() {
<option value="impeccable">Impeccable — Light</option>
<option value="impeccable-dark">Impeccable — Dark</option>
<option value="cosmic">Cosmic — Stardust</option>
<option value="workshop">Workshop — Cybernetic</option>
<option value="workshop-dark">Workshop — Dark</option>
</select>
<div
aria-hidden="true"

View file

@ -5,7 +5,6 @@ import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { declaredPermissionRows, formatExpiry } from '../lib/permissions';
import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
@ -18,7 +17,6 @@ export function SignerModeScreen() {
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46CancelPairing,
nip46Approve,
embeddedSignerApprove,
refresh,
@ -194,19 +192,17 @@ export function SignerModeScreen() {
};
}, [pairingUri]);
// Abort an in-flight pairing (e.g. expired QR): cancel the pairing
// attempt only. Never disconnect here — if pairing a second signer
// account parked the first one, a cancel must bring the parked session
// back instead of revoking it.
// Abort an in-flight pairing (e.g. expired QR) — same teardown as a
// disconnect; nothing was persisted yet so it is safe at any point.
const handlePairCancel = useCallback(async () => {
setPairError(null);
try {
const status = await nip46CancelPairing();
const status = await nip46Disconnect();
setNip46StatusState(status);
} catch (err) {
setPairError(err instanceof Error ? err.message : String(err));
}
}, [nip46CancelPairing]);
}, [nip46Disconnect]);
const handleNip46Disconnect = useCallback(async () => {
setError(null);
@ -529,40 +525,6 @@ export function SignerModeScreen() {
via {nip46StatusState.connected_relays?.length ?? 0} of{' '}
{nip46StatusState.relays?.length ?? 0} relays
</p>
<div className="signer-permissions">
<h3>Permissions</h3>
{(() => {
const rows = declaredPermissionRows(nip46StatusState.permissions);
const expiry = formatExpiry(nip46StatusState.expires_at);
return (
<>
{rows === null ? (
<p className="hint">
This signer approves every request on your phone — Keynctr holds no
standing permission list for this connection.
</p>
) : rows.length === 0 ? (
<p className="hint">
No operations were granted by the connect request.
</p>
) : (
<ul className="signer-permission-list">
{rows.map((row) => (
<li key={row} className="mono">
{row}
</li>
))}
</ul>
)}
{expiry && (
<p className="hint">
<Icon name="shield" size={14} /> This connection expires {expiry}.
</p>
)}
</>
);
})()}
</div>
{nip46StatusState.error && (
<Alert tone="error" title="Connection error">
{nip46StatusState.error}

View file

@ -5,7 +5,6 @@ import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { shortHexId } from '../lib/format';
import { grantLabel } from '../lib/permissions';
import type { SignerGrant, SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
@ -253,7 +252,7 @@ export function SignerScreen() {
{grants.map((grant) => (
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{grantLabel(grant)}</code>
<code className="mono signer-pending-method">{grant.method}</code>
<p>for {shortHexId(grant.app_pubkey)}</p>
</div>
<div className="settings-inline">

View file

@ -82,7 +82,6 @@ interface AppContextValue {
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46CancelPairing: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated)
@ -290,7 +289,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
[],
);
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
const nip46CancelPairing = useCallback(() => api.nip46CancelPairing(), []);
const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
const nip46Approve = useCallback(
@ -387,7 +385,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46CancelPairing,
nip46Status,
nip46Approve,
signerConnect,
@ -448,7 +445,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46CancelPairing,
nip46Status,
nip46Approve,
signerConnect,

View file

@ -426,12 +426,6 @@ html[data-theme='cosmic'] .sidebar-logo img {
filter: invert(1);
}
/* The logo art is dark ink; on the dark workshop material it inverts to
the warm paper ink so the mark stays legible on #12110f. */
html[data-theme='workshop-dark'] .sidebar-logo img {
filter: invert(1) grayscale(1) brightness(1.4);
}
/* Cosmic workspace - Deep Space background with vignette effect */
html[data-theme='cosmic'] .app-shell {
background:
@ -576,136 +570,6 @@ html[data-theme='cosmic'] .empty-state {
border-color: var(--border);
}
/* "Workshop" — Cybernetic Workshop, the Moi portfolio identity (DESIGN.md):
warm paper, near-black ink, hairline borders, one pine accent, copper
index ticks, serif headings. Light is the material; flat depth
Paper -> Surface -> Stone, shadow only where Moi allows it. */
:root[data-theme='workshop'] {
--bg: #f3efe6;
--surface: #faf7f0;
--surface-2: #eae4d8;
--surface-hover: #faf7f0;
--border: #d9d1c3;
--border-strong: #c9bfad;
--text: #1c1814;
--text-muted: #5e574d;
--primary: #215c48;
--primary-hover: #184536;
--primary-soft: #e4f0ea;
--on-primary: #f3efe6;
--danger: #a13d2e;
--danger-soft: #f5e5e1;
--warning: #8b5a32;
--warning-soft: #f3ead9;
--success: #215c48;
--success-soft: #e4f0ea;
--info: #3c5a72;
--info-soft: #e6ecf1;
--focus: #215c48;
--shadow: 0 1px 2px rgba(28, 24, 20, 0.05), 0 8px 24px rgba(28, 24, 20, 0.06);
--shadow-modal: 0 12px 40px rgba(28, 24, 20, 0.18);
--radius: 14px;
--radius-sm: 9px;
/* Moi atmosphere layer: hairline graph-paper grid + soft mint/clay washes */
--wk-grid: rgba(28, 24, 20, 0.04);
--wk-wash-mint: rgba(33, 92, 72, 0.1);
--wk-wash-clay: rgba(191, 112, 64, 0.1);
--wk-copper: #8b5a32;
}
/* Workshop type: Iowan/Palatino serif for display surfaces, everything
else stays on the local system stack (Moi ships no webfonts). */
html[data-theme='workshop'] h1,
html[data-theme='workshop-dark'] h1,
html[data-theme='workshop'] h2,
html[data-theme='workshop-dark'] h2 {
font-family: 'Iowan Old Style', Palatino, Georgia, serif;
font-weight: 500;
letter-spacing: -0.02em;
}
html[data-theme='workshop'] h1 {
font-size: 30px;
line-height: 1.05;
}
html[data-theme='workshop'] h2 {
font-size: 19px;
line-height: 1.2;
}
/* Workshop dark material (Moi DESIGN.md palette, dark column): near-black
paper, warm light ink, pale pine accent. Same type and radii. */
:root[data-theme='workshop-dark'] {
--bg: #12110f;
--surface: #1c1a17;
--surface-2: #26221c;
--surface-hover: #221f1a;
--border: #3a342c;
--border-strong: #4d463c;
--text: #ebe6dc;
--text-muted: #a39b8f;
--primary: #7eb89a;
--primary-hover: #96d0b2;
--primary-soft: #1c322a;
--on-primary: #12110f;
--danger: #d98a7d;
--danger-soft: #35211d;
--warning: #d4a574;
--warning-soft: #322719;
--success: #7eb89a;
--success-soft: #1c322a;
--info: #92b4cc;
--info-soft: #1d2831;
--focus: #7eb89a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35);
--shadow-modal: 0 12px 40px rgba(0, 0, 0, 0.6);
--radius: 14px;
--radius-sm: 9px;
/* Moi dark atmosphere (site.css dark block): pale grid + pine/clay washes */
--wk-grid: rgba(235, 230, 220, 0.035);
--wk-wash-mint: rgba(126, 184, 154, 0.08);
--wk-wash-clay: rgba(212, 165, 116, 0.07);
--wk-copper: #d4a574;
}
/* One accent rule: pine carries focus and active states; copper marks
index-like mono elements instead of a second saturated hue. */
html[data-theme='workshop'] .main,
html[data-theme='workshop-dark'] .main {
/* Moi body canvas: graph-paper hairlines at 24px + mint/clay washes.
Applied to .main (the scroll surface) with fixed attachment so it
reads as the room, not a texture on a panel. */
background-image:
linear-gradient(var(--wk-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--wk-grid) 1px, transparent 1px),
radial-gradient(50% 40% at 88% 8%, var(--wk-wash-mint), transparent 70%),
radial-gradient(45% 36% at 8% 92%, var(--wk-wash-clay), transparent 68%);
background-size:
24px 24px,
24px 24px,
auto,
auto;
background-attachment: fixed;
}
html[data-theme='workshop'] .sidebar,
html[data-theme='workshop-dark'] .sidebar {
/* keep the sidebar a clean surface over the grid */
background: var(--surface);
}
html[data-theme='workshop'] code,
html[data-theme='workshop-dark'] code,
html[data-theme='workshop'] .mono,
html[data-theme='workshop-dark'] .mono {
background: var(--surface-2);
border: 1px solid var(--border);
}
html[data-theme='workshop'] .sidebar {
background: var(--surface);
border-right-color: var(--border);
}
html[data-theme='workshop'] .card {
border-color: var(--border);
}
* {
box-sizing: border-box;
}
@ -2378,26 +2242,6 @@ select {
gap: 12px;
}
.signer-permissions {
width: 100%;
padding: 10px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.signer-permissions h3 {
margin: 0 0 6px;
font-size: 13px;
}
.signer-permission-list {
margin: 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.7;
}
/* -------------------------------------------------------------------------
Motion system
Purposeful motion for feedback, state, and continuity.

View file

@ -137,10 +137,7 @@ describe('CreateProfileModal', () => {
await screen.findByText(/Waiting for the signer to scan/i);
await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));
// Cancel must be the NON-revoking cancel (parked sessions survive it),
// never the disconnect that revokes the stored connection.
expect(backend.requests.some((r) => r.method === 'nip46_cancel_pairing')).toBe(true);
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(false);
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(true);
expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument();

View file

@ -69,40 +69,4 @@ describe('SignerModeScreen handshake states', () => {
expect(backend.requests.some((r) => r.method === 'nip46_status')).toBe(true),
);
});
it('shows the declared permission list on a connected session', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: true,
signer_pubkey: 'aabbccddeeff0011',
relays: ['wss://relay.test'],
connected_relays: ['wss://relay.test'],
pending_approvals: [],
permissions: {
granted: [{ method: 'sign_event', allowed_kinds: [1, 30023] }, { method: 'nip44_encrypt' }],
},
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText('Permissions')).toBeInTheDocument();
expect(screen.getByText('Sign events — kinds 1, 30023')).toBeInTheDocument();
expect(screen.getByText('Encrypt messages (NIP-44)')).toBeInTheDocument();
});
it('explains signer-side enforcement when no grant list was declared', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: true,
signer_pubkey: 'aabbccddeeff0011',
relays: ['wss://relay.test'],
connected_relays: ['wss://relay.test'],
pending_approvals: [],
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText('Permissions')).toBeInTheDocument();
expect(await screen.findByText(/approves every request on your phone/i)).toBeInTheDocument();
});
});

View file

@ -226,14 +226,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
backend.setNip46(next);
return next;
}
case 'nip46_cancel_pairing': {
// Mirrors the real backend: aborts ONLY the pairing attempt and
// re-dials the parked session — a cancel must not clear a
// connected session, only the pairing URI.
const next = { ...backend.nip46, pairing_uri: undefined };
backend.setNip46(next);
return next;
}
case 'nip46_approve':
return backend.nip46;

View file

@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
declaredPermissionRows,
formatExpiry,
grantLabel,
permissionLabel,
} from '../lib/permissions';
describe('permission labels', () => {
it('labels a kind-scoped sign_event grant', () => {
expect(permissionLabel('sign_event', [1, 30023])).toBe('Sign events — kinds 1, 30023');
});
it('labels an all-kinds sign_event grant', () => {
expect(permissionLabel('sign_event', [])).toBe('Sign events — all kinds');
expect(permissionLabel('sign_event')).toBe('Sign events — all kinds');
});
it('labels non-signing methods without kind noise', () => {
expect(permissionLabel('nip44_decrypt')).toBe('Decrypt messages (NIP-44)');
});
it('renders a grant row through grantLabel', () => {
expect(grantLabel({ app_pubkey: 'aa', method: 'sign_event', allowed_kinds: [1] })).toBe(
'Sign events — kinds 1',
);
});
});
describe('declared permission rows', () => {
it('returns null when the connection declared no grant list', () => {
expect(declaredPermissionRows(undefined)).toBeNull();
expect(declaredPermissionRows({})).toEqual([]);
});
it('renders each granted method', () => {
expect(
declaredPermissionRows({
granted: [{ method: 'sign_event', allowed_kinds: [1] }, { method: 'nip44_encrypt' }],
}),
).toEqual(['Sign events — kinds 1', 'Encrypt messages (NIP-44)']);
});
});
describe('formatExpiry', () => {
it('formats a unix timestamp', () => {
expect(formatExpiry(1760000000)).toBeTruthy();
});
it('returns null when there is no deadline', () => {
expect(formatExpiry(undefined)).toBeNull();
});
});

View file

@ -402,23 +402,6 @@ struct RawRequest {
params: Vec<String>,
}
/// The event kind a gated request operates on, when it has one.
/// `sign_event` carries the unsigned event JSON in `params[0]`; other
/// gated methods (encrypt/decrypt) have no kind dimension, and an
/// unparseable payload returns `None` so grant checks fail closed toward
/// prompting.
fn request_kind(request: &RawRequest) -> Option<u16> {
if request.method != "sign_event" {
return None;
}
request
.params
.first()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
.map(|k| k as u16)
}
/// `{"id":..,"result":<s>,"error":null}`
fn response_ok(id: &str, result: String) -> String {
json!({ "id": id, "result": result, "error": null }).to_string()
@ -771,12 +754,11 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
// run — unless the user granted this app standing "always allow"
// permission for that method. Everything else is answered immediately.
let response = if requires_approval(&request.method) {
let kind = request_kind(&request);
let granted = {
let guard = app.lock().await;
guard
.vault
.has_signer_grant(&uri.peer.to_hex(), &request.method, kind)
.has_signer_grant(&uri.peer.to_hex(), &request.method)
};
if granted {
approved_response(&keys, &request)

View file

@ -173,12 +173,6 @@ pub enum Request {
},
/// Disconnect from the NIP-46 signer.
Nip46Disconnect,
/// Cancel an in-flight pairing (the GUI left the QR view): abort the
/// pairing attempt WITHOUT touching any parked/saved session, then try
/// to re-dial the active profile's saved session so parking for a
/// cancelled pairing is fully transparent. (Plain Nip46Disconnect would
/// revoke the currently stored connection — wrong for a cancel.)
Nip46CancelPairing,
/// Get NIP-46 connection status.
Nip46Status,
/// Approve/reject a pending NIP-46 request. `always = true` additionally
@ -498,13 +492,6 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
));
};
// Option A (one live session, many saved): connecting a signer
// while another session is live PARKS the current one — its
// vault row, secret, and client key stay restorable, so the
// parked account can be switched back to later with no scan.
if signer.has_live_session().await {
signer.park_live_session().await;
}
let status = signer.connect(&uri, label).await?;
Ok(json!(status))
}
@ -517,12 +504,6 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
));
};
// Pairing a second signer account (e.g. another Amber account)
// parks the live session instead of refusing it (Option A):
// the parked account stays restorable for a later switch.
if signer.has_live_session().await {
signer.park_live_session().await;
}
let status = signer.start_pairing(label).await?;
Ok(json!(status))
}
@ -534,14 +515,6 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
let status = signer.status().await;
Ok(json!(status))
}
Request::Nip46CancelPairing => {
let Some(signer) = ensure_nip46_signer(app).await else {
return Err(AppError::config("NIP-46 signer not initialized"));
};
signer.cancel_pairing().await?;
let status = signer.status().await;
Ok(json!(status))
}
Request::Nip46Status => {
let Some(signer) = ensure_nip46_signer(app).await else {
return Ok(json!({ "connected": false, "error": "Not initialized" }));
@ -681,41 +654,6 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
Ok(json!(items))
}
// Handled here (not in run_with_app) so the App guard can be
// dropped before the signer session switch: `switch_to_profile`
// re-locks the App to read the vault, exactly like connect().
Request::SelectProfile { npub } => {
{
let mut guard = app.lock().await;
profiles::set_active(&mut guard.vault, &npub)?;
if guard.signer_mode == SignerMode::Embedded {
if let Some(signer) = &guard.embedded_signer {
signer.set_active_profile(Some(npub.clone())).await;
}
} else if guard.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &guard.nip46_signer {
signer.set_active_profile(Some(npub.clone())).await;
}
}
guard.save_vault()?;
}
// Option A: follow the switch with the signer session. If the
// target profile has a restorable NIP-46 connection, the live
// session (if any, serving a different account) is parked and
// this profile's session is re-dialed — no fresh scan. If the
// target has no signer connection (local-key profile), the live
// session is left alone.
if app.lock().await.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = ensure_nip46_signer(app).await {
if let Err(e) = signer.switch_to_profile(&npub).await {
eprintln!("[NIP46] profile switch session change failed: {e}");
}
}
}
let guard = app.lock().await;
Ok(json!(guard.state_view()))
}
// Vault state requests (require lock)
other => {
let mut guard = app.lock().await;
@ -773,8 +711,21 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!({ "profile": summary, "state": app.state_view() }))
}
// NOTE: SelectProfile is handled in `run` (above), not here — it
// must drop the App guard before switching the signer session.
Request::SelectProfile { npub } => {
profiles::set_active(&mut app.vault, &npub)?;
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(npub)).await;
}
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(Some(npub)).await;
}
}
app.save_vault()?;
Ok(json!(app.state_view()))
}
Request::PublishProfileMetadata { npub } => {
// Route through the profile's Signing source, exactly like
// PublishNote: an embedded profile signs locally, a paired

View file

@ -28,13 +28,6 @@ pub enum Theme {
#[serde(rename = "impeccable-dark")]
ImpeccableDark,
Cosmic,
/// "Cybernetic Workshop" — the Moi portfolio look: warm paper, near-black
/// ink, hairline borders, one pine accent, serif headings.
Workshop,
/// The Workshop dark material (Moi DESIGN.md: "a second material, not a
/// personality change") — near-black paper, warm light ink, pale pine.
#[serde(rename = "workshop-dark")]
WorkshopDark,
}
impl Theme {
@ -48,8 +41,6 @@ impl Theme {
"impeccable" => Some(Self::Impeccable),
"impeccable-dark" => Some(Self::ImpeccableDark),
"cosmic" => Some(Self::Cosmic),
"workshop" => Some(Self::Workshop),
"workshop-dark" => Some(Self::WorkshopDark),
_ => None,
}
}

View file

@ -488,148 +488,6 @@ impl Nip46ClientSigner {
self.disconnect().await
}
/// Park the live session: stop the wire task and clear in-memory state,
/// but keep the vault row, pairing secret, and persisted client key
/// INTACT, so the session can be re-dialed later with no fresh scan.
///
/// Unlike [`Self::disconnect`] this does NOT revoke: switching signer
/// accounts (pair a second Amber account, or switch back to a previously
/// paired one) must leave the parked account restorable. The vault row
/// is what `reactivate_saved_sessions` dials from, so a parked session
/// is exactly a saved session.
pub async fn park_live_session(&self) {
let mut inner = self.inner.lock().await;
if let Some(task) = inner.task.take() {
task.abort();
}
if let Some(pairing) = inner.pairing.take() {
pairing.task.abort();
}
if let Some(client) = inner.client.take() {
let _ = client.disconnect().await;
}
inner.phase = Nip46Phase::Stopped;
inner.connection = None;
inner.conversation_key = None;
inner.keys = None;
inner.identity = None;
inner.expected_identity = None;
inner.active_npub = None;
inner.pending.clear();
// Wake any callers awaiting a remote response; their waiters turn
// into `NotConnected` rather than hanging until the request timeout.
for (_, waiter) in inner.remote_pending.drain() {
let _ = waiter.sender.send(Err(
"Switched signer accounts before the signer responded.".to_string(),
));
}
}
/// Whether a session or pairing is currently live.
pub async fn has_live_session(&self) -> bool {
let inner = self.inner.lock().await;
inner.task.is_some() || inner.pairing.is_some()
}
/// Cancel an in-flight pairing attempt: abort ONLY the pairing task and
/// its session state, then try to re-dial the active profile's saved
/// session. Used by the GUI when the user leaves the QR view after
/// pairing-for-a-second-account parked the first one: the cancel must
/// not revoke anything, and the parked session should come back so the
/// park is invisible.
pub async fn cancel_pairing(&self) -> Result<(), AppError> {
{
let mut inner = self.inner.lock().await;
if let Some(pairing) = inner.pairing.take() {
pairing.task.abort();
}
// A pairing that was cancelled before anyone scanned never
// reached the vault; the only live slot it held is now free.
// If a *connected* session exists (task, not pairing), leave it
// alone — cancelling pairing must not kill a working session.
if inner.task.is_some() {
return Ok(());
}
inner.phase = Nip46Phase::Stopped;
inner.connection = None;
inner.conversation_key = None;
inner.keys = None;
inner.identity = None;
inner.expected_identity = None;
inner.active_npub = None;
inner.pending.clear();
}
// The park-then-pair flow (Option A) may have left the previous
// account parked: re-dial it (reactivate prefers the active
// profile's row) so the cancelled pairing changes nothing.
self.reactivate_saved_sessions().await?;
Ok(())
}
/// Follow a profile switch with the signer session (Option A: one live
/// session, many saved ones).
///
/// - The live session already serves `npub`: keep it, do nothing.
/// - `npub` has no live saved NIP-46 connection (local-key profile, or
/// never paired): leave the live session alone — signing for `npub`
/// routes through its own source and killing a working Amber session
/// to look at a local profile would be a regression.
/// - `npub` has a restorable saved connection: park the live session
/// (restorable, not revoked) and re-dial `npub`'s row.
///
/// Returns `true` when a re-dial was started. The re-dial resolves
/// asynchronously through the same handshake as restore, including the
/// `expected_identity` cross-account guard.
pub async fn switch_to_profile(&self, npub: &str) -> Result<bool, AppError> {
// Already serving the target identity? Nothing to do.
{
let inner = self.inner.lock().await;
if let Some(identity) = &inner.identity {
if identity.to_bech32().ok().as_deref() == Some(npub) {
return Ok(false);
}
}
}
// Does the target profile have a live, restorable saved connection?
let restorable = {
let app = self.app.lock().await;
let vault_key = app.vault_key().copied();
let now = crate::vault::unix_timestamp().unwrap_or(0);
app.vault.nip46_connections.iter().any(|c| {
c.profile_npub.as_deref() == Some(npub)
&& c.revoked_at.is_none()
&& c.expires_at.map(|t| t > now).unwrap_or(true)
&& crate::vault::resolve_connection_client_key(
&app.vault,
vault_key.as_ref(),
&crate::signer::VaultRef::from_connection(c),
)
.ok()
.flatten()
.is_some()
})
};
if !restorable {
return Ok(false);
}
// Make sure the vault agrees on the target before re-dialing (the
// IPC path already did this; idempotent here, and it makes the
// reactivate preference order correct regardless of caller).
{
let mut app = self.app.lock().await;
if app.vault.active_profile.as_deref() != Some(npub) {
crate::profiles::set_active(&mut app.vault, npub)?;
app.save_vault()?;
}
}
self.park_live_session().await;
let dialed = self.reactivate_saved_sessions().await?;
Ok(dialed > 0)
}
/// Re-dial the saved signer sessions after startup/unlock, no scan.
///
/// Amber remembers our *client pubkey* as the identity of an approved
@ -1382,8 +1240,6 @@ impl Nip46ClientSigner {
} else {
None
},
permissions: connection.as_ref().and_then(|c| c.permissions.clone()),
expires_at: connection.as_ref().and_then(|c| c.expires_at),
}
}
@ -1433,17 +1289,8 @@ impl Nip46ClientSigner {
(entry, peer)
};
if approved && always && !peer_hex.is_empty() {
// A "sign_event" always-allow covers only the kinds of the
// request the user actually saw — never other kinds. Other
// gated methods have no kind dimension.
let kinds: Vec<u16> = if entry.method == "sign_event" {
entry.details.event_kind.into_iter().collect()
} else {
Vec::new()
};
let mut app = self.app.lock().await;
app.vault
.grant_signer_method(&peer_hex, &entry.method, &kinds)?;
app.vault.grant_signer_method(&peer_hex, &entry.method)?;
app.save_vault()?;
}
let _ = entry.sender.send(if approved {
@ -1458,22 +1305,7 @@ impl Nip46ClientSigner {
let message = message.into();
eprintln!("[nip46] session failed: {message}");
eprintln!("[NIP46] session failed: {message}");
// Forensics-log only LIVE sessions. The e2e harness deliberately
// fails restored sessions (wrong-identity refusal test), and its
// "session failed" lines were landing in pairing-trace.log among
// real ones — three different bogus "restored signer answered as a
// different account" npubs turned out to be test runs, not live
// Amber misbehaviour. Read the relays under a short lock before the
// mutating one below.
let relays = self
.inner
.try_lock()
.ok()
.and_then(|g| g.connection.as_ref().map(|c| c.relays.clone()))
.unwrap_or_default();
if live_relays(&relays) {
pairing_trace(&format!("session failed: {message}"));
}
if let Ok(mut inner) = self.inner.try_lock() {
// First failure wins: the demux loop exits with a generic
// "Connect handshake failed" AFTER the handshake task already
@ -1917,14 +1749,16 @@ impl Nip46ClientSigner {
}
// Check method permissions
let event_kind = request
let allowed = match request.method.as_str() {
"sign_event" => {
let kind = request
.params
.first()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
.map(|k| k as u16);
let allowed = match request.method.as_str() {
"sign_event" => self.can_sign_event(event_kind.unwrap_or(0)).await,
.unwrap_or(0) as u16;
self.can_sign_event(kind).await
}
"nip44_encrypt" => self.can_encrypt().await,
"nip44_decrypt" => self.can_decrypt().await,
_ => false,
@ -1938,10 +1772,9 @@ impl Nip46ClientSigner {
));
}
// Standing grant ("always allow") for this peer + method + kind:
// skip the prompt and run. Grants are per (peer pubkey, method,
// kind-set) and revocable from the Signer screen — a kind-1 grant
// never covers a kind-3 request.
// Standing grant ("always allow") for this peer + method: skip the
// prompt and run. Grants are per (peer pubkey, method) and revocable
// from the Signer screen.
{
let peer_hex = self
.inner
@ -1953,10 +1786,7 @@ impl Nip46ClientSigner {
.unwrap_or_default();
if !peer_hex.is_empty() {
let app = self.app.lock().await;
if app
.vault
.has_signer_grant(&peer_hex, &request.method, event_kind)
{
if app.vault.has_signer_grant(&peer_hex, &request.method) {
drop(app);
self.inner.lock().await.phase = Nip46Phase::Connected;
return self.approved_response(keys, request);
@ -2498,7 +2328,6 @@ impl Nip46ClientSigner {
// UI polls status and vault, so the row fills in a moment later.
{
let app = self.app.clone();
let live_enrichment = live_relays(&connection.relays);
tokio::spawn(async move {
let relays_for_meta = {
let app = app.lock().await;
@ -2529,27 +2358,15 @@ impl Nip46ClientSigner {
meta = Some(found);
break;
}
Ok(Ok(None)) => {
if live_enrichment {
pairing_trace(&format!(
Ok(Ok(None)) => pairing_trace(&format!(
"auto-name attempt {attempt}: no kind-0 found on any relay"
));
}
}
Ok(Err(join_err)) => {
if live_enrichment {
pairing_trace(&format!(
)),
Ok(Err(join_err)) => pairing_trace(&format!(
"auto-name attempt {attempt}: fetch task panicked: {join_err}"
));
}
}
Err(_) => {
if live_enrichment {
pairing_trace(&format!(
)),
Err(_) => pairing_trace(&format!(
"auto-name attempt {attempt}: fetch timed out (75s budget)"
));
}
}
)),
}
if attempt < 4 {
tokio::time::sleep(Duration::from_secs(20)).await;
@ -2559,12 +2376,10 @@ impl Nip46ClientSigner {
Some(meta) => meta,
None => return,
};
if live_enrichment {
pairing_trace(&format!(
"auto-name: kind-0 fetched (display_name={:?} name={:?})",
meta.display_name, meta.name
));
}
let mut app = app.lock().await;
let mut changed = false;
if let Some(row) = app

View file

@ -98,16 +98,6 @@ pub struct Nip46Status {
/// `None` once paired or when not pairing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pairing_uri: Option<String>,
/// The declared per-connection permissions for the live session, when
/// the connect URI carried a `perms=` grant list. `None` means no local
/// grant list — enforcement is the signer app's own approval prompts.
/// Surfaced so the UI can show what the connection was granted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<super::permissions::Nip46Permissions>,
/// When the live connection expires (unix timestamp), if it has a
/// deadline. Expired connections are refused at request time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
}
/// A pending approval request from the signer.

View file

@ -72,51 +72,6 @@ fn frontend_dir() -> PathBuf {
source_dir().join("frontend")
}
/// PATH used for package-manager commands.
///
/// When the backend is spawned by Electron its environment is the desktop
/// launcher's, NOT a login shell: `~/.cargo/bin` (cargo) and the node
/// version-manager bins/shims (npm) are missing, so the update commands
/// failed with "'npm' was not found" even though both exist in a terminal.
/// Augment the inherited PATH with the well-known per-user tool locations,
/// appended after the inherited entries: where a tool is already resolvable
/// on the inherited PATH that version wins, and entries that do not exist
/// are simply ignored by exec.
fn augmented_path() -> std::ffi::OsString {
let inherited = std::env::var_os("PATH");
let home = std::env::var_os("HOME");
build_augmented_path(inherited.as_deref(), home.as_deref())
}
/// Pure form of [`augmented_path`], testable without mutating the process
/// environment.
fn build_augmented_path(
inherited: Option<&std::ffi::OsStr>,
home: Option<&std::ffi::OsStr>,
) -> std::ffi::OsString {
let inherited_parts: Vec<PathBuf> = inherited
.map(std::env::split_paths)
.map(|p| p.collect())
.unwrap_or_default();
let home = home.map(PathBuf::from);
let extra: Vec<PathBuf> = [
".cargo/bin",
".local/bin",
".local/share/mise/shims",
".asdf/shims",
"/usr/local/bin",
]
.into_iter()
.filter_map(|rel| match rel.strip_prefix('/') {
// A leading slash marks an absolute system entry: use it verbatim.
Some(_) => Some(PathBuf::from(rel)),
None => home.as_ref().map(|h| h.join(rel)),
})
.collect();
std::env::join_paths(inherited_parts.into_iter().chain(extra))
.unwrap_or_else(|_| std::ffi::OsString::from("/usr/local/bin:/usr/bin:/bin"))
}
/// Verify the app is running from its source checkout before shelling out.
fn require_source_checkout() -> Result<(), AppError> {
let manifest = source_dir().join("Cargo.toml");
@ -133,22 +88,13 @@ fn require_source_checkout() -> Result<(), AppError> {
/// Run a command with a timeout, capturing stdout/stderr separately.
async fn run(dir: &Path, program: &str, args: &[&str]) -> Result<std::process::Output, AppError> {
let mut command = Command::new(program);
command
.current_dir(dir)
.args(args)
// See augmented_path(): Electron-spawned backends inherit a desktop
// PATH without the per-user tool dirs, and npm/cargo "don't exist".
.env("PATH", augmented_path())
.kill_on_drop(true);
command.current_dir(dir).args(args).kill_on_drop(true);
let future = command.output();
match tokio::time::timeout(COMMAND_TIMEOUT, future).await {
Ok(Ok(output)) => Ok(output),
Ok(Err(err)) => {
let hint = if err.kind() == std::io::ErrorKind::NotFound {
format!(
"'{program}' was not found on this computer. \
Install it (or add it to the app's PATH) and try again."
)
format!("'{program}' was not found on this computer.")
} else {
format!("Could not run '{program}': {err}")
};
@ -409,29 +355,6 @@ pub async fn apply() -> Result<UpdateApplyReport, AppError> {
mod tests {
use super::*;
#[test]
fn augmented_path_appends_tool_dirs_after_inherited() {
let joined = build_augmented_path(
Some(std::ffi::OsStr::new("/usr/bin:/bin")),
Some(std::ffi::OsStr::new("/home/tester")),
);
let joined = joined.to_string_lossy().into_owned();
// Inherited entries keep priority.
assert!(joined.starts_with("/usr/bin:/bin:"));
// The dirs an Electron-spawned process is missing are now present.
assert!(joined.contains("/home/tester/.cargo/bin"));
assert!(joined.contains("/home/tester/.local/share/mise/shims"));
assert!(joined.contains("/usr/local/bin"));
}
#[test]
fn augmented_path_survives_missing_env() {
// No PATH and no HOME: still a usable absolute system path.
let joined = build_augmented_path(None, None);
let joined = joined.to_string_lossy().into_owned();
assert!(joined.contains("/usr/local/bin"));
}
#[test]
fn parses_npm_outdated_entries() {
let json = r#"{

View file

@ -149,16 +149,6 @@ pub struct SignerGrant {
/// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`,
/// or `nip44_decrypt`.
pub method: String,
/// Event kinds covered when `method` is `sign_event`.
///
/// A grant is created "always allow" against ONE concrete request, so a
/// new `sign_event` grant carries exactly the kinds of that request.
/// A non-empty list restricts the grant to those kinds; an empty list
/// means all kinds — possible only on legacy grants (written before
/// grants were kind-scoped), never created anew. Grants for other
/// methods always leave this empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_kinds: Vec<u16>,
/// Unix timestamp of when the user granted it.
pub created_at: u64,
}
@ -216,44 +206,22 @@ impl Vault {
}
/// Whether `app_pubkey` may run gated `method` without a prompt.
///
/// For `sign_event` the grant is kind-scoped: a grant recorded for
/// specific kinds does NOT cover other kinds — an uncovered kind falls
/// back to the approval prompt. `None` for `event_kind` means the
/// request has no kind dimension (encrypt/decrypt) or the kind could
/// not be parsed; a kind-restricted grant never answers `None`, so
/// unparseable kinds fail closed toward prompting.
pub fn has_signer_grant(
&self,
app_pubkey: &str,
method: &str,
event_kind: Option<u16>,
) -> bool {
self.signer_grants.iter().any(|g| {
g.app_pubkey == app_pubkey
&& g.method == method
&& (g.allowed_kinds.is_empty()
|| event_kind.is_some_and(|k| g.allowed_kinds.contains(&k)))
})
pub fn has_signer_grant(&self, app_pubkey: &str, method: &str) -> bool {
self.signer_grants
.iter()
.any(|g| g.app_pubkey == app_pubkey && g.method == method)
}
/// Record an "always allow" grant (idempotent).
///
/// `allowed_kinds` scopes a `sign_event` grant to the kinds of the
/// request the user actually approved. Pass an empty slice for
/// non-signing methods and for kinds the user did not see — a new
/// grant never silently covers more than the request behind it.
pub fn grant_signer_method(
&mut self,
app_pubkey: &str,
method: &str,
allowed_kinds: &[u16],
) -> Result<(), crate::errors::AppError> {
if !self.has_signer_grant(app_pubkey, method, allowed_kinds.first().copied()) {
if !self.has_signer_grant(app_pubkey, method) {
self.signer_grants.push(SignerGrant {
app_pubkey: app_pubkey.to_string(),
method: method.to_string(),
allowed_kinds: allowed_kinds.to_vec(),
created_at: crate::vault::unix_timestamp()?,
});
}
@ -828,53 +796,24 @@ mod tests {
#[test]
fn signer_grants_roundtrip_and_revoke() {
let mut vault = Vault::empty();
assert!(!vault.has_signer_grant("aa", "sign_event", Some(1)));
assert!(!vault.has_signer_grant("aa", "sign_event"));
vault.grant_signer_method("aa", "sign_event", &[1]).unwrap();
vault.grant_signer_method("aa", "sign_event", &[1]).unwrap(); // idempotent
vault.grant_signer_method("bb", "sign_event", &[1]).unwrap();
vault.grant_signer_method("aa", "sign_event").unwrap();
vault.grant_signer_method("aa", "sign_event").unwrap(); // idempotent
vault.grant_signer_method("bb", "sign_event").unwrap();
assert_eq!(vault.signer_grants.len(), 2);
assert!(vault.has_signer_grant("aa", "sign_event", Some(1)));
assert!(!vault.has_signer_grant("aa", "nip04_decrypt", None));
assert!(vault.has_signer_grant("aa", "sign_event"));
assert!(!vault.has_signer_grant("aa", "nip04_decrypt"));
let json = serde_json::to_string(&vault).unwrap();
let mut loaded: Vault = serde_json::from_str(&json).unwrap();
assert!(loaded.has_signer_grant("aa", "sign_event", Some(1)));
assert!(loaded.has_signer_grant("bb", "sign_event", Some(1)));
assert!(loaded.has_signer_grant("aa", "sign_event"));
assert!(loaded.has_signer_grant("bb", "sign_event"));
assert!(loaded.revoke_signer_grant("aa", "sign_event"));
assert!(!loaded.revoke_signer_grant("aa", "sign_event"));
assert!(!loaded.has_signer_grant("aa", "sign_event", Some(1)));
assert!(loaded.has_signer_grant("bb", "sign_event", Some(1)));
}
#[test]
fn signer_grants_are_kind_scoped() {
let mut vault = Vault::empty();
// A grant made against a kind-1 request must not cover kind-3.
vault.grant_signer_method("aa", "sign_event", &[1]).unwrap();
assert!(vault.has_signer_grant("aa", "sign_event", Some(1)));
assert!(!vault.has_signer_grant("aa", "sign_event", Some(3)));
// An unparseable kind (None) also falls back to the prompt.
assert!(!vault.has_signer_grant("aa", "sign_event", None));
// A legacy grant with no kind list (written before grants were
// kind-scoped) still covers every kind — stored vaults keep working.
vault.signer_grants.push(SignerGrant {
app_pubkey: "legacy".to_string(),
method: "sign_event".to_string(),
allowed_kinds: Vec::new(),
created_at: 0,
});
assert!(vault.has_signer_grant("legacy", "sign_event", Some(1)));
assert!(vault.has_signer_grant("legacy", "sign_event", Some(30023)));
assert!(vault.has_signer_grant("legacy", "sign_event", None));
// Non-signing methods carry no kind dimension.
vault
.grant_signer_method("aa", "nip44_decrypt", &[])
.unwrap();
assert!(vault.has_signer_grant("aa", "nip44_decrypt", None));
assert!(!loaded.has_signer_grant("aa", "sign_event"));
assert!(loaded.has_signer_grant("bb", "sign_event"));
}
static COUNTER: AtomicU32 = AtomicU32::new(0);

View file

@ -1324,248 +1324,3 @@ async fn nip46_session_restore_redials_and_refuses_wrong_identity() {
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
// ---------------------------------------------------------------------------
// Option A: many saved sessions, one live. Pairing/connecting a second
// signer account PARKS the live session (restorable, never revoked), and
// switching a profile back to a parked account re-dials it with no scan.
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[allow(clippy::await_holding_lock)]
async fn nip46_second_account_parks_first_and_switch_restores_it() {
let _vault_guard = VAULT_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let app = {
let tmp = std::env::temp_dir().join(format!(
"keynectr-e2e-switch-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let app_dir = tmp.join("keynectr");
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
app_dir.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
let app = std::sync::Arc::new(Mutex::new(App::load().expect("load app")));
assert!(
app.try_lock().unwrap().vault.profiles.is_empty(),
"e2e vault isolation failed for switch test"
);
app
};
let relay_url = start_relay().await;
let comms_a = Keys::generate();
let identity_a = Keys::generate();
let comms_b = Keys::generate();
let identity_b = Keys::generate();
// Two fake Ambers on one relay: each only answers traffic it can
// NIP-44-decrypt with its own comms key, so they never cross-talk.
tokio::spawn(run_fake_amber(
relay_url.clone(),
comms_a.clone(),
identity_a.clone(),
Duration::from_millis(30),
));
tokio::spawn(run_fake_amber(
relay_url.clone(),
comms_b.clone(),
identity_b.clone(),
Duration::from_millis(30),
));
let signer = Nip46ClientSigner::new(app.clone());
let wait_connected = |signer: Nip46ClientSigner| async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
loop {
let st = signer.status().await;
if let Some(err) = &st.error {
panic!("session failed: {err}");
}
if st.connected {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"session never connected: {:?}",
signer.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
};
// --- 1. Account A pairs (the flow the GUI runs).
signer
.connect(
&format!(
"bunker://{}?relay={}",
comms_a.public_key().to_hex(),
relay_url
),
"amber A".to_string(),
)
.await
.expect("connect account A");
wait_connected(signer.clone()).await;
let npub_a = SignerTrait::get_public_key(&signer)
.await
.expect("identity A")
.to_bech32()
.unwrap();
// --- 2. Account B pairs while A is live. The IPC dispatcher parks the
// live session first (the exact sequence the dispatcher now runs).
assert!(signer.has_live_session().await);
signer.park_live_session().await;
assert!(
!signer.has_live_session().await,
"park must clear the live slot"
);
signer
.connect(
&format!(
"bunker://{}?relay={}",
comms_b.public_key().to_hex(),
relay_url
),
"amber B".to_string(),
)
.await
.expect("connect account B while A is parked");
wait_connected(signer.clone()).await;
let npub_b = SignerTrait::get_public_key(&signer)
.await
.expect("identity B")
.to_bech32()
.unwrap();
assert_ne!(npub_a, npub_b, "two accounts, two identities");
// B is fully usable: sign through it.
let unsigned = UnsignedEvent::new(
identity_b.public_key(),
Timestamp::now(),
Kind::TextNote,
vec![],
"signed by B".to_string(),
);
let signed = SignerTrait::sign_event(&signer, unsigned.clone())
.await
.expect("sign through B");
assert!(signed.verify_signature());
// Parking must NOT have revoked A: its row stays live with its client
// key resolvable — that is what makes it restorable.
let ref_a =
keynectr::signer::VaultRef::new(Some(npub_a.clone()), comms_a.public_key().to_hex());
{
let g = app.lock().await;
let row = g
.vault
.nip46_connections
.iter()
.find(|c| c.profile_npub.as_deref() == Some(npub_a.as_str()))
.expect("A's connection row survives B's pairing");
assert!(
row.revoked_at.is_none(),
"parked session must not be revoked"
);
assert!(
keynectr::vault::resolve_connection_client_key(&g.vault, None, &ref_a)
.expect("resolve")
.is_some(),
"parked session must keep its client key"
);
}
// --- 3. Switch back to A: park B, re-dial A — no fresh pairing.
let dialed = signer
.switch_to_profile(&npub_a)
.await
.expect("switch to A");
assert!(dialed, "A has a restorable session, switch must re-dial it");
wait_connected(signer.clone()).await;
let back = SignerTrait::get_public_key(&signer)
.await
.expect("identity after switch");
assert_eq!(
back.to_bech32().unwrap(),
npub_a,
"switched session must answer as A"
);
let unsigned_a = UnsignedEvent::new(
identity_a.public_key(),
Timestamp::now(),
Kind::TextNote,
vec![],
"signed by A again".to_string(),
);
let signed_a = SignerTrait::sign_event(&signer, unsigned_a.clone())
.await
.expect("sign through A after switch");
assert!(signed_a.verify_signature());
assert_eq!(signed_a.content, "signed by A again");
// Switching to A again is a no-op (already serving A): no second dial.
assert!(
!signer
.switch_to_profile(&npub_a)
.await
.expect("noop switch"),
"switch to the identity already live must not re-dial"
);
// --- 4. A profile with NO signer connection must leave B... (here A)
// alone: local-key profiles route signing through their own source.
let local = Keys::generate();
let npub_local = local.public_key().to_bech32().unwrap();
{
let mut g = app.lock().await;
g.vault.profiles.push(keynectr::vault::StoredProfile {
label: "local".to_string(),
public_key: npub_local.clone(),
secret_key: local
.secret_key()
.to_secret_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect(),
created_at: 0,
picture: None,
nip05: None,
signer_mode: keynectr::vault::SignerMode::Embedded,
});
g.save_vault().unwrap();
}
assert!(
!signer
.switch_to_profile(&npub_local)
.await
.expect("switch to local"),
"local-key profile must not touch the live signer session"
);
assert!(
signer.status().await.connected,
"live A session survives a switch to a local profile"
);
let still_a = SignerTrait::get_public_key(&signer).await.expect("still A");
assert_eq!(still_a.to_bech32().unwrap(), npub_a);
// And switching back to B works too — B was parked, never revoked.
let dialed_b = signer
.switch_to_profile(&npub_b)
.await
.expect("switch back to B");
assert!(dialed_b, "B was parked, must be restorable");
wait_connected(signer.clone()).await;
let b_again = SignerTrait::get_public_key(&signer).await.expect("B again");
assert_eq!(b_again.to_bech32().unwrap(), npub_b);
signer.disconnect().await.ok();
}