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
5 changes: 4 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,10 @@ const overrides = new Map([
// queued to split when ProfileSettingsCard is broken into sub-components.
// +20 lines: scroll-position save/restore across avatar editor open/close
// to prevent layout shift from the Sign Out section causing a viewport jump.
["src/features/settings/ui/ProfileSettingsCard.tsx", 1033],
// +11 lines: signout-dev-webview-state — clear localStorage/sessionStorage
// on successful signOut() resolve so dev-build webview state doesn't survive
// a reset and vouch for the fresh key. Comment explains the race/redundancy.
["src/features/settings/ui/ProfileSettingsCard.tsx", 1044],
// keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const
// to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix.
// +10 (1042 -> 1052): media_fetch_client with redirect::Policy::none() so a
Expand Down
9 changes: 8 additions & 1 deletion desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,11 @@ function AppReady({
}

function CommunityApp({
currentPubkey,
onBackToMachineConfig,
sharedIdentity,
}: {
currentPubkey: string | null;
onBackToMachineConfig: () => void;
sharedIdentity: boolean;
}) {
Expand Down Expand Up @@ -325,6 +327,7 @@ function CommunityApp({
relayUrl: transaction.relayUrl,
token: transaction.token,
reposDir: transaction.reposDir,
pubkey: currentPubkey ?? undefined,
addedAt: new Date().toISOString(),
});
communityOnboarding.update({
Expand All @@ -340,6 +343,7 @@ function CommunityApp({
addCommunity,
communities,
communityOnboarding,
currentPubkey,
reconnectCommunity,
switchCommunity,
]);
Expand Down Expand Up @@ -494,7 +498,9 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
const { activeCommunity } = useCommunities();
const communityOnboarding = useCommunityOnboarding();
const machine = useMachineOnboardingState({
hasConfiguredCommunity: activeCommunity !== null,
activeCommunityPubkey: activeCommunity
? (activeCommunity.pubkey ?? null)
: undefined,
isSharedIdentity: sharedIdentity,
});
const [machineInitialPage, setMachineInitialPage] =
Expand Down Expand Up @@ -548,6 +554,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
if (machine.stage === "ready") {
return (
<CommunityApp
currentPubkey={machine.currentPubkey}
onBackToMachineConfig={reopenMachineConfig}
sharedIdentity={sharedIdentity}
/>
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,11 @@ export function AppShell() {
isCreateChannelOpen={isCreateChannelOpen}
isPresencePending={presenceSession.isPending}
onAddCommunity={(community) => {
const id = communitiesHook.addCommunity(community);
const id = communitiesHook.addCommunity({
...community,
pubkey:
community.pubkey ?? identityQuery.data?.pubkey,
});
handleSwitchCommunity(id);
}}
onAddCommunityOpenChange={
Expand Down
174 changes: 174 additions & 0 deletions desktop/src/features/onboarding/machineOnboarding.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
migrateMachineOnboardingCompletion,
readMachineOnboardingCompletion,
} from "./machineOnboarding.ts";

// machineOnboarding.ts reads/writes window.localStorage directly, so we inject
// a minimal in-memory storage into globalThis.window before each test and
// restore it afterward.

function createMemoryStorage(initial = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
clear: () => values.clear(),
key: (index) => Array.from(values.keys())[index] ?? null,
get length() {
return values.size;
},
};
}

function withFakeWindow(initial, fn) {
const prev = globalThis.window;
const storage = createMemoryStorage(initial);
globalThis.window = {
localStorage: storage,
// location.href without a machineOnboarding=1 param → forceMachineOnboarding() returns false
location: { href: "http://localhost/" },
};
try {
return fn(storage);
} finally {
globalThis.window = prev;
}
}

const PUBKEY_A =
"aaaaaa1111112222223333334444445555556666667777778888889999990000aa";
const PUBKEY_B =
"bbbbbb1111112222223333334444445555556666667777778888889999990000bb";
const LEGACY_KEY = `buzz-onboarding-complete.v1:${PUBKEY_A}`;
const V2_KEY = `buzz-machine-onboarding-complete.v2:${PUBKEY_A}`;

// ── Fix A regression case ────────────────────────────────────────────────────

test("migrate_mismatched_community_pubkey_does_not_vouch_for_current_key", () => {
// Community pubkey is PUBKEY_B (stale from a previous identity); current
// pubkey is PUBKEY_A (freshly generated after a dev reset). The mismatch
// must NOT grant completion.
withFakeWindow({}, (storage) => {
const result = migrateMachineOnboardingCompletion(
PUBKEY_A,
PUBKEY_B, // activeCommunityPubkey — a different identity's community
false,
);
assert.equal(result, false, "mismatched pubkey must not vouch");
assert.equal(
storage.getItem(V2_KEY),
null,
"completion must not be written to storage",
);
assert.equal(
readMachineOnboardingCompletion(PUBKEY_A),
false,
"readMachineOnboardingCompletion must return false",
);
});
});

// ── Matching pubkey vouches ──────────────────────────────────────────────────

test("migrate_matching_community_pubkey_vouches_for_current_key", () => {
// Community pubkey matches current pubkey → legitimate veteran, grant.
withFakeWindow({}, (storage) => {
const result = migrateMachineOnboardingCompletion(
PUBKEY_A,
PUBKEY_A, // same pubkey — community belongs to this identity
false,
);
assert.equal(result, true, "matching pubkey must vouch");
assert.equal(storage.getItem(V2_KEY), "true");
assert.equal(readMachineOnboardingCompletion(PUBKEY_A), true);
});
});

// ── Absent community pubkey — regression case (Thufir pass 1) ───────────────

test("migrate_absent_community_pubkey_does_not_vouch_for_fresh_identity", () => {
// Stale current-format community with no pubkey stamp (created before the
// stamp was added, or from an older build) + freshly generated identity
// after a dev reset. The absent pubkey must NOT vouch — this was the
// reachable producer of the half-onboarded state.
withFakeWindow({}, (storage) => {
const result = migrateMachineOnboardingCompletion(
PUBKEY_A,
null, // absent pubkey — could be legacy OR an unstamped modern entry
false,
);
assert.equal(
result,
false,
"absent pubkey must not vouch for a fresh identity",
);
assert.equal(
storage.getItem(V2_KEY),
null,
"completion must not be written to storage",
);
});
});

// ── No community configured ──────────────────────────────────────────────────

test("migrate_no_community_does_not_vouch_for_current_key", () => {
// `undefined` = no active community at all.
withFakeWindow({}, (storage) => {
const result = migrateMachineOnboardingCompletion(
PUBKEY_A,
undefined, // no community
false,
);
assert.equal(result, false, "no community must not vouch");
assert.equal(storage.getItem(V2_KEY), null);
});
});

// ── Legacy onboarding completion still grants (pubkey-scoped) ───────────────

test("migrate_legacy_completion_key_still_grants_migration", () => {
withFakeWindow({ [LEGACY_KEY]: "true" }, (storage) => {
const result = migrateMachineOnboardingCompletion(
PUBKEY_A,
undefined, // no community — but legacy key is present
false,
);
assert.equal(result, true, "legacy completion key must grant migration");
assert.equal(storage.getItem(V2_KEY), "true");
});
});

// ── Shared identity grants regardless of community ───────────────────────────

test("migrate_shared_identity_grants_regardless_of_community", () => {
withFakeWindow({}, () => {
const result = migrateMachineOnboardingCompletion(
PUBKEY_A,
undefined, // no community
true, // isSharedIdentity
);
assert.equal(result, true, "shared identity must always grant");
});
});

// ── Already-completed v2 key returns true without re-writing ─────────────────

test("migrate_already_completed_pubkey_returns_true_immediately", () => {
withFakeWindow({ [V2_KEY]: "true" }, (storage) => {
// Pass mismatched community to prove early-exit, not community vouching.
const result = migrateMachineOnboardingCompletion(
PUBKEY_A,
PUBKEY_B,
false,
);
assert.equal(result, true, "already-completed pubkey must return true");
// Value was already there; the function should not have touched it
// (but a redundant write is also acceptable — just verify it's still true).
assert.equal(storage.getItem(V2_KEY), "true");
});
});
39 changes: 31 additions & 8 deletions desktop/src/features/onboarding/machineOnboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,27 @@ function clearMachineOnboardingCompletion(pubkey: string | null) {
}

function forceMachineOnboarding() {
if (!import.meta.env.DEV || typeof window === "undefined") return false;
if (!import.meta.env?.DEV || typeof window === "undefined") return false;
return (
new URL(window.location.href).searchParams.get("machineOnboarding") === "1"
);
}

function migrateMachineOnboardingCompletion(
/** @internal Exported for unit testing only. */
export function migrateMachineOnboardingCompletion(
pubkey: string,
hasConfiguredCommunity: boolean,
/**
* The `pubkey` field of the active community from localStorage, or
* `undefined` if no community is configured. Community-creation paths stamp
* the current identity's pubkey on write; absent pubkey (`null`) therefore
* indicates a legacy entry that pre-dates the stamp and is NOT treated as a
* voucher. Pass `undefined` when there is no active community at all.
*
* Using the community's own pubkey prevents a freshly generated post-reset
* key from being vouched for by a stale community entry that survived the
* webview wipe.
*/
activeCommunityPubkey: string | null | undefined,
isSharedIdentity: boolean,
) {
if (forceMachineOnboarding()) return false;
Expand All @@ -54,9 +66,20 @@ function migrateMachineOnboardingCompletion(
window.localStorage.getItem(
completionKey(LEGACY_ONBOARDING_COMPLETION_STORAGE_KEY, pubkey),
) === "true";

// A community entry vouches for the current pubkey only when its recorded
// pubkey matches. Absent pubkey (legacy entries predating the stamp) and
// no community at all (undefined) do not vouch — after community creation
// paths stamp pubkey on write, absent means the entry pre-dates the stamp
// and cannot be trusted to identify which identity created it.
const communityVouchesForPubkey =
activeCommunityPubkey !== undefined &&
activeCommunityPubkey !== null &&
activeCommunityPubkey === pubkey;

if (
!completedLegacyOnboarding &&
!hasConfiguredCommunity &&
!communityVouchesForPubkey &&
!isSharedIdentity
) {
return false;
Expand All @@ -74,10 +97,10 @@ function identitySettled(status: QueryStatus, isFetching: boolean) {
}

export function useMachineOnboardingState({
hasConfiguredCommunity,
activeCommunityPubkey,
isSharedIdentity,
}: {
hasConfiguredCommunity: boolean;
activeCommunityPubkey: string | null | undefined;
isSharedIdentity: boolean;
}) {
const queryClient = useQueryClient();
Expand Down Expand Up @@ -130,7 +153,7 @@ export function useMachineOnboardingState({
if (
migrateMachineOnboardingCompletion(
currentPubkey,
hasConfiguredCommunity,
activeCommunityPubkey,
isSharedIdentity,
)
) {
Expand All @@ -139,7 +162,7 @@ export function useMachineOnboardingState({
setEvaluatedPubkey(currentPubkey);
}, [
currentPubkey,
hasConfiguredCommunity,
activeCommunityPubkey,
identityLost,
identityQuery.status,
isSharedIdentity,
Expand Down
28 changes: 21 additions & 7 deletions desktop/src/features/settings/ui/ProfileSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1009,13 +1009,27 @@ export function ProfileSettingsCard({
onClick={() => {
setIsSignOutPending(true);
// Keep the pending state if signOut() resolves before restart.
signOut().catch((err: unknown) => {
setIsSignOutPending(false);
setIsSignOutOpen(false);
toast.error(
err instanceof Error ? err.message : "Sign out failed.",
);
});
signOut()
.then(() => {
// Clear web storage for this origin on the success path
// only. This covers dev builds where the Rust webview wipe
// targets the .app-bundle WebKit dir (missing in `tauri
// dev`), preventing stale community config from vouching
// for the fresh key on next boot. In production the Rust
// wipe already handles this; the clear here is redundant
// but harmless. The restart may race this clear — that is
// acceptable; Fix A (pubkey-scoped heuristic) is the
// correctness gate.
window.localStorage.clear();
window.sessionStorage.clear();
})
.catch((err: unknown) => {
setIsSignOutPending(false);
setIsSignOutOpen(false);
toast.error(
err instanceof Error ? err.message : "Sign out failed.",
);
});
}}
>
{isSignOutPending ? "Signing out…" : "Delete My Data"}
Expand Down
Loading
Loading