diff --git a/.gitignore b/.gitignore index b5ab3b4f9..88b387549 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ doc/ # Local identity files identity.key **/identity.key + +# Claude Code worktrees +.claude/worktrees/ diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 3be94f8fa..4eec001db 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -37,6 +37,17 @@ pub fn get_default_relay_url() -> String { relay::relay_ws_url() } +#[tauri::command] +pub fn is_shared_identity() -> bool { + std::env::var("SPROUT_SHARE_IDENTITY") + .map(|v| v == "1") + .unwrap_or(false) + && std::env::var("SPROUT_PRIVATE_KEY") + .ok() + .and_then(|k| Keys::parse(k.trim()).ok()) + .is_some() +} + #[tauri::command] pub fn get_relay_ws_url(state: State<'_, AppState>) -> String { relay_ws_url_with_override(&state) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 93472357e..d27a35914 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -461,6 +461,7 @@ pub fn run() { get_presence, set_presence, get_default_relay_url, + is_shared_identity, get_relay_ws_url, get_relay_http_url, get_media_proxy_port, diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 62c9bdd08..4f75637f5 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -16,6 +16,7 @@ import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit"; import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup"; import { createSproutQueryClient } from "@/shared/api/queryClient"; +import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { listenForDeepLinks } from "@/shared/deep-link"; function AppLoadingGate() { @@ -44,8 +45,8 @@ function WorkspaceQueryProvider({ children }: { children: ReactNode }) { ); } -function AppReady() { - const onboarding = useAppOnboardingState(); +function AppReady({ isSharedIdentity }: { isSharedIdentity: boolean }) { + const onboarding = useAppOnboardingState(isSharedIdentity); if (onboarding.stage === "onboarding") { return ( @@ -69,6 +70,16 @@ export function App() { void getCurrentWindow().show(); }, []); + const [sharedIdentity, setSharedIdentity] = useState(null); + useEffect(() => { + isSharedIdentityCmd() + .then(setSharedIdentity) + .catch((err) => { + console.warn("is_shared_identity command failed:", err); + setSharedIdentity(false); + }); + }, []); + const { activeWorkspace, reinitKey, @@ -90,7 +101,11 @@ export function App() { // Composite key: changes when workspace ID changes OR when // the active workspace's config is updated (relayUrl/token). const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`; - const workspace = useWorkspaceInit(activeWorkspace, workspaceKey); + const workspace = useWorkspaceInit( + activeWorkspace, + workspaceKey, + sharedIdentity ?? false, + ); const handleSetupComplete = useCallback(() => { // Force a full reload so useWorkspaces re-initializes from localStorage. @@ -98,6 +113,13 @@ export function App() { window.location.reload(); }, []); + // Wait for the shared-identity IPC call to resolve before rendering + // anything that depends on it. Without this gate, children briefly see + // isSharedIdentity=false and may flash WelcomeSetup or the onboarding flow. + if (sharedIdentity === null) { + return ; + } + // Show welcome setup for first-run users with no workspaces if (workspace.needsSetup) { return ( @@ -118,7 +140,7 @@ export function App() { return ( - + ); } diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index db6f6bf82..3f9d6ae2c 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -36,6 +36,7 @@ type UseFirstRunOnboardingGateOptions = { hasExistingProfile: boolean; identityIsFetching: boolean; identityStatus: QueryStatus; + isSharedIdentity: boolean; profileStatus: QueryStatus; }; @@ -129,13 +130,15 @@ export function useFirstRunOnboardingGate({ hasExistingProfile, identityIsFetching, identityStatus, + isSharedIdentity, profileStatus, }: UseFirstRunOnboardingGateOptions) { const [gateState, setGateState] = React.useState(() => createOnboardingGateState(currentPubkey), ); const activeGateState = resolveActiveGateState(gateState, currentPubkey); - const { hasSettledCurrentPubkey } = activeGateState; + const { hasCompletedCurrentPubkey, hasSettledCurrentPubkey } = + activeGateState; React.useEffect(() => { setGateState((current) => @@ -146,6 +149,33 @@ export function useFirstRunOnboardingGate({ }, [currentPubkey]); React.useEffect(() => { + // Fast-path: shared identity worktrees have already onboarded in the + // main checkout. Skip unconditionally without waiting for the relay + // profile query. Guarded by !hasCompletedCurrentPubkey so it fires once. + if ( + isSharedIdentity && + currentPubkey && + identityStatus === "success" && + !hasCompletedCurrentPubkey + ) { + if (typeof window !== "undefined") { + window.localStorage.setItem( + onboardingCompletionStorageKey(currentPubkey), + "true", + ); + } + setGateState((current) => + updateActiveGateState(current, currentPubkey, (activeGateState) => ({ + ...activeGateState, + hasCompletedCurrentPubkey: true, + hasSettledCurrentPubkey: true, + isOpen: false, + })), + ); + return; + } + + // Original guard — restored to simple form. if (hasSettledCurrentPubkey || !currentPubkey) { return; } @@ -194,9 +224,11 @@ export function useFirstRunOnboardingGate({ ); }, [ currentPubkey, + hasCompletedCurrentPubkey, hasExistingProfile, hasSettledCurrentPubkey, identityStatus, + isSharedIdentity, profileStatus, ]); @@ -246,7 +278,7 @@ function hasRealDisplayName(displayName?: string | null): boolean { return !lower.startsWith("npub1") && !lower.startsWith("nostr:npub1"); } -export function useAppOnboardingState() { +export function useAppOnboardingState(isSharedIdentity: boolean) { const queryClient = useQueryClient(); const identityQuery = useIdentityQuery(); const identity = identityQuery.data; @@ -257,6 +289,7 @@ export function useAppOnboardingState() { hasExistingProfile: hasRealDisplayName(profileQuery.data?.displayName), identityIsFetching: identityQuery.fetchStatus === "fetching", identityStatus: identityQuery.status, + isSharedIdentity, profileStatus: profileQuery.status, }); const gateComplete = onboardingGate.complete; diff --git a/desktop/src/features/workspaces/ui/WelcomeSetup.tsx b/desktop/src/features/workspaces/ui/WelcomeSetup.tsx index fe8ede92d..d422a36d1 100644 --- a/desktop/src/features/workspaces/ui/WelcomeSetup.tsx +++ b/desktop/src/features/workspaces/ui/WelcomeSetup.tsx @@ -4,13 +4,7 @@ import { getIdentity } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; -import type { Workspace } from "../types"; -import { - deriveWorkspaceName, - normalizeRelayUrl, - saveActiveWorkspaceId, - saveWorkspaces, -} from "../workspaceStorage"; +import { initFirstWorkspace, deriveWorkspaceName } from "../workspaceStorage"; const LOCAL_RELAY_URL = "ws://localhost:3000"; @@ -35,7 +29,6 @@ export function WelcomeSetup({ return; } - const normalizedUrl = normalizeRelayUrl(trimmedUrl); setIsConnecting(true); setError(null); @@ -44,17 +37,7 @@ export function WelcomeSetup({ // labels, etc.). The private key lives on disk in `identity.key` and // is the single source of truth — never copied into localStorage. const identity = await getIdentity(); - - const workspace: Workspace = { - id: crypto.randomUUID(), - name: deriveWorkspaceName(normalizedUrl), - relayUrl: normalizedUrl, - pubkey: identity.pubkey, - addedAt: new Date().toISOString(), - }; - - saveWorkspaces([workspace]); - saveActiveWorkspaceId(workspace.id); + initFirstWorkspace(trimmedUrl, identity.pubkey); // The reload triggered by onComplete() will re-run useWorkspaceInit, // which calls applyWorkspace with the saved config. No need to apply here. diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index 265004485..8da3e949d 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -1,12 +1,17 @@ import { useEffect, useRef, useState } from "react"; import { relayClient } from "@/shared/api/relayClient"; -import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri"; +import { + applyWorkspace, + getDefaultRelayUrl, + getIdentity, +} from "@/shared/api/tauri"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts } from "@/features/messages/lib/useDrafts"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; +import { initFirstWorkspace } from "./workspaceStorage"; import type { Workspace } from "./types"; /** @@ -43,6 +48,7 @@ type WorkspaceInitResult = export function useWorkspaceInit( activeWorkspace: Workspace | null, workspaceKey: string, + isSharedIdentity: boolean, ): WorkspaceInitResult { const [result, setResult] = useState({ isReady: false, @@ -60,9 +66,19 @@ export function useWorkspaceInit( async function init() { if (!activeWorkspace) { - // No workspace — need setup try { const defaultRelayUrl = await getDefaultRelayUrl(); + + if (isSharedIdentity) { + const identity = await getIdentity(); + if (cancelled) return; + initFirstWorkspace(defaultRelayUrl, identity.pubkey); + if (!cancelled) { + window.location.reload(); + } + return; + } + if (!cancelled) { setResult({ isReady: false, @@ -136,6 +152,7 @@ export function useWorkspaceInit( activeWorkspace?.id, activeWorkspace?.relayUrl, activeWorkspace?.token, + isSharedIdentity, workspaceKey, ]); diff --git a/desktop/src/features/workspaces/workspaceStorage.ts b/desktop/src/features/workspaces/workspaceStorage.ts index 37b687452..407dd7514 100644 --- a/desktop/src/features/workspaces/workspaceStorage.ts +++ b/desktop/src/features/workspaces/workspaceStorage.ts @@ -79,3 +79,20 @@ export function deriveWorkspaceName(relayUrl: string): string { return "Workspace"; } } + +export function initFirstWorkspace( + relayUrl: string, + pubkey: string, +): Workspace { + const normalizedUrl = normalizeRelayUrl(relayUrl); + const workspace: Workspace = { + id: crypto.randomUUID(), + name: deriveWorkspaceName(normalizedUrl), + relayUrl: normalizedUrl, + pubkey, + addedAt: new Date().toISOString(), + }; + saveWorkspaces([workspace]); + saveActiveWorkspaceId(workspace.id); + return workspace; +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 7b5f78d7a..8cbee3f76 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -519,6 +519,10 @@ export function getDefaultRelayUrl(): Promise { return invokeTauri("get_default_relay_url"); } +export function isSharedIdentity(): Promise { + return invokeTauri("is_shared_identity"); +} + export function getRelayWsUrl(): Promise { return invokeTauri("get_relay_ws_url"); }