Compare commits

..
8 Commits
Author SHA1 Message Date
vorotamoroz 0b1f5ca719 Merge pull request #1137 from vrtmrz/1_0_20
Releasing 1.0.20
2026-08-26 00:06:07 +09:00
vorotamoroz 9de4f2952d Polish 1.0.20 release notes 2026-08-25 14:44:31 +00:00
github-actions[bot] 330f2acd42 Releasing 1.0.20 2026-08-25 14:40:38 +00:00
vorotamoroz 1a84b08de5 Merge pull request #1136 from vrtmrz/fix/declarative-settings-startup-evaluation
Fix premature settings evaluation at start-up
2026-08-25 23:25:13 +09:00
vorotamoroz ce7988fe7c Document settings registration lifecycle 2026-08-25 13:57:04 +00:00
vorotamoroz 537e0e42c3 Fix premature settings evaluation at startup 2026-08-25 13:57:04 +00:00
vorotamoroz a5e1acb960 Merge pull request #1135 from vrtmrz/release/reconcile-1.0.19
Record published 1.0.19 pre-release metadata
2026-08-25 22:55:42 +09:00
vorotamoroz b8191e548d Record 1.0.19 pre-release metadata 2026-08-25 13:25:47 +00:00
16 changed files with 317 additions and 100 deletions
+6
View File
@@ -245,6 +245,12 @@ export class ModuleExample extends AbstractObsidianModule {
- Settings are defined by Commonlib (`ObsidianLiveSyncSettings`)
- Configuration metadata is supplied by the Commonlib settings exports
- Obsidian may request declarative definitions immediately from
`Plugin.addSettingTab()`. Register a settings tab which reads persisted values
from the sequential `onSettingLoaded` lifecycle, seed its editing snapshot
before registration, and keep definition construction independent of local
database and replicator readiness. See
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
### Database Operations
@@ -1,7 +1,7 @@
---
date: 2026-08-25
commonlib-version: "0.1.19"
self-hosted-livesync-version: "1.0.18"
self-hosted-livesync-version: "1.0.20"
status: accepted
---
@@ -15,7 +15,8 @@ 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`.
`Rebuilder`. Setting-tab registration and definition construction also follow
the persisted-settings lifecycle rather than transient runtime readiness.
## Context
@@ -25,6 +26,15 @@ native rendering, validation, navigation, and global settings search. When the
method returns a non-empty array, Obsidian does not call the existing
`display()` implementation.
Obsidian may call `getSettingDefinitions()` as soon as a tab is passed to
`Plugin.addSettingTab()`. Registering the tab during initialisation therefore
allowed definition construction to observe constructor defaults before
persisted settings had loaded. The former landing-page predicate also inspected
the active replicator, although the local database and replicator are created
only after the settings-loaded lifecycle. On start-up this ordering could emit
a spurious missing-replicator warning and produce a landing-page order from
transient state.
Self-hosted LiveSync still supports Obsidian versions before 1.13 through its
`minAppVersion` of 1.7.2. It must therefore retain an imperative `display()`
fallback unless the minimum supported Obsidian version is raised separately.
@@ -168,13 +178,13 @@ for narrow mobile displays while preventing the unheaded page entries from
appearing to continue the preceding Quick Setup group. The root order reflects
the current task:
| Current state | First root sections |
| --------------------------- | ----------------------------------------------------------------------------------- |
| Synchronisation is inactive | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General |
| Synchronisation is active | Synchronisation (Remote Configuration and Sync Settings), General, then Quick Setup |
| Configuration state | First root sections |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| Unconfigured | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General |
| Configured | Synchronisation (Remote Configuration and Sync Settings), General, Set up other devices, then Quick Setup |
Set up other devices follows the Quick Setup and General groups when the
plug-in is configured. The remaining destinations are grouped explicitly:
Set up other devices is hidden until the plug-in is configured. The remaining
destinations are grouped explicitly:
| Group | Pages |
| ------------------------ | ---------------------------------------- |
@@ -189,10 +199,11 @@ requests a catalogue refresh after persistence. External setting reloads use
the same boundary. Constructing the definitions still performs no persistence,
service, file, database, or network operation.
The imperative renderer retains its existing default-page selection: Quick Setup for
an inactive configuration and General for an active configuration. The landing
composition is therefore a native 1.13 improvement rather than a behaviour
change for earlier supported Obsidian versions.
The imperative renderer uses the same stable distinction for its default-page
selection: Quick Setup for an unconfigured installation and General for a
configured installation. The landing composition is therefore a native 1.13
improvement rather than a separate interpretation of synchronisation state on
earlier supported Obsidian versions.
The custom `SettingPage` adapter class will be constructed lazily from the
1.13-or-later path. `SettingPage` may remain a normal runtime import because the
@@ -355,6 +366,23 @@ and side-effect free. Obsidian calls the method during search indexing and
again on updates; it must perform no file, database, network, or settings
write.
### Register the setting tab after persisted settings load
The settings module registers its `PluginSettingTab` from the sequential
`onSettingLoaded` lifecycle, not from `onInitialise`. Immediately before
registration, it seeds the tab's editing and initial snapshots through
`reloadAllSettings(true)`. Skipping the update request is intentional because
the tab is not yet owned by Obsidian; `addSettingTab()` may request definitions
immediately after this seeding step.
This lifecycle still precedes local database opening and replicator activation.
Definition construction must therefore depend only on the seeded setting
snapshot, static catalogue data, and translations. In particular, root-page
ordering is based on the persisted `isConfigured` value. It must not inspect
automatic synchronisation triggers, the active replicator, replication status,
database readiness, files, or the network. Runtime operations remain explicit
actions which run after the user selects them.
### Give imperative pages an explicit lifetime and refresh boundary
The present `display()` renders every pane together, so arrays of
@@ -549,7 +577,12 @@ Stage C1 and the landing-page focused unit tests verify:
identifiers and names;
- Appearance, Logging, Extra menus, and Advanced are native-items child pages,
and ten child pages retain custom factories;
- inactive and active configurations use their specified landing-page order;
- configured and unconfigured installations use their specified landing-page
order regardless of transient replication status;
- definition construction does not request the active replicator before the
database is ready;
- the settings tab is registered only after persisted settings load, and its
editing snapshot is seeded before registration without requesting a render;
- Remote Configuration and Sync Settings remain native navigable pages inside
the separate Synchronisation group;
- maintenance, extra features, advanced settings, and help have explicit page
@@ -626,17 +659,27 @@ persistence of the same Advanced value. The shared E2E navigator owns both the
separate settings renderer used by Obsidian 1.13 and the legacy
`.sls-setting-menu-btn` interface.
The Stage C2 landing composition was then exercised on Obsidian 1.13.4. With
synchronisation inactive, the real interface rendered Quick Setup, a separate
Synchronisation group containing Remote Configuration and Sync Settings, and a
General Settings group containing Appearance, Logging, and Extra menus in the
specified order. It opened all 14 nested settings pages, found the Advanced
control through global settings search, and restored its saved value after
reopening settings. In mobile test mode, Remote Configuration remained inside
the initial viewport below the two Quick Setup actions and the Synchronisation
heading. The complete scenario also passed with the same bundle on Obsidian
1.12.7, confirming that the imperative fallback retained its navigation and
save behaviour.
Before the start-up lifecycle correction, the Stage C2 landing composition was
exercised on Obsidian 1.13.4 with a configured installation whose automatic
synchronisation triggers were disabled. Under the former predicate, the real
interface rendered Quick Setup, a separate Synchronisation group containing
Remote Configuration and Sync Settings, and a General Settings group containing
Appearance, Logging, and Extra menus in that order. It opened all 14 nested
settings pages, found the Advanced control through global settings search, and
restored its saved value after reopening settings. In mobile test mode, Remote
Configuration remained inside the initial viewport below the two Quick Setup
actions and the Synchronisation heading. The complete scenario also passed with
the same bundle on Obsidian 1.12.7, confirming that the imperative fallback
retained its navigation and save behaviour.
The start-up lifecycle correction was subsequently exercised with the same
official Obsidian 1.13.4 build. The settings scenario captured and verified the
exact configured and unconfigured root-group orders, including Set up other
devices before Quick Setup for a configured installation. The same bundle
opened General Settings by default through the imperative fallback on Obsidian
1.12.7. Focused unit tests own the earlier lifecycle boundary: persisted
settings are copied before registration, and definition construction does not
request an active replicator.
## Expansion Checkpoints
+1 -1
View File
@@ -4,7 +4,7 @@ NOTE: This document not completed. I'll improve this doc in a while. but your co
There are many settings in Self-hosted LiveSync. This document describes each setting in detail (not how-to). Configuration and settings are divided into several categories and indicated by icons. The icon is as follows:
On Obsidian 1.13 or later, the root settings page is organised by task. When synchronisation is inactive, **Quick Setup** appears first. Once any synchronisation mode is active, **Synchronisation** and **General Settings** move ahead of **Quick Setup**. **Set up other devices** appears after this plug-in has been configured. Earlier supported Obsidian versions retain a pane-based interface with the same controls.
On Obsidian 1.13 or later, the root settings page is organised by task. On an unconfigured installation, **Quick Setup** appears first, followed by **Synchronisation** and **General Settings**. Once this plug-in has been configured, **Synchronisation** and **General Settings** appear first, followed by **Set up other devices** and **Quick Setup**. Earlier supported Obsidian versions retain a pane-based interface with the same controls; they open **Quick Setup** when unconfigured and **General Settings** when configured.
| Icon | Root group | Contents or availability |
| :--: | ------------------------ | ------------------------------------------------------------- |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.19",
"version": "1.0.20",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.19",
"version": "1.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.19",
"version": "1.0.20",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -15813,7 +15813,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.19-cli",
"version": "1.0.20-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -15838,7 +15838,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.19-webapp",
"version": "1.0.20-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.53"
},
@@ -15850,7 +15850,7 @@
}
},
"src/apps/webpeer": {
"version": "1.0.19-webpeer",
"version": "1.0.20-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.53"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.19",
"version": "1.0.20",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.19-cli",
"version": "1.0.20-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.19-webapp",
"version": "1.0.20-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.19-webpeer",
"version": "1.0.20-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -8,8 +8,9 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
settingTab!: ObsidianLiveSyncSettingTab;
_everyOnloadStart(): Promise<boolean> {
_everyOnloadAfterLoadSettings(): Promise<boolean> {
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
this.settingTab.reloadAllSettings(true);
this.plugin.addSettingTab(this.settingTab);
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
@@ -24,6 +25,6 @@ export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
return `${"appId" in this.app ? this.app.appId : ""}`;
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
}
}
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const settingTabState = vi.hoisted(() => ({
callOrder: [] as string[],
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
}));
const eventHubState = vi.hoisted(() => ({
onEvent: vi.fn(),
}));
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
reloadAllSettings(skipUpdate?: boolean) {
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
settingTabState.reloadAllSettings(skipUpdate);
}
},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_SETTINGS: "request-open-settings",
eventHub: eventHubState,
}));
import { ModuleObsidianSettingDialogue } from "./ModuleObsidianSettingTab.ts";
function createModuleHarness() {
let initialisationHandler: (() => Promise<boolean>) | undefined;
let settingsLoadedHandler: (() => Promise<boolean>) | undefined;
const plugin = {
app: {},
addSettingTab: vi.fn(() => settingTabState.callOrder.push("add-setting-tab")),
};
const services = {
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
initialisationHandler = handler;
}),
},
onSettingLoaded: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
settingsLoadedHandler = handler;
}),
},
},
};
const module = Object.assign(Object.create(ModuleObsidianSettingDialogue.prototype), {
plugin,
core: { services },
}) as ModuleObsidianSettingDialogue;
module.onBindFunction(module.core as never, services as never);
return {
initialisationHandler: () => initialisationHandler,
module,
plugin,
services,
settingsLoadedHandler: () => settingsLoadedHandler,
};
}
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
beforeEach(() => {
settingTabState.callOrder.length = 0;
settingTabState.reloadAllSettings.mockClear();
eventHubState.onEvent.mockClear();
});
it("registers the setting tab after persisted settings have loaded", () => {
const { initialisationHandler, services, settingsLoadedHandler } = createModuleHarness();
expect(services.appLifecycle.onInitialise.addHandler).not.toHaveBeenCalled();
expect(services.appLifecycle.onSettingLoaded.addHandler).toHaveBeenCalledOnce();
expect(initialisationHandler()).toBeUndefined();
expect(settingsLoadedHandler()).toBeTypeOf("function");
});
it("seeds the setting editor without requesting a render before registration", async () => {
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
const handler = settingsLoadedHandler() ?? initialisationHandler();
expect(handler).toBeTypeOf("function");
await handler!();
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
});
});
@@ -145,22 +145,36 @@ function findPage(tab: ObsidianLiveSyncSettingTab, name: string): SettingDefinit
return page;
}
function createSettingsTab(): ObsidianLiveSyncSettingTab {
const plugin = {
app: {},
core: {
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: {
askInPopup: vi.fn(),
type SettingsTabOptions = {
activeReplicatorGetter?: () => { syncStatus: "CONNECTED" | "PAUSED" } | undefined;
replicationStatus?: "CLOSED" | "CONNECTED" | "PAUSED";
};
function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab {
const core = {
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: {
askInPopup: vi.fn(),
},
services: {
setting: {
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
},
services: {
setting: {
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
replicator: {
replicationStatics: {
value: { syncStatus: options.replicationStatus ?? "CLOSED" },
},
},
},
};
Object.defineProperty(core, "replicator", {
get: options.activeReplicatorGetter ?? (() => undefined),
});
const plugin = {
app: {},
core,
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, {
_editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
@@ -181,8 +195,27 @@ beforeEach(() => {
});
describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
it("keeps Quick Setup first while synchronisation is inactive and separates synchronisation pages from it", () => {
it("builds definitions before database readiness without requesting the active replicator", () => {
const activeReplicatorGetter = vi.fn(() => {
throw new Error("The active replicator is not ready");
});
const tab = createSettingsTab({ activeReplicatorGetter });
expect(() => tab.getSettingDefinitions()).not.toThrow();
expect(activeReplicatorGetter).not.toHaveBeenCalled();
});
it("keeps Quick Setup first while LiveSync is not configured, regardless of transient replication status", () => {
const tab = createSettingsTab({ replicationStatus: "CONNECTED" });
tab.editingSettings.isConfigured = false;
const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions[0]?.heading).toBe("🧙‍♂️ Quick Setup");
});
it("keeps Quick Setup first while LiveSync is not configured and separates synchronisation pages from it", () => {
const tab = createSettingsTab();
tab.editingSettings.isConfigured = false;
const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
@@ -192,14 +225,15 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
]);
});
it("keeps the synchronisation group first and orders General Settings before Quick Setup while synchronisation is active", () => {
it("keeps the synchronisation group first for a configured device with automatic triggers disabled", () => {
const tab = createSettingsTab();
tab.editingSettings.liveSync = true;
tab.editingSettings.isConfigured = true;
const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
expect(definitions.slice(0, 4).map(itemLabel)).toEqual([
"🔄 Synchronisation",
"⚙️ General Settings",
"📲 Set up other devices",
"🧙‍♂️ Quick Setup",
]);
});
@@ -40,7 +40,6 @@ import {
eventHub,
} from "@/common/events.ts";
import {
enableOnly,
// findAttrFromParent,
// getLevelStr,
setLevelClass,
@@ -587,19 +586,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
"encrypt",
]);
}
isAnySyncEnabled() {
if (this.isConfiguredAs("isConfigured", false)) return false;
if (this.isConfiguredAs("liveSync", true)) return true;
if (this.isConfiguredAs("periodicReplication", true)) return true;
if (this.isConfiguredAs("syncOnFileOpen", true)) return true;
if (this.isConfiguredAs("syncOnSave", true)) return true;
if (this.isConfiguredAs("syncOnEditorSave", true)) return true;
if (this.isConfiguredAs("syncOnStart", true)) return true;
if (this.isConfiguredAs("syncAfterMerge", true)) return true;
if (this.isConfiguredAs("syncOnFileOpen", true)) return true;
if (this.core?.replicator?.syncStatus == "CONNECTED") return true;
if (this.core?.replicator?.syncStatus == "PAUSED") return true;
return false;
isLiveSyncConfigured() {
return this.isConfiguredAs("isConfigured", true);
}
private supportsDeclarativeSettings(): boolean {
@@ -905,13 +893,20 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
getPage("help"),
getPage("change-log"),
]);
const laterGroups = [setupOtherDevices, maintenance, extraFeatures, advancedSettings, helpAndInformation];
const laterGroups = [maintenance, extraFeatures, advancedSettings, helpAndInformation];
const pendingInitialisation = this.createRebuildRequiredAction();
if (this.isAnySyncEnabled()) {
return [pendingInitialisation, synchronisation, generalSettings, quickSetup, ...laterGroups];
if (this.isLiveSyncConfigured()) {
return [
pendingInitialisation,
synchronisation,
generalSettings,
setupOtherDevices,
quickSetup,
...laterGroups,
];
}
return [pendingInitialisation, quickSetup, synchronisation, generalSettings, ...laterGroups];
return [pendingInitialisation, quickSetup, synchronisation, generalSettings, setupOtherDevices, ...laterGroups];
}
private beginRenderScope(refresh: () => void): Component {
@@ -936,8 +931,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.controlledElementFunc.length = 0;
}
enableOnlySyncDisabled = enableOnly(() => !this.isAnySyncEnabled());
onlyOnP2POrCouchDB = () =>
({
visibility:
@@ -1184,7 +1177,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
void yieldNextAnimationFrame().then(() => {
if (this.selectedScreen == "") {
if (this.isAnySyncEnabled()) {
if (this.isLiveSyncConfigured()) {
changeDisplay("20");
} else {
changeDisplay("110");
+69 -24
View File
@@ -14,7 +14,7 @@ import {
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import type { Locator } from "playwright";
import type { Locator, Page } from "playwright";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true";
@@ -34,6 +34,11 @@ type LiveSyncTestPlugin = {
applySettings: () => Promise<void>;
isP2P: boolean;
}) => Promise<unknown>;
settingTab?: {
editingSettings: { isConfigured: boolean };
initialSettings?: { isConfigured: boolean };
requestCatalogueRefresh(): void;
};
}[];
settings: {
handleFilenameCaseSensitive: boolean;
@@ -84,17 +89,14 @@ const settingsPageNames = [
"Change Log",
] as const;
async function assertDeclarativeLandingOrder(root: Locator): Promise<void> {
async function assertDeclarativeLandingOrder(root: Locator, configured: boolean): Promise<void> {
const synchronisation = ["Synchronisation", "Remote Configuration", "Sync Settings"];
const generalSettings = ["General Settings", "Appearance", "Logging", "Extra menus"];
const setup = configured
? [...synchronisation, ...generalSettings, "📲 Set up other devices", "Quick Setup"]
: ["Quick Setup", ...synchronisation, ...generalSettings, "📲 Set up other devices"];
const labels = [
"Quick Setup",
"Synchronisation",
"Remote Configuration",
"Sync Settings",
"General Settings",
"Appearance",
"Logging",
"Extra menus",
"📲 Set up other devices",
...setup,
"Maintenance and recovery",
"Maintenance",
"Hatch",
@@ -131,10 +133,31 @@ async function assertDeclarativeLandingOrder(root: Locator): Promise<void> {
}, labels);
}
async function scrollDeclarativeLandingToTop(root: Locator): Promise<void> {
const quickSetupHeading = root.locator(".setting-item-heading").filter({ hasText: "Quick Setup" }).first();
await quickSetupHeading.waitFor({ state: "visible", timeout: uiTimeoutMs });
await quickSetupHeading.scrollIntoViewIfNeeded();
async function scrollDeclarativeLandingToTop(root: Locator, configured: boolean): Promise<void> {
const firstHeading = root
.locator(".setting-item-heading")
.filter({ hasText: configured ? "Synchronisation" : "Quick Setup" })
.first();
await firstHeading.waitFor({ state: "visible", timeout: uiTimeoutMs });
await firstHeading.scrollIntoViewIfNeeded();
}
async function setConfiguredStateForLandingInspection(page: Page, configured: boolean): Promise<void> {
await page.evaluate((nextConfigured) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
const settingDialogue = plugin.core.modules.find(
(module) => module.constructor.name === "ModuleObsidianSettingDialogue"
);
if (settingDialogue?.settingTab === undefined) {
throw new Error("The Self-hosted LiveSync setting tab is unavailable");
}
settingDialogue.settingTab.editingSettings.isConfigured = nextConfigured;
if (settingDialogue.settingTab.initialSettings !== undefined) {
settingDialogue.settingTab.initialSettings.isConfigured = nextConfigured;
}
settingDialogue.settingTab.requestCatalogueRefresh();
}, configured);
}
async function captureDeclarativeMobileLanding(): Promise<string | undefined> {
@@ -148,8 +171,8 @@ async function captureDeclarativeMobileLanding(): Promise<string | undefined> {
return undefined;
}
await settingsNavigator.returnToCatalogue();
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue);
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true);
const remoteConfiguration = settingsNavigator.dialogue
.locator(".setting-item-name")
.filter({ hasText: "Remote Configuration" })
@@ -437,8 +460,8 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
});
}
async function verifyEffectiveSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> {
return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const sleepPreferences = await page.evaluate(() => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
@@ -462,6 +485,12 @@ async function verifyEffectiveSettings(): Promise<void> {
}
let settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
if (settingsNavigator.renderer === "imperative") {
await settingsNavigator.dialogue.screenshot({
...settingsScreenshotOptions,
path: `${diagnosticsDirectory}/settings-imperative-landing.png`,
});
}
for (const hiddenPage of ["Selector", "Customisation sync", "Advanced", "Power users", "Patches"]) {
if (await settingsNavigator.isPageListed(hiddenPage)) {
throw new Error(`${hiddenPage} was visible before its feature level was enabled.`);
@@ -568,12 +597,22 @@ async function verifyEffectiveSettings(): Promise<void> {
if (settingsNavigator.renderer === "declarative") {
await settingsNavigator.returnToCatalogue();
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue);
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await settingsNavigator.dialogue.screenshot({
...settingsScreenshotOptions,
path: `${diagnosticsDirectory}/settings-declarative-landing.png`,
});
await assertDeclarativeLandingOrder(settingsNavigator.dialogue);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true);
await setConfiguredStateForLandingInspection(page, false);
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, false);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue, false);
await settingsNavigator.dialogue.screenshot({
...settingsScreenshotOptions,
path: `${diagnosticsDirectory}/settings-declarative-landing-unconfigured.png`,
});
await setConfiguredStateForLandingInspection(page, true);
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true);
const rerunOnboarding = settingsNavigator.dialogue
.locator(".setting-item-name")
.filter({ hasText: "Rerun Onboarding Wizard" })
@@ -647,7 +686,9 @@ async function verifyEffectiveSettings(): Promise<void> {
}
}
const renderer = settingsNavigator.renderer;
await settingsNavigator.close();
return renderer;
});
}
@@ -690,7 +731,11 @@ async function verifyPendingSettingsInitialisationFlow(): Promise<{ choice: stri
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 });
if (settingsNavigator.renderer === "declarative") {
await applySetting.click({ timeout: uiTimeoutMs });
} else {
await applySetting.getByRole("button", { name: "Apply", exact: true }).click({ timeout: uiTimeoutMs });
}
const choiceDialogue = await waitForVisibleObsidianDialogue(
settingsNavigator.page,
@@ -798,10 +843,10 @@ async function main(): Promise<void> {
await verifyCompatibilityReview();
await verifyConfigDoctorFollowsCompatibilityReview();
}
await verifyEffectiveSettings();
const settingsRenderer = await verifyEffectiveSettings();
const initialisation = await verifyPendingSettingsInitialisationFlow();
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
const mobileLanding = await captureDeclarativeMobileLanding();
const mobileLanding = settingsRenderer === "declarative" ? await captureDeclarativeMobileLanding() : undefined;
if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`);
console.log(
`Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}`
+5 -2
View File
@@ -12,17 +12,20 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased
## 1.0.19
## 1.0.20
~~1.0.19~~ was cancelled because prerelease validation exposed an incorrect warning at start-up.
25th August, 2026
I had grown quite fond of the settings screen, but it seems that a simpler, healthier life is called for.
I know this is the second time I have said it, but I had grown quite fond of the settings screen. It seems, however, that a simpler, healthier life is called for.
### Interface and translation
#### Fixed
- Compatibility pause warnings now direct you to the dedicated compatibility review instead of the Change Log.
- The Obsidian 1.13 settings page now waits for saved settings before choosing its initial layout. This prevents a spurious missing-replicator warning at start-up, keeps configured devices on the Synchronisation-first layout even when automatic synchronisation triggers are disabled, and keeps Quick Setup first on unconfigured devices.
#### Improved
+2 -1
View File
@@ -31,5 +31,6 @@
"1.0.16": "1.7.2",
"1.0.17": "1.7.2",
"1.0.18": "1.7.2",
"1.0.19": "1.7.2"
"1.0.19": "1.7.2",
"1.0.20": "1.7.2"
}