31 Commits
Author SHA1 Message Date
reya 8b9c8bd35e Prune dead code and key elements by stable ids 2026-11-13 04:37:13 +07:00
reya e9f29dc398 stabilize ids and harden release pipeline 2026-11-12 20:42:50 +07:00
reya fd802a8870 harden proxy auth and event ordering 2026-11-12 17:59:28 +07:00
reya fe0ca04817 add plan 2026-11-12 15:41:15 +07:00
reya 8fa79aef33 nip4e: drop processed-requests persistence 2026-11-12 14:39:28 +07:00
reya 8acbbaef5f feat: add onboarding dialog 2026-11-12 13:48:51 +07:00
reya 85f2bb9885 chore: update deps 2026-11-12 09:18:35 +07:00
reya baea9cd9ff themes: add bundled theme registry and picker 2026-11-11 15:16:12 +07:00
reya 9ec8b128db chore: use same composer and message actions for nip29 2026-11-11 10:40:34 +07:00
reya 4166b30319 ui: add wallet icon and sidebar border 2026-11-11 09:57:42 +07:00
reya 6fc7732128 nip4e: reject stale key requests 2026-11-11 09:28:41 +07:00
reya 41b6c80057 chore: update deps 2026-11-11 08:16:31 +07:00
reya 98a12e438a feat: add support for cashu wallet (nip60 & nip61) (#71)
Reviewed-on: #71
2026-11-09 01:12:27 +00:00
reya c1757bbcae chore: fix web build 2026-11-04 13:05:03 +07:00
reya 74d548e5c6 nip4e: catch up psstpsst (#70)
Reviewed-on: #70
2026-11-04 03:12:52 +00:00
reya 690dc37fc5 chore: fix web build 2026-11-01 16:40:49 +07:00
reya 2f2661ecd2 chore: improve native theme usage 2026-11-01 16:15:28 +07:00
reya d045a7ef3a chore: update the unlock dialog 2026-10-30 20:17:37 +07:00
reya 23d7017f25 feat: add text to speech (#69)
Reviewed-on: #69
2026-10-30 07:16:46 +00:00
reya af2a276b1b chore: update deps 2026-10-28 10:08:47 +07:00
reya 69ca199a7d chore: drop macos x64 from release builds 2026-10-28 09:09:18 +07:00
reya 83a440d893 chore: release version 2.0.0-alpha3 2026-10-28 05:14:03 +07:00
reya 77b2a5f3cd feat: add support for speech to text (#68)
Reviewed-on: #68
2026-10-27 06:25:19 +00:00
reya b0121f8df2 chore: simplify import dialog & sidebar menu 2026-10-26 19:59:48 +07:00
reya bfa34959cf feat: add deploy to cloudflare script 2026-10-26 10:16:14 +07:00
reya 4d0e2f5050 feat: add syncing progress 2026-10-26 09:54:38 +07:00
reya 0824b1825b feat: make the web version fully working (#67)
Reviewed-on: #67
2026-10-25 13:10:51 +00:00
reya 3212951ed4 chore: release version 2.0.0-alpha2 2026-10-24 16:09:59 +07:00
reya ceb69660be chore: improve wot 2026-10-24 11:39:22 +07:00
reya 8f35ae92b7 feat: add user-configurable theme settings 2026-10-24 09:15:12 +07:00
reya 30da985ac1 feat: adopt the native OS theme (#66)
Reviewed-on: #66
2026-10-23 04:07:11 +00:00
168 changed files with 23379 additions and 4065 deletions
-3
View File
@@ -20,9 +20,6 @@ jobs:
- platform: windows-arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- platform: macos-x64
os: macos-15-intel
target: x86_64-apple-darwin
- platform: macos-arm64
os: macos-latest
target: aarch64-apple-darwin
+23
View File
@@ -30,3 +30,26 @@ jobs:
- name: Run tests
run: cargo test --verbose
check-wasm:
name: Check web target
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-10-31
targets: wasm32-unknown-unknown
components: llvm-tools-preview
- name: Install wasm C toolchain
run: sudo apt-get update && sudo apt-get install -y clang llvm
- name: Check web target
env:
CC_wasm32_unknown_unknown: clang
AR_wasm32_unknown_unknown: llvm-ar
run: cargo +nightly-2026-10-31 check -p coop_web --target wasm32-unknown-unknown
+1
View File
@@ -24,3 +24,4 @@ dist/
vendor/
wasm/
node_modules/
.wrangler/
Generated
+2936 -599
View File
File diff suppressed because it is too large Load Diff
+20 -16
View File
@@ -4,27 +4,19 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "2.0.0-alpha"
version = "2.0.0-alpha3"
edition = "2024"
publish = false
[workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.8" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { package = "gpui-pre-linux", version = "0.3.8" }
gpui_windows = { package = "gpui-pre-windows", version = "0.3.8" }
gpui_macos = { package = "gpui-pre-macos", version = "0.3.8" }
gpui_web = { package = "gpui-pre-web", version = "0.3.8" }
gpui_util = { package = "gpui-pre-util", version = "0.3.8" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
gpui = { package = "gpui-pre", version = "0.3.12" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.12", features = ["font-kit", "x11", "wayland"] }
gpui_util = { package = "gpui-pre-util", version = "0.3.12" }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.12" }
gpui_tokio = { path = "crates/gpui_tokio" }
gpui-base = "0.7.1"
gpui-kit = "0.7.3"
# Native OS theme reading; the connector is written against gpui-component 0.7.x
# and gpui-pre 0.3.8, both satisfied by the versions above (0.7.3 / 0.3.9).
native-theme = { version = "0.6.1", features = ["watch"] }
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
gpui-base = "0.9.1"
gpui-kit = "0.9.1"
# Nostr
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
@@ -32,8 +24,10 @@ nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-browser-signer = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
nostr-sqlite = { git = "https://github.com/rust-nostr/nostr" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip60", "nip98" ] }
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
@@ -46,9 +40,13 @@ chacha20 = "0.9"
hmac = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng", "thread_rng" ] }
# Cashu (NIP-60).
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
# Others
anyhow = "1.0.44"
tts = { git = "https://github.com/ndarilek/tts-rs" }
chrono = { version = "0.4.38", features = ["wasmbind"] }
futures = "0.3"
itertools = "0.13.0"
@@ -64,6 +62,12 @@ webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
instant = "0.1"
# cdk's registry nostr/nostr-sdk requirements are redirected onto the same
# git repo coop already uses, keeping a single copy of each in the graph.
[patch.crates-io]
nostr = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
[profile.release]
strip = true
opt-level = "z"
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.25 13L12 16.25L8.75 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 7.75V15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

+14 -1
View File
@@ -1,3 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M10 5.75L3.75 12L10 18.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 12H20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path
d="M10 5.75L3.75 12L10 18.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.5 12H20.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

Before

Width:  |  Height:  |  Size: 319 B

After

Width:  |  Height:  |  Size: 418 B

+1 -13
View File
@@ -1,13 +1 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M9.25 9.75C7.317 9.75 5.75 8.183 5.75 6.25C5.75 4.317 7.317 2.75 9.25 2.75C11.183 2.75 12.75 4.317 12.75 6.25C12.75 8.183 11.183 9.75 9.25 9.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 2.75C16.933 2.75 18.5 4.317 18.5 6.25C18.5 8.183 16.933 9.75 15 9.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.24806 12.75C5.46395 12.75 2.27489 15.1517 1.30259 18.2496C0.971822 19.3035 1.89349 20.2187 2.99806 20.2188H15.4981C16.6026 20.2188 17.5243 19.3035 17.1935 18.2496C16.2212 15.1517 13.0322 12.75 9.24806 12.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.75 20.25H21.25C22.3546 20.25 23.2748 19.3347 22.9405 18.2819C22.2598 16.1389 20.512 14.31 18.25 13.5088" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 1014 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M5.75 9.5L12 15.75L18.25 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.5 18.25L15.75 12L9.5 5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.9231L10.5625 15.75L15.25 8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="black" stroke-width="1.5" stroke-linecap="round"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 191 B

After

Width:  |  Height:  |  Size: 201 B

+2 -13
View File
@@ -1,14 +1,3 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="3.5" ry="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 9.25H20.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 14.75H20.5" stroke="currentColor" stroke-width="1.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 351 B

After

Width:  |  Height:  |  Size: 377 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 21.25L21.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.75 21.25V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 522 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M19 1.75V8.25M15.75 5H22.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10.75 9.9C10.75 11.0046 10.0784 11.75 9.25 11.75C8.42157 11.75 7.75 11.0046 7.75 9.9C7.75 8.79543 8.42157 8 9.25 8C10.0784 8 10.75 8.79543 10.75 9.9Z" fill="currentColor"/><path d="M16.25 9.9C16.25 11.0046 15.5784 11.75 14.75 11.75C13.9216 11.75 13.25 11.0046 13.25 9.9C13.25 8.79543 13.9216 8 14.75 8C15.5784 8 16.25 8.79543 16.25 9.9Z" fill="currentColor"/><path d="M16.1123 14.8493C16.1942 14.7105 16.2249 14.545 16.192 14.3857C16.1592 14.2263 16.0665 14.0867 15.933 13.9968C15.7996 13.9069 15.6354 13.8733 15.4754 13.9028C15.3154 13.9321 15.1736 14.0226 15.0757 14.1507C15.0008 14.2469 14.9237 14.3367 14.8415 14.4241C14.1096 15.2083 13.061 15.628 12.0035 15.625C10.946 15.6265 9.8972 15.2055 9.16254 14.4222C9.08002 14.3348 9.00261 14.2451 8.92738 14.1491C8.8291 14.0214 8.68699 13.9313 8.52686 13.9024C8.36679 13.8735 8.20268 13.9076 8.06954 13.9979C7.9364 14.0882 7.84406 14.2281 7.81174 14.3875C7.77938 14.547 7.81054 14.7123 7.89293 14.8509C7.97731 14.99 8.06686 15.1223 8.16553 15.2526C9.04297 16.4311 10.5292 17.1343 12.0024 17.125C13.4754 17.1367 14.965 16.4342 15.8405 15.2521C15.939 15.1215 16.0282 14.9888 16.1123 14.8493Z" fill="currentColor"/><path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8.75" r="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="4" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.25 16.625V16.5C7.25 13.8766 9.37665 11.75 12 11.75C14.6234 11.75 16.75 13.8766 16.75 16.5V16.625C16.75 17.5225 16.0225 18.25 15.125 18.25H8.875C7.97754 18.25 7.25 17.5225 7.25 16.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.25 17.2602H2.75C1.64543 17.2602 0.706551 16.3538 0.919944 15.2701C1.25877 13.5493 2.15049 12.3257 4 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.75 17.2601H21.25C22.3546 17.2601 23.2935 16.3538 23.08 15.27C22.7412 13.5493 21.8495 12.3257 20 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M11.75 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V15.25C21.25 16.3546 20.3546 17.25 19.25 17.25H11.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 6.75H4.75C3.64543 6.75 2.75 7.64543 2.75 8.75V15.25C2.75 16.3546 3.64543 17.25 4.75 17.25H5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.75 3.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4.75 10.9853V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V10.9853M9.75 7.75H14.25M12.617 13.5499L19.9415 11.1744C20.5875 10.9649 21.25 11.4465 21.25 12.1256V18.25C21.25 19.3546 20.3546 20.25 19.25 20.25H4.75C3.64543 20.25 2.75 19.3546 2.75 18.25V12.1256C2.75 11.4465 3.41249 10.9649 4.0585 11.1744L11.383 13.5499C11.784 13.68 12.216 13.68 12.617 13.5499Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

+2 -13
View File
@@ -1,14 +1,3 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.75027 5.52371L10.7168 4.55722C13.1264 2.14759 17.0332 2.14759 19.4428 4.55722C21.8524 6.96684 21.8524 10.8736 19.4428 13.2832L18.4742 14.2519M5.52886 9.74513L4.55722 10.7168C2.14759 13.1264 2.1476 17.0332 4.55722 19.4428C6.96684 21.8524 10.8736 21.8524 13.2832 19.4428L14.2478 18.4782M9.5 14.5L14.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 359 B

After

Width:  |  Height:  |  Size: 462 B

+2 -13
View File
@@ -1,14 +1,3 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 309 B

After

Width:  |  Height:  |  Size: 626 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.66936 5.12886C2.12122 3.64104 3.6759 2.24953 5.09409 2.95862L20.0468 10.435C21.3366 11.0799 21.3366 12.9205 20.0468 13.5655L5.09409 21.0418C3.67589 21.7509 2.12122 20.3594 2.66936 18.8715L4.92467 12.75H9.25021C9.66442 12.75 10.0002 12.4142 10.0002 12C10.0002 11.5858 9.66442 11.25 9.25021 11.25H4.92452L2.66936 5.12886Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12.2126 10.7642L7.07919 15.8829C6.51216 16.4483 6.51216 17.365 7.07919 17.9304C7.64621 18.4958 8.56554 18.4958 9.13257 17.9304L19.3995 7.69303C19.9664 7.12769 20.25 6.38673 20.25 5.64575C20.25 4.90465 19.9665 4.16353 19.3995 3.59809C18.8324 3.03271 18.0893 2.75001 17.3461 2.75C16.6029 2.74999 15.8597 3.03269 15.2927 3.59809L5.02581 13.8354C4.17522 14.6836 3.74995 15.7952 3.75 16.9068C3.75005 18.0183 4.17532 19.1298 5.02581 19.9779C5.87635 20.826 6.99113 21.25 8.10591 21.25C9.22067 21.25 10.3354 20.8259 11.1859 19.9779L16.3194 14.8592" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M12 8.75V15.25M8.75 12H15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>

After

Width:  |  Height:  |  Size: 624 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 5.25V12M12 12V18.75M12 12H5.25M12 12H18.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 6.75V12M12 12V17.25M12 12H6.75M12 12H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 203 B

After

Width:  |  Height:  |  Size: 203 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M17.75 2.75H6.25C5.14543 2.75 4.25 3.64543 4.25 4.75V19.25C4.25 20.3546 5.14543 21.25 6.25 21.25H17.75C18.8546 21.25 19.75 20.3546 19.75 19.25V4.75C19.75 3.64543 18.8546 2.75 17.75 2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12.25" r="2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M16 21C16 18.7909 14.2091 17 12 17C9.79086 17 8 18.7909 8 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M9.75 6.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 696 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="9.25" r="1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 21.25L11.75 9.25H12.25L16.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.5 17.75H14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75693 12.7501C6.08102 10.7234 6.08103 7.77679 7.75693 5.75M16.2431 5.75C17.919 7.77679 17.919 10.7234 16.2431 12.7501" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.06494 2.7574C1.64285 6.40823 1.64502 12.1018 5.07145 15.75M18.9281 2.75C22.3572 6.40053 22.3573 12.0993 18.9285 15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 899 B

+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M20.25 20.25L16.1265 16.1265M16.1265 16.1265C17.4385 14.8145 18.25 13.002 18.25 11C18.25 6.99594 15.0041 3.75 11 3.75C6.99594 3.75 3.75 6.99594 3.75 11C3.75 15.0041 6.99594 18.25 11 18.25C13.002 18.25 14.8145 17.4385 16.1265 16.1265Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M20 20L16.1265 16.1265M16.1265 16.1265C17.4385 14.8145 18.25 13.002 18.25 11C18.25 6.99594 15.0041 3.75 11 3.75C6.99594 3.75 3.75 6.99594 3.75 11C3.75 15.0041 6.99594 18.25 11 18.25C13.002 18.25 14.8145 17.4385 16.1265 16.1265Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 408 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 19.25H5.95C4.82989 19.25 4.26984 19.25 3.84202 19.032C3.46569 18.8403 3.15973 18.5343 2.96799 18.158C2.75 17.7302 2.75 17.1701 2.75 16.05V7.95C2.75 6.82989 2.75 6.26984 2.96799 5.84202C3.15973 5.46569 3.46569 5.15973 3.84202 4.96799C4.26984 4.75 4.8299 4.75 5.95 4.75H18.05C19.1701 4.75 19.7302 4.75 20.158 4.96799C20.5343 5.15973 20.8403 5.46569 21.032 5.84202C21.25 6.26984 21.25 6.8299 21.25 7.95V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3 5.63635L10.9761 10.3898C11.6069 10.7657 12.3931 10.7657 13.0239 10.3898L21 5.63635" stroke="currentColor" stroke-width="1.5"/><path d="M21.8148 15.375L21.1669 15.7491M16.1856 18.625L16.8335 18.2509M21.8147 18.625L21.1669 18.251M16.1855 15.375L16.8335 15.7491M19.0002 20.25L19.0002 19.4375M19.0002 13.75V14.5625M21.1669 17C21.1669 16.6053 21.0613 16.2352 20.8769 15.9165C20.5022 15.269 19.8021 14.8333 19.0002 14.8333C18.1983 14.8333 17.4981 15.269 17.1235 15.9165C16.9391 16.2352 16.8335 16.6053 16.8335 17C16.8335 17.3947 16.9391 17.7648 17.1235 18.0835C17.4982 18.731 18.1983 19.1667 19.0002 19.1667C19.8021 19.1667 20.5022 18.731 20.8769 18.0835C21.0613 17.7648 21.1669 17.3947 21.1669 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M10.75 14.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.25 7.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.75 10.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M21.25 12.75V15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 5.25V3.75H7M11.25 5.25V3.75H7M7 3.75V12.25M7 12.25H5.25M7 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 726 B

+1 -15
View File
@@ -1,15 +1 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3v12" />
<path d="m17 8-5-5-5 5" />
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.75 16.25L6.58579 13.4142C7.36683 12.6332 8.63317 12.6332 9.41421 13.4142L12 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M14.5 11.5C15.6046 11.5 16.5 10.6046 16.5 9.5C16.5 8.39543 15.6046 7.5 14.5 7.5C13.3954 7.5 12.5 8.39543 12.5 9.5C12.5 10.6046 13.3954 11.5 14.5 11.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M15.75 18.5H21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M18.5 15.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 849 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.25 10.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V10.75M21.25 10.75H18.75M21.25 10.75V6.75C21.25 5.64543 20.3546 4.75 19.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V10.75M2.75 10.75H5.25M5.25 10.75H7.13177C7.53452 10.75 7.92789 10.8716 8.26039 11.0989L9.43406 11.9011C9.76655 12.1284 10.1599 12.25 10.5627 12.25H13.4373C13.8401 12.25 14.2334 12.1284 14.5659 11.9011L15.7396 11.0989C16.0721 10.8716 16.4655 10.75 16.8682 10.75H18.75M5.25 10.75V7.75C5.25 7.47386 5.47386 7.25 5.75 7.25H18.25C18.5261 7.25 18.75 7.47386 18.75 7.75V10.75" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 748 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M11.3121 12.3511L11.0582 7.9983C11.0266 7.45662 11.4574 7 12 7C12.5426 7 12.9734 7.45662 12.9418 7.9983L12.6879 12.3511C12.6666 12.7154 12.365 13 12 13C11.635 13 11.3334 12.7154 11.3121 12.3511Z" fill="currentColor"/><circle cx="11.9999" cy="15.8998" r="1.1" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 445 B

+307
View File
@@ -0,0 +1,307 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Adventure",
"author": "iTerm2-Color-Schemes",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
"themes": [
{
"name": "Adventure",
"mode": "dark",
"colors": {
"accent.background": "#25292d",
"accent.foreground": "#feffff",
"background": "#040404",
"border": "#282828",
"danger.background": "#d84a33",
"foreground": "#feffff",
"input.border": "#333333",
"link.active.foreground": "#417AB3",
"link.foreground": "#417AB3",
"link.hover.foreground": "#417AB3",
"list.active.background": "#5da60222",
"list.active.border": "#5da602",
"list.even.background": "#0e0e0e",
"muted.background": "#171717",
"muted.foreground": "#5d6165",
"panel.background": "#003a5b",
"popover.background": "#040404",
"popover.foreground": "#feffff",
"primary.active.background": "#0265a799",
"primary.background": "#4384ad",
"primary.foreground": "#feffff",
"primary.hover.background": "#417AB3",
"scrollbar.background": "#003a5b00",
"scrollbar.thumb.background": "#606060",
"secondary.background": "#171a1c",
"secondary.active.background": "#191d1e",
"secondary.foreground": "#e4d5c7",
"secondary.hover.background": "#1e2224",
"tab.active.background": "#040404",
"tab.active.foreground": "#feffff",
"tab.background": "#04040400",
"tab.foreground": "#677179",
"tab_bar.background": "#040404",
"table.background": "#040404",
"table.head.foreground": "#feffffb3",
"table.row.border": "#4b647970",
"title_bar.background": "#0f1112",
"title_bar.border": "#282828",
"ring": "#5da602",
"base.red": "#d84a33",
"base.red.light": "#d76b42",
"base.green": "#5da602",
"base.green.light": "#99b52c",
"base.blue": "#417ab3",
"base.blue.light": "#97d7ef",
"base.cyan": "#41b3a9",
"base.cyan.light": "#97efd6",
"base.magenta": "#882252",
"base.magenta.light": "#e599bc",
"base.yellow": "#aa7900",
"base.yellow.light": "#ffb670"
},
"highlight": {
"editor.foreground": "#feffff",
"editor.background": "#040404",
"editor.active_line.background": "#0e0e0e",
"editor.line_number": "#5d6165",
"editor.active_line_number": "#feffff",
"editor.invisible": "#5d616566",
"conflict": "#d84a33",
"created": "#5da602",
"deleted": "#d84a33",
"error": "#d84a33",
"hidden": "#5d6165",
"hint": "#99b52c",
"ignored": "#5d6165",
"info": "#417ab3",
"modified": "#eebb6e",
"predictive": "#5d6165",
"renamed": "#5da602",
"success": "#5da602",
"unreachable": "#5d6165",
"warning": "#eebb6e",
"syntax": {
"attribute": {
"color": "#eebb6e"
},
"boolean": {
"color": "#5da602"
},
"comment": {
"color": "#5d6165",
"font_style": "italic"
},
"comment.doc": {
"color": "#5d6165",
"font_style": "italic"
},
"constant": {
"color": "#d84a33"
},
"constructor": {
"color": "#eebb6e"
},
"embedded": {
"color": "#feffff"
},
"function": {
"color": "#5da602"
},
"keyword": {
"color": "#417ab3"
},
"link_text": {
"color": "#97d7ef",
"font_style": "normal"
},
"link_uri": {
"color": "#4b6479",
"font_style": "italic"
},
"number": {
"color": "#d84a33"
},
"string": {
"color": "#5da602"
},
"string.escape": {
"color": "#5da602"
},
"string.regex": {
"color": "#5da602"
},
"string.special": {
"color": "#eebb6e"
},
"string.special.symbol": {
"color": "#eebb6e"
},
"tag": {
"color": "#eebb6e"
},
"text.literal": {
"color": "#d84a33"
},
"title": {
"color": "#97d7ef",
"font_weight": 600
},
"type": {
"color": "#eebb6e"
},
"property": {
"color": "#feffff"
},
"variable.special": {
"color": "#d84a33"
}
}
}
},
{
"name": "Adventure Time",
"mode": "dark",
"colors": {
"accent.background": "#36345f",
"accent.foreground": "#C7C7D4",
"background": "#1f1d45",
"border": "#333150",
"foreground": "#C7C7D4",
"input.border": "#555465",
"link.active.foreground": "#4b61bf",
"link.foreground": "#5f72c6",
"link.hover.foreground": "#8593d3",
"list.active.background": "#4ab11822",
"list.active.border": "#549235",
"list.even.background": "#1c1a37",
"muted.background": "#29274a",
"muted.foreground": "#717192",
"panel.background": "#003a5b",
"popover.background": "#1f1d45",
"popover.foreground": "#C7C7D4",
"primary.active.background": "#5f72c699",
"primary.background": "#5f72c6",
"primary.foreground": "#ffffff",
"primary.hover.background": "#5f72c6aa",
"scrollbar.background": "#003a5b00",
"scrollbar.thumb.background": "#555465",
"secondary.background": "#2e2c51",
"secondary.active.background": "#36345f",
"secondary.foreground": "#C7C7D4",
"secondary.hover.background": "#34325b",
"tab.active.background": "#1f1d45",
"tab.active.foreground": "#C7C7D4",
"tab.background": "#1f1d4500",
"tab.foreground": "#aeab9e",
"tab_bar.background": "#1f1d45",
"table.active.background": "#4ab11811",
"table.active.border": "#549235",
"table.head.foreground": "#f8dcc0b3",
"table.row.border": "#333333",
"title_bar.background": "#1c1a3e",
"title_bar.border": "#333150",
"base.red": "#a02733",
"base.red.light": "#fc5f5a",
"base.green": "#549235",
"base.green.light": "#9eff6e",
"base.blue": "#2b53ab",
"base.blue.light": "#1997c6",
"base.cyan": "#26977b",
"base.cyan.light": "#5afcd4",
"base.magenta": "#665993",
"base.magenta.light": "#9b5953",
"base.yellow": "#ce7837",
"base.yellow.light": "#efc11a"
},
"highlight": {
"editor.foreground": "#f8dcc0",
"editor.background": "#1f1d45",
"editor.active_line.background": "#36345f",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#5d616566",
"conflict": "#D2602D",
"created": "#3f72e2",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"modified": "#B0A878",
"predictive": "#5D5945",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
}
]
}
+148
View File
@@ -0,0 +1,148 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Alduin",
"author": "AlessandroYorba",
"url": "https://github.com/AlessandroYorba/Alduin",
"themes": [
{
"name": "Alduin",
"mode": "dark",
"colors": {
"accent.background": "#2e2b29",
"accent.foreground": "#ebdbb2",
"background": "#1C1C1C",
"border": "#3a3a3a",
"danger.background": "#cc241d",
"danger.active.background": "#cc241d",
"danger.hover.background": "#fb4934",
"foreground": "#9E9E9E",
"input.border": "#504945",
"link.active.foreground": "#83a598",
"link.foreground": "#83a598",
"link.hover.foreground": "#83a598",
"list.active.background": "#262626",
"list.active.border": "#9E9E9E",
"list.even.background": "#262626",
"list.head.background": "#1C1C1C",
"muted.background": "#262626",
"muted.foreground": "#878787",
"panel.background": "#282828",
"primary.active.background": "#45858899",
"primary.background": "#458588",
"primary.foreground": "#F0F0F0",
"primary.hover.background": "#458588AA",
"scrollbar.background": "#28282800",
"scrollbar.thumb.background": "#504945",
"secondary.background": "#282828",
"secondary.active.background": "#35322f",
"secondary.foreground": "#9E9E9E",
"secondary.hover.background": "#36323099",
"tab.active.background": "#1C1C1C",
"tab.active.foreground": "#ebdbb2",
"tab.background": "#14141400",
"tab.foreground": "#848382",
"table.row.border": "#50494570",
"title_bar.background": "#262626",
"title_bar.border": "#3a3a3a",
"ring": "#9E9E9E",
"base.blue": "#87afaf",
"base.cyan": "#878787",
"base.green": "#7a875f",
"base.magenta": "#af8787",
"base.magenta.light": "#af878788",
"base.red": "#8b5f61",
"base.yellow": "#9d906c"
},
"highlight": {
"editor.foreground": "#DDDDDD",
"editor.background": "#000000",
"editor.active_line.background": "#131313",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#9E9E9E66",
"conflict": "#8b5f61",
"created": "#87afaf",
"error": "#8b5f61",
"hidden": "#9E9E9E",
"hint": "#af8787",
"info": "#878787",
"modified": "#B0A878",
"predictive": "#5D5945",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
}
]
}
+147
View File
@@ -0,0 +1,147 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Asciinema",
"author": "asciinema.org",
"url": "https://asciinema.org",
"themes": [
{
"name": "Asciinema",
"mode": "dark",
"colors": {
"accent.background": "#262829",
"accent.foreground": "#cccccc",
"background": "#121314",
"border": "#303030",
"danger.background": "#dd3c69",
"danger.active.background": "#dd3c69",
"danger.hover.background": "#e94f7a",
"foreground": "#cccccc",
"input.border": "#3a3a3a",
"link.active.foreground": "#26b0d7",
"link.foreground": "#26b0d7",
"link.hover.foreground": "#3cc0e7",
"list.active.background": "#1a1b1c",
"list.active.border": "#cccccc",
"list.even.background": "#1a1b1c",
"list.head.background": "#121314",
"muted.background": "#1a1b1c",
"muted.foreground": "#6d6d6d",
"panel.background": "#181919",
"primary.active.background": "#26b0d799",
"primary.background": "#26b0d7",
"primary.foreground": "#ffffff",
"primary.hover.background": "#26b0d7AA",
"scrollbar.background": "#12131400",
"scrollbar.thumb.background": "#2a2a2a",
"secondary.background": "#181919",
"secondary.active.background": "#1f2020",
"secondary.foreground": "#cccccc",
"secondary.hover.background": "#1f202099",
"tab.active.background": "#121314",
"tab.active.foreground": "#cccccc",
"tab.background": "#12131400",
"tab.foreground": "#6d6d6d",
"table.row.border": "#2a2a2a70",
"title_bar.background": "#181919",
"title_bar.border": "#303030",
"ring": "#5d5d5d",
"base.blue": "#26b0d7",
"base.cyan": "#54e1b9",
"base.green": "#4ebf22",
"base.magenta": "#b954e1",
"base.red": "#dd3c69",
"base.yellow": "#ddaf3c"
},
"highlight": {
"editor.foreground": "#cccccc",
"editor.background": "#121314",
"editor.active_line.background": "#1a1b1c",
"editor.line_number": "#6d6d6d",
"editor.active_line_number": "#cccccc",
"editor.invisible": "#6d6d6d66",
"conflict": "#dd3c69",
"created": "#4ebf22",
"error": "#dd3c69",
"hidden": "#6d6d6d",
"hint": "#b954e1",
"info": "#26b0d7",
"modified": "#ddaf3c",
"predictive": "#2a2a2a",
"syntax": {
"attribute": {
"color": "#ddaf3c"
},
"boolean": {
"color": "#b954e1"
},
"comment": {
"color": "#5d5d5d"
},
"comment.doc": {
"color": "#5d5d5d"
},
"constant": {
"color": "#b954e1"
},
"constructor": {
"color": "#26b0d7"
},
"embedded": {
"color": "#cccccc"
},
"function": {
"color": "#54e1b9"
},
"keyword": {
"color": "#dd3c69"
},
"link_text": {
"color": "#26b0d7",
"font_style": "normal"
},
"link_uri": {
"color": "#54e1b9",
"font_style": "italic"
},
"number": {
"color": "#b954e1"
},
"string": {
"color": "#4ebf22"
},
"string.escape": {
"color": "#54e1b9"
},
"string.regex": {
"color": "#4ebf22"
},
"string.special": {
"color": "#ddaf3c"
},
"string.special.symbol": {
"color": "#ddaf3c"
},
"tag": {
"color": "#dd3c69"
},
"text.literal": {
"color": "#cccccc"
},
"title": {
"color": "#ddaf3c",
"font_weight": 600
},
"type": {
"color": "#26b0d7"
},
"property": {
"color": "#cccccc"
},
"variable.special": {
"color": "#dd3c69"
}
}
}
}
]
}
+246
View File
@@ -0,0 +1,246 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Aurora",
"author": "Longbridge",
"url": "https://github.com/longbridge/gpui-kit",
"themes": [
{
"name": "Aurora Light",
"mode": "light",
"colors": {
"accent.background": "#F1F5F9",
"accent.foreground": "#0F172A",
"accordion.background": "#FFFFFF",
"accordion.hover.background": "#F8FAFC",
"background": "#FFFFFF",
"border": "#E2E8F0",
"button.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
"button.hover.background": "linear-gradient(180deg, #F8FAFC, #F1F5F9)",
"button.active.background": "linear-gradient(180deg, #F1F5F9, #E2E8F0)",
"button.foreground": "#0F172A",
"button.primary.background": "linear-gradient(180deg, #1E293B, #0F172A)",
"button.primary.hover.background": "linear-gradient(180deg, #334155, #1E293B)",
"button.primary.active.background": "linear-gradient(180deg, #0F172A, #020617)",
"button.primary.foreground": "#F8FAFC",
"button.secondary.background": "linear-gradient(180deg, #F1F5F9, #E2E8F0)",
"button.secondary.hover.background": "linear-gradient(180deg, #E2E8F0, #CBD5E1)",
"button.secondary.active.background": "linear-gradient(180deg, #CBD5E1, #94A3B8)",
"button.secondary.foreground": "#0F172A",
"button.danger.background": "linear-gradient(180deg, #FEF2F2, #FEE2E2)",
"button.danger.hover.background": "linear-gradient(180deg, #FEE2E2, #FECACA)",
"button.danger.active.background": "linear-gradient(180deg, #FECACA, #FCA5A5)",
"button.danger.foreground": "#B91C1C",
"button.info.background": "linear-gradient(180deg, #ECFEFF, #CFFAFE)",
"button.info.hover.background": "linear-gradient(180deg, #CFFAFE, #A5F3FC)",
"button.info.active.background": "linear-gradient(180deg, #A5F3FC, #67E8F9)",
"button.info.foreground": "#0E7490",
"button.success.background": "linear-gradient(180deg, #F0FDF4, #DCFCE7)",
"button.success.hover.background": "linear-gradient(180deg, #DCFCE7, #BBF7D0)",
"button.success.active.background": "linear-gradient(180deg, #BBF7D0, #86EFAC)",
"button.success.foreground": "#15803D",
"button.warning.background": "linear-gradient(180deg, #FEFCE8, #FEF3C7)",
"button.warning.hover.background": "linear-gradient(180deg, #FEF3C7, #FDE68A)",
"button.warning.active.background": "linear-gradient(180deg, #FDE68A, #FCD34D)",
"button.warning.foreground": "#A16207",
"caret": "#0F172A",
"chart.1": "#93C5FD",
"chart.2": "#3B82F6",
"chart.3": "#2563EB",
"chart.4": "#1D4ED8",
"chart.5": "#1E40AF",
"chart.bullish": "green-600",
"chart.bearish": "red-600",
"danger.background": "linear-gradient(180deg, #EF4444, #DC2626)",
"danger.active.background": "linear-gradient(180deg, #DC2626, #B91C1C)",
"danger.foreground": "#FFFFFF",
"danger.hover.background": "linear-gradient(180deg, #F87171, #EF4444)",
"description_list.label.background": "#F8FAFC",
"description_list.label.foreground": "#0F172A",
"drag.border": "#3B82F6",
"drop_target.background": "#3B82F640",
"foreground": "#020617",
"group_box.background": "#F8FAFC",
"group_box.foreground": "#0F172A",
"info.background": "linear-gradient(180deg, #06B6D4, #0891B2)",
"info.active.background": "linear-gradient(180deg, #0891B2, #0E7490)",
"info.foreground": "#FFFFFF",
"info.hover.background": "linear-gradient(180deg, #22D3EE, #06B6D4)",
"input.border": "#E2E8F0",
"link": "#0F172A",
"link.active": "#020617",
"link.hover": "#334155",
"list.background": "#FFFFFF",
"list.active.background": "#BFDBFE33",
"list.active.border": "#60A5FA",
"list.even.background": "#F8FAFC",
"list.head.background": "#F8FAFC",
"list.hover.background": "#F1F5F9",
"muted.background": "#F1F5F9",
"muted.foreground": "#64748B",
"popover.background": "#FFFFFF",
"popover.foreground": "#020617",
"primary.background": "linear-gradient(180deg, #1E293B, #0F172A)",
"primary.active.background": "linear-gradient(180deg, #0F172A, #020617)",
"primary.foreground": "#F8FAFC",
"primary.hover.background": "linear-gradient(180deg, #334155, #1E293B)",
"progress.bar.background": "linear-gradient(180deg, #1E293B, #0F172A)",
"ring": "#0F172A",
"scrollbar.background": "#F8FAFC00",
"scrollbar.thumb.background": "linear-gradient(to right, #CBD5E173, #94A3B873)",
"scrollbar.thumb.hover.background": "linear-gradient(to right, #CBD5E199, #94A3B899)",
"secondary.background": "#E2E8F0",
"secondary.active.background": "#CBD5E1",
"secondary.foreground": "#0F172A",
"secondary.hover.background": "#E2E8F0",
"selection.background": "#55A0FC",
"sidebar.background": "#F8FAFC",
"sidebar.accent.background": "#E2E8F0",
"sidebar.accent.foreground": "#0F172A",
"sidebar.border": "#E2E8F0",
"sidebar.foreground": "#0F172A",
"sidebar.primary.background": "#0F172A",
"sidebar.primary.foreground": "#F8FAFC",
"skeleton.background": "#F1F5F9",
"slider.background": "#0F172A",
"slider.thumb.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
"success.background": "linear-gradient(180deg, #22C55E, #16A34A)",
"success.active.background": "linear-gradient(180deg, #16A34A, #15803D)",
"success.foreground": "#FFFFFF",
"success.hover.background": "linear-gradient(180deg, #4ADE80, #22C55E)",
"switch.background": "linear-gradient(180deg, #E2E8F0, #CBD5E1)",
"switch.thumb.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
"tab.background": "#00000000",
"tab.active.background": "#FFFFFF",
"tab.active.foreground": "#0F172A",
"tab.foreground": "#334155",
"tab_bar.background": "#F1F5F9",
"tab_bar.segmented.background": "#F1F5F9",
"table.background": "#FFFFFF",
"table.active.background": "#BFDBFE33",
"table.active.border": "#60A5FA",
"table.even.background": "#F8FAFC",
"table.head.background": "#F8FAFC",
"table.head.foreground": "#64748B",
"table.foot.background": "#F8FAFC",
"table.foot.foreground": "#64748B",
"table.hover.background": "#F1F5F9",
"table.row.border": "#E2E8F0B3",
"title_bar.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
"title_bar.border": "#E2E8F0",
"status_bar.background": "linear-gradient(180deg, #F8FAFC, #F1F5F9)",
"status_bar.border": "#E2E8F0",
"warning.background": "linear-gradient(180deg, #EAB308, #CA8A04)",
"warning.active.background": "linear-gradient(180deg, #CA8A04, #A16207)",
"warning.hover.background": "linear-gradient(180deg, #FACC15, #EAB308)",
"warning.foreground": "#422006",
"overlay": "#0000000D",
"window.border": "#E2E8F0",
"base.red": "red-600",
"base.red.light": "red-400",
"base.green": "green-600",
"base.green.light": "green-400",
"base.blue": "blue-600",
"base.blue.light": "blue-400",
"base.yellow": "yellow-600",
"base.yellow.light": "yellow-400",
"base.magenta": "purple-600",
"base.magenta.light": "purple-400",
"base.cyan": "cyan-600",
"base.cyan.light": "cyan-400"
},
"highlight": {
"editor.foreground": "#020617",
"editor.background": "#FFFFFF",
"editor.active_line.background": "#F8FAFC",
"editor.line_number": "#94A3B8",
"editor.active_line_number": "#0F172A",
"editor.invisible": "#64748B66",
"syntax": {
"attribute": {
"color": "#957931"
},
"boolean": {
"color": "#C5060B"
},
"comment": {
"color": "#007fff"
},
"comment.doc": {
"color": "#007fff"
},
"constant": {
"color": "#C5060B"
},
"constructor": {
"color": "#0433ff"
},
"embedded": {
"color": "#333333"
},
"emphasis": {
"font_style": "italic"
},
"emphasis.strong": {
"font_weight": 700
},
"function": {
"color": "#0000A2"
},
"keyword": {
"color": "#0433ff"
},
"link_text": {
"color": "#0000A2",
"font_style": "normal"
},
"link_uri": {
"color": "#6A7293",
"font_style": "italic"
},
"number": {
"color": "#0433ff"
},
"string": {
"color": "#036A07"
},
"string.escape": {
"color": "#036A07"
},
"string.regex": {
"color": "#036A07"
},
"string.special": {
"color": "#d21f07"
},
"string.special.symbol": {
"color": "#d21f07"
},
"tag": {
"color": "#0433ff"
},
"text.literal": {
"color": "#6F42C1"
},
"text.code.span": {
"color": "#6F42C1"
},
"title": {
"color": "#0433FF"
},
"type": {
"color": "#6f42c1"
},
"property": {
"color": "#333333"
},
"variable": {
"color": "#333333"
},
"variable.special": {
"color": "#C5060B"
}
}
}
}
]
}
+459
View File
@@ -0,0 +1,459 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Ayu",
"author": "Zed Industries",
"url": "https://github.com/zed-industries/zed/blob/e62dd2a0e584154886ff86cc1e9e3e060558b977/assets/themes/ayu",
"themes": [
{
"name": "Ayu Light",
"mode": "light",
"colors": {
"accent.background": "#dadadc",
"accent.foreground": "#5C6773",
"background": "#FCFCFC",
"foreground": "#5c6166",
"border": "#cfd1d2ff",
"ring": "#FF9940",
"input.border": "#D9DCE3",
"list.active.background": "#55B4D422",
"list.active.border": "#55B4D4",
"list.even.background": "#E6E6E6",
"list.head.background": "#F4F4F599",
"muted.background": "#F4F4F5",
"muted.foreground": "#99a0a6",
"panel.background": "#F3F4F5",
"popover.background": "#ECECED",
"popover.foreground": "#5C6773",
"primary.active.background": "#42accf",
"primary.background": "#55b4d3",
"primary.foreground": "#FAFAFA",
"primary.hover.background": "#5fb9d7",
"scrollbar.background": "#FAFAFA00",
"scrollbar.thumb.background": "#5c61664c",
"secondary.active.background": "#CECECE",
"secondary.background": "#ECECED",
"secondary.foreground": "#5C6773",
"secondary.hover.background": "#e0e0e0",
"tab.active.background": "#FCFCFC",
"tab.active.foreground": "#5C6773",
"tab.background": "#ECECED00",
"tab_bar.background": "#F4F4F5",
"title_bar.background": "#ECECED",
"title_bar.border": "#CFD1D2",
"base.yellow": "#F1AD49",
"base.red": "#F07171",
"base.blue": "#55b4d3",
"base.green": "#85b304",
"base.magenta": "#9371f0",
"base.cyan": "#4dbf99"
},
"highlight": {
"editor.foreground": "#5C6773",
"editor.background": "#FCFCFC",
"editor.active_line.background": "#ECECED",
"editor.line_number": "#ABB0B6",
"editor.active_line_number": "#5C6773",
"editor.invisible": "#73777b66",
"conflict": "#f1ad49ff",
"conflict.background": "#ffeedaff",
"conflict.border": "#ffe1beff",
"created": "#85b304ff",
"created.background": "#e9efd2ff",
"created.border": "#d7e3aeff",
"deleted": "#ef7271ff",
"deleted.background": "#ffe3e1ff",
"deleted.border": "#ffcdcaff",
"error": "#ef7271ff",
"error.background": "#ffe3e1ff",
"error.border": "#ffcdcaff",
"hidden": "#a9acaeff",
"hidden.background": "#dcdddeff",
"hidden.border": "#d5d6d8ff",
"hint": "#8ca7c2ff",
"hint.background": "#deebfaff",
"hint.border": "#c4daf6ff",
"ignored": "#a9acaeff",
"ignored.background": "#dcdddeff",
"ignored.border": "#cfd1d2ff",
"info": "#3b9ee5ff",
"info.background": "#deebfaff",
"info.border": "#c4daf6ff",
"modified": "#f1ad49ff",
"modified.background": "#ffeedaff",
"modified.border": "#ffe1beff",
"predictive": "#9eb9d3ff",
"predictive.background": "#e9efd2ff",
"predictive.border": "#d7e3aeff",
"renamed": "#3b9ee5ff",
"renamed.background": "#deebfaff",
"renamed.border": "#c4daf6ff",
"success": "#85b304ff",
"success.background": "#e9efd2ff",
"success.border": "#d7e3aeff",
"unreachable": "#8b8e92ff",
"unreachable.background": "#dcdddeff",
"unreachable.border": "#cfd1d2ff",
"warning": "#f1ad49ff",
"warning.background": "#ffeedaff",
"warning.border": "#ffe1beff",
"syntax": {
"attribute": {
"color": "#3b9ee5ff"
},
"boolean": {
"color": "#a37accff"
},
"comment": {
"color": "#898d90ff"
},
"comment.doc": {
"color": "#898d90ff"
},
"constant": {
"color": "#a37accff"
},
"constructor": {
"color": "#3b9ee5ff"
},
"embedded": {
"color": "#5c6166ff"
},
"emphasis": {
"color": "#3b9ee5ff"
},
"emphasis.strong": {
"color": "#3b9ee5ff",
"font_weight": 700
},
"enum": {
"color": "#f98d3fff"
},
"function": {
"color": "#f2ad48ff"
},
"hint": {
"color": "#8ca7c2ff",
"font_weight": 700
},
"keyword": {
"color": "#fa8d3eff"
},
"label": {
"color": "#3b9ee5ff"
},
"link_text": {
"color": "#f98d3fff",
"font_style": "italic"
},
"link_uri": {
"color": "#85b304ff"
},
"namespace": {
"color": "#5c6166ff"
},
"number": {
"color": "#a37accff"
},
"operator": {
"color": "#ed9365ff"
},
"predictive": {
"color": "#9eb9d3ff",
"font_style": "italic"
},
"preproc": {
"color": "#5c6166ff"
},
"primary": {
"color": "#5c6166ff"
},
"property": {
"color": "#3b9ee5ff"
},
"punctuation": {
"color": "#73777bff"
},
"punctuation.bracket": {
"color": "#73777bff"
},
"punctuation.delimiter": {
"color": "#73777bff"
},
"punctuation.list_marker": {
"color": "#73777bff"
},
"punctuation.markup": {
"color": "#73777bff"
},
"punctuation.special": {
"color": "#a37accff"
},
"selector": {
"color": "#a37accff"
},
"selector.pseudo": {
"color": "#3b9ee5ff"
},
"string": {
"color": "#86b300ff"
},
"string.escape": {
"color": "#898d90ff"
},
"string.regex": {
"color": "#4bbf98ff"
},
"string.special": {
"color": "#e6ba7eff"
},
"string.special.symbol": {
"color": "#f98d3fff"
},
"tag": {
"color": "#3b9ee5ff"
},
"text.literal": {
"color": "#f98d3fff"
},
"title": {
"color": "#5c6166ff",
"font_weight": 700
},
"type": {
"color": "#389ee6ff"
},
"variable": {
"color": "#5c6166ff"
},
"variant": {
"color": "#3b9ee5ff"
}
}
}
},
{
"name": "Ayu Dark",
"mode": "dark",
"colors": {
"accent.background": "#20242b",
"accent.foreground": "#B3B1AD",
"background": "#0D1016",
"border": "#292a2c",
"ring": "#FFB454",
"foreground": "#B3B1AD",
"input.border": "#2D2F34",
"list.active.background": "#36A3D922",
"list.active.border": "#36A3D9",
"list.even.background": "#191F2A99",
"muted.background": "#16191F",
"muted.foreground": "#52514f",
"popover.background": "#0D1016",
"popover.foreground": "#B3B1AD",
"primary.active.background": "#36A3D9",
"primary.background": "#5ac1fe",
"primary.foreground": "#1F2430",
"primary.hover.background": "#3DAEE9",
"scrollbar.background": "#0A0E1400",
"scrollbar.thumb.background": "#bfbdb64c",
"secondary.active.background": "#26282f",
"secondary.background": "#1F2127",
"secondary.foreground": "#B3B1AD",
"secondary.hover.background": "#26282f99",
"tab.active.foreground": "#B3B1AD",
"tab.active.background": "#0D1016",
"tab_bar.background": "#16191F",
"title_bar.background": "#16191F",
"title_bar.border": "#292a2c",
"base.yellow": "#FEB454",
"base.red": "#ef7177",
"base.blue": "#5ac1fe",
"base.green": "#aad84c",
"base.magenta": "#d2a6ff",
"base.cyan": "#5a728b"
},
"highlight": {
"editor.foreground": "#bfbdb6",
"editor.background": "#0d1016",
"editor.active_line.background": "#1f2127bf",
"editor.line_number": "#4b4c4e",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#73777b66",
"conflict": "#feb454ff",
"conflict.background": "#572815ff",
"conflict.border": "#754221ff",
"created": "#aad84cff",
"created.background": "#294113ff",
"created.border": "#405c1cff",
"deleted": "#ef7177ff",
"deleted.background": "#48161bff",
"deleted.border": "#66272dff",
"error": "#ef7177ff",
"error.background": "#48161bff",
"error.border": "#66272dff",
"hidden": "#696a6aff",
"hidden.background": "#313337ff",
"hidden.border": "#383a3eff",
"hint": "#628b80ff",
"hint.background": "#0d2f4eff",
"hint.border": "#1b4a6eff",
"ignored": "#696a6aff",
"ignored.background": "#313337ff",
"ignored.border": "#3f4043ff",
"info": "#5ac1feff",
"info.background": "#0d2f4eff",
"info.border": "#1b4a6eff",
"modified": "#feb454ff",
"modified.background": "#572815ff",
"modified.border": "#754221ff",
"predictive": "#5a728bff",
"predictive.background": "#294113ff",
"predictive.border": "#405c1cff",
"renamed": "#5ac1feff",
"renamed.background": "#0d2f4eff",
"renamed.border": "#1b4a6eff",
"success": "#aad84cff",
"success.background": "#294113ff",
"success.border": "#405c1cff",
"unreachable": "#8a8986ff",
"unreachable.background": "#313337ff",
"unreachable.border": "#3f4043ff",
"warning": "#feb454ff",
"warning.background": "#572815ff",
"warning.border": "#754221ff",
"syntax": {
"attribute": {
"color": "#5ac1feff"
},
"boolean": {
"color": "#d2a6ffff"
},
"comment": {
"color": "#8c8b88ff"
},
"comment.doc": {
"color": "#8c8b88ff"
},
"constant": {
"color": "#d2a6ffff"
},
"constructor": {
"color": "#5ac1feff"
},
"embedded": {
"color": "#bfbdb6ff"
},
"emphasis": {
"color": "#5ac1feff"
},
"emphasis.strong": {
"color": "#5ac1feff",
"font_weight": 700
},
"enum": {
"color": "#fe8f40ff"
},
"function": {
"color": "#ffb353ff"
},
"hint": {
"color": "#628b80ff",
"font_weight": 700
},
"keyword": {
"color": "#ff8f3fff"
},
"label": {
"color": "#5ac1feff"
},
"link_text": {
"color": "#fe8f40ff",
"font_style": "italic"
},
"link_uri": {
"color": "#aad84cff"
},
"namespace": {
"color": "#bfbdb6ff"
},
"number": {
"color": "#d2a6ffff"
},
"operator": {
"color": "#f29668ff"
},
"predictive": {
"color": "#5a728bff",
"font_style": "italic"
},
"preproc": {
"color": "#bfbdb6ff"
},
"primary": {
"color": "#bfbdb6ff"
},
"property": {
"color": "#5ac1feff"
},
"punctuation": {
"color": "#a6a5a0ff"
},
"punctuation.bracket": {
"color": "#a6a5a0ff"
},
"punctuation.delimiter": {
"color": "#a6a5a0ff"
},
"punctuation.list_marker": {
"color": "#a6a5a0ff"
},
"punctuation.markup": {
"color": "#a6a5a0ff"
},
"punctuation.special": {
"color": "#d2a6ffff"
},
"selector": {
"color": "#d2a6ffff"
},
"selector.pseudo": {
"color": "#5ac1feff"
},
"string": {
"color": "#a9d94bff"
},
"string.escape": {
"color": "#8c8b88ff"
},
"string.regex": {
"color": "#95e6cbff"
},
"string.special": {
"color": "#e5b572ff"
},
"string.special.symbol": {
"color": "#fe8f40ff"
},
"tag": {
"color": "#5ac1feff"
},
"text.literal": {
"color": "#fe8f40ff"
},
"title": {
"color": "#bfbdb6ff",
"font_weight": 700
},
"type": {
"color": "#59c2ffff"
},
"variable": {
"color": "#bfbdb6ff"
},
"variant": {
"color": "#5ac1feff"
}
}
}
}
]
}
+829
View File
@@ -0,0 +1,829 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Catppuccin",
"author": "Catppuccino",
"url": "https://github.com/catppuccin/catppuccin",
"themes": [
{
"name": "Catppuccin Latte",
"mode": "light",
"colors": {
"accent.background": "#d3d8e0",
"accent.foreground": "#4c4f69",
"background": "#E5E9EF",
"border": "#CCD0DA",
"ring": "#7287fd",
"foreground": "#4c4f69",
"input.border": "#acb0be",
"link.active.foreground": "#7287fd",
"link.foreground": "#7287fd",
"link.hover.foreground": "#7287fd",
"list.active.background": "#7287fd22",
"list.active.border": "#7287fd",
"list.even.background": "#EFF1F5",
"list.head.background": "#dce0e8",
"muted.background": "#dce0e8",
"muted.foreground": "#9a9db2",
"panel.background": "#dce0e8",
"primary.active.background": "#7287fd",
"primary.background": "#7287fd",
"primary.foreground": "#EFF1F5",
"scrollbar.background": "#EFF1F500",
"scrollbar.thumb.background": "#acb0be",
"secondary.active.background": "#CCD2DE",
"secondary.background": "#dce0e8",
"secondary.foreground": "#4c4f69",
"secondary.hover.background": "#CCD2DE99",
"tab.active.background": "#E5E9EF",
"tab.active.foreground": "#4c4f69",
"tab.background": "#D2D7E200",
"tab.foreground": "#82848c",
"tab_bar.background": "#DCE0E8",
"title_bar.background": "#DCE0E8",
"title_bar.border": "#bec3d0",
"base.red": "#d26a53",
"base.green": "#5aa93b",
"base.yellow": "#df8e1d",
"base.blue": "#78acdc",
"base.magenta": "#8778dc",
"base.magenta.light": "#9978dc66",
"base.cyan": "#53d2b0"
},
"highlight": {
"editor.foreground": "#4c4f69",
"editor.background": "#EFF1F5",
"editor.active_line.background": "#dce0e8",
"editor.line_number": "#9a9db2",
"editor.active_line_number": "#4c4f69",
"editor.invisible": "#7c7f9366",
"conflict": "#d20f39",
"created": "#85dc78",
"deleted": "#dc8a78",
"error": "#d20f39",
"hidden": "#9a9db2",
"hint": "#7287fd",
"ignored": "#acb0be",
"info": "#1e66f5",
"modified": "#df8e1d",
"predictive": "#9a9db2",
"renamed": "#9978dc",
"success": "#85dc78",
"unreachable": "#acb0be",
"warning": "#df8e1d",
"syntax": {
"variable": {
"color": "#4c4f69"
},
"variable.builtin": {
"color": "#d20f39"
},
"variable.parameter": {
"color": "#e64553"
},
"variable.member": {
"color": "#1e66f5"
},
"variable.special": {
"color": "#d20f39",
"font_style": "italic"
},
"constant": {
"color": "#fe640b"
},
"constant.builtin": {
"color": "#fe640b"
},
"constant.macro": {
"color": "#8839ef"
},
"module": {
"color": "#df8e1d",
"font_style": "italic"
},
"label": {
"color": "#209fb5"
},
"string": {
"color": "#85dc78"
},
"string.documentation": {
"color": "#179299"
},
"string.regexp": {
"color": "#fe640b"
},
"string.escape": {
"color": "#ea76cb"
},
"string.special": {
"color": "#ea76cb"
},
"string.special.path": {
"color": "#ea76cb"
},
"string.special.symbol": {
"color": "#dd7878"
},
"string.special.url": {
"color": "#dc8a78",
"font_style": "italic"
},
"character": {
"color": "#179299"
},
"character.special": {
"color": "#ea76cb"
},
"boolean": {
"color": "#fe640b"
},
"number": {
"color": "#fe640b"
},
"number.float": {
"color": "#fe640b"
},
"type": {
"color": "#df8e1d"
},
"type.builtin": {
"color": "#8839ef",
"font_style": "italic"
},
"type.definition": {
"color": "#df8e1d"
},
"type.interface": {
"color": "#df8e1d",
"font_style": "italic"
},
"type.super": {
"color": "#df8e1d",
"font_style": "italic"
},
"attribute": {
"color": "#fe640b"
},
"property": {
"color": "#1e66f5"
},
"function": {
"color": "#1e66f5"
},
"function.builtin": {
"color": "#fe640b"
},
"function.call": {
"color": "#1e66f5"
},
"function.macro": {
"color": "#179299"
},
"function.method": {
"color": "#1e66f5"
},
"function.method.call": {
"color": "#1e66f5"
},
"constructor": {
"color": "#dd7878"
},
"operator": {
"color": "#04a5e5"
},
"keyword": {
"color": "#8839ef"
},
"keyword.modifier": {
"color": "#8839ef"
},
"keyword.type": {
"color": "#8839ef"
},
"keyword.coroutine": {
"color": "#8839ef"
},
"keyword.function": {
"color": "#8839ef"
},
"keyword.operator": {
"color": "#8839ef"
},
"keyword.import": {
"color": "#8839ef"
},
"keyword.repeat": {
"color": "#8839ef"
},
"keyword.return": {
"color": "#8839ef"
},
"keyword.debug": {
"color": "#8839ef"
},
"keyword.exception": {
"color": "#8839ef"
},
"keyword.conditional": {
"color": "#8839ef"
},
"keyword.conditional.ternary": {
"color": "#8839ef"
},
"keyword.directive": {
"color": "#ea76cb"
},
"keyword.directive.define": {
"color": "#ea76cb"
},
"keyword.export": {
"color": "#04a5e5"
},
"punctuation": {
"color": "#7c7f93"
},
"punctuation.delimiter": {
"color": "#7c7f93"
},
"punctuation.bracket": {
"color": "#7c7f93"
},
"punctuation.special": {
"color": "#ea76cb"
},
"punctuation.special.symbol": {
"color": "#dd7878"
},
"punctuation.list_marker": {
"color": "#179299"
},
"comment": {
"color": "#7c7f93",
"font_style": "italic"
},
"comment.doc": {
"color": "#7c7f93",
"font_style": "italic"
},
"comment.documentation": {
"color": "#7c7f93",
"font_style": "italic"
},
"comment.error": {
"color": "#d20f39",
"font_style": "italic"
},
"comment.warning": {
"color": "#df8e1d",
"font_style": "italic"
},
"comment.hint": {
"color": "#1e66f5",
"font_style": "italic"
},
"comment.todo": {
"color": "#dd7878",
"font_style": "italic"
},
"comment.note": {
"color": "#dc8a78",
"font_style": "italic"
},
"diff.plus": {
"color": "#40a02b"
},
"diff.minus": {
"color": "#d20f39"
},
"tag": {
"color": "#1e66f5"
},
"tag.attribute": {
"color": "#df8e1d",
"font_style": "italic"
},
"tag.delimiter": {
"color": "#179299"
},
"parameter": {
"color": "#e64553"
},
"field": {
"color": "#7287fd"
},
"namespace": {
"color": "#df8e1d",
"font_style": "italic"
},
"float": {
"color": "#fe640b"
},
"symbol": {
"color": "#ea76cb"
},
"string.regex": {
"color": "#fe640b"
},
"text": {
"color": "#4c4f69"
},
"emphasis.strong": {
"color": "#e64553",
"font_weight": 700
},
"emphasis": {
"color": "#e64553",
"font_style": "italic"
},
"embedded": {
"color": "#e64553"
},
"text.literal": {
"color": "#40a02b"
},
"concept": {
"color": "#209fb5"
},
"enum": {
"color": "#179299",
"font_weight": 700
},
"function.decorator": {
"color": "#fe640b"
},
"type.class.definition": {
"color": "#df8e1d",
"font_weight": 700
},
"hint": {
"color": "#acb0be",
"font_style": "italic"
},
"link_text": {
"color": "#7287fd"
},
"link_uri": {
"color": "#1e66f5",
"font_style": "italic"
},
"parent": {
"color": "#fe640b"
},
"predictive": {
"color": "#9ca0b0"
},
"predoc": {
"color": "#d20f39"
},
"primary": {
"color": "#e64553"
},
"tag.doctype": {
"color": "#8839ef"
},
"string.doc": {
"color": "#179299",
"font_style": "italic"
},
"title": {
"color": "#4c4f69",
"font_weight": 800
},
"variant": {
"color": "#d20f39"
}
}
}
},
{
"name": "Catppuccin Frappe",
"mode": "dark",
"colors": {
"accent.background": "#3b3f4f",
"accent.foreground": "#c6d0f5",
"background": "#232634",
"border": "#3e4255",
"ring": "#f2d5cf",
"foreground": "#c6d0f5",
"input.border": "#51576d",
"list.active.background": "#8caaee15",
"list.even.background": "#303446",
"list.head.background": "#2A2C3C",
"muted.background": "#2c303f",
"muted.foreground": "#979db5",
"panel.background": "#414559",
"popover.background": "#1E202B",
"popover.foreground": "#c6d0f5",
"primary.active.background": "#8caaee",
"primary.background": "#8caaee",
"primary.foreground": "#232634",
"primary.hover.background": "#8caaee",
"scrollbar.background": "#30344600",
"scrollbar.thumb.background": "#51576d",
"secondary.active.background": "#313445",
"secondary.background": "#414559",
"secondary.foreground": "#c6d0f5",
"secondary.hover.background": "#31344599",
"tab.active.background": "#232634",
"tab.active.foreground": "#dce2f9",
"tab.background": "#1E202B00",
"tab.foreground": "#abb5d9",
"tab_bar.background": "#1E202B",
"title_bar.background": "#1D202B",
"title_bar.border": "#30354b",
"base.red": "#e78284",
"base.green": "#a6d189",
"base.yellow": "#e7d682",
"base.blue": "#8caaee",
"base.magenta": "#9882e7",
"base.magenta.light": "#9882e766",
"base.cyan": "#81c8be"
},
"highlight": {
"editor.foreground": "#c6d0f5",
"editor.background": "#232634",
"editor.active_line.background": "#41455977",
"editor.line_number": "#979db5",
"editor.active_line_number": "#c6d0f5",
"editor.invisible": "#7c7f9366",
"conflict": "#e78284",
"created": "#a6d189",
"deleted": "#3b2e2e",
"error": "#3b2e2e",
"hidden": "#51576d",
"hint": "#8caaee",
"info": "#1e2b4d",
"modified": "#e7d682",
"predictive": "#51576d",
"syntax": {
"attribute": {
"color": "#e7d682"
},
"boolean": {
"color": "#e78284"
},
"comment": {
"color": "#51576d"
},
"comment.doc": {
"color": "#51576d"
},
"constant": {
"color": "#e7d682"
},
"constructor": {
"color": "#a6d189"
},
"embedded": {
"color": "#c6d0f5"
},
"function": {
"color": "#8caaee"
},
"keyword": {
"color": "#e78284"
},
"link_text": {
"color": "#8caaee",
"font_style": "normal"
},
"link_uri": {
"color": "#51576d",
"font_style": "italic"
},
"number": {
"color": "#e78284"
},
"string": {
"color": "#a6d189"
},
"string.escape": {
"color": "#a6d189"
},
"string.regex": {
"color": "#a6d189"
},
"string.special": {
"color": "#e7d682"
},
"string.special.symbol": {
"color": "#e7d682"
},
"tag": {
"color": "#8caaee"
},
"text.literal": {
"color": "#c6d0f5"
},
"title": {
"color": "#e78284",
"font_weight": 600
},
"type": {
"color": "#8caaee"
},
"property": {
"color": "#c6d0f5"
},
"variable.special": {
"color": "#e78284"
}
}
}
},
{
"name": "Catppuccin Macchiato",
"mode": "dark",
"colors": {
"accent.background": "#30344d",
"accent.foreground": "#cad3f5",
"action_bar.background": "#1E2030",
"background": "#1E2030",
"border": "#494d64",
"ring": "#f4dbd6",
"danger.background": "#ed8796",
"foreground": "#cad3f5",
"input.border": "#494d64",
"link.active.foreground": "#8aadf4",
"link.foreground": "#8aadf4",
"link.hover.foreground": "#8aadf4",
"list.active.background": "#8aadf420",
"list.active.border": "#8aadf4",
"list.even.background": "#24273a",
"list.head.background": "#363a4f33",
"muted.background": "#282a3a",
"muted.foreground": "#b8c0e0",
"popover.foreground": "#cad3f5",
"primary.active.background": "#8aadf499",
"primary.background": "#8aadf4",
"primary.foreground": "#24273a",
"primary.hover.background": "#8aadf4",
"scrollbar.background": "#24273a00",
"scrollbar.thumb.background": "#494d64",
"secondary.active.background": "#3a3f55",
"secondary.background": "#363a4f",
"secondary.foreground": "#cad3f5",
"secondary.hover.background": "#24273a99",
"tab.background": "#17192600",
"tab.foreground": "#aeb8db",
"tab_bar.background": "#171926",
"title_bar.background": "#171926",
"title_bar.border": "#363A4F",
"base.red": "#ed8796",
"base.green": "#a6da95",
"base.yellow": "#eed49f",
"base.blue": "#8aadf4",
"base.magenta": "#f5bde6",
"base.magenta.light": "#f5bde666",
"base.cyan": "#8bd5ca"
},
"highlight": {
"editor.foreground": "#cad3f5",
"editor.background": "#1E2030",
"editor.active_line.background": "#363a4f",
"editor.line_number": "#b8c0e0",
"editor.active_line_number": "#cad3f5",
"editor.invisible": "#7c7f9366",
"conflict": "#ed8796",
"created": "#a6da95",
"deleted": "#ed8796",
"error": "#ed8796",
"hidden": "#b8c0e0",
"hint": "#8aadf4",
"ignored": "#494d64",
"info": "#8aadf4",
"modified": "#eed49f",
"predictive": "#b8c0e0",
"renamed": "#f5bde6",
"success": "#a6da95",
"unreachable": "#b8c0e0",
"warning": "#eed49f",
"syntax": {
"attribute": {
"color": "#eed49f"
},
"boolean": {
"color": "#f5bde6"
},
"comment": {
"color": "#b8c0e0",
"font_style": "italic"
},
"comment.doc": {
"color": "#b8c0e0",
"font_style": "italic"
},
"constant": {
"color": "#eed49f"
},
"constructor": {
"color": "#8aadf4"
},
"embedded": {
"color": "#cad3f5"
},
"function": {
"color": "#8aadf4"
},
"keyword": {
"color": "#f5bde6"
},
"link_text": {
"color": "#8aadf4",
"font_style": "normal"
},
"link_uri": {
"color": "#b8c0e0",
"font_style": "italic"
},
"number": {
"color": "#eed49f"
},
"string": {
"color": "#a6da95"
},
"string.escape": {
"color": "#a6da95"
},
"string.regex": {
"color": "#a6da95"
},
"string.special": {
"color": "#eed49f"
},
"string.special.symbol": {
"color": "#eed49f"
},
"tag": {
"color": "#8aadf4"
},
"text.literal": {
"color": "#cad3f5"
},
"title": {
"color": "#f5bde6",
"font_weight": 600
},
"type": {
"color": "#8aadf4"
},
"property": {
"color": "#cad3f5"
},
"variable.special": {
"color": "#f5bde6"
}
}
}
},
{
"name": "Catppuccin Mocha",
"mode": "dark",
"colors": {
"accent.background": "#2e2e3e",
"accent.foreground": "#cdd6f4",
"background": "#181825",
"border": "#313244",
"ring": "#cba6f7",
"danger.background": "#f38ba8",
"danger.active.background": "#eba0ac",
"danger.hover.background": "#f38ba8",
"foreground": "#cdd6f4",
"input.border": "#6c7086",
"link.active.foreground": "#89b4fa",
"link.foreground": "#89b4fa",
"link.hover.foreground": "#89b4fa",
"list.active.background": "#89b4fa15",
"list.active.border": "#89b4fa77",
"list.hover.background": "#202031",
"list.even.background": "#161622",
"list.head.background": "#1E1E2E",
"muted.background": "#302d41",
"muted.foreground": "#6c7086",
"panel.background": "#302d41",
"popover.background": "#1e1e2e",
"popover.foreground": "#cdd6f4",
"primary.active.background": "#89b4fa",
"primary.background": "#89b4fa",
"primary.foreground": "#1e1e2e",
"primary.hover.background": "#74c7ec",
"scrollbar.background": "#1e1e2e00",
"scrollbar.thumb.background": "#4e4e5e",
"secondary.active.background": "#45475a",
"secondary.background": "#302d41",
"secondary.foreground": "#cdd6f4",
"secondary.hover.background": "#575268",
"tab.background": "#0B0B1100",
"tab.foreground": "#aeb7d5",
"tab_bar.background": "#0B0B11",
"title_bar.background": "#11111B",
"title_bar.border": "#313244",
"base.red": "#f38ba8",
"base.green": "#a6e3a1",
"base.yellow": "#f9e2af",
"base.blue": "#89b4fa",
"base.magenta": "#f5c2e7",
"base.magenta.light": "#f38ba866",
"base.cyan": "#94e2d5"
},
"highlight": {
"editor.foreground": "#cdd6f4",
"editor.background": "#181825",
"editor.active_line.background": "#222230AA",
"editor.line_number": "#6c7086",
"editor.active_line_number": "#cdd6f4",
"editor.invisible": "#7c7f9366",
"conflict": "#f38ba8",
"created": "#a6e3a1",
"deleted": "#f38ba8",
"error": "#f38ba8",
"hidden": "#6c7086",
"hint": "#89b4fa",
"ignored": "#6c7086",
"info": "#89b4fa",
"modified": "#f9e2af",
"predictive": "#6c7086",
"renamed": "#f5c2e7",
"success": "#a6e3a1",
"unreachable": "#6c7086",
"warning": "#f9e2af",
"syntax": {
"attribute": {
"color": "#f9e2af"
},
"boolean": {
"color": "#f5c2e7"
},
"comment": {
"color": "#6c7086",
"font_style": "italic"
},
"comment.doc": {
"color": "#6c7086",
"font_style": "italic"
},
"constant": {
"color": "#f9e2af"
},
"constructor": {
"color": "#89b4fa"
},
"embedded": {
"color": "#cdd6f4"
},
"function": {
"color": "#89b4fa"
},
"keyword": {
"color": "#f5c2e7"
},
"link_text": {
"color": "#89b4fa",
"font_style": "normal"
},
"link_uri": {
"color": "#6c7086",
"font_style": "italic"
},
"number": {
"color": "#f9e2af"
},
"string": {
"color": "#a6e3a1"
},
"string.escape": {
"color": "#a6e3a1"
},
"string.regex": {
"color": "#a6e3a1"
},
"string.special": {
"color": "#f9e2af"
},
"string.special.symbol": {
"color": "#f9e2af"
},
"tag": {
"color": "#89b4fa"
},
"text.literal": {
"color": "#cdd6f4"
},
"title": {
"color": "#f5c2e7",
"font_weight": 600
},
"type": {
"color": "#89b4fa"
},
"property": {
"color": "#cdd6f4"
},
"variable.special": {
"color": "#f5c2e7"
}
}
}
}
]
}
+286
View File
@@ -0,0 +1,286 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Everforest",
"author": "sainnhe",
"url": "https://github.com/sainnhe/everforest",
"themes": [
{
"name": "Everforest Light",
"mode": "light",
"colors": {
"accent.background": "#E7E5D4",
"accent.foreground": "#5F6D75",
"background": "#FEFCEE",
"border": "#E7E5D4",
"danger.background": "#e67e80",
"danger.foreground": "#ffffff",
"foreground": "#5F6D75",
"input.border": "#E7E5D4",
"link.active.foreground": "#7fbbb3",
"link.foreground": "#7fbbb3",
"link.hover.foreground": "#7fbbb3",
"list.active.background": "#a7c08025",
"list.active.border": "#a7c080",
"list.even.background": "#F4F1E2",
"list.head.background": "#F4F1E2",
"muted.background": "#f1f0e2",
"muted.foreground": "#959a9d",
"popover.background": "#FEFCEE",
"popover.foreground": "#5F6D75",
"primary.background": "#e69875",
"primary.foreground": "#FEFCEE",
"scrollbar.background": "#e0e0e000",
"scrollbar.thumb.background": "#D7D5C4",
"secondary.background": "#EEEADA",
"secondary.foreground": "#5F6D75",
"tab.active.background": "#FEFCEE",
"tab.active.foreground": "#5F6D75",
"tab.background": "#F4F1E200",
"tab.foreground": "#6b7b84",
"tab_bar.background": "#F4F1E2",
"title_bar.background": "#F9F5E4",
"title_bar.border": "#E7E5D4",
"status_bar.background": "#F9F5E4",
"base.red": "#e67e80",
"base.green": "#a7c080",
"base.yellow": "#dbbc7f",
"base.blue": "#7fbbb3",
"base.magenta": "#d699b6",
"base.magenta.light": "#d699b699",
"base.cyan": "#83c092"
},
"highlight": {
"editor.foreground": "#5F6D75",
"editor.background": "#FEFCEE",
"editor.active_line.background": "#E7E5D4",
"editor.line_number": "#959a9d",
"editor.active_line_number": "#5F6D75",
"editor.invisible": "#959a9d66",
"conflict": "#e67e80",
"created": "#a7c080",
"deleted": "#e67e80",
"error": "#e67e80",
"hidden": "#959a9d",
"hint": "#7fbbb3",
"ignored": "#959a9d",
"info": "#7fbbb3",
"modified": "#dbbc7f",
"predictive": "#5F6D75",
"renamed": "#a7c080",
"success": "#a7c080",
"unreachable": "#959a9d",
"warning": "#dbbc7f",
"syntax": {
"attribute": {
"color": "#dbbc7f"
},
"boolean": {
"color": "#e69875"
},
"comment": {
"color": "#959a9d",
"font_style": "italic"
},
"comment.doc": {
"color": "#959a9d",
"font_style": "italic"
},
"constant": {
"color": "#e69875"
},
"constructor": {
"color": "#a7c080"
},
"embedded": {
"color": "#5F6D75"
},
"function": {
"color": "#a7c080"
},
"keyword": {
"color": "#e67e80"
},
"link_text": {
"color": "#7fbbb3",
"font_style": "normal"
},
"link_uri": {
"color": "#5F6D75",
"font_style": "italic"
},
"number": {
"color": "#e69875"
},
"string": {
"color": "#a7c080"
},
"string.escape": {
"color": "#a7c080"
},
"string.regex": {
"color": "#a7c080"
},
"string.special": {
"color": "#dbbc7f"
},
"string.special.symbol": {
"color": "#dbbc7f"
},
"tag": {
"color": "#a7c080"
},
"text.literal": {
"color": "#dbbc7f"
},
"title": {
"color": "#e69875",
"font_weight": 600
},
"type": {
"color": "#7fbbb3"
},
"property": {
"color": "#5F6D75"
},
"variable.special": {
"color": "#e67e80"
}
}
}
},
{
"name": "Everforest Dark",
"mode": "dark",
"colors": {
"accent.background": "#3c4448",
"accent.foreground": "#d3c6aa",
"background": "#262E34",
"border": "#40484c",
"foreground": "#d3c6aa",
"input.border": "#485156",
"link.active.foreground": "#7fbbb3",
"link.foreground": "#7fbbb3",
"link.hover.foreground": "#7fbbb3",
"list.active.background": "#a7c08022",
"list.active.border": "#a7c08088",
"list.even.background": "#2E383B",
"list.head.background": "#2E383B",
"muted.background": "#2E383B",
"muted.foreground": "#6D7873",
"panel.background": "#2E383B",
"popover.background": "#262E34",
"popover.foreground": "#d3c6aa",
"primary.background": "#e69875",
"primary.foreground": "#262E34",
"scrollbar.background": "#2E383B00",
"scrollbar.thumb.background": "#485156",
"secondary.background": "#2E383B",
"secondary.foreground": "#849087",
"secondary.active.background": "#303b3e",
"secondary.hover.background": "#3E474B99",
"tab.active.background": "#262E34",
"tab.active.foreground": "#d3c6aa",
"tab.background": "#262E3400",
"tab.foreground": "#849087",
"tab_bar.background": "#2E383B",
"title_bar.background": "#1f262b",
"title_bar.border": "#40484c",
"base.red": "#e67e80",
"base.green": "#a7c080",
"base.yellow": "#dbbc7f",
"base.blue": "#7fbbb3",
"base.magenta": "#844d64",
"base.magenta.light": "#844d6499",
"base.cyan": "#83c092"
},
"highlight": {
"editor.foreground": "#d3c6aa",
"editor.background": "#262E34",
"editor.active_line.background": "#3c4448",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#959a9d66",
"conflict": "#D2602D",
"created": "#3f72e2",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"modified": "#B0A878",
"predictive": "#5D5945",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
}
]
}
+137
View File
@@ -0,0 +1,137 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Fahrenheit",
"author": "iTerm2-Color-Schemes",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
"themes": [
{
"name": "Fahrenheit",
"mode": "dark",
"colors": {
"accent.background": "#171717",
"accent.foreground": "#FFFFCE",
"background": "#000000",
"border": "#252525",
"danger.background": "#593002",
"foreground": "#FFFFCE",
"input.border": "#252525",
"list.active.background": "#72020222",
"list.active.border": "#720202",
"list.even.background": "#090909",
"list.hover.background": "#111111",
"muted.background": "#20202099",
"muted.foreground": "#828282",
"panel.background": "#1e1e1e",
"popover.background": "#090909",
"popover.foreground": "#FFFFCE",
"primary.background": "#720202",
"primary.foreground": "#FFFFCE",
"scrollbar.background": "#1e1e1e00",
"scrollbar.thumb.background": "#3e3e3e",
"secondary.background": "#202020",
"secondary.active.background": "#202020DD",
"secondary.foreground": "#979797",
"secondary.hover.background": "#20202099",
"tab.foreground": "#808080",
"title_bar.background": "#0e0e0e",
"table.background": "#1e1e1e00",
"table.hover.background": "#1E1E1E",
"ring": "#570101",
"base.red": "#723202",
"base.red.light": "#c97636",
"base.green": "#027225",
"base.green.light": "#39c936",
"base.yellow": "#726302",
"base.yellow.light": "#c9b536",
"base.blue": "#022d72",
"base.blue.light": "#0551cb",
"base.magenta": "#3a286c",
"base.magenta.light": "#58197a",
"base.cyan": "#027272",
"base.cyan.light": "#36c9c9"
},
"highlight": {
"editor.foreground": "#FFFFCE",
"editor.background": "#000000",
"editor.active_line.background": "#1e1e1e",
"editor.line_number": "#828282",
"editor.invisible": "#82828266",
"warning.border": "#720202",
"syntax": {
"attribute": {
"color": "#fecf75"
},
"boolean": {
"color": "#fd9f4d"
},
"comment": {
"color": "#828282"
},
"comment.doc": {
"color": "#828282"
},
"constant": {
"color": "#fd9f4d"
},
"constructor": {
"color": "#cb4a05"
},
"embedded": {
"color": "#dcdcdc"
},
"function": {
"color": "#fd9f4d"
},
"keyword": {
"color": "#cb4a05"
},
"link_text": {
"color": "#cda074",
"font_style": "normal"
},
"link_uri": {
"color": "#9e744d",
"font_style": "italic"
},
"number": {
"color": "#cb4a05"
},
"string": {
"color": "#cc734d"
},
"string.escape": {
"color": "#cc734d"
},
"string.regex": {
"color": "#cc734d"
},
"string.special": {
"color": "#fd9f4d"
},
"string.special.symbol": {
"color": "#fd9f4d"
},
"tag": {
"color": "#cb4a05"
},
"text.literal": {
"color": "#fd9f4d"
},
"title": {
"color": "#cda074",
"font_weight": 600
},
"type": {
"color": "#cda074"
},
"property": {
"color": "#dcdcdc"
},
"variable.special": {
"color": "#cb4a05"
}
}
}
}
]
}
+307
View File
@@ -0,0 +1,307 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Flexoki",
"author": "kepano",
"url": "https://github.com/kepano/flexoki",
"themes": [
{
"name": "Flexoki Light",
"mode": "light",
"colors": {
"accent.background": "#E8E4CE",
"accent.foreground": "#100F0F",
"background": "#FFFCF0",
"foreground": "#100F0F",
"border": "#E6E4D9",
"ring": "#D0A215",
"selection.background": "#D0A21577",
"input.border": "#E6E4D9",
"list.active.background": "#D0A21520",
"list.active.border": "#D0A215",
"list.background": "#FFFCF0",
"list.even.background": "#F2F0E5",
"muted.background": "#F2F0E5",
"muted.foreground": "#6F6E69",
"primary.background": "#3AA99F",
"primary.foreground": "#FAFAFA",
"info.background": "#4385BE",
"scrollbar.background": "#FAFAFA00",
"scrollbar.thumb.background": "#E6E4D9",
"scrollbar.thumb.hover.background": "#DAD8CE",
"secondary.active.background": "#DAD8CE",
"secondary.background": "#F2F0E5",
"secondary.foreground": "#100F0F",
"secondary.hover.background": "#E6E4D9",
"tab.active.background": "#FFFCF0",
"tab.active.foreground": "#100F0F",
"tab.background": "#ECECED00",
"tab.foreground": "#8d8986",
"tab_bar.background": "#F2F0E5",
"title_bar.background": "#F2F0E5",
"title_bar.border": "#DAD8CE",
"base.yellow": "#D0A215",
"base.red": "#D14D41",
"base.blue": "#4385BE",
"base.green": "#879A39",
"base.magenta": "#CE5D97",
"base.cyan": "#3AA99F"
},
"highlight": {
"editor.foreground": "#100F0F",
"editor.background": "#FFFCF0",
"editor.active_line.background": "#F2F0E5",
"editor.line_number": "#B7B5AC",
"editor.active_line_number": "#24837B",
"editor.invisible": "#B7B5AC66",
"conflict": "#AD8301",
"conflict.background": "#FAEEC6",
"conflict.border": "#AD8301",
"created": "#66800B",
"created.background": "#EDEECF",
"created.border": "#66800B",
"deleted": "#AF3029",
"deleted.background": "#FFE1D5",
"deleted.border": "#AF3029",
"error": "#AF3029",
"error.background": "#FFE1D5",
"error.border": "#AF3029",
"hidden": "#B7B5AC",
"hidden.background": "#F2F0E5",
"hidden.border": "#B7B5AC",
"hint": "#B7B5AC",
"hint.background": "#F2F0E5",
"hint.border": "#B7B5AC",
"ignored": "#B7B5AC",
"ignored.background": "#F2F0E5",
"ignored.border": "#B7B5AC",
"info": "#24837B",
"info.background": "#DDF1E4",
"info.border": "#24837B",
"modified": "#AD8301",
"modified.background": "#FAEEC6",
"modified.border": "#AD8301",
"predictive": "#B7B5AC",
"renamed": "#24837B",
"renamed.background": "#DDF1E4",
"renamed.border": "#24837B",
"success": "#66800B",
"success.background": "#EDEECF",
"success.border": "#66800B",
"unreachable": "#6F6E69",
"unreachable.background": "#F2F0E5",
"unreachable.border": "#6F6E69",
"warning": "#AD8301",
"warning.background": "#FAEEC6",
"warning.border": "#AD8301",
"syntax": {
"attribute": {
"color": "#205EA6"
},
"boolean": {
"color": "#BC5215"
},
"comment": {
"color": "#B7B5AC"
},
"comment.doc": {
"color": "#B7B5AC"
},
"constant": {
"color": "#F07171"
},
"constructor": {
"color": "#205EA6"
},
"embedded": {
"color": "#5C6773"
},
"function": {
"color": "#BC5215"
},
"keyword": {
"color": "#66800B"
},
"link_text": {
"color": "#24837B"
},
"link_uri": {
"color": "#24837B"
},
"number": {
"color": "#5E409D"
},
"string": {
"color": "#24837B"
},
"string.escape": {
"color": "#24837B"
},
"string.regex": {
"color": "#24837B"
},
"string.special": {
"color": "#24837B"
},
"string.special.symbol": {
"color": "#24837B"
},
"tag": {
"color": "#205EA6"
},
"text.literal": {
"color": "#24837B"
},
"title": {
"color": "#24837B",
"font_weight": 600
},
"type": {
"color": "#AD8301"
},
"property": {
"color": "#BC5215"
},
"variable.special": {
"color": "#205EA6"
}
}
}
},
{
"name": "Flexoki Dark",
"mode": "dark",
"colors": {
"accent.background": "#242221",
"accent.foreground": "#CECDC3",
"background": "#100F0F",
"border": "#282726",
"ring": "#AD8301",
"selection.background": "#D0A21577",
"foreground": "#CECDC3",
"input.border": "#282726",
"list.active.background": "#D0A21515",
"list.active.border": "#AD8301",
"list.even.background": "#1C1B1A",
"muted.background": "#1C1B1A",
"muted.foreground": "#878580",
"panel.background": "#1C1B1A",
"popover.background": "#100F0F",
"popover.foreground": "#CECDC3",
"primary.background": "#24837B",
"primary.foreground": "#FFFCF0",
"info.background": "#205EA6",
"scrollbar.background": "#100F0F00",
"scrollbar.thumb.background": "#282726",
"scrollbar.thumb.hover.background": "#343331",
"secondary.background": "#232221",
"secondary.active.background": "#262423",
"secondary.foreground": "#CECDC3",
"secondary.hover.background": "#232221",
"tab.active.background": "#100F0F",
"tab.active.foreground": "#CECDC3",
"tab.background": "#1C1B1A",
"tab.foreground": "#878580",
"tab_bar.background": "#1C1B1A99",
"title_bar.background": "#191818",
"base.yellow": "#AD8301",
"base.yellow.light": "#D0A215",
"base.red": "#AF3029",
"base.red.light": "#D14D41",
"base.blue": "#205EA6",
"base.blue.light": "#4385BE",
"base.green": "#66800B",
"base.green.light": "#879A39",
"base.magenta": "#A02F6F",
"base.magenta.light": "#CE5D97",
"base.cyan": "#24837B",
"base.cyan.light": "#3AA99F"
},
"highlight": {
"editor.foreground": "#CECDC3",
"editor.background": "#100F0F",
"editor.active_line.background": "#1C1B1A",
"editor.line_number": "#575653",
"editor.active_line_number": "#3AA99F",
"editor.invisible": "#B7B5AC66",
"conflict": "#D0A215",
"created": "#879A39",
"hidden": "#575653",
"hint": "#575653",
"modified": "#D0A215",
"predictive": "#878580",
"syntax": {
"attribute": {
"color": "#4385BE"
},
"boolean": {
"color": "#DA702C"
},
"comment": {
"color": "#575653"
},
"comment.doc": {
"color": "#575653"
},
"constant": {
"color": "#CE5D97"
},
"constructor": {
"color": "#4385BE"
},
"embedded": {
"color": "#878580"
},
"function": {
"color": "#DA702C"
},
"keyword": {
"color": "#879A39"
},
"link_text": {
"color": "#3AA99F"
},
"link_uri": {
"color": "#3AA99F"
},
"number": {
"color": "#8B7EC8"
},
"string": {
"color": "#3AA99F"
},
"string.escape": {
"color": "#3AA99F"
},
"string.regex": {
"color": "#3AA99F"
},
"string.special": {
"color": "#3AA99F"
},
"string.special.symbol": {
"color": "#3AA99F"
},
"tag": {
"color": "#4385BE"
},
"text.literal": {
"color": "#3AA99F"
},
"title": {
"color": "#D0A215",
"font_weight": 600
},
"type": {
"color": "#D0A215"
},
"property": {
"color": "#DA702C"
},
"variable.special": {
"color": "#4385BE"
}
}
}
}
]
}
+302
View File
@@ -0,0 +1,302 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Gruvbox",
"author": "Pavel Pertsev",
"url": "https://github.com/morhetz/gruvbox",
"themes": [
{
"name": "Gruvbox Light",
"mode": "light",
"colors": {
"accent.background": "#e6d19e",
"accent.foreground": "#3c3836",
"background": "#fbf1c7",
"border": "#d5c4a1",
"danger.background": "#fb4934",
"danger.active.background": "#cc241d",
"danger.foreground": "#fbf1c7",
"foreground": "#3c3836",
"input.border": "#d5c4a1",
"link.active.foreground": "#458588",
"link.foreground": "#458588",
"link.hover.foreground": "#458588",
"list.active.background": "#d7992122",
"list.hover.background": "#ebdbb2",
"list.active.border": "#d79921",
"list.even.background": "#f9ecba",
"muted.background": "#d5c4a166",
"muted.foreground": "#928374",
"panel.background": "#ebdbb2",
"popover.background": "#fbf1c7",
"popover.foreground": "#3c3836",
"primary.active.background": "#b7841f",
"primary.background": "#d79921",
"primary.foreground": "#fbf1c7",
"primary.hover.background": "#d79921ee",
"ring": "#d79921",
"scrollbar.background": "#fbf1c700",
"scrollbar.thumb.background": "#d5c4a1",
"secondary.background": "#EBDBB2",
"secondary.active.background": "#d5c4a1",
"secondary.foreground": "#3c3836",
"secondary.hover.background": "#e8d5a6",
"tab.active.background": "#fbf1c7",
"tab.active.foreground": "#3c3836",
"tab.background": "#ebdbb200",
"tab.foreground": "#928374",
"tab_bar.background": "#ebdbb2",
"title_bar.background": "#ebdbb2",
"title_bar.border": "#d5c4a1",
"base.magenta.light": "#D3869B66",
"base.red": "#fb4934",
"base.green": "#67a64f",
"base.yellow": "#b57614",
"base.blue": "#076678",
"base.magenta": "#8f3f71",
"base.cyan": "#427b58"
},
"highlight": {
"editor.foreground": "#3c3836",
"editor.background": "#fbf1c7",
"editor.active_line.background": "#ebdbb2",
"editor.line_number": "#928374",
"editor.active_line_number": "#3c3836",
"editor.invisible": "#92837466",
"conflict": "#cc241d",
"created": "#79740e",
"deleted": "#9d0006",
"error": "#cc241d",
"hidden": "#928374",
"hint": "#458588",
"ignored": "#928374",
"info": "#458588",
"modified": "#b57614",
"predictive": "#928374",
"renamed": "#b57614",
"success": "#79740e",
"unreachable": "#928374",
"warning": "#d79921",
"syntax": {
"attribute": {
"color": "#b57614"
},
"boolean": {
"color": "#d79921"
},
"comment": {
"color": "#928374",
"font_style": "italic"
},
"comment.doc": {
"color": "#928374",
"font_style": "italic"
},
"constant": {
"color": "#d79921"
},
"constructor": {
"color": "#b57614"
},
"embedded": {
"color": "#3c3836"
},
"function": {
"color": "#d79921"
},
"keyword": {
"color": "#cc241d"
},
"link_text": {
"color": "#458588",
"font_style": "normal"
},
"link_uri": {
"color": "#076678",
"font_style": "italic"
},
"number": {
"color": "#cc241d"
},
"string": {
"color": "#79740e"
},
"string.escape": {
"color": "#79740e"
},
"string.regex": {
"color": "#79740e"
},
"string.special": {
"color": "#d79921"
},
"string.special.symbol": {
"color": "#d79921"
},
"tag": {
"color": "#b57614"
},
"text.literal": {
"color": "#d79921"
},
"title": {
"color": "#b57614",
"font_weight": 600
},
"type": {
"color": "#b57614"
},
"property": {
"color": "#3c3836"
},
"variable.special": {
"color": "#cc241d"
}
}
}
},
{
"name": "Gruvbox Dark",
"mode": "dark",
"colors": {
"accent.background": "#303030",
"accent.foreground": "#ebdbb2",
"background": "#1d2021",
"border": "#3e3936",
"danger.active.background": "#fb4934",
"foreground": "#ebdbb2",
"input.border": "#504945",
"link.active.foreground": "#83a598",
"link.foreground": "#83a598",
"link.hover.foreground": "#83a598",
"list.active.background": "#d7992111",
"list.active.border": "#b17f1b",
"list.even.background": "#282828",
"muted.background": "#3c383655",
"muted.foreground": "#928374",
"panel.background": "#282828",
"popover.background": "#1d2021",
"popover.foreground": "#ebdbb2",
"primary.background": "#d79921",
"primary.foreground": "#282828",
"primary.hover.background": "#fabd2f",
"ring": "#b17f1b",
"scrollbar.background": "#1d202100",
"scrollbar.thumb.background": "#504945",
"secondary.active.background": "#333332",
"secondary.background": "#282828",
"secondary.foreground": "#ebdbb2",
"secondary.hover.background": "#33333299",
"tab.active.background": "#1d2021",
"tab.active.foreground": "#ebdbb2",
"tab.background": "#28282800",
"tab.foreground": "#928374",
"tab_bar.background": "#28282899",
"title_bar.background": "#252525",
"title_bar.border": "#3e3936",
"base.magenta.light": "#D3869B66",
"base.red": "#f06555",
"base.green": "#98971a",
"base.yellow": "#d79921",
"base.blue": "#458588",
"base.magenta": "#b16286",
"base.cyan": "#689d6a"
},
"highlight": {
"editor.foreground": "#DDDDDD",
"editor.background": "#1d2021",
"editor.active_line.background": "#131313",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#92837466",
"conflict": "#f06555",
"created": "#3f72e2",
"deleted": "#9d0006",
"error": "#cc241d",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"ignored": "#928374",
"info": "#458588",
"modified": "#B0A878",
"predictive": "#5D5945",
"renamed": "#b57614",
"success": "#79740e",
"unreachable": "#928374",
"warning": "#d79921",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
}
]
}
+150
View File
@@ -0,0 +1,150 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Harper",
"author": "iTerm2-Color-Schemes",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
"themes": [
{
"name": "Harper",
"mode": "dark",
"colors": {
"accent.background": "#27222c",
"accent.foreground": "#a8a49d",
"background": "#010101",
"border": "#333333",
"foreground": "#a8a49d",
"input.border": "#333333",
"list.active.background": "#B196C622",
"list.active.border": "#B196C699",
"list.even.background": "#18151B77",
"muted.background": "#171717",
"muted.foreground": "#726E69",
"panel.background": "#003a5b",
"popover.background": "#010101",
"popover.foreground": "#a8a49d",
"primary.background": "#B196C6",
"primary.foreground": "#000000",
"scrollbar.background": "#003a5b00",
"scrollbar.thumb.background": "#726E69",
"secondary.background": "#1b1b1b",
"secondary.active.background": "#2c2632",
"secondary.foreground": "#A8A49D",
"secondary.hover.background": "#23232399",
"tab.active.background": "#010101",
"tab.active.foreground": "#a8a49d",
"tab.foreground": "#787878",
"title_bar.background": "#101010",
"base.red": "#ff5874",
"base.red.light": "#ff587499",
"base.green": "#489e48",
"base.green.light": "#489e4899",
"base.blue": "#7fb5e1",
"base.blue.light": "#7fb5e199",
"base.cyan": "#bff5e5",
"base.cyan.light": "#bff5e599",
"base.magenta": "#b296c6",
"base.magenta.light": "#b296c699",
"base.yellow": "#f8b63f",
"base.yellow.light": "#f8b63f99"
},
"highlight": {
"editor.foreground": "#a8a49d",
"editor.background": "#010101",
"editor.active_line.background": "#18151B",
"editor.line_number": "#726E69",
"editor.active_line_number": "#a8a49d",
"editor.invisible": "#726E6966",
"conflict": "#ff5874",
"created": "#489e48",
"deleted": "#ff5874",
"error": "#ff5874",
"hidden": "#726E69",
"hint": "#7fb5e1",
"ignored": "#726E69",
"info": "#7fb5e1",
"modified": "#b296c6",
"predictive": "#726E69",
"renamed": "#b296c6",
"success": "#489e48",
"unreachable": "#726E69",
"warning": "#f8b63f",
"syntax": {
"attribute": {
"color": "#b296c6"
},
"boolean": {
"color": "#f8b63f"
},
"comment": {
"color": "#726E69",
"font_style": "italic"
},
"comment.doc": {
"color": "#726E69",
"font_style": "italic"
},
"constant": {
"color": "#f8b63f"
},
"constructor": {
"color": "#b296c6"
},
"embedded": {
"color": "#a8a49d"
},
"function": {
"color": "#f8b63f"
},
"keyword": {
"color": "#ff5874"
},
"link_text": {
"color": "#7fb5e1",
"font_style": "normal"
},
"link_uri": {
"color": "#bff5e5",
"font_style": "italic"
},
"number": {
"color": "#ff5874"
},
"string": {
"color": "#489e48"
},
"string.escape": {
"color": "#489e48"
},
"string.regex": {
"color": "#489e48"
},
"string.special": {
"color": "#f8b63f"
},
"string.special.symbol": {
"color": "#f8b63f"
},
"tag": {
"color": "#b296c6"
},
"text.literal": {
"color": "#f8b63f"
},
"title": {
"color": "#b296c6",
"font_weight": 600
},
"type": {
"color": "#b296c6"
},
"property": {
"color": "#a8a49d"
},
"variable.special": {
"color": "#ff5874"
}
}
}
}
]
}
+303
View File
@@ -0,0 +1,303 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Hybrid",
"author": "Andrew Wong",
"url": "https://github.com/w0ng/vim-hybrid",
"themes": [
{
"name": "Hybrid Light",
"mode": "light",
"shadow": false,
"colors": {
"accent.background": "#c8ced1",
"accent.foreground": "#1c1c1c",
"background": "#E4E4E4",
"border": "#CACACA",
"danger.background": "#ff5f5f",
"danger.foreground": "#ffffff",
"foreground": "#1c1c1c",
"input.border": "#CACACA",
"link.active.foreground": "#005f87",
"link.foreground": "#005f87",
"link.hover.foreground": "#005f87",
"list.active.background": "#79792C20",
"list.active.border": "#79792C",
"list.hover.background": "#D5D5D5",
"list.even.background": "#DFDFDF",
"muted.background": "#d7d7d7",
"muted.foreground": "#5f5f5f",
"primary.background": "#005f87",
"primary.foreground": "#ffffff",
"scrollbar.background": "#E0E0E000",
"scrollbar.thumb.background": "#8f8f8f",
"secondary.background": "#D0D0D0",
"secondary.active.background": "#c4c4c4",
"secondary.foreground": "#1c1c1c",
"secondary.hover.background": "#c4c4c499",
"tab.active.background": "#E4E4E4",
"tab.active.foreground": "#1c1c1c",
"tab.background": "#e8e8e800",
"tab.foreground": "#5f5f5f",
"tab_bar.background": "#e8e8e8",
"title_bar.background": "#D0D0D0",
"title_bar.border": "#B3B3B3",
"status_bar.background": "#DADADA",
"base.red": "#5F0000",
"base.green": "#005F00",
"base.yellow": "#948000",
"base.blue": "#00195f",
"base.magenta": "#5f1c51",
"base.magenta.light": "#5f1c5111",
"base.cyan": "#005a5f"
},
"highlight": {
"editor.foreground": "#1c1c1c",
"editor.background": "#E4E4E4",
"editor.active_line.background": "#D3D3D3",
"editor.line_number": "#5F5F5F",
"editor.active_line_number": "#1C1C1C",
"editor.invisible": "#5F5F5F66",
"conflict": "#D2602D",
"created": "#3F72E2",
"deleted": "#FF5F5F",
"error": "#FF5F5F",
"hidden": "#9E9E9E",
"hint": "#5F87AF",
"ignored": "#B3B3B3",
"info": "#005F87",
"modified": "#B0A878",
"predictive": "#5D5945",
"renamed": "#5F87AF",
"success": "#005F00",
"unreachable": "#9E9E9E",
"warning": "#948000",
"syntax": {
"attribute": {
"color": "#948000"
},
"boolean": {
"color": "#005F00"
},
"comment": {
"color": "#5F5F5F",
"font_style": "italic"
},
"comment.doc": {
"color": "#5F5F5F",
"font_style": "italic"
},
"constant": {
"color": "#005F87"
},
"constructor": {
"color": "#79792C"
},
"embedded": {
"color": "#1C1C1C"
},
"function": {
"color": "#005F87"
},
"keyword": {
"color": "#5F1C51"
},
"link_text": {
"color": "#005F87",
"font_style": "underline"
},
"link_uri": {
"color": "#005F87",
"font_style": "italic"
},
"number": {
"color": "#005F00"
},
"string": {
"color": "#005F00"
},
"string.escape": {
"color": "#005F00"
},
"string.regex": {
"color": "#005F00"
},
"string.special": {
"color": "#005F87"
},
"string.special.symbol": {
"color": "#005F87"
},
"tag": {
"color": "#79792C"
},
"text.literal": {
"color": "#005F87"
},
"title": {
"color": "#005F87",
"font_weight": 600
},
"type": {
"color": "#5F1C51"
},
"property": {
"color": "#1C1C1C"
},
"variable.special": {
"color": "#5F1C51"
}
}
}
},
{
"name": "Hybrid Dark",
"mode": "dark",
"colors": {
"accent.background": "#31393a",
"accent.foreground": "#e8e8e8",
"background": "#1D1F21",
"border": "#34373c",
"foreground": "#e8e8e8",
"input.border": "#34373c",
"link.active.foreground": "#87d7ff",
"link.foreground": "#81A2BE",
"link.hover.foreground": "#006f9f",
"list.active.background": "#15678a10",
"list.active.border": "#15678a",
"list.even.background": "#282A2E",
"muted.background": "#282A2E99",
"muted.foreground": "#878787",
"panel.background": "#1D1F21",
"popover.background": "#1D1F21",
"popover.foreground": "#e8e8e8",
"primary.background": "#15678a",
"primary.foreground": "#e8e8e8",
"scrollbar.background": "#1D1F2100",
"scrollbar.thumb.background": "#5f5f5f",
"secondary.background": "#303336",
"secondary.active.background": "#33363b",
"secondary.foreground": "#e8e8e8",
"secondary.hover.background": "#373b3e",
"tab.active.background": "#1D1F21",
"tab.active.foreground": "#d4d4d4",
"tab.background": "#24262700",
"tab.foreground": "#999999",
"tab_bar.background": "#242627",
"title_bar.background": "#242629",
"title_bar.border": "#34373c",
"base.red": "#8a1515",
"base.red.light": "#ff5f5f",
"base.green": "#7c8a15",
"base.green.light": "#b3bf5a",
"base.yellow": "#8a7c15",
"base.yellow.light": "#e4b55e",
"base.blue": "#15678a",
"base.blue.light": "#6e90b0",
"base.magenta": "#8a1567",
"base.magenta.light": "#B294BB",
"base.cyan": "#15678a",
"base.cyan.light": "#7fbfb4"
},
"highlight": {
"editor.foreground": "#DDDDDD",
"editor.background": "#000000",
"editor.active_line.background": "#131313",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#5F5F5F66",
"conflict": "#D2602D",
"created": "#3f72e2",
"created.background": "#0C4619",
"deleted.background": "#46190C",
"error.background": "#46190C",
"error.border": "#802207",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"hint.background": "#250c4b",
"hint.border": "#3f0891",
"info.background": "#0C194D",
"info.border": "#082190",
"modified": "#B0A878",
"modified.background": "#3A310E",
"predictive": "#5D5945",
"success.background": "#0C4619",
"warning.background": "#3A310E",
"warning.border": "#7B6508",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
}
]
}
+147
View File
@@ -0,0 +1,147 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Jellybeans",
"author": "nanotech",
"url": "https://github.com/nanotech/jellybeans.vim",
"themes": [
{
"name": "Jellybeans",
"mode": "dark",
"colors": {
"accent.background": "#292929",
"accent.foreground": "#e8e8e8",
"background": "#151515",
"border": "#2e2e2e",
"danger.foreground": "#151515",
"foreground": "#E8E8D3",
"input.border": "#4E4847",
"link.active.foreground": "#97bedc",
"link.foreground": "#97bedc",
"link.hover.foreground": "#97bedc",
"list.active.background": "#97bedc15",
"list.active.border": "#97bedc",
"list.even.background": "#1C1C1C",
"muted.background": "#242424",
"muted.foreground": "#767676",
"panel.background": "#151515",
"popover.background": "#151515",
"popover.foreground": "#e8e8e8",
"primary.background": "#97bedc",
"primary.foreground": "#151515",
"scrollbar.background": "#26262600",
"scrollbar.thumb.background": "#6e6e6e",
"secondary.background": "#2F2F2F",
"secondary.active.background": "#2f2f2f",
"secondary.foreground": "#e8e8e8",
"secondary.hover.background": "#252525",
"tab.active.background": "#151515",
"tab.active.foreground": "#e8e8e8",
"tab.background": "#10101000",
"tab.foreground": "#969696",
"tab_bar.background": "#101010",
"title_bar.background": "#101010",
"title_bar.border": "#2e2e2e",
"base.red": "#e27373",
"base.red.light": "#ffa1a1",
"base.green": "#94b979",
"base.green.light": "#bddeab",
"base.yellow": "#ffba7b",
"base.yellow.light": "#ffdca0",
"base.blue": "#97bedc",
"base.blue.light": "#b1d8f6",
"base.magenta": "#B294BB",
"base.magenta.light": "#fbdaff",
"base.cyan": "#00988e",
"base.cyan.light": "#1ab2a8"
},
"highlight": {
"editor.foreground": "#DDDDDD",
"editor.background": "#000000",
"editor.active_line.background": "#131313",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#9E9E9E66",
"conflict": "#D2602D",
"created": "#3f72e2",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"modified": "#B0A878",
"predictive": "#5D5945",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
}
]
}
+157
View File
@@ -0,0 +1,157 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Kibble",
"author": "iTerm2-Color-Schemes",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
"themes": [
{
"name": "Kibble",
"mode": "dark",
"colors": {
"accent.background": "#252525",
"accent.foreground": "#f7f7f7",
"background": "#0e100a",
"border": "#292a24",
"danger.background": "#e07b5c",
"foreground": "#f7f7f7",
"input.border": "#333333",
"list.active.background": "#2BCF1315",
"list.active.border": "#2BCF13",
"list.hover.background": "#2c292b54",
"list.even.background": "#24222355",
"muted.background": "#292a2455",
"muted.foreground": "#777777",
"panel.background": "#003a5b",
"popover.background": "#0e100a",
"popover.foreground": "#f7f7f7",
"primary.active.background": "#6ce05c99",
"primary.background": "#6ce05c",
"primary.foreground": "#101010",
"scrollbar.background": "#003a5b00",
"scrollbar.thumb.background": "#726E69",
"secondary.background": "#22231f",
"secondary.active.background": "#292825",
"secondary.foreground": "#f7f7f7",
"secondary.hover.background": "#22231f",
"success.background": "#5c6ee0",
"warning.background": "#e0c85c",
"info.background": "#5cd1e0",
"selection.background": "#9ba787",
"tab.active.background": "#0e100a",
"tab.active.foreground": "#f7f7f7",
"tab.foreground": "#969696",
"title_bar.background": "#161712",
"base.red": "#C70231",
"base.red.light": "#e07b5c",
"base.green": "#2BCF13",
"base.green.light": "#6ce05c",
"base.blue": "#3449d1",
"base.blue.light": "#5c6ee0",
"base.cyan": "#0798ab",
"base.cyan.light": "#5cd1e0",
"base.magenta": "#8400ff",
"base.magenta.light": "#C495F0",
"base.yellow": "#c7a302",
"base.yellow.light": "#e0c85c"
},
"highlight": {
"editor.foreground": "#f7f7f7",
"editor.background": "#0e100a",
"editor.active_line.background": "#242223",
"editor.line_number": "#726E69",
"editor.active_line_number": "#f7f7f7",
"editor.invisible": "#726E6966",
"conflict": "#c70031",
"created": "#2BCF13",
"deleted": "#c70031",
"error": "#c70031",
"hidden": "#726E69",
"hint": "#0798ab",
"ignored": "#726E69",
"info": "#0798ab",
"modified": "#3449d1",
"predictive": "#726E69",
"renamed": "#3449d1",
"success": "#2BCF13",
"unreachable": "#726E69",
"warning": "#c7a302",
"syntax": {
"attribute": {
"color": "#3449d1"
},
"boolean": {
"color": "#c7a302"
},
"comment": {
"color": "#726E69",
"font_style": "italic"
},
"comment.doc": {
"color": "#726E69",
"font_style": "italic"
},
"constant": {
"color": "#c7a302"
},
"constructor": {
"color": "#3449d1"
},
"embedded": {
"color": "#f7f7f7"
},
"function": {
"color": "#c7a302"
},
"keyword": {
"color": "#c70031"
},
"link_text": {
"color": "#0798ab",
"font_style": "normal"
},
"link_uri": {
"color": "#68f2e0",
"font_style": "italic"
},
"number": {
"color": "#c70031"
},
"string": {
"color": "#2BCF13"
},
"string.escape": {
"color": "#2BCF13"
},
"string.regex": {
"color": "#2BCF13"
},
"string.special": {
"color": "#c7a302"
},
"string.special.symbol": {
"color": "#c7a302"
},
"tag": {
"color": "#3449d1"
},
"text.literal": {
"color": "#c7a302"
},
"title": {
"color": "#3449d1",
"font_weight": 600
},
"type": {
"color": "#3449d1"
},
"property": {
"color": "#f7f7f7"
},
"variable.special": {
"color": "#c70031"
}
}
}
}
]
}
+288
View File
@@ -0,0 +1,288 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "macOS Classic",
"author": "huacnlee",
"url": "https://github.com/huacnlee/zed-theme-macos-classic",
"themes": [
{
"name": "macOS Classic Light",
"mode": "light",
"shadow": false,
"colors": {
"accent.background": "#E0E0E0",
"accent.foreground": "#000000",
"background": "#F9F9F9",
"foreground": "#000000",
"border": "#D2D2D2",
"ring": "#0060de",
"danger.foreground": "#FFFFFF",
"list.active.background": "#0060de15",
"list.active.border": "#0060de",
"list.even.background": "#EFEFEF",
"list.hover.background": "#E7E7E7",
"muted.background": "#EAEAEA",
"muted.foreground": "#707070",
"popover.background": "#F5F5F5",
"popover.foreground": "#000000",
"primary.background": "#0060de",
"primary.foreground": "#FFFFFF",
"scrollbar.background": "#FFFFFF00",
"scrollbar.thumb.background": "#C8C8C8",
"secondary.active.background": "#D9D9D9",
"secondary.background": "#E0E0E0",
"secondary.foreground": "#000000",
"secondary.hover.background": "#E5E5E5",
"tab.active.foreground": "#000000",
"tab.background": "#E9E9E9",
"tab.foreground": "#606060",
"tab_bar.background": "#E9E9E9",
"title_bar.background": "#F7F7F7",
"title_bar.border": "#DADADA",
"status_bar.background": "#E9E9E9",
"base.yellow": "#B59A00",
"base.red": "#d21f07",
"base.blue": "#0060de",
"base.green": "#319a00",
"base.magenta": "#9A0068",
"base.cyan": "#007E8A"
},
"highlight": {
"editor.foreground": "#000000",
"editor.background": "#ffffff",
"editor.active_line.background": "#F5F5F5",
"editor.line_number": "#929292",
"editor.active_line_number": "#000000",
"editor.invisible": "#007fff66",
"conflict": "#d21f07",
"created": "#0060de",
"hidden": "#6D6D6D",
"hint": "#9A0068",
"modified": "#319a00",
"predictive": "#A4ABB6",
"warning": "#B59A00",
"syntax": {
"attribute": {
"color": "#957931"
},
"boolean": {
"color": "#C5060B"
},
"comment": {
"color": "#007fff"
},
"comment.doc": {
"color": "#007fff"
},
"constant": {
"color": "#C5060B"
},
"constructor": {
"color": "#0433ff"
},
"embedded": {
"color": "#333333"
},
"function": {
"color": "#0000A2"
},
"keyword": {
"color": "#0433ff"
},
"link_text": {
"color": "#0000A2",
"font_style": "normal"
},
"link_uri": {
"color": "#6A7293",
"font_style": "italic"
},
"number": {
"color": "#0433ff"
},
"string": {
"color": "#036A07"
},
"string.escape": {
"color": "#036A07"
},
"string.regex": {
"color": "#036A07"
},
"string.special": {
"color": "#d21f07"
},
"string.special.symbol": {
"color": "#d21f07"
},
"tag": {
"color": "#0433ff"
},
"text.literal": {
"color": "#6F42C1"
},
"title": {
"color": "#0433FF"
},
"type": {
"color": "#6f42c1"
},
"property": {
"color": "#333333"
},
"variable": {
"color": "#333333"
},
"variable.special": {
"color": "#C5060B"
}
}
}
},
{
"name": "macOS Classic Dark",
"mode": "dark",
"shadow": false,
"colors": {
"accent.background": "#282629",
"accent.foreground": "#CACCCA",
"background": "#131313",
"border": "#303030",
"ring": "#077CFD",
"foreground": "#DEDEDE",
"danger.background": "#E44A4F",
"list.even.background": "#232323",
"list.active.background": "#0059D115",
"list.active.border": "#077CFD",
"muted.background": "#202020",
"muted.foreground": "#9D9D9D",
"popover.background": "#101010",
"popover.foreground": "#CACCCA",
"primary.background": "#077CFD",
"primary.foreground": "#F2F9FF",
"switch.background": "#393939",
"switch.thumb.background": "#DAECFF",
"scrollbar.background": "#13131300",
"scrollbar.thumb.background": "#9F9F9F",
"secondary.active.background": "#353535",
"secondary.background": "#353535",
"secondary.foreground": "#DEDEDE",
"secondary.hover.background": "#35353599",
"link": "#419CFF",
"tab_bar.background": "#1C1C1E",
"tab.active.background": "#131313",
"tab.foreground": "#8F8F8F",
"title_bar.background": "#171717",
"selection.background": "#3F638B",
"base.blue": "#419CFF",
"base.cyan": "#07FDD3",
"base.green": "#30D158",
"base.magenta": "#A550A7",
"base.red": "#FF5257",
"base.yellow": "#FFC600"
},
"highlight": {
"editor.foreground": "#CACCCA",
"editor.background": "#131313",
"editor.active_line.background": "#35343666",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#007fff66",
"conflict": "#D2602D",
"created": "#3f72e2",
"created.background": "#0C4619",
"deleted.background": "#46190C",
"error.background": "#46190C",
"error.border": "#E44A4F",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"hint.background": "#250c4b",
"hint.border": "#3f0891",
"info.background": "#0059D1",
"info.border": "#0059D1",
"modified": "#B0A878",
"modified.background": "#3A310E",
"predictive": "#5D5945",
"success.background": "#0C4619",
"warning.background": "#3A310E",
"warning.border": "#7B6508",
"syntax": {
"attribute": {
"color": "#7FAEF9"
},
"boolean": {
"color": "#CC9E00"
},
"comment": {
"color": "#9D9D9D"
},
"comment.doc": {
"color": "#9D9D9D"
},
"constant": {
"color": "#CC9E00"
},
"constructor": {
"color": "#CBA6F7"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#B3C5F3"
},
"keyword": {
"color": "#87B1F6"
},
"link_text": {
"color": "#419CFF",
"font_style": "normal"
},
"link_uri": {
"color": "#7faef9",
"font_style": "italic"
},
"number": {
"color": "#CC9E00"
},
"string": {
"color": "#A3E09F"
},
"text.code.span": {
"color": "#A3E09F"
},
"string.escape": {
"color": "#68DC7C"
},
"string.regex": {
"color": "#68DC7C"
},
"string.special": {
"color": "#A3E09F"
},
"string.special.symbol": {
"color": "#A3E09F"
},
"tag": {
"color": "#419CFF"
},
"text.literal": {
"color": "#A3E09F"
},
"title": {
"color": "#CC9E00",
"font_weight": 600
},
"type": {
"color": "#CBA6F7"
},
"property": {
"color": "#BCC4E0"
},
"variable.special": {
"color": "#419CFF"
}
}
}
}
]
}
+330
View File
@@ -0,0 +1,330 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Mellifluous",
"author": "Ramojus Lapinskas",
"url": "https://github.com/ramojus/mellifluous.nvim",
"themes": [
{
"name": "Mellifluous Light",
"mode": "light",
"shadow": false,
"colors": {
"accent.background": "#d4d4d4",
"accent.foreground": "#383a42",
"background": "#E7E7E7",
"border": "#CACACA",
"danger.active.background": "#e06c75",
"danger.hover.background": "#be5046",
"foreground": "#383a42",
"link.active.foreground": "#5A6599",
"link.foreground": "#5A6599",
"link.hover.foreground": "#5A6599",
"list.hover.background": "#d5d5d5",
"list.active.background": "#5A659912",
"list.active.border": "#5A6599",
"list.even.background": "#F0F0F0",
"muted.background": "#E0E0E0",
"muted.foreground": "#828997",
"panel.background": "#fafafa",
"popover.background": "#E4E4E4",
"popover.foreground": "#383a42",
"primary.active.background": "#5A6599AA",
"primary.background": "#5A6599",
"primary.foreground": "#F0F0F0",
"primary.hover.background": "#626ca2",
"scrollbar.background": "#fafafa00",
"scrollbar.thumb.background": "#c0c0c0",
"secondary.background": "#d0d0d0",
"secondary.active.background": "#cccccc",
"secondary.foreground": "#383a42",
"secondary.hover.background": "#d7d7d7",
"tab.active.background": "#E7E7E7",
"tab.active.foreground": "#383a42",
"tab.background": "#F0F0F000",
"tab.foreground": "#727272",
"tab_bar.background": "#E7E7E7",
"title_bar.background": "#dfdfdf",
"title_bar.border": "#CACACA",
"status_bar.background": "#dfdfdf",
"base.red": "#C95954",
"base.green": "#828040",
"base.yellow": "#c98f54",
"base.blue": "#a8a1be",
"base.magenta": "#b39fb0",
"base.magenta.light": "#9C699588",
"base.cyan": "#54c981"
},
"highlight": {
"editor.foreground": "#383a42",
"editor.background": "#E7E7E7",
"editor.active_line.background": "#d5d5d5",
"editor.line_number": "#727272",
"editor.active_line_number": "#383a42",
"editor.invisible": "#A0A0A066",
"conflict": "#e06c75",
"created": "#828040",
"deleted": "#be5046",
"error": "#e06c75",
"hidden": "#727272",
"hint": "#5A6599",
"ignored": "#727272",
"info": "#5A6599",
"modified": "#727272",
"predictive": "#727272",
"renamed": "#727272",
"success": "#828040",
"unreachable": "#727272",
"warning": "#cbaa89",
"syntax": {
"attribute": {
"color": "#727272"
},
"boolean": {
"color": "#cbaa89"
},
"comment": {
"color": "#A0A0A0",
"font_style": "italic"
},
"comment.doc": {
"color": "#A0A0A0",
"font_style": "italic"
},
"constant": {
"color": "#cbaa89"
},
"constructor": {
"color": "#727272"
},
"embedded": {
"color": "#383a42"
},
"function": {
"color": "#cbaa89"
},
"keyword": {
"color": "#e06c75"
},
"link_text": {
"color": "#5A6599",
"font_style": "normal"
},
"link_uri": {
"color": "#5A6599",
"font_style": "italic"
},
"number": {
"color": "#e06c75"
},
"string": {
"color": "#828040"
},
"string.escape": {
"color": "#828040"
},
"string.regex": {
"color": "#828040"
},
"string.special": {
"color": "#cbaa89"
},
"string.special.symbol": {
"color": "#cbaa89"
},
"tag": {
"color": "#727272"
},
"text.literal": {
"color": "#cbaa89"
},
"title": {
"color": "#727272",
"font_weight": 600
},
"type": {
"color": "#727272"
},
"property": {
"color": "#383a42"
},
"variable.special": {
"color": "#e06c75"
}
}
}
},
{
"name": "Mellifluous Dark",
"mode": "dark",
"shadow": false,
"colors": {
"accent.background": "#2f2f2f",
"accent.foreground": "#abb2bf",
"background": "#1A1A1A",
"border": "#444444",
"foreground": "#abb2bf",
"input.border": "#444444",
"link.active.foreground": "#5A6599",
"link.foreground": "#5A6599",
"link.hover.foreground": "#5A6599",
"list.active.background": "#5A659922",
"list.active.border": "#5A6599",
"list.even.background": "#29292988",
"muted.background": "#44444455",
"muted.foreground": "#828997",
"panel.background": "#282c34",
"popover.background": "#1A1A1A",
"popover.foreground": "#abb2bf",
"primary.active.background": "#5A6599AA",
"primary.background": "#5A6599",
"primary.foreground": "#ecedf4",
"primary.hover.background": "#626ca2",
"scrollbar.background": "#282c3400",
"scrollbar.thumb.background": "#444444",
"secondary.background": "#292929",
"secondary.active.background": "#323232",
"secondary.foreground": "#abb2bf",
"secondary.hover.background": "#292929",
"tab.active.background": "#1A1A1A",
"tab.active.foreground": "#abb2bf",
"tab.background": "#1A1A1A00",
"tab.foreground": "#abb2bf",
"title_bar.background": "#171717",
"title_bar.border": "#444444",
"base.red": "#C95954",
"base.green": "#828040",
"base.yellow": "#c98d54",
"base.blue": "#5481c9",
"base.magenta": "#9C6995",
"base.magenta.light": "#9C699577",
"base.cyan": "#54c9bd"
},
"highlight": {
"editor.foreground": "#abb2bf",
"editor.background": "#1A1A1A",
"editor.active_line.background": "#29292977",
"editor.line_number": "#828997",
"editor.active_line_number": "#abb2bf",
"editor.invisible": "#A0A0A066",
"conflict": "#e06c75",
"conflict.background": "#1A1A1A",
"conflict.border": "#be5046",
"created": "#828040",
"created.background": "#292929",
"created.border": "#929292",
"deleted": "#be5046",
"deleted.background": "#1A1A1A",
"deleted.border": "#e06c75",
"error": "#e06c75",
"error.background": "#1A1A1A",
"error.border": "#be5046",
"hidden": "#828997",
"hidden.background": "#292929",
"hidden.border": "#444444",
"hint": "#5A6599",
"hint.background": "#292929",
"hint.border": "#626ca2",
"ignored": "#828997",
"ignored.background": "#292929",
"ignored.border": "#444444",
"info": "#5A6599",
"info.background": "#292929",
"info.border": "#626ca2",
"modified": "#929292",
"modified.background": "#292929",
"modified.border": "#444444",
"predictive": "#828997",
"predictive.background": "#292929",
"predictive.border": "#444444",
"renamed": "#929292",
"renamed.background": "#292929",
"renamed.border": "#444444",
"success": "#828040",
"success.background": "#292929",
"success.border": "#929292",
"unreachable": "#828997",
"unreachable.background": "#292929",
"unreachable.border": "#444444",
"warning": "#d19a66",
"warning.background": "#292929",
"warning.border": "#929292",
"syntax": {
"attribute": {
"color": "#929292"
},
"boolean": {
"color": "#d19a66"
},
"comment": {
"color": "#828997",
"font_style": "italic"
},
"comment.doc": {
"color": "#828997",
"font_style": "italic"
},
"constant": {
"color": "#d19a66"
},
"constructor": {
"color": "#929292"
},
"embedded": {
"color": "#abb2bf"
},
"function": {
"color": "#d19a66"
},
"keyword": {
"color": "#e06c75"
},
"link_text": {
"color": "#5A6599",
"font_style": "normal"
},
"link_uri": {
"color": "#5A6599",
"font_style": "italic"
},
"number": {
"color": "#e06c75"
},
"string": {
"color": "#828040"
},
"string.escape": {
"color": "#828040"
},
"string.regex": {
"color": "#828040"
},
"string.special": {
"color": "#d19a66"
},
"string.special.symbol": {
"color": "#d19a66"
},
"tag": {
"color": "#929292"
},
"text.literal": {
"color": "#d19a66"
},
"title": {
"color": "#929292",
"font_weight": 600
},
"type": {
"color": "#929292"
},
"property": {
"color": "#abb2bf"
},
"variable.special": {
"color": "#e06c75"
}
}
}
}
]
}
+283
View File
@@ -0,0 +1,283 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Molokai",
"author": "Tomas Restrepo",
"url": "https://github.com/tomasr/molokai",
"themes": [
{
"name": "Molokai Light",
"mode": "light",
"shadow": false,
"colors": {
"accent.background": "#EBE4E1",
"accent.foreground": "#060606",
"background": "#FEFAF9",
"border": "#E4DEDA",
"window_border": "#d6d6d6",
"ring": "#a0a0ab",
"foreground": "#0a0a0a",
"input.border": "#E4DEDA",
"list.active.background": "#E1477411",
"list.active.border": "#E14774AA",
"list.even.background": "#E4DEDA33",
"muted.background": "#f1eded",
"muted.foreground": "#767676",
"panel.background": "#FEFAF9",
"popover.background": "#FEFAF9",
"popover.foreground": "#0a0a0a",
"primary.background": "#E14774",
"primary.foreground": "#fafafa",
"scrollbar.background": "#FEFAF900",
"scrollbar.thumb.background": "#ADADB0",
"secondary.background": "#E4DEDA",
"secondary.foreground": "#060606",
"secondary.hover.background": "#E4DEDA99",
"title_bar.background": "#e8e3e0",
"title_bar.border": "#d5ccc6",
"base.red": "#e17047",
"base.green": "#82cc0a",
"base.yellow": "#ccac0a",
"base.blue": "#e16032",
"base.blue.light": "#e16032",
"base.magenta": "#7058be",
"base.cyan": "#0acc78"
},
"highlight": {
"editor.foreground": "#0a0a0a",
"editor.background": "#FEFAF9",
"editor.active_line.background": "#E4DEDA",
"editor.line_number": "#767676",
"editor.active_line_number": "#0a0a0a",
"editor.invisible": "#76767666",
"conflict": "#e14775",
"created": "#269D69",
"deleted": "#e14775",
"error": "#e14775",
"hidden": "#767676",
"hint": "#7058be",
"ignored": "#767676",
"info": "#1c8ca8",
"modified": "#cc7a0a",
"predictive": "#ADADB0",
"renamed": "#E14774",
"success": "#269D69",
"unreachable": "#767676",
"warning": "#cc7a0a",
"syntax": {
"attribute": {
"color": "#e14775"
},
"boolean": {
"color": "#cc7a0a"
},
"comment": {
"color": "#767676",
"font_style": "italic"
},
"comment.doc": {
"color": "#767676",
"font_style": "italic"
},
"constant": {
"color": "#7058be"
},
"constructor": {
"color": "#269D69"
},
"embedded": {
"color": "#1c8ca8"
},
"function": {
"color": "#e14775"
},
"keyword": {
"color": "#e14775",
"font_style": "normal"
},
"link_text": {
"color": "#cc7a0a",
"font_style": "underline"
},
"link_uri": {
"color": "#7058be",
"font_style": "italic"
},
"number": {
"color": "#cc7a0a"
},
"string": {
"color": "#269D69"
},
"string.escape": {
"color": "#7058be"
},
"string.regex": {
"color": "#269D69"
},
"string.special": {
"color": "#e14775"
},
"string.special.symbol": {
"color": "#e14775"
},
"tag": {
"color": "#e14775"
},
"text.literal": {
"color": "#7058be"
},
"title": {
"color": "#cc7a0a",
"font_weight": 600
},
"type": {
"color": "#1c8ca8"
},
"property": {
"color": "#7058be"
},
"variable.special": {
"color": "#e14775"
}
}
}
},
{
"name": "Molokai Dark",
"mode": "dark",
"colors": {
"accent.background": "#2b2f30",
"accent.foreground": "#f8f8f2",
"background": "#1b1d1e",
"border": "#393939",
"foreground": "#f8f8f2",
"input.border": "#3b3b3b",
"list.active.background": "#f9267211",
"list.active.border": "#f9267288",
"list.even.background": "#292c2e",
"muted.background": "#232526",
"muted.foreground": "#5b5a54",
"panel.background": "#1b1d1e",
"popover.background": "#1b1d1e",
"popover.foreground": "#f8f8f2",
"primary.background": "#dc1860",
"primary.foreground": "#f8f8f2",
"scrollbar.background": "#1b1d1e00",
"scrollbar.thumb.background": "#4b4b4b",
"secondary.background": "#292c2e",
"secondary.active.background": "#303436",
"secondary.foreground": "#f5f5f4",
"secondary.hover.background": "#30343699",
"table.row.border": "#3b3b3b70",
"title_bar.background": "#202223",
"title_bar.border": "#3b3b3b",
"base.red": "#dc1860",
"base.green": "#82cc0a",
"base.yellow": "#807607",
"base.blue": "#075880",
"base.magenta": "#800780",
"base.cyan": "#07807e"
},
"highlight": {
"editor.foreground": "#DDDDDD",
"editor.background": "#000000",
"editor.active_line.background": "#131313",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#76767666",
"conflict": "#D2602D",
"created": "#3f72e2",
"created.background": "#0C4619",
"deleted.background": "#46190C",
"error.background": "#46190C",
"error.border": "#802207",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"hint.background": "#250c4b",
"hint.border": "#3f0891",
"info.background": "#0C194D",
"info.border": "#082190",
"modified": "#B0A878",
"modified.background": "#3A310E",
"predictive": "#5D5945",
"success.background": "#0C4619",
"warning.background": "#3A310E",
"warning.border": "#7B6508",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
}
]
}
+291
View File
@@ -0,0 +1,291 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Solarized",
"author": "Ethan Schoonover",
"url": "https://ethanschoonover.com/solarized",
"themes": [
{
"name": "Solarized Light",
"mode": "light",
"colors": {
"accent.background": "#ebe4ce",
"accent.foreground": "#073642",
"background": "#FDF6E3",
"border": "#DCD4BC",
"foreground": "#586E75",
"input.border": "#DCD4BC",
"link.foreground": "#587573",
"list.active.background": "#586E75",
"list.active.border": "#586E75",
"list.even.background": "#EEE8D599",
"muted.background": "#eee8d5",
"muted.foreground": "#93a1a1",
"panel.background": "#fdf6e3",
"popover.background": "#fdf6e3",
"popover.foreground": "#073642",
"primary.background": "#586E75",
"primary.foreground": "#fdf6e3",
"scrollbar.background": "#EEE8D500",
"scrollbar.thumb.background": "#d5cbaf",
"secondary.background": "#EEE8D5",
"secondary.active.background": "#DCD4BC",
"secondary.foreground": "#073642",
"secondary.hover.background": "#DCD4BC88",
"tab.active.background": "#fdf6e3",
"tab.active.foreground": "#073642",
"tab.background": "#fdf6e300",
"tab.foreground": "#657b83",
"tab_bar.background": "#eee8d5",
"title_bar.background": "#f4ecd7",
"title_bar.border": "#dcd4bc",
"chart.grid": "#ebe5d4",
"base.red": "#755858",
"base.green": "#717558",
"base.yellow": "#756e58",
"base.blue": "#586975",
"base.magenta": "#755866",
"base.cyan": "#587573"
},
"highlight": {
"editor.foreground": "#586E75",
"editor.background": "#FDF6E3",
"editor.active_line.background": "#EEE8D5",
"editor.line_number": "#93A1A1",
"editor.active_line_number": "#073642",
"editor.invisible": "#93A1A166",
"conflict": "#DC322F",
"created": "#859900",
"deleted": "#DC322F",
"error": "#DC322F",
"hidden": "#93A1A1",
"hint": "#2AA198",
"ignored": "#93A1A1",
"info": "#268BD2",
"modified": "#B58900",
"predictive": "#586E75",
"renamed": "#2AA198",
"success": "#859900",
"unreachable": "#DC322F",
"warning": "#B58900",
"syntax": {
"attribute": {
"color": "#B58900"
},
"boolean": {
"color": "#CB4B16"
},
"comment": {
"color": "#93A1A1",
"font_style": "italic"
},
"comment.doc": {
"color": "#93A1A1",
"font_style": "italic"
},
"constant": {
"color": "#268BD2"
},
"constructor": {
"color": "#859900"
},
"embedded": {
"color": "#2AA198"
},
"function": {
"color": "#268BD2"
},
"keyword": {
"color": "#DC322F"
},
"link_text": {
"color": "#268BD2",
"font_style": "underline"
},
"link_uri": {
"color": "#2AA198",
"font_style": "italic"
},
"number": {
"color": "#D33682"
},
"string": {
"color": "#859900"
},
"string.escape": {
"color": "#2AA198"
},
"string.regex": {
"color": "#D33682"
},
"string.special": {
"color": "#B58900"
},
"string.special.symbol": {
"color": "#B58900"
},
"tag": {
"color": "#268BD2"
},
"text.literal": {
"color": "#586E75"
},
"title": {
"color": "#073642",
"font_weight": 600
},
"type": {
"color": "#2AA198"
},
"property": {
"color": "#586E75"
},
"variable.special": {
"color": "#DC322F"
}
}
}
},
{
"name": "Solarized Dark",
"author": "Ethan Schoonover",
"url": "https://ethanschoonover.com/solarized",
"mode": "dark",
"colors": {
"accent.background": "#073945",
"accent.foreground": "#fdf6e3",
"background": "#002B36",
"border": "#103a44",
"foreground": "#fdf6e3",
"input.border": "#083e4c",
"list.active.background": "#0d667d25",
"list.active.border": "#0d667d",
"list.even.background": "#15333A99",
"muted.background": "#073642",
"muted.foreground": "#839496",
"panel.background": "#002b36",
"popover.background": "#002b36",
"popover.foreground": "#fdf6e3",
"primary.background": "#0d667d",
"primary.foreground": "#fdf6e3",
"scrollbar.background": "#002b3600",
"scrollbar.thumb.background": "#586e75",
"secondary.background": "#073642",
"secondary.active.background": "#083e4b",
"secondary.foreground": "#fdf6e3",
"secondary.hover.background": "#073945",
"tab.active.background": "#002b36",
"tab.active.foreground": "#fdf6e3",
"tab.foreground": "#93a1a1",
"tab_bar.background": "#07364266",
"title_bar.background": "#09323d",
"title_bar.border": "#16404b",
"base.red": "#7d2f0d",
"base.green": "#0d7d3f",
"base.yellow": "#7d650d",
"base.blue": "#0d507d",
"base.magenta": "#7d0d43",
"base.cyan": "#0d7d70"
},
"highlight": {
"editor.foreground": "#DDDDDD",
"editor.background": "#002B36",
"editor.active_line.background": "#073642",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#93A1A166",
"conflict": "#D2602D",
"created": "#3f72e2",
"created.background": "#0C4619",
"deleted.background": "#46190C",
"error.background": "#46190C",
"error.border": "#802207",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"hint.background": "#250c4b",
"hint.border": "#3f0891",
"info.background": "#0C194D",
"info.border": "#082190",
"modified": "#B0A878",
"modified.background": "#3A310E",
"predictive": "#5D5945",
"success.background": "#0C4619",
"warning.background": "#3A310E",
"warning.border": "#7B6508",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
}
]
}
+148
View File
@@ -0,0 +1,148 @@
{
"name": "Spaceduck",
"author": "Guillermo Rodriguez",
"url": "https://github.com/pineapplegiant/spaceduck",
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"themes": [
{
"name": "Spaceduck",
"mode": "dark",
"colors": {
"accent.background": "#212440",
"accent.foreground": "#a3a49d",
"background": "#0F111B",
"border": "#272C42",
"foreground": "#a3a49d",
"input.border": "#272C42",
"link.active.foreground": "#089CC5",
"link.foreground": "#089CC5",
"link.hover.foreground": "#089CC5",
"list.active.background": "#089CC511",
"list.active.border": "#089CC5",
"list.even.background": "#161928",
"muted.background": "#272C4255",
"muted.foreground": "#4b6479",
"panel.background": "#003a5b",
"popover.background": "#0F111B",
"popover.foreground": "#a3a49d",
"primary.background": "#089CC5",
"primary.foreground": "#ecf0c1",
"scrollbar.background": "#003a5b00",
"scrollbar.thumb.background": "#24303a",
"secondary.background": "#242547",
"secondary.active.background": "#292b51",
"secondary.foreground": "#a3a49d",
"secondary.hover.background": "#242547",
"tab.active.background": "#0F111B",
"tab.foreground": "#838182",
"title_bar.background": "#141724",
"title_bar.border": "#272C42",
"base.red": "#e33400",
"base.green": "#5ccc96",
"base.blue": "#00a3cc",
"base.cyan": "#089CC5",
"base.magenta": "#C86D8C",
"base.magenta.light": "#C86D8C33",
"base.yellow": "#b89c00"
},
"highlight": {
"editor.foreground": "#ecf0c1",
"editor.background": "#0F111B",
"editor.active_line.background": "#1c1d39",
"editor.line_number": "#4b6479",
"editor.active_line_number": "#ecf0c1",
"editor.invisible": "#4b647966",
"conflict": "#e33400",
"created": "#5ccc96",
"deleted": "#e33400",
"error": "#e33400",
"hidden": "#4b6479",
"hint": "#C86D8C",
"ignored": "#4b6479",
"info": "#089CC5",
"modified": "#f2ce00",
"predictive": "#5D5945",
"renamed": "#089CC5",
"success": "#5ccc96",
"unreachable": "#4b6479",
"warning": "#f2ce00",
"syntax": {
"attribute": {
"color": "#f2ce00"
},
"boolean": {
"color": "#f2ce00"
},
"comment": {
"color": "#4b6479",
"font_style": "italic"
},
"comment.doc": {
"color": "#4b6479",
"font_style": "italic"
},
"constant": {
"color": "#e33400"
},
"constructor": {
"color": "#5ccc96"
},
"embedded": {
"color": "#ecf0c1"
},
"function": {
"color": "#089CC5"
},
"keyword": {
"color": "#C86D8C"
},
"link_text": {
"color": "#089CC5",
"font_style": "underline"
},
"link_uri": {
"color": "#089CC5",
"font_style": "italic"
},
"number": {
"color": "#f2ce00"
},
"string": {
"color": "#5ccc96"
},
"string.escape": {
"color": "#5ccc96"
},
"string.regex": {
"color": "#5ccc96"
},
"string.special": {
"color": "#f2ce00"
},
"string.special.symbol": {
"color": "#f2ce00"
},
"tag": {
"color": "#C86D8C"
},
"text.literal": {
"color": "#ecf0c1"
},
"title": {
"color": "#f2ce00",
"font_weight": 600
},
"type": {
"color": "#089CC5"
},
"property": {
"color": "#ecf0c1"
},
"variable.special": {
"color": "#C86D8C"
}
}
}
}
]
}
+421
View File
@@ -0,0 +1,421 @@
{
"name": "Tokyo",
"author": "Folke Lemaitre",
"url": "https://github.com/folke/tokyonight.nvim",
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"themes": [
{
"name": "Tokyo Night",
"mode": "dark",
"colors": {
"accent.background": "#2C3045",
"accent.foreground": "#c0caf5",
"background": "#1a1b26",
"border": "#292e42",
"window_border": "#3d4462",
"ring": "#7aa2f7",
"foreground": "#c0caf5",
"link.active.foreground": "#7aa2f722",
"link.foreground": "#7aa2f7",
"link.hover.foreground": "#7aa2f7",
"list.active.background": "#7aa2f722",
"list.active.border": "#7aa2f7",
"list.even.background": "#2C304599",
"muted.background": "#2C304544",
"muted.foreground": "#565f89",
"panel.background": "#292e42",
"popover.background": "#1a1b26",
"popover.foreground": "#c0caf5",
"primary.background": "#7aa2f7",
"primary.foreground": "#1a1b26",
"scrollbar.background": "#1a1b2600",
"scrollbar.thumb.background": "#414868",
"secondary.active.background": "#292e42",
"secondary.background": "#292e42",
"secondary.foreground": "#C0CAF5",
"secondary.hover.background": "#2d3349",
"title_bar.background": "#161720",
"title_bar.border": "#292e42",
"chart.grid": "#222332",
"base.red": "#f7768e",
"base.green": "#9ece6a",
"base.yellow": "#e0af68",
"base.blue": "#7aa2f7",
"base.magenta": "#565f89",
"base.cyan": "#7dcfff"
},
"highlight": {
"editor.foreground": "#c0caf5",
"editor.background": "#1a1b26",
"editor.active_line.background": "#292e42",
"editor.line_number": "#565f89",
"editor.active_line_number": "#c0caf5",
"editor.invisible": "#565f8966",
"conflict": "#f7768e",
"created": "#9ece6a",
"deleted": "#f7768e",
"error": "#f7768e",
"hidden": "#565f89",
"hint": "#7dcfff",
"ignored": "#565f89",
"info": "#7aa2f7",
"modified": "#e0af68",
"predictive": "#565f89",
"renamed": "#7aa2f7",
"success": "#9ece6a",
"unreachable": "#565f89",
"warning": "#e0af68",
"syntax": {
"attribute": {
"color": "#e0af68"
},
"boolean": {
"color": "#9ece6a"
},
"comment": {
"color": "#565f89",
"font_style": "italic"
},
"comment.doc": {
"color": "#565f89",
"font_style": "italic"
},
"constant": {
"color": "#7aa2f7"
},
"constructor": {
"color": "#e0af68"
},
"embedded": {
"color": "#c0caf5"
},
"function": {
"color": "#7aa2f7"
},
"keyword": {
"color": "#f7768e"
},
"link_text": {
"color": "#7dcfff",
"font_style": "underline"
},
"link_uri": {
"color": "#7aa2f7",
"font_style": "italic"
},
"number": {
"color": "#e0af68"
},
"string": {
"color": "#9ece6a"
},
"string.escape": {
"color": "#7dcfff"
},
"string.regex": {
"color": "#9ece6a"
},
"string.special": {
"color": "#e0af68"
},
"string.special.symbol": {
"color": "#e0af68"
},
"tag": {
"color": "#f7768e"
},
"text.literal": {
"color": "#c0caf5"
},
"title": {
"color": "#7aa2f7",
"font_weight": 600
},
"type": {
"color": "#7dcfff"
},
"property": {
"color": "#c0caf5"
},
"variable.special": {
"color": "#f7768e"
}
}
}
},
{
"name": "Tokyo Storm",
"mode": "dark",
"colors": {
"accent.background": "#414868",
"accent.foreground": "#c0caf5",
"background": "#24283b",
"border": "#3d4462",
"window_border": "#3d4462",
"ring": "#7aa2f7",
"foreground": "#c0caf5",
"input.border": "#414868",
"link.active.foreground": "#7aa2f7",
"link.foreground": "#7aa2f7",
"link.hover.foreground": "#7aa2f7",
"list.active.background": "#7aa2f711",
"list.active.border": "#7aa2f7",
"list.even.background": "#41486844",
"muted.background": "#292e42",
"muted.foreground": "#565f89",
"panel.background": "#292e42",
"popover.background": "#24283b",
"popover.foreground": "#c0caf5",
"primary.background": "#7aa2f7",
"primary.foreground": "#24283b",
"scrollbar.background": "#24283b00",
"scrollbar.thumb.background": "#414868",
"secondary.active.background": "#323750",
"secondary.background": "#292e42",
"secondary.foreground": "#c0caf5",
"secondary.hover.background": "#32375099",
"title_bar.background": "#202435",
"title_bar.border": "#3d4462",
"base.red": "#f7768e",
"base.green": "#9ece6a",
"base.yellow": "#e0af68",
"base.blue": "#7aa2f7",
"base.magenta": "#b283f8",
"base.cyan": "#7dcfff"
},
"highlight": {
"editor.foreground": "#B0B9E2",
"editor.background": "#24283B",
"editor.active_line.background": "#292E42",
"editor.line_number": "#363C58",
"editor.active_line_number": "#B0B9E2",
"editor.invisible": "#565f8966",
"conflict": "#D2602D",
"created": "#3f72e2",
"deleted": "#f7768e",
"error": "#f7768e",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"modified": "#B0A878",
"predictive": "#5D5945",
"success": "#9ece6a",
"warning": "#e0af68",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
},
{
"name": "Tokyo Moon",
"mode": "dark",
"colors": {
"accent.background": "#32364E",
"accent.foreground": "#c0caf5",
"background": "#222436",
"border": "#323752",
"window_border": "#404668",
"ring": "#82aaff",
"foreground": "#c0caf5",
"input.border": "#444b6a",
"link.active.foreground": "#82aaff",
"link.foreground": "#82aaff",
"link.hover.foreground": "#82aaff",
"list.active.background": "#82aaff22",
"list.active.border": "#82aaff",
"list.background": "#222436",
"list.even.background": "#282a40",
"muted.background": "#2d3149",
"muted.foreground": "#6e738d",
"panel.background": "#2d3149",
"popover.background": "#222436",
"popover.foreground": "#c0caf5",
"primary.background": "#82aaff",
"primary.foreground": "#222436",
"scrollbar.background": "#22243600",
"scrollbar.thumb.background": "#444b6a",
"secondary.active.background": "#2f334c",
"secondary.background": "#2d3149",
"secondary.foreground": "#8c94b5",
"secondary.hover.background": "#31354f",
"table.row.border": "#2d3149",
"title_bar.background": "#1e2030",
"title_bar.border": "#323752",
"base.red": "#ff757f",
"base.green": "#c3e88d",
"base.yellow": "#ffc777",
"base.blue": "#82aaff",
"base.magenta": "#6e738d",
"base.cyan": "#86e1fc"
},
"highlight": {
"editor.foreground": "#c0caf5",
"editor.background": "#222436",
"editor.active_line.background": "#2d3149",
"editor.line_number": "#6e738d",
"editor.active_line_number": "#c0caf5",
"editor.invisible": "#565f8966",
"conflict": "#ed8796",
"created": "#c3e88d",
"deleted": "#ed8796",
"error": "#ed8796",
"hidden": "#6e738d",
"hint": "#86e1fc",
"ignored": "#6e738d",
"info": "#82aaff",
"modified": "#ffc777",
"predictive": "#6e738d",
"renamed": "#82aaff",
"success": "#c3e88d",
"unreachable": "#6e738d",
"warning": "#ffc777",
"syntax": {
"attribute": {
"color": "#ffc777"
},
"boolean": {
"color": "#c3e88d"
},
"comment": {
"color": "#6e738d",
"font_style": "italic"
},
"comment.doc": {
"color": "#6e738d",
"font_style": "italic"
},
"constant": {
"color": "#82aaff"
},
"constructor": {
"color": "#ffc777"
},
"embedded": {
"color": "#c0caf5"
},
"function": {
"color": "#82aaff"
},
"keyword": {
"color": "#ed8796"
},
"link_text": {
"color": "#86e1fc",
"font_style": "underline"
},
"link_uri": {
"color": "#82aaff",
"font_style": "italic"
},
"number": {
"color": "#ffc777"
},
"string": {
"color": "#c3e88d"
},
"string.escape": {
"color": "#86e1fc"
},
"string.regex": {
"color": "#c3e88d"
},
"string.special": {
"color": "#ffc777"
},
"string.special.symbol": {
"color": "#ffc777"
},
"tag": {
"color": "#ed8796"
},
"text.literal": {
"color": "#c0caf5"
},
"title": {
"color": "#82aaff",
"font_weight": 600
},
"type": {
"color": "#86e1fc"
},
"property": {
"color": "#c0caf5"
},
"variable.special": {
"color": "#ed8796"
}
}
}
}
]
}
+141
View File
@@ -0,0 +1,141 @@
{
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
"name": "Twilight",
"author": "MacroMates",
"url": "https://macromates.com",
"themes": [
{
"name": "Twilight",
"mode": "dark",
"colors": {
"accent.background": "#2b2b2b",
"accent.foreground": "#dcdcdc",
"background": "#141414",
"border": "#343434",
"foreground": "#dcdcdc",
"input.border": "#3e3e3e",
"list.active.background": "#CDA86911",
"list.active.border": "#CDA86988",
"list.even.background": "#1E1E1E99",
"muted.background": "#2e2e2e88",
"muted.foreground": "#828282",
"primary.background": "#CDA869",
"primary.foreground": "#1e1e1e",
"scrollbar.background": "#1e1e1e00",
"scrollbar.thumb.background": "#3e3e3e",
"secondary.background": "#262626",
"secondary.active.background": "#2a2a2a",
"secondary.foreground": "#dcdcdc",
"secondary.hover.background": "#272727",
"tab.active.background": "#141414",
"tab.active.foreground": "#dcdcdc",
"tab.background": "#1E1E1E00",
"tab.foreground": "#A9A9A9",
"tab_bar.background": "#1E1E1E",
"table.background": "#1e1e1e00",
"title_bar.background": "#1e1e1e",
"title_bar.border": "#343434",
"base.red": "#c06d44",
"base.red.light": "#de7c4c",
"base.green": "#afb97a",
"base.green.light": "#ccd88c",
"base.yellow": "#c2a86c",
"base.yellow.light": "#e2c47e",
"base.blue": "#44474a",
"base.blue.light": "#5a5e62",
"base.magenta": "#b4be7c",
"base.magenta.light": "#d0dc8e99",
"base.cyan": "#778385",
"base.cyan.light": "#8a989b"
},
"highlight": {
"editor.foreground": "#DDDDDD",
"editor.background": "#000000",
"editor.active_line.background": "#131313",
"editor.line_number": "#8F8F8F",
"editor.active_line_number": "#DDDDDD",
"editor.invisible": "#9E9E9E66",
"conflict": "#D2602D",
"created": "#3f72e2",
"hidden": "#9E9E9E",
"hint": "#b283f8",
"modified": "#B0A878",
"predictive": "#5D5945",
"syntax": {
"attribute": {
"color": "#be9a52"
},
"boolean": {
"color": "#E1D797"
},
"comment": {
"color": "#9E9E9E"
},
"comment.doc": {
"color": "#9E9E9E"
},
"constant": {
"color": "#E1D797"
},
"constructor": {
"color": "#b5af9a"
},
"embedded": {
"color": "#CACCCA"
},
"function": {
"color": "#E1D797"
},
"keyword": {
"color": "#E19773"
},
"link_text": {
"color": "#A86D3B",
"font_style": "normal"
},
"link_uri": {
"color": "#6F6D66",
"font_style": "italic"
},
"number": {
"color": "#E19773"
},
"string": {
"color": "#76BA53"
},
"string.escape": {
"color": "#76BA53"
},
"string.regex": {
"color": "#76BA53"
},
"string.special": {
"color": "#E1D797"
},
"string.special.symbol": {
"color": "#E1D797"
},
"tag": {
"color": "#b5af9a"
},
"text.literal": {
"color": "#E1D797"
},
"title": {
"color": "#A76D3B",
"font_weight": 600
},
"type": {
"color": "#A86D3B"
},
"property": {
"color": "#CACCCA"
},
"variable.special": {
"color": "#E19773"
}
}
}
}
]
}
+8 -1
View File
@@ -6,6 +6,13 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
anyhow.workspace = true
rust-embed.workspace = true
gpui-kit.workspace = true
[dev-dependencies]
serde_json.workspace = true
# On wasm there is no filesystem to read from, so rust-embed's debug-mode
# disk loading returns nothing; embed the assets even in debug builds.
[target.'cfg(target_arch = "wasm32")'.dependencies]
rust-embed = { workspace = true, features = ["debug-embed"] }
+54
View File
@@ -8,6 +8,7 @@ use rust_embed::RustEmbed;
#[include = "fonts/**/*"]
#[include = "brand/**/*"]
#[include = "icons/**/*"]
#[include = "themes/**/*"]
#[exclude = "*.DS_Store"]
pub struct Assets;
@@ -20,12 +21,39 @@ impl AssetSource for CombinedAssets {
if let Ok(Some(data)) = Assets.load(path) {
return Ok(Some(data));
}
#[cfg(target_arch = "wasm32")]
{
// The kit bundle fetches icons over HTTP on the web; a single
// instance keeps its download cache alive across loads.
use std::sync::OnceLock;
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
KIT_ASSETS
.get_or_init(gpui_kit::assets::Assets::default)
.load(path)
}
#[cfg(not(target_arch = "wasm32"))]
gpui_kit::assets::Assets.load(path)
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
let mut paths = Assets.list(path).unwrap_or_default();
#[cfg(target_arch = "wasm32")]
{
use std::sync::OnceLock;
static KIT_ASSETS: OnceLock<gpui_kit::assets::Assets> = OnceLock::new();
paths.extend(
KIT_ASSETS
.get_or_init(gpui_kit::assets::Assets::default)
.list(path)?,
);
}
#[cfg(not(target_arch = "wasm32"))]
paths.extend(gpui_kit::assets::Assets.list(path)?);
Ok(paths)
}
}
@@ -47,3 +75,29 @@ impl AssetSource for Assets {
.collect())
}
}
#[cfg(test)]
mod tests {
use gpui_kit::component::ThemeSet;
use super::*;
/// Every bundled theme file must parse against gpui-kit's schema.
#[test]
fn bundled_themes_parse() {
let mut count = 0;
for entry in Assets::iter().filter(|p| p.starts_with("themes/")) {
let file = Assets::get(&entry).expect("theme file exists");
let content = std::str::from_utf8(&file.data).expect("theme is UTF-8");
let set: ThemeSet = serde_json::from_str(content).expect("theme parses");
assert!(!set.themes.is_empty(), "{entry} has no themes");
count += set.themes.len();
}
assert!(
count >= 36,
"expected the bundled gpui-kit themes, got {count}"
);
}
}
-39
View File
@@ -1,39 +0,0 @@
use gpui::AssetSource;
#[test]
fn icons_resolve_from_the_vendored_folder() {
// Icons coop's code and the kit components draw, served by the vendored
// folder (signed's set, filled with the kit bundle where coop needs more).
let vendored = [
"icons/search.svg",
"icons/close.svg",
"icons/panel-left.svg",
"icons/window-minimize.svg",
"icons/settings-2.svg",
"icons/x.svg",
"icons/compass.svg",
"icons/radio-tower.svg",
];
for path in vendored {
let own = assets::Assets
.load(path)
.unwrap_or_else(|e| panic!("{path} errored in own embed: {e}"));
assert!(own.is_some(), "own embed is missing {path}");
let combined = assets::CombinedAssets.load(path).unwrap();
assert!(combined.is_some(), "CombinedAssets fails to serve {path}");
}
// Coop's own files still load through the combined source.
let own = [
"brand/coop.svg",
"brand/bg1.jpg",
"fonts/Inter/Inter-Medium.ttf",
];
for path in own {
assert!(
matches!(assets::CombinedAssets.load(path), Ok(Some(_))),
"CombinedAssets fails to serve coop asset {path}"
);
}
}
+1
View File
@@ -11,6 +11,7 @@ serde.workspace = true
serde_json.workspace = true
futures.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
gpui-updater-pre = "0.2"
[dev-dependencies]
+21
View File
@@ -63,6 +63,18 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
/// The subset of updater state the UI renders, so views can hold it in a small
/// entity instead of observing the whole updater.
#[derive(Clone, PartialEq)]
pub struct UpdateDisplay {
/// Whether the running version is the newest release.
pub up_to_date: bool,
/// Whether a verified update is installed and waiting for a restart.
pub staged: bool,
/// A short, human-readable description of the current status.
pub status: SharedString,
}
pub struct AutoUpdater {
updater: Entity<Updater>,
status: UpdateStatus,
@@ -173,6 +185,15 @@ impl AutoUpdater {
}
}
/// Snapshot the state the UI renders.
pub fn display(&self) -> UpdateDisplay {
UpdateDisplay {
up_to_date: self.up_to_date(),
staged: self.staged(),
status: self.status(),
}
}
/// Check the release host for a newer version, then download and install it.
pub fn check(&mut self, cx: &mut Context<Self>) {
self.updater.update(cx, |updater, cx| {
+3 -3
View File
@@ -37,9 +37,8 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
};
let arch = match (os, arch) {
// cargo-packager names the disk images `aarch64`/`x64`.
// cargo-packager names the disk images `aarch64`.
("macos", "aarch64") => "aarch64",
("macos", "x86_64") => "x64",
// `script/bundle-linux` names the tarballs `aarch64`/`x86_64`.
("linux", "aarch64") => "aarch64",
("linux", "x86_64") => "x86_64",
@@ -243,7 +242,7 @@ mod tests {
#[test]
fn picks_the_artifact_matching_os_and_architecture() {
assert_eq!(selected("macos", "aarch64"), Some("Coop_1.0.1_aarch64.dmg"));
assert_eq!(selected("macos", "x86_64"), Some("Coop_1.0.1_x64.dmg"));
assert_eq!(selected("macos", "x86_64"), None);
assert_eq!(
selected("linux", "aarch64"),
Some("coop-linux-aarch64.tar.gz")
@@ -258,6 +257,7 @@ mod tests {
fn has_no_target_for_windows_or_unknown_platforms() {
assert_eq!(asset_filter_for("windows", "x86_64"), None);
assert_eq!(asset_filter_for("freebsd", "x86_64"), None);
assert_eq!(asset_filter_for("macos", "x86_64"), None);
assert_eq!(asset_filter_for("macos", "riscv64"), None);
}
+3 -3
View File
@@ -5,7 +5,7 @@ async function pollForRequests() {
isPolling = true;
try {
const response = await fetch('/api/pending');
const response = await fetch('/api/pending?token=' + encodeURIComponent(window.proxySessionToken));
const data = await response.json();
console.log('Polled for requests, got:', data);
@@ -94,7 +94,7 @@ async function handleNip07Request(request) {
console.log('Sending response:', responsePayload);
await fetch('/api/response', {
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -117,7 +117,7 @@ async function handleNip07Request(request) {
console.log('Sending error response:', errorPayload);
await fetch('/api/response', {
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+223 -85
View File
@@ -26,6 +26,13 @@ pub use self::error::Error;
const DEFAULT_HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js");
/// Cap on the request line and headers so a peer cannot force unbounded reads
const MAX_HEADER_BYTES: usize = 16 * 1024;
/// Cap on the request body so a huge Content-Length cannot force a huge allocation
const MAX_BODY_BYTES: usize = 1024 * 1024;
/// Maximum time a connection may stay open before it is dropped
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
#[derive(Debug, Deserialize)]
@@ -265,6 +272,10 @@ impl BrowserSignerProxy {
let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
// Regenerated on every startup, never part of the opened URL
let session_token: String = Uuid::new_v4().simple().to_string();
// Only the exact host:port the server binds may appear in the Host header
let expected_host: String = addr.to_string();
smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}");
@@ -291,13 +302,30 @@ impl BrowserSignerProxy {
AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
let expected_host: String = expected_host.clone();
let session_token: String = session_token.clone();
smol::spawn(async move {
let shutdown_listener = shutdown.listen();
smol::future::or(
async {
handle_connection(stream, state, custom_html).await;
smol::future::or(
async {
handle_connection(
stream,
state,
custom_html,
expected_host,
session_token,
)
.await;
},
async {
smol::Timer::after(CONNECTION_TIMEOUT).await;
},
)
.await;
},
async {
shutdown_listener.await;
@@ -336,6 +364,18 @@ impl BrowserSignerProxy {
outgoing_requests.push(request);
}
/// Drop a request from both queues so timed-out request data is not retained.
#[inline]
async fn cancel_request(&self, id: Uuid) {
self.inner.state.pending_responses.lock().await.remove(&id);
self.inner
.state
.outgoing_requests
.lock()
.await
.retain(|request| request.id != id);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where
T: DeserializeOwned,
@@ -345,6 +385,7 @@ impl BrowserSignerProxy {
// Construct the request
let request: RequestData = RequestData::new(method, params);
let request_id = request.id;
// Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
@@ -361,7 +402,10 @@ impl BrowserSignerProxy {
match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()),
Err(TimeoutError) => {
self.cancel_request(request_id).await;
Err(Error::timeout())
}
}
}
@@ -372,10 +416,19 @@ impl BrowserSignerProxy {
#[inline]
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
let expected_id: EventId = event.compute_id();
let event: Event = self
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
.await?;
event.verify()?;
if event.id != expected_id {
return Err(Error::generic(format!(
"signed event doesn't match the requested event: expected ID {expected_id}, got {}",
event.id
)));
}
Ok(event)
}
@@ -437,18 +490,83 @@ impl AsyncNip44 for BrowserSignerProxy {
}
}
// ── Minimal HTTP server ──────────────────────────────────────────────────
/// Outcome of reading a line with [`read_line_capped`].
enum HeaderLine {
/// A line, including the trailing newline when present
Line(Vec<u8>),
/// Clean EOF with no data
Eof,
/// The line exceeded the allowed length
TooLong,
}
/// Read one `\n`-terminated line with a hard cap so a peer cannot force unbounded buffering.
async fn read_line_capped(
reader: &mut BufReader<TcpStream>,
limit: usize,
) -> Result<HeaderLine, std::io::Error> {
let mut line: Vec<u8> = Vec::new();
loop {
let (data, line_complete) = {
let chunk = reader.fill_buf().await?;
if chunk.is_empty() {
break;
}
match chunk.iter().position(|byte| *byte == b'\n') {
Some(index) => (chunk[..=index].to_vec(), true),
None => (chunk.to_vec(), false),
}
};
if line.len() + data.len() > limit {
return Ok(HeaderLine::TooLong);
}
let taken = data.len();
line.extend_from_slice(&data);
reader.consume(taken);
if line_complete {
return Ok(HeaderLine::Line(line));
}
}
Ok(if line.is_empty() {
HeaderLine::Eof
} else {
HeaderLine::Line(line)
})
}
/// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
async fn handle_connection(
stream: TcpStream,
state: Arc<ProxyState>,
custom_html: &'static str,
expected_host: String,
session_token: String,
) {
let mut reader = BufReader::new(stream);
let mut header_bytes: usize = 0;
// Read the request line
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.is_err() {
return;
}
let request_line = request_line.trim_end().to_string();
let request_line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
Ok(HeaderLine::Line(line)) => line,
Ok(HeaderLine::Eof) => Vec::new(),
Ok(HeaderLine::TooLong) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
Err(_) => return,
};
header_bytes += request_line.len();
let request_line = match String::from_utf8(request_line) {
Ok(line) => line.trim_end().to_string(),
Err(_) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
};
// Parse method, path, and HTTP version from request line
let parts: Vec<&str> = request_line.split_whitespace().collect();
@@ -462,24 +580,72 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Read headers until empty line
let mut headers = Vec::new();
let mut content_length: usize = 0;
let mut host: Option<String> = None;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
let line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
Ok(HeaderLine::Line(line)) => line,
Ok(HeaderLine::Eof) => break,
Ok(HeaderLine::TooLong) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
Err(_) => return,
};
header_bytes += line.len();
if header_bytes > MAX_HEADER_BYTES {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let line = line.trim_end().to_string();
let line = match String::from_utf8(line) {
Ok(line) => line.trim_end().to_string(),
Err(_) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
};
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("host:") {
host = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("Host:") {
host = Some(value.trim().to_string());
}
headers.push(line);
}
match (method.as_str(), path.as_str()) {
// Reject oversized bodies before allocating the read buffer
if content_length > MAX_BODY_BYTES {
send_response(&mut reader, 413, "Payload Too Large", "", "").await;
return;
}
// DNS rebinding defense: only the exact bound host:port is accepted
if host.as_deref() != Some(expected_host.as_str()) {
send_response(&mut reader, 403, "Forbidden", "", "").await;
return;
}
let (route, query) = match path.split_once('?') {
Some((route, query)) => (route, Some(query)),
None => (path.as_str(), None),
};
if route.starts_with("/api/") && query_param(query, "token") != Some(session_token.as_str()) {
send_response(&mut reader, 403, "Forbidden", "", "").await;
return;
}
match (method.as_str(), route) {
// Serve the HTML proxy page
("GET", "/") => {
let html = if custom_html.is_empty() {
@@ -487,7 +653,8 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
} else {
custom_html
};
send_response(&mut reader, 200, "OK", "text/html", html).await;
let html = inject_session_token(html, &session_token);
send_response(&mut reader, 200, "OK", "text/html", &html).await;
}
// Serve the JS proxy script
("GET", "/proxy.js") => {
@@ -516,7 +683,7 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Clear the outgoing requests after sending them
outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await;
send_response(&mut reader, 200, "OK", "application/json", &json).await;
}
// Receive response from browser extension
("POST", "/api/response") => {
@@ -535,33 +702,32 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
}
};
tracing::debug!("Received response from browser: {message:?}");
// Never log the response value, it can contain decrypted plaintext
let outcome = if message.error.is_some() {
"error"
} else {
"ok"
};
tracing::debug!(
"Received response from browser: id={} outcome={outcome}",
message.id
);
let id: Uuid = message.id;
let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) {
Some(sender) => {
// Use try_send since we already hold the lock
let _ = sender.try_send(message.into_result());
tracing::info!("Forwarded response for request {id}");
}
Some(sender) => match sender.try_send(message.into_result()) {
Ok(()) => tracing::info!("Forwarded response for request {id}"),
Err(e) => {
tracing::warn!("Failed to forward response for request {id}: {e}")
}
},
None => tracing::warn!("No pending request found for {id}"),
}
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
send_response(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// 404 - not found
_ => {
@@ -585,68 +751,44 @@ async fn send_response(
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
write_response(stream, &response).await;
}
/// Write a response with CORS headers and JSON content type.
async fn send_response_cors_json(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers.
async fn send_response_cors(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
async fn write_response(stream: &mut (impl AsyncWriteExt + Unpin), response: &str) {
if let Err(e) = stream.write_all(response.as_bytes()).await {
tracing::debug!("Failed to write response to peer: {e}");
}
if let Err(e) = stream.flush().await {
tracing::debug!("Failed to flush response to peer: {e}");
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
// ── Timeout helper ───────────────────────────────────────────────────────
/// Get a parameter value from an optional query string.
fn query_param<'a>(query: Option<&'a str>, name: &str) -> Option<&'a str> {
let query = query?;
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == name).then_some(value)
})
}
fn inject_session_token(html: &str, token: &str) -> String {
let script = format!("<script>window.proxySessionToken=\"{token}\";</script>");
match html.rfind("</body>") {
Some(index) => format!("{}{script}{}", &html[..index], &html[index..]),
None => format!("{html}{script}"),
}
}
/// An error indicating that an operation timed out.
#[derive(Debug)]
struct TimeoutError;
/// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>(
duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>,
@@ -677,10 +819,6 @@ async fn race_timeout<T>(
}
}
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline]
fn current_time() -> u64 {
SystemTime::now()
-3
View File
@@ -24,6 +24,3 @@ flume.workspace = true
futures.workspace = true
fuzzy-matcher = "0.3.7"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
+230 -98
View File
@@ -11,12 +11,12 @@ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
WeakEntity,
};
use instant::Duration;
use instant::{Duration, Instant};
use nip4e::{DeviceEvent, DeviceRegistry};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP};
use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP, UniversalSigner};
mod message;
mod room;
@@ -29,7 +29,10 @@ pub use state::FileAttachment;
use crate::search::MessageSearchIndex;
/// A static keypair used only for signing locally-cached rumor events.
/// Upper bound on signals applied to the UI in a single update
const MAX_SIGNAL_BATCH: usize = 256;
/// Keypair used only for identifying locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
pub fn init(cx: &mut App) {
@@ -44,6 +47,7 @@ impl Global for GlobalChatRegistry {}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ChatEvent {
Ping,
Loaded,
InboxRelayNotFound,
Error(String),
}
@@ -74,6 +78,13 @@ impl Signal {
}
}
/// Which signer to attempt when unwrapping a gift wrap
enum UnwrapRoute {
User,
Device,
Both,
}
/// Chat Registry
#[derive(Debug)]
pub struct ChatRegistry {
@@ -216,32 +227,34 @@ impl ChatRegistry {
let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
let executor = cx.background_executor().clone();
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
const MAX_PROCESSED: usize = 10_000;
let mut notifications = client.notifications();
let mut processed_events = HashSet::new();
let mut user_key = signer.get_public_key_async().await.ok();
let device_key = match device_signer.as_ref() {
Some(device) => device.get_public_key_async().await.ok(),
None => None,
};
let mut last_yield = Instant::now();
while let Some(notification) = notifications.next().await {
if last_yield.elapsed() >= Duration::from_millis(8) {
executor.timer(Duration::from_millis(0)).await;
last_yield = Instant::now();
}
let ClientNotification::Message { message, relay_url } = notification else {
continue;
};
match *message {
RelayMessage::Event {
subscription_id,
event,
..
} => {
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
let device_sub = subscription_id.as_str() != sub_id2.as_str();
// Concord wraps are also kind 1059.
//
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
continue;
}
RelayMessage::Event { event, .. } => {
// Prune the dedup set before it grows unbounded
if processed_events.len() >= MAX_PROCESSED {
processed_events.clear();
@@ -253,8 +266,10 @@ impl ChatRegistry {
// Handle msg relays event to determine when the app is ready to subscribe
if event.kind == Kind::InboxRelays {
let current_user = signer.get_public_key_async().await?;
if event.pubkey == current_user {
if user_key.is_none() {
user_key = signer.get_public_key_async().await.ok();
}
if Some(event.pubkey) == user_key {
tx.send_async(Signal::InboxReady).await?;
}
}
@@ -270,15 +285,39 @@ impl ChatRegistry {
seen.entry(event.id).or_default().insert(relay_url);
}
// Route to the signer matching the recipient tag
// so a wrap is never decrypted twice.
let recipient = event.tags.public_keys().next();
let route = match recipient {
Some(key) if Some(key) == device_key => UnwrapRoute::Device,
Some(key) if Some(key) == user_key => UnwrapRoute::User,
Some(_) if user_key.is_some() && device_key.is_some() => continue,
_ => UnwrapRoute::Both,
};
// Extract the rumor from the gift wrap event
match extract_rumor(
&client,
&signer,
device_signer.as_ref(),
event.as_ref(),
)
.await
{
let result = match route {
UnwrapRoute::User => {
extract_rumor(&client, &signer, None, event.as_ref()).await
}
UnwrapRoute::Device => match device_signer.as_ref() {
Some(device) => {
extract_rumor(&client, device, None, event.as_ref()).await
}
None => continue,
},
UnwrapRoute::Both => {
extract_rumor(
&client,
&signer,
device_signer.as_ref(),
event.as_ref(),
)
.await
}
};
match result {
Ok(rumor) => {
let Some(rumor_id) = rumor.id else {
log::error!("Rumor missing id after ensure_id");
@@ -305,9 +344,10 @@ impl ChatRegistry {
}
}
}
RelayMessage::EndOfStoredEvents(id)
if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) =>
{
RelayMessage::EndOfStoredEvents {
subscription_id: id,
..
} if (id.as_ref() == &sub_id1 || id.as_ref() == &sub_id2) => {
tx.send_async(Signal::Eose).await?;
}
_ => {}
@@ -317,35 +357,74 @@ impl ChatRegistry {
Ok(())
}));
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(message) = rx.recv_async().await {
match message {
Signal::Message(message) => {
this.update(cx, |this, cx| {
this.new_message(message, cx);
})?;
}
Signal::InboxReady => {
this.update(cx, |this, cx| {
this.get_messages(cx);
})?;
}
Signal::Eose => {
this.update(cx, |this, _cx| {
this.tracking.store(false, Ordering::Release);
})?;
let tracking = self.tracking.clone();
this.update(cx, |this, cx| {
this.get_rooms(cx);
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(first) = rx.recv_async().await {
// Batch whatever has already arrived so the UI updates once per burst
let mut messages = Vec::new();
let mut errors = Vec::new();
let mut inbox_ready = false;
let mut eose = false;
let mut pending = Some(first);
while let Some(signal) = pending.take() {
match signal {
Signal::Message(message) => messages.push(message),
Signal::InboxReady => inbox_ready = true,
Signal::Eose => eose = true,
Signal::Error(failed) => errors.push(failed),
}
if messages.len() + errors.len() >= MAX_SIGNAL_BATCH {
break;
}
pending = rx.try_recv().ok();
}
if inbox_ready {
this.update(cx, |this, cx| {
this.get_messages(cx);
})?;
}
if !messages.is_empty() {
this.update(cx, |this, cx| {
this.new_messages(messages, cx);
})?;
}
if !errors.is_empty() {
let mut newly_trashed = 0;
trash.update(cx, |trash, cx| {
newly_trashed = errors
.iter()
.filter(|message| trash.insert((*message).clone()))
.count();
cx.notify();
})?;
// Only newly trashed messages emit an error event so repeated
// reprocess attempts of the same message don't re-alert the user
if newly_trashed > 0 {
let reason = if newly_trashed == 1 {
"A message could not be decrypted".to_string()
} else {
format!("{newly_trashed} messages could not be decrypted")
};
this.update(cx, |_, cx| {
cx.emit(ChatEvent::Error(reason));
})?;
}
Signal::Error(failed) => {
trash.update(cx, |this, cx| {
this.insert(failed);
cx.notify();
})?;
}
};
}
if eose && tracking.swap(false, Ordering::AcqRel) {
this.update(cx, |this, cx| {
cx.emit(ChatEvent::Loaded);
this.get_rooms(cx);
})?;
}
}
Ok(())
@@ -669,6 +748,7 @@ impl ChatRegistry {
self.rooms.clear();
self.room_index.clear();
self.search.write().unwrap().clear();
self.tracking.store(true, Ordering::Release);
self.trash.update(cx, |this, cx| {
this.clear();
cx.notify();
@@ -748,6 +828,7 @@ impl ChatRegistry {
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let search = self.search.clone();
let executor = cx.background_executor().clone();
cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
@@ -775,7 +856,15 @@ impl ChatRegistry {
let events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
// Keep the single-threaded web event loop responsive during large rescans
let mut last_yield = Instant::now();
for raw in events.into_iter() {
if last_yield.elapsed() >= Duration::from_millis(8) {
executor.timer(Duration::from_millis(0)).await;
last_yield = Instant::now();
}
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().next().is_some()
{
@@ -811,12 +900,17 @@ impl ChatRegistry {
})
}
/// Parse a nostr event into a message and push it to the belonging room
/// Parse nostr events into messages and push them to the belonging rooms
///
/// - If the room doesn't exist, it will be created.
/// - Updates room ordering based on the most recent messages.
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
self.search.write().unwrap().insert(&message.rumor);
/// - If a room doesn't exist, it will be created.
/// - Room ordering is updated once after the whole batch.
pub(crate) fn new_messages(&mut self, messages: Vec<NewMessage>, cx: &mut Context<Self>) {
{
let mut search = self.search.write().unwrap();
for message in &messages {
search.insert(&message.rumor);
}
}
let nostr = NostrRegistry::global(cx);
@@ -824,37 +918,42 @@ impl ChatRegistry {
return;
};
match self.room_index.get(&message.room).cloned() {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request
&& (message.rumor.pubkey == public_key || this.auto_trusted(public_key, cx))
{
this.set_ongoing(cx);
}
this.push_message(message, cx);
});
self.sort(cx);
}
None => {
// Push the new room to the front of the list
self.add_room(message.rumor, cx);
for message in messages {
match self.room_index.get(&message.room).cloned() {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request
&& (message.rumor.pubkey == public_key
|| this.auto_trusted(public_key, cx))
{
this.set_ongoing(cx);
}
this.push_message(message, cx);
});
}
None => {
// Push the new room to the front of the list
self.add_room(message.rumor, cx);
}
}
}
self.sort(cx);
}
/// Parse a nostr event into a message and push it to the belonging room
pub(crate) fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
self.new_messages(vec![message], cx);
}
}
/// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor<S, D>(
async fn extract_rumor(
client: &Client,
signer: &S,
device_signer: Option<&D>,
signer: &UniversalSigner,
device_signer: Option<&UniversalSigner>,
gift_wrap: &Event,
) -> Result<UnsignedEvent, Error>
where
S: AsyncNip44 + ?Sized,
D: AsyncNip44 + ?Sized,
{
) -> Result<UnsignedEvent, Error> {
// Try to get cached rumor first
if let Ok(rumor) = get_rumor(client, gift_wrap.id).await {
return Ok(rumor);
@@ -866,7 +965,11 @@ where
let Some(device_signer) = device_signer else {
return Err(error);
};
try_unwrap_with(device_signer, gift_wrap).await?
match try_unwrap_with(device_signer, gift_wrap).await {
Ok(unwrapped) => unwrapped,
// The wrap may be sealed to a key retired by a rotation
Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?,
}
}
};
let mut rumor = unwrapped.rumor;
@@ -888,10 +991,10 @@ where
}
/// Attempts to unwrap a gift wrap event with a given signer.
async fn try_unwrap_with<S>(signer: &S, gift_wrap: &Event) -> Result<UnwrappedGift, Error>
where
S: AsyncNip44 + ?Sized,
{
async fn try_unwrap_with(
signer: &UniversalSigner,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
// Get the sealed event
let seal = signer
.nip44_decrypt_async(&gift_wrap.pubkey, &gift_wrap.content)
@@ -922,21 +1025,50 @@ where
})
}
/// Unwrap with an encryption key retired by a rotation; peers may still seal
/// to the previous key until their announcement cache refreshes
async fn try_unwrap_archived(
client: &Client,
signer: &UniversalSigner,
gift_wrap: &Event,
) -> Result<UnwrappedGift, Error> {
for keys in DeviceRegistry::archived_keys(client, signer).await? {
let archived = UniversalSigner::new(keys);
if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await {
return Ok(unwrapped);
}
}
Err(anyhow!(
"No archived encryption key could unwrap the gift wrap"
))
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let room_id = rumor.uniq_id().to_string();
let tags = vec![
let tags = Tags::from_list(vec![
Tag::identifier(id),
Tag::public_key(rumor.pubkey),
Tag::custom("r", [room_id]),
Tag::custom("k", [rumor.kind.to_string()]),
];
]);
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
.tags(tags)
.finalize_async(&*LOCAL_KEYS)
.await?;
let public_key = LOCAL_KEYS.public_key();
let created_at = Timestamp::now();
let kind = Kind::ApplicationSpecificData;
let content = rumor.as_json();
let event = Event::new(
EventId::compute(&public_key, &created_at, &kind, &tags, &content),
public_key,
created_at,
kind,
tags,
content,
Signature::from_byte_array([0u8; 64]),
);
client.database().save_event(&event).await?;
+1 -59
View File
@@ -1,16 +1,13 @@
use std::hash::Hash;
use std::ops::Range;
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls};
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls, nevent_refs};
use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*;
use state::FileAttachment;
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
const SCHEME_WITH_COLON: &str = "nostr:";
const NEVENT_PREFIX: &str = "nevent1";
/// Rendered message.
#[derive(Debug, Clone)]
pub struct Message {
@@ -248,61 +245,6 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
.collect()
}
/// A `nevent` reference found in a content string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeventRef {
/// Range of the reference, including the optional `nostr:` scheme
pub range: Range<usize>,
/// Compact display label, like `https://njump.me/nevent1...d9sp`
pub label: String,
}
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
let mut references = Vec::new();
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
// Only lowercase references are matched, matching NIP-19 encodings in practice
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
match_start - SCHEME_WITH_COLON.len()
} else {
match_start
};
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
if content[..start].ends_with(char::is_alphanumeric) {
continue;
}
let end = match_start
+ content[match_start..]
.bytes()
.take_while(u8::is_ascii_alphanumeric)
.count();
// The bech32 token excludes the optional `nostr:` scheme
let bech32 = &content[match_start..end];
if Nip19Event::from_bech32(bech32).is_err() {
continue;
}
references.push(NeventRef {
range: start..end,
label: nevent_label(bech32),
});
}
references
}
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
fn nevent_label(bech32: &str) -> String {
match bech32.len() {
0..=4 => format!("https://njump.me/{bech32}"),
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
}
}
/// Extracts all reply (ids) from the event tags.
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![];
+24
View File
@@ -16,6 +16,7 @@ use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them.";
const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging.";
/// The outcome of delivering an encrypted rumor to a single receiver.
#[derive(Debug, Clone)]
@@ -553,6 +554,7 @@ impl Room {
let config = self.config.clone();
let device = DeviceRegistry::global(cx);
let resyncing = device.read(cx).is_resyncing();
let encryption_signer = device.read(cx).signer(cx);
let nostr = NostrRegistry::global(cx);
@@ -574,6 +576,28 @@ impl Room {
Some(cx.background_spawn(async move {
let signer_kind = config.signer_kind();
// Block sends while the local encryption key is stale against the
// current announcement; the replacement is being synced from
// another device
if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) {
let mut reports: Vec<SendReport> = receivers
.into_iter()
.map(|receiver| SendReport {
receiver: receiver.public_key(),
result: Err(KEY_STALE.into()),
})
.collect();
if config.backup() {
reports.push(SendReport {
receiver: sender.public_key(),
result: Err(KEY_STALE.into()),
});
}
return reports;
}
// Deliver to every receiver concurrently
let sends = receivers.into_iter().map(|receiver| {
deliver(
+7 -1
View File
@@ -6,18 +6,24 @@ publish.workspace = true
[dependencies]
state = { path = "../state" }
speech = { path = "../speech" }
ui = { path = "../ui" }
gpui-kit.workspace = true
gpui-kit = { workspace = true, features = ["speech"] }
common = { path = "../common" }
person = { path = "../person" }
chat = { path = "../chat" }
settings = { path = "../settings" }
gpui.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
log.workspace = true
itertools.workspace = true
smallvec.workspace = true
serde.workspace = true
regex = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
nip60_ui = { path = "../nip60_ui" }
+4 -1
View File
@@ -1,4 +1,4 @@
use gpui::Action;
use gpui::{Action, actions};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use settings::SignerKind;
@@ -15,3 +15,6 @@ pub enum Command {
Njump(PublicKey),
Trace(EventId),
}
// Start or stop dictation in the focused composer
actions!(chat, [ToggleDictation]);
+48 -10
View File
@@ -1,32 +1,70 @@
use std::path::{Path, PathBuf};
use chat::FileAttachment;
use anyhow::Error;
use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::{FileAttachment, LocalFile, PickedFile};
/// A file attachment that has been uploaded, but not sent yet.
///
/// The local `path` is kept around so the composer can preview
/// The `local` handle is kept around so the composer can preview
/// the file without downloading and decrypting it again.
pub(crate) struct PendingFile {
pub file: FileAttachment,
pub path: PathBuf,
pub local: LocalFile,
}
/// State of the encrypted file attachment of a message
pub(crate) enum DecryptedFile {
Loading,
Ready(PathBuf),
Ready(LocalFile),
Failed(SharedString),
}
/// Result of an upload, either plain or encrypted
pub(crate) enum Uploaded {
Url(Url),
File(FileAttachment, PathBuf),
File(Box<UploadedFile>),
}
/// A `file://` url for a decrypted file, so it can be opened by the OS
pub(crate) fn file_url(path: &Path) -> String {
format!("file://{}", path.display())
/// An encrypted upload and the handle to preview it locally
pub(crate) struct UploadedFile {
pub file: FileAttachment,
pub local: LocalFile,
}
impl Uploaded {
/// Build the upload result for a picked file, encrypted when the
/// attachment is the whole message
pub(crate) async fn from_pick(
encrypted: bool,
server: Url,
picked: PickedFile,
cx: &gpui::AsyncApp,
) -> Result<Self, Error> {
if encrypted {
let UploadedFileParts { file, local } = upload_encrypted(server, picked, cx).await?;
Ok(Self::File(Box::new(UploadedFile { file, local })))
} else {
state::upload_file(server, picked, cx).await.map(Self::Url)
}
}
}
/// The pieces of an encrypted upload, split so the local handle survives the
/// picked file being consumed by the upload
struct UploadedFileParts {
file: FileAttachment,
local: LocalFile,
}
async fn upload_encrypted(
server: Url,
picked: PickedFile,
cx: &gpui::AsyncApp,
) -> Result<UploadedFileParts, Error> {
let local = picked.local.clone();
state::upload_encrypted_file(server, picked, cx)
.await
.map(|file| UploadedFileParts { file, local })
}
+437 -225
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -9,6 +9,7 @@ gpui.workspace = true
nostr.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
sha2.workspace = true
itertools.workspace = true
chrono.workspace = true
@@ -16,3 +17,7 @@ futures.workspace = true
dirs = "5.0"
regex = "1.10"
anyhow.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Window", "Storage"] }
+99 -23
View File
@@ -1,20 +1,18 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use sha2::{Digest, Sha256};
const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
pub trait EventExt {
/// Id over the event's participants, stable across Rust versions.
fn uniq_id(&self) -> u64;
fn extract_public_keys(&self) -> Vec<PublicKey>;
}
impl EventExt for Event {
fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
pubkeys.sort();
pubkeys.hash(&mut hasher);
hasher.finish()
pubkey_set_uniq_id(&self.extract_public_keys())
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
@@ -27,31 +25,62 @@ impl EventExt for Event {
impl EventExt for UnsignedEvent {
fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = vec![];
// Add all public keys from event
pubkeys.push(self.pubkey);
pubkeys.extend(self.tags.public_keys().collect::<Vec<_>>());
// Generate unique hash
pubkeys
.into_iter()
.unique()
.sorted()
.collect::<Vec<_>>()
.hash(&mut hasher);
hasher.finish()
pubkey_set_uniq_id(&self.extract_public_keys())
}
fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
public_keys.push(self.pubkey);
public_keys.into_iter().unique().sorted().collect()
}
}
/// Digest over NUL-separated parts, so part boundaries stay unambiguous.
fn parts_digest(parts: &[&str]) -> [u8; 32] {
let mut hasher = Sha256::new();
for part in parts {
hasher.update(part.as_bytes());
hasher.update([0]);
}
hasher.finalize().into()
}
/// Stable id over parts: hex-encoded SHA-256 truncated to 128 bits.
pub fn stable_id(parts: &[&str]) -> String {
let digest = parts_digest(parts);
let mut id = String::with_capacity(32);
for byte in &digest[..16] {
id.push(HEX_DIGITS[usize::from(byte >> 4)] as char);
id.push(HEX_DIGITS[usize::from(byte & 0x0f)] as char);
}
id
}
/// Stable 64-bit id over parts, for callers that key ids on `u64`.
pub fn stable_id_u64(parts: &[&str]) -> u64 {
let digest = parts_digest(parts);
let mut lead = [0u8; 8];
for (slot, byte) in lead.iter_mut().zip(digest.iter()) {
*slot = *byte;
}
u64::from_be_bytes(lead)
}
/// Hashes the sorted hex forms of the participant set.
fn pubkey_set_uniq_id(public_keys: &[PublicKey]) -> u64 {
let mut parts: Vec<String> = public_keys.iter().map(|key| key.to_string()).collect();
parts.sort();
let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
stable_id_u64(&parts)
}
/// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
let mut emojis: Vec<(String, Url)> = Vec::new();
@@ -100,4 +129,51 @@ mod tests {
Url::parse("https://example.com/soapbox.png").unwrap()
);
}
fn event_with_participants(public_key: PublicKey, participants: Vec<PublicKey>) -> Event {
let tags = Tags::from_list(participants.into_iter().map(Tag::public_key).collect());
Event::new(
EventId::from_byte_array([0; 32]),
public_key,
Timestamp::from(0u64),
Kind::TextNote,
tags,
"content".to_string(),
Signature::from_byte_array([0; 64]),
)
}
#[test]
fn uniq_id_ignores_participant_order() {
let me = Keys::new(SecretKey::generate()).public_key();
let alice = Keys::new(SecretKey::generate()).public_key();
let bob = Keys::new(SecretKey::generate()).public_key();
let first = event_with_participants(me, vec![alice, bob]);
let second = event_with_participants(me, vec![bob, alice]);
assert_eq!(first.uniq_id(), second.uniq_id());
}
#[test]
fn uniq_id_tracks_participants() {
let me = Keys::new(SecretKey::generate()).public_key();
let alice = Keys::new(SecretKey::generate()).public_key();
let bob = Keys::new(SecretKey::generate()).public_key();
let with_alice = event_with_participants(me, vec![alice]);
let with_bob = event_with_participants(me, vec![bob]);
assert_ne!(with_alice.uniq_id(), with_bob.uniq_id());
}
#[test]
fn stable_id_is_deterministic_128_bit_hex() {
let id = stable_id(&["alpha", "beta"]);
assert_eq!(id.len(), 32);
assert!(id.chars().all(|character| character.is_ascii_hexdigit()));
assert_eq!(id, stable_id(&["alpha", "beta"]));
}
}
+10 -7
View File
@@ -1,13 +1,16 @@
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
pub use media_extractor::*;
pub use parser::*;
pub use paths::*;
mod debounced_delay;
mod display;
mod event;
mod media_extractor;
mod parser;
mod paths;
#[cfg(target_arch = "wasm32")]
pub mod web_store;
pub use debounced_delay::*;
pub use display::*;
pub use event::*;
pub use media_extractor::*;
pub use parser::*;
pub use paths::*;
+141 -45
View File
@@ -1,65 +1,161 @@
use std::sync::LazyLock;
use gpui::SharedUri;
use regex::Regex;
/// Extracts media URLs from a string and returns both
/// the extracted URLs and the string with media URLs removed
struct MediaExtractor {
image_regex: Regex,
}
/// Matches image URLs with common image extensions
static IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
)
.unwrap()
});
/// Extracts media URLs from a string and returns the URLs and the cleaned text
struct MediaExtractor;
impl MediaExtractor {
/// Creates a new MediaExtractor with compiled regex patterns
fn new() -> Self {
MediaExtractor {
// Match common image extensions
image_regex: Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
).unwrap(),
}
}
/// Extracts all media URLs from a string
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
let mut urls = Vec::new();
// Extract image URLs
for capture in self.image_regex.find_iter(text) {
urls.push(capture.as_str().to_string().into());
}
urls
fn extract_media_urls(text: &str) -> Vec<SharedUri> {
IMAGE_RE
.find_iter(text)
.map(|capture| capture.as_str().to_string().into())
.collect()
}
/// Removes all media URLs from a string and returns the cleaned text
fn remove_media_urls(&self, text: &str) -> String {
let mut result = text.to_string();
fn remove_media_urls(text: &str) -> String {
let mut cleaned = String::with_capacity(text.len());
let mut last_end = 0;
// Whitespace bordered a removed URL, so one space stands in for it
let mut spaced = false;
// Remove image URLs
result = self.image_regex.replace_all(&result, "").to_string();
for url in IMAGE_RE.find_iter(text) {
let raw_gap = &text[last_end..url.start()];
let surrounded = raw_gap.ends_with(char::is_whitespace)
|| text[url.end()..].starts_with(char::is_whitespace);
// Clean up extra whitespace that might result from removal
self.cleanup_text(&result)
let gap = if last_end == 0 {
raw_gap.trim_end()
} else {
raw_gap.trim()
};
if !gap.is_empty() {
if spaced && !cleaned.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(gap);
}
spaced |= surrounded;
last_end = url.end();
}
let tail = text[last_end..].trim_start();
if spaced && !cleaned.is_empty() && !tail.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(tail);
cleaned.trim().to_string()
}
/// Extracts media URLs and removes them from the string, returning both
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
let urls = self.extract_media_urls(text);
let cleaned_text = self.remove_media_urls(text);
(urls, cleaned_text)
}
/// Helper function to clean up text after URL removal
fn cleanup_text(&self, text: &str) -> String {
let text = text.trim();
// Remove multiple consecutive spaces
let re = Regex::new(r"\s+").unwrap();
re.replace_all(text, " ").trim().to_string()
fn extract_and_remove(text: &str) -> (Vec<SharedUri>, String) {
(
Self::extract_media_urls(text),
Self::remove_media_urls(text),
)
}
}
/// Convenience function for one-time extraction and removal
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
let extractor = MediaExtractor::new();
extractor.extract_and_remove(text)
MediaExtractor::extract_and_remove(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multi_line_text_without_urls_keeps_line_breaks() {
let text = "first line\n\nsecond line";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn removes_url_and_tidies_around_it() {
let (urls, cleaned) =
extract_and_remove_media_urls("check https://example.com/pic.png here");
assert_eq!(urls, vec![SharedUri::from("https://example.com/pic.png")]);
assert_eq!(cleaned, "check here");
}
#[test]
fn removing_url_keeps_other_line_breaks() {
let (urls, cleaned) = extract_and_remove_media_urls(
"first\nsecond https://example.com/pic.png third\nfourth",
);
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first\nsecond third\nfourth");
}
#[test]
fn url_on_its_own_line_joins_neighbors_with_one_space() {
let (urls, cleaned) =
extract_and_remove_media_urls("first\n\nhttps://example.com/pic.png\n\nlast");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first last");
}
#[test]
fn multiple_urls_collapse_to_single_spaces() {
let (urls, cleaned) = extract_and_remove_media_urls(
"a https://example.com/a.png b https://example.com/b.png c",
);
assert_eq!(urls.len(), 2);
assert_eq!(cleaned, "a b c");
}
#[test]
fn message_with_only_a_url_becomes_empty() {
let (urls, cleaned) = extract_and_remove_media_urls("https://example.com/pic.png");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "");
}
#[test]
fn digits_glued_to_url_scheme_prevent_match() {
let text = "v1.2https://example.com/pic.png";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn digits_around_removed_url_survive() {
let (urls, cleaned) = extract_and_remove_media_urls("123 https://example.com/pic.png 456");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "123 456");
}
#[test]
fn emoji_around_removed_url_survives() {
let (urls, cleaned) = extract_and_remove_media_urls("👍 https://example.com/pic.png 👍");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "👍 👍");
}
}
+94 -44
View File
@@ -4,6 +4,62 @@ use nostr::prelude::*;
const BECH32_SEPARATOR: u8 = b'1';
const SCHEME_WITH_COLON: &str = "nostr:";
const NEVENT_PREFIX: &str = "nevent1";
/// A `nevent` reference found in a content string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeventRef {
/// Range of the reference, including the optional `nostr:` scheme
pub range: Range<usize>,
/// Compact display label, like `https://njump.me/nevent1...d9sp`
pub label: String,
}
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
let mut references = Vec::new();
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
// Only lowercase references are matched, matching NIP-19 encodings in practice
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
match_start - SCHEME_WITH_COLON.len()
} else {
match_start
};
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
if content[..start].ends_with(char::is_alphanumeric) {
continue;
}
let end = match_start
+ content[match_start..]
.bytes()
.take_while(u8::is_ascii_alphanumeric)
.count();
// The bech32 token excludes the optional `nostr:` scheme
let bech32 = &content[match_start..end];
if Nip19Event::from_bech32(bech32).is_err() {
continue;
}
references.push(NeventRef {
range: start..end,
label: nevent_label(bech32),
});
}
references
}
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
fn nevent_label(bech32: &str) -> String {
match bech32.len() {
0..=4 => format!("https://njump.me/{bech32}"),
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
}
}
/// Nostr parsed token with its range in the original text
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -128,20 +184,11 @@ impl Iterator for FindMatches<'_> {
}
}
enum HandleMatch {
Token(Token),
Recursion,
}
pub struct NostrParserIter<'a> {
/// The original text
text: &'a str,
/// Matches found
matches: FindMatches<'a>,
/// A pending match
pending_match: Option<Match>,
/// Last match end index
last_match_end: usize,
}
impl<'a> NostrParserIter<'a> {
@@ -149,62 +196,65 @@ impl<'a> NostrParserIter<'a> {
Self {
text,
matches: FindMatches::new(text),
pending_match: None,
last_match_end: 0,
}
}
fn handle_match(&mut self, mat: Match) -> HandleMatch {
// Update last match end
self.last_match_end = mat.end;
fn handle_match(&self, mat: Match) -> Option<Token> {
// Extract the matched string
let data: &str = &self.text[mat.start..mat.end];
// Parse NIP-21 URI
match Nip21::parse(data) {
Ok(uri) => HandleMatch::Token(Token {
Ok(uri) => Some(Token {
value: uri,
range: mat.start..mat.end,
}),
// If the nostr URI parsing is invalid, skip it
Err(_) => HandleMatch::Recursion,
Err(_) => None,
}
}
}
impl<'a> Iterator for NostrParserIter<'a> {
impl Iterator for NostrParserIter<'_> {
type Item = Token;
fn next(&mut self) -> Option<Self::Item> {
// Handle a pending match
if let Some(pending_match) = self.pending_match.take() {
return match self.handle_match(pending_match) {
HandleMatch::Token(token) => Some(token),
HandleMatch::Recursion => self.next(),
};
}
// Iterative, not recursive: message text controls the match count
loop {
let mat = self.matches.next()?;
match self.matches.next() {
Some(mat) => {
// Skip any text before this match
if mat.start > self.last_match_end {
// Update pending match
// This will be handled at next iteration, in `handle_match` method.
self.pending_match = Some(mat);
// Skip the text before the match
self.last_match_end = mat.start;
return self.next();
}
// Handle match
match self.handle_match(mat) {
HandleMatch::Token(token) => Some(token),
HandleMatch::Recursion => self.next(),
}
if let Some(token) = self.handle_match(mat) {
return Some(token);
}
None => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// A long run of scanner matches that fail `Nip21::parse`: the count is
// attacker-controlled, so it must be consumed iteratively.
#[test]
fn skips_long_run_of_invalid_uris() {
let content = "nostr:1q".repeat(50_000);
let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
assert!(tokens.is_empty());
}
#[test]
fn yields_valid_uri_after_invalid_run() {
let public_key = Keys::new(SecretKey::generate()).public_key();
let npub = public_key.to_bech32().unwrap();
let content = format!("{}nostr:{npub}", "nostr:1q".repeat(50_000));
let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].range.start, 400_000);
assert_eq!(tokens[0].value, Nip21::Pubkey(public_key));
}
}
+32
View File
@@ -0,0 +1,32 @@
use anyhow::{Error, anyhow};
const LOCAL_STORAGE: &str = "localStorage";
fn storage() -> Result<web_sys::Storage, Error> {
web_sys::window()
.ok_or_else(|| anyhow!("no browser window"))?
.local_storage()
.map_err(|e| anyhow!("{LOCAL_STORAGE} unavailable: {e:?}"))?
.ok_or_else(|| anyhow!("{LOCAL_STORAGE} unavailable"))
}
/// Read a value previously written with [`set`]
pub fn get(key: &str) -> Result<Option<String>, Error> {
storage()?
.get_item(key)
.map_err(|e| anyhow!("{LOCAL_STORAGE} read failed: {e:?}"))
}
/// Write a value, replacing any previous one
pub fn set(key: &str, value: &str) -> Result<(), Error> {
storage()?
.set_item(key, value)
.map_err(|e| anyhow!("{LOCAL_STORAGE} write failed: {e:?}"))
}
/// Remove a value if it exists
pub fn remove(key: &str) -> Result<(), Error> {
storage()?
.remove_item(key)
.map_err(|e| anyhow!("{LOCAL_STORAGE} delete failed: {e:?}"))
}
-1
View File
@@ -11,7 +11,6 @@ futures.workspace = true
serde_json.workspace = true
state = { path = "../state" }
common = { path = "../common" }
person = { path = "../person" }
chat = { path = "../chat" }
gpui.workspace = true
+16 -3
View File
@@ -13,7 +13,7 @@ use crate::LOCAL_KEYS;
use crate::protocol::{
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
TimelineWindow, tag_value,
TimelineWindow, is_newer_event, tag_value,
};
const REPLAY_LIMIT: usize = 100;
@@ -91,7 +91,8 @@ pub struct Group {
membership: Membership,
membership_events: Vec<Event>,
elsewhere: Option<GroupKey>,
state_ids: HashMap<Kind, EventId>,
/// The last accepted (created_at, id) per state kind.
state_ids: HashMap<Kind, (Timestamp, EventId)>,
rows: Vec<Row>,
index: HashSet<EventId>,
reactions: HashMap<EventId, Vec<Reaction>>,
@@ -694,6 +695,15 @@ impl Group {
}
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
// Relays deliver same-kind state events in any order.
if !is_newer_event(
self.state_ids.get(&event.kind).copied(),
event.created_at,
event.id,
) {
return false;
}
match event.kind {
Kind::GroupMetadata => match GroupMetadata::parse(event) {
Ok(metadata) => self.metadata = Some(metadata),
@@ -724,7 +734,10 @@ impl Group {
_ => return false,
}
self.state_ids.insert(event.kind, event.id) != Some(event.id)
self.state_ids
.insert(event.kind, (event.created_at, event.id));
true
}
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
+48 -19
View File
@@ -76,6 +76,8 @@ pub struct GroupsRegistry {
groups: Vec<Entity<Group>>,
previews: HashMap<GroupKey, Entity<Group>>,
list: GroupList,
/// The (created_at, id) of the last accepted group list event.
list_stamp: Option<(Timestamp, EventId)>,
browse: HashMap<SubscriptionId, RelayUrl>,
routes: HashMap<SubscriptionId, GroupKey>,
observers: HashMap<GroupKey, Subscription>,
@@ -132,6 +134,7 @@ impl GroupsRegistry {
groups: Vec::new(),
previews: HashMap::new(),
list: GroupList::default(),
list_stamp: None,
browse: HashMap::new(),
routes: HashMap::new(),
observers: HashMap::new(),
@@ -455,6 +458,8 @@ impl GroupsRegistry {
self.fork_authors.clear();
self.syncing.clear();
self.list = GroupList::default();
// A new signer's list events must not be judged against the old stamp.
self.list_stamp = None;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -488,24 +493,26 @@ impl GroupsRegistry {
let signer = nostr.read(cx).signer();
// Show the cached groups before the relays answer.
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move {
let me = signer.get_public_key_async().await?;
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
let task: Task<Result<(GroupList, Option<Event>), Error>> =
cx.background_spawn(async move {
let me = signer.get_public_key_async().await?;
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
match client.database().query(filter).await?.into_iter().next() {
Some(event) => {
let list = GroupList::parse(&event)?;
Ok(Self::hydrate_list(list, &signer, &me).await)
match client.database().query(filter).await?.into_iter().next() {
Some(event) => {
let list =
Self::hydrate_list(GroupList::parse(&event)?, &signer, &me).await;
Ok((list, Some(event)))
}
None => Ok((GroupList::default(), None)),
}
None => Ok(GroupList::default()),
}
});
});
self.tasks
.push(cx.spawn(async move |this, cx| match task.await {
Ok(list) => {
Ok((list, event)) => {
this.update(cx, |this, cx| {
this.merge(list, cx);
this.merge(list, event.as_ref(), cx);
})
.ok();
}
@@ -538,7 +545,16 @@ impl GroupsRegistry {
self.watch_forks(cx);
}
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
fn merge(&mut self, list: GroupList, event: Option<&Event>, cx: &mut Context<Self>) {
if let Some(event) = event {
// Relays replay list events out of order; only strictly newer applies.
if !is_newer_event(self.list_stamp, event.created_at, event.id) {
return;
}
self.list_stamp = Some((event.created_at, event.id));
}
// Carry the event's private section so a later publish can preserve it.
self.list
.set_content(list.content().map(str::to_owned), list.content_readable());
@@ -589,20 +605,21 @@ impl GroupsRegistry {
};
if list.content().is_none() {
self.merge(list, cx);
self.merge(list, Some(&event), cx);
return;
}
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let Some(me) = nostr.read(cx).current_user() else {
self.merge(list, cx);
self.merge(list, Some(&event), cx);
return;
};
self.tasks.push(cx.spawn(async move |this, cx| {
let list = Self::hydrate_list(list, &signer, &me).await;
this.update(cx, |this, cx| this.merge(list, cx)).ok();
this.update(cx, |this, cx| this.merge(list, Some(&event), cx))
.ok();
}));
}
@@ -864,7 +881,7 @@ impl GroupsRegistry {
let me = nostr.read(cx).current_user();
let list = self.list.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| {
self.tasks.push(cx.spawn(async move |this, cx| {
// Re-encrypt the private section when it carries local entries; a
// section this session could not read is carried verbatim.
let content = match (list.has_private(), list.content_readable()) {
@@ -893,6 +910,15 @@ impl GroupsRegistry {
}
};
// Our own publish is the newest list until a newer event arrives.
if let Err(error) = this.update(cx, |this, _cx| {
if is_newer_event(this.list_stamp, event.created_at, event.id) {
this.list_stamp = Some((event.created_at, event.id));
}
}) {
log::warn!("recording the published list failed: {error}");
}
match client.send_event(&event).to_nip65().await {
Ok(output) if output.failed.is_empty() => {}
Ok(output) => {
@@ -1208,7 +1234,10 @@ impl GroupsRegistry {
tx.send_async(signal).await.ok();
}
}
RelayMessage::EndOfStoredEvents(id) if is_own_subscription(&id) => {
RelayMessage::EndOfStoredEvents {
subscription_id: id,
..
} if is_own_subscription(&id) => {
tx.send_async(Signal::Eose(id.into_owned())).await.ok();
}
RelayMessage::Closed {
@@ -1222,7 +1251,7 @@ impl GroupsRegistry {
RelayMessage::NegMsg { .. } => {
tx.send_async(Signal::Syncing(relay_url)).await.ok();
}
RelayMessage::NegErr { .. } | RelayMessage::EndOfStoredEvents(_) => {
RelayMessage::NegErr { .. } | RelayMessage::EndOfStoredEvents { .. } => {
tx.send_async(Signal::Synced(relay_url)).await.ok();
}
_ => {}
+26 -5
View File
@@ -1,10 +1,9 @@
use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
use anyhow::{Result, anyhow, bail};
use common::stable_id_u64;
use nostr_sdk::prelude::*;
pub(crate) const TIMELINE_WINDOW: usize = 50;
@@ -50,6 +49,15 @@ pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
.and_then(|tag| tag.content())
}
/// Whether `(created_at, id)` is strictly newer than the last accepted stamp.
pub(crate) fn is_newer_event(
accepted: Option<(Timestamp, EventId)>,
created_at: Timestamp,
id: EventId,
) -> bool {
accepted.is_none_or(|last| (created_at, id) > last)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GroupId(String);
@@ -280,9 +288,7 @@ impl GroupKey {
}
pub fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new();
self.hash(&mut hasher);
hasher.finish()
stable_id_u64(&[self.relay.as_str(), self.id.as_str()])
}
}
@@ -1152,4 +1158,19 @@ mod tests {
let malformed = Tag::custom("previous", ["nothex"]);
assert_eq!(window.references_seen(Some(&malformed)), Some(false));
}
#[test]
fn is_newer_event_accepts_only_strictly_newer_events() {
let lower = EventId::from_byte_array([0x00; 32]);
let higher = EventId::from_byte_array([0xff; 32]);
let earlier = Timestamp::from(100);
let later = Timestamp::from(200);
assert!(is_newer_event(None, earlier, lower));
assert!(!is_newer_event(Some((earlier, lower)), earlier, lower));
assert!(is_newer_event(Some((earlier, lower)), later, lower));
assert!(!is_newer_event(Some((later, lower)), earlier, higher));
assert!(is_newer_event(Some((earlier, lower)), earlier, higher));
assert!(!is_newer_event(Some((earlier, higher)), earlier, lower));
}
}
+5 -6
View File
@@ -1,7 +1,6 @@
use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use common::stable_id_u64;
use nostr_sdk::prelude::*;
use crate::{GroupId, GroupKey};
@@ -25,10 +24,10 @@ pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
}
pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
let mut hasher = DefaultHasher::new();
relay.hash(&mut hasher);
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
SubscriptionId::new(format!(
"{BROWSE_PREFIX}{:08x}",
stable_id_u64(&[relay.as_str()]) as u32
))
}
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
+3 -1
View File
@@ -11,11 +11,13 @@ common = { path = "../common" }
nip29 = { path = "../nip29" }
person = { path = "../person" }
settings = { path = "../settings" }
speech = { path = "../speech" }
state = { path = "../state" }
gpui-kit.workspace = true
gpui-kit = { workspace = true, features = ["speech"] }
ui = { path = "../ui" }
gpui.workspace = true
instant.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
log.workspace = true
+226 -60
View File
@@ -1,14 +1,14 @@
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::Result;
use chat_ui::message::WelcomeMessage;
use chat_ui::text::{RenderedText, rendered_text};
use chat_ui::{Command, ToggleDictation};
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
list, px,
Action, Anchor, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
FocusHandle, Focusable, FollowMode, InteractiveElement, IntoElement, ListAlignment,
ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task,
WeakEntity, Window, div, list, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
@@ -16,8 +16,10 @@ use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{InputEvent, Textarea, TextareaState};
use gpui_kit::component::menu::DropdownMenu;
use gpui_kit::component::notification::Notification;
use gpui_kit::component::scroll::Scrollbar;
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
use nostr_sdk::prelude::*;
@@ -25,8 +27,9 @@ use person::Directory;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload};
use ui::Avatar;
use speech::speaker::Speaker;
use state::{NostrRegistry, pick_file, upload_file};
use ui::{Avatar, REACTION_EMOJIS, RenderedText, RevealText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report};
mod details;
@@ -315,6 +318,16 @@ pub struct GroupPanel {
reply_to: Option<EventId>,
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
input: Entity<TextareaState>,
/// Dictation session for the composer input
speech: Entity<SpeechState>,
/// When the running dictation started, for the elapsed counter
speech_started: Option<instant::Instant>,
/// Reads messages aloud
speaker: Speaker,
/// The message being revealed in step with speech, with its estimated duration
speech_reveal: Option<(EventId, Duration)>,
/// Fallback reveal end for backends that cannot report utterance end
reveal_timeout: Option<Task<Result<()>>>,
/// Uploaded, non-encrypted attachments waiting to be sent.
attachments: Vec<Url>,
/// Whether an upload is in flight.
@@ -322,7 +335,7 @@ pub struct GroupPanel {
/// Whether the user closed the encryption notice this visit
notice_dismissed: bool,
tasks: SmallVec<[Task<Result<()>>; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
impl GroupPanel {
@@ -341,6 +354,18 @@ impl GroupPanel {
.clean_on_escape()
});
let speech = cx.new(|cx| {
let state = SpeechState::new(cx)
.recognizer(speech::recognizer())
// Batch transcription of a long recording can take a while.
.stop_timeout(Duration::from_secs(180));
#[cfg(target_arch = "wasm32")]
let state = state.input(speech::input());
state
});
let mut subscriptions = smallvec![];
subscriptions.push(
@@ -351,6 +376,34 @@ impl GroupPanel {
}),
);
subscriptions.push(
cx.subscribe_in(&speech, window, |this, _, event, window, cx| {
match event {
SpeechEvent::Started => {
this.speech_started = Some(instant::Instant::now());
cx.notify();
}
SpeechEvent::Final(text) => {
this.speech_started = None;
if !text.is_empty() {
this.input.update(cx, |input, cx| {
input.insert(text.clone(), window, cx);
});
}
}
SpeechEvent::Error(error) => {
this.speech_started = None;
window.push_notification(Notification::error(error.to_string()), cx);
}
SpeechEvent::Cancelled => {
this.speech_started = None;
}
_ => {}
};
}),
);
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
if !matches!(event, GroupEvent::Sent(_)) {
this.sync(cx);
@@ -374,9 +427,24 @@ impl GroupPanel {
}
},
));
this.track_participants(cx);
});
let mut this = Self {
// End the speech reveal when the utterance finishes ahead of its estimate
let speaker = Speaker::new();
let end_events = speaker.end_events();
let reveal_end_task = cx.spawn(async move |this, cx| {
while end_events.recv_async().await.is_ok() {
this.update(cx, |this, cx| {
this.speech_reveal = None;
cx.notify();
})?;
}
Ok(())
});
Self {
focus_handle: cx.focus_handle(),
group: group.downgrade(),
directory: Directory::new(cx),
@@ -386,19 +454,19 @@ impl GroupPanel {
notice_dismissed: false,
rendered_texts_by_id: BTreeMap::new(),
input,
speech,
speech_started: None,
speaker,
speech_reveal: None,
reveal_timeout: None,
attachments: Vec::new(),
uploading: false,
tasks: smallvec![],
tasks: smallvec![reveal_end_task],
_subscriptions: subscriptions,
};
this.track_participants(cx);
this
}
}
/// Track the keys rendered by this panel: members, message authors, and
/// activity subjects
/// Track the keys rendered by this panel.
fn track_participants(&mut self, cx: &mut Context<Self>) {
let Some(group) = self.group.upgrade() else {
return;
@@ -555,22 +623,17 @@ impl GroupPanel {
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let server = AppSettings::get_file_server(cx);
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
let picker = pick_file(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
let Some(picked) = picker.await? else {
return Ok(());
};
this.update(cx, |this, cx| this.set_uploading(true, cx))?;
match upload(server, path, cx).await {
match upload_file(server, picked, cx).await {
Ok(url) => {
this.update_in(cx, |this, _window, cx| {
this.attachments.push(url);
@@ -981,19 +1044,30 @@ impl GroupPanel {
let nostr = NostrRegistry::global(cx);
let directory = self.directory.clone();
let content = self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
&message.mentions,
&message.emojis,
&directory,
cx,
)
})
.element(ix.into(), window, cx);
let content = match self
.speech_reveal
.as_ref()
.filter(|(reveal_id, _)| *reveal_id == message.id)
{
Some((_, duration)) => RevealText::new(message.preview(), *duration).into_any_element(),
None => self
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
rendered_text(
&message.content,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
)
})
// Key by event id: parse state is kept per element id and indices shift on prepend
.element(SharedString::from(message.id.to_hex()).into(), window, cx),
};
let admin = nostr
.read(cx)
@@ -1004,7 +1078,6 @@ impl GroupPanel {
let pinned = group.pins().pins().contains(&Pin::Event(message.id));
message::render(
ix,
message,
content,
show_author,
@@ -1014,13 +1087,41 @@ impl GroupPanel {
panel: cx.weak_entity(),
admin,
pinned,
speaking: self.speech_reveal.is_some(),
persons: self.directory.clone(),
},
cx,
)
}
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
fn toggle_dictation(
&mut self,
_ev: &ToggleDictation,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.speech.update(cx, |speech, cx| {
speech.toggle(cx);
});
}
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::Insert(content) => {
self.input.update(cx, |input, cx| {
let new_value = format!("{} {}", input.value(), content);
input.set_value(new_value, window, cx);
});
}
Command::Copy(public_key) => match public_key.to_bech32() {
Ok(content) => cx.write_to_clipboard(ClipboardItem::new_string(content)),
Err(error) => log::warn!("nip29: encoding the author key failed: {error}"),
},
_ => {}
}
}
fn render_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let notice = self.group.upgrade().and_then(|group| {
let group = group.read(cx);
@@ -1045,46 +1146,109 @@ impl GroupPanel {
return self.render_notice(&notice, cx);
}
let dictating = self.speech.read(cx).status().is_active();
v_flex()
.flex_shrink_0()
.w_full()
.p_2()
.gap_1()
.gap_1p5()
.when_some(self.reply_to, |this, target| {
this.child(self.render_reply_target(target, cx))
})
.children(self.render_attachments(cx))
.child(
h_flex()
.gap_1()
.items_end()
.child(
Button::new("upload")
.icon(IconName::Upload)
.tooltip("Upload file")
.icon(ui::IconName::Paperclip)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(self.uploading)
.disabled(dictating || self.uploading)
.ghost()
.large()
.on_click(cx.listener(|this, _event, window, cx| {
this.upload(window, cx);
})),
)
.child(Textarea::new(&self.input).appearance(false).flex_1())
.when(dictating, |this| {
this.child(
h_flex()
.h_8()
.flex_1()
.gap_2()
.pl_1()
.child(SpeechWaveform::new(&self.speech).small().flex_1())
.when_some(self.speech_started, |this, started| {
this.child(speech::elapsed(started, cx))
}),
)
})
.when(!dictating, |this| {
this.child(
Textarea::new(&self.input)
.appearance(false)
.small()
.min_h_8()
.py_1()
.flex_1(),
)
})
.child(
Button::new("send")
.icon(IconName::SendHorizontal)
.tooltip("Send")
.ghost()
.large()
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
h_flex()
.pl_1()
.gap_1()
.child(SpeechButton::new(&self.speech))
.child(self.render_emoji_menu(dictating, window, cx))
.child(
Button::new("send")
.icon(ui::IconName::PaperPlaneFill)
.ghost()
.disabled(dictating || self.uploading)
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
),
),
)
.into_any_element()
}
fn render_emoji_menu(
&self,
disabled: bool,
_window: &Window,
cx: &Context<Self>,
) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
let emojis = current_user
.map(|current_user| {
self.directory
.read(cx)
.person(&current_user, cx)
.emojis()
.to_vec()
})
.unwrap_or_default();
Button::new("emoji")
.icon(ui::IconName::Emoji)
.ghost()
.disabled(disabled)
.dropdown_menu_with_anchor(Anchor::BottomLeft, move |this, _window, _cx| {
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
});
emojis.iter().fold(menu, |this, (shortcode, _)| {
let label = format!(":{shortcode}:");
this.menu(label.clone(), Box::new(Command::Insert(label)))
})
})
}
fn render_reply_target(&self, target: EventId, cx: &mut Context<Self>) -> AnyElement {
let context = self.group.upgrade().and_then(|entity| {
entity.read_with(cx, |group, cx| {
@@ -1141,7 +1305,7 @@ impl GroupPanel {
)
.child(
Button::new("cancel-reply")
.icon(IconName::X)
.icon(ui::IconName::Close)
.tooltip("Cancel reply")
.ghost()
.small()
@@ -1178,7 +1342,7 @@ impl GroupPanel {
)
.child(
Button::new(SharedString::from(format!("remove-attachment-{ix}")))
.icon(IconName::X)
.icon(ui::IconName::Close)
.tooltip("Remove")
.ghost()
.xsmall()
@@ -1267,13 +1431,15 @@ impl Focusable for GroupPanel {
}
impl Render for GroupPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let notice = self.notice(cx);
let empty = self.item_count(cx) == 2;
v_flex()
.size_full()
.min_w_0()
.on_action(cx.listener(Self::on_command))
.on_action(cx.listener(Self::toggle_dictation))
.child(self.render_encryption_notice(cx))
.child(self.render_banner(cx))
.child(self.render_pins(cx))
@@ -1313,6 +1479,6 @@ impl Render for GroupPanel {
})
.child(Scrollbar::vertical(&self.list_state)),
)
.child(self.render_composer(cx))
.child(self.render_composer(window, cx))
}
}
+115 -73
View File
@@ -1,5 +1,5 @@
use chat::Message;
use chat_ui::message::{MessageMedia, MessageRow, ReactionBar, ReplyPreview, group_reactions};
use chat_ui::Command;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -8,29 +8,33 @@ use gpui::{
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind};
use person::Directory;
use settings::AppSettings;
use speech::speaker::END_GRACE;
use state::NostrRegistry;
use ui::Avatar;
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS,
ReactionBar, ReplyPreview, group_reactions, react_menu,
};
use crate::GroupPanel;
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
pub(crate) struct Actions {
pub group: WeakEntity<Group>,
pub panel: WeakEntity<GroupPanel>,
pub admin: bool,
pub pinned: bool,
/// Whether a message is currently being spoken, disabling the speech buttons
pub speaking: bool,
pub persons: Entity<Directory>,
}
pub(crate) fn render(
ix: usize,
message: &Message,
content: AnyElement,
show_author: bool,
@@ -55,12 +59,14 @@ pub(crate) fn render(
nostr.read(cx).current_user(),
);
let row = MessageRow::new(ix)
// Key by event id: indices shift on prepend and would re-key descendants' state
let row = MessageRow::new(SharedString::from(message.id.to_hex()))
.show_author(show_author)
.hide_avatar(hide_avatar)
.avatar(
Avatar::new(author.avatar())
.seed(author.avatar_seed())
.small()
.flex_shrink_0(),
)
.author(author.name())
@@ -87,6 +93,91 @@ pub(crate) fn render(
let pinned = actions.pinned;
let group = actions.group;
let panel = actions.panel;
let speech_panel = panel.clone();
let speech_text = message.preview();
let speech_id = message.id;
let speaking = actions.speaking;
let author = message.author;
let mut bar = MessageActions::new();
for emoji in COMPACT_REACTION_EMOJIS {
let emoji = *emoji;
let group = group.clone();
let panel = panel.clone();
bar = bar.reaction(emoji, move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
});
}
let reply_panel = panel.clone();
let bar = bar.reply(move |_event, _window, cx| {
if let Err(error) = reply_panel.update_in(cx, |panel, window, cx| {
panel.start_reply(id, window, cx);
}) {
log::warn!("nip29: starting a reply failed: {error}");
}
});
let bar = bar
.copy(
SharedString::from(format!("message-copy-{id}")),
message.preview(),
)
.speech(speaking, move |_event, _window, cx| {
if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| {
match panel.speaker.speak(&speech_text) {
Ok(duration) => {
panel.speech_reveal = Some((speech_id, duration));
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
panel.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}) {
log::warn!("nip29: reading the message aloud failed: {error}");
}
});
let row = row.overlay(bar.dropdown(move |menu, _window, _cx| {
menu.menu("Copy author", Box::new(Command::Copy(author)))
}));
let custom = nostr
.read(cx)
.current_user()
.map(|current_user| {
persons
.read(cx)
.person(&current_user, cx)
.emojis()
.iter()
.map(|(shortcode, _)| format!(":{shortcode}:"))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let row = div()
.id(SharedString::from(format!("message-menu-{}", id.to_hex())))
@@ -106,8 +197,20 @@ pub(crate) fn render(
.submenu("React", window, cx, {
let group = group.clone();
let panel = panel.clone();
let custom = custom.clone();
move |menu, _window, cx| react_menu(menu, group.clone(), panel.clone(), id, cx)
move |menu, _window, _cx| {
let group = group.clone();
let panel = panel.clone();
react_menu(menu, REACTION_EMOJIS, &custom, move |emoji, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
})
}
});
if !admin {
@@ -142,67 +245,6 @@ pub(crate) fn render(
row.into_any_element()
}
fn react_menu(
menu: PopupMenu,
group: WeakEntity<Group>,
panel: WeakEntity<GroupPanel>,
id: EventId,
cx: &App,
) -> PopupMenu {
let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
let emoji = *emoji;
let group = group.clone();
let panel = panel.clone();
menu.item(
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
}),
)
});
let emojis = panel
.upgrade()
.and_then(|panel| {
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
Some(
panel
.read(cx)
.directory
.read(cx)
.person(&current_user, cx)
.emojis()
.to_vec(),
)
})
.unwrap_or_default();
if emojis.is_empty() {
return menu;
}
let menu = menu.separator();
emojis.into_iter().fold(menu, |menu, (shortcode, _)| {
let emoji = format!(":{shortcode}:");
let group = group.clone();
let panel = panel.clone();
menu.item(
PopupMenuItem::new(emoji.clone()).on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.clone(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
}),
)
})
}
pub(crate) fn render_activity(
ix: usize,
activity: &Activity,
@@ -218,7 +260,7 @@ pub(crate) fn render_activity(
.gap_2()
.py_1()
.px_3()
.hover(|this| this.bg(cx.theme().primary))
.hover(|this| this.bg(cx.theme().list_hover))
.child(badge(activity, cx))
.when(!hide_avatar, |this| {
this.child(
@@ -295,7 +337,7 @@ fn replies(
let mut items = Vec::with_capacity(targets.len());
for (ix, id) in targets.iter().enumerate() {
for id in targets.iter() {
let context = entity.read_with(cx, |group, cx| {
let message = group.message(*id)?;
let author = persons.read(cx).person(&message.author, cx);
@@ -314,7 +356,7 @@ fn replies(
let panel = panel.clone();
items.push(
ReplyPreview::new(ix, author, preview)
ReplyPreview::new(SharedString::from(format!("reply-{id}")), author, preview)
.on_click(move |_event, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.scroll_to(&id, cx);
+55 -67
View File
@@ -8,13 +8,13 @@ use gpui::{
};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::form::field as form_field;
use gpui_kit::component::input::InputState;
use gpui_kit::component::notification::Notification;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
use nip29::{Group, GroupId, GroupMetadata};
use nostr_sdk::prelude::RelayUrl;
use ui::settings_row;
use crate::util::field;
@@ -126,73 +126,61 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
.child(
v_flex()
.gap_2()
.child(
form_field()
.label("Private")
.description("Only members can read group messages")
.child(
Switch::new("private")
.checked(current.private)
.on_click(toggle(&flags, |flags| &mut flags.private)),
),
)
.child(
form_field()
.label("Restricted")
.description("Only members can post messages")
.child(
Switch::new("restricted")
.checked(current.restricted)
.on_click(toggle(&flags, |flags| {
&mut flags.restricted
})),
),
)
.child(
form_field()
.label("Hidden")
.description("Hidden from non-members, not discoverable")
.child(
Switch::new("hidden")
.checked(current.hidden)
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
),
)
.child(
form_field()
.label("Closed")
.description("Join requests are ignored (invite-only)")
.child(
Switch::new("closed")
.checked(current.closed)
.on_click(toggle(&flags, |flags| &mut flags.closed)),
),
)
.child(
form_field()
.label("LiveKit")
.description(if supported {
"Announce audio and video support."
} else {
"The relay doesn't announce LiveKit support."
})
.child(
Switch::new("livekit").checked(current.livekit).on_click({
let flags = flags.clone();
let livekit = livekit.clone();
.child(settings_row(
"Private",
"Only members can read group messages",
Switch::new("private")
.checked(current.private)
.on_click(toggle(&flags, |flags| &mut flags.private)),
cx,
))
.child(settings_row(
"Restricted",
"Only members can post messages",
Switch::new("restricted")
.checked(current.restricted)
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
cx,
))
.child(settings_row(
"Hidden",
"Hidden from non-members, not discoverable",
Switch::new("hidden")
.checked(current.hidden)
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
cx,
))
.child(settings_row(
"Closed",
"Join requests are ignored (invite-only)",
Switch::new("closed")
.checked(current.closed)
.on_click(toggle(&flags, |flags| &mut flags.closed)),
cx,
))
.child(settings_row(
"LiveKit",
if supported {
"Announce audio and video support."
} else {
"The relay doesn't announce LiveKit support."
},
Switch::new("livekit").checked(current.livekit).on_click({
let flags = flags.clone();
let livekit = livekit.clone();
move |next, _window, cx| {
if *next && *livekit.read(cx) == Some(false) {
return;
}
flags.update(cx, |flags, cx| {
flags.livekit = *next;
cx.notify();
});
}
}),
),
),
move |next, _window, cx| {
if *next && *livekit.read(cx) == Some(false) {
return;
}
flags.update(cx, |flags, cx| {
flags.livekit = *next;
cx.notify();
});
}
}),
cx,
)),
),
)
.on_ok({
+1 -1
View File
@@ -35,7 +35,7 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
};
match (current, previous) {
(Row::Message(current), Row::Message(previous)) => chat_ui::message::opens_run(
(Row::Message(current), Row::Message(previous)) => ui::opens_run(
Some((previous.author, previous.created_at.as_secs())),
(current.author, current.created_at.as_secs()),
),
+509 -204
View File
@@ -4,6 +4,7 @@ use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
@@ -16,17 +17,30 @@ use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use person::Directory;
use settings::AppSettings;
use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS};
use smallvec::{SmallVec, smallvec};
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
use state::{Announcement, NostrRegistry, UniversalSigner};
use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys};
use ui::Avatar;
const IDENTIFIER: &str = "coop:device";
mod storage;
/// Ignore key requests older than a day so stale events on relays cannot resurface
const REQUEST_MAX_AGE: u64 = SECONDS_PER_DAY;
pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
}
struct DeviceNotification;
/// A key request awaiting user approval on this device
#[derive(Debug)]
struct PendingRequest {
event: Event,
client_key: PublicKey,
}
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
impl Global for GlobalDeviceRegistry {}
@@ -59,11 +73,19 @@ impl DeviceEvent {
#[derive(Debug)]
pub struct DeviceRegistry {
signer: Entity<Option<UniversalSigner>>,
/// Whether there is a pending request for encryption key approval
pub(crate) pending_request: bool,
/// Ephemeral client key for the in-flight key request; never persisted
app_keys: Option<Keys>,
/// The key request currently awaiting user approval on this device
pending_request: Option<PendingRequest>,
/// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>,
/// Person directory for requesters shown in notifications
/// Whether the local key is stale against the current announcement.
resync: Arc<AtomicBool>,
/// Whether a key rotation is in flight, to coalesce overlapping requests
rotating: bool,
/// Whether a key approval is in flight, to guard against a double click
approving: bool,
/// Person directory for requesters shown in the approval dialog
directory: Entity<Directory>,
/// Relay notification listener
notification_listener: Option<Task<Result<(), Error>>>,
@@ -86,7 +108,6 @@ impl DeviceRegistry {
/// Create a new device registry instance
fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
@@ -108,22 +129,35 @@ impl DeviceRegistry {
if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) {
// The old account's state must not leak into the new one
this.announcement_existed.store(false, Ordering::Relaxed);
this.resync.store(false, Ordering::Relaxed);
// The in-flight request and client key belong to the old account
this.app_keys = None;
if this.pending_request.take().is_some() {
// The old approval must not target the new account's signer
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
this.handle_notifications(cx);
this.get_announcement(cx);
}
}),
);
cx.defer(move |cx| {
entity
.update(cx, |this, cx| this.handle_notifications(cx))
.ok();
});
Self {
signer,
pending_request: false,
app_keys: None,
pending_request: None,
announcement_existed: Arc::new(AtomicBool::new(false)),
resync: Arc::new(AtomicBool::new(false)),
rotating: false,
approving: false,
directory: Directory::new(cx),
notification_listener: None,
tasks: vec![],
@@ -155,22 +189,31 @@ impl DeviceRegistry {
continue;
}
match event.kind {
Kind::Custom(10044) if current_user == Some(event.pubkey) => {
announcement_existed.store(true, Ordering::Relaxed);
tx.send_async(event.into_owned()).await?;
}
Kind::Custom(4454) if current_user == Some(event.pubkey) => {
tx.send_async(event.into_owned()).await?;
}
Kind::Custom(4455) if current_user == Some(event.pubkey) => {
tx.send_async(event.into_owned()).await?;
}
_ => {}
let relevant = matches!(
event.kind,
Kind::Custom(10044) | Kind::Custom(4454) | Kind::Custom(4455)
) && current_user == Some(event.pubkey);
if !relevant {
continue;
}
if event.kind == Kind::Custom(10044) {
announcement_existed.store(true, Ordering::Relaxed);
}
let event_id = event.id;
// Dropping keeps the listener alive when the consumer is gone;
// missed 4e events are re-synced on the next signer change
if let Err(error) = tx.send_async(event.into_owned()).await {
log::error!("Dropping device key event {event_id}: {error}");
}
}
}
// The stream only ends without being replaced when the relay source is
// gone, after which no 4e event is processed anymore
log::error!("Device key notification listener stopped");
Ok(())
}));
@@ -191,7 +234,7 @@ impl DeviceRegistry {
// New response event from the master device
Kind::Custom(4455) => {
this.update(cx, |this, cx| {
this.extract_encryption(event, cx);
this.handle_transfer(event, cx);
})?;
}
_ => {}
@@ -201,12 +244,37 @@ impl DeviceRegistry {
}));
}
/// Set whether there is a pending request for encryption key approval
fn set_pending_request(&mut self, pending: bool, cx: &mut Context<Self>) {
self.pending_request = pending;
/// Withdraw the pending approval.
fn resolve_pending_request(&mut self, cx: &mut Context<Self>) {
if self.pending_request.take().is_none() {
return;
}
if let Some(window) = cx.windows().first().copied() {
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
cx.notify();
}
/// Route a key transfer.
fn handle_transfer(&mut self, event: Event, cx: &mut Context<Self>) {
let resolved = self
.pending_request
.as_ref()
.is_some_and(|pending| transfer_addresses(&event, pending.client_key));
if resolved {
self.resolve_pending_request(cx);
return;
}
self.extract_encryption(event, cx);
}
/// Get the signer
pub fn signer(&self, cx: &App) -> Option<UniversalSigner> {
self.signer.read(cx).clone()
@@ -218,28 +286,48 @@ impl DeviceRegistry {
*this = Some(UniversalSigner::new(new_signer));
cx.notify();
});
// The local key is now consistent with the announcement
self.resync.store(false, Ordering::Release);
cx.emit(DeviceEvent::Set);
}
/// Backup the encryption's secret key to a file
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
/// Whether the local encryption key is stale against the current announcement.
pub fn is_resyncing(&self) -> bool {
self.resync.load(Ordering::Acquire)
}
/// Backup the encryption's secret key, returning the `nsec1...` content
pub fn backup_bytes(&self, cx: &App) -> Task<Result<String, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
cx.background_spawn(async move {
let keys = get_keys(&client, &signer).await?;
let content = keys.secret_key().to_bech32()?;
Ok(keys.secret_key().to_bech32()?)
})
}
if cfg!(target_arch = "wasm32") {
return Err(anyhow!("Not supported"));
}
/// Backup the encryption's secret key to a file
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
let backup = self.backup_bytes(cx);
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(target_arch = "wasm32"))]
return cx.background_spawn(async move {
let content = backup.await?;
smol::fs::write(path, &content).await?;
Ok(())
})
});
#[cfg(target_arch = "wasm32")]
{
let _ = path;
cx.background_spawn(async move {
backup.await?;
Err(anyhow!("File backups are not supported on web; copy the key from the backup panel instead."))
})
}
}
/// Get device announcement for current user
@@ -310,6 +398,95 @@ impl DeviceRegistry {
}));
}
/// Replace the encryption key with a new one, archiving the old key.
pub fn rotate(&mut self, cx: &mut Context<Self>) {
if self.rotating {
return;
}
self.rotating = true;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let result: Result<Keys, Error> = async {
// Archive the current key before replacing it; a failed load or
// archive aborts the rotation so the old key is never overwritten
// without a record
let old = get_keys(&client, &signer)
.await
.context("Failed to load the current encryption key before rotation")?;
archive_key(&client, &signer, &old.secret_key().to_secret_hex())
.await
.context("Failed to archive the current encryption key")?;
let created = this
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
.await?;
this.update(cx, |this, cx| {
this.set_signer(created.clone(), cx);
this.wait_for_request(cx);
})?;
Ok(created)
}
.await;
this.update(cx, |this, cx| {
this.rotating = false;
if let Err(e) = result {
cx.emit(DeviceEvent::error(e.to_string()));
}
})
.ok();
Ok(())
}));
}
/// Rotate the encryption key when the configured interval has elapsed.
///
/// Runs after the announcement reconciled, outside the blocking boot path.
pub fn rotate_if_due(&mut self, cx: &mut Context<Self>) {
let Some(interval_days) = AppSettings::get_nip4e_rotation_days(cx) else {
return;
};
let interval_days = interval_days.clamp(NIP4E_ROTATION_MIN_DAYS, NIP4E_ROTATION_MAX_DAYS);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let Ok(keys) = load_device_keys(&client, &signer).await else {
return Ok(());
};
let elapsed = Timestamp::now()
.as_secs()
.saturating_sub(keys.created_at.as_secs());
if elapsed < u64::from(interval_days) * SECONDS_PER_DAY {
return Ok(());
}
this.update(cx, |this, cx| this.rotate(cx)).ok();
Ok(())
}));
}
/// Load encryption keys retired by a rotation from the local database.
pub async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
storage::archived_keys(client, signer).await
}
/// Create new encryption key and announce it to user's relay list
fn create_encryption(&self, keys: Keys, cx: &App) -> Task<Result<Keys, Error>> {
let nostr = NostrRegistry::global(cx);
@@ -327,7 +504,7 @@ impl DeviceRegistry {
.await?;
// Persist the key locally before announcing it
set_keys(&client, &signer, &secret).await?;
set_keys(&client, &signer, &secret, Timestamp::now()).await?;
// Publish announcement
client
@@ -370,10 +547,12 @@ impl DeviceRegistry {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
this.wait_for_request(cx);
this.rotate_if_due(cx);
})?;
} else {
this.update(cx, |this, cx| {
this.request(cx);
this.resync.store(true, Ordering::Release);
this.request(Some(device_pubkey), cx);
})?;
}
Ok(())
@@ -388,10 +567,13 @@ impl DeviceRegistry {
self.tasks.push(cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
let id = SubscriptionId::new("dekey-requests");
// Construct a filter for encryption key requests.
let filter = Filter::new().kind(Kind::Custom(4454)).author(public_key);
let id = SubscriptionId::new("dekey-requests");
let since = Timestamp::now().as_secs().saturating_sub(REQUEST_MAX_AGE);
let filter = Filter::new()
.kind(Kind::Custom(4454))
.author(public_key)
.since(Timestamp::from_secs(since));
// Subscribe to the device key requests on user's write relays
client.subscribe(vec![filter]).with_id(id).await?;
@@ -401,15 +583,17 @@ impl DeviceRegistry {
}
/// Request encryption keys from other device
pub(crate) fn request(&mut self, cx: &mut Context<Self>) {
pub(crate) fn request(
&mut self,
expected_encryption: Option<PublicKey>,
cx: &mut Context<Self>,
) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let app_keys_task = get_or_init_app_keys(cx);
let app_keys = self.app_keys.get_or_insert_with(Keys::generate).clone();
let task: Task<Result<Option<Event>, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let app_pubkey = app_keys.public_key();
let public_key = signer.get_public_key_async().await?;
@@ -421,8 +605,7 @@ impl DeviceRegistry {
.limit(1);
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
// Only reuse the stored approval if its key is still valid,
// otherwise it's stale and a fresh request is needed
// Only reuse the stored approval if its key is still valid
if decrypt_approval(&client, &app_keys, public_key, &event)
.await
.is_ok()
@@ -431,9 +614,26 @@ impl DeviceRegistry {
}
}
// No usable approval event found, construct a request event
// No usable approval event found, construct a request event.
//
// The `pubkey` tag is a compatibility alias for the draft's `P`,
// `relay` hints tell other devices where the response is accepted,
// `n` names the exact key this device expects to be transferred
let mut tags = vec![
Tag::custom("P", vec![app_pubkey]),
Tag::custom("pubkey", vec![app_pubkey]),
];
for url in write_relay_hints(&client, public_key).await? {
tags.push(Tag::custom("relay", vec![url.to_string()]));
}
if let Some(expected) = expected_encryption {
tags.push(Tag::custom("n", vec![expected]));
}
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(vec![Tag::custom("P", vec![app_pubkey])])
.tags(tags)
.finalize_async(&signer)
.await?;
@@ -470,19 +670,13 @@ impl DeviceRegistry {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let app_keys_task = get_or_init_app_keys(cx);
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Cannot wait for approval without a client key");
return;
};
self.tasks.push(cx.spawn(async move |this, cx| {
let app_keys = match app_keys_task.await {
Ok(app_keys) => app_keys,
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
return Ok(());
}
};
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::Requesting(authorization_code(
&app_keys.public_key(),
@@ -492,7 +686,7 @@ impl DeviceRegistry {
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let public_key = signer.get_public_key_async().await?;
// Only approvals addressed to this device's app key matter
// Only approvals addressed to this device's client key matter
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
@@ -516,15 +710,18 @@ impl DeviceRegistry {
/// Parse the approval event to get encryption key then set it
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let app_keys_task = get_or_init_app_keys(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let Some(app_keys) = self.app_keys.clone() else {
log::warn!("Ignoring key transfer addressed to another device");
return;
};
let approval_id = event.id;
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let app_keys = app_keys_task.await?;
let identity = signer.get_public_key_async().await?;
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
@@ -536,14 +733,14 @@ impl DeviceRegistry {
Ok(keys) => {
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
// The client key was consumed; start fresh on the next request
this.app_keys = None;
// Clean up our request and the approval event from relays
this.cleanup_sync_events(approval_id, cx);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(DeviceEvent::error(e.to_string()));
})?;
log::warn!("Ignoring key transfer: {e}");
}
}
Ok(())
@@ -593,44 +790,70 @@ impl DeviceRegistry {
/// Approve requests for device keys from other devices
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
// An approval already in flight must not send the device secret twice;
// the toast's disabled state only applies after its next re-render
if self.approving {
return;
}
self.approving = true;
cx.notify();
let approved_id = event.id;
let event = event.clone();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
// Get user's write relays
let event = event.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Get device keys
let keys = get_keys(&client, &signer).await?;
let secret = keys.secret_key().to_secret_hex();
let identity = signer.get_public_key_async().await?;
// Extract the target public key from the event tags
let target = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Target is not a valid public key")?;
let target =
client_pubkey_from_event(&event).context("Target is not a valid public key")?;
// Encrypt the device keys with the user's signer
let payload = keys.nip44_encrypt_async(&target, &secret).await?;
// The requester may pin the exact key it expects
if let Some(requested) = requested_encryption_key(&event)
&& requested != keys.public_key()
{
return Err(anyhow!(
"The requesting device expects an encryption key this device no longer holds"
));
}
// Encrypt to a fresh one-time keypair so the transfer event
// can never be decrypted with the long-lived encryption key.
let sender = Keys::generate();
let payload = sender.nip44_encrypt_async(&target, &secret).await?;
// Construct the response event
//
// P tag: the current device's public key
// p tag: the requester's public key
let event = EventBuilder::new(Kind::Custom(4455), payload)
// P tag: the one-time sender key used for the encryption
// p tag: the account identity, for inbox routing
// p tag: the requester's client key
let response = EventBuilder::new(Kind::Custom(4455), payload)
.tags(vec![
Tag::custom("P", vec![keys.public_key().to_hex()]),
Tag::custom("P", vec![sender.public_key().to_hex()]),
Tag::public_key(identity),
Tag::public_key(target),
])
.finalize_async(&signer)
.await?;
// Send the response event to the user's relay list
client.send_event(&event).to_nip65().await?;
let relays = merge_relays(
write_relay_hints(&client, identity).await?,
request_relay_hints(&event),
);
let send = client.send_event(&response);
if relays.is_empty() {
send.to_nip65().await?;
} else {
send.to(relays).await?;
}
Ok(())
});
@@ -638,17 +861,41 @@ impl DeviceRegistry {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(_) => {
this.update(cx, |this, cx| {
// Allow future requests to surface again
this.set_pending_request(false, cx);
let superseded = this.update(cx, |this, cx| {
this.approving = false;
cx.notify();
// Keep a newer request's pending state and toast
if this
.pending_request
.as_ref()
.is_some_and(|pending| pending.event.id == approved_id)
{
this.pending_request = None;
false
} else {
true
}
})?;
cx.update(|window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
if !superseded {
cx.update(|window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
}
Err(e) => {
this.update(cx, |this, cx| {
this.approving = false;
cx.notify();
// Pushing a fresh toast replaces the wedged one whose
// button stays disabled
if let Some(pending) = this.pending_request.take() {
this.show_approval(pending.event, pending.client_key, cx);
}
})?;
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
@@ -665,21 +912,56 @@ impl DeviceRegistry {
/// Handle encryption request
fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
// Ignore if there is already a pending request
if self.pending_request {
let expires_at = Timestamp::now()
.as_secs()
.saturating_sub(event.created_at.as_secs());
if expires_at > REQUEST_MAX_AGE {
log::warn!("Ignoring expired encryption key request");
return;
}
self.set_pending_request(true, cx);
let notification = match self.notification(event, cx) {
let Some(client_key) = client_pubkey_from_event(&event) else {
log::warn!("Ignoring invalid encryption key request: no valid requester key");
return;
};
match self.pending_request.take() {
// A retry signs a new request id but keeps the same client key
Some(mut pending) if pending.client_key == client_key => {
pending.event = event;
self.pending_request = Some(pending);
}
// A different client key is a remounted requester
other => {
if other.is_some()
&& let Some(window) = cx.windows().first().copied()
{
window
.update(cx, |_view, window, cx| {
window.remove_notification::<DeviceNotification>(cx);
})
.ok();
}
self.show_approval(event, client_key, cx);
}
}
}
/// Build and surface the approval notification for a key request
fn show_approval(&mut self, event: Event, client_key: PublicKey, cx: &mut Context<Self>) {
let notification = match self.notification(event.clone(), cx) {
Ok(notification) => notification,
Err(e) => {
log::warn!("Ignoring invalid encryption key request: {e}");
self.set_pending_request(false, cx);
return;
}
};
self.pending_request = Some(PendingRequest { event, client_key });
cx.notify();
if let Some(window) = cx.windows().first().copied() {
if let Err(error) = window.update(cx, |_view, window, cx| {
window.push_notification(notification, cx);
@@ -702,19 +984,17 @@ impl DeviceRegistry {
compare the code with the one shown on the requesting \
device.";
let requester = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Request has no valid requester key")?;
let requester =
client_pubkey_from_event(&event).context("Request has no valid requester key")?;
let code = authorization_code(&requester);
let directory = self.directory.clone();
directory.update(cx, |directory, cx| directory.track([requester], cx));
let code_label = code.clone();
let directory = self.directory.clone();
directory.update(cx, |directory, cx| {
directory.track([requester], cx);
});
let entity = cx.entity().downgrade();
let loading = Rc::new(Cell::new(false));
@@ -803,9 +1083,10 @@ impl DeviceRegistry {
.on_click({
let loading = Rc::clone(&loading);
move |_ev, window, cx| {
// Set loading state to true
loading.set(true);
// Process to approve the request
// The toast caches its render, so force a redraw
// to disable the button while the approval is in flight
window.refresh();
view.update(cx, |this, cx| {
this.approve(&event, window, cx);
})
@@ -816,7 +1097,86 @@ impl DeviceRegistry {
}
}
struct DeviceNotification;
/// Extract the client public key from an event.
fn client_pubkey_from_event(event: &Event) -> Option<PublicKey> {
let key = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok());
key.or_else(|| {
event
.tags
.iter()
.find(|tag| tag.kind() == "pubkey")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
})
}
/// Whether the transfer event is p-tagged to the given client key
fn transfer_addresses(event: &Event, client_key: PublicKey) -> bool {
event.tags.iter().any(|tag| {
tag.kind() == "p"
&& tag
.content()
.and_then(|content| PublicKey::parse(content).ok())
.is_some_and(|key| key == client_key)
})
}
/// Parse the `relay` hints attached to a key request.
fn request_relay_hints(event: &Event) -> Vec<RelayUrl> {
event
.tags
.iter()
.filter(|tag| tag.kind() == "relay")
.filter_map(|tag| {
tag.content()
.and_then(|content| RelayUrl::parse(content).ok())
})
.collect()
}
/// Parse the optional `n` tag of a key request.
fn requested_encryption_key(event: &Event) -> Option<PublicKey> {
event
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
}
/// Collect the user's NIP-65 write relays.
async fn write_relay_hints(client: &Client, identity: PublicKey) -> Result<Vec<RelayUrl>, Error> {
let filter = Filter::new()
.kind(Kind::RelayList)
.author(identity)
.limit(1);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
// Unmarked relays serve both reads and writes
Ok(nip65::extract_relay_list(&event)
.filter(|(_, metadata)| *metadata != Some(RelayMetadata::Read))
.map(|(url, _)| url)
.collect())
}
/// Merge relay lists, dropping duplicates
fn merge_relays(first: Vec<RelayUrl>, second: Vec<RelayUrl>) -> Vec<RelayUrl> {
first
.into_iter()
.chain(second)
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
/// The first eight hexadecimal characters of the client public key.
fn authorization_code(public_key: &PublicKey) -> SharedString {
@@ -825,36 +1185,6 @@ fn authorization_code(public_key: &PublicKey) -> SharedString {
SharedString::from(format!("{} {}", &code[..4], &code[4..]))
}
/// Get or create new app keys (async, returns a task)
fn get_or_init_app_keys(cx: &App) -> Task<Result<Keys, Error>> {
let read = cx.read_credentials(CLIENT_NAME);
cx.spawn(async move |cx| {
if let Ok(Some((_, secret))) = read.await
&& let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new)
{
return Ok(keys);
}
// No stored keys found or invalid — generate new ones
let keys = Keys::generate();
let user = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_bytes();
cx.update(|cx| {
let write = cx.write_credentials(CLIENT_NAME, &user, &secret);
cx.background_spawn(async move {
if let Err(e) = write.await {
log::error!("Keyring not available or panic: {e}")
}
})
.detach();
});
Ok(keys)
})
}
/// Decrypt and validate an approval event against the current announcement.
async fn decrypt_approval(
client: &Client,
@@ -862,13 +1192,7 @@ async fn decrypt_approval(
identity: PublicKey,
event: &Event,
) -> Result<Keys, Error> {
let master = event
.tags
.iter()
.find(|tag| tag.kind() == "P")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let master = client_pubkey_from_event(event).context("Invalid event's tags")?;
let decrypted = app_keys
.nip44_decrypt_async(&master, event.content.as_str())
@@ -877,71 +1201,52 @@ async fn decrypt_approval(
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
// Reject approvals carrying a key that no longer matches the published announcement.
// Reject approvals carrying a key that no longer matches the published announcement
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(identity)
.limit(1);
if let Some(announcement) = client.database().query(filter).await?.into_iter().next() {
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
let announcement = client
.database()
.query(filter.clone())
.await?
.into_iter()
.next();
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
let announcement = match announcement {
Some(announcement) => Some(announcement),
None => {
let mut stream = client
.stream_events(filter)
.timeout(Duration::from_secs(3))
.await?;
stream
.next()
.await
.map(|(_url, result)| result)
.transpose()?
}
};
let Some(announcement) = announcement else {
return Err(anyhow!(
"No encryption announcement available to validate the approval"
));
};
let announced = announcement
.tags
.iter()
.find(|tag| tag.kind() == "n")
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Announcement has no valid encryption key")?;
if keys.public_key() != announced {
return Err(anyhow!("Approval key doesn't match the announcement"));
}
Ok(keys)
}
/// Encrypt and store device keys in the local database.
async fn set_keys<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer.nip44_encrypt_async(&public_key, secret).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
if let Some(event) = client.database().query(filter).await?.first() {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}
+174
View File
@@ -0,0 +1,174 @@
use anyhow::{Error, anyhow};
use nostr_sdk::prelude::*;
/// The storage identifier for the device encryption key
const IDENTIFIER: &str = "coop:device";
/// The storage identifier for encryption keys retired by a rotation
const ARCHIVE_IDENTIFIER: &str = "coop:device-archive";
/// Keep the archived key list bounded
const ARCHIVE_LIMIT: usize = 10;
/// Seconds in a day, for rotation intervals
pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
/// Device encryption key material with its creation time
#[derive(Debug, Clone)]
pub(crate) struct DeviceKeys {
pub(crate) keys: Keys,
pub(crate) created_at: Timestamp,
}
/// Encode the stored key content: secret hex and creation time
fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String {
format!("{secret}\n{}", created_at.as_secs())
}
/// Parse stored key content.
fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> {
match content.split_once('\n') {
Some((secret, created_at)) => {
let created_at = created_at
.trim()
.parse::<u64>()
.unwrap_or_else(|_| Timestamp::now().as_secs());
Ok((
SecretKey::from_hex(secret)?,
Timestamp::from_secs(created_at),
))
}
None => Ok((SecretKey::from_hex(content)?, Timestamp::now())),
}
}
/// Parse archived secrets from stored content, one hex secret per line
fn parse_archived_keys(content: &str) -> Vec<Keys> {
content
.lines()
.filter_map(|line| SecretKey::from_hex(line.trim()).ok())
.map(Keys::new)
.collect()
}
/// Load the device keys with their creation time from the local database.
pub(crate) async fn load_device_keys<S>(client: &Client, signer: &S) -> Result<DeviceKeys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Err(anyhow!("Key not found"));
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
let (secret, created_at) = parse_keys_content(&content)?;
Ok(DeviceKeys {
keys: Keys::new(secret),
created_at,
})
}
/// Encrypt and store device keys in the local database.
pub(crate) async fn set_keys<S>(
client: &Client,
signer: &S,
secret: &str,
created_at: Timestamp,
) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let content = signer
.nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at))
.await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(IDENTIFIER))
.finalize_async(signer)
.await?;
// Save the event to the database
client.database().save_event(&event).await?;
Ok(())
}
/// Append an encryption secret to the bounded archive of retired keys.
pub(crate) async fn archive_key<S>(client: &Client, signer: &S, secret: &str) -> Result<(), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let mut archived: Vec<String> = match client.database().query(filter).await?.into_iter().next()
{
Some(event) => {
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
content.lines().map(str::to_string).collect()
}
None => vec![],
};
archived.insert(0, secret.to_string());
archived.truncate(ARCHIVE_LIMIT);
let content = signer
.nip44_encrypt_async(&public_key, &archived.join("\n"))
.await?;
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(ARCHIVE_IDENTIFIER))
.finalize_async(signer)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
/// Get device keys from the local database.
pub(crate) async fn get_keys<S>(client: &Client, signer: &S) -> Result<Keys, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
Ok(load_device_keys(client, signer).await?.keys)
}
/// Load encryption keys retired by a rotation from the local database.
pub(crate) async fn archived_keys<S>(client: &Client, signer: &S) -> Result<Vec<Keys>, Error>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let public_key = signer.get_public_key_async().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ARCHIVE_IDENTIFIER)
.author(public_key);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(vec![]);
};
let content = signer
.nip44_decrypt_async(&public_key, &event.content)
.await?;
Ok(parse_archived_keys(&content))
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "nip60"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
state = { path = "../state" }
common = { path = "../common" }
gpui.workspace = true
gpui_tokio.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
log.workspace = true
flume.workspace = true
serde_json.workspace = true
futures.workspace = true
hkdf.workspace = true
sha2.workspace = true
cdk.workspace = true
cdk-redb.workspace = true
+369
View File
@@ -0,0 +1,369 @@
use anyhow::{Error, anyhow};
use hkdf::Hkdf;
use nostr::nips::nip60::TokenEvent;
use nostr_sdk::prelude::*;
use sha2::Sha256;
const SEED_SALT: &[u8] = b"coop nip60 seed salt";
const SEED_INFO: &[u8] = b"coop cashu seed";
/// Parsed 17375 wallet event: the wallet key and its mint list.
#[derive(Clone)]
pub(crate) struct WalletData {
pub(crate) event_id: EventId,
pub(crate) created_at: Timestamp,
pub(crate) privkey: SecretKey,
pub(crate) mints: Vec<Url>,
}
impl WalletData {
/// A new wallet with a fresh key.
pub(crate) fn generate(first_mint: Url) -> Self {
Self {
event_id: EventId::from_byte_array([0; 32]),
created_at: Timestamp::from_secs(0),
privkey: SecretKey::generate(),
mints: vec![first_mint],
}
}
/// The cdk seed, derived deterministically from the wallet key.
pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> {
let ikm = privkey.to_secret_bytes();
let hk = Hkdf::<Sha256>::new(Some(SEED_SALT), &ikm);
let mut seed = [0u8; 64];
hk.expand(SEED_INFO, &mut seed)
.map_err(|error| anyhow!("seed derivation failed: {error}"))?;
Ok(seed)
}
/// Decrypt and parse a 17375 event published by this account.
pub(crate) async fn decrypt<S>(signer: &S, me: &PublicKey, event: &Event) -> Result<Self, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("wallet event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let (privkey, mints) = parse_wallet_content(&content)?;
Ok(Self {
event_id: event.id,
created_at: event.created_at,
privkey,
mints,
})
}
/// Build the encrypted 17375 event for this wallet data.
pub(crate) async fn to_event<S>(&self, signer: &S, me: &PublicKey) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut rows = vec![vec!["privkey".to_string(), self.privkey.to_secret_hex()]];
for mint in &self.mints {
rows.push(vec!["mint".to_string(), mint.to_string()]);
}
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWallet, content)
.finalize_async(signer)
.await?)
}
}
fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec<Url>), Error> {
let rows: Vec<Vec<String>> =
serde_json::from_str(content).map_err(|error| anyhow!("invalid wallet event: {error}"))?;
let mut privkey = None;
let mut mints = Vec::new();
for row in rows {
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
continue;
};
match key.as_str() {
"privkey" if privkey.is_none() => privkey = Some(SecretKey::from_hex(value)?),
"mint" => {
if let Ok(mint) = Url::parse(value) {
mints.push(mint);
}
}
_ => {}
}
}
let Some(privkey) = privkey else {
return Err(anyhow!("wallet event is missing the private key"));
};
if mints.is_empty() {
return Err(anyhow!("wallet event is missing mints"));
}
Ok((privkey, mints))
}
pub(crate) async fn decrypt_token_event<S>(
signer: &S,
me: &PublicKey,
event: &Event,
) -> Result<TokenEvent, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("token event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let token =
serde_json::from_str(&content).map_err(|error| anyhow!("invalid token event: {error}"))?;
Ok(token)
}
pub(crate) async fn build_token_event<S>(
signer: &S,
me: &PublicKey,
token: &TokenEvent,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(token)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWalletUnspentProof, content)
.finalize_async(signer)
.await?)
}
/// NIP-09 deletion carrying the `k: 7375` tag required by NIP-60.
pub(crate) async fn build_token_deletion<S>(signer: &S, ids: &[EventId]) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut tags: Vec<Tag> = ids.iter().map(|id| Tag::event(*id)).collect();
tags.push(Tag::custom("k", ["7375".to_string()]));
Ok(EventBuilder::new(Kind::EventDeletion, "")
.tags(tags)
.finalize_async(signer)
.await?)
}
/// Decrypted 7376 payload, enough for the wallet panel's history list.
pub(crate) struct HistoryPayload {
pub(crate) direction: TransactionDirection,
pub(crate) amount: u64,
}
pub(crate) async fn decrypt_history_event<S>(
signer: &S,
me: &PublicKey,
event: &Event,
) -> Result<HistoryPayload, Error>
where
S: AsyncNip44 + ?Sized,
{
if event.pubkey != *me {
return Err(anyhow!("history event was not published by this account"));
}
let content = signer.nip44_decrypt_async(me, &event.content).await?;
let rows: Vec<Vec<String>> = serde_json::from_str(&content)
.map_err(|error| anyhow!("invalid history event: {error}"))?;
let mut direction = None;
let mut amount = None;
for row in rows {
let (Some(key), Some(value)) = (row.first(), row.get(1)) else {
continue;
};
match key.as_str() {
"direction" if direction.is_none() => direction = value.parse().ok(),
"amount" if amount.is_none() => amount = value.parse().ok(),
_ => {}
}
}
let Some(direction) = direction else {
return Err(anyhow!("history event is missing the direction"));
};
let Some(amount) = amount else {
return Err(anyhow!("history event is missing the amount"));
};
Ok(HistoryPayload { direction, amount })
}
/// Build the public kind 9321 nutzap event carrying P2PK-locked proofs.
pub(crate) async fn build_nutzap_event<S>(
signer: &S,
recipient: PublicKey,
mint: &str,
proofs: &[String],
target: Option<EventId>,
comment: &str,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let mut tags: Vec<Tag> = vec![
Tag::custom("p", [recipient.to_hex()]),
Tag::custom("u", [mint.to_string()]),
Tag::custom("unit", ["sat".to_string()]),
];
for proof in proofs {
tags.push(Tag::custom("proof", [proof.clone()]));
}
if let Some(target) = target {
tags.push(Tag::custom("e", [target.to_hex()]));
}
Ok(EventBuilder::new(Kind::Custom(9321), comment)
.tags(tags)
.finalize_async(signer)
.await?)
}
/// Encrypted 7376 history payload for one state change.
pub(crate) struct History {
pub(crate) direction: TransactionDirection,
pub(crate) amount: u64,
/// NIP-61 nutzap events redeemed by this state change
pub(crate) redeemed: Vec<EventId>,
/// Author of the redeemed nutzap, for the unencrypted `p` tag
pub(crate) sender: Option<PublicKey>,
}
pub(crate) async fn build_history_event<S>(
signer: &S,
me: &PublicKey,
history: &History,
created: EventId,
destroyed: &[EventId],
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let mut rows = vec![
vec!["direction".to_string(), history.direction.to_string()],
vec!["amount".to_string(), history.amount.to_string()],
];
for id in destroyed {
rows.push(vec![
"e".to_string(),
id.to_hex(),
String::new(),
"destroyed".to_string(),
]);
}
rows.push(vec![
"e".to_string(),
created.to_hex(),
String::new(),
"created".to_string(),
]);
let mut tags: Vec<Tag> = history
.redeemed
.iter()
.map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
.collect();
if let Some(sender) = history.sender {
tags.push(Tag::custom("p", [sender.to_hex()]));
}
let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
.await?;
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
.tags(tags)
.finalize_async(signer)
.await?)
}
pub(crate) async fn build_nutzap_info_event<S>(
signer: &S,
mints: &[Url],
relays: &[RelayUrl],
wallet_pubkey: PublicKey,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let mut tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::custom("relay", [relay.to_string()]))
.collect();
for mint in mints {
tags.push(Tag::custom("mint", [mint.to_string(), "sat".to_string()]));
}
tags.push(Tag::custom(
"pubkey",
[format!("02{}", wallet_pubkey.to_hex())],
));
Ok(EventBuilder::new(Kind::Custom(10019), "")
.tags(tags)
.finalize_async(signer)
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_wallet_content() {
let privkey = SecretKey::generate();
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
let content = serde_json::to_string(&vec![
vec!["privkey".to_string(), privkey.to_secret_hex()],
vec!["mint".to_string(), mint.to_string()],
])
.expect("serialization cannot fail");
let (parsed_key, mints) = parse_wallet_content(&content).expect("valid content");
assert_eq!(parsed_key.to_secret_hex(), privkey.to_secret_hex());
assert_eq!(mints, vec![mint]);
}
#[test]
fn wallet_content_rejects_missing_privkey() {
let mint = Url::parse("https://mint.example.com").expect("valid mint URL");
let content = serde_json::to_string(&vec![vec!["mint".to_string(), mint.to_string()]])
.expect("serialization cannot fail");
assert!(parse_wallet_content(&content).is_err());
}
#[test]
fn seed_is_deterministic_per_key() {
let key = SecretKey::generate();
let first = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
let second = WalletData::derive_seed(&key).expect("seed derivation cannot fail");
assert_eq!(first, second);
let other =
WalletData::derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail");
assert_ne!(first, other);
}
}
File diff suppressed because it is too large Load Diff
+630
View File
@@ -0,0 +1,630 @@
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Error, anyhow};
use cdk::Amount;
use cdk::amount::SplitTarget;
use cdk::mint_url::MintUrl;
use cdk::nuts::nut00::KnownMethod;
use cdk::nuts::{
CurrencyUnit, Id, KeySetInfo, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey,
SpendingConditions, State,
};
use cdk::secret::Secret;
use cdk::wallet::types::ProofInfo;
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
use futures::StreamExt;
use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection};
use nostr_sdk::prelude::*;
use crate::keys;
/// How long to wait for relays when fetching a recipient's wallet info
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
/// A job mutating the wallet store or reconciling it with relays.
#[derive(Debug, Clone)]
pub(crate) enum Job {
Reconcile,
CreateQuote {
mint: MintUrl,
amount: u64,
},
ClaimQuote {
mint: MintUrl,
quote_id: String,
},
SendNutzap {
recipient: PublicKey,
amount: u64,
target: Option<EventId>,
comment: Option<String>,
},
Receive {
mint: MintUrl,
token: String,
},
RedeemNutzap {
mint: MintUrl,
proofs: Proofs,
nutzap_id: EventId,
sender: PublicKey,
},
}
impl Job {
pub(crate) async fn run<S>(self, ctx: JobCtx<S>) -> Result<Outcome, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static,
{
match self {
Job::Reconcile => ctx.reconcile().await,
Job::CreateQuote { mint, amount } => {
let wallet = ctx.wallet(&mint)?;
let quote = wallet
.mint_quote(
PaymentMethod::Known(KnownMethod::Bolt11),
Some(Amount::from(amount)),
None,
None,
)
.await?;
Ok(Outcome::QuoteCreated {
mint,
quote_id: quote.id,
request: quote.request,
amount,
})
}
Job::ClaimQuote { mint, quote_id } => {
let wallet = ctx.wallet(&mint)?;
let quote = wallet.check_mint_quote(&quote_id).await?;
if quote.state != MintQuoteState::Paid {
return Ok(Outcome::Idle);
}
let proofs = wallet.mint(&quote_id, SplitTarget::default(), None).await?;
let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum();
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(quote.amount.unwrap_or_default()),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::QuoteMinted {
quote_id,
update,
amount,
})
}
Job::SendNutzap {
recipient,
amount,
target,
comment,
} => {
let (mint_url, mint_tag, p2pk) = recipient_wallet_info(&ctx, recipient).await?;
let wallet = ctx.wallet(&mint_url)?;
let keysets = wallet
.keysets(cdk::wallet::types::KeysetLoadPolicy::CacheOnly)
.await?;
let infos: Vec<KeySetInfo> = keysets
.iter()
.map(|keyset| KeySetInfo {
id: keyset.id,
unit: keyset.unit.clone(),
active: keyset.active.unwrap_or(true),
input_fee_ppk: keyset.input_fee_ppk,
final_expiry: keyset.final_expiry,
})
.collect();
let options = SendOptions {
conditions: Some(SpendingConditions::new_p2pk(p2pk, None)),
..Default::default()
};
let token = wallet
.prepare_send(Amount::from(amount), options)
.await?
.confirm(None)
.await?;
let proofs = token.proofs(&infos)?;
let mut tags = Vec::new();
for proof in &proofs {
tags.push(serde_json::to_string(proof)?);
}
let event = keys::build_nutzap_event(
&ctx.signer,
recipient,
&mint_tag,
&tags,
target,
comment.as_deref().unwrap_or(""),
)
.await?;
ctx.client.send_event(&event).await?;
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint_url)?,
&ctx.previous_ids(&mint_url),
Some(keys::History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::NutzapSent { update, amount })
}
Job::Receive { mint, token } => {
let wallet = ctx.wallet(&mint)?;
let received = wallet.receive(&token, ReceiveOptions::default()).await?;
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Received {
update,
amount: u64::from(received),
})
}
Job::RedeemNutzap {
mint,
proofs,
nutzap_id,
sender,
} => {
let wallet = ctx.wallet(&mint)?;
let options = ReceiveOptions {
p2pk_signing_keys: vec![ctx.wallet_key.clone()],
..Default::default()
};
let received = wallet.receive_proofs(proofs, options, None, None).await?;
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: vec![nutzap_id],
sender: Some(sender),
}),
)
.await?;
Ok(Outcome::NutzapRedeemed {
update,
nutzap_id,
amount: u64::from(received),
})
}
}
}
}
pub(crate) struct TokenUpdate {
pub(crate) event_id: EventId,
pub(crate) token: TokenEvent,
pub(crate) destroyed: Vec<EventId>,
}
pub(crate) enum Outcome {
/// Nothing changed (e.g. an unpaid quote)
Idle,
/// Token events published by a reconcile pass
Reconciled {
updates: Vec<TokenUpdate>,
},
QuoteCreated {
mint: MintUrl,
quote_id: String,
request: String,
amount: u64,
},
QuoteMinted {
quote_id: String,
update: TokenUpdate,
amount: u64,
},
NutzapSent {
update: TokenUpdate,
amount: u64,
},
Received {
update: TokenUpdate,
amount: u64,
},
NutzapRedeemed {
update: TokenUpdate,
nutzap_id: EventId,
amount: u64,
},
}
pub(crate) struct JobCtx<S> {
pub(crate) wallets: HashMap<MintUrl, Wallet>,
/// Wallet signing key, unlocking P2PK-locked nutzap proofs
pub(crate) wallet_key: SecretKey,
pub(crate) signer: S,
pub(crate) client: Client,
/// Relay token events at schedule time.
pub(crate) tokens: HashMap<EventId, TokenEvent>,
/// A previous job errored after possibly mutating the store
pub(crate) publish_failed: bool,
}
impl<S> JobCtx<S>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
{
fn wallet(&self, mint: &MintUrl) -> Result<&Wallet, Error> {
self.wallets
.get(mint)
.ok_or_else(|| anyhow!("no open wallet for mint {mint}"))
}
fn previous_ids(&self, mint: &MintUrl) -> Vec<EventId> {
previous_ids(&self.tokens, mint)
}
/// Publish the store's current unspent set as a fresh token event.
async fn sync_token_events(
&self,
wallet: &Wallet,
mint: Url,
previous: &[EventId],
history: Option<keys::History>,
) -> Result<TokenUpdate, Error> {
let me = self.signer.get_public_key_async().await?;
let unspent = wallet.get_unspent_proofs().await?;
let proofs = unspent
.iter()
.map(cashu_proof)
.collect::<Result<Vec<_>, _>>()?;
let mut token = TokenEvent::new(mint, proofs);
token.del = previous.iter().map(|id| id.to_hex()).collect();
let event = keys::build_token_event(&self.signer, &me, &token).await?;
self.client.send_event(&event).await?;
if !previous.is_empty() {
let deletion = keys::build_token_deletion(&self.signer, previous).await?;
self.client.send_event(&deletion).await?;
}
if let Some(history) = history {
let history_event =
keys::build_history_event(&self.signer, &me, &history, event.id, previous).await?;
self.client.send_event(&history_event).await?;
}
Ok(TokenUpdate {
event_id: event.id,
token,
destroyed: previous.to_vec(),
})
}
/// Claim paid quotes, then converge each mint.
async fn reconcile(self) -> Result<Outcome, Error> {
let mut tokens = self.tokens.clone();
let mut updates = Vec::new();
for (mint_url, wallet) in &self.wallets {
if let Err(error) = wallet.recover_incomplete_sagas().await {
log::warn!("saga recovery failed for {mint_url}: {error:#}");
}
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
let states = vec![State::Pending, State::Reserved, State::PendingSpent];
let in_flight = wallet.get_proofs_by_states(states).await?;
let in_flight_ys = proof_ys(in_flight.iter())?;
let mut relay_ys = HashSet::new();
let mut unseen = Vec::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint_url.to_string())
{
for proof in &token.proofs {
let info = match proof_info(proof, mint_url) {
Ok(info) => info,
Err(error) => {
log::warn!("skipping proof: {error:#}");
continue;
}
};
if !relay_ys.insert(info.y) {
continue;
}
if local_ys.contains(&info.y) || in_flight_ys.contains(&info.y) {
continue;
}
unseen.push(info);
}
}
if unseen.is_empty() {
continue;
}
let unseen_proofs: Proofs = unseen.iter().map(|info| info.proof.clone()).collect();
let spent = match wallet.check_proofs_spent(unseen_proofs).await {
Ok(states) => states
.into_iter()
.filter(|state| state.state == State::Spent)
.map(|state| state.y)
.collect::<HashSet<_>>(),
Err(error) => {
log::warn!("mint state check failed, accepting unseen proofs: {error:#}");
HashSet::new()
}
};
let added: Vec<ProofInfo> = unseen
.into_iter()
.filter(|info| !spent.contains(&info.y))
.collect();
if !added.is_empty() {
wallet.localstore.update_proofs(added, Vec::new()).await?;
}
}
for (mint_url, wallet) in &self.wallets {
let minted = wallet.mint_unissued_quotes().await?;
if minted != Amount::ZERO {
let update = self
.sync_token_events(
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
Some(keys::History {
direction: TransactionDirection::In,
amount: u64::from(minted),
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
}
let relay_ys = relay_proof_ys(&tokens, mint_url);
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
if local_ys != relay_ys {
if self.publish_failed {
let update = self
.sync_token_events(
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
None,
)
.await?;
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
} else {
let removed = wallet
.get_unspent_proofs()
.await?
.into_iter()
.filter(|proof| match proof.y() {
Ok(y) => !relay_ys.contains(&y),
Err(_) => false,
})
.map(|proof| proof.y())
.collect::<Result<Vec<_>, _>>()?;
wallet.localstore.update_proofs(Vec::new(), removed).await?;
}
}
}
Ok(Outcome::Reconciled { updates })
}
}
fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<EventId> {
tokens
.iter()
.filter(|(_, token)| token.mint.as_str() == mint.to_string())
.map(|(id, _)| *id)
.collect()
}
fn proof_ys<'a>(
proofs: impl Iterator<Item = &'a Proof>,
) -> Result<HashSet<cdk::nuts::PublicKey>, Error> {
proofs.map(|proof| proof.y().map_err(Error::from)).collect()
}
/// Proof ys referenced by the relay token events of one mint
fn relay_proof_ys(
tokens: &HashMap<EventId, TokenEvent>,
mint: &MintUrl,
) -> HashSet<cdk::nuts::PublicKey> {
let mut ys = HashSet::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint.to_string())
{
for proof in &token.proofs {
match proof_info(proof, mint) {
Ok(info) => {
ys.insert(info.y);
}
Err(error) => log::warn!("skipping proof: {error:#}"),
}
}
}
ys
}
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
async fn recipient_wallet_info<S>(
ctx: &JobCtx<S>,
recipient: PublicKey,
) -> Result<(MintUrl, String, cdk::nuts::PublicKey), Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync,
{
let filter = Filter::new()
.author(recipient)
.kind(Kind::Custom(10019))
.limit(1);
let info = match ctx
.client
.database()
.query(filter.clone())
.await?
.into_iter()
.next()
{
Some(event) => event,
None => {
let mut stream = ctx
.client
.stream_events(filter)
.timeout(FETCH_TIMEOUT)
.await?;
let mut found: Option<Event> = None;
while let Some((_url, result)) = stream.next().await {
if let Ok(event) = result {
let newer = found
.as_ref()
.is_none_or(|current| event.created_at > current.created_at);
if newer {
found = Some(event);
}
}
}
found.ok_or_else(|| anyhow!("recipient has no wallet info"))?
}
};
let (p2pk, mints) = parse_nutzap_info(&info)?;
for mint in mints {
let Ok(mint_url) = MintUrl::from_str(&mint) else {
continue;
};
if ctx.wallets.contains_key(&mint_url) {
return Ok((mint_url, mint, p2pk));
}
}
Err(anyhow!("no shared mint with the recipient"))
}
fn parse_nutzap_info(event: &Event) -> Result<(cdk::nuts::PublicKey, Vec<String>), Error> {
let mut p2pk = None;
let mut mints = Vec::new();
for tag in event.tags.iter() {
let Some(value) = tag.content() else {
continue;
};
match tag.kind() {
"pubkey" if p2pk.is_none() => {
p2pk = Some(cdk::nuts::PublicKey::from_str(value)?);
}
"mint" => mints.push(value.to_string()),
_ => {}
}
}
let Some(p2pk) = p2pk else {
return Err(anyhow!("recipient has no wallet pubkey"));
};
Ok((p2pk, mints))
}
fn cashu_proof(proof: &Proof) -> Result<CashuProof, Error> {
Ok(CashuProof {
id: proof.keyset_id.to_string(),
amount: u64::from(proof.amount),
secret: proof.secret.to_string(),
c: proof.c.to_string(),
})
}
fn nostr_mint(mint: &MintUrl) -> Result<Url, Error> {
Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}"))
}
fn proof_info(proof: &CashuProof, mint: &MintUrl) -> Result<ProofInfo, Error> {
let keyset_id = Id::from_str(&proof.id)?;
let c = cdk::nuts::PublicKey::from_str(&proof.c)?;
let info = ProofInfo::new(
Proof::new(
Amount::from(proof.amount),
keyset_id,
Secret::new(proof.secret.clone()),
c,
),
mint.clone(),
State::Unspent,
CurrencyUnit::Sat,
)?;
Ok(info)
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "nip60_ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
nip60 = { path = "../nip60" }
person = { path = "../person" }
settings = { path = "../settings" }
state = { path = "../state" }
ui = { path = "../ui" }
gpui.workspace = true
gpui-kit.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
+77
View File
@@ -0,0 +1,77 @@
#![cfg(not(target_arch = "wasm32"))]
use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::{WindowExt, h_flex, v_flex};
use nip60::WalletRegistry;
use nostr_sdk::prelude::{EventId, PublicKey};
pub mod wallet;
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub fn open_nutzap(
window: &mut Window,
cx: &mut App,
recipient: PublicKey,
target: Option<EventId>,
) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let send: SubmitFn = {
let input = input.clone();
Rc::new(move |_, window, cx| {
let amount = match input.read(cx).value().trim().parse::<u64>() {
Ok(amount) if amount > 0 => amount,
_ => {
window.push_notification(Notification::error("Enter an amount in sats"), cx);
return false;
}
};
let wallet = WalletRegistry::global(cx);
wallet.update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, target, None, cx);
});
true
})
};
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(380.))
.title("Send Nutzap")
.on_ok({
let send = send.clone();
move |event, window, cx| send(event, window, cx)
})
.child(
v_flex()
.gap_3()
.child(Input::new(&input).cleanable(true))
.child(
h_flex()
.gap_1()
.justify_end()
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(Button::new("send").label("Send").primary().on_click({
let send = send.clone();
move |event, window, cx| {
if send(event, window, cx) {
window.close_dialog(cx);
}
}
})),
),
)
});
}
+895
View File
@@ -0,0 +1,895 @@
use std::ops::Range;
use std::rc::Rc;
use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement as _, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement as _, Styled, Subscription, Task, Window, div, px, relative,
uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{
ActiveTheme, Disableable as _, Icon, Sizable, WindowExt, h_flex, v_flex,
};
use nostr_sdk::prelude::*;
use person::Directory;
use settings::AppSettings;
use smallvec::SmallVec;
use state::NostrRegistry;
use ui::Avatar;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<WalletPanel> {
cx.new(|cx| WalletPanel::new(window, cx))
}
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub struct WalletPanel {
name: SharedString,
focus_handle: FocusHandle,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl WalletPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let wallet = WalletRegistry::global(cx);
let mut subscriptions = SmallVec::new();
subscriptions.push(cx.subscribe_in(
&wallet,
window,
|_this, _wallet, event, window, cx| match event {
WalletEvent::Updated => cx.notify(),
WalletEvent::Error(error) => {
window.push_notification(Notification::error(error.clone()), cx);
}
},
));
Self {
name: "Wallet".into(),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
}
}
fn render_balance(&self, cx: &Context<Self>) -> impl IntoElement {
let balance = WalletRegistry::global(cx).read(cx).balance();
h_flex()
.justify_center()
.items_baseline()
.gap_2()
.child(
div()
.text_3xl()
.line_height(relative(1.0))
.child(SharedString::from(balance.to_string())),
)
.child(
div()
.italic()
.line_height(relative(1.0))
.text_color(cx.theme().muted_foreground)
.child("sats"),
)
}
fn render_actions(&self) -> impl IntoElement {
h_flex().w_full().justify_center().child(
h_flex()
.w_80()
.gap_2()
.child(
Button::new("send")
.label("Send")
.secondary()
.rounded_full()
.flex_1()
.on_click(|_, window, cx| open_send_dialog(window, cx)),
)
.child(
Button::new("receive")
.label("Receive")
.secondary()
.rounded_full()
.flex_1()
.on_click(|_, window, cx| open_receive_dialog(window, cx)),
),
)
}
fn render_quotes(&self, quotes: Vec<MintQuoteEntry>, cx: &Context<Self>) -> Option<AnyElement> {
if quotes.is_empty() {
return None;
}
Some(
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Pending invoices"),
)
.children(
quotes
.iter()
.map(|quote| self.render_quote_entry(quote, cx)),
)
.into_any_element(),
)
}
fn render_quote_entry(&self, quote: &MintQuoteEntry, cx: &Context<Self>) -> impl IntoElement {
let wallet = WalletRegistry::global(cx);
let quote_id = quote.quote_id.clone();
v_flex()
.gap_1()
.w_full()
.child(
h_flex()
.justify_between()
.gap_2()
.child(
h_flex()
.gap_2()
.min_w_0()
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().success)
.child(SharedString::from(format!("{} sats", quote.amount))),
)
.child(
div()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(quote.mint.to_string())),
),
)
.child(
h_flex()
.flex_shrink_0()
.gap_1()
.child(
Button::new(format!("mint-quote-check-{quote_id}"))
.label("Check")
.tooltip("Check payment and claim")
.ghost()
.small()
.on_click({
let wallet = wallet.clone();
let quote_id = quote_id.clone();
move |_, _, cx| {
wallet.update(cx, |registry, cx| {
registry.claim_mint_quote(quote_id.clone(), cx)
});
}
}),
)
.child(
Clipboard::new(format!("mint-quote-copy-{quote_id}"))
.value(quote.request.clone())
.tooltip("Copy invoice")
.small(),
)
.child(
Button::new(format!("mint-quote-dismiss-{quote_id}"))
.icon(IconName::Close)
.tooltip("Dismiss")
.ghost()
.small()
.on_click({
let wallet = wallet.clone();
move |_, _, cx| {
wallet.update(cx, |registry, cx| {
registry.dismiss_mint_quote(quote_id.clone(), cx)
});
}
}),
),
),
)
.child(
div()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis_middle()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(quote.request.clone())),
)
}
fn render_history(
&mut self,
history: Vec<HistoryEntry>,
cx: &mut Context<Self>,
) -> impl IntoElement {
if history.is_empty() {
return v_flex()
.items_center()
.justify_center()
.h_32()
.w_full()
.text_xs()
.text_color(cx.theme().muted_foreground)
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.child("No transactions yet.")
.into_any_element();
}
uniform_list(
"wallet-history",
history.len(),
cx.processor(move |this, range, _window, cx| {
this.render_history_rows(range, &history, cx)
}),
)
.flex_1()
.min_h_0()
.into_any_element()
}
fn render_history_rows(
&self,
range: std::ops::Range<usize>,
history: &[HistoryEntry],
cx: &Context<Self>,
) -> Vec<AnyElement> {
range
.map(|ix| {
self.render_history_entry(&history[ix], cx)
.into_any_element()
})
.collect()
}
fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context<Self>) -> impl IntoElement {
let (label, color) = match entry.direction {
TransactionDirection::In => ("Received", cx.theme().success),
TransactionDirection::Out => ("Sent", cx.theme().danger),
};
h_flex()
.justify_between()
.w_full()
.py_0p5()
.text_xs()
.child(
h_flex()
.gap_2()
.child(div().text_color(color).child(label))
.child(SharedString::from(format!("{} sats", entry.amount))),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(entry.created_at.to_human_time()),
)
}
}
/// Pick a contact and an amount, then send a NIP-61 nutzap
fn open_send_dialog(window: &mut Window, cx: &mut App) {
let view = cx.new(|cx| SendDialog::new(window, cx));
window.open_dialog(cx, {
let view = view.clone();
move |this, _window, _cx| this.width(px(420.)).title("Send eCash").child(view.clone())
});
}
struct SendDialog {
/// Search input, filters contacts or accepts an npub / NIP-05 address
input: Entity<InputState>,
amount_input: Entity<InputState>,
/// Current user's contacts
contacts: Option<Vec<PublicKey>>,
/// Person directory for contact names and avatars
directory: Entity<Directory>,
/// The contact chosen to receive the nutzap
selected: Option<PublicKey>,
/// Whether a NIP-05 address is being resolved
resolving: bool,
error: Option<SharedString>,
tasks: Vec<Task<Result<(), anyhow::Error>>>,
_subscriptions: Vec<Subscription>,
}
impl SendDialog {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| {
InputState::new(window, cx).placeholder("Search or enter npub or user@address.me")
});
let amount_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Amount (sats)")
.step(1.)
.min(1.)
});
let subscriptions = vec![
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.submit(window, cx);
}
}),
cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.send(window, cx);
}
}),
];
// Load the contacts once the view is mounted
cx.defer_in(window, |this, window, cx| {
this.load_contacts(window, cx);
});
Self {
input,
amount_input,
contacts: None,
directory: Directory::new(cx),
selected: None,
resolving: false,
error: None,
tasks: Vec::new(),
_subscriptions: subscriptions,
}
}
/// Load the signed-in user's contact list.
fn load_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.contacts.is_some() {
return;
}
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, anyhow::Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let contacts: Vec<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contacts)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let contacts = task.await;
this.update_in(cx, |this, _window, cx| {
match contacts {
Ok(contacts) => {
this.directory.update(cx, |directory, cx| {
directory.track(contacts.iter().copied(), cx)
});
this.contacts = Some(contacts);
}
Err(error) => this.set_error(error.to_string(), cx),
}
cx.notify();
})?;
Ok(())
}));
}
/// The contacts matching the current input, or every contact when empty.
fn filtered_contacts(&self, cx: &Context<Self>) -> Vec<PublicKey> {
let Some(contacts) = self.contacts.as_ref() else {
return Vec::new();
};
let query = self.input.read(cx).value().trim().to_lowercase();
if query.is_empty() {
return contacts.clone();
}
contacts
.iter()
.filter(|public_key| {
if public_key.to_hex().to_lowercase().contains(&query) {
return true;
}
let name = self.directory.read(cx).person(public_key, cx).name();
name.to_lowercase().contains(&query)
})
.cloned()
.collect()
}
/// Resolve the input into a contact selection.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.resolving {
return;
}
let query = self.input.read(cx).value().trim().to_string();
if query.is_empty() {
return;
}
// A raw public key or an npub can be selected directly.
if let Ok(public_key) = PublicKey::parse(&query) {
self.select(public_key, cx);
self.clear_input(window, cx);
return;
}
// A NIP-05 address is resolved before being selected.
if let Ok(address) = Nip05Address::parse(&query) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).query_address(address, cx);
self.set_resolving(true, window, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
this.set_resolving(false, window, cx);
match result {
Ok(public_key) => {
this.select(public_key, cx);
this.clear_input(window, cx);
}
Err(error) => this.set_error(error.to_string(), cx),
}
})?;
Ok(())
}));
return;
}
// Fall back to a local search by contact name.
let matches = self.filtered_contacts(cx);
match matches.len() {
0 => self.set_error("Enter a valid npub or NIP-05 address", cx),
1 => {
let public_key = matches[0];
self.select(public_key, cx);
self.clear_input(window, cx);
}
_ => self.set_error("Multiple contacts match, pick one from the list", cx),
}
}
/// Select a single recipient for the nutzap.
fn select(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
if nostr.read(cx).current_user() == Some(public_key) {
self.set_error("You cannot send to yourself", cx);
return;
}
self.directory
.update(cx, |directory, cx| directory.track([public_key], cx));
self.selected = Some(public_key);
self.error = None;
cx.notify();
}
/// Toggle a contact's selection.
fn toggle_selection(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
if self.selected == Some(*public_key) {
self.selected = None;
cx.notify();
} else {
self.select(*public_key, cx);
}
}
/// Validate the selection and amount, then schedule the nutzap.
fn send(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(recipient) = self.selected else {
self.set_error("Select a contact to send to", cx);
return false;
};
let Some(amount) = parse_amount(&self.amount_input, cx) else {
self.set_error("Enter an amount in sats", cx);
return false;
};
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, None, None, cx);
});
true
}
fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
/// Toggle the input's loading spinner while a NIP-05 address is resolved.
fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |this, cx| {
this.set_loading(resolving, window, cx);
});
self.resolving = resolving;
cx.notify();
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into());
cx.notify();
}
fn render_contacts(
&self,
range: Range<usize>,
contacts: &[PublicKey],
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
contacts
.get(range)
.into_iter()
.flatten()
.map(|public_key| {
let profile = self.directory.read(cx).person(public_key, cx);
let public_key = *public_key;
let selected = self.selected == Some(public_key);
let id = SharedString::from(format!("contact-{}", public_key.to_hex()));
h_flex()
.id(id)
.w_full()
.h_8()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.text_sm()
.cursor_pointer()
.hover(|this| this.bg(cx.theme().list_hover))
.when(selected, |this| this.bg(cx.theme().list_active))
.when(!AppSettings::get_hide_avatar(cx), |this| {
this.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
})
.child(div().min_w_0().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_selection(&public_key, cx);
}))
.into_any_element()
})
.collect()
}
fn render_selected(&self, cx: &mut Context<Self>) -> impl IntoElement {
let Some(public_key) = self.selected else {
return div().into_any_element();
};
let profile = self.directory.read(cx).person(&public_key, cx);
let id = SharedString::from(format!("selected-{}", public_key.to_hex()));
h_flex()
.gap_1()
.child(
h_flex()
.id(id)
.gap_1()
.p_0p5()
.rounded_full()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(
div()
.max_w_32()
.truncate()
.text_xs()
.line_height(relative(1.0))
.child(profile.name()),
)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.selected = None;
cx.notify();
})),
)
.into_any_element()
}
}
impl Render for SendDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const NOTICE: &str = "Recipients need a NIP-60 wallet on a mint you both use.";
let contacts = Rc::new(self.filtered_contacts(cx));
let contact_count = contacts.len();
v_flex()
.gap_3()
.child(NumberInput::new(&self.amount_input))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.input).flex_1().cleanable(true))
.child(
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add person")
.ghost()
.large()
.disabled(self.resolving)
.on_click(cx.listener(|this, _event, window, cx| {
this.submit(window, cx);
})),
),
)
.when(self.selected.is_some(), |this| {
this.child(self.render_selected(cx))
})
.child(
uniform_list(
"send-contacts",
contact_count,
cx.processor(move |this, range, _window, cx| {
this.render_contacts(range, contacts.as_slice(), cx)
}),
)
.h_64(),
)
.map(|this| {
if let Some(error) = self.error.clone() {
this.child(div().text_xs().text_color(cx.theme().danger).child(error))
} else {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(NOTICE),
)
}
})
.child(
h_flex()
.gap_1()
.justify_end()
.child(
Button::new("cancel")
.label("Cancel")
.secondary()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("send")
.label("Send")
.primary()
.on_click(cx.listener(move |this, _event, window, cx| {
if this.send(window, cx) {
window.close_dialog(cx);
}
})),
),
)
}
}
/// Create a lightning invoice or redeem a pasted cashu token
fn open_receive_dialog(window: &mut Window, cx: &mut App) {
let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…"));
let create_invoice: SubmitFn = {
let wallet = WalletRegistry::global(cx);
let input = amount_input.clone();
Rc::new(move |_, window, cx| {
let Some(amount) = parse_amount(&input, cx) else {
window.push_notification(Notification::error("Enter an amount in sats"), cx);
return false;
};
wallet.update(cx, |registry, cx| {
registry.create_mint_quote(amount, cx);
});
true
})
};
let redeem: SubmitFn = {
let wallet = WalletRegistry::global(cx);
let input = token_input.clone();
Rc::new(move |_, window, cx| {
let token = input.read(cx).value().trim().to_string();
if token.is_empty() {
window.push_notification(Notification::error("Paste a cashu token"), cx);
return false;
}
wallet.update(cx, |registry, cx| {
registry.receive_token(token, cx);
});
true
})
};
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(420.))
.title("Receive")
.on_ok({
let token_input = token_input.clone();
let create_invoice = create_invoice.clone();
let redeem = redeem.clone();
move |_event, window, cx| {
if !token_input.read(cx).value().trim().is_empty() {
redeem(_event, window, cx)
} else {
create_invoice(_event, window, cx)
}
}
})
.child(
v_flex()
.gap_3()
.child(
v_flex()
.gap_1p5()
.child(div().text_xs().font_semibold().child("Lightning invoice:"))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&amount_input).cleanable(true))
.child(
Button::new("invoice")
.label("Invoice")
.secondary()
.w_24()
.on_click({
let create_invoice = create_invoice.clone();
move |event, window, cx| {
if create_invoice(event, window, cx) {
window.close_dialog(cx);
}
}
}),
),
),
)
.child(Separator::horizontal())
.child(
v_flex()
.gap_1p5()
.child(div().text_xs().font_semibold().child("Cashu token:"))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&token_input).cleanable(true))
.child(
Button::new("redeem")
.label("Redeem")
.secondary()
.w_24()
.on_click({
let redeem = redeem.clone();
move |event, window, cx| {
if redeem(event, window, cx) {
window.close_dialog(cx);
}
}
}),
),
),
),
)
});
}
fn parse_amount(input: &Entity<InputState>, cx: &App) -> Option<u64> {
match input.read(cx).value().trim().parse::<u64>() {
Ok(amount) if amount > 0 => Some(amount),
_ => None,
}
}
impl gpui_kit::component::dock::BasePanel for WalletPanel {
fn panel_name(&self) -> &'static str {
"Wallet"
}
}
impl Panel for WalletPanel {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Wallet)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for WalletPanel {}
impl Focusable for WalletPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for WalletPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let wallet = WalletRegistry::global(cx);
let history = wallet.read(cx).history().to_vec();
let quotes = wallet.read(cx).mint_quotes().to_vec();
v_flex()
.size_full()
.p_3()
.gap_4()
.text_xs()
.child(self.render_balance(cx))
.child(self.render_actions())
.children(self.render_quotes(quotes, cx))
.child(
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("History"),
)
.child(self.render_history(history, cx)),
)
}
}
+1
View File
@@ -8,6 +8,7 @@ publish.workspace = true
common = { path = "../common" }
state = { path = "../state" }
futures.workspace = true
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
+77 -29
View File
@@ -1,9 +1,12 @@
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use std::sync::{Arc, RwLock};
use anyhow::{Error, anyhow};
use common::emojis_from_tags;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use futures::{FutureExt, pin_mut};
use gpui::{
App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Subscription, Task,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
@@ -51,7 +54,7 @@ impl EventEmitter<PersonEvent> for PersonRegistry {}
#[derive(Debug, Clone)]
enum Dispatch {
Person(Person),
Person(Box<Person>),
Announcement(Event),
Relays(Event),
Emojis(Event),
@@ -61,7 +64,7 @@ enum Dispatch {
#[derive(Debug)]
pub struct PersonRegistry {
/// Public keys with a metadata request in flight
in_flight: RwLock<HashSet<PublicKey>>,
in_flight: Arc<RwLock<HashSet<PublicKey>>>,
/// Web of trust graph of the signed-in user
wot: Entity<WebOfTrust>,
sender: flume::Sender<PublicKey>,
@@ -98,8 +101,13 @@ impl PersonRegistry {
}));
let client3 = client.clone();
let executor = cx.background_executor().clone();
let in_flight = Arc::new(RwLock::new(HashSet::new()));
let in_flight_requests = Arc::clone(&in_flight);
tasks.push(cx.background_spawn(async move {
Self::handle_requests(&client3, &metadata_rx).await;
Self::handle_requests(&client3, &metadata_rx, executor, &in_flight_requests).await;
}));
tasks.push(cx.spawn(async move |this, cx| {
@@ -107,7 +115,7 @@ impl PersonRegistry {
this.update(cx, |this, cx| {
match event {
Dispatch::Person(person) => {
this.insert(person, cx);
this.insert(*person, cx);
}
Dispatch::Announcement(event) => {
this.set_announcement(&event, cx);
@@ -136,7 +144,7 @@ impl PersonRegistry {
});
Self {
in_flight: RwLock::new(HashSet::new()),
in_flight,
wot,
sender: metadata_tx,
tasks,
@@ -171,7 +179,11 @@ impl PersonRegistry {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata);
if tx.send_async(Dispatch::Person(person)).await.is_err() {
if tx
.send_async(Dispatch::Person(Box::new(person)))
.await
.is_err()
{
log::warn!("Channel closed, dropping metadata event");
}
}
@@ -193,34 +205,60 @@ impl PersonRegistry {
}
/// Handle request for metadata
async fn handle_requests(client: &Client, rx: &flume::Receiver<PublicKey>) {
async fn handle_requests(
client: &Client,
rx: &flume::Receiver<PublicKey>,
executor: BackgroundExecutor,
in_flight: &RwLock<HashSet<PublicKey>>,
) {
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
match flume::Selector::new()
.recv(rx, |result| result.ok())
.wait_timeout(Duration::from_secs(TIMEOUT))
{
Ok(Some(public_key)) => {
batch.insert(public_key);
// Process the batch if it's full
if batch.len() >= 20
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
}
_ => {
if !batch.is_empty()
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
let timer = executor.timer(Duration::from_secs(TIMEOUT)).fuse();
pin_mut!(timer);
let request = futures::select! {
result = rx.recv_async().fuse() => match result {
Ok(public_key) => public_key,
Err(_) => {
Self::flush_batch(client, &mut batch, in_flight).await;
return;
}
},
() = timer => {
Self::flush_batch(client, &mut batch, in_flight).await;
continue;
}
};
batch.insert(request);
// Process the batch if it's full
if batch.len() >= 20 {
Self::flush_batch(client, &mut batch, in_flight).await;
}
}
}
/// Send one metadata request for the batch, releasing its keys for retry on failure
async fn flush_batch(
client: &Client,
batch: &mut HashSet<PublicKey>,
in_flight: &RwLock<HashSet<PublicKey>>,
) {
let batch = std::mem::take(batch);
if batch.is_empty() {
return;
}
if let Err(error) = get_metadata(client, batch.iter().cloned()).await {
Self::release_keys(in_flight, batch.iter().cloned());
log::warn!(
"Failed to get metadata for {} public keys, released for retry: {error}",
batch.len()
);
}
}
/// Subscribe to the signed-in user's emoji list
fn get_emojis(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -311,11 +349,21 @@ impl PersonRegistry {
/// Allow future fetches for a key once its metadata has arrived
fn release_in_flight(&self, public_key: &PublicKey) {
let mut in_flight = match self.in_flight.write() {
Self::release_keys(&self.in_flight, [*public_key]);
}
/// Remove the given keys from the in-flight set
fn release_keys(
in_flight: &RwLock<HashSet<PublicKey>>,
keys: impl IntoIterator<Item = PublicKey>,
) {
let mut in_flight = match in_flight.write() {
Ok(in_flight) => in_flight,
Err(poisoned) => poisoned.into_inner(),
};
in_flight.remove(public_key);
for public_key in keys {
in_flight.remove(&public_key);
}
}
}
+7 -29
View File
@@ -1,6 +1,3 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
@@ -20,32 +17,6 @@ pub struct Person {
messaging_relays: Vec<RelayUrl>,
}
impl PartialEq for Person {
fn eq(&self, other: &Self) -> bool {
self.public_key == other.public_key
}
}
impl Eq for Person {}
impl PartialOrd for Person {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Person {
fn cmp(&self, other: &Self) -> Ordering {
self.name().cmp(&other.name())
}
}
impl Hash for Person {
fn hash<H: Hasher>(&self, state: &mut H) {
self.public_key.hash(state)
}
}
impl Person {
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
Self {
@@ -144,6 +115,13 @@ impl Person {
/// Set profile encryption keys announcement
pub(crate) fn set_announcement(&mut self, announcement: Announcement) {
// A delayed or repeated announcement must not roll the key back
if let Some(current) = &self.announcement
&& !announcement.is_newer_than(current)
{
return;
}
self.announcement = Some(announcement);
}
+119 -71
View File
@@ -4,7 +4,7 @@ use gpui::{Context, EventEmitter, Task};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use state::{BOOTSTRAP_RELAYS, NostrRegistry};
/// Disable scoring when the follow set exceeds this size
const MAX_FOLLOWS: usize = 2000;
@@ -14,6 +14,10 @@ const MAX_TAGS_PER_LIST: usize = 5000;
const SYNC_INTERVAL: u64 = 15 * 60;
/// Longest wait between retries while the graph has no data yet
const MAX_RETRY_WAIT: u64 = 60;
/// Time for a relay to prove negentropy support before the reconcile gives up
const SYNC_INITIAL_TIMEOUT: u64 = 30;
/// Longest silence from a relay before the reconcile gives up
const SYNC_IDLE_TIMEOUT: u64 = 60;
/// The web of trust graph changed
#[derive(Debug, Clone)]
@@ -77,6 +81,36 @@ impl Snapshot {
snapshot
}
/// Build the snapshot from the contact lists the local database holds
async fn load(client: &Client, self_key: PublicKey) -> Option<Self> {
let own = stored_contact_list(client, self_key).await?;
let follows: HashSet<PublicKey> = own.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
let events: Vec<Event> = if follows.is_empty() {
Vec::new()
} else {
let authors: Vec<PublicKey> = follows.iter().copied().collect();
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.collect()
};
let snapshot = Self::build(self_key, follows, events);
log::debug!(
"wot loaded from database: {} follows, {} scored keys",
snapshot.follows.len(),
snapshot.scores.len()
);
Some(snapshot)
}
}
impl EventEmitter<WoTEvent> for WebOfTrust {}
@@ -88,8 +122,10 @@ impl EventEmitter<WoTEvent> for WebOfTrust {}
pub struct WebOfTrust {
/// Current user's public key
self_key: Option<PublicKey>,
/// Latest built snapshot, none until the first refresh completes
/// Latest built snapshot, none until the first load completes
snapshot: Option<Snapshot>,
/// Whether a reconciliation with the relays is in flight
syncing: bool,
/// In-flight refresh, replaced by newer ones
refresh_task: Option<Task<()>>,
/// Keeps the periodic reconciliation loop alive
@@ -110,9 +146,9 @@ impl WebOfTrust {
let mut has_data = false;
// Ignore errors, the next refresh will retry
// Ignore errors, the next sync will retry
let _ = this.update(cx, |this, cx| {
this.refresh(cx);
this.sync(cx);
has_data = this.has_data();
});
@@ -133,34 +169,14 @@ impl WebOfTrust {
Self {
self_key: None,
snapshot: None,
syncing: false,
refresh_task: None,
_tasks: tasks,
}
}
/// Load the graph for the current account
/// Load the graph for the current account from the local database
pub(crate) fn hydrate(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(self_key) = nostr.read(cx).current_user() else {
return;
};
self.self_key = Some(self_key);
self.refresh(cx);
}
/// Clear the graph on sign out or account switch
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
self.self_key = None;
self.snapshot = None;
self.refresh_task = None;
cx.emit(WoTEvent);
cx.notify();
}
/// Reconcile the follow set's contact lists with the relays and rebuild.
fn refresh(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -168,6 +184,56 @@ impl WebOfTrust {
return;
};
self.self_key = Some(self_key);
// Replacing the task below cancels any in-flight work
self.syncing = false;
self.refresh_task = Some(cx.spawn(async move |this, cx| {
let Some(snapshot) = Snapshot::load(&client, self_key).await else {
return;
};
this.update(cx, |this, cx| {
this.snapshot = Some(snapshot);
cx.emit(WoTEvent);
cx.notify();
})
.ok();
}));
}
/// Clear the graph on sign out or account switch
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
self.self_key = None;
self.snapshot = None;
self.syncing = false;
self.refresh_task = None;
cx.emit(WoTEvent);
cx.notify();
}
/// Reconcile the follow set's contact lists with the bootstrap relays and rebuild.
fn sync(&mut self, cx: &mut Context<Self>) {
if self.syncing {
return;
}
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(self_key) = nostr.read(cx).current_user() else {
return;
};
// The defaults are too short for relays streaming large batches
let opts = SyncOptions::new()
.initial_timeout(Duration::from_secs(SYNC_INITIAL_TIMEOUT))
.idle_timeout(Duration::from_secs(SYNC_IDLE_TIMEOUT));
self.syncing = true;
cx.notify();
self.refresh_task = Some(cx.spawn(async move |this, cx| {
// Scope the reconcile to the follow set known so far
let known: HashSet<PublicKey> = stored_contact_list(&client, self_key)
@@ -178,60 +244,37 @@ impl WebOfTrust {
let mut authors: Vec<PublicKey> = known.iter().copied().collect();
authors.push(self_key);
// Negentropy transfers only the lists the relays hold
// and the database is missing
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
if let Err(error) = client.sync(filter).await {
log::warn!("Failed to reconcile contact lists: {error}");
match client.sync(filter).with(BOOTSTRAP_RELAYS).opts(opts).await {
Ok(output) => {
for (url, error) in output.failed {
log::warn!("Failed to reconcile contact lists with {url}: {error}");
}
}
Err(error) => log::warn!("Failed to reconcile contact lists: {error}"),
}
// The follow set after the reconcile
let Some(own) = stored_contact_list(&client, self_key).await else {
return;
};
let follows: HashSet<PublicKey> =
own.tags.public_keys().take(MAX_FOLLOWS + 1).collect();
let mut authors: Vec<PublicKey> = follows.iter().copied().collect();
authors.push(self_key);
let events: Vec<Event> = if follows.is_empty() {
Vec::new()
} else {
let filter = Filter::new().kind(Kind::ContactList).authors(authors);
client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.collect()
};
let snapshot = Snapshot::build(self_key, follows.clone(), events);
log::debug!(
"Web of trust rebuilt: {} follows, {} scored keys",
snapshot.follows.len(),
snapshot.scores.len()
);
// Newly followed authors were not part of the reconcile
let grew = !known.is_superset(&follows);
let snapshot = Snapshot::load(&client, self_key).await;
this.update(cx, |this, cx| {
this.snapshot = Some(snapshot);
cx.emit(WoTEvent);
this.syncing = false;
if let Some(snapshot) = snapshot {
// Newly followed authors were not part of the reconcile
let grew = !known.is_superset(&snapshot.follows);
this.snapshot = Some(snapshot);
cx.emit(WoTEvent);
if grew {
this.sync(cx);
}
}
cx.notify();
})
.ok();
if grew {
this.update(cx, |this, cx| {
this.refresh(cx);
})
.ok();
}
}));
}
@@ -273,6 +316,11 @@ impl WebOfTrust {
.is_some_and(|snapshot| !snapshot.disabled)
}
/// Whether a reconciliation with the relays is in flight
pub fn is_syncing(&self) -> bool {
self.syncing
}
/// Whether the snapshot covers a follow set
fn has_data(&self) -> bool {
self.snapshot
+549 -107
View File
@@ -1,12 +1,41 @@
use std::path::PathBuf;
use std::rc::Rc;
use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task};
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
use gpui_kit::component::{Theme, ThemeConfig, ThemeMode, ThemeRegistry};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};
/// Browser storage key for the settings JSON
#[cfg(target_arch = "wasm32")]
const SETTINGS_KEY: &str = "coop.settings";
/// The default base font size for the interface
pub const DEFAULT_FONT_SIZE: f32 = 16.0;
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Minimum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
/// Maximum days between automatic encryption key rotations
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
/// Days between automatic encryption key rotations when enabled
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
/// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
fn default_onboarded() -> bool {
true
}
fn default_shadow() -> bool {
true
}
/// The user's appearance preference, persisted with the rest of the settings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AppearanceMode {
@@ -31,88 +60,6 @@ impl AppearanceMode {
}
}
pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(AppSettings::new), cx)
}
/// Read the persisted appearance mode synchronously.
///
/// The async load fills the global settings later; the native theme must be
/// applied before the first window opens, so boot reads the mode straight
/// from the settings file instead.
pub fn persisted_appearance_mode() -> AppearanceMode {
#[cfg(not(target_arch = "wasm32"))]
{
let path = config_dir().join(".settings");
if let Ok(content) = std::fs::read_to_string(path)
&& let Ok(settings) = serde_json::from_str::<Settings>(&content)
{
return settings.appearance_mode;
}
}
AppearanceMode::default()
}
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
macro_rules! setting_accessors {
($(pub $field:ident: $type:ty),* $(,)?) => {
impl AppSettings {
$(
paste::paste! {
pub fn [<get_ $field>](cx: &App) -> $type {
Self::global(cx).read(cx).inner.read(cx).$field.clone()
}
pub fn [<update_ $field>](value: $type, cx: &mut App) {
Self::global(cx).update(cx, |this, cx| {
this.inner.update(cx, |inner, cx| {
inner.$field = value;
cx.notify();
});
});
}
}
)*
}
};
}
macro_rules! setting_getters {
($(pub $field:ident: $type:ty),* $(,)?) => {
impl AppSettings {
$(
paste::paste! {
pub fn [<get_ $field>](cx: &App) -> $type {
Self::global(cx).read(cx).inner.read(cx).$field.clone()
}
}
)*
}
};
}
setting_accessors! {
pub appearance_mode: AppearanceMode,
pub hide_avatar: bool,
pub screening: bool,
pub wot_auto_trust: bool,
pub nip4e: bool,
pub hide_group_notice: bool,
pub file_server: Url,
pub panel_background: bool,
pub panel_background_path: Option<PathBuf>,
}
setting_getters! {
pub pinned_rooms: Vec<u64>,
pub pinned_groups: Vec<String>,
}
/// Signer kind
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerKind {
@@ -128,6 +75,34 @@ impl SignerKind {
}
}
/// User tuning applied on top of the native OS theme
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ThemeSettings {
/// The base UI font size in pixels
pub font_size: f32,
/// Draw a ring around focused controls
pub focus_ring: bool,
/// Corner radius for general elements; follows the OS theme when unset
pub radius: Option<f32>,
/// Corner radius for large elements like dialogs; follows the OS theme when unset
pub radius_lg: Option<f32>,
/// Draw soft shadows on inputs, buttons and dialogs
#[serde(default = "default_shadow")]
pub shadow: bool,
}
impl Default for ThemeSettings {
fn default() -> Self {
Self {
font_size: DEFAULT_FONT_SIZE,
focus_ring: false,
radius: None,
radius_lg: None,
shadow: true,
}
}
}
/// Room configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoomConfig {
@@ -176,9 +151,27 @@ pub struct Settings {
pub wot_auto_trust: bool,
/// Enable decoupling encryption key
pub nip4e: bool,
/// Days between automatic encryption key rotations; None disables rotation
#[serde(default)]
pub nip4e_rotation_days: Option<u32>,
/// Hide the notice that messages in public groups are not encrypted
#[serde(default)]
pub hide_group_notice: bool,
/// Enable the NIP-60 wallet
#[serde(default)]
pub wallet: bool,
/// Whether the first-run onboarding has been completed
#[serde(default = "default_onboarded")]
pub onboarded: bool,
/// Tuning applied on top of the native OS theme
#[serde(default)]
pub theme: ThemeSettings,
/// Bundled theme applied in light appearance; None uses the default light theme
#[serde(default)]
pub theme_light: Option<String>,
/// Bundled theme applied in dark appearance; None uses the default dark theme
#[serde(default)]
pub theme_dark: Option<String>,
/// Show an image behind chat and group panels
#[serde(default)]
pub panel_background: bool,
@@ -203,9 +196,15 @@ impl Default for Settings {
appearance_mode: AppearanceMode::default(),
hide_avatar: false,
screening: true,
wot_auto_trust: true,
wot_auto_trust: false,
nip4e: false,
nip4e_rotation_days: None,
hide_group_notice: false,
wallet: false,
onboarded: false,
theme: ThemeSettings::default(),
theme_light: None,
theme_dark: None,
panel_background: true,
panel_background_path: None,
trusted_relays: vec![],
@@ -224,10 +223,78 @@ impl Global for GlobalAppSettings {}
pub struct AppSettings {
/// Settings
inner: Entity<Settings>,
/// The pending save task, so writes to the settings file complete in order
#[cfg(not(target_arch = "wasm32"))]
save_task: Option<Task<()>>,
/// Set after a failed load so its own save cannot overwrite the file with defaults
suppress_next_save: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
macro_rules! setting_accessors {
($(pub $field:ident: $type:ty),* $(,)?) => {
impl AppSettings {
$(
paste::paste! {
pub fn [<get_ $field>](cx: &App) -> $type {
Self::global(cx).read(cx).inner.read(cx).$field.clone()
}
pub fn [<update_ $field>](value: $type, cx: &mut App) {
Self::global(cx).update(cx, |this, cx| {
this.inner.update(cx, |inner, cx| {
inner.$field = value;
cx.notify();
});
// Notify the handle too, so observers of AppSettings
// react to the change
cx.notify();
});
}
}
)*
}
};
}
macro_rules! setting_getters {
($(pub $field:ident: $type:ty),* $(,)?) => {
impl AppSettings {
$(
paste::paste! {
pub fn [<get_ $field>](cx: &App) -> $type {
Self::global(cx).read(cx).inner.read(cx).$field.clone()
}
}
)*
}
};
}
setting_accessors! {
pub appearance_mode: AppearanceMode,
pub hide_avatar: bool,
pub screening: bool,
pub wot_auto_trust: bool,
pub nip4e: bool,
pub nip4e_rotation_days: Option<u32>,
pub hide_group_notice: bool,
pub file_server: Url,
pub panel_background: bool,
pub panel_background_path: Option<PathBuf>,
pub theme_light: Option<String>,
pub theme_dark: Option<String>,
pub wallet: bool,
pub onboarded: bool,
}
setting_getters! {
pub theme: ThemeSettings,
pub pinned_rooms: Vec<u64>,
pub pinned_groups: Vec<String>,
}
impl AppSettings {
/// Retrieve the global settings instance
pub fn global(cx: &App) -> Entity<Self> {
@@ -253,11 +320,16 @@ impl AppSettings {
// Run at the end of current cycle
cx.defer(move |cx| {
entity.update(cx, |this, cx| this.load(cx)).ok();
if let Err(error) = entity.update(cx, |this, cx| this.load(cx)) {
log::error!("failed to load settings: {error:#}");
}
});
Self {
inner,
#[cfg(not(target_arch = "wasm32"))]
save_task: None,
suppress_next_save: false,
_subscriptions: subscriptions,
}
}
@@ -270,21 +342,95 @@ impl AppSettings {
});
}
/// Edit the theme tuning
pub fn update_theme(edit: impl FnOnce(&mut ThemeSettings), cx: &mut App) {
Self::global(cx).update(cx, |this, cx| {
this.inner.update(cx, |inner, cx| {
edit(&mut inner.theme);
cx.notify();
});
// Notify the handle too, so observers of AppSettings react
cx.notify();
});
}
pub fn apply_theme_style(cx: &mut App) {
Self::apply_theme_tuning(Self::get_theme(cx), cx);
}
fn apply_theme_tuning(tuning: ThemeSettings, cx: &mut App) {
{
let styled = Theme::global_mut(cx);
styled.font_size = px(tuning.font_size);
styled.focus_ring = tuning.focus_ring;
styled.shadow = tuning.shadow;
if let Some(radius) = tuning.radius {
styled.radius = px(radius);
}
if let Some(radius_lg) = tuning.radius_lg {
styled.radius_lg = px(radius_lg);
}
}
Theme::update(cx, |styled| {
let background = styled.background;
let foreground = styled.foreground;
let (hover_alpha, active_alpha) = if styled.is_dark() {
(0.08, 0.12)
} else {
(0.04, 0.06)
};
if styled.list_hover == background {
styled.list_hover = foreground.alpha(hover_alpha);
}
if styled.tab_active == background {
styled.tab_active = foreground.alpha(active_alpha);
}
});
}
/// Load settings
fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
let task: Task<Result<Option<Settings>, Error>> = cx.background_spawn(async move {
#[cfg(not(target_arch = "wasm32"))]
{
let path = config_dir().join(".settings");
if let Ok(content) = smol::fs::read_to_string(&path).await {
return Ok(serde_json::from_str(&content)?);
match smol::fs::read_to_string(&path).await {
Ok(content) => {
let settings: Settings =
serde_json::from_str(&content).map_err(|error| {
anyhow!("failed to parse {}: {error}", path.display())
})?;
return Ok(Some(settings));
}
// No settings file yet on a fresh install, so keep the defaults
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(anyhow!("failed to read {}: {error}", path.display()));
}
}
}
Err(anyhow!("Not found"))
#[cfg(target_arch = "wasm32")]
{
if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
let settings: Settings = serde_json::from_str(&content)
.map_err(|error| anyhow!("failed to parse saved settings: {error}"))?;
return Ok(Some(settings));
}
}
Ok(None)
});
cx.spawn(async move |this, cx| {
let mut settings = task.await.unwrap_or(Settings::default());
let (mut settings, load_failed) = match task.await {
Ok(settings) => (settings.unwrap_or_default(), false),
Err(error) => {
log::error!("failed to load settings, keeping defaults: {error:#}");
(Settings::default(), true)
}
};
// Move settings still pointed at the old default file server over to the new one
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
@@ -292,40 +438,139 @@ impl AppSettings {
}
// Update settings
this.update(cx, |this, cx| {
if let Err(error) = this.update(cx, |this, cx| {
// A failed load must not trigger the save that would overwrite the file
this.suppress_next_save = load_failed;
this.set_settings(settings, cx);
this.apply_theme(cx);
cx.refresh_windows();
})
.ok();
}) {
log::error!("failed to apply loaded settings: {error:#}");
}
})
.detach();
}
/// Save settings
pub fn save(&mut self, cx: &mut Context<Self>) {
// Consume the failed-load suppression, so the next user change saves again
if self.suppress_next_save {
self.suppress_next_save = false;
return;
}
let settings = self.inner.read(cx);
if let Ok(content) = serde_json::to_string(&settings) {
#[cfg(not(target_arch = "wasm32"))]
cx.background_spawn(async move {
let content = match serde_json::to_string(settings) {
Ok(content) => content,
Err(error) => {
log::error!("failed to serialize settings: {error:#}");
return;
}
};
#[cfg(not(target_arch = "wasm32"))]
{
let previous_task = self.save_task.take();
self.save_task = Some(cx.background_spawn(async move {
// Await the previous write, so saves to the file complete in order
if let Some(previous_task) = previous_task {
previous_task.await;
}
let path = config_dir().join(".settings");
smol::fs::write(&path, content).await.ok();
})
.detach();
let temporary_path = config_dir().join(".settings.tmp");
let result: std::io::Result<()> = async {
smol::fs::write(&temporary_path, &content).await?;
smol::fs::rename(&temporary_path, &path).await
}
.await;
if let Err(error) = result {
log::error!("failed to save settings to {}: {error}", path.display());
}
}));
}
#[cfg(target_arch = "wasm32")]
if let Err(error) = common::web_store::set(SETTINGS_KEY, &content) {
log::error!("failed to persist settings: {error:#}");
}
}
/// Apply the persisted appearance mode to the app
/// Apply the persisted appearance mode and theme selection to the app
pub fn apply_theme(&mut self, cx: &mut Context<Self>) {
use gpui_kit::component::Theme;
let mode = self.inner.read(cx).appearance_mode;
match mode {
let appearance = self.inner.read(cx).appearance_mode;
match appearance {
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
}
cx.refresh_windows();
let (light, dark) = {
let inner = self.inner.read(cx);
(inner.theme_light.clone(), inner.theme_dark.clone())
};
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
let tuning = self.inner.read(cx).theme;
Self::apply_theme_tuning(tuning, cx);
}
/// Select a registered theme by name.
pub fn select_theme(name: &str, cx: &mut App) {
let Some(config) = registered_theme(name, cx) else {
log::warn!("theme is not registered: {name}");
return;
};
let dark = config.mode.is_dark();
if dark {
Self::update_theme_dark(Some(name.to_string()), cx);
Self::update_appearance_mode(AppearanceMode::Dark, cx);
} else {
Self::update_theme_light(Some(name.to_string()), cx);
Self::update_appearance_mode(AppearanceMode::Light, cx);
}
Theme::change(
if dark {
ThemeMode::Dark
} else {
ThemeMode::Light
},
None,
cx,
);
Self::reapply_theme(cx);
}
/// Select the theme applied in light appearance; `None` restores the default
pub fn set_theme_light(name: Option<String>, cx: &mut App) {
Self::update_theme_light(name, cx);
Self::reapply_theme(cx);
}
/// Select the theme applied in dark appearance; `None` restores the default
pub fn set_theme_dark(name: Option<String>, cx: &mut App) {
Self::update_theme_dark(name, cx);
Self::reapply_theme(cx);
}
/// Re-apply the selected themes and tuning from the current settings
fn reapply_theme(cx: &mut App) {
let (light, dark, tuning) = AppSettings::global(cx).read_with(cx, |this, cx| {
let inner = this.inner.read(cx);
(
inner.theme_light.clone(),
inner.theme_dark.clone(),
inner.theme,
)
});
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
Self::apply_theme_tuning(tuning, cx);
}
/// Check if decoupling encryption key is enabled
@@ -382,3 +627,200 @@ impl AppSettings {
});
}
}
pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(AppSettings::new), cx)
}
/// Register the bundled gpui-kit themes with the theme registry
pub fn init_themes(cx: &mut App) {
let assets = cx.asset_source().clone();
for path in assets.list("themes").unwrap_or_default() {
let Some(content) = assets.load(&path).unwrap_or_default() else {
log::warn!("bundled theme {path} is listed but missing");
continue;
};
let Ok(content) = std::str::from_utf8(&content) else {
log::warn!("bundled theme {path} is not valid UTF-8");
continue;
};
if let Err(error) = ThemeRegistry::global_mut(cx).load_themes_from_str(content) {
log::warn!("failed to load bundled theme {path} ({error})");
}
}
}
/// Read the persisted appearance mode synchronously.
fn persisted_settings() -> Option<Settings> {
let content = persisted_content();
content.and_then(|content| serde_json::from_str(&content).ok())
}
/// The persisted settings JSON, from the config file or browser storage
#[cfg(not(target_arch = "wasm32"))]
fn persisted_content() -> Option<String> {
let path = config_dir().join(".settings");
std::fs::read_to_string(path).ok()
}
#[cfg(target_arch = "wasm32")]
fn persisted_content() -> Option<String> {
common::web_store::get(SETTINGS_KEY).ok().flatten()
}
/// Read the persisted onboarding flag synchronously.
pub fn persisted_onboarded() -> bool {
persisted_settings().is_some_and(|settings| settings.onboarded)
}
pub fn persisted_appearance_mode() -> AppearanceMode {
persisted_settings()
.map(|settings| settings.appearance_mode)
.unwrap_or_default()
}
/// Read the persisted theme tuning synchronously.
pub fn persisted_theme() -> ThemeSettings {
persisted_settings()
.map(|settings| settings.theme)
.unwrap_or_default()
}
/// Read the persisted theme selection for light appearance.
pub fn persisted_theme_light() -> Option<String> {
persisted_settings().and_then(|settings| settings.theme_light)
}
/// Read the persisted theme selection for dark appearance.
pub fn persisted_theme_dark() -> Option<String> {
persisted_settings().and_then(|settings| settings.theme_dark)
}
/// The registered theme for a persisted theme name, if any
fn registered_theme(name: &str, cx: &App) -> Option<Rc<ThemeConfig>> {
ThemeRegistry::global(cx).themes().get(name).cloned()
}
/// Swap the selected themes into the light/dark slots and reload the active mode.
/// An unset or unknown theme falls back to the registry's default theme.
fn apply_theme_selection(light: Option<&str>, dark: Option<&str>, cx: &mut App) {
let light_config = light
.and_then(|name| registered_theme(name, cx))
.or_else(|| Some(ThemeRegistry::global(cx).default_light_theme().clone()));
let dark_config = dark
.and_then(|name| registered_theme(name, cx))
.or_else(|| Some(ThemeRegistry::global(cx).default_dark_theme().clone()));
{
let theme = Theme::global_mut(cx);
if let Some(config) = light_config {
theme.light_theme = config;
}
if let Some(config) = dark_config {
theme.dark_theme = config;
}
}
let mode = Theme::global(cx).mode;
Theme::change(mode, None, cx);
}
/// Apply the persisted appearance, theme selection and tuning synchronously.
pub fn apply_persisted_theme(cx: &mut App) {
match persisted_appearance_mode() {
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
}
let light = persisted_theme_light();
let dark = persisted_theme_dark();
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
AppSettings::apply_theme_tuning(persisted_theme(), cx);
}
#[cfg(test)]
mod tests {
use super::*;
/// Settings written before theme tuning existed must keep loading.
#[test]
fn settings_without_theme_keep_defaults() {
let settings: Settings = serde_json::from_str(
r#"{
"appearance_mode": "Dark",
"hide_avatar": false,
"screening": true,
"wot_auto_trust": true,
"nip4e": false,
"trusted_relays": [],
"file_server": "https://nostr.download/"
}"#,
)
.unwrap();
assert_eq!(settings.theme, ThemeSettings::default());
assert_eq!(settings.theme_light, None);
assert_eq!(settings.theme_dark, None);
// Files written before onboarding and the wallet flag existed count
// as onboarded with the wallet off
assert!(settings.onboarded);
assert!(!settings.wallet);
}
/// Fresh installs start un-onboarded with optional features off, and the
/// flags round-trip through serde
#[test]
fn onboarding_and_wallet_persist() {
let settings = Settings::default();
assert!(!settings.onboarded);
assert!(!settings.wallet);
assert!(!settings.wot_auto_trust);
assert!(settings.theme.shadow);
let settings: Settings = serde_json::from_str(
r#"{
"appearance_mode": "Dark",
"hide_avatar": false,
"screening": true,
"wot_auto_trust": true,
"nip4e": false,
"trusted_relays": [],
"file_server": "https://nostr.download/",
"onboarded": true,
"wallet": true
}"#,
)
.unwrap();
assert!(settings.onboarded);
assert!(settings.wallet);
}
/// The theme selection round-trips through serde.
#[test]
fn theme_selection_persists() {
let settings: Settings = serde_json::from_str(
r#"{
"appearance_mode": "Dark",
"hide_avatar": false,
"screening": true,
"wot_auto_trust": true,
"nip4e": false,
"trusted_relays": [],
"file_server": "https://nostr.download/",
"theme_light": "Solarized Light",
"theme_dark": "Tokyo Night"
}"#,
)
.unwrap();
assert_eq!(settings.theme_light.as_deref(), Some("Solarized Light"));
assert_eq!(settings.theme_dark.as_deref(), Some("Tokyo Night"));
}
}

Some files were not shown because too many files have changed in this diff Show More