Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ doc/
# Local identity files
identity.key
**/identity.key

# Claude Code worktrees
.claude/worktrees/
11 changes: 11 additions & 0 deletions desktop/src-tauri/src/commands/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 26 additions & 4 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 (
Expand All @@ -69,6 +70,16 @@ export function App() {
void getCurrentWindow().show();
}, []);

const [sharedIdentity, setSharedIdentity] = useState<boolean | null>(null);
useEffect(() => {
isSharedIdentityCmd()
.then(setSharedIdentity)
.catch((err) => {
console.warn("is_shared_identity command failed:", err);
setSharedIdentity(false);
});
}, []);

const {
activeWorkspace,
reinitKey,
Expand All @@ -90,14 +101,25 @@ 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.
// This only runs once — during first-run setup when no workspace existed.
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 <AppLoadingGate />;
}

// Show welcome setup for first-run users with no workspaces
if (workspace.needsSetup) {
return (
Expand All @@ -118,7 +140,7 @@ export function App() {

return (
<WorkspaceQueryProvider key={workspaceKey}>
<AppReady key={workspaceKey} />
<AppReady key={workspaceKey} isSharedIdentity={sharedIdentity} />
</WorkspaceQueryProvider>
);
}
37 changes: 35 additions & 2 deletions desktop/src/features/onboarding/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type UseFirstRunOnboardingGateOptions = {
hasExistingProfile: boolean;
identityIsFetching: boolean;
identityStatus: QueryStatus;
isSharedIdentity: boolean;
profileStatus: QueryStatus;
};

Expand Down Expand Up @@ -129,13 +130,15 @@ export function useFirstRunOnboardingGate({
hasExistingProfile,
identityIsFetching,
identityStatus,
isSharedIdentity,
profileStatus,
}: UseFirstRunOnboardingGateOptions) {
const [gateState, setGateState] = React.useState<OnboardingGateState>(() =>
createOnboardingGateState(currentPubkey),
);
const activeGateState = resolveActiveGateState(gateState, currentPubkey);
const { hasSettledCurrentPubkey } = activeGateState;
const { hasCompletedCurrentPubkey, hasSettledCurrentPubkey } =
activeGateState;

React.useEffect(() => {
setGateState((current) =>
Expand All @@ -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;
}
Expand Down Expand Up @@ -194,9 +224,11 @@ export function useFirstRunOnboardingGate({
);
}, [
currentPubkey,
hasCompletedCurrentPubkey,
hasExistingProfile,
hasSettledCurrentPubkey,
identityStatus,
isSharedIdentity,
profileStatus,
]);

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
21 changes: 2 additions & 19 deletions desktop/src/features/workspaces/ui/WelcomeSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -35,7 +29,6 @@ export function WelcomeSetup({
return;
}

const normalizedUrl = normalizeRelayUrl(trimmedUrl);
setIsConnecting(true);
setError(null);

Expand All @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions desktop/src/features/workspaces/useWorkspaceInit.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -43,6 +48,7 @@ type WorkspaceInitResult =
export function useWorkspaceInit(
activeWorkspace: Workspace | null,
workspaceKey: string,
isSharedIdentity: boolean,
): WorkspaceInitResult {
const [result, setResult] = useState<WorkspaceInitResult>({
isReady: false,
Expand All @@ -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,
Expand Down Expand Up @@ -136,6 +152,7 @@ export function useWorkspaceInit(
activeWorkspace?.id,
activeWorkspace?.relayUrl,
activeWorkspace?.token,
isSharedIdentity,
workspaceKey,
]);

Expand Down
17 changes: 17 additions & 0 deletions desktop/src/features/workspaces/workspaceStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 4 additions & 0 deletions desktop/src/shared/api/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,10 @@ export function getDefaultRelayUrl(): Promise<string> {
return invokeTauri<string>("get_default_relay_url");
}

export function isSharedIdentity(): Promise<boolean> {
return invokeTauri<boolean>("is_shared_identity");
}

export function getRelayWsUrl(): Promise<string> {
return invokeTauri<string>("get_relay_ws_url");
}
Expand Down
Loading