Compare commits

..

17 commits

Author SHA1 Message Date
Avi
6bff188714 docs(checkpoint): white logo + no-restart self-update commits 2026-09-28 09:27:35 -05:00
Avi
dcc701f88b feat(updater): apply updates without restarting the app
New app:selfupdate IPC (main process): npm run build + cargo build --release with the augmented PATH, then kill the backend child, reset the spawn flag so the next request starts the NEW binary, and reloadIgnoringCache every window. The Electron shell keeps running — no manual restart. Settings install now triggers it automatically when restart_required. Honest limits: a change to the Electron main process itself still needs one manual relaunch, and packaged builds report that bundle replacement is the update path.
2026-09-28 09:26:48 -05:00
Avi
4cc04812fe feat(theme): white logo mark on workshop-dark
The logo PNG is dark ink art; invert+brighten it on the dark workshop material (same technique cosmic uses) so it reads as warm paper ink on #12110f. Nav icons are currentColor and already themed. Verified filter present in built bundle.
2026-09-28 09:17:30 -05:00
Avi
ef7b79bfe4 docs(checkpoint): Workshop atmosphere commit de804c8 2026-09-28 09:10:18 -05:00
Avi
de804c8fc5 feat(theme): Workshop atmosphere — Moi's graph-paper grid + mint/clay washes
The Moi dark material is not just tokens: site.css paints a 24px hairline grid (rgba(235,230,220,0.035)) plus pine and clay radial washes over the paper. Applied the identical background stack to .main under both workshop themes (fixed attachment), with per-theme --wk-grid/--wk-wash-mint/--wk-wash-clay/--wk-copper tokens from Moi's light and dark blocks. Sidebar stays a clean opaque surface. Verified against the live Moi site in a browser: both render body #12110f with grid:true, wash:true, pine #7eb89a, copper #d4a574.
2026-09-28 09:10:00 -05:00
Avi
8f1c5e0e18 docs(checkpoint): Workshop Dark theme commit c18f59a 2026-09-28 09:02:31 -05:00
Avi
c18f59ad6c feat(theme): Workshop — Dark, the Moi dark material
Moi DESIGN.md dark column: paper #12110f, surface #1c1a17, stone #26221c, warm ink #ebe6dc, pale pine #7eb89a. Same serif type and radii as the light Workshop theme. Verified rendering live via computed styles (body rgb(18,17,15), cards rgb(28,26,23), pine #7eb89a).
2026-09-28 09:02:17 -05:00
Avi
17fd47c081 docs(checkpoint): Workshop theme + verified updater apply at add956a (2026-09-28) 2026-09-28 08:54:58 -05:00
Avi
add956abdd feat(theme): add Workshop theme — Cybernetic Workshop identity from Moi DESIGN.md
Warm paper (#f3efe6), near-black ink, hairline borders, one pine accent (#215c48), serif display headings, Moi radii (14/9). Tokens verified rendering live via computed styles on the built shell: body bg #f3efe6, card #faf7f0, border #d9d1c3, h2 Iowan/Palatino 500. Settings -> Appearance -> 'Workshop — Cybernetic'.
2026-09-28 08:54:13 -05:00
Avi
15fa331f46 chore(deps): apply dependency updates from the in-app updater run
Applying npm audit fix + npm update + cargo update via the (fixed) updater: clears the high-severity js-yaml advisory (maxTotalMergeKeys CPU use on empty merge sources). Full suites verified green after the bump: 219 Rust unit + 6 e2e, 135 frontend, vite build clean.
2026-09-28 08:30:48 -05:00
Avi
118f5d37bd docs(checkpoint): permissions UI + updater PATH fix at fa59b63 (2026-09-27) 2026-09-27 22:44:58 -05:00
Avi
fa59b63a17 fix(updates): augment spawned PATH so npm/cargo resolve from Electron
The backend spawned by Electron inherits the desktop launcher's
environment, not a login shell: ~/.cargo/bin and the mise/asdf shim
dirs are missing, so 'Check for updates' failed with "'npm' was not
found" even though both tools exist in a terminal.

run() now sets PATH to the inherited value plus the well-known per-user
tool dirs (~/.cargo/bin, ~/.local/bin, mise + asdf shims,
/usr/local/bin), appended after the inherited entries so existing
resolutions keep priority. The NotFound hint now says what to do.

Verified live: update_check under env -i PATH=/usr/bin:/bin returns a
full report (npm advisories + cargo updates) where it previously errored.
Unit tests cover the pure PATH builder incl. missing-env fallback.
2026-09-27 22:43:42 -05:00
Avi
adbc7c2d75 feat(signer): permissions UI — declared grants surfaced, always-allow kind-scoped
Step 4 groundwork, the enforcement half that was invisible or too broad:

- Nip46Status now carries the connection's declared perms= grant list and
  its expiry; Signer Mode shows a Permissions panel on a live session
  (explicit grant rows, or a plain statement that the signer app approves
  each request when no list was declared).
- 'Always allow' grants are kind-scoped: a sign_event grant records the
  kind of the request the user actually approved and never covers other
  kinds. Legacy kind-less grants keep their all-kinds meaning so existing
  vaults keep working. Enforced in both bunker.rs and nip46_client.rs.
- Grants list on the Signer screen renders human labels with kind scope.

Tests: vault kind-scoping unit tests, frontend permission-label unit
tests + two SignerModeScreen tests (declared list, signer-side note).
2026-09-27 22:37:54 -05:00
Avi
98593cb170 docs(checkpoint): multi-account signer switching (park/switch/cancel) at c89b31a (2026-09-27) 2026-09-27 21:02:21 -05:00
Avi
c89b31aaf1 feat(nip46): pair a second signer account — park the live session, switch re-dials it
One live NIP-46 session, many saved ones (Option A):
- start_pairing/connect while a session is live PARKS it instead of
  refusing: row, pairing secret, and persisted client key stay intact,
  so the parked account is restorable with no fresh scan.
- SelectProfile follows the switch: target has a restorable connection ->
  park current + re-dial target's row (expected_identity guard applies);
  target is local-key or unpaired -> live session untouched.
- New nip46_cancel_pairing IPC: aborts ONLY an in-flight pairing and
  re-dials the parked session, so cancel-after-park is transparent.
  The QR cancel paths (Add-profile modal, Signer Mode screen) use it —
  plain disconnect would revoke the parked connection.
- e2e: two fake Ambers on one relay; A pairs, B's pairing parks A
  (revoked_at none, client key resolvable), switch back re-dials A and
  signs; no-op switch; local profile leaves session alone; B restorable.
2026-09-27 21:01:23 -05:00
Avi
1b4655c07b docs(checkpoint): forensics log cleaned + live publish confirmed at 332ab64 (2026-09-26) 2026-09-26 20:37:21 -05:00
Avi
332ab647c9 fix(nip46): gate remaining trace writes to live relay sessions
The wrong-identity refusal test and the auto-name retry loop still wrote
into the live pairing-trace.log on every e2e run: three bogus "restored
signer answered as a different account" npubs and phantom "auto-name
attempt" lines were test traffic, not live Amber misbehaviour, and they
kept derailing the forensics review. fail and the background enrichment
traces now check live_relays like every other trace site.
Verified: running the e2e suite appends zero lines to the trace file.
2026-09-26 20:34:08 -05:00
26 changed files with 1969 additions and 534 deletions

View file

@ -1,3 +1,192 @@
# 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) # Checkpoint — auto-naming hardened (2026-09-25 eve)
## What changed since the label-step checkpoint ## What changed since the label-step checkpoint

297
Cargo.lock generated
View file

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

View file

@ -394,6 +394,94 @@ function resolveWindowIcon(): string {
return path.join(base, 'icon.png'); 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 { function startBackend(): void {
if (backendStarted && backend && backend.exitCode === null) { if (backendStarted && backend && backend.exitCode === null) {
return; return;
@ -985,6 +1073,8 @@ app.whenReady().then(() => {
}, },
); );
ipcMain.handle('app:selfupdate', () => selfUpdate());
ipcMain.handle('clipboard:write', (_event, text: string) => { ipcMain.handle('clipboard:write', (_event, text: string) => {
clipboard.writeText(String(text)); clipboard.writeText(String(text));
return true; return true;

View file

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

View file

@ -0,0 +1,52 @@
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,5 +1,13 @@
export type Theme = export type Theme =
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; | 'light'
| 'dark'
| 'glass'
| 'neon'
| 'impeccable'
| 'impeccable-dark'
| 'cosmic'
| 'workshop'
| 'workshop-dark';
/** Active signer mode. */ /** Active signer mode. */
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client'; export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
@ -29,6 +37,19 @@ export interface PendingApproval {
details?: ApprovalDetails; 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 /** A standing "always allow" grant: one app may use one method without a
* prompt. Created by choosing "Always allow" on an approval; revoked from * prompt. Created by choosing "Always allow" on an approval; revoked from
* the Signer screen. */ * the Signer screen. */
@ -37,6 +58,8 @@ export interface SignerGrant {
app_pubkey: string; app_pubkey: string;
/** NIP-46 method that runs without prompting (e.g. "sign_event"). */ /** NIP-46 method that runs without prompting (e.g. "sign_event"). */
method: string; 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. */ /** Non-secret snapshot of the NIP-46 remote signer for display. */
@ -75,6 +98,11 @@ export interface Nip46SignerStatus {
pending_approvals: PendingApproval[]; pending_approvals: PendingApproval[];
/** nostrconnect:// pairing token while a QR pairing is in flight. */ /** nostrconnect:// pairing token while a QR pairing is in flight. */
pairing_uri?: string; 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. */ /** Union of all signer statuses. */

View file

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

View file

@ -73,7 +73,25 @@ export function SettingsScreen() {
const result = await updateApply(); const result = await updateApply();
const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)]; const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)];
if (result.restart_required) { if (result.restart_required) {
lines.push('Rebuild and restart the app (cargo build --release, then relaunch) to finish.'); // 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.',
);
}
} }
setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']); setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']);
} catch (err) { } catch (err) {
@ -109,6 +127,8 @@ export function SettingsScreen() {
<option value="impeccable">Impeccable — Light</option> <option value="impeccable">Impeccable — Light</option>
<option value="impeccable-dark">Impeccable — Dark</option> <option value="impeccable-dark">Impeccable — Dark</option>
<option value="cosmic">Cosmic — Stardust</option> <option value="cosmic">Cosmic — Stardust</option>
<option value="workshop">Workshop — Cybernetic</option>
<option value="workshop-dark">Workshop — Dark</option>
</select> </select>
<div <div
aria-hidden="true" aria-hidden="true"

View file

@ -5,6 +5,7 @@ import { Badge } from '../components/Badge';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText'; import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { declaredPermissionRows, formatExpiry } from '../lib/permissions';
import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types'; import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
@ -17,6 +18,7 @@ export function SignerModeScreen() {
nip46Connect, nip46Connect,
nip46PairStart, nip46PairStart,
nip46Disconnect, nip46Disconnect,
nip46CancelPairing,
nip46Approve, nip46Approve,
embeddedSignerApprove, embeddedSignerApprove,
refresh, refresh,
@ -192,17 +194,19 @@ export function SignerModeScreen() {
}; };
}, [pairingUri]); }, [pairingUri]);
// Abort an in-flight pairing (e.g. expired QR) — same teardown as a // Abort an in-flight pairing (e.g. expired QR): cancel the pairing
// disconnect; nothing was persisted yet so it is safe at any point. // 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.
const handlePairCancel = useCallback(async () => { const handlePairCancel = useCallback(async () => {
setPairError(null); setPairError(null);
try { try {
const status = await nip46Disconnect(); const status = await nip46CancelPairing();
setNip46StatusState(status); setNip46StatusState(status);
} catch (err) { } catch (err) {
setPairError(err instanceof Error ? err.message : String(err)); setPairError(err instanceof Error ? err.message : String(err));
} }
}, [nip46Disconnect]); }, [nip46CancelPairing]);
const handleNip46Disconnect = useCallback(async () => { const handleNip46Disconnect = useCallback(async () => {
setError(null); setError(null);
@ -525,6 +529,40 @@ export function SignerModeScreen() {
via {nip46StatusState.connected_relays?.length ?? 0} of{' '} via {nip46StatusState.connected_relays?.length ?? 0} of{' '}
{nip46StatusState.relays?.length ?? 0} relays {nip46StatusState.relays?.length ?? 0} relays
</p> </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 && ( {nip46StatusState.error && (
<Alert tone="error" title="Connection error"> <Alert tone="error" title="Connection error">
{nip46StatusState.error} {nip46StatusState.error}

View file

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

View file

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

View file

@ -426,6 +426,12 @@ html[data-theme='cosmic'] .sidebar-logo img {
filter: invert(1); 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 */ /* Cosmic workspace - Deep Space background with vignette effect */
html[data-theme='cosmic'] .app-shell { html[data-theme='cosmic'] .app-shell {
background: background:
@ -570,6 +576,136 @@ html[data-theme='cosmic'] .empty-state {
border-color: var(--border); 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; box-sizing: border-box;
} }
@ -2242,6 +2378,26 @@ select {
gap: 12px; 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 Motion system
Purposeful motion for feedback, state, and continuity. Purposeful motion for feedback, state, and continuity.

View file

@ -137,7 +137,10 @@ describe('CreateProfileModal', () => {
await screen.findByText(/Waiting for the signer to scan/i); await screen.findByText(/Waiting for the signer to scan/i);
await user.click(screen.getByRole('button', { name: 'Cancel pairing' })); await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(true); // 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( expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }), screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument(); ).toBeInTheDocument();

View file

@ -69,4 +69,40 @@ describe('SignerModeScreen handshake states', () => {
expect(backend.requests.some((r) => r.method === 'nip46_status')).toBe(true), 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,6 +226,14 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
backend.setNip46(next); backend.setNip46(next);
return 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': case 'nip46_approve':
return backend.nip46; return backend.nip46;

View file

@ -0,0 +1,53 @@
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,6 +402,23 @@ struct RawRequest {
params: Vec<String>, 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}` /// `{"id":..,"result":<s>,"error":null}`
fn response_ok(id: &str, result: String) -> String { fn response_ok(id: &str, result: String) -> String {
json!({ "id": id, "result": result, "error": null }).to_string() json!({ "id": id, "result": result, "error": null }).to_string()
@ -754,11 +771,12 @@ 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" // run — unless the user granted this app standing "always allow"
// permission for that method. Everything else is answered immediately. // permission for that method. Everything else is answered immediately.
let response = if requires_approval(&request.method) { let response = if requires_approval(&request.method) {
let kind = request_kind(&request);
let granted = { let granted = {
let guard = app.lock().await; let guard = app.lock().await;
guard guard
.vault .vault
.has_signer_grant(&uri.peer.to_hex(), &request.method) .has_signer_grant(&uri.peer.to_hex(), &request.method, kind)
}; };
if granted { if granted {
approved_response(&keys, &request) approved_response(&keys, &request)

View file

@ -173,6 +173,12 @@ pub enum Request {
}, },
/// Disconnect from the NIP-46 signer. /// Disconnect from the NIP-46 signer.
Nip46Disconnect, 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. /// Get NIP-46 connection status.
Nip46Status, Nip46Status,
/// Approve/reject a pending NIP-46 request. `always = true` additionally /// Approve/reject a pending NIP-46 request. `always = true` additionally
@ -492,6 +498,13 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
"NIP-46 signer not initialized. Set signer mode to nip46 first.", "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?; let status = signer.connect(&uri, label).await?;
Ok(json!(status)) Ok(json!(status))
} }
@ -504,6 +517,12 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
"NIP-46 signer not initialized. Set signer mode to nip46 first.", "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?; let status = signer.start_pairing(label).await?;
Ok(json!(status)) Ok(json!(status))
} }
@ -515,6 +534,14 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
let status = signer.status().await; let status = signer.status().await;
Ok(json!(status)) 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 => { Request::Nip46Status => {
let Some(signer) = ensure_nip46_signer(app).await else { let Some(signer) = ensure_nip46_signer(app).await else {
return Ok(json!({ "connected": false, "error": "Not initialized" })); return Ok(json!({ "connected": false, "error": "Not initialized" }));
@ -654,6 +681,41 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
Ok(json!(items)) 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) // Vault state requests (require lock)
other => { other => {
let mut guard = app.lock().await; let mut guard = app.lock().await;
@ -711,21 +773,8 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!({ "profile": summary, "state": app.state_view() })) Ok(json!({ "profile": summary, "state": app.state_view() }))
} }
Request::SelectProfile { npub } => { // NOTE: SelectProfile is handled in `run` (above), not here — it
profiles::set_active(&mut app.vault, &npub)?; // must drop the App guard before switching the signer session.
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 } => { Request::PublishProfileMetadata { npub } => {
// Route through the profile's Signing source, exactly like // Route through the profile's Signing source, exactly like
// PublishNote: an embedded profile signs locally, a paired // PublishNote: an embedded profile signs locally, a paired

View file

@ -28,6 +28,13 @@ pub enum Theme {
#[serde(rename = "impeccable-dark")] #[serde(rename = "impeccable-dark")]
ImpeccableDark, ImpeccableDark,
Cosmic, 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 { impl Theme {
@ -41,6 +48,8 @@ impl Theme {
"impeccable" => Some(Self::Impeccable), "impeccable" => Some(Self::Impeccable),
"impeccable-dark" => Some(Self::ImpeccableDark), "impeccable-dark" => Some(Self::ImpeccableDark),
"cosmic" => Some(Self::Cosmic), "cosmic" => Some(Self::Cosmic),
"workshop" => Some(Self::Workshop),
"workshop-dark" => Some(Self::WorkshopDark),
_ => None, _ => None,
} }
} }

View file

@ -488,6 +488,148 @@ impl Nip46ClientSigner {
self.disconnect().await 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. /// Re-dial the saved signer sessions after startup/unlock, no scan.
/// ///
/// Amber remembers our *client pubkey* as the identity of an approved /// Amber remembers our *client pubkey* as the identity of an approved
@ -1240,6 +1382,8 @@ impl Nip46ClientSigner {
} else { } else {
None None
}, },
permissions: connection.as_ref().and_then(|c| c.permissions.clone()),
expires_at: connection.as_ref().and_then(|c| c.expires_at),
} }
} }
@ -1289,8 +1433,17 @@ impl Nip46ClientSigner {
(entry, peer) (entry, peer)
}; };
if approved && always && !peer_hex.is_empty() { 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; let mut app = self.app.lock().await;
app.vault.grant_signer_method(&peer_hex, &entry.method)?; app.vault
.grant_signer_method(&peer_hex, &entry.method, &kinds)?;
app.save_vault()?; app.save_vault()?;
} }
let _ = entry.sender.send(if approved { let _ = entry.sender.send(if approved {
@ -1305,7 +1458,22 @@ impl Nip46ClientSigner {
let message = message.into(); let message = message.into();
eprintln!("[nip46] session failed: {message}"); eprintln!("[nip46] session failed: {message}");
eprintln!("[NIP46] session failed: {message}"); eprintln!("[NIP46] session failed: {message}");
pairing_trace(&format!("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() { if let Ok(mut inner) = self.inner.try_lock() {
// First failure wins: the demux loop exits with a generic // First failure wins: the demux loop exits with a generic
// "Connect handshake failed" AFTER the handshake task already // "Connect handshake failed" AFTER the handshake task already
@ -1749,16 +1917,14 @@ impl Nip46ClientSigner {
} }
// Check method permissions // Check method permissions
let event_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() { let allowed = match request.method.as_str() {
"sign_event" => { "sign_event" => self.can_sign_event(event_kind.unwrap_or(0)).await,
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()))
.unwrap_or(0) as u16;
self.can_sign_event(kind).await
}
"nip44_encrypt" => self.can_encrypt().await, "nip44_encrypt" => self.can_encrypt().await,
"nip44_decrypt" => self.can_decrypt().await, "nip44_decrypt" => self.can_decrypt().await,
_ => false, _ => false,
@ -1772,9 +1938,10 @@ impl Nip46ClientSigner {
)); ));
} }
// Standing grant ("always allow") for this peer + method: skip the // Standing grant ("always allow") for this peer + method + kind:
// prompt and run. Grants are per (peer pubkey, method) and revocable // skip the prompt and run. Grants are per (peer pubkey, method,
// from the Signer screen. // kind-set) and revocable from the Signer screen — a kind-1 grant
// never covers a kind-3 request.
{ {
let peer_hex = self let peer_hex = self
.inner .inner
@ -1786,7 +1953,10 @@ impl Nip46ClientSigner {
.unwrap_or_default(); .unwrap_or_default();
if !peer_hex.is_empty() { if !peer_hex.is_empty() {
let app = self.app.lock().await; let app = self.app.lock().await;
if app.vault.has_signer_grant(&peer_hex, &request.method) { if app
.vault
.has_signer_grant(&peer_hex, &request.method, event_kind)
{
drop(app); drop(app);
self.inner.lock().await.phase = Nip46Phase::Connected; self.inner.lock().await.phase = Nip46Phase::Connected;
return self.approved_response(keys, request); return self.approved_response(keys, request);
@ -2328,6 +2498,7 @@ impl Nip46ClientSigner {
// UI polls status and vault, so the row fills in a moment later. // UI polls status and vault, so the row fills in a moment later.
{ {
let app = self.app.clone(); let app = self.app.clone();
let live_enrichment = live_relays(&connection.relays);
tokio::spawn(async move { tokio::spawn(async move {
let relays_for_meta = { let relays_for_meta = {
let app = app.lock().await; let app = app.lock().await;
@ -2358,15 +2529,27 @@ impl Nip46ClientSigner {
meta = Some(found); meta = Some(found);
break; break;
} }
Ok(Ok(None)) => pairing_trace(&format!( Ok(Ok(None)) => {
"auto-name attempt {attempt}: no kind-0 found on any relay" if live_enrichment {
)), pairing_trace(&format!(
Ok(Err(join_err)) => pairing_trace(&format!( "auto-name attempt {attempt}: no kind-0 found on any relay"
"auto-name attempt {attempt}: fetch task panicked: {join_err}" ));
)), }
Err(_) => pairing_trace(&format!( }
"auto-name attempt {attempt}: fetch timed out (75s budget)" Ok(Err(join_err)) => {
)), if live_enrichment {
pairing_trace(&format!(
"auto-name attempt {attempt}: fetch task panicked: {join_err}"
));
}
}
Err(_) => {
if live_enrichment {
pairing_trace(&format!(
"auto-name attempt {attempt}: fetch timed out (75s budget)"
));
}
}
} }
if attempt < 4 { if attempt < 4 {
tokio::time::sleep(Duration::from_secs(20)).await; tokio::time::sleep(Duration::from_secs(20)).await;
@ -2376,10 +2559,12 @@ impl Nip46ClientSigner {
Some(meta) => meta, Some(meta) => meta,
None => return, None => return,
}; };
pairing_trace(&format!( if live_enrichment {
"auto-name: kind-0 fetched (display_name={:?} name={:?})", pairing_trace(&format!(
meta.display_name, meta.name "auto-name: kind-0 fetched (display_name={:?} name={:?})",
)); meta.display_name, meta.name
));
}
let mut app = app.lock().await; let mut app = app.lock().await;
let mut changed = false; let mut changed = false;
if let Some(row) = app if let Some(row) = app

View file

@ -98,6 +98,16 @@ pub struct Nip46Status {
/// `None` once paired or when not pairing. /// `None` once paired or when not pairing.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub pairing_uri: Option<String>, 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. /// A pending approval request from the signer.

View file

@ -72,6 +72,51 @@ fn frontend_dir() -> PathBuf {
source_dir().join("frontend") 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. /// Verify the app is running from its source checkout before shelling out.
fn require_source_checkout() -> Result<(), AppError> { fn require_source_checkout() -> Result<(), AppError> {
let manifest = source_dir().join("Cargo.toml"); let manifest = source_dir().join("Cargo.toml");
@ -88,13 +133,22 @@ fn require_source_checkout() -> Result<(), AppError> {
/// Run a command with a timeout, capturing stdout/stderr separately. /// Run a command with a timeout, capturing stdout/stderr separately.
async fn run(dir: &Path, program: &str, args: &[&str]) -> Result<std::process::Output, AppError> { async fn run(dir: &Path, program: &str, args: &[&str]) -> Result<std::process::Output, AppError> {
let mut command = Command::new(program); let mut command = Command::new(program);
command.current_dir(dir).args(args).kill_on_drop(true); 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);
let future = command.output(); let future = command.output();
match tokio::time::timeout(COMMAND_TIMEOUT, future).await { match tokio::time::timeout(COMMAND_TIMEOUT, future).await {
Ok(Ok(output)) => Ok(output), Ok(Ok(output)) => Ok(output),
Ok(Err(err)) => { Ok(Err(err)) => {
let hint = if err.kind() == std::io::ErrorKind::NotFound { let hint = if err.kind() == std::io::ErrorKind::NotFound {
format!("'{program}' was not found on this computer.") format!(
"'{program}' was not found on this computer. \
Install it (or add it to the app's PATH) and try again."
)
} else { } else {
format!("Could not run '{program}': {err}") format!("Could not run '{program}': {err}")
}; };
@ -355,6 +409,29 @@ pub async fn apply() -> Result<UpdateApplyReport, AppError> {
mod tests { mod tests {
use super::*; 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] #[test]
fn parses_npm_outdated_entries() { fn parses_npm_outdated_entries() {
let json = r#"{ let json = r#"{

View file

@ -149,6 +149,16 @@ pub struct SignerGrant {
/// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`, /// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`,
/// or `nip44_decrypt`. /// or `nip44_decrypt`.
pub method: String, 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. /// Unix timestamp of when the user granted it.
pub created_at: u64, pub created_at: u64,
} }
@ -206,22 +216,44 @@ impl Vault {
} }
/// Whether `app_pubkey` may run gated `method` without a prompt. /// Whether `app_pubkey` may run gated `method` without a prompt.
pub fn has_signer_grant(&self, app_pubkey: &str, method: &str) -> bool { ///
self.signer_grants /// For `sign_event` the grant is kind-scoped: a grant recorded for
.iter() /// specific kinds does NOT cover other kinds — an uncovered kind falls
.any(|g| g.app_pubkey == app_pubkey && g.method == method) /// 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)))
})
} }
/// Record an "always allow" grant (idempotent). /// 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( pub fn grant_signer_method(
&mut self, &mut self,
app_pubkey: &str, app_pubkey: &str,
method: &str, method: &str,
allowed_kinds: &[u16],
) -> Result<(), crate::errors::AppError> { ) -> Result<(), crate::errors::AppError> {
if !self.has_signer_grant(app_pubkey, method) { if !self.has_signer_grant(app_pubkey, method, allowed_kinds.first().copied()) {
self.signer_grants.push(SignerGrant { self.signer_grants.push(SignerGrant {
app_pubkey: app_pubkey.to_string(), app_pubkey: app_pubkey.to_string(),
method: method.to_string(), method: method.to_string(),
allowed_kinds: allowed_kinds.to_vec(),
created_at: crate::vault::unix_timestamp()?, created_at: crate::vault::unix_timestamp()?,
}); });
} }
@ -796,24 +828,53 @@ mod tests {
#[test] #[test]
fn signer_grants_roundtrip_and_revoke() { fn signer_grants_roundtrip_and_revoke() {
let mut vault = Vault::empty(); let mut vault = Vault::empty();
assert!(!vault.has_signer_grant("aa", "sign_event")); assert!(!vault.has_signer_grant("aa", "sign_event", Some(1)));
vault.grant_signer_method("aa", "sign_event").unwrap(); vault.grant_signer_method("aa", "sign_event", &[1]).unwrap();
vault.grant_signer_method("aa", "sign_event").unwrap(); // idempotent vault.grant_signer_method("aa", "sign_event", &[1]).unwrap(); // idempotent
vault.grant_signer_method("bb", "sign_event").unwrap(); vault.grant_signer_method("bb", "sign_event", &[1]).unwrap();
assert_eq!(vault.signer_grants.len(), 2); assert_eq!(vault.signer_grants.len(), 2);
assert!(vault.has_signer_grant("aa", "sign_event")); assert!(vault.has_signer_grant("aa", "sign_event", Some(1)));
assert!(!vault.has_signer_grant("aa", "nip04_decrypt")); assert!(!vault.has_signer_grant("aa", "nip04_decrypt", None));
let json = serde_json::to_string(&vault).unwrap(); let json = serde_json::to_string(&vault).unwrap();
let mut loaded: Vault = serde_json::from_str(&json).unwrap(); let mut loaded: Vault = serde_json::from_str(&json).unwrap();
assert!(loaded.has_signer_grant("aa", "sign_event")); assert!(loaded.has_signer_grant("aa", "sign_event", Some(1)));
assert!(loaded.has_signer_grant("bb", "sign_event")); assert!(loaded.has_signer_grant("bb", "sign_event", Some(1)));
assert!(loaded.revoke_signer_grant("aa", "sign_event")); assert!(loaded.revoke_signer_grant("aa", "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")); assert!(!loaded.has_signer_grant("aa", "sign_event", Some(1)));
assert!(loaded.has_signer_grant("bb", "sign_event")); 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));
} }
static COUNTER: AtomicU32 = AtomicU32::new(0); static COUNTER: AtomicU32 = AtomicU32::new(0);

View file

@ -1324,3 +1324,248 @@ async fn nip46_session_restore_redials_and_refuses_wrong_identity() {
tokio::time::sleep(Duration::from_millis(100)).await; 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();
}