Compare commits

...
Author SHA1 Message Date
vorotamoroz 783fbb8f23 Refine mobile settings action layout markers 2026-08-26 05:13:38 +00:00
vorotamoroz 8644af6128 Narrow mobile settings layout fix 2026-08-26 04:30:44 +00:00
vorotamoroz 4ff5b4dfe8 Fix mobile settings layout overflow 2026-08-25 17:27:31 +00:00
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
vorotamoroz 1c3feb526c Merge pull request #1133 from vrtmrz/refactor/obsidian-api-modernisation
Modernise LiveSync settings for Obsidian's declarative API
2026-08-25 20:14:42 +09:00
24 changed files with 759 additions and 202 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
+42
View File
@@ -2,6 +2,48 @@
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
## 1.0.14
14th August, 2026
Thank you for your patience. At last, it looks as though we can clear some of the Community Review warnings.
### Synchronisation and storage
#### Fixed
- CouchDB operations which run to completion now close their temporary remote database connections after use across both Commonlib and LiveSync, including Setup Wizard and settings probes, command-line milestone verification, database maintenance, Security Seed refreshes, status queries, and retry and error paths (Commonlib PR #112).
- This covers every temporary connection currently identified as a possible contributor to the long-running resource growth tracked in #1034. Validation over extended sessions is continuing.
- Thank you to @apple-ouyang for the contribution!
## 1.0.13
13th August, 2026
### Conflict handling and recovery
#### Improved
- **Inspect conflicts and file/database differences** now reports local Metadata whose stored document ID does not match the ID derived from its recorded path. Ordinary scans leave unresolved entries and their corresponding Vault paths unchanged, while allowing consistently addressed Metadata for the same logical path to proceed normally.
- When the current winner has no conflict leaves and has an unambiguous target, its wrench menu can repair that one local Metadata document after separate confirmation. The target is written and verified before the mismatched source ID is removed; ambiguous or otherwise unsafe entries remain read-only.
#### Fixed
- Fast Fetch now writes deletion tombstones to the local database without attempting to decrypt them. A tombstone has no encrypted payload, and decryption previously aborted the whole fetch at the first deleted document. New devices could not complete their initial sync on vaults that contain old deletions (Commonlib PR #108).
- Thank you to @KennethLloyd for the contribution!
## 1.0.12
11th August, 2026
### Synchronisation and storage
#### Fixed
- One-shot CouchDB replication now closes its temporary remote database after each run and before retrying, preventing inactive PouchDB instances from accumulating during long-running periodic synchronisation (Commonlib PR #75). Thank you to @apple-ouyang for the contribution!
- Start-up and recovery scans now keep failed database-to-Vault writes retryable instead of recording them as successful and later mistaking the still-missing file for a local deletion (Commonlib PR #106).
- Files over the size limit or in conflict remain deliberately skipped, while actual write failures are reported to Fast Setup, CLI mirror, and daemon callers.
## 1.0.11
9th August, 2026
+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.18",
"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.18",
"version": "1.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.18",
"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.18-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.18-webapp",
"version": "1.0.20-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.53"
},
@@ -15850,7 +15850,7 @@
}
},
"src/apps/webpeer": {
"version": "1.0.18-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.18",
"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.18-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.18-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.18-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");
@@ -9,7 +9,7 @@ import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts";
import { visibleOnly } from "./SettingPane.ts";
import { markSettingRowWithSubsequentButtons, markSubsequentButton, visibleOnly } from "./SettingPane.ts";
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import { ExtraSuffixIndexedDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { migrateDatabases } from "./settingUtils.ts";
@@ -177,7 +177,7 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch");
});
void addPanel(paneEl, "Remediation").then((paneEl) => {
const setting = new Setting(paneEl);
const setting = markSettingRowWithSubsequentButtons(new Setting(paneEl));
const dateEl = setting.controlEl.createSpan();
setting
.addText((text) => {
@@ -215,6 +215,9 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
})
.setAuto("maxMTimeForReflectEvents")
.addApplyButton(["maxMTimeForReflectEvents"]);
if (setting.applyButtonComponent) {
markSubsequentButton(setting.applyButtonComponent);
}
this.addOnSaved("maxMTimeForReflectEvents", async (key) => {
const buttons = ["Restart Now", "Later"] as const;
@@ -12,17 +12,26 @@ const remediationHarness = vi.hoisted(() => {
onChange: vi.fn(),
setValue: vi.fn(),
};
const addButtonClass = vi.fn();
const setClass = vi.fn();
return {
addButtonClass,
createSpan,
dateElement,
inputEl,
setClass,
textComponent,
};
});
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class LiveSyncSetting {
applyButtonComponent = {
buttonEl: {
addClass: remediationHarness.addButtonClass,
},
};
controlEl = {
createSpan: remediationHarness.createSpan,
};
@@ -36,6 +45,11 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this;
}
setClass(value: string): this {
remediationHarness.setClass(value);
return this;
}
addApplyButton(): this {
return this;
}
@@ -93,5 +107,7 @@ describe("panePatches remediation setting", () => {
expect(createSpan).not.toHaveBeenCalled();
expect(remediationHarness.createSpan).toHaveBeenCalledOnce();
expect(remediationHarness.dateElement.textContent).toBe("No limit configured");
expect(remediationHarness.setClass).toHaveBeenCalledWith("sls-setting-row-with-subsequent-buttons");
expect(remediationHarness.addButtonClass).toHaveBeenCalledWith("sls-setting-subsequent-button");
});
});
@@ -11,7 +11,12 @@ import { Menu, type ButtonComponent } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import { setButtonDestructiveState, type PageFunctions } from "./SettingPane.ts";
import {
markSettingRowWithSubsequentButtons,
markSubsequentButton,
setButtonDestructiveState,
type PageFunctions,
} from "./SettingPane.ts";
// import { visibleOnly } from "./SettingPane.ts";
import InfoPanel from "./InfoPanel.svelte";
import { writable } from "svelte/store";
@@ -105,10 +110,10 @@ export function paneRemoteConfig(
void addPanel(paneEl, "E2EE Configuration", () => {}).then((paneEl) => {
const infoPanel = new SveltePanel(InfoPanel, paneEl, E2EESummaryWritable);
this.lifetimeComponent.register(() => infoPanel.destroy());
const setupButton = new Setting(paneEl).setName("Configure E2EE");
const setupButton = markSettingRowWithSubsequentButtons(new Setting(paneEl).setName("Configure E2EE"));
setupButton
.addButton((button) =>
setButtonDestructiveState(button)
setButtonDestructiveState(markSubsequentButton(button))
.onClick(async () => {
const setupManager = this.core.getModule(SetupManager);
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
@@ -118,7 +123,7 @@ export function paneRemoteConfig(
.setButtonText("Configure")
)
.addButton((button) =>
setButtonDestructiveState(button)
setButtonDestructiveState(markSubsequentButton(button))
.onClick(async () => {
const setupManager = this.core.getModule(SetupManager);
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
@@ -1,7 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({
buttonClasses: [] as string[],
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
settingClasses: [] as string[],
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({
@@ -30,7 +32,25 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this;
}
addButton() {
setClass(value: string) {
runtime.settingClasses.push(value);
return this;
}
addButton(callback: (button: unknown) => void) {
const button = {
buttonEl: {
addClass: (value: string) => runtime.buttonClasses.push(value),
classList: { toggle: vi.fn() },
},
onClick() {
return this;
},
setButtonText() {
return this;
},
};
callback(button);
return this;
}
@@ -85,7 +105,9 @@ function createPanelElement(): HTMLElement {
}
afterEach(() => {
runtime.buttonClasses.length = 0;
runtime.panels.length = 0;
runtime.settingClasses.length = 0;
vi.clearAllMocks();
});
@@ -96,7 +118,13 @@ describe("paneRemoteConfig", () => {
register: vi.fn((callback: () => unknown) => callbacks.push(callback)),
unload: vi.fn(() => callbacks.splice(0).forEach((callback) => callback())),
};
const addPanel = vi.fn((_parent: HTMLElement, _heading: string) => Promise.resolve(createPanelElement()));
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
then(callback: (paneEl: HTMLElement) => void) {
if (heading === "E2EE Configuration") {
callback(createPanelElement());
}
},
}));
const host = {
editingSettings: { remoteConfigurations: {} },
core: { settings: { remoteConfigurations: {} } },
@@ -105,6 +133,8 @@ describe("paneRemoteConfig", () => {
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(runtime.panels).toHaveLength(1));
expect(runtime.settingClasses).toContain("sls-setting-row-with-subsequent-buttons");
expect(runtime.buttonClasses).toEqual(["sls-setting-subsequent-button", "sls-setting-subsequent-button"]);
lifetimeComponent.unload();
@@ -6,7 +6,7 @@ import {
type ConfigLevel,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { AllSettingItemKey, AllSettings } from "./settingConstants";
import type { ButtonComponent } from "@/deps.ts";
import type { ButtonComponent, Setting } from "@/deps.ts";
export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => {
return () => ({
@@ -58,6 +58,18 @@ export function setButtonDestructiveState(button: ButtonComponent, isDestructive
return button;
}
/** Marks a setting row whose selected action buttons may wrap onto separate lines. */
export function markSettingRowWithSubsequentButtons<T extends Setting>(setting: T): T {
setting.setClass("sls-setting-row-with-subsequent-buttons");
return setting;
}
/** Marks an action button which may wrap onto a later line in its setting row. */
export function markSubsequentButton(button: ButtonComponent): ButtonComponent {
button.buttonEl.addClass("sls-setting-subsequent-button");
return button;
}
export function visibleOnly(cond: () => boolean): OnUpdateFunc {
return () => ({
visibility: cond(),
@@ -1,6 +1,6 @@
import type { ButtonComponent } from "@/deps.ts";
import type { ButtonComponent, Setting } from "@/deps.ts";
import { describe, expect, it, vi } from "vitest";
import { setButtonDestructiveState } from "./SettingPane.ts";
import { markSettingRowWithSubsequentButtons, markSubsequentButton, setButtonDestructiveState } from "./SettingPane.ts";
type CompatibleButton = ButtonComponent & {
setDestructive?: () => ButtonComponent;
@@ -10,6 +10,7 @@ type CompatibleButton = ButtonComponent & {
function createButton(overrides: Partial<CompatibleButton> = {}): CompatibleButton {
return {
buttonEl: {
addClass: vi.fn(),
classList: {
toggle: vi.fn(),
},
@@ -18,6 +19,30 @@ function createButton(overrides: Partial<CompatibleButton> = {}): CompatibleButt
} as unknown as CompatibleButton;
}
function createSetting(): Setting {
return {
setClass: vi.fn().mockReturnThis(),
} as unknown as Setting;
}
describe("markSettingRowWithSubsequentButtons", () => {
it("marks only the supplied setting row as containing subsequent actions", () => {
const setting = createSetting();
expect(markSettingRowWithSubsequentButtons(setting)).toBe(setting);
expect(setting.setClass).toHaveBeenCalledWith("sls-setting-row-with-subsequent-buttons");
});
});
describe("markSubsequentButton", () => {
it("marks only the supplied button as a subsequent action", () => {
const button = createButton();
expect(markSubsequentButton(button)).toBe(button);
expect(button.buttonEl.addClass).toHaveBeenCalledWith("sls-setting-subsequent-button");
});
});
describe("setButtonDestructiveState", () => {
it("uses the native destructive-button API when it is available", () => {
const setDestructive = vi.fn();
+19 -2
View File
@@ -536,14 +536,31 @@ div.workspace-leaf-content[data-type="bases"] .livesync-status {
}
.sls-setting-panel-title {
position: sticky;
font-size: medium;
top: 2.5em;
background-color: var(--background-secondary-alt);
border-radius: 10px;
padding: 0.5em 1em;
}
body.is-mobile .sls-setting button {
max-width: 100%;
white-space: normal;
}
body.is-mobile .sls-setting-row-with-subsequent-buttons {
flex-wrap: wrap;
}
body.is-mobile .sls-setting-row-with-subsequent-buttons .setting-item-control {
min-width: 0;
flex: 1 1 100%;
flex-wrap: wrap;
}
body.is-mobile .sls-setting .sls-setting-subsequent-button {
flex: 1 1 12rem;
}
.active-pane .sls-setting-panel-title {
border: 1px solid var(--interactive-accent);
}
+336 -72
View File
@@ -1,8 +1,12 @@
import { mkdir } from "node:fs/promises";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import {
assertMobileDialogueLayout,
setObsidianMobileTestMode,
setObsidianMobileTestModeBeforePluginStart,
} from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
allowPendingObsidianTestVaultOpenAction,
@@ -14,7 +18,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 +38,11 @@ type LiveSyncTestPlugin = {
applySettings: () => Promise<void>;
isP2P: boolean;
}) => Promise<unknown>;
settingTab?: {
editingSettings: { isConfigured: boolean };
initialSettings?: { isConfigured: boolean };
requestCatalogueRefresh(): void;
};
}[];
settings: {
handleFilenameCaseSensitive: boolean;
@@ -84,17 +93,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,45 +137,221 @@ 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 captureDeclarativeMobileLanding(): Promise<string | undefined> {
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 captureDeclarativeMobileSettings(): Promise<
| {
landingPage: string;
maintenance: string;
patches: string;
remoteConfiguration: string;
}
| undefined
> {
const port = obsidianRemoteDebuggingPort();
await setObsidianMobileTestMode(port, true, uiTimeoutMs);
try {
return await withObsidianPage(port, async (page) => {
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
if (settingsNavigator.renderer !== "declarative") {
await settingsNavigator.close();
return undefined;
}
await settingsNavigator.returnToCatalogue();
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue);
const remoteConfiguration = settingsNavigator.dialogue
.locator(".setting-item-name")
.filter({ hasText: "Remote Configuration" })
.first();
await remoteConfiguration.waitFor({ state: "visible", timeout: uiTimeoutMs });
const path = `${diagnosticsDirectory}/settings-declarative-landing-mobile.png`;
await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path });
const remotePosition = await remoteConfiguration.evaluate((element) => {
const bounds = element.getBoundingClientRect();
return { top: bounds.top, bottom: bounds.bottom, viewportHeight: window.innerHeight };
});
if (remotePosition.top < 0 || remotePosition.bottom > remotePosition.viewportHeight) {
throw new Error("Remote Configuration was not visible at the top of the mobile settings landing page.");
}
return await withObsidianPage(port, async (page) => {
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
if (settingsNavigator.renderer !== "declarative") {
await settingsNavigator.close();
return path;
return undefined;
}
await settingsNavigator.returnToCatalogue();
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await assertDeclarativeLandingOrder(settingsNavigator.dialogue, true);
const remoteConfiguration = settingsNavigator.dialogue
.locator(".setting-item-name")
.filter({ hasText: "Remote Configuration" })
.first();
await remoteConfiguration.waitFor({ state: "visible", timeout: uiTimeoutMs });
const path = `${diagnosticsDirectory}/settings-declarative-landing-mobile.png`;
await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path });
const remotePosition = await remoteConfiguration.evaluate((element) => {
const bounds = element.getBoundingClientRect();
return { top: bounds.top, bottom: bounds.bottom, viewportHeight: window.innerHeight };
});
} finally {
await setObsidianMobileTestMode(port, false, uiTimeoutMs);
}
if (remotePosition.top < 0 || remotePosition.bottom > remotePosition.viewportHeight) {
throw new Error("Remote Configuration was not visible at the top of the mobile settings landing page.");
}
const remotePage = await settingsNavigator.openPage("Remote Configuration");
const e2eeHeading = remotePage
.locator("h4.sls-setting-panel-title")
.filter({ hasText: "E2EE Configuration" })
.first();
const e2eeActions = remotePage.locator(".setting-item").filter({
has: settingsNavigator.page.getByText("Configure E2EE", { exact: true }),
});
await e2eeHeading.waitFor({ state: "visible", timeout: uiTimeoutMs });
await e2eeActions.waitFor({ state: "visible", timeout: uiTimeoutMs });
const layoutFailures: string[] = [];
const actionLayout = await e2eeActions.evaluate((setting) => {
const control = setting.querySelector<HTMLElement>(".setting-item-control");
if (control === null) throw new Error("The E2EE action row did not contain a control group.");
const settingBounds = setting.getBoundingClientRect();
const buttonBounds = Array.from(control.querySelectorAll("button")).map((button) =>
button.getBoundingClientRect()
);
return {
controlClientWidth: control.clientWidth,
controlScrollWidth: control.scrollWidth,
rightmostButton: Math.max(...buttonBounds.map((bounds) => bounds.right)),
settingRight: settingBounds.right,
};
});
if (
actionLayout.controlScrollWidth > actionLayout.controlClientWidth + 1 ||
actionLayout.rightmostButton > actionLayout.settingRight + 1
) {
layoutFailures.push(`the E2EE actions overflowed their setting row (${JSON.stringify(actionLayout)})`);
}
await remotePage.evaluate((content) => {
content.scrollTop = content.scrollHeight - content.clientHeight;
content.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await settingsNavigator.page.waitForTimeout(50);
const panelLayout = await e2eeHeading.evaluate((heading) => {
const infoPanel = heading.parentElement?.querySelector<HTMLElement>(".info-panel");
if (infoPanel === null || infoPanel === undefined) {
throw new Error("The E2EE section did not contain its information panel.");
}
const headingBounds = heading.getBoundingClientRect();
const infoBounds = infoPanel.getBoundingClientRect();
return {
headingBottom: headingBounds.bottom,
headingPosition: getComputedStyle(heading).position,
headingTop: headingBounds.top,
infoBottom: infoBounds.bottom,
infoTop: infoBounds.top,
};
});
if (
panelLayout.headingBottom > panelLayout.infoTop + 1 &&
panelLayout.headingTop < panelLayout.infoBottom - 1
) {
layoutFailures.push(`the E2EE section heading overlapped its contents (${JSON.stringify(panelLayout)})`);
}
const remotePath = `${diagnosticsDirectory}/settings-declarative-remote-mobile.png`;
await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path: remotePath });
if (layoutFailures.length > 0) {
throw new Error(`The mobile Remote Configuration layout was invalid: ${layoutFailures.join("; ")}.`);
}
const maintenancePage = await settingsNavigator.openPage("Maintenance");
const markResolvedButton = maintenancePage
.locator(".op-warn button")
.filter({ hasText: "I've made a backup, mark this device 'resolved'" })
.first();
await markResolvedButton.evaluate((button) => {
const warning = button.closest<HTMLElement>(".op-warn");
if (warning === null) throw new Error("The Maintenance recovery action had no warning container.");
warning.removeClass("sls-setting-hidden");
});
await markResolvedButton.waitFor({ state: "visible", timeout: uiTimeoutMs });
await markResolvedButton.scrollIntoViewIfNeeded();
const maintenanceLayout = await markResolvedButton.evaluate((button) => {
const content = button.closest<HTMLElement>(".vertical-tab-content");
if (content === null) throw new Error("The Maintenance button was outside the settings content.");
const buttonBounds = button.getBoundingClientRect();
const contentBounds = content.getBoundingClientRect();
return {
buttonLeft: buttonBounds.left,
buttonRight: buttonBounds.right,
contentLeft: contentBounds.left,
contentRight: contentBounds.right,
rootClientWidth: document.documentElement.clientWidth,
rootScrollWidth: document.documentElement.scrollWidth,
};
});
const maintenancePath = `${diagnosticsDirectory}/settings-declarative-maintenance-mobile.png`;
await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path: maintenancePath });
if (
maintenanceLayout.buttonLeft < maintenanceLayout.contentLeft - 1 ||
maintenanceLayout.buttonRight > maintenanceLayout.contentRight + 1 ||
maintenanceLayout.rootScrollWidth > maintenanceLayout.rootClientWidth + 1
) {
layoutFailures.push(
`the Maintenance recovery action overflowed the settings pane (${JSON.stringify(maintenanceLayout)})`
);
}
const patchesPage = await settingsNavigator.openPage("Patches");
const remediationSetting = patchesPage.locator(".setting-item").filter({
has: settingsNavigator.page.locator('input[type="datetime-local"]'),
});
await remediationSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
await remediationSetting.scrollIntoViewIfNeeded();
const patchesLayout = await remediationSetting.evaluate((setting) => {
const content = setting.closest<HTMLElement>(".vertical-tab-content");
const control = setting.querySelector<HTMLElement>(".setting-item-control");
if (content === null || control === null) {
throw new Error("The Patches remediation row was incomplete.");
}
const applyButton = control.querySelector<HTMLElement>("button");
if (applyButton === null) throw new Error("The Patches remediation row did not contain Apply.");
const settingBounds = setting.getBoundingClientRect();
const contentBounds = content.getBoundingClientRect();
const buttonBounds = applyButton.getBoundingClientRect();
return {
buttonRight: buttonBounds.right,
contentRight: contentBounds.right,
controlClientWidth: control.clientWidth,
controlScrollWidth: control.scrollWidth,
rootClientWidth: document.documentElement.clientWidth,
rootScrollWidth: document.documentElement.scrollWidth,
settingRight: settingBounds.right,
};
});
const patchesPath = `${diagnosticsDirectory}/settings-declarative-patches-mobile.png`;
await settingsNavigator.dialogue.screenshot({ ...settingsScreenshotOptions, path: patchesPath });
if (
patchesLayout.buttonRight > patchesLayout.settingRight + 1 ||
patchesLayout.buttonRight > patchesLayout.contentRight + 1 ||
patchesLayout.controlScrollWidth > patchesLayout.controlClientWidth + 1 ||
patchesLayout.rootScrollWidth > patchesLayout.rootClientWidth + 1
) {
layoutFailures.push(
`the Patches remediation actions overflowed their setting row (${JSON.stringify(patchesLayout)})`
);
}
if (layoutFailures.length > 0) {
throw new Error(`The mobile settings layout was invalid: ${layoutFailures.join("; ")}.`);
}
await settingsNavigator.close();
return {
landingPage: path,
maintenance: maintenancePath,
patches: patchesPath,
remoteConfiguration: remotePath,
};
});
}
async function openSettingsInitialisationDialogueForInspection(isP2P: boolean): Promise<void> {
@@ -437,8 +619,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 +644,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 +756,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 +845,9 @@ async function verifyEffectiveSettings(): Promise<void> {
}
}
const renderer = settingsNavigator.renderer;
await settingsNavigator.close();
return renderer;
});
}
@@ -690,7 +890,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,
@@ -750,6 +954,72 @@ async function verifyPendingSettingsInitialisationFlow(): Promise<{ choice: stri
});
}
function createSettingsPluginData(settingsOnlyRun: boolean): Record<string, unknown> {
return {
doctorProcessedVersion: settingsOnlyRun ? "1.0.0" : "0.25.27",
isConfigured: true,
liveSync: false,
versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncAfterMerge: false,
periodicReplication: false,
handleFilenameCaseSensitive: false,
useAdvancedMode: false,
usePowerUserMode: false,
useEdgeCaseMode: false,
};
}
async function captureDeclarativeMobileSettingsInFreshSession(
binary: string,
cliBinary: string
): Promise<
| {
landingPage: string;
maintenance: string;
patches: string;
remoteConfiguration: string;
}
| undefined
> {
// Enter mobile mode before LiveSync first loads so Obsidian fires the
// mobile settings-registration lifecycle used by a real mobile start-up.
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
...createSettingsPluginData(true),
useAdvancedMode: true,
useEdgeCaseMode: true,
usePowerUserMode: true,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
lifecycle: {
beforePluginStart: async ({ remoteDebuggingPort }) => {
await setObsidianMobileTestModeBeforePluginStart(remoteDebuggingPort, true, uiTimeoutMs);
},
},
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await resumePendingCompatibilityReviewForSettings();
return await captureDeclarativeMobileSettings();
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -759,29 +1029,14 @@ async function main(): Promise<void> {
const vault = await createTemporaryVault();
await mkdir(diagnosticsDirectory, { recursive: true });
let session: ObsidianLiveSyncSession | undefined;
let settingsRenderer: "declarative" | "imperative" | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: settingsOnly ? "1.0.0" : "0.25.27",
isConfigured: true,
liveSync: false,
versionUpFlash: settingsOnly ? "" : compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncAfterMerge: false,
periodicReplication: false,
handleFilenameCaseSensitive: false,
useAdvancedMode: false,
usePowerUserMode: false,
useEdgeCaseMode: false,
},
pluginData: createSettingsPluginData(settingsOnly),
lifecycle: settingsOnly
? {
afterLaunch: async ({ remoteDebuggingPort }) => {
@@ -798,11 +1053,9 @@ async function main(): Promise<void> {
await verifyCompatibilityReview();
await verifyConfigDoctorFollowsCompatibilityReview();
}
await verifyEffectiveSettings();
settingsRenderer = 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}`
);
@@ -813,6 +1066,17 @@ async function main(): Promise<void> {
}
await vault.dispose();
}
const mobileSettings =
settingsRenderer === "declarative"
? await captureDeclarativeMobileSettingsInFreshSession(binary, cli.binary)
: undefined;
if (mobileSettings) {
console.log(`Declarative mobile settings landing page: ${mobileSettings.landingPage}`);
console.log(`Declarative mobile Remote Configuration page: ${mobileSettings.remoteConfiguration}`);
console.log(`Declarative mobile Maintenance page: ${mobileSettings.maintenance}`);
console.log(`Declarative mobile Patches page: ${mobileSettings.patches}`);
}
}
main().catch((error: unknown) => {
+16 -43
View File
@@ -16,11 +16,26 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Fixed
- Remote Configuration section headings no longer overlap their contents when scrolling on mobile. Action buttons in Remote Configuration, Maintenance, and Patches now remain inside the settings pane on narrow screens.
## 1.0.20
~~1.0.19~~ was cancelled because prerelease validation exposed an incorrect warning at start-up.
25th August, 2026
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
- Settings page names, 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 page names, controls in General Settings, 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
@@ -88,45 +103,3 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Improved
- The Traditional Chinese translation catalogue has been completed and polished for broader coverage and more natural, consistent terminology (PR #1106). Thank you to @nimula for the contribution!
## 1.0.14
14th August, 2026
Thank you for your patience. At last, it looks as though we can clear some of the Community Review warnings.
### Synchronisation and storage
#### Fixed
- CouchDB operations which run to completion now close their temporary remote database connections after use across both Commonlib and LiveSync, including Setup Wizard and settings probes, command-line milestone verification, database maintenance, Security Seed refreshes, status queries, and retry and error paths (Commonlib PR #112).
- This covers every temporary connection currently identified as a possible contributor to the long-running resource growth tracked in #1034. Validation over extended sessions is continuing.
- Thank you to @apple-ouyang for the contribution!
## 1.0.13
13th August, 2026
### Conflict handling and recovery
#### Improved
- **Inspect conflicts and file/database differences** now reports local Metadata whose stored document ID does not match the ID derived from its recorded path. Ordinary scans leave unresolved entries and their corresponding Vault paths unchanged, while allowing consistently addressed Metadata for the same logical path to proceed normally.
- When the current winner has no conflict leaves and has an unambiguous target, its wrench menu can repair that one local Metadata document after separate confirmation. The target is written and verified before the mismatched source ID is removed; ambiguous or otherwise unsafe entries remain read-only.
#### Fixed
- Fast Fetch now writes deletion tombstones to the local database without attempting to decrypt them. A tombstone has no encrypted payload, and decryption previously aborted the whole fetch at the first deleted document. New devices could not complete their initial sync on vaults that contain old deletions (Commonlib PR #108).
- Thank you to @KennethLloyd for the contribution!
## 1.0.12
11th August, 2026
### Synchronisation and storage
#### Fixed
- One-shot CouchDB replication now closes its temporary remote database after each run and before retrying, preventing inactive PouchDB instances from accumulating during long-running periodic synchronisation (Commonlib PR #75). Thank you to @apple-ouyang for the contribution!
- Start-up and recovery scans now keep failed database-to-Vault writes retryable instead of recording them as successful and later mistaking the still-missing file for a local deletion (Commonlib PR #106).
- Files over the size limit or in conflict remain deliberately skipped, while actual write failures are reported to Fast Setup, CLI mirror, and daemon callers.
+3 -1
View File
@@ -30,5 +30,7 @@
"1.0.15": "1.7.2",
"1.0.16": "1.7.2",
"1.0.17": "1.7.2",
"1.0.18": "1.7.2"
"1.0.18": "1.7.2",
"1.0.19": "1.7.2",
"1.0.20": "1.7.2"
}