Route pending settings through initialisation choices

This commit is contained in:
vorotamoroz
2026-08-25 09:12:16 +00:00
parent 131ffeb0ef
commit f6eefed97c
14 changed files with 762 additions and 48 deletions
@@ -11715,6 +11715,72 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "覆盖",
"zh-tw": "覆寫",
},
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation": {
def: "Apply without Initialisation",
},
"Ui.SetupWizard.ApplySettingsInitialisation.Back": {
def: "Review another way to apply these settings",
},
"Ui.SetupWizard.ApplySettingsInitialisation.BypassGuidance": {
def: "Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.",
},
"Ui.SetupWizard.ApplySettingsInitialisation.BypassTitle": {
def: "Apply Settings without Initialisation?",
},
"Ui.SetupWizard.ApplySettingsInitialisation.ContinueFetch": {
def: "Continue with Fetch",
},
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOption": {
def: "Reset Synchronisation on This Device",
},
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionDesc": {
def: "After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.",
},
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionP2PDesc": {
def: "After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.",
},
"Ui.SetupWizard.ApplySettingsInitialisation.Guidance": {
def: "These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.",
},
"Ui.SetupWizard.ApplySettingsInitialisation.KeepEditing": {
def: "Keep Editing",
},
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetch": {
def: "Restart and Fetch Synchronisation Data",
},
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetchP2P": {
def: "Restart and Select a Source Device",
},
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuild": {
def: "Restart and Overwrite Server Data",
},
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuildP2P": {
def: "Restart and Prepare This Device",
},
"Ui.SetupWizard.ApplySettingsInitialisation.Question": {
def: "Which existing data should be used after restart?",
},
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOption": {
def: "Overwrite Server Data with This Device's Files",
},
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionDesc": {
def: "Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.",
},
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2P": {
def: "Prepare This Device from This Vault",
},
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2PDesc": {
def: "Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.",
},
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationGuidance": {
def: "The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.",
},
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationTitle": {
def: "Remote Synchronisation Data Could Not Be Verified",
},
"Ui.SetupWizard.ApplySettingsInitialisation.Title": {
def: "Apply Settings and Reinitialise Synchronisation",
},
"Ui.SetupWizard.Common.Back": {
def: "No, please take me back",
es: "No, volver atrás",
+22
View File
@@ -1321,6 +1321,28 @@
"Ui.Settings.SyncSettings.Fetch": "Fetch",
"Ui.Settings.SyncSettings.Merge": "Merge",
"Ui.Settings.SyncSettings.Overwrite": "Overwrite",
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation": "Apply without Initialisation",
"Ui.SetupWizard.ApplySettingsInitialisation.Back": "Review another way to apply these settings",
"Ui.SetupWizard.ApplySettingsInitialisation.BypassGuidance": "Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.",
"Ui.SetupWizard.ApplySettingsInitialisation.BypassTitle": "Apply Settings without Initialisation?",
"Ui.SetupWizard.ApplySettingsInitialisation.ContinueFetch": "Continue with Fetch",
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOption": "Reset Synchronisation on This Device",
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionDesc": "After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.",
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionP2PDesc": "After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.",
"Ui.SetupWizard.ApplySettingsInitialisation.Guidance": "These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.",
"Ui.SetupWizard.ApplySettingsInitialisation.KeepEditing": "Keep Editing",
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetch": "Restart and Fetch Synchronisation Data",
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetchP2P": "Restart and Select a Source Device",
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuild": "Restart and Overwrite Server Data",
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuildP2P": "Restart and Prepare This Device",
"Ui.SetupWizard.ApplySettingsInitialisation.Question": "Which existing data should be used after restart?",
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOption": "Overwrite Server Data with This Device's Files",
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionDesc": "Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.",
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2P": "Prepare This Device from This Vault",
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2PDesc": "Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.",
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationGuidance": "The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.",
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationTitle": "Remote Synchronisation Data Could Not Be Verified",
"Ui.SetupWizard.ApplySettingsInitialisation.Title": "Apply Settings and Reinitialise Synchronisation",
"Ui.SetupWizard.Common.Back": "No, please take me back",
"Ui.SetupWizard.Common.Cancel": "Cancel",
"Ui.SetupWizard.Common.ProceedSelectOption": "Please select an option to proceed",
+23
View File
@@ -2309,6 +2309,29 @@ Ui:
Fetch: Fetch
Overwrite: Overwrite
SetupWizard:
ApplySettingsInitialisation:
ApplyWithoutInitialisation: Apply without Initialisation
Back: Review another way to apply these settings
BypassGuidance: Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.
BypassTitle: Apply Settings without Initialisation?
ContinueFetch: Continue with Fetch
FetchOption: Reset Synchronisation on This Device
FetchOptionDesc: After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.
FetchOptionP2PDesc: After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.
Guidance: These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.
KeepEditing: Keep Editing
ProceedFetch: Restart and Fetch Synchronisation Data
ProceedFetchP2P: Restart and Select a Source Device
ProceedRebuild: Restart and Overwrite Server Data
ProceedRebuildP2P: Restart and Prepare This Device
Question: Which existing data should be used after restart?
RebuildOption: Overwrite Server Data with This Device's Files
RebuildOptionDesc: Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.
RebuildOptionP2P: Prepare This Device from This Vault
RebuildOptionP2PDesc: Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.
RemoteVerificationGuidance: The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.
RemoteVerificationTitle: Remote Synchronisation Data Could Not Be Verified
Title: Apply Settings and Reinitialise Synchronisation
Common:
Back: No, please take me back
Cancel: Cancel
@@ -121,6 +121,10 @@ function isGroup(item: SettingDefinitionItem): item is SettingDefinitionGroup {
return "type" in item && item.type === "group";
}
function isAction(item: SettingDefinitionItem): item is Extract<SettingDefinitionItem, { action: unknown }> {
return "action" in item && typeof item.action === "function";
}
function itemLabel(item: SettingDefinitionItem): string {
if (isPage(item)) return item.name;
if (isGroup(item)) return item.heading ?? "";
@@ -179,7 +183,7 @@ beforeEach(() => {
describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
it("keeps Quick Setup first while synchronisation is inactive and separates synchronisation pages from it", () => {
const tab = createSettingsTab();
const definitions = tab.getSettingDefinitions();
const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
"🧙‍♂️ Quick Setup",
@@ -191,7 +195,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
it("keeps the synchronisation group first and orders General Settings before Quick Setup while synchronisation is active", () => {
const tab = createSettingsTab();
tab.editingSettings.liveSync = true;
const definitions = tab.getSettingDefinitions();
const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
"🔄 Synchronisation",
@@ -200,6 +204,16 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
]);
});
it("keeps the pending initialisation action visible on the root settings page", () => {
const tab = createSettingsTab();
tab.editingSettings.handleFilenameCaseSensitive = !tab.initialSettings!.handleFilenameCaseSensitive;
const action = tab.getSettingDefinitions().find(isAction);
expect(action?.name).toBe("Apply");
expect(typeof action?.visible === "function" ? action.visible() : action?.visible).toBe(true);
});
it("keeps Remote Configuration and Sync Settings as native pages inside the Synchronisation group", () => {
const tab = createSettingsTab();
const definitions = tab.getSettingDefinitions();
@@ -3,7 +3,6 @@ import {
type ObsidianLiveSyncSettings,
type RemoteDBSettings,
LOG_LEVEL_NOTICE,
FlagFilesHumanReadable,
REMOTE_COUCHDB,
REMOTE_MINIO,
type ConfigLevel,
@@ -79,6 +78,7 @@ import type {
} from "obsidian";
import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts";
import { SetupManager } from "@/modules/features/SetupManager.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
// For creating a document
// const toc = new Set<string>();
@@ -907,10 +907,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
]);
const laterGroups = [setupOtherDevices, maintenance, extraFeatures, advancedSettings, helpAndInformation];
const pendingInitialisation = this.createRebuildRequiredAction();
if (this.isAnySyncEnabled()) {
return [synchronisation, generalSettings, quickSetup, ...laterGroups];
return [pendingInitialisation, synchronisation, generalSettings, quickSetup, ...laterGroups];
}
return [quickSetup, synchronisation, generalSettings, ...laterGroups];
return [pendingInitialisation, quickSetup, synchronisation, generalSettings, ...laterGroups];
}
private beginRenderScope(refresh: () => void): Component {
@@ -1031,50 +1032,53 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
Logger(`Passphrase is not valid, please fix it.`, LOG_LEVEL_NOTICE);
return;
}
const OPTION_FETCH = $msg("obsidianLiveSyncSettingTab.optionFetchFromRemote");
const OPTION_REBUILD_BOTH = $msg("obsidianLiveSyncSettingTab.optionRebuildBoth");
const OPTION_ONLY_SETTING = $msg("obsidianLiveSyncSettingTab.optionSaveOnlySettings");
const OPTION_CANCEL = $msg("obsidianLiveSyncSettingTab.optionCancel");
const title = $msg("obsidianLiveSyncSettingTab.titleRebuildRequired");
const note = $msg("obsidianLiveSyncSettingTab.msgRebuildRequired", {
OPTION_REBUILD_BOTH,
OPTION_FETCH,
OPTION_ONLY_SETTING,
const keepEditing = $msg("Ui.SetupWizard.ApplySettingsInitialisation.KeepEditing");
const setupManager = this.core.getModule(SetupManager);
const result = await setupManager.applySettingsWithInitialisationChoice({
isP2P: isP2PMainRemote(this.editingSettings),
validateChoice: async (mode) => {
if (mode !== "fetch" || (await this.checkWorkingPassphrase())) {
return true;
}
const continueFetch = $msg("Ui.SetupWizard.ApplySettingsInitialisation.ContinueFetch");
return (
(await this.core.confirm.confirmWithMessage(
$msg("Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationTitle"),
$msg("Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationGuidance"),
[continueFetch, keepEditing],
keepEditing
)) === continueFetch
);
},
applySettings: async () => {
if (!this.editingSettings.encrypt) {
this.editingSettings.passphrase = "";
}
await this.saveAllDirtySettings();
},
});
const buttons = [
OPTION_FETCH,
OPTION_REBUILD_BOTH, // OPTION_REBUILD_REMOTE,
OPTION_ONLY_SETTING,
OPTION_CANCEL,
];
const result = await this.core.confirm.confirmWithMessage(title, note, buttons, OPTION_CANCEL);
if (result == OPTION_CANCEL) return;
if (result == OPTION_FETCH) {
if (!(await this.checkWorkingPassphrase())) {
if (
(await this.core.confirm.askYesNoDialog($msg("obsidianLiveSyncSettingTab.msgAreYouSureProceed"), {
defaultOption: "No",
})) != "yes"
)
return;
if (result.result === "scheduled") {
this.closeSetting();
return;
}
if (result.result === "failed") {
return;
}
const applyWithoutInitialisation = $msg(
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation"
);
const fallback = await this.core.confirm.confirmWithMessage(
$msg("Ui.SetupWizard.ApplySettingsInitialisation.BypassTitle"),
$msg("Ui.SetupWizard.ApplySettingsInitialisation.BypassGuidance"),
[applyWithoutInitialisation, keepEditing],
keepEditing
);
if (fallback === applyWithoutInitialisation) {
if (!this.editingSettings.encrypt) {
this.editingSettings.passphrase = "";
}
}
if (!this.editingSettings.encrypt) {
this.editingSettings.passphrase = "";
}
await this.saveAllDirtySettings();
await Promise.resolve(this.applyAllSettings());
if (result == OPTION_FETCH) {
await this.core.storageAccess.writeFileAuto(FlagFilesHumanReadable.FETCH_ALL, "");
this.services.appLifecycle.scheduleRestart();
this.closeSetting();
// await rebuildDB("localOnly");
} else if (result == OPTION_REBUILD_BOTH) {
await this.core.storageAccess.writeFileAuto(FlagFilesHumanReadable.REBUILD_ALL, "");
this.services.appLifecycle.scheduleRestart();
this.closeSetting();
} else if (result == OPTION_ONLY_SETTING) {
await this.services.setting.saveSettingData();
await this.saveAllDirtySettings();
}
}
@@ -1,9 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
const negotiationMocks = vi.hoisted(() => ({
checkSyncInfo: vi.fn(async () => true),
}));
const settingsInitialisationMocks = vi.hoisted(() => ({
applySettingsWithInitialisationChoice: vi.fn(),
}));
vi.mock("@/deps.ts", () => ({
App: class {},
@@ -63,6 +66,10 @@ vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
beforeEach(() => {
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
});
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
it("closes the finite remote connection after checking synchronisation information", async () => {
const remoteDatabase = {
@@ -95,6 +102,116 @@ describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
});
});
describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
function createSettingsTab() {
const saveSettingData = vi.fn(async () => undefined);
const confirmWithMessage = vi.fn();
const plugin = {
app: {},
core: {
settings: {
...DEFAULT_SETTINGS,
handleFilenameCaseSensitive: false,
},
getModule: vi.fn(() => settingsInitialisationMocks),
confirm: {
confirmWithMessage,
},
services: {
setting: {
saveSettingData,
getDeviceAndVaultName: vi.fn(() => ""),
},
},
},
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, {
_editingSettings: {
...DEFAULT_SETTINGS,
handleFilenameCaseSensitive: true,
},
initialSettings: {
...DEFAULT_SETTINGS,
handleFilenameCaseSensitive: false,
},
});
vi.spyOn(tab, "isPassphraseValid").mockResolvedValue(true);
vi.spyOn(tab, "checkWorkingPassphrase").mockResolvedValue(true);
const closeSetting = vi.spyOn(tab, "closeSetting").mockImplementation(() => undefined);
return { tab, saveSettingData, confirmWithMessage, closeSetting };
}
it("keeps pending settings in the editing buffer when initialisation and the fallback are cancelled", async () => {
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
result: "cancelled",
});
confirmWithMessage.mockResolvedValueOnce("Keep Editing");
await tab.confirmRebuild();
expect(settingsInitialisationMocks.applySettingsWithInitialisationChoice).toHaveBeenCalledOnce();
expect(confirmWithMessage).toHaveBeenCalledWith(
"Apply Settings without Initialisation?",
expect.any(String),
["Apply without Initialisation", "Keep Editing"],
"Keep Editing"
);
expect(saveSettingData).not.toHaveBeenCalled();
expect(tab.editingSettings.handleFilenameCaseSensitive).toBe(true);
expect(tab.core.settings.handleFilenameCaseSensitive).toBe(false);
expect(closeSetting).not.toHaveBeenCalled();
});
it("applies pending settings only after a separately confirmed initialisation bypass", async () => {
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
result: "cancelled",
});
confirmWithMessage.mockResolvedValueOnce("Apply without Initialisation");
await tab.confirmRebuild();
expect(settingsInitialisationMocks.applySettingsWithInitialisationChoice).toHaveBeenCalledOnce();
expect(saveSettingData).toHaveBeenCalledOnce();
expect(tab.core.settings.handleFilenameCaseSensitive).toBe(true);
expect(closeSetting).not.toHaveBeenCalled();
});
it("closes settings only after initialisation has been scheduled", async () => {
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockImplementationOnce(
async ({ applySettings }: { applySettings: () => Promise<void> }) => {
await applySettings();
return { result: "scheduled", mode: "rebuild" };
}
);
await tab.confirmRebuild();
expect(saveSettingData).toHaveBeenCalledOnce();
expect(confirmWithMessage).not.toHaveBeenCalled();
expect(closeSetting).toHaveBeenCalledOnce();
});
it("does not offer the settings-only fallback after an initialisation failure", async () => {
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
result: "failed",
mode: "fetch",
});
await tab.confirmRebuild();
expect(saveSettingData).not.toHaveBeenCalled();
expect(confirmWithMessage).not.toHaveBeenCalled();
expect(tab.editingSettings.handleFilenameCaseSensitive).toBe(true);
expect(tab.core.settings.handleFilenameCaseSensitive).toBe(false);
expect(closeSetting).not.toHaveBeenCalled();
});
});
describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
function createSettingsTab() {
const saveSettingData = vi.fn(async () => undefined);
+41
View File
@@ -19,6 +19,7 @@ import UseSetupURI from "./SetupWizard/dialogs/UseSetupURI.svelte";
import OutroNewUser from "./SetupWizard/dialogs/OutroNewUser.svelte";
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
import ApplySettingsInitialisation from "./SetupWizard/dialogs/ApplySettingsInitialisation.svelte";
import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
@@ -38,10 +39,13 @@ import type {
SetupRemoteP2PResultType,
SetupRemoteResultType,
UseSetupURIResultType,
ApplySettingsInitialisationResultType,
ApplySettingsInitialisationInitialData,
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
import {
applySettingsAndFetchOnActivation,
applySettingsWithScheduledInitialisation,
type SetupInitialisationMode,
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
@@ -75,6 +79,17 @@ export const enum UserMode {
Update = "unknown", // Alias for Unknown for better readability
}
export type SettingsInitialisationApplicationResult =
| { result: "scheduled"; mode: SetupInitialisationMode }
| { result: "cancelled" }
| { result: "failed"; mode: SetupInitialisationMode };
export type ApplySettingsWithInitialisationChoiceOptions = {
applySettings: () => Promise<void>;
isP2P: boolean;
validateChoice?: (mode: SetupInitialisationMode) => Promise<boolean>;
};
/**
* Setup Manager to handle onboarding and configuration setup
*/
@@ -87,6 +102,32 @@ export class SetupManager extends AbstractModule {
return this.services.UI.dialogManager;
}
/**
* Ask which existing data should be authoritative for pending setting changes,
* then reserve the matching next-start operation before applying them.
*
* Cancellation and reservation failure remain distinct so the caller may
* offer an explicit settings-only fallback only after a user cancellation.
*/
async applySettingsWithInitialisationChoice({
applySettings,
isP2P,
validateChoice = () => Promise.resolve(true),
}: ApplySettingsWithInitialisationChoiceOptions): Promise<SettingsInitialisationApplicationResult> {
const mode = await this.dialogManager.openWithExplicitCancel<
ApplySettingsInitialisationResultType,
ApplySettingsInitialisationInitialData
>(ApplySettingsInitialisation, { isP2P });
if (mode === "cancelled") {
return { result: "cancelled" };
}
if (!(await validateChoice(mode))) {
return { result: "failed", mode };
}
const scheduled = await applySettingsWithScheduledInitialisation(this.core.rebuilder, mode, applySettings);
return scheduled ? { result: "scheduled", mode } : { result: "failed", mode };
}
/**
* Starts the onboarding process
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
@@ -17,6 +17,7 @@ vi.mock("./SetupWizard/dialogs/UseSetupURI.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/OutroNewUser.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/OutroExistingUser.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/OutroAskUserMode.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/ApplySettingsInitialisation.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
@@ -273,6 +274,64 @@ describe("SetupManager", () => {
expect(setting.currentSettings().isConfigured).toBe(false);
});
it("reports cancellation separately when applying settings which require initialisation", async () => {
const { manager, dialogManager, core } = createSetupManager();
const applySettings = vi.fn(async () => undefined);
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
const result = await manager.applySettingsWithInitialisationChoice({ applySettings, isP2P: true });
expect(result).toEqual({ result: "cancelled" });
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), { isP2P: true });
expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
expect(core.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
expect(applySettings).not.toHaveBeenCalled();
});
it("reserves the selected initialisation before applying pending settings", async () => {
const { manager, dialogManager, core } = createSetupManager();
const applySettings = vi.fn(async () => undefined);
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("fetch");
const result = await manager.applySettingsWithInitialisationChoice({ applySettings, isP2P: false });
expect(result).toEqual({ result: "scheduled", mode: "fetch" });
expect(core.rebuilder.scheduleFetch).toHaveBeenCalledWith(expect.any(Function));
expect(core.rebuilder.scheduleFetch.mock.invocationCallOrder[0]).toBeLessThan(
applySettings.mock.invocationCallOrder[0]
);
});
it("reports a failed reservation without applying pending settings", async () => {
const { manager, dialogManager, core } = createSetupManager();
const applySettings = vi.fn(async () => undefined);
core.rebuilder.scheduleRebuild.mockResolvedValueOnce(false);
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("rebuild");
const result = await manager.applySettingsWithInitialisationChoice({ applySettings, isP2P: false });
expect(result).toEqual({ result: "failed", mode: "rebuild" });
expect(applySettings).not.toHaveBeenCalled();
});
it("does not reserve initialisation when the selected source cannot be validated", async () => {
const { manager, dialogManager, core } = createSetupManager();
const applySettings = vi.fn(async () => undefined);
const validateChoice = vi.fn(async () => false);
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("fetch");
const result = await manager.applySettingsWithInitialisationChoice({
applySettings,
isP2P: false,
validateChoice,
});
expect(result).toEqual({ result: "failed", mode: "fetch" });
expect(validateChoice).toHaveBeenCalledWith("fetch");
expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
expect(applySettings).not.toHaveBeenCalled();
});
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const imported = {
@@ -0,0 +1,85 @@
<script lang="ts">
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
import { $msg as msg } from "@/common/translation";
import {
type ApplySettingsInitialisationInitialData,
type ApplySettingsInitialisationResultType,
TYPE_CANCELLED,
TYPE_FETCH,
TYPE_REBUILD,
} from "./setupDialogTypes";
type Props = {
setResult: (result: ApplySettingsInitialisationResultType) => void;
getInitialData?: () => ApplySettingsInitialisationInitialData | undefined;
};
const { setResult, getInitialData }: Props = $props();
const isP2P = $derived(getInitialData?.()?.isP2P === true);
let selectedMode = $state<ApplySettingsInitialisationResultType>(TYPE_CANCELLED);
const canProceed = $derived(selectedMode === TYPE_FETCH || selectedMode === TYPE_REBUILD);
const proceedMessage = $derived.by(() => {
if (selectedMode === TYPE_FETCH) {
return isP2P
? msg("Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetchP2P")
: msg("Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetch");
}
if (selectedMode === TYPE_REBUILD) {
return isP2P
? msg("Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuildP2P")
: msg("Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuild");
}
return msg("Ui.SetupWizard.Common.ProceedSelectOption");
});
</script>
<DialogHeader title={msg("Ui.SetupWizard.ApplySettingsInitialisation.Title")} />
<Guidance>
<p>{msg("Ui.SetupWizard.ApplySettingsInitialisation.Guidance")}</p>
</Guidance>
<Instruction>
<Question>{msg("Ui.SetupWizard.ApplySettingsInitialisation.Question")}</Question>
<Option
title={msg("Ui.SetupWizard.ApplySettingsInitialisation.FetchOption")}
bind:value={selectedMode}
selectedValue={TYPE_FETCH}
>
<InfoNote notice>
{isP2P
? msg("Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionP2PDesc")
: msg("Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionDesc")}
</InfoNote>
</Option>
<Option
title={isP2P
? msg("Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2P")
: msg("Ui.SetupWizard.ApplySettingsInitialisation.RebuildOption")}
bind:value={selectedMode}
selectedValue={TYPE_REBUILD}
>
<InfoNote warning={!isP2P} notice={isP2P}>
{isP2P
? msg("Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2PDesc")
: msg("Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionDesc")}
</InfoNote>
</Option>
</Instruction>
<UserDecisions>
<Decision
title={proceedMessage}
important={selectedMode !== TYPE_REBUILD || isP2P}
destructive={selectedMode === TYPE_REBUILD && !isP2P}
disabled={!canProceed}
commit={() => setResult(selectedMode)}
/>
<Decision
title={msg("Ui.SetupWizard.ApplySettingsInitialisation.Back")}
commit={() => setResult(TYPE_CANCELLED)}
/>
</UserDecisions>
@@ -28,6 +28,10 @@ export const TYPE_COMPATIBLE_EXISTING = "compatible-existing-user";
// OutroExistingUser
export const TYPE_APPLY = "apply";
// Applying pending settings which require database initialisation
export const TYPE_FETCH = "fetch";
export const TYPE_REBUILD = "rebuild";
// Select methods
export const TYPE_USE_SETUP_URI = "use-setup-uri";
export const TYPE_SCAN_QR_CODE = "scan-qr-code";
@@ -82,6 +86,12 @@ export type OutroExistingUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCEL
export type OutroNewUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
export type ApplySettingsInitialisationResultType = typeof TYPE_FETCH | typeof TYPE_REBUILD | typeof TYPE_CANCELLED;
export type ApplySettingsInitialisationInitialData = {
isP2P: boolean;
};
export type SelectMethodNewUserResultType =
| typeof TYPE_USE_SETUP_URI
| typeof TYPE_CONFIGURE_MANUALLY