mirror of
https://github.com/block/goose.git
synced 2026-05-19 07:54:19 +00:00
add encrypted Nostr session sharing (#8922)
Signed-off-by: callebtc <93376500+callebtc@users.noreply.github.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Signed-off-by: Michael Neale <michael.neale@gmail.com> Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
parent
8c36ba86c6
commit
dbbee1cdbf
17 changed files with 1469 additions and 27 deletions
364
Cargo.lock
generated
364
Cargo.lock
generated
|
|
@ -17,6 +17,16 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
|
|
@ -379,6 +389,37 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-utility"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"gloo-timers",
|
||||
"tokio",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-wsocket"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-socks",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atoi"
|
||||
version = "2.0.0"
|
||||
|
|
@ -388,6 +429,12 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-destructor"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4"
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
|
|
@ -1014,6 +1061,12 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bech32"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
|
||||
|
||||
[[package]]
|
||||
name = "better_scoped_tls"
|
||||
version = "1.0.1"
|
||||
|
|
@ -1315,6 +1368,17 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bip39"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
||||
dependencies = [
|
||||
"bitcoin_hashes",
|
||||
"serde",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
|
|
@ -1336,6 +1400,23 @@ version = "0.10.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-io"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin_hashes"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
|
||||
dependencies = [
|
||||
"bitcoin-io",
|
||||
"hex-conservative",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
|
|
@ -1793,6 +1874,17 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
|
|
@ -1804,6 +1896,19 @@ dependencies = [
|
|||
"rand_core 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
|
|
@ -1826,6 +1931,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
|||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2409,6 +2515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
|
|
@ -4362,6 +4469,18 @@ dependencies = [
|
|||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-timers"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "goose"
|
||||
version = "1.34.0"
|
||||
|
|
@ -4417,6 +4536,8 @@ dependencies = [
|
|||
"minijinja",
|
||||
"mockall",
|
||||
"nanoid",
|
||||
"nostr",
|
||||
"nostr-sdk",
|
||||
"oauth2",
|
||||
"once_cell",
|
||||
"opentelemetry",
|
||||
|
|
@ -4437,6 +4558,7 @@ dependencies = [
|
|||
"reqwest 0.13.2",
|
||||
"rmcp",
|
||||
"rubato",
|
||||
"rustls",
|
||||
"schemars 1.2.1",
|
||||
"sec1",
|
||||
"serde",
|
||||
|
|
@ -4803,6 +4925,15 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hex-conservative"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hipstr"
|
||||
version = "0.6.0"
|
||||
|
|
@ -5405,6 +5536,18 @@ dependencies = [
|
|||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instant"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
|
|
@ -6208,6 +6351,12 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "negentropy"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d"
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
|
|
@ -6296,6 +6445,83 @@ dependencies = [
|
|||
"nom 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nostr"
|
||||
version = "0.44.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bech32",
|
||||
"bip39",
|
||||
"bitcoin_hashes",
|
||||
"cbc",
|
||||
"chacha20 0.9.1",
|
||||
"chacha20poly1305",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"instant",
|
||||
"scrypt",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nostr-database"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1"
|
||||
dependencies = [
|
||||
"lru",
|
||||
"nostr",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nostr-gossip"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6"
|
||||
dependencies = [
|
||||
"nostr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nostr-relay-pool"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"async-wsocket",
|
||||
"atomic-destructor",
|
||||
"hex",
|
||||
"lru",
|
||||
"negentropy",
|
||||
"nostr",
|
||||
"nostr-database",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nostr-sdk"
|
||||
version = "0.44.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"nostr",
|
||||
"nostr-database",
|
||||
"nostr-gossip",
|
||||
"nostr-relay-pool",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
|
@ -6616,6 +6842,12 @@ dependencies = [
|
|||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.4"
|
||||
|
|
@ -6857,6 +7089,17 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
|
|
@ -6890,6 +7133,16 @@ version = "0.2.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pctx_code_execution_runtime"
|
||||
version = "0.2.0"
|
||||
|
|
@ -7257,6 +7510,17 @@ dependencies = [
|
|||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
|
|
@ -7693,7 +7957,7 @@ version = "0.10.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"chacha20 0.10.0",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.0",
|
||||
]
|
||||
|
|
@ -8398,6 +8662,15 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "salsa20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
|
|
@ -8500,6 +8773,18 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scrypt"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
|
||||
dependencies = [
|
||||
"password-hash",
|
||||
"pbkdf2",
|
||||
"salsa20",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sdd"
|
||||
version = "3.0.10"
|
||||
|
|
@ -8520,6 +8805,26 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"secp256k1-sys",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1-sys"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
|
|
@ -10723,6 +11028,18 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-socks"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"futures-util",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
|
|
@ -10734,6 +11051,22 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite 0.26.2",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
|
|
@ -11204,6 +11537,25 @@ version = "0.25.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
|
|
@ -11443,6 +11795,16 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
|
|
|
|||
|
|
@ -543,6 +543,28 @@ enum SessionCommand {
|
|||
default_value = "markdown"
|
||||
)]
|
||||
format: String,
|
||||
|
||||
#[arg(
|
||||
long = "nostr",
|
||||
help = "Publish the JSON session export as an encrypted Nostr event and print a Goose share link"
|
||||
)]
|
||||
nostr: bool,
|
||||
|
||||
#[arg(
|
||||
long = "relay",
|
||||
value_name = "RELAY",
|
||||
help = "Nostr relay URL to publish to (can be specified multiple times)",
|
||||
action = clap::ArgAction::Append
|
||||
)]
|
||||
relays: Vec<String>,
|
||||
},
|
||||
#[command(about = "Import a session from JSON or an encrypted Nostr share link")]
|
||||
Import {
|
||||
#[arg(help = "Path to a JSON session export, or a goose://sessions/nostr share link")]
|
||||
input: String,
|
||||
|
||||
#[arg(long = "nostr", help = "Treat input as an encrypted Nostr share link")]
|
||||
nostr: bool,
|
||||
},
|
||||
#[command(name = "diagnostics")]
|
||||
Diagnostics {
|
||||
|
|
@ -1227,6 +1249,8 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
|
|||
identifier,
|
||||
output,
|
||||
format,
|
||||
nostr,
|
||||
relays,
|
||||
} => {
|
||||
let session_manager = SessionManager::instance();
|
||||
let session_identifier = if let Some(id) = identifier {
|
||||
|
|
@ -1244,8 +1268,17 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
|
|||
}
|
||||
}
|
||||
};
|
||||
crate::commands::session::handle_session_export(session_identifier, output, format)
|
||||
.await?;
|
||||
crate::commands::session::handle_session_export(
|
||||
session_identifier,
|
||||
output,
|
||||
format,
|
||||
nostr,
|
||||
relays,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
SessionCommand::Import { input, nostr } => {
|
||||
crate::commands::session::handle_session_import(input, nostr).await?;
|
||||
}
|
||||
SessionCommand::Diagnostics { identifier, output } => {
|
||||
let session_manager = SessionManager::instance();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ use anyhow::{Context, Result};
|
|||
|
||||
use cliclack::{confirm, multiselect, select};
|
||||
use etcetera::home_dir;
|
||||
use goose::session::{generate_diagnostics, Session, SessionManager};
|
||||
use goose::config::Config;
|
||||
use goose::session::{generate_diagnostics, nostr_share, Session, SessionManager, SessionType};
|
||||
use goose::utils::safe_truncate;
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
|
|
@ -216,6 +217,8 @@ pub async fn handle_session_export(
|
|||
session_id: String,
|
||||
output_path: Option<PathBuf>,
|
||||
format: String,
|
||||
nostr: bool,
|
||||
relays: Vec<String>,
|
||||
) -> Result<()> {
|
||||
let session_manager = SessionManager::instance();
|
||||
let session = match session_manager.get_session(&session_id, true).await {
|
||||
|
|
@ -241,6 +244,29 @@ pub async fn handle_session_export(
|
|||
_ => return Err(anyhow::anyhow!("Unsupported format: {}", format)),
|
||||
};
|
||||
|
||||
if nostr {
|
||||
if format != "json" {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Nostr session sharing only supports --format json"
|
||||
));
|
||||
}
|
||||
if output_path.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Nostr session sharing cannot be combined with --output"
|
||||
));
|
||||
}
|
||||
|
||||
let relays = nostr_share::resolve_relays(relays, Config::global());
|
||||
let share = nostr_share::publish_session_json(&output, relays).await?;
|
||||
println!("Session published to Nostr relays:");
|
||||
for relay in &share.relays {
|
||||
println!("- {}", relay);
|
||||
}
|
||||
println!("\nShare link:");
|
||||
println!("{}", share.deeplink);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(output_path) = output_path {
|
||||
fs::write(&output_path, output).with_context(|| {
|
||||
format!("Failed to write to output file: {}", output_path.display())
|
||||
|
|
@ -253,6 +279,25 @@ pub async fn handle_session_export(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_session_import(input: String, nostr: bool) -> Result<()> {
|
||||
let json = if nostr || input.starts_with("goose://sessions/nostr") {
|
||||
nostr_share::import_session_json_from_deeplink(&input).await?
|
||||
} else {
|
||||
fs::read_to_string(&input)
|
||||
.with_context(|| format!("Failed to read session import file: {input}"))?
|
||||
};
|
||||
|
||||
let session_manager = SessionManager::instance();
|
||||
let session = session_manager
|
||||
.import_session(&json, Some(SessionType::User))
|
||||
.await?;
|
||||
|
||||
println!("Session imported:");
|
||||
println!("{} - {}", session.id, session.name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_diagnostics(session_id: &str, output_path: Option<PathBuf>) -> Result<()> {
|
||||
println!(
|
||||
"Generating diagnostics bundle for session '{}'...",
|
||||
|
|
|
|||
|
|
@ -443,6 +443,8 @@ derive_utoipa!(IconTheme as IconThemeSchema);
|
|||
super::routes::session::delete_session,
|
||||
super::routes::session::export_session,
|
||||
super::routes::session::import_session,
|
||||
super::routes::session::share_session_nostr,
|
||||
super::routes::session::import_session_nostr,
|
||||
super::routes::session::update_session_user_recipe_values,
|
||||
super::routes::session::fork_session,
|
||||
super::routes::session::get_session_extensions,
|
||||
|
|
@ -512,6 +514,9 @@ derive_utoipa!(IconTheme as IconThemeSchema);
|
|||
super::routes::session_events::SessionReplyResponse,
|
||||
super::routes::session_events::CancelRequest,
|
||||
super::routes::session::ImportSessionRequest,
|
||||
super::routes::session::ShareSessionNostrRequest,
|
||||
super::routes::session::ShareSessionNostrResponse,
|
||||
super::routes::session::ImportSessionNostrRequest,
|
||||
super::routes::session::SessionListResponse,
|
||||
super::routes::session::UpdateSessionNameRequest,
|
||||
super::routes::session::UpdateSessionUserRecipeValuesRequest,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use axum::{
|
|||
};
|
||||
use goose::agents::ExtensionConfig;
|
||||
use goose::recipe::Recipe;
|
||||
use goose::session::nostr_share;
|
||||
use goose::session::session_manager::{SessionInsights, SessionType};
|
||||
use goose::session::{EnabledExtensionsState, Session};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -50,6 +51,28 @@ pub struct ImportSessionRequest {
|
|||
json: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShareSessionNostrRequest {
|
||||
#[serde(default)]
|
||||
relays: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShareSessionNostrResponse {
|
||||
deeplink: String,
|
||||
nevent: String,
|
||||
event_id: String,
|
||||
relays: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportSessionNostrRequest {
|
||||
deeplink: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ForkRequest {
|
||||
|
|
@ -364,6 +387,79 @@ async fn import_session(
|
|||
Ok(Json(session))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sessions/{session_id}/share/nostr",
|
||||
request_body = ShareSessionNostrRequest,
|
||||
params(
|
||||
("session_id" = String, Path, description = "Unique identifier for the session")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Session shared to Nostr successfully", body = ShareSessionNostrResponse),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 404, description = "Session not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Session Management"
|
||||
)]
|
||||
async fn share_session_nostr(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(session_id): Path<String>,
|
||||
Json(request): Json<ShareSessionNostrRequest>,
|
||||
) -> Result<Json<ShareSessionNostrResponse>, StatusCode> {
|
||||
let exported = state
|
||||
.session_manager()
|
||||
.export_session(&session_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let relays = nostr_share::resolve_relays(request.relays, goose::config::Config::global());
|
||||
let share = nostr_share::publish_session_json(&exported, relays)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(ShareSessionNostrResponse {
|
||||
deeplink: share.deeplink,
|
||||
nevent: share.nevent,
|
||||
event_id: share.event_id,
|
||||
relays: share.relays,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sessions/import/nostr",
|
||||
request_body = ImportSessionNostrRequest,
|
||||
responses(
|
||||
(status = 200, description = "Nostr shared session imported successfully", body = Session),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 400, description = "Bad request - Invalid Nostr share link"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Session Management"
|
||||
)]
|
||||
async fn import_session_nostr(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<ImportSessionNostrRequest>,
|
||||
) -> Result<Json<Session>, StatusCode> {
|
||||
let json = nostr_share::import_session_json_from_deeplink(&request.deeplink)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let session = state
|
||||
.session_manager()
|
||||
.import_session(&json, Some(SessionType::User))
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
Ok(Json(session))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sessions/{session_id}/fork",
|
||||
|
|
@ -505,10 +601,18 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
|||
.route("/sessions/{session_id}", get(get_session))
|
||||
.route("/sessions/{session_id}", delete(delete_session))
|
||||
.route("/sessions/{session_id}/export", get(export_session))
|
||||
.route(
|
||||
"/sessions/{session_id}/share/nostr",
|
||||
post(share_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/sessions/import",
|
||||
post(import_session).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/sessions/import/nostr",
|
||||
post(import_session_nostr).layer(DefaultBodyLimit::max(25 * 1024 * 1024)),
|
||||
)
|
||||
.route("/sessions/insights", get(get_session_insights))
|
||||
.route("/sessions/{session_id}/name", put(update_session_name))
|
||||
.route(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ aws-providers = [
|
|||
cuda = ["local-inference", "candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"]
|
||||
vulkan = ["local-inference", "llama-cpp-2/vulkan"]
|
||||
rustls-tls = [
|
||||
"dep:rustls",
|
||||
"reqwest/rustls",
|
||||
"rmcp/reqwest",
|
||||
"sqlx/runtime-tokio-rustls",
|
||||
|
|
@ -59,6 +60,7 @@ native-tls = [
|
|||
"oauth2/native-tls",
|
||||
]
|
||||
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
|
@ -194,6 +196,9 @@ goose-acp-macros = { path = "../goose-acp-macros" }
|
|||
tower-http = { workspace = true, features = ["cors"] }
|
||||
http-body-util = "0.1.3"
|
||||
process-wrap = { version = "9.1.0", features = ["std"] }
|
||||
nostr = { version = "0.44.2", features = ["nip44"] }
|
||||
nostr-sdk = { version = "0.44.1", features = ["nip44"] }
|
||||
rustls = { version = "0.23", features = ["aws_lc_rs"], optional = true }
|
||||
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ mod chat_history_search;
|
|||
mod diagnostics;
|
||||
pub mod extension_data;
|
||||
mod legacy;
|
||||
pub mod nostr_share;
|
||||
pub mod session_manager;
|
||||
|
||||
pub use diagnostics::{
|
||||
|
|
|
|||
370
crates/goose/src/session/nostr_share.rs
Normal file
370
crates/goose/src/session/nostr_share.rs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use nostr::nips::nip19::{FromBech32, Nip19Event, ToBech32};
|
||||
use nostr::nips::nip44;
|
||||
use nostr::prelude::*;
|
||||
use nostr_sdk::Client;
|
||||
|
||||
use crate::config::{Config, ConfigError};
|
||||
|
||||
pub const EVENT_KIND: u16 = 30278;
|
||||
pub const CONFIG_RELAYS_KEY: &str = "GOOSE_NOSTR_RELAYS";
|
||||
|
||||
const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NostrShare {
|
||||
pub deeplink: String,
|
||||
pub nevent: String,
|
||||
pub event_id: String,
|
||||
pub relays: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedShareLink {
|
||||
pub nevent: String,
|
||||
pub decryption_key: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait NostrPublisher {
|
||||
async fn publish(&self, event: Event, relays: &[String]) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait NostrFetcher {
|
||||
async fn fetch(&self, event_id: EventId, relays: &[String]) -> Result<Event>;
|
||||
}
|
||||
|
||||
pub struct LiveNostrClient;
|
||||
|
||||
#[async_trait]
|
||||
impl NostrPublisher for LiveNostrClient {
|
||||
async fn publish(&self, event: Event, relays: &[String]) -> Result<()> {
|
||||
install_rustls_crypto_provider();
|
||||
let client = Client::default();
|
||||
for relay in relays {
|
||||
client
|
||||
.add_relay(relay)
|
||||
.await
|
||||
.with_context(|| format!("Failed to add relay {relay}"))?;
|
||||
}
|
||||
|
||||
client.try_connect(Duration::from_secs(8)).await;
|
||||
let output = client
|
||||
.send_event_to(relays.iter().map(String::as_str), &event)
|
||||
.await
|
||||
.context("Failed to publish session to Nostr relays")?;
|
||||
client.shutdown().await;
|
||||
|
||||
if output.success.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to publish session to any Nostr relay: {:?}",
|
||||
output.failed
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NostrFetcher for LiveNostrClient {
|
||||
async fn fetch(&self, event_id: EventId, relays: &[String]) -> Result<Event> {
|
||||
install_rustls_crypto_provider();
|
||||
let client = Client::default();
|
||||
for relay in relays {
|
||||
client
|
||||
.add_relay(relay)
|
||||
.await
|
||||
.with_context(|| format!("Failed to add relay {relay}"))?;
|
||||
}
|
||||
|
||||
client.try_connect(Duration::from_secs(8)).await;
|
||||
let filter = Filter::new()
|
||||
.id(event_id)
|
||||
.kind(Kind::Custom(EVENT_KIND))
|
||||
.limit(1);
|
||||
let events = client
|
||||
.fetch_events_from(
|
||||
relays.iter().map(String::as_str),
|
||||
filter,
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await
|
||||
.context("Failed to fetch shared session from Nostr relays")?;
|
||||
client.shutdown().await;
|
||||
|
||||
events
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("Shared session event not found"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rustls-tls")]
|
||||
fn install_rustls_crypto_provider() {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "rustls-tls"))]
|
||||
fn install_rustls_crypto_provider() {}
|
||||
|
||||
pub fn default_relays() -> Vec<String> {
|
||||
DEFAULT_RELAYS
|
||||
.iter()
|
||||
.map(|relay| relay.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn relays_from_config(config: &Config) -> Vec<String> {
|
||||
match config.get_param::<Vec<String>>(CONFIG_RELAYS_KEY) {
|
||||
Ok(relays) if !relays.is_empty() => normalize_relays(relays),
|
||||
Err(ConfigError::NotFound(_)) => default_relays(),
|
||||
_ => default_relays(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_relays(cli_relays: Vec<String>, config: &Config) -> Vec<String> {
|
||||
if cli_relays.is_empty() {
|
||||
relays_from_config(config)
|
||||
} else {
|
||||
normalize_relays(cli_relays)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn publish_session_json(session_json: &str, relays: Vec<String>) -> Result<NostrShare> {
|
||||
publish_session_json_with(session_json, relays, &LiveNostrClient).await
|
||||
}
|
||||
|
||||
pub async fn publish_session_json_with<P>(
|
||||
session_json: &str,
|
||||
relays: Vec<String>,
|
||||
publisher: &P,
|
||||
) -> Result<NostrShare>
|
||||
where
|
||||
P: NostrPublisher + Sync,
|
||||
{
|
||||
let relays = normalize_relays(relays);
|
||||
if relays.is_empty() {
|
||||
return Err(anyhow!("At least one Nostr relay is required"));
|
||||
}
|
||||
let relay_urls = relays
|
||||
.iter()
|
||||
.map(|relay| RelayUrl::parse(relay))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let publish_keys = Keys::generate();
|
||||
let encryption_key = SecretKey::generate();
|
||||
let encryption_keys = Keys::new(encryption_key.clone());
|
||||
let encrypted = nip44::encrypt(
|
||||
&encryption_key,
|
||||
&encryption_keys.public_key(),
|
||||
session_json,
|
||||
nip44::Version::V2,
|
||||
)?;
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(EVENT_KIND), encrypted)
|
||||
.tag(Tag::identifier(format!(
|
||||
"goose-session-{}",
|
||||
uuid::Uuid::now_v7()
|
||||
)))
|
||||
.tag(Tag::parse(["client", "goose"])?)
|
||||
.sign_with_keys(&publish_keys)?;
|
||||
|
||||
publisher.publish(event.clone(), &relays).await?;
|
||||
|
||||
let nevent = Nip19Event::new(event.id)
|
||||
.author(event.pubkey)
|
||||
.kind(Kind::Custom(EVENT_KIND))
|
||||
.relays(relay_urls)
|
||||
.to_bech32()?;
|
||||
let decryption_key = encryption_key.to_secret_hex();
|
||||
let deeplink = build_deeplink(&nevent, &decryption_key);
|
||||
|
||||
Ok(NostrShare {
|
||||
deeplink,
|
||||
nevent,
|
||||
event_id: event.id.to_hex(),
|
||||
relays,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn import_session_json_from_deeplink(deeplink: &str) -> Result<String> {
|
||||
import_session_json_from_deeplink_with(deeplink, &LiveNostrClient).await
|
||||
}
|
||||
|
||||
pub async fn import_session_json_from_deeplink_with<F>(
|
||||
deeplink: &str,
|
||||
fetcher: &F,
|
||||
) -> Result<String>
|
||||
where
|
||||
F: NostrFetcher + Sync,
|
||||
{
|
||||
let ParsedShareLink {
|
||||
nevent,
|
||||
decryption_key,
|
||||
} = parse_deeplink(deeplink)?;
|
||||
let event_ref = Nip19Event::from_bech32(&nevent)?;
|
||||
let relays = event_ref
|
||||
.relays
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if relays.is_empty() {
|
||||
return Err(anyhow!("Shared session link does not include any relays"));
|
||||
}
|
||||
|
||||
let event = fetcher.fetch(event_ref.event_id, &relays).await?;
|
||||
if event.kind != Kind::Custom(EVENT_KIND) {
|
||||
return Err(anyhow!(
|
||||
"Unexpected Nostr event kind: {}",
|
||||
u16::from(event.kind)
|
||||
));
|
||||
}
|
||||
|
||||
let secret_key = SecretKey::parse(&decryption_key)?;
|
||||
let encryption_keys = Keys::new(secret_key.clone());
|
||||
nip44::decrypt(&secret_key, &encryption_keys.public_key(), event.content).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn build_deeplink(nevent: &str, decryption_key: &str) -> String {
|
||||
format!(
|
||||
"goose://sessions/nostr?nevent={}&key={}",
|
||||
urlencoding::encode(nevent),
|
||||
urlencoding::encode(decryption_key)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_deeplink(deeplink: &str) -> Result<ParsedShareLink> {
|
||||
let parsed = url::Url::parse(deeplink).context("Invalid Goose session share link")?;
|
||||
if parsed.scheme() != "goose"
|
||||
|| parsed.host_str() != Some("sessions")
|
||||
|| parsed.path() != "/nostr"
|
||||
{
|
||||
return Err(anyhow!("Invalid Goose Nostr session share link"));
|
||||
}
|
||||
|
||||
let nevent = parsed
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "nevent").then(|| value.into_owned()))
|
||||
.ok_or_else(|| anyhow!("Missing nevent parameter"))?;
|
||||
let decryption_key = parsed
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "key").then(|| value.into_owned()))
|
||||
.ok_or_else(|| anyhow!("Missing decryption key parameter"))?;
|
||||
|
||||
Ok(ParsedShareLink {
|
||||
nevent,
|
||||
decryption_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_relays(relays: Vec<String>) -> Vec<String> {
|
||||
let mut normalized = Vec::new();
|
||||
for relay in relays {
|
||||
let relay = relay.trim();
|
||||
if relay.is_empty() || normalized.iter().any(|existing| existing == relay) {
|
||||
continue;
|
||||
}
|
||||
normalized.push(relay.to_string());
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct RecordingPublisher {
|
||||
event: Arc<Mutex<Option<Event>>>,
|
||||
relays: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NostrPublisher for RecordingPublisher {
|
||||
async fn publish(&self, event: Event, relays: &[String]) -> Result<()> {
|
||||
*self.event.lock().unwrap() = Some(event);
|
||||
*self.relays.lock().unwrap() = relays.to_vec();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticFetcher(Event);
|
||||
|
||||
#[async_trait]
|
||||
impl NostrFetcher for StaticFetcher {
|
||||
async fn fetch(&self, _event_id: EventId, _relays: &[String]) -> Result<Event> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_builds_deeplink_and_encrypted_kind_30278_event() {
|
||||
let event = Arc::new(Mutex::new(None));
|
||||
let relays = Arc::new(Mutex::new(Vec::new()));
|
||||
let publisher = RecordingPublisher {
|
||||
event: event.clone(),
|
||||
relays: relays.clone(),
|
||||
};
|
||||
|
||||
let share = publish_session_json_with(
|
||||
r#"{"id":"session-id","conversation":{"messages":[]}}"#,
|
||||
vec!["wss://relay.example".to_string()],
|
||||
&publisher,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(share.deeplink.starts_with("goose://sessions/nostr?"));
|
||||
assert!(share.nevent.starts_with("nevent1"));
|
||||
assert_eq!(share.relays, vec!["wss://relay.example"]);
|
||||
assert_eq!(*relays.lock().unwrap(), vec!["wss://relay.example"]);
|
||||
|
||||
let event = event.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(event.kind, Kind::Custom(EVENT_KIND));
|
||||
assert_ne!(
|
||||
event.content,
|
||||
r#"{"id":"session-id","conversation":{"messages":[]}}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_and_import_round_trips_session_json() {
|
||||
let event = Arc::new(Mutex::new(None));
|
||||
let publisher = RecordingPublisher {
|
||||
event: event.clone(),
|
||||
relays: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let json = r#"{"id":"session-id","name":"shared"}"#;
|
||||
|
||||
let share =
|
||||
publish_session_json_with(json, vec!["wss://relay.example".to_string()], &publisher)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fetched_event = event.lock().unwrap().clone().unwrap();
|
||||
let imported =
|
||||
import_session_json_from_deeplink_with(&share.deeplink, &StaticFetcher(fetched_event))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(imported, json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_deeplink() {
|
||||
let parsed = parse_deeplink("goose://sessions/nostr?nevent=abc&key=def").unwrap();
|
||||
assert_eq!(parsed.nevent, "abc");
|
||||
assert_eq!(parsed.decryption_key, "def");
|
||||
}
|
||||
}
|
||||
|
|
@ -3145,6 +3145,50 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/sessions/import/nostr": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Session Management"
|
||||
],
|
||||
"operationId": "import_session_nostr",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ImportSessionNostrRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Nostr shared session imported successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Session"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request - Invalid Nostr share link"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Invalid or missing API key"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/sessions/insights": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -3656,6 +3700,61 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/share/nostr": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Session Management"
|
||||
],
|
||||
"operationId": "share_session_nostr",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "session_id",
|
||||
"in": "path",
|
||||
"description": "Unique identifier for the session",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ShareSessionNostrRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Session shared to Nostr successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ShareSessionNostrResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Invalid or missing API key"
|
||||
},
|
||||
"404": {
|
||||
"description": "Session not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/sessions/{session_id}/user_recipe_values": {
|
||||
"put": {
|
||||
"tags": [
|
||||
|
|
@ -5625,6 +5724,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"ImportSessionNostrRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deeplink"
|
||||
],
|
||||
"properties": {
|
||||
"deeplink": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImportSessionRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -8105,6 +8215,43 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"ShareSessionNostrRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"relays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ShareSessionNostrResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deeplink",
|
||||
"nevent",
|
||||
"eventId",
|
||||
"relays"
|
||||
],
|
||||
"properties": {
|
||||
"deeplink": {
|
||||
"type": "string"
|
||||
},
|
||||
"eventId": {
|
||||
"type": "string"
|
||||
},
|
||||
"nevent": {
|
||||
"type": "string"
|
||||
},
|
||||
"relays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SlashCommand": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
useLocation,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import { openSharedSessionFromDeepLink } from './sessionLinks';
|
||||
import { openSharedSessionFromDeepLink, importNostrSessionFromDeepLink } from './sessionLinks';
|
||||
import { type SharedSessionDetails } from './sharedSessions';
|
||||
import { ErrorUI } from './components/ErrorBoundary';
|
||||
import { ExtensionInstallModal } from './components/ExtensionInstallModal';
|
||||
|
|
@ -428,6 +428,11 @@ export function AppInner() {
|
|||
setIsLoadingSharedSession(true);
|
||||
setSharedSessionError(null);
|
||||
try {
|
||||
if (link.startsWith('goose://sessions/nostr')) {
|
||||
await importNostrSessionFromDeepLink(link);
|
||||
navigate('/sessions');
|
||||
return;
|
||||
}
|
||||
await openSharedSessionFromDeepLink(link, (_view: View, options?: ViewOptions) => {
|
||||
navigate('/shared-session', { state: options });
|
||||
});
|
||||
|
|
@ -438,14 +443,18 @@ export function AppInner() {
|
|||
action: 'open_shared_session',
|
||||
recoverable: true,
|
||||
});
|
||||
// Navigate to shared session view with error
|
||||
const shareToken = link.replace('goose://sessions/', '');
|
||||
const options = {
|
||||
sessionDetails: null,
|
||||
error: errorMessage(error, 'Unknown error'),
|
||||
shareToken,
|
||||
};
|
||||
navigate('/shared-session', { state: options });
|
||||
if (link.startsWith('goose://sessions/nostr')) {
|
||||
toast.error(`Failed to import Nostr session: ${errorMessage(error, 'Unknown error')}`);
|
||||
navigate('/sessions');
|
||||
} else {
|
||||
const shareToken = link.replace('goose://sessions/', '');
|
||||
const options = {
|
||||
sessionDetails: null,
|
||||
error: errorMessage(error, 'Unknown error'),
|
||||
shareToken,
|
||||
};
|
||||
navigate('/shared-session', { state: options });
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingSharedSession(false);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -593,6 +593,10 @@ export type ImportAppResponse = {
|
|||
name: string;
|
||||
};
|
||||
|
||||
export type ImportSessionNostrRequest = {
|
||||
deeplink: string;
|
||||
};
|
||||
|
||||
export type ImportSessionRequest = {
|
||||
json: string;
|
||||
};
|
||||
|
|
@ -1368,6 +1372,17 @@ export type SetupResponse = {
|
|||
success: boolean;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrRequest = {
|
||||
relays?: Array<string>;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrResponse = {
|
||||
deeplink: string;
|
||||
eventId: string;
|
||||
nevent: string;
|
||||
relays: Array<string>;
|
||||
};
|
||||
|
||||
export type SlashCommand = {
|
||||
command: string;
|
||||
command_type: CommandType;
|
||||
|
|
@ -4091,6 +4106,37 @@ export type ImportSessionResponses = {
|
|||
|
||||
export type ImportSessionResponse = ImportSessionResponses[keyof ImportSessionResponses];
|
||||
|
||||
export type ImportSessionNostrData = {
|
||||
body: ImportSessionNostrRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/sessions/import/nostr';
|
||||
};
|
||||
|
||||
export type ImportSessionNostrErrors = {
|
||||
/**
|
||||
* Bad request - Invalid Nostr share link
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Unauthorized - Invalid or missing API key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type ImportSessionNostrResponses = {
|
||||
/**
|
||||
* Nostr shared session imported successfully
|
||||
*/
|
||||
200: Session;
|
||||
};
|
||||
|
||||
export type ImportSessionNostrResponse = ImportSessionNostrResponses[keyof ImportSessionNostrResponses];
|
||||
|
||||
export type GetSessionInsightsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
|
@ -4473,6 +4519,42 @@ export type UpdateSessionNameResponses = {
|
|||
200: unknown;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrData = {
|
||||
body: ShareSessionNostrRequest;
|
||||
path: {
|
||||
/**
|
||||
* Unique identifier for the session
|
||||
*/
|
||||
session_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/sessions/{session_id}/share/nostr';
|
||||
};
|
||||
|
||||
export type ShareSessionNostrErrors = {
|
||||
/**
|
||||
* Unauthorized - Invalid or missing API key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Session not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrResponses = {
|
||||
/**
|
||||
* Session shared to Nostr successfully
|
||||
*/
|
||||
200: ShareSessionNostrResponse;
|
||||
};
|
||||
|
||||
export type ShareSessionNostrResponse2 = ShareSessionNostrResponses[keyof ShareSessionNostrResponses];
|
||||
|
||||
export type UpdateSessionUserRecipeValuesData = {
|
||||
body: UpdateSessionUserRecipeValuesRequest;
|
||||
path: {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
Trash2,
|
||||
Download,
|
||||
Upload,
|
||||
Share2,
|
||||
LoaderCircle,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Puzzle,
|
||||
|
|
@ -28,18 +30,29 @@ import { Skeleton } from '../ui/skeleton';
|
|||
import { toast } from 'react-toastify';
|
||||
import { ConfirmationModal } from '../ui/ConfirmationModal';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import {
|
||||
deleteSession,
|
||||
exportSession,
|
||||
forkSession,
|
||||
importSession,
|
||||
importSessionNostr,
|
||||
listSessions,
|
||||
searchSessions,
|
||||
shareSessionNostr,
|
||||
Session,
|
||||
updateSessionName,
|
||||
ExtensionConfig,
|
||||
ExtensionData,
|
||||
} from '../../api';
|
||||
import { getTunnelStatus } from '../../api/sdk.gen';
|
||||
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
|
||||
import { getSearchShortcutText } from '../../utils/keyboardShortcuts';
|
||||
import { shouldShowNewChatTitle } from '../../sessions';
|
||||
|
|
@ -55,6 +68,11 @@ const i18n = defineMessages({
|
|||
sessionUpdateFailed: { id: 'sessions.toast.updateFailed', defaultMessage: 'Failed to update session description: {error}' },
|
||||
chatHistory: { id: 'sessions.chatHistory', defaultMessage: 'Chat history' },
|
||||
importSession: { id: 'sessions.import', defaultMessage: 'Import Session' },
|
||||
importNostrSession: { id: 'sessions.importNostr', defaultMessage: 'Import Link' },
|
||||
importNostrTitle: { id: 'sessions.importNostr.title', defaultMessage: 'Import Nostr Session' },
|
||||
importNostrDesc: { id: 'sessions.importNostr.description', defaultMessage: 'Paste a Goose Nostr share link to fetch, decrypt, and import the session.' },
|
||||
importNostrPlaceholder: { id: 'sessions.importNostr.placeholder', defaultMessage: 'goose://sessions/nostr?nevent=...&key=...' },
|
||||
importing: { id: 'sessions.importing', defaultMessage: 'Importing...' },
|
||||
chatHistoryDesc: { id: 'sessions.chatHistoryDesc', defaultMessage: 'View and search your past conversations with Goose. {shortcut} to search.' },
|
||||
searchPlaceholder: { id: 'sessions.searchPlaceholder', defaultMessage: 'Search history...' },
|
||||
errorLoading: { id: 'sessions.error.loading', defaultMessage: 'Error Loading Sessions' },
|
||||
|
|
@ -73,12 +91,19 @@ const i18n = defineMessages({
|
|||
importSuccess: { id: 'sessions.toast.imported', defaultMessage: 'Session imported successfully' },
|
||||
importFailed: { id: 'sessions.toast.importFailed', defaultMessage: 'Failed to import session: {error}' },
|
||||
exportSuccess: { id: 'sessions.toast.exported', defaultMessage: 'Session exported successfully' },
|
||||
shareNostrSuccess: { id: 'sessions.toast.shareNostr', defaultMessage: 'Encrypted Nostr share link created' },
|
||||
shareNostrFailed: { id: 'sessions.toast.shareNostrFailed', defaultMessage: 'Failed to create Nostr share link: {error}' },
|
||||
copied: { id: 'sessions.toast.copied', defaultMessage: 'Copied to clipboard' },
|
||||
openInNewWindow: { id: 'sessions.action.openNewWindow', defaultMessage: 'Open in new window' },
|
||||
editSessionName: { id: 'sessions.action.editName', defaultMessage: 'Edit session name' },
|
||||
duplicateSession: { id: 'sessions.action.duplicate', defaultMessage: 'Duplicate session' },
|
||||
deleteSession: { id: 'sessions.action.delete', defaultMessage: 'Delete session' },
|
||||
exportSession: { id: 'sessions.action.export', defaultMessage: 'Export session' },
|
||||
shareNostrSession: { id: 'sessions.action.shareNostr', defaultMessage: 'Share encrypted Nostr link' },
|
||||
extensions: { id: 'sessions.extensions', defaultMessage: 'Extensions:' },
|
||||
shareNostrTitle: { id: 'sessions.shareNostr.title', defaultMessage: 'Encrypted Nostr Share Link' },
|
||||
shareNostrDesc: { id: 'sessions.shareNostr.description', defaultMessage: 'Anyone with this link can fetch and decrypt the session. Treat it like a secret.' },
|
||||
close: { id: 'sessions.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
function getSessionExtensionNames(extensionData: ExtensionData): string[] {
|
||||
|
|
@ -265,6 +290,14 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<Session | null>(null);
|
||||
|
||||
const [showImportLinkModal, setShowImportLinkModal] = useState(false);
|
||||
const [nostrImportLink, setNostrImportLink] = useState('');
|
||||
const [isImportingNostr, setIsImportingNostr] = useState(false);
|
||||
const [shareLink, setShareLink] = useState('');
|
||||
const [showShareLinkModal, setShowShareLinkModal] = useState(false);
|
||||
const [sharingSessionId, setSharingSessionId] = useState<string | null>(null);
|
||||
const [nostrEnabled, setNostrEnabled] = useState(true);
|
||||
|
||||
// Search state for debouncing
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
|
|
@ -338,6 +371,17 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// Hide Nostr sharing when tunnel is disabled (restricted/enterprise bundles)
|
||||
useEffect(() => {
|
||||
getTunnelStatus()
|
||||
.then(({ data }) => {
|
||||
if (data?.state === 'disabled') {
|
||||
setNostrEnabled(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Timing logic to prevent flicker between skeleton and content on initial load
|
||||
useEffect(() => {
|
||||
if (!isLoading && showSkeleton) {
|
||||
|
|
@ -542,10 +586,62 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
toast.success(intl.formatMessage(i18n.exportSuccess));
|
||||
}, [intl]);
|
||||
|
||||
const handleShareSessionNostr = useCallback(
|
||||
async (session: Session, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSharingSessionId(session.id);
|
||||
try {
|
||||
const response = await shareSessionNostr({
|
||||
path: { session_id: session.id },
|
||||
body: {},
|
||||
throwOnError: true,
|
||||
});
|
||||
setShareLink(response.data.deeplink);
|
||||
setShowShareLinkModal(true);
|
||||
toast.success(intl.formatMessage(i18n.shareNostrSuccess));
|
||||
} catch (error) {
|
||||
toast.error(intl.formatMessage(i18n.shareNostrFailed, { error: errorMessage(error, 'Unknown error') }));
|
||||
} finally {
|
||||
setSharingSessionId(null);
|
||||
}
|
||||
},
|
||||
[intl]
|
||||
);
|
||||
|
||||
const handleImportClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleImportNostrLink = useCallback(async () => {
|
||||
const deeplink = nostrImportLink.trim();
|
||||
if (!deeplink) return;
|
||||
|
||||
setIsImportingNostr(true);
|
||||
try {
|
||||
await importSessionNostr({
|
||||
body: { deeplink },
|
||||
throwOnError: true,
|
||||
});
|
||||
setNostrImportLink('');
|
||||
setShowImportLinkModal(false);
|
||||
toast.success(intl.formatMessage(i18n.importSuccess));
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
toast.error(intl.formatMessage(i18n.importFailed, { error: errorMessage(error, 'Unknown error') }));
|
||||
} finally {
|
||||
setIsImportingNostr(false);
|
||||
}
|
||||
}, [intl, loadSessions, nostrImportLink]);
|
||||
|
||||
const handleCopyShareLink = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
toast.success(intl.formatMessage(i18n.copied));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to copy: ${errorMessage(error, 'Unknown error')}`);
|
||||
}
|
||||
}, [intl, shareLink]);
|
||||
|
||||
const handleImportSession = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
|
@ -586,14 +682,18 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
onExportClick,
|
||||
onShareClick,
|
||||
onOpenInNewWindow,
|
||||
isSharing,
|
||||
}: {
|
||||
session: Session;
|
||||
onEditClick: (session: Session) => void;
|
||||
onDuplicateClick: (session: Session) => void;
|
||||
onDeleteClick: (session: Session) => void;
|
||||
onExportClick: (session: Session, e: React.MouseEvent) => void;
|
||||
onShareClick: (session: Session, e: React.MouseEvent) => void;
|
||||
onOpenInNewWindow: (session: Session, e: React.MouseEvent) => void;
|
||||
isSharing: boolean;
|
||||
}) {
|
||||
const handleEditClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -630,6 +730,13 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
[onExportClick, session]
|
||||
);
|
||||
|
||||
const handleShareClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
onShareClick(session, e);
|
||||
},
|
||||
[onShareClick, session]
|
||||
);
|
||||
|
||||
const handleOpenInNewWindowClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
onOpenInNewWindow(session, e);
|
||||
|
|
@ -736,6 +843,20 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
>
|
||||
<Download className="w-3 h-3 text-text-secondary hover:text-text-primary" />
|
||||
</button>
|
||||
{nostrEnabled && (
|
||||
<button
|
||||
onClick={handleShareClick}
|
||||
disabled={isSharing}
|
||||
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer disabled:cursor-wait disabled:opacity-60"
|
||||
title={intl.formatMessage(i18n.shareNostrSession)}
|
||||
>
|
||||
{isSharing ? (
|
||||
<LoaderCircle className="w-3 h-3 text-text-secondary animate-spin" />
|
||||
) : (
|
||||
<Share2 className="w-3 h-3 text-text-secondary hover:text-text-primary" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -828,7 +949,9 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
onDuplicateClick={handleDuplicateSession}
|
||||
onDeleteClick={handleDeleteSession}
|
||||
onExportClick={handleExportSession}
|
||||
onShareClick={handleShareSessionNostr}
|
||||
onOpenInNewWindow={handleOpenInNewWindow}
|
||||
isSharing={sharingSessionId === session.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -855,15 +978,28 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
<div className="flex flex-col page-transition">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">{intl.formatMessage(i18n.chatHistory)}</h1>
|
||||
<Button
|
||||
onClick={handleImportClick}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{intl.formatMessage(i18n.importSession)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{nostrEnabled && (
|
||||
<Button
|
||||
onClick={() => setShowImportLinkModal(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
{intl.formatMessage(i18n.importNostrSession)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleImportClick}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{intl.formatMessage(i18n.importSession)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
{intl.formatMessage(i18n.chatHistoryDesc, { shortcut: getSearchShortcutText() })}
|
||||
|
|
@ -957,6 +1093,83 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
|||
onSave={handleModalSave}
|
||||
/>
|
||||
|
||||
<Dialog open={showImportLinkModal} onOpenChange={setShowImportLinkModal}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Share2 className="w-5 h-5" />
|
||||
{intl.formatMessage(i18n.importNostrTitle)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{intl.formatMessage(i18n.importNostrDesc)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<textarea
|
||||
value={nostrImportLink}
|
||||
onChange={(event) => setNostrImportLink(event.target.value)}
|
||||
placeholder={intl.formatMessage(i18n.importNostrPlaceholder)}
|
||||
className="min-h-28 w-full resize-none rounded-lg border border-border-primary bg-background-primary p-3 text-sm text-text-primary outline-none focus:ring-2 focus:ring-border-active"
|
||||
disabled={isImportingNostr}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowImportLinkModal(false)}
|
||||
disabled={isImportingNostr}
|
||||
>
|
||||
{intl.formatMessage(i18n.cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportNostrLink}
|
||||
disabled={isImportingNostr || !nostrImportLink.trim()}
|
||||
>
|
||||
{isImportingNostr ? (
|
||||
<>
|
||||
<LoaderCircle className="w-4 h-4 animate-spin" />
|
||||
{intl.formatMessage(i18n.importing)}
|
||||
</>
|
||||
) : (
|
||||
intl.formatMessage(i18n.importSession)
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showShareLinkModal} onOpenChange={setShowShareLinkModal}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Share2 className="w-5 h-5" />
|
||||
{intl.formatMessage(i18n.shareNostrTitle)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{intl.formatMessage(i18n.shareNostrDesc)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative rounded-lg border border-border-primary bg-background-secondary p-3 pr-12">
|
||||
<code className="block max-h-36 overflow-y-auto break-all text-sm text-text-primary">
|
||||
{shareLink}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={handleCopyShareLink}
|
||||
disabled={!shareLink}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span className="sr-only">{intl.formatMessage(i18n.copied)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowShareLinkModal(false)}>
|
||||
{intl.formatMessage(i18n.close)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={showDeleteConfirmation}
|
||||
title={intl.formatMessage(i18n.deleteTitle)}
|
||||
|
|
|
|||
|
|
@ -3908,6 +3908,9 @@
|
|||
"sessions.action.openNewWindow": {
|
||||
"defaultMessage": "Open in new window"
|
||||
},
|
||||
"sessions.action.shareNostr": {
|
||||
"defaultMessage": "Share encrypted Nostr link"
|
||||
},
|
||||
"sessions.cancel": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
|
|
@ -3917,6 +3920,9 @@
|
|||
"sessions.chatHistoryDesc": {
|
||||
"defaultMessage": "View and search your past conversations with Goose. {shortcut} to search."
|
||||
},
|
||||
"sessions.close": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"sessions.delete.message": {
|
||||
"defaultMessage": "Are you sure you want to delete the session \"{name}\"? This action cannot be undone."
|
||||
},
|
||||
|
|
@ -3947,6 +3953,21 @@
|
|||
"sessions.import": {
|
||||
"defaultMessage": "Import Session"
|
||||
},
|
||||
"sessions.importNostr": {
|
||||
"defaultMessage": "Import Link"
|
||||
},
|
||||
"sessions.importNostr.description": {
|
||||
"defaultMessage": "Paste a Goose Nostr share link to fetch, decrypt, and import the session."
|
||||
},
|
||||
"sessions.importNostr.placeholder": {
|
||||
"defaultMessage": "goose://sessions/nostr?nevent=...&key=..."
|
||||
},
|
||||
"sessions.importNostr.title": {
|
||||
"defaultMessage": "Import Nostr Session"
|
||||
},
|
||||
"sessions.importing": {
|
||||
"defaultMessage": "Importing..."
|
||||
},
|
||||
"sessions.loadingMore": {
|
||||
"defaultMessage": "Loading more sessions..."
|
||||
},
|
||||
|
|
@ -3965,6 +3986,15 @@
|
|||
"sessions.searchPlaceholder": {
|
||||
"defaultMessage": "Search history..."
|
||||
},
|
||||
"sessions.shareNostr.description": {
|
||||
"defaultMessage": "Anyone with this link can fetch and decrypt the session. Treat it like a secret."
|
||||
},
|
||||
"sessions.shareNostr.title": {
|
||||
"defaultMessage": "Encrypted Nostr Share Link"
|
||||
},
|
||||
"sessions.toast.copied": {
|
||||
"defaultMessage": "Copied to clipboard"
|
||||
},
|
||||
"sessions.toast.deleteFailed": {
|
||||
"defaultMessage": "Failed to delete session \"{name}\": {error}"
|
||||
},
|
||||
|
|
@ -3986,6 +4016,12 @@
|
|||
"sessions.toast.imported": {
|
||||
"defaultMessage": "Session imported successfully"
|
||||
},
|
||||
"sessions.toast.shareNostr": {
|
||||
"defaultMessage": "Encrypted Nostr share link created"
|
||||
},
|
||||
"sessions.toast.shareNostrFailed": {
|
||||
"defaultMessage": "Failed to create Nostr share link: {error}"
|
||||
},
|
||||
"sessions.toast.updateFailed": {
|
||||
"defaultMessage": "Failed to update session description: {error}"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -511,7 +511,7 @@ app.on('open-url', async (_event, url) => {
|
|||
if (process.platform !== 'win32') {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
log.info('[Main] Received open-url event:', url);
|
||||
log.info('[Main] Received open-url event:', url.includes('key=') ? url.replace(/key=[^&]+/, 'key=REDACTED') : url);
|
||||
|
||||
await app.whenReady();
|
||||
|
||||
|
|
@ -540,7 +540,7 @@ app.on('open-url', async (_event, url) => {
|
|||
|
||||
// For extension/session URLs, store the deep link for processing after React is ready
|
||||
pendingDeepLink = url;
|
||||
log.info('[Main] Stored pending deep link for processing after React ready:', url);
|
||||
log.info('[Main] Stored pending deep link for processing after React ready:', url.includes('key=') ? url.replace(/key=[^&]+/, 'key=REDACTED') : url);
|
||||
|
||||
const existingWindows = BrowserWindow.getAllWindows();
|
||||
if (existingWindows.length > 0) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
import { fetchSharedSessionDetails, SharedSessionDetails } from './sharedSessions';
|
||||
import { View, ViewOptions } from './utils/navigationUtils';
|
||||
import { errorMessage } from './utils/conversionUtils';
|
||||
import { importSessionNostr } from './api';
|
||||
|
||||
/**
|
||||
* Imports a session from an encrypted Nostr deep link.
|
||||
* Separated from shared-session handling so callers can route independently.
|
||||
*/
|
||||
export async function importNostrSessionFromDeepLink(url: string): Promise<void> {
|
||||
await importSessionNostr({
|
||||
body: { deeplink: url },
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles opening a shared session from a deep link
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue