mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: pnpm workspace, deep links, and web repos page (#476)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -190,6 +190,22 @@ jobs:
|
||||
/tmp/sprout-relay.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
web:
|
||||
name: Web
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Web lint and format
|
||||
run: just web-check
|
||||
- name: Web build
|
||||
run: just web-build
|
||||
|
||||
mobile:
|
||||
name: Mobile
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+4
-1
@@ -26,6 +26,9 @@ playwright-report/
|
||||
test-results/
|
||||
blob-report/
|
||||
|
||||
# Node modules (pnpm workspace hoists to root)
|
||||
node_modules/
|
||||
|
||||
# Root npm lockfiles are accidental; desktop uses pnpm in /desktop.
|
||||
/package-lock.json
|
||||
|
||||
@@ -39,4 +42,4 @@ typesense-data/
|
||||
# Hermit (toolchain manager cache)
|
||||
.hermit/
|
||||
doc/
|
||||
repos/
|
||||
/repos/
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": false
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-35
@@ -1,37 +1,3 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": false
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
"extends": ["../biome.json"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -37,9 +36,9 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "~2.10",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-opener": "~2.5",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tiptap/core": "^3.22.3",
|
||||
@@ -68,12 +67,11 @@
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tanstack/router-plugin": "^1.167.12",
|
||||
"@tanstack/virtual-file-routes": "^1.161.7",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "~2.10",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
|
||||
Generated
+121
-1
@@ -879,6 +879,26 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
@@ -1071,6 +1091,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -1347,6 +1373,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -2115,6 +2150,12 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -2320,7 +2361,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
"windows-registry 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3513,6 +3554,16 @@ dependencies = [
|
||||
"audiopus_sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -4578,6 +4629,16 @@ dependencies = [
|
||||
"windowfunctions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -5306,11 +5367,13 @@ dependencies = [
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-websocket",
|
||||
"tauri-plugin-window-state",
|
||||
@@ -5869,6 +5932,27 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry 0.5.3",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.0"
|
||||
@@ -5977,6 +6061,22 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
@@ -6262,6 +6362,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -7380,6 +7489,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
|
||||
@@ -27,7 +27,9 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
||||
atomic-write-file = "0.3"
|
||||
dirs = "6"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
@@ -31,6 +31,7 @@ use std::sync::{
|
||||
};
|
||||
use tauri::{Emitter, Manager, RunEvent};
|
||||
use tauri_plugin_window_state::StateFlags;
|
||||
use url::Url;
|
||||
|
||||
fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let state = app.state::<AppState>();
|
||||
@@ -151,9 +152,76 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle an incoming `sprout://` deep link URL.
|
||||
///
|
||||
/// Currently supports:
|
||||
/// - `sprout://connect?relay=<ws(s)://...>` — emits `deep-link-connect` to the frontend
|
||||
fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
|
||||
let url = match Url::parse(url_str) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
eprintln!("sprout-desktop: invalid deep link URL {url_str:?}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if url.scheme() != "sprout" {
|
||||
eprintln!("sprout-desktop: ignoring non-sprout deep link: {url_str}");
|
||||
return;
|
||||
}
|
||||
|
||||
match url.host_str() {
|
||||
Some("connect") => {
|
||||
let relay = url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == "relay")
|
||||
.map(|(_, v)| v.into_owned());
|
||||
let Some(relay_url) = relay else {
|
||||
eprintln!("sprout-desktop: connect deep link missing relay param: {url_str}");
|
||||
return;
|
||||
};
|
||||
// Validate the relay URL is ws:// or wss://
|
||||
match Url::parse(&relay_url) {
|
||||
Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {}
|
||||
Ok(parsed) => {
|
||||
eprintln!(
|
||||
"sprout-desktop: rejecting non-websocket relay URL scheme {:?}: {relay_url}",
|
||||
parsed.scheme()
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("sprout-desktop: invalid relay URL {relay_url:?}: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = app.emit("deep-link-connect", relay_url);
|
||||
}
|
||||
Some(action) => {
|
||||
eprintln!("sprout-desktop: unknown deep link action: {action}");
|
||||
}
|
||||
None => {
|
||||
eprintln!("sprout-desktop: deep link missing action: {url_str}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
// Focus the existing window when a duplicate instance launches.
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
// Forward any deep link URLs from the duplicate launch.
|
||||
for arg in &argv {
|
||||
if arg.starts_with("sprout://") {
|
||||
handle_deep_link_url(app, arg);
|
||||
}
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(
|
||||
@@ -342,6 +410,20 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deep link URLs received while the app is running (macOS)
|
||||
// and on cold start. The single-instance plugin handles forwarding
|
||||
// from duplicate launches on Windows/Linux.
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
let dl_handle = app.handle().clone();
|
||||
app.deep_link().on_open_url(move |event| {
|
||||
for url in event.urls() {
|
||||
handle_deep_link_url(&dl_handle, url.as_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keep launch-time agent restoration off the synchronous setup path
|
||||
// so the frontend can mount and reveal the window promptly.
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": []
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["sprout"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+11
-2
@@ -1,7 +1,7 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { useCallback, useLayoutEffect } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect } from "react";
|
||||
|
||||
import { router } from "@/app/router";
|
||||
import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
|
||||
@@ -10,6 +10,7 @@ import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
|
||||
import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit";
|
||||
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
|
||||
import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup";
|
||||
import { listenForDeepLinks } from "@/shared/deep-link";
|
||||
|
||||
function AppLoadingGate() {
|
||||
return (
|
||||
@@ -56,7 +57,15 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { activeWorkspace, reinitKey } = useWorkspaces();
|
||||
const { activeWorkspace, reinitKey, addWorkspace, switchWorkspace } =
|
||||
useWorkspaces();
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listenForDeepLinks({ addWorkspace, switchWorkspace });
|
||||
return () => {
|
||||
void unlisten.then((fn) => fn());
|
||||
};
|
||||
}, [addWorkspace, switchWorkspace]);
|
||||
const workspace = useWorkspaceInit(activeWorkspace);
|
||||
|
||||
// Composite key: changes when workspace ID changes OR when
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import {
|
||||
deriveWorkspaceName,
|
||||
normalizeRelayUrl,
|
||||
} from "@/features/workspaces/workspaceStorage";
|
||||
|
||||
export interface DeepLinkDeps {
|
||||
addWorkspace: (workspace: Workspace) => string;
|
||||
switchWorkspace: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register listeners for deep-link events emitted by the Rust backend.
|
||||
*
|
||||
* When a `sprout://connect?relay=<url>` link is opened, the handler
|
||||
* adds a workspace for the relay (deduplicating by URL) and switches
|
||||
* to it. Returns an unlisten function to tear down all listeners.
|
||||
*/
|
||||
export function listenForDeepLinks(deps: DeepLinkDeps): Promise<UnlistenFn> {
|
||||
return listen<string>("deep-link-connect", (event) => {
|
||||
const relayUrl = normalizeRelayUrl(event.payload);
|
||||
const name = deriveWorkspaceName(relayUrl);
|
||||
const id = deps.addWorkspace({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
relayUrl,
|
||||
addedAt: new Date().toISOString(),
|
||||
});
|
||||
deps.switchWorkspace(id);
|
||||
toast.success(`Connected to ${name}`);
|
||||
});
|
||||
}
|
||||
@@ -58,13 +58,13 @@ fmt-check:
|
||||
clippy:
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Install desktop JS dependencies
|
||||
# Install JS dependencies (pnpm workspace — installs all packages from root)
|
||||
desktop-install:
|
||||
cd {{desktop_dir}} && pnpm install
|
||||
pnpm install
|
||||
|
||||
# Install desktop JS dependencies reproducibly for CI
|
||||
# Install JS dependencies reproducibly for CI (pnpm workspace)
|
||||
desktop-install-ci:
|
||||
cd {{desktop_dir}} && pnpm install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Run desktop lint and format checks
|
||||
desktop-check:
|
||||
@@ -109,7 +109,8 @@ desktop-release-build target="aarch64-apple-darwin":
|
||||
touch "desktop/src-tauri/binaries/sprout-acp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
|
||||
cd {{desktop_dir}} && pnpm install && pnpm tauri build --target {{target}}
|
||||
pnpm install
|
||||
cd {{desktop_dir}} && pnpm tauri build --target {{target}}
|
||||
|
||||
# Run desktop checks suitable for CI / pre-push
|
||||
desktop-ci: desktop-check desktop-tauri-fmt-check desktop-build desktop-tauri-check
|
||||
@@ -175,9 +176,7 @@ dev *ARGS: _ensure-sidecar-stubs
|
||||
staging *ARGS: _ensure-sidecar-stubs
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd {{desktop_dir}}
|
||||
pnpm install
|
||||
cd ..
|
||||
cargo build --release -p sprout-acp -p sprout-mcp
|
||||
cd {{desktop_dir}}
|
||||
source ../scripts/instance-env.sh
|
||||
@@ -205,9 +204,7 @@ desktop-app *ARGS:
|
||||
web:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd {{web_dir}}
|
||||
[[ -d node_modules ]] || pnpm install
|
||||
cd ..
|
||||
source scripts/instance-env.sh
|
||||
export VITE_PORT=$((SPROUT_VITE_PORT + 100))
|
||||
export VITE_RELAY_URL="${SPROUT_RELAY_URL}"
|
||||
@@ -215,13 +212,13 @@ web:
|
||||
cd {{web_dir}}
|
||||
pnpm exec vite --port "${VITE_PORT}" --strictPort
|
||||
|
||||
# Install web JS dependencies
|
||||
# Install web JS dependencies (pnpm workspace — installs all packages from root)
|
||||
web-install:
|
||||
cd {{web_dir}} && pnpm install
|
||||
pnpm install
|
||||
|
||||
# Install web JS dependencies reproducibly for CI
|
||||
# Install web JS dependencies reproducibly for CI (pnpm workspace)
|
||||
web-install-ci:
|
||||
cd {{web_dir}} && pnpm install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Run web lint and format checks
|
||||
web-check:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "sprout-workspace",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"check": "pnpm -r check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6"
|
||||
}
|
||||
}
|
||||
Generated
+639
-589
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "desktop"
|
||||
- "web"
|
||||
+1
-35
@@ -1,37 +1,3 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": false
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
"extends": ["../biome.json"]
|
||||
}
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -24,13 +23,13 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nostr-tools": "^2.23.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tanstack/router-plugin": "^1.167.12",
|
||||
"@tanstack/virtual-file-routes": "^1.161.7",
|
||||
|
||||
Generated
-2586
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,14 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from "./routes/root";
|
||||
import { Route as reposRouteImport } from "./routes/repos";
|
||||
import { Route as indexRouteImport } from "./routes/index";
|
||||
|
||||
const reposRoute = reposRouteImport.update({
|
||||
id: "/repos",
|
||||
path: "/repos",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const indexRoute = indexRouteImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
@@ -15,28 +21,39 @@ const indexRoute = indexRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof indexRoute;
|
||||
"/repos": typeof reposRoute;
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof indexRoute;
|
||||
"/repos": typeof reposRoute;
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport;
|
||||
"/": typeof indexRoute;
|
||||
"/repos": typeof reposRoute;
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||
fullPaths: "/";
|
||||
fullPaths: "/" | "/repos";
|
||||
fileRoutesByTo: FileRoutesByTo;
|
||||
to: "/";
|
||||
id: "__root__" | "/";
|
||||
to: "/" | "/repos";
|
||||
id: "__root__" | "/" | "/repos";
|
||||
fileRoutesById: FileRoutesById;
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
indexRoute: typeof indexRoute;
|
||||
reposRoute: typeof reposRoute;
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface FileRoutesByPath {
|
||||
"/repos": {
|
||||
id: "/repos";
|
||||
path: "/repos";
|
||||
fullPath: "/repos";
|
||||
preLoaderRoute: typeof reposRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/": {
|
||||
id: "/";
|
||||
path: "/";
|
||||
@@ -49,6 +66,7 @@ declare module "@tanstack/react-router" {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
indexRoute: indexRoute,
|
||||
reposRoute: reposRoute,
|
||||
};
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { index, rootRoute } from "@tanstack/virtual-file-routes";
|
||||
import { index, route, rootRoute } from "@tanstack/virtual-file-routes";
|
||||
|
||||
export const routes = rootRoute("root.tsx", [index("index.tsx")]);
|
||||
export const routes = rootRoute("root.tsx", [
|
||||
index("index.tsx"),
|
||||
route("/repos", "repos.tsx"),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ReposPage } from "@/features/repos/ui/ReposPage";
|
||||
|
||||
export const Route = createFileRoute("/repos")({
|
||||
component: ReposPage,
|
||||
});
|
||||
@@ -1,15 +1,34 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground [&.active]:font-medium [&.active]:text-foreground"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<header className="flex h-12 items-center justify-between border-b px-4">
|
||||
<span className="text-sm font-semibold tracking-tight">Sprout</span>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm font-semibold tracking-tight text-foreground"
|
||||
>
|
||||
Sprout
|
||||
</Link>
|
||||
<NavLink to="/repos">Repos</NavLink>
|
||||
</nav>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col">
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Check, Copy, ExternalLink, GitBranch } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import type { Repo } from "../use-repos";
|
||||
|
||||
function truncateHex(hex: string): string {
|
||||
if (hex.length <= 12) return hex;
|
||||
return `${hex.slice(0, 8)}...${hex.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatDate(unix: number): string {
|
||||
return new Date(unix * 1000).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function CopyButton({ value, label }: { value: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(value).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
},
|
||||
() => {
|
||||
toast.error("Failed to copy to clipboard");
|
||||
},
|
||||
);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleCopy}
|
||||
aria-label={label}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepoCard({ repo }: { repo: Repo }) {
|
||||
const relayUrl = relayWsUrl();
|
||||
const deepLink = `sprout://connect?relay=${encodeURIComponent(relayUrl)}`;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<GitBranch className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">{repo.name}</CardTitle>
|
||||
{repo.description && (
|
||||
<CardDescription className="mt-1 line-clamp-2">
|
||||
{repo.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 space-y-3 text-sm">
|
||||
{repo.cloneUrls.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Clone
|
||||
</span>
|
||||
{repo.cloneUrls.map((url) => (
|
||||
<div key={url} className="flex items-center gap-1">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">
|
||||
{url}
|
||||
</code>
|
||||
<CopyButton value={url} label={`Copy clone URL: ${url}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default font-mono">
|
||||
{truncateHex(repo.owner)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{repo.owner}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>{formatDate(repo.createdAt)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="gap-2 border-t pt-4">
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<a href={deepLink} aria-label={`Open ${repo.name} in Sprout`}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Open in Sprout
|
||||
</a>
|
||||
</Button>
|
||||
<CopyButton value={relayUrl} label="Copy relay URL" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import { useRepos } from "../use-repos";
|
||||
import { RepoCard } from "./RepoCard";
|
||||
|
||||
function RepoCardSkeleton() {
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-0.5 h-4 w-4 shrink-0 animate-pulse rounded bg-muted" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-5 w-2/3 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-12 animate-pulse rounded bg-muted" />
|
||||
<div className="h-7 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="flex gap-2 border-t p-6 pt-4">
|
||||
<div className="h-8 flex-1 animate-pulse rounded-md bg-muted" />
|
||||
<div className="h-8 w-8 animate-pulse rounded-md bg-muted" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||
<GitBranch className="h-7 w-7 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-lg font-semibold">No repositories yet</h2>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Repositories published to this relay will appear here. Push a git repo
|
||||
using the Sprout desktop app to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReposPage() {
|
||||
const { data: repos, isLoading, error } = useRepos();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error("Failed to load repositories", {
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
|
||||
Repositories
|
||||
</h1>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{["a", "b", "c", "d", "e", "f"].map((key) => (
|
||||
<RepoCardSkeleton key={key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!repos || repos.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
|
||||
Repositories
|
||||
</h1>
|
||||
<EmptyState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
|
||||
Repositories
|
||||
</h1>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{repos.map((repo) => (
|
||||
<RepoCard key={repo.id} repo={repo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client";
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
|
||||
export interface Repo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cloneUrls: string[];
|
||||
webUrl: string | null;
|
||||
owner: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Extract the first value for a given tag name from a Nostr event. */
|
||||
function getTag(event: NostrEvent, name: string): string | undefined {
|
||||
return event.tags.find((t) => t[0] === name)?.[1];
|
||||
}
|
||||
|
||||
/** Extract all values for a given tag name from a Nostr event. */
|
||||
function getAllTags(event: NostrEvent, name: string): string[] {
|
||||
return event.tags.filter((t) => t[0] === name).map((t) => t[1]);
|
||||
}
|
||||
|
||||
function eventToRepo(event: NostrEvent): Repo {
|
||||
const d = getTag(event, "d") ?? event.id;
|
||||
const name = getTag(event, "name") || d;
|
||||
const description = getTag(event, "description") || event.content || "";
|
||||
const cloneUrls = getAllTags(event, "clone");
|
||||
const webUrl = getTag(event, "web") ?? null;
|
||||
const owner = getAllTags(event, "p")[0] ?? event.pubkey;
|
||||
|
||||
return {
|
||||
id: d,
|
||||
name,
|
||||
description,
|
||||
cloneUrls,
|
||||
webUrl,
|
||||
owner,
|
||||
createdAt: event.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deduplicate NIP-33 parameterized replaceable events, keeping the latest per (pubkey, kind, d-tag). */
|
||||
function dedup(events: NostrEvent[]): NostrEvent[] {
|
||||
const best = new Map<string, NostrEvent>();
|
||||
for (const e of events) {
|
||||
const d = getTag(e, "d") ?? "";
|
||||
const key = `${e.pubkey}:${e.kind}:${d}`;
|
||||
const prev = best.get(key);
|
||||
if (!prev || e.created_at > prev.created_at) {
|
||||
best.set(key, e);
|
||||
}
|
||||
}
|
||||
return [...best.values()];
|
||||
}
|
||||
|
||||
async function fetchRepos(): Promise<Repo[]> {
|
||||
const events = await queryEvents(relayWsUrl(), { kinds: [30617] });
|
||||
return dedup(events)
|
||||
.map(eventToRepo)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export function useRepos() {
|
||||
return useQuery({
|
||||
queryKey: ["repos"],
|
||||
queryFn: fetchRepos,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Minimal Nostr client with NIP-01 queries and NIP-42 AUTH.
|
||||
*
|
||||
* Generates an ephemeral keypair per session to authenticate with the relay.
|
||||
* This is sufficient for read-only public queries (e.g. repo listings).
|
||||
*/
|
||||
|
||||
import { generateSecretKey, finalizeEvent } from "nostr-tools/pure";
|
||||
import { makeAuthEvent } from "nostr-tools/nip42";
|
||||
|
||||
export interface NostrFilter {
|
||||
ids?: string[];
|
||||
authors?: string[];
|
||||
kinds?: number[];
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
[tag: `#${string}`]: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface NostrEvent {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
created_at: number;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
const QUERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Lazily-generated ephemeral keypair for NIP-42 AUTH. */
|
||||
let _secretKey: Uint8Array | null = null;
|
||||
function getEphemeralKey(): Uint8Array {
|
||||
if (!_secretKey) {
|
||||
_secretKey = generateSecretKey();
|
||||
}
|
||||
return _secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a WebSocket to `wsUrl`, authenticate via NIP-42 if challenged,
|
||||
* send a REQ with the given filter, collect EVENTs until EOSE, then
|
||||
* close and return them.
|
||||
*/
|
||||
export function queryEvents(
|
||||
wsUrl: string,
|
||||
filter: NostrFilter,
|
||||
): Promise<NostrEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const events: NostrEvent[] = [];
|
||||
const subId = `q-${Date.now().toString(36)}`;
|
||||
let settled = false;
|
||||
let reqSent = false;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ws.close();
|
||||
reject(new Error(`Relay query timed out after ${QUERY_TIMEOUT_MS}ms`));
|
||||
}
|
||||
}, QUERY_TIMEOUT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const sendReq = () => {
|
||||
if (!reqSent) {
|
||||
reqSent = true;
|
||||
ws.send(JSON.stringify(["REQ", subId, filter]));
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
// Wait briefly for an AUTH challenge before sending REQ.
|
||||
// Sprout relays always send AUTH, but other relays may not.
|
||||
setTimeout(() => sendReq(), 100);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (msg) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(String(msg.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(data)) return;
|
||||
|
||||
const [type] = data;
|
||||
|
||||
if (type === "AUTH" && typeof data[1] === "string") {
|
||||
// NIP-42: relay sent an AUTH challenge — sign and respond.
|
||||
const challenge = data[1];
|
||||
const sk = getEphemeralKey();
|
||||
const template = makeAuthEvent(wsUrl, challenge);
|
||||
const signed = finalizeEvent(template, sk);
|
||||
ws.send(JSON.stringify(["AUTH", signed]));
|
||||
// After AUTH, send our REQ.
|
||||
sendReq();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "OK") {
|
||||
// AUTH response accepted (or rejected). If we haven't sent REQ yet, do so now.
|
||||
sendReq();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "EVENT" && data[1] === subId && data[2]) {
|
||||
events.push(data[2] as NostrEvent);
|
||||
} else if (type === "EOSE" && data[1] === subId) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(events);
|
||||
}
|
||||
} else if (type === "CLOSED" && data[1] === subId) {
|
||||
// Subscription was rejected (e.g. auth failed).
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
const reason =
|
||||
typeof data[2] === "string"
|
||||
? data[2]
|
||||
: "subscription closed by relay";
|
||||
reject(new Error(reason));
|
||||
}
|
||||
} else if (type === "NOTICE") {
|
||||
// Informational notice from relay — ignore for now.
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(events);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Convert a WebSocket relay URL to its HTTP equivalent. */
|
||||
export function relayHttpUrl(wsUrl: string): string {
|
||||
if (wsUrl.startsWith("wss://")) {
|
||||
return `https://${wsUrl.slice(6)}`;
|
||||
}
|
||||
if (wsUrl.startsWith("ws://")) {
|
||||
return `http://${wsUrl.slice(5)}`;
|
||||
}
|
||||
return wsUrl;
|
||||
}
|
||||
|
||||
/** Read the relay WebSocket URL from environment or fall back to localhost. */
|
||||
export function relayWsUrl(): string {
|
||||
return import.meta.env.VITE_RELAY_URL || "ws://localhost:3000";
|
||||
}
|
||||
|
||||
/** HTTP base URL for the relay (derived from the WS URL). */
|
||||
export function relayHttpBaseUrl(): string {
|
||||
return relayHttpUrl(relayWsUrl());
|
||||
}
|
||||
Reference in New Issue
Block a user