diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f1d6067c6..bf1756d74 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,3 +1,5 @@ +[workspace] + [package] name = "sprout" version = "0.1.0" diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index 3b4c4e5a1..db6f6bf82 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -33,6 +33,7 @@ type OnboardingGateStage = "blocking" | "onboarding" | "ready"; type UseFirstRunOnboardingGateOptions = { currentPubkey: string | null; + hasExistingProfile: boolean; identityIsFetching: boolean; identityStatus: QueryStatus; profileStatus: QueryStatus; @@ -125,6 +126,7 @@ function resolveOnboardingGateStage({ export function useFirstRunOnboardingGate({ currentPubkey, + hasExistingProfile, identityIsFetching, identityStatus, profileStatus, @@ -166,14 +168,37 @@ export function useFirstRunOnboardingGate({ return; } + // If the relay already has a real profile for this pubkey, the user has + // been onboarded elsewhere — skip the gate and persist the completion so + // future launches in this data dir don't re-check. + if (hasExistingProfile) { + if (typeof window !== "undefined" && currentPubkey) { + window.localStorage.setItem( + onboardingCompletionStorageKey(currentPubkey), + "true", + ); + } + } + setGateState((current) => - updateActiveGateState(current, currentPubkey, (activeGateState) => ({ - ...activeGateState, - hasSettledCurrentPubkey: true, - isOpen: !activeGateState.hasCompletedCurrentPubkey, - })), + updateActiveGateState(current, currentPubkey, (activeGateState) => { + const alreadyOnboarded = + activeGateState.hasCompletedCurrentPubkey || hasExistingProfile; + return { + ...activeGateState, + hasCompletedCurrentPubkey: alreadyOnboarded, + hasSettledCurrentPubkey: true, + isOpen: !alreadyOnboarded, + }; + }), ); - }, [currentPubkey, hasSettledCurrentPubkey, identityStatus, profileStatus]); + }, [ + currentPubkey, + hasExistingProfile, + hasSettledCurrentPubkey, + identityStatus, + profileStatus, + ]); const skipForNow = React.useCallback(() => { setGateState((current) => @@ -213,6 +238,14 @@ export function useFirstRunOnboardingGate({ }; } +function hasRealDisplayName(displayName?: string | null): boolean { + if (!displayName) return false; + const trimmed = displayName.trim(); + if (trimmed.length === 0) return false; + const lower = trimmed.toLowerCase(); + return !lower.startsWith("npub1") && !lower.startsWith("nostr:npub1"); +} + export function useAppOnboardingState() { const queryClient = useQueryClient(); const identityQuery = useIdentityQuery(); @@ -221,6 +254,7 @@ export function useAppOnboardingState() { const profileQuery = useProfileQuery(); const onboardingGate = useFirstRunOnboardingGate({ currentPubkey, + hasExistingProfile: hasRealDisplayName(profileQuery.data?.displayName), identityIsFetching: identityQuery.fetchStatus === "fetching", identityStatus: identityQuery.status, profileStatus: profileQuery.status, diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 2a8283d0d..0d2b61f3f 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -9,6 +9,10 @@ const BLANK_TYLER_IDENTITY = { ...TEST_IDENTITIES.tyler, username: "", }; +const FIRST_RUN_ALICE = { + ...TEST_IDENTITIES.alice, + username: "", +}; type TestIdentity = { privateKey: string; @@ -139,17 +143,16 @@ test("page 1 accepts an avatar URL as the secondary avatar path", async ({ test("first-run onboarding keeps the shell hidden through both pages and only marks Home seen after finish", async ({ page, }) => { - await seedActiveIdentity(page, TEST_IDENTITIES.alice); + await seedActiveIdentity(page, FIRST_RUN_ALICE); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await expect(page.getByTestId("onboarding-gate")).toBeVisible(); await expect(page.getByTestId("onboarding-page-1")).toBeVisible(); - await expect(page.getByTestId("onboarding-display-name")).toHaveValue( - "alice", - ); + await expect(page.getByTestId("onboarding-display-name")).toHaveValue(""); await expectNoHomeSeenEntries(page); + await page.getByTestId("onboarding-display-name").fill("Alice"); await continueToSetupPage(page); await expectShellHidden(page); await expect(page.getByTestId("onboarding-provider-goose")).toBeVisible(); @@ -161,6 +164,17 @@ test("first-run onboarding keeps the shell hidden through both pages and only ma await expectHomeSeenCount(page, 2); }); +test("existing relay profile auto-skips onboarding without localStorage completion", async ({ + page, +}) => { + await seedActiveIdentity(page, TEST_IDENTITIES.alice); + await installMockBridge(page, undefined, { skipOnboardingSeed: true }); + await page.goto("/"); + + await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); + await expect(page.getByTestId("chat-title")).toHaveText("Home"); +}); + test("finishing onboarding auto-joins the #general channel for a new member", async ({ page, }) => { @@ -179,7 +193,7 @@ test("finishing onboarding auto-joins the #general channel for a new member", as test("page 2 falls back to Doctor guidance when ACP tools are not installed", async ({ page, }) => { - await seedActiveIdentity(page, TEST_IDENTITIES.alice); + await seedActiveIdentity(page, FIRST_RUN_ALICE); await installMockBridge( page, { @@ -189,6 +203,7 @@ test("page 2 falls back to Doctor guidance when ACP tools are not installed", as ); await page.goto("/"); + await page.getByTestId("onboarding-display-name").fill("Alice"); await continueToSetupPage(page); await expect(page.getByTestId("onboarding-acp-empty")).toBeVisible(); await expect( diff --git a/scripts/instance-env.sh b/scripts/instance-env.sh index 982c27490..c972b92cd 100755 --- a/scripts/instance-env.sh +++ b/scripts/instance-env.sh @@ -5,6 +5,7 @@ # SPROUT_RELAY_PORT, SPROUT_RELAY_URL # SPROUT_INSTANCE_SLUG, SPROUT_WORKTREE_LABEL, VITE_DEV_BRANCH (worktrees only) # SPROUT_TAURI_CONFIG +# SPROUT_PRIVATE_KEY (worktrees only, when SPROUT_SHARE_IDENTITY=1) WORKTREE_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) @@ -24,13 +25,31 @@ unset VITE_DEV_BRANCH # In worktrees, extract a label from the branch name and derive a unique app # identity and icon so multiple local desktop instances can run side by side. +# +# Worktree detection: compare --git-dir to --git-common-dir. In the main +# working tree these are identical; in any worktree (whether under .worktrees/, +# .claude/worktrees/, or elsewhere on disk) they differ. if git rev-parse --is-inside-work-tree &>/dev/null; then GIT_DIR=$(git rev-parse --git-dir) - if [[ "$GIT_DIR" == *".git/worktrees/"* ]]; then + GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) + if [[ -n "$GIT_COMMON_DIR" && "$GIT_DIR" != "$GIT_COMMON_DIR" ]]; then BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) export SPROUT_WORKTREE_LABEL="${BRANCH_NAME##*/}" export SPROUT_INSTANCE_SLUG=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//') + # SPROUT_SHARE_IDENTITY=1: reuse the main dev checkout's Nostr key so + # worktrees skip onboarding and share the same identity. The per-worktree + # identifier is kept so concurrent instances don't collide on + # tauri-plugin-single-instance or the app data directory. + if [[ "${SPROUT_SHARE_IDENTITY:-0}" == "1" ]]; then + CANONICAL_KEY="$HOME/Library/Application Support/xyz.block.sprout.app.dev/identity.key" + if [[ -f "$CANONICAL_KEY" ]]; then + export SPROUT_PRIVATE_KEY="$(cat "$CANONICAL_KEY")" + else + echo "⚠ SPROUT_SHARE_IDENTITY=1 but no identity found at $CANONICAL_KEY — run Sprout from repo root first" >&2 + fi + fi + ICON_DIR="$(pwd)/src-tauri/target/dev-icons" mkdir -p "$ICON_DIR" DEV_ICON="$ICON_DIR/icon.icns"