Compare commits
17 commits
704addc773
...
6bff188714
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bff188714 | ||
|
|
dcc701f88b | ||
|
|
4cc04812fe | ||
|
|
ef7b79bfe4 | ||
|
|
de804c8fc5 | ||
|
|
8f1c5e0e18 | ||
|
|
c18f59ad6c | ||
|
|
17fd47c081 | ||
|
|
add956abdd | ||
|
|
15fa331f46 | ||
|
|
118f5d37bd | ||
|
|
fa59b63a17 | ||
|
|
adbc7c2d75 | ||
|
|
98593cb170 | ||
|
|
c89b31aaf1 | ||
|
|
1b4655c07b | ||
|
|
332ab647c9 |
26 changed files with 1969 additions and 534 deletions
|
|
@ -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
297
Cargo.lock
generated
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
721
frontend/package-lock.json
generated
721
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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 }),
|
||||||
|
|
|
||||||
52
frontend/src/lib/permissions.ts
Normal file
52
frontend/src/lib/permissions.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
53
frontend/src/test/permissions.test.ts
Normal file
53
frontend/src/test/permissions.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
79
src/ipc.rs
79
src/ipc.rs
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
// 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}"));
|
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 allowed = match request.method.as_str() {
|
let event_kind = request
|
||||||
"sign_event" => {
|
|
||||||
let kind = request
|
|
||||||
.params
|
.params
|
||||||
.first()
|
.first()
|
||||||
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
||||||
.and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
|
.and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
|
||||||
.unwrap_or(0) as u16;
|
.map(|k| k as u16);
|
||||||
self.can_sign_event(kind).await
|
let allowed = match request.method.as_str() {
|
||||||
}
|
"sign_event" => self.can_sign_event(event_kind.unwrap_or(0)).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)) => {
|
||||||
|
if live_enrichment {
|
||||||
|
pairing_trace(&format!(
|
||||||
"auto-name attempt {attempt}: no kind-0 found on any relay"
|
"auto-name attempt {attempt}: no kind-0 found on any relay"
|
||||||
)),
|
));
|
||||||
Ok(Err(join_err)) => pairing_trace(&format!(
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(join_err)) => {
|
||||||
|
if live_enrichment {
|
||||||
|
pairing_trace(&format!(
|
||||||
"auto-name attempt {attempt}: fetch task panicked: {join_err}"
|
"auto-name attempt {attempt}: fetch task panicked: {join_err}"
|
||||||
)),
|
));
|
||||||
Err(_) => pairing_trace(&format!(
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
if live_enrichment {
|
||||||
|
pairing_trace(&format!(
|
||||||
"auto-name attempt {attempt}: fetch timed out (75s budget)"
|
"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,
|
||||||
};
|
};
|
||||||
|
if live_enrichment {
|
||||||
pairing_trace(&format!(
|
pairing_trace(&format!(
|
||||||
"auto-name: kind-0 fetched (display_name={:?} name={:?})",
|
"auto-name: kind-0 fetched (display_name={:?} name={:?})",
|
||||||
meta.display_name, meta.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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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#"{
|
||||||
|
|
|
||||||
91
src/vault.rs
91
src/vault.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue