feat: restore opt-in first-run onboarding

This commit is contained in:
vorotamoroz
2026-07-20 11:28:41 +00:00
parent f70779c352
commit 52e7bd25da
12 changed files with 511 additions and 40 deletions
@@ -0,0 +1,39 @@
export interface ConfiguredStartupLifecycleRuntime {
databaseReady: boolean;
reportDatabaseNotReady(): void;
hasCompromisedChunks(): Promise<boolean>;
hasIncompleteDocuments(): Promise<boolean>;
runDoctor(): Promise<boolean>;
migrateBulkSend(): Promise<void>;
}
export interface StartupEntryLifecycleRuntime {
configured: boolean;
inviteToOnboarding(): void;
}
/**
* Keeps an unconfigured Vault outside database initialisation and all
* configured-only start-up work while offering an explicit setup action.
*/
export function runStartupEntryLifecycle(runtime: StartupEntryLifecycleRuntime): boolean {
if (runtime.configured) return true;
runtime.inviteToOnboarding();
return false;
}
/**
* Separates the inert, unconfigured startup path from checks which must run
* before an already configured device is allowed to synchronise.
*/
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleRuntime): Promise<boolean> {
if (!runtime.databaseReady) {
runtime.reportDatabaseNotReady();
return false;
}
if (!(await runtime.hasCompromisedChunks())) return false;
if (!(await runtime.hasIncompleteDocuments())) return false;
if (!(await runtime.runDoctor())) return false;
await runtime.migrateBulkSend();
return true;
}
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import {
runConfiguredStartupLifecycle,
runStartupEntryLifecycle,
type ConfiguredStartupLifecycleRuntime,
} from "./configuredStartupLifecycle";
function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[] } {
const events: string[] = [];
return {
events,
databaseReady: true,
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
hasCompromisedChunks: vi.fn(async () => {
events.push("compromised-chunks");
return true;
}),
hasIncompleteDocuments: vi.fn(async () => {
events.push("incomplete-documents");
return true;
}),
runDoctor: vi.fn(async () => {
events.push("doctor");
return true;
}),
migrateBulkSend: vi.fn(async () => {
events.push("bulk-send");
}),
};
}
describe("runConfiguredStartupLifecycle", () => {
it("runs configured checks in order before allowing initialisation", async () => {
const runtime = createRuntime();
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]);
});
it("stops before onboarding or checks when the database is unavailable", async () => {
const runtime = createRuntime();
runtime.databaseReady = false;
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
expect(runtime.events).toEqual(["database-not-ready"]);
});
it("stops the configured sequence at the first failed check", async () => {
const runtime = createRuntime();
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
runtime.events.push("incomplete-documents");
return false;
});
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents"]);
});
});
describe("runStartupEntryLifecycle", () => {
it("offers onboarding and stops before database initialisation on an unconfigured Vault", () => {
const inviteToOnboarding = vi.fn();
expect(
runStartupEntryLifecycle({
configured: false,
inviteToOnboarding,
})
).toBe(false);
expect(inviteToOnboarding).toHaveBeenCalledOnce();
});
it("allows a configured Vault to continue to database initialisation", () => {
const inviteToOnboarding = vi.fn();
expect(
runStartupEntryLifecycle({
configured: true,
inviteToOnboarding,
})
).toBe(true);
expect(inviteToOnboarding).not.toHaveBeenCalled();
});
});
@@ -1,8 +1,38 @@
import { type SetupManager, UserMode } from "@/modules/features/SetupManager";
import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types";
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import {
EVENT_REQUEST_OPEN_P2P_SETTINGS,
EVENT_REQUEST_OPEN_SETUP_URI,
} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
const ONBOARDING_NOTICE_DURATION_MS = 60_000;
export async function openOnboarding(setupManager: SetupManager) {
return await setupManager.startOnBoarding();
}
export function showOnboardingInvitation(host: NecessaryServices<"UI", never>, setupManager: SetupManager): void {
const message = `${$msg("Welcome to Self-hosted LiveSync")} ${$msg(
"We will now guide you through a few questions to simplify the synchronisation setup."
)} {HERE}`;
host.services.UI.confirm.askInPopup(
"initial-onboarding",
message,
(anchor) => {
anchor.href = "#";
anchor.classList.add("sls-onboarding-invitation-action");
anchor.textContent = $msg("Ui.SetupWizard.Invitation.Start");
anchor.addEventListener("click", (event) => {
event.preventDefault();
fireAndForget(() => openOnboarding(setupManager));
});
},
ONBOARDING_NOTICE_DURATION_MS
);
}
export async function openSetupURI(setupManager: SetupManager) {
await setupManager.onUseSetupURI(UserMode.Unknown);
@@ -17,6 +47,11 @@ export function useSetupManagerHandlersFeature(
setupManager: SetupManager
) {
host.services.appLifecycle.onLoaded.addHandler(() => {
host.services.API.addCommand({
id: "livesync-open-onboarding",
name: "Open onboarding wizard",
callback: () => fireAndForget(() => openOnboarding(setupManager)),
});
host.services.API.addCommand({
id: "livesync-opensetupuri",
name: "Use the copied setup URI (Formerly Open setup URI)",
@@ -1,6 +1,15 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { openP2PSettings, openSetupURI, useSetupManagerHandlersFeature } from "./setupManagerHandlers";
import {
EVENT_REQUEST_OPEN_P2P_SETTINGS,
EVENT_REQUEST_OPEN_SETUP_URI,
} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import {
openOnboarding,
openP2PSettings,
openSetupURI,
showOnboardingInvitation,
useSetupManagerHandlersFeature,
} from "./setupManagerHandlers";
vi.mock("@/modules/features/SetupManager", () => {
return {
@@ -43,6 +52,63 @@ describe("setupObsidian/setupManagerHandlers", () => {
expect(setupManager.onP2PManualSetup).toHaveBeenCalledWith("unknown", settings, false);
});
it("openOnboarding should delegate to SetupManager.startOnBoarding", async () => {
const setupManager = {
startOnBoarding: vi.fn(async () => await Promise.resolve(false)),
} as any;
await openOnboarding(setupManager);
expect(setupManager.startOnBoarding).toHaveBeenCalledOnce();
});
it("showOnboardingInvitation should wait for its fixed action link before opening onboarding", async () => {
let configureAnchor: ((anchor: HTMLAnchorElement) => void) | undefined;
const askInPopup = vi.fn(
(_key: string, _text: string, callback: (anchor: HTMLAnchorElement) => void, _durationMs?: number) => {
configureAnchor = callback;
}
);
const host = {
services: {
UI: { confirm: { askInPopup } },
},
} as any;
const setupManager = {
startOnBoarding: vi.fn(async () => await Promise.resolve(false)),
} as any;
showOnboardingInvitation(host, setupManager);
expect(setupManager.startOnBoarding).not.toHaveBeenCalled();
expect(askInPopup).toHaveBeenCalledWith(
"initial-onboarding",
expect.stringContaining("{HERE}"),
expect.any(Function),
60_000
);
let click: ((event: { preventDefault(): void }) => void) | undefined;
const addClass = vi.fn();
const anchor = {
href: "",
textContent: "",
classList: { add: addClass },
addEventListener: vi.fn((_name: string, listener: typeof click) => {
click = listener;
}),
} as unknown as HTMLAnchorElement;
configureAnchor!(anchor);
expect(anchor.href).toBe("#");
expect(anchor.textContent).toBe("Start setup");
expect(addClass).toHaveBeenCalledWith("sls-onboarding-invitation-action");
const preventDefault = vi.fn();
click!({ preventDefault });
await vi.waitFor(() => expect(setupManager.startOnBoarding).toHaveBeenCalledOnce());
expect(preventDefault).toHaveBeenCalledOnce();
});
it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => {
const addHandler = vi.fn();
const addCommand = vi.fn();
@@ -54,6 +120,11 @@ describe("setupObsidian/setupManagerHandlers", () => {
API: {
addCommand,
},
UI: {
confirm: {
askInPopup: vi.fn(),
},
},
appLifecycle: {
onLoaded: {
addHandler,
@@ -65,6 +136,7 @@ describe("setupObsidian/setupManagerHandlers", () => {
},
} as any;
const setupManager = {
startOnBoarding: vi.fn(async () => await Promise.resolve(false)),
onUseSetupURI: vi.fn(async () => await Promise.resolve(true)),
onP2PManualSetup: vi.fn(async () => await Promise.resolve(true)),
} as any;
@@ -75,6 +147,12 @@ describe("setupObsidian/setupManagerHandlers", () => {
const loadedHandler = addHandler.mock.calls[0][0] as () => Promise<boolean>;
await loadedHandler();
expect(addCommand).toHaveBeenCalledWith(
expect.objectContaining({
id: "livesync-open-onboarding",
name: "Open onboarding wizard",
})
);
expect(addCommand).toHaveBeenCalledWith(
expect.objectContaining({
id: "livesync-opensetupuri",