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
@@ -13,6 +13,9 @@ Accepted and implemented through Stage C1 and two bounded Stage C2
landing-page improvements. The shared specification remains deliberately
limited to one-key, immediately persisted controls. Complex pages retain their
existing renderers instead of being forced through a general abstraction.
Settings pending application which require database initialisation now delegate
their decision, scheduling, and restart boundary to `SetupManager` and
`Rebuilder`.
## Context
@@ -502,6 +505,29 @@ conversion should be justified by useful settings-search coverage and retain
the catalogue, persistence owner, and refresh boundaries established by Stage
C1.
### Centralise initialisation for settings pending application
Settings which cannot take effect safely through immediate persistence remain
in the settings tab's editing buffer. Their Apply action delegates to a focused
`SetupManager` dialogue which asks whether the next start should use existing
synchronisation data or the files in the current Vault. `SetupManager` reports
user cancellation and validation or reservation failure distinctly instead of
collapsing them into a boolean setup outcome. A settings-persistence exception
still propagates after `Rebuilder` removes the reserved flag.
`Rebuilder.scheduleFetch()` and `Rebuilder.scheduleRebuild()` are the only
owners of the corresponding flag files. They reserve the next-start operation
before the callback persists the edited settings, remove the flag if that
callback fails, and request the restart only after preparation succeeds. The
settings tab must not write those flag files or request the restart directly.
A user cancellation returns to a separate confirmation which offers either to
keep editing or to apply the settings without initialisation. This preserves
the former advanced fallback without presenting it as an equal data-source
choice. A validation or flag-reservation failure is not a cancellation and
must not offer that bypass. The pending action is present on the native root
settings page as well as within custom and native child pages.
## Verification
Stage A runs the maintained onboarding E2E scenario and an ordinary settings
@@ -533,6 +559,12 @@ Stage C1 and the landing-page focused unit tests verify:
- the three Extra menus controls are registered once and refresh page
visibility after persistence;
- every standard setting key is registered once;
- a setting pending application exposes its Apply action on the native root
page;
- cancelling the initialisation choice preserves the editing buffer, while the
separately confirmed settings-only path persists it;
- Fetch and Rebuild reserve their flag through `Rebuilder` before pending
settings are persisted, and a reservation failure leaves them unapplied;
- reads use the editing buffer;
- writes use `saveSettings([key])` and never `plugin.settings`;
- custom pages dispose their page-owned resources and do not duplicate saved
@@ -556,6 +588,13 @@ Real-Obsidian verification on 1.13 or later confirms:
- no duplicate save, update handler, or saved-setting effect occurs after
leaving and reopening a page.
The maintained real-Obsidian settings scenario also changes a setting which
remains pending until initialisation, captures the source-choice and
settings-only fallback dialogues, and confirms that keeping the setting
pending does not persist it. It mounts the P2P variant directly to confirm that
it offers a source device and local Vault preparation without presenting a
central-server overwrite operation.
Because the manifest continues to support earlier Obsidian versions, a
pre-1.13 real-runtime smoke test must confirm that the imperative fallback
still opens, navigates, saves one Advanced value, and opens one custom page. If
+22
View File
@@ -28,6 +28,28 @@ The following status applies to optional and compatibility features in the 1.0 l
| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 for CouchDB | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. |
| Compatibility only | V1 dynamic iteration counts, the old IndexedDB adapter, non-current hash algorithms, Eden chunks, and the stored `doNotUseFixedRevisionForChunks` key | Existing settings and data remain readable. New Vaults use the current defaults, and compatibility controls are shown only where a migration or recovery path still needs them. |
### Apply changes which require initialisation
Some compatibility settings are not saved immediately. They remain pending
until **Apply** is selected. The Apply action remains visible on the root
settings page and on the relevant child page. The following dialogue asks which
existing data should be used after restarting:
- **Reset Synchronisation on This Device** reconstructs this device's local
database from the configured remote. For P2P, an online source device is
selected after restart.
- **Overwrite Server Data with This Device's Files** reconstructs the local and
remote databases from this Vault. The P2P equivalent prepares only this
device from its current Vault files.
- **Review another way to apply these settings** returns to a separate choice
between keeping the changes pending and applying them without initialisation.
Applying them alone is an advanced compatibility fallback and can make the
device incompatible with its existing synchronisation data.
LiveSync reserves the selected next-start operation before saving the pending
settings. If validation or that reservation fails, the settings remain
unapplied and the settings-only fallback is not offered.
| Icon | Description |
| :--: | ------------------------------------------------------------------ |
| 💬 | [0. Change Log](#0-change-log) |
@@ -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
+211
View File
@@ -19,6 +19,7 @@ import type { Locator } from "playwright";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true";
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const settingsInitialisationRunStateKey = "__livesyncE2ESettingsInitialisation";
const settingsScreenshotOptions = {
animations: "disabled" as const,
style: ".notice-container { visibility: hidden !important; }",
@@ -27,6 +28,16 @@ const compatibilityReviewMessage = "Review the internal database compatibility c
type LiveSyncTestPlugin = {
core: {
modules: {
constructor: { name: string };
applySettingsWithInitialisationChoice?: (options: {
applySettings: () => Promise<void>;
isP2P: boolean;
}) => Promise<unknown>;
}[];
settings: {
handleFilenameCaseSensitive: boolean;
};
services: {
setting: {
currentSettings(): {
@@ -44,6 +55,12 @@ type LiveSyncTestPlugin = {
};
};
type SettingsInitialisationRunState = {
done: boolean;
error?: string;
result?: unknown;
};
type ObsidianTestApp = {
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
};
@@ -155,6 +172,96 @@ async function captureDeclarativeMobileLanding(): Promise<string | undefined> {
}
}
async function openSettingsInitialisationDialogueForInspection(isP2P: boolean): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(
({ stateKey, isP2P }) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
if (typeof manager?.applySettingsWithInitialisationChoice !== "function") {
throw new Error("Could not find the pending-settings initialisation workflow");
}
const state: SettingsInitialisationRunState = { done: false };
(globalThis as unknown as Record<string, SettingsInitialisationRunState>)[stateKey] = state;
void manager
.applySettingsWithInitialisationChoice({
isP2P,
applySettings: Promise.resolve.bind(Promise),
})
.then(
(result) => {
state.result = result;
state.done = true;
},
(error: unknown) => {
state.error = error instanceof Error ? error.message : String(error);
state.done = true;
}
);
},
{ stateKey: settingsInitialisationRunStateKey, isP2P }
);
});
}
async function assertSettingsInitialisationRunCancelled(): Promise<void> {
const state = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, SettingsInitialisationRunState | undefined>)[stateKey]
?.done === true,
settingsInitialisationRunStateKey,
{ timeout: uiTimeoutMs }
);
return await page.evaluate(
(stateKey) =>
(globalThis as unknown as Record<string, SettingsInitialisationRunState | undefined>)[stateKey],
settingsInitialisationRunStateKey
);
});
if (!state) throw new Error("The pending-settings initialisation dialogue did not record its result.");
if (state.error) throw new Error(`The pending-settings initialisation dialogue failed: ${state.error}`);
if (JSON.stringify(state.result) !== JSON.stringify({ result: "cancelled" })) {
throw new Error(`The pending-settings initialisation dialogue returned ${JSON.stringify(state.result)}.`);
}
}
async function captureP2PSettingsInitialisationDialogue(): Promise<string> {
await openSettingsInitialisationDialogueForInspection(true);
const path = `${diagnosticsDirectory}/settings-initialisation-p2p.png`;
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const dialogue = await waitForVisibleObsidianDialogue(
page,
"Apply Settings and Reinitialise Synchronisation",
uiTimeoutMs
);
await dialogue.getByText("Prepare This Device from This Vault", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await dialogue.getByText("Reset Synchronisation on This Device", { exact: true }).click({
timeout: uiTimeoutMs,
});
await dialogue.getByRole("button", { name: "Restart and Select a Source Device", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if (
(await dialogue.getByText("Overwrite Server Data with This Device's Files", { exact: true }).count()) !== 0
) {
throw new Error("The P2P initialisation dialogue exposed the central-server overwrite operation.");
}
await dialogue.screenshot({ ...settingsScreenshotOptions, path });
await dialogue
.getByRole("button", { name: "Review another way to apply these settings", exact: true })
.click({ timeout: uiTimeoutMs });
await dialogue.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await assertSettingsInitialisationRunCancelled();
return path;
}
async function resumePendingCompatibilityReviewForSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const review = page.locator(".modal-container").filter({
@@ -544,6 +651,105 @@ async function verifyEffectiveSettings(): Promise<void> {
});
}
async function verifyPendingSettingsInitialisationFlow(): Promise<{ choice: string; fallback: string }> {
return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
if (settingsNavigator.renderer === "imperative") {
const general = await settingsNavigator.openPage("General Settings");
const edgeCaseMode = general.locator(".setting-item").filter({
has: settingsNavigator.page.getByText("Enable edge case treatment features", { exact: true }),
});
await edgeCaseMode.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
await page.waitForFunction(
() => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
return plugin?.core.services.setting.currentSettings().useEdgeCaseMode === true;
},
undefined,
{ timeout: uiTimeoutMs }
);
}
const patches = await settingsNavigator.openPage("Patches");
const caseSensitiveSetting = patches.locator(".setting-item").filter({
has: settingsNavigator.page.getByText("Handle files as Case-Sensitive", { exact: true }),
});
await caseSensitiveSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
const toggle = caseSensitiveSetting.locator(".checkbox-container");
if ((await toggle.evaluate((element) => element.classList.contains("is-enabled"))) === true) {
throw new Error("The pending-settings fixture expected case-sensitive file handling to be disabled.");
}
await toggle.click({ timeout: uiTimeoutMs });
if (settingsNavigator.renderer === "declarative") {
await settingsNavigator.returnToCatalogue();
}
const applySetting = settingsNavigator.dialogue
.locator(settingsNavigator.renderer === "declarative" ? ".setting-item" : ".sls-setting-menu-buttons")
.filter({
has: settingsNavigator.page.getByText("Changes need to be applied!", { exact: true }),
});
await applySetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
await applySetting.getByRole("button", { name: "Apply", exact: true }).click({ timeout: uiTimeoutMs });
const choiceDialogue = await waitForVisibleObsidianDialogue(
settingsNavigator.page,
"Apply Settings and Reinitialise Synchronisation",
uiTimeoutMs
);
await choiceDialogue.getByText("Reset Synchronisation on This Device", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await choiceDialogue
.getByText("Overwrite Server Data with This Device's Files", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await choiceDialogue.getByText("Reset Synchronisation on This Device", { exact: true }).click({
timeout: uiTimeoutMs,
});
await choiceDialogue
.getByRole("button", { name: "Restart and Fetch Synchronisation Data", exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const choice = `${diagnosticsDirectory}/settings-initialisation-choice.png`;
await choiceDialogue.screenshot({ ...settingsScreenshotOptions, path: choice });
await choiceDialogue
.getByRole("button", { name: "Review another way to apply these settings", exact: true })
.click({ timeout: uiTimeoutMs });
await choiceDialogue.waitFor({ state: "hidden", timeout: uiTimeoutMs });
const fallbackDialogue = await waitForVisibleObsidianDialogue(
settingsNavigator.page,
"Apply Settings without Initialisation?",
uiTimeoutMs
);
await fallbackDialogue
.getByRole("button", { name: "Apply without Initialisation", exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await fallbackDialogue.getByRole("button", { name: "Keep Editing", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const fallback = `${diagnosticsDirectory}/settings-initialisation-fallback.png`;
await fallbackDialogue.screenshot({ ...settingsScreenshotOptions, path: fallback });
await fallbackDialogue.getByRole("button", { name: "Keep Editing", exact: true }).click({
timeout: uiTimeoutMs,
});
await fallbackDialogue.waitFor({ state: "hidden", timeout: uiTimeoutMs });
const persistedCaseSensitivity = await page.evaluate(() => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
return plugin.core.settings.handleFilenameCaseSensitive;
});
if (persistedCaseSensitivity !== false) {
throw new Error("Cancelling initialisation unexpectedly persisted the pending case-sensitivity setting.");
}
await applySetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
await settingsNavigator.close();
return { choice, fallback };
});
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -593,8 +799,13 @@ async function main(): Promise<void> {
await verifyConfigDoctorFollowsCompatibilityReview();
}
await verifyEffectiveSettings();
const initialisation = await verifyPendingSettingsInitialisationFlow();
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
const mobileLanding = await captureDeclarativeMobileLanding();
if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`);
console.log(
`Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}`
);
console.log("Compatibility review and settings expose only effective user controls.");
} finally {
if (session) {
+1
View File
@@ -21,6 +21,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Improved
- Settings pages, General controls, Quick Setup actions, and Advanced controls now use Obsidian 1.13's native settings interface and global search, while retaining their familiar icons. The landing page keeps Remote Configuration and Sync Settings together, places Appearance, Logging, and Extra menus under General Settings, and groups maintenance, optional features, advanced settings, and help by purpose. Earlier supported Obsidian versions continue to use the pane-based interface.
- Settings changes which require database initialisation now use a focused Setup Manager dialogue to choose between existing synchronisation data and the files in the current Vault. The selected reset or rebuild is reserved before the settings are saved, while cancelling offers a separate, explicit settings-only fallback.
## 1.0.18