Compare commits

..
16 Commits
Author SHA1 Message Date
vorotamoroz 011a8405b5 Merge pull request #1139 from vrtmrz/1_0_21
Releasing 1.0.21
2026-08-26 20:36:43 +09:00
vorotamoroz f5f7aab11f Polish 1.0.21 release notes 2026-08-26 11:04:15 +00:00
github-actions[bot] 9854319c96 Releasing 1.0.21 2026-08-26 10:43:35 +00:00
vorotamoroz 4ab0689c7f Merge pull request #1138 from vrtmrz/fix/mobile-settings-layout
Fix settings layout on narrow mobile screens
2026-08-26 15:26:08 +09:00
vorotamoroz edeac6f7e2 Separate settings component style helpers 2026-08-26 05:49:37 +00:00
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
29 changed files with 835 additions and 239 deletions
+6
View File
@@ -245,6 +245,12 @@ export class ModuleExample extends AbstractObsidianModule {
- Settings are defined by Commonlib (`ObsidianLiveSyncSettings`) - Settings are defined by Commonlib (`ObsidianLiveSyncSettings`)
- Configuration metadata is supplied by the Commonlib settings exports - 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 - Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
### Database Operations ### Database Operations
@@ -1,7 +1,7 @@
--- ---
date: 2026-08-25 date: 2026-08-25
commonlib-version: "0.1.19" commonlib-version: "0.1.19"
self-hosted-livesync-version: "1.0.18" self-hosted-livesync-version: "1.0.20"
status: accepted 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. existing renderers instead of being forced through a general abstraction.
Settings pending application which require database initialisation now delegate Settings pending application which require database initialisation now delegate
their decision, scheduling, and restart boundary to `SetupManager` and 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 ## 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 method returns a non-empty array, Obsidian does not call the existing
`display()` implementation. `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 Self-hosted LiveSync still supports Obsidian versions before 1.13 through its
`minAppVersion` of 1.7.2. It must therefore retain an imperative `display()` `minAppVersion` of 1.7.2. It must therefore retain an imperative `display()`
fallback unless the minimum supported Obsidian version is raised separately. 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 appearing to continue the preceding Quick Setup group. The root order reflects
the current task: the current task:
| Current state | First root sections | | Configuration state | First root sections |
| --------------------------- | ----------------------------------------------------------------------------------- | | ------------------- | --------------------------------------------------------------------------------------------------------- |
| Synchronisation is inactive | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General | | Unconfigured | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General |
| Synchronisation is active | Synchronisation (Remote Configuration and Sync Settings), General, then Quick Setup | | 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 Set up other devices is hidden until the plug-in is configured. The remaining
plug-in is configured. The remaining destinations are grouped explicitly: destinations are grouped explicitly:
| Group | Pages | | 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, the same boundary. Constructing the definitions still performs no persistence,
service, file, database, or network operation. service, file, database, or network operation.
The imperative renderer retains its existing default-page selection: Quick Setup for The imperative renderer uses the same stable distinction for its default-page
an inactive configuration and General for an active configuration. The landing selection: Quick Setup for an unconfigured installation and General for a
composition is therefore a native 1.13 improvement rather than a behaviour configured installation. The landing composition is therefore a native 1.13
change for earlier supported Obsidian versions. 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 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 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 again on updates; it must perform no file, database, network, or settings
write. 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 ### Give imperative pages an explicit lifetime and refresh boundary
The present `display()` renders every pane together, so arrays of 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; identifiers and names;
- Appearance, Logging, Extra menus, and Advanced are native-items child pages, - Appearance, Logging, Extra menus, and Advanced are native-items child pages,
and ten child pages retain custom factories; 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 - Remote Configuration and Sync Settings remain native navigable pages inside
the separate Synchronisation group; the separate Synchronisation group;
- maintenance, extra features, advanced settings, and help have explicit page - 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 separate settings renderer used by Obsidian 1.13 and the legacy
`.sls-setting-menu-btn` interface. `.sls-setting-menu-btn` interface.
The Stage C2 landing composition was then exercised on Obsidian 1.13.4. With Before the start-up lifecycle correction, the Stage C2 landing composition was
synchronisation inactive, the real interface rendered Quick Setup, a separate exercised on Obsidian 1.13.4 with a configured installation whose automatic
Synchronisation group containing Remote Configuration and Sync Settings, and a synchronisation triggers were disabled. Under the former predicate, the real
General Settings group containing Appearance, Logging, and Extra menus in the interface rendered Quick Setup, a separate Synchronisation group containing
specified order. It opened all 14 nested settings pages, found the Advanced Remote Configuration and Sync Settings, and a General Settings group containing
control through global settings search, and restored its saved value after Appearance, Logging, and Extra menus in that order. It opened all 14 nested
reopening settings. In mobile test mode, Remote Configuration remained inside settings pages, found the Advanced control through global settings search, and
the initial viewport below the two Quick Setup actions and the Synchronisation restored its saved value after reopening settings. In mobile test mode, Remote
heading. The complete scenario also passed with the same bundle on Obsidian Configuration remained inside the initial viewport below the two Quick Setup
1.12.7, confirming that the imperative fallback retained its navigation and actions and the Synchronisation heading. The complete scenario also passed with
save behaviour. 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 ## Expansion Checkpoints
+16
View File
@@ -2,6 +2,22 @@
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). 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.15
15th August, 2026
### Synchronisation and storage
#### Improved
- Start-up offline scanning is now faster, especially for larger Vaults using path obfuscation (Commonlib 0.1.15).
### Interface and translation
#### 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 ## 1.0.14
14th August, 2026 14th 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: 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 | | Icon | Root group | Contents or availability |
| :--: | ------------------------ | ------------------------------------------------------------- | | :--: | ------------------------ | ------------------------------------------------------------- |
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "1.0.19", "version": "1.0.21",
"minAppVersion": "1.7.2", "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.", "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", "author": "vorotamoroz",
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.19", "version": "1.0.21",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.19", "version": "1.0.21",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"src/apps/cli", "src/apps/cli",
@@ -15813,7 +15813,7 @@
}, },
"src/apps/cli": { "src/apps/cli": {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"version": "1.0.19-cli", "version": "1.0.21-cli",
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"minimatch": "^10.2.5", "minimatch": "^10.2.5",
@@ -15838,7 +15838,7 @@
}, },
"src/apps/webapp": { "src/apps/webapp": {
"name": "livesync-webapp", "name": "livesync-webapp",
"version": "1.0.19-webapp", "version": "1.0.21-webapp",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.53" "octagonal-wheels": "^0.1.53"
}, },
@@ -15850,7 +15850,7 @@
} }
}, },
"src/apps/webpeer": { "src/apps/webpeer": {
"version": "1.0.19-webpeer", "version": "1.0.21-webpeer",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.53" "octagonal-wheels": "^0.1.53"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.19", "version": "1.0.21",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "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", "main": "main.js",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"private": true, "private": true,
"version": "1.0.19-cli", "version": "1.0.21-cli",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "livesync-webapp", "name": "livesync-webapp",
"private": true, "private": true,
"version": "1.0.19-webapp", "version": "1.0.21-webapp",
"type": "module", "type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API", "description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "webpeer", "name": "webpeer",
"private": true, "private": true,
"version": "1.0.19-webpeer", "version": "1.0.21-webpeer",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -8,8 +8,9 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule { export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
settingTab!: ObsidianLiveSyncSettingTab; settingTab!: ObsidianLiveSyncSettingTab;
_everyOnloadStart(): Promise<boolean> { _everyOnloadAfterLoadSettings(): Promise<boolean> {
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin); this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
this.settingTab.reloadAllSettings(true);
this.plugin.addSettingTab(this.settingTab); this.plugin.addSettingTab(this.settingTab);
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting()); 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 : ""}`; return `${"appId" in this.app ? this.app.appId : ""}`;
} }
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void { 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"]);
});
});
@@ -24,7 +24,8 @@ import {
type AllBooleanItemKey, type AllBooleanItemKey,
} from "./settingConstants.ts"; } from "./settingConstants.ts";
import { $msg } from "@/common/translation"; import { $msg } from "@/common/translation";
import { setButtonDestructiveState, wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts"; import { wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
export class LiveSyncSetting extends Setting { export class LiveSyncSetting extends Setting {
autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent; autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent;
@@ -145,22 +145,36 @@ function findPage(tab: ObsidianLiveSyncSettingTab, name: string): SettingDefinit
return page; return page;
} }
function createSettingsTab(): ObsidianLiveSyncSettingTab { type SettingsTabOptions = {
const plugin = { activeReplicatorGetter?: () => { syncStatus: "CONNECTED" | "PAUSED" } | undefined;
app: {}, replicationStatus?: "CLOSED" | "CONNECTED" | "PAUSED";
core: { };
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: { function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab {
askInPopup: vi.fn(), const core = {
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: {
askInPopup: vi.fn(),
},
services: {
setting: {
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
}, },
services: { replicator: {
setting: { replicationStatics: {
getDeviceAndVaultName: vi.fn(() => ""), value: { syncStatus: options.replicationStatus ?? "CLOSED" },
saveSettingData: vi.fn(async () => undefined),
}, },
}, },
}, },
}; };
Object.defineProperty(core, "replicator", {
get: options.activeReplicatorGetter ?? (() => undefined),
});
const plugin = {
app: {},
core,
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never); const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, { Object.assign(tab, {
_editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true }, _editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
@@ -181,8 +195,27 @@ beforeEach(() => {
}); });
describe("ObsidianLiveSyncSettingTab native page lifecycle", () => { 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(); const tab = createSettingsTab();
tab.editingSettings.isConfigured = false;
const definitions = tab.getSettingDefinitions().filter(isGroup); const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([ 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(); const tab = createSettingsTab();
tab.editingSettings.liveSync = true; tab.editingSettings.isConfigured = true;
const definitions = tab.getSettingDefinitions().filter(isGroup); const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([ expect(definitions.slice(0, 4).map(itemLabel)).toEqual([
"🔄 Synchronisation", "🔄 Synchronisation",
"⚙️ General Settings", "⚙️ General Settings",
"📲 Set up other devices",
"🧙‍♂️ Quick Setup", "🧙‍♂️ Quick Setup",
]); ]);
}); });
@@ -40,7 +40,6 @@ import {
eventHub, eventHub,
} from "@/common/events.ts"; } from "@/common/events.ts";
import { import {
enableOnly,
// findAttrFromParent, // findAttrFromParent,
// getLevelStr, // getLevelStr,
setLevelClass, setLevelClass,
@@ -587,19 +586,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
"encrypt", "encrypt",
]); ]);
} }
isAnySyncEnabled() { isLiveSyncConfigured() {
if (this.isConfiguredAs("isConfigured", false)) return false; return this.isConfiguredAs("isConfigured", true);
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;
} }
private supportsDeclarativeSettings(): boolean { private supportsDeclarativeSettings(): boolean {
@@ -905,13 +893,20 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
getPage("help"), getPage("help"),
getPage("change-log"), getPage("change-log"),
]); ]);
const laterGroups = [setupOtherDevices, maintenance, extraFeatures, advancedSettings, helpAndInformation]; const laterGroups = [maintenance, extraFeatures, advancedSettings, helpAndInformation];
const pendingInitialisation = this.createRebuildRequiredAction(); const pendingInitialisation = this.createRebuildRequiredAction();
if (this.isAnySyncEnabled()) { if (this.isLiveSyncConfigured()) {
return [pendingInitialisation, synchronisation, generalSettings, quickSetup, ...laterGroups]; 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 { private beginRenderScope(refresh: () => void): Component {
@@ -936,8 +931,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.controlledElementFunc.length = 0; this.controlledElementFunc.length = 0;
} }
enableOnlySyncDisabled = enableOnly(() => !this.isAnySyncEnabled());
onlyOnP2POrCouchDB = () => onlyOnP2POrCouchDB = () =>
({ ({
visibility: visibility:
@@ -1184,7 +1177,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
void yieldNextAnimationFrame().then(() => { void yieldNextAnimationFrame().then(() => {
if (this.selectedScreen == "") { if (this.selectedScreen == "") {
if (this.isAnySyncEnabled()) { if (this.isLiveSyncConfigured()) {
changeDisplay("20"); changeDisplay("20");
} else { } else {
changeDisplay("110"); changeDisplay("110");
@@ -24,7 +24,8 @@ import {
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts"; import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts"; import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import { setButtonDestructiveState, type PageFunctions } from "./SettingPane.ts"; import type { PageFunctions } from "./SettingPane.ts";
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import { import {
chooseAndCopyFileDatabaseInfo, chooseAndCopyFileDatabaseInfo,
@@ -10,7 +10,8 @@ import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import { setButtonDestructiveState, visibleOnly, type PageFunctions } from "./SettingPane"; import { visibleOnly, type PageFunctions } from "./SettingPane";
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
export function paneMaintenance( export function paneMaintenance(
this: ObsidianLiveSyncSettingTab, this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement, paneEl: HTMLElement,
@@ -10,6 +10,7 @@ import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts"; import type { PageFunctions } from "./SettingPane.ts";
import { visibleOnly } from "./SettingPane.ts"; import { visibleOnly } from "./SettingPane.ts";
import { setButtonAdditionalActionState, setSettingAdditionalActionsState } from "./settingComponentStyles.ts";
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import { ExtraSuffixIndexedDB } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { ExtraSuffixIndexedDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { migrateDatabases } from "./settingUtils.ts"; import { migrateDatabases } from "./settingUtils.ts";
@@ -177,7 +178,7 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch"); new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch");
}); });
void addPanel(paneEl, "Remediation").then((paneEl) => { void addPanel(paneEl, "Remediation").then((paneEl) => {
const setting = new Setting(paneEl); const setting = setSettingAdditionalActionsState(new Setting(paneEl));
const dateEl = setting.controlEl.createSpan(); const dateEl = setting.controlEl.createSpan();
setting setting
.addText((text) => { .addText((text) => {
@@ -215,6 +216,9 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
}) })
.setAuto("maxMTimeForReflectEvents") .setAuto("maxMTimeForReflectEvents")
.addApplyButton(["maxMTimeForReflectEvents"]); .addApplyButton(["maxMTimeForReflectEvents"]);
if (setting.applyButtonComponent) {
setButtonAdditionalActionState(setting.applyButtonComponent);
}
this.addOnSaved("maxMTimeForReflectEvents", async (key) => { this.addOnSaved("maxMTimeForReflectEvents", async (key) => {
const buttons = ["Restart Now", "Later"] as const; const buttons = ["Restart Now", "Later"] as const;
@@ -12,17 +12,33 @@ const remediationHarness = vi.hoisted(() => {
onChange: vi.fn(), onChange: vi.fn(),
setValue: vi.fn(), setValue: vi.fn(),
}; };
const setButtonClassState = vi.fn();
const setSettingClassState = vi.fn();
return { return {
createSpan, createSpan,
dateElement, dateElement,
inputEl, inputEl,
setButtonClassState,
setSettingClassState,
textComponent, textComponent,
}; };
}); });
vi.mock("./LiveSyncSetting.ts", () => ({ vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class LiveSyncSetting { LiveSyncSetting: class LiveSyncSetting {
applyButtonComponent = {
buttonEl: {
classList: {
toggle: remediationHarness.setButtonClassState,
},
},
};
settingEl = {
classList: {
toggle: remediationHarness.setSettingClassState,
},
};
controlEl = { controlEl = {
createSpan: remediationHarness.createSpan, createSpan: remediationHarness.createSpan,
}; };
@@ -93,5 +109,10 @@ describe("panePatches remediation setting", () => {
expect(createSpan).not.toHaveBeenCalled(); expect(createSpan).not.toHaveBeenCalled();
expect(remediationHarness.createSpan).toHaveBeenCalledOnce(); expect(remediationHarness.createSpan).toHaveBeenCalledOnce();
expect(remediationHarness.dateElement.textContent).toBe("No limit configured"); expect(remediationHarness.dateElement.textContent).toBe("No limit configured");
expect(remediationHarness.setSettingClassState).toHaveBeenCalledWith(
"sls-setting-with-additional-actions",
true
);
expect(remediationHarness.setButtonClassState).toHaveBeenCalledWith("sls-setting-additional-action", true);
}); });
}); });
@@ -11,7 +11,12 @@ import { Menu, type ButtonComponent } from "@/deps.ts";
import { $msg } from "@/common/translation"; import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import { setButtonDestructiveState, type PageFunctions } from "./SettingPane.ts"; import {
setButtonAdditionalActionState,
setButtonDestructiveState,
setSettingAdditionalActionsState,
} from "./settingComponentStyles.ts";
import type { PageFunctions } from "./SettingPane.ts";
// import { visibleOnly } from "./SettingPane.ts"; // import { visibleOnly } from "./SettingPane.ts";
import InfoPanel from "./InfoPanel.svelte"; import InfoPanel from "./InfoPanel.svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
@@ -105,7 +110,7 @@ export function paneRemoteConfig(
void addPanel(paneEl, "E2EE Configuration", () => {}).then((paneEl) => { void addPanel(paneEl, "E2EE Configuration", () => {}).then((paneEl) => {
const infoPanel = new SveltePanel(InfoPanel, paneEl, E2EESummaryWritable); const infoPanel = new SveltePanel(InfoPanel, paneEl, E2EESummaryWritable);
this.lifetimeComponent.register(() => infoPanel.destroy()); this.lifetimeComponent.register(() => infoPanel.destroy());
const setupButton = new Setting(paneEl).setName("Configure E2EE"); const setupButton = setSettingAdditionalActionsState(new Setting(paneEl).setName("Configure E2EE"));
setupButton setupButton
.addButton((button) => .addButton((button) =>
setButtonDestructiveState(button) setButtonDestructiveState(button)
@@ -118,7 +123,7 @@ export function paneRemoteConfig(
.setButtonText("Configure") .setButtonText("Configure")
) )
.addButton((button) => .addButton((button) =>
setButtonDestructiveState(button) setButtonDestructiveState(setButtonAdditionalActionState(button))
.onClick(async () => { .onClick(async () => {
const setupManager = this.core.getModule(SetupManager); const setupManager = this.core.getModule(SetupManager);
const originalSettings = getSettingsFromEditingSettings(this.editingSettings); const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
@@ -1,7 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({ const runtime = vi.hoisted(() => ({
buttonClasses: [] as string[],
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>, panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
settingClasses: [] as string[],
})); }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({ vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({
@@ -21,6 +23,13 @@ vi.mock("@/common/translation", () => ({
vi.mock("./LiveSyncSetting.ts", () => ({ vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class { LiveSyncSetting: class {
nameEl = { addClass: vi.fn(), appendText: vi.fn() }; nameEl = { addClass: vi.fn(), appendText: vi.fn() };
settingEl = {
classList: {
toggle: (value: string, enabled: boolean) => {
if (enabled) runtime.settingClasses.push(value);
},
},
};
setName() { setName() {
return this; return this;
@@ -30,7 +39,26 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this; return this;
} }
addButton() { addButton(callback: (button: unknown) => void) {
const button = {
buttonEl: {
classList: {
toggle: (value: string, enabled: boolean) => {
if (enabled) runtime.buttonClasses.push(value);
},
},
},
setDestructive() {
return this;
},
onClick() {
return this;
},
setButtonText() {
return this;
},
};
callback(button);
return this; return this;
} }
@@ -85,7 +113,9 @@ function createPanelElement(): HTMLElement {
} }
afterEach(() => { afterEach(() => {
runtime.buttonClasses.length = 0;
runtime.panels.length = 0; runtime.panels.length = 0;
runtime.settingClasses.length = 0;
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -96,7 +126,13 @@ describe("paneRemoteConfig", () => {
register: vi.fn((callback: () => unknown) => callbacks.push(callback)), register: vi.fn((callback: () => unknown) => callbacks.push(callback)),
unload: vi.fn(() => callbacks.splice(0).forEach((callback) => 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 = { const host = {
editingSettings: { remoteConfigurations: {} }, editingSettings: { remoteConfigurations: {} },
core: { settings: { remoteConfigurations: {} } }, core: { settings: { remoteConfigurations: {} } },
@@ -105,6 +141,8 @@ describe("paneRemoteConfig", () => {
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never); paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(runtime.panels).toHaveLength(1)); await vi.waitFor(() => expect(runtime.panels).toHaveLength(1));
expect(runtime.settingClasses).toContain("sls-setting-with-additional-actions");
expect(runtime.buttonClasses).toEqual(["sls-setting-additional-action"]);
lifetimeComponent.unload(); lifetimeComponent.unload();
@@ -6,7 +6,6 @@ import {
type ConfigLevel, type ConfigLevel,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { AllSettingItemKey, AllSettings } from "./settingConstants"; import type { AllSettingItemKey, AllSettings } from "./settingConstants";
import type { ButtonComponent } from "@/deps.ts";
export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => { export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => {
return () => ({ return () => ({
@@ -39,25 +38,6 @@ export function setStyle(el: HTMLElement, styleHead: string, condition: () => bo
} }
} }
/**
* Applies destructive-action styling without requiring Obsidian 1.13 at
* runtime. Older supported versions used the `mod-warning` class for the same
* presentation.
*/
export function setButtonDestructiveState(button: ButtonComponent, isDestructive = true): ButtonComponent {
const compatibleButton = button as unknown as {
setDestructive?: () => ButtonComponent;
removeDestructive?: () => ButtonComponent;
};
const updateNativeStyle = isDestructive ? compatibleButton.setDestructive : compatibleButton.removeDestructive;
if (typeof updateNativeStyle === "function") {
updateNativeStyle.call(button);
} else {
button.buttonEl.classList.toggle("mod-warning", isDestructive);
}
return button;
}
export function visibleOnly(cond: () => boolean): OnUpdateFunc { export function visibleOnly(cond: () => boolean): OnUpdateFunc {
return () => ({ return () => ({
visibility: cond(), visibility: cond(),
@@ -1,44 +0,0 @@
import type { ButtonComponent } from "@/deps.ts";
import { describe, expect, it, vi } from "vitest";
import { setButtonDestructiveState } from "./SettingPane.ts";
type CompatibleButton = ButtonComponent & {
setDestructive?: () => ButtonComponent;
removeDestructive?: () => ButtonComponent;
};
function createButton(overrides: Partial<CompatibleButton> = {}): CompatibleButton {
return {
buttonEl: {
classList: {
toggle: vi.fn(),
},
},
...overrides,
} as unknown as CompatibleButton;
}
describe("setButtonDestructiveState", () => {
it("uses the native destructive-button API when it is available", () => {
const setDestructive = vi.fn();
const removeDestructive = vi.fn();
const button = createButton({ setDestructive, removeDestructive });
expect(setButtonDestructiveState(button, true)).toBe(button);
expect(setButtonDestructiveState(button, false)).toBe(button);
expect(setDestructive).toHaveBeenCalledOnce();
expect(removeDestructive).toHaveBeenCalledOnce();
expect(button.buttonEl.classList.toggle).not.toHaveBeenCalled();
});
it("uses the legacy warning class when the native API is unavailable", () => {
const button = createButton();
setButtonDestructiveState(button, true);
setButtonDestructiveState(button, false);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(1, "mod-warning", true);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(2, "mod-warning", false);
});
});
@@ -0,0 +1,35 @@
import type { ButtonComponent, Setting } from "@/deps.ts";
const SETTING_WITH_ADDITIONAL_ACTIONS_CLASS = "sls-setting-with-additional-actions";
const ADDITIONAL_ACTION_CLASS = "sls-setting-additional-action";
/**
* Applies destructive-action styling without requiring Obsidian 1.13 at
* runtime. Older supported versions used the `mod-warning` class for the same
* presentation.
*/
export function setButtonDestructiveState<T extends ButtonComponent>(button: T, isDestructive = true): T {
const compatibleButton = button as unknown as {
setDestructive?: () => ButtonComponent;
removeDestructive?: () => ButtonComponent;
};
const updateNativeStyle = isDestructive ? compatibleButton.setDestructive : compatibleButton.removeDestructive;
if (typeof updateNativeStyle === "function") {
updateNativeStyle.call(button);
} else {
button.buttonEl.classList.toggle("mod-warning", isDestructive);
}
return button;
}
/** Sets whether a setting row contains actions which may move onto a later line. */
export function setSettingAdditionalActionsState<T extends Setting>(setting: T, hasAdditionalActions = true): T {
setting.settingEl.classList.toggle(SETTING_WITH_ADDITIONAL_ACTIONS_CLASS, hasAdditionalActions);
return setting;
}
/** Sets whether a button is an additional action which may move onto a later line. */
export function setButtonAdditionalActionState<T extends ButtonComponent>(button: T, isAdditionalAction = true): T {
button.buttonEl.classList.toggle(ADDITIONAL_ACTION_CLASS, isAdditionalAction);
return button;
}
@@ -0,0 +1,88 @@
import type { ButtonComponent, Setting } from "@/deps.ts";
import { describe, expect, it, vi } from "vitest";
import {
setButtonAdditionalActionState,
setButtonDestructiveState,
setSettingAdditionalActionsState,
} from "./settingComponentStyles.ts";
type CompatibleButton = ButtonComponent & {
setDestructive?: () => ButtonComponent;
removeDestructive?: () => ButtonComponent;
};
function createButton(overrides: Partial<CompatibleButton> = {}): CompatibleButton {
return {
buttonEl: {
classList: {
toggle: vi.fn(),
},
},
...overrides,
} as unknown as CompatibleButton;
}
function createSetting(): Setting {
return {
settingEl: {
classList: {
toggle: vi.fn(),
},
},
} as unknown as Setting;
}
describe("setSettingAdditionalActionsState", () => {
it("sets whether the supplied setting row contains additional actions", () => {
const setting = createSetting();
expect(setSettingAdditionalActionsState(setting, true)).toBe(setting);
expect(setSettingAdditionalActionsState(setting, false)).toBe(setting);
expect(setting.settingEl.classList.toggle).toHaveBeenNthCalledWith(
1,
"sls-setting-with-additional-actions",
true
);
expect(setting.settingEl.classList.toggle).toHaveBeenNthCalledWith(
2,
"sls-setting-with-additional-actions",
false
);
});
});
describe("setButtonAdditionalActionState", () => {
it("sets whether the supplied button is an additional action", () => {
const button = createButton();
expect(setButtonAdditionalActionState(button, true)).toBe(button);
expect(setButtonAdditionalActionState(button, false)).toBe(button);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(1, "sls-setting-additional-action", true);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(2, "sls-setting-additional-action", false);
});
});
describe("setButtonDestructiveState", () => {
it("uses the native destructive-button API when it is available", () => {
const setDestructive = vi.fn();
const removeDestructive = vi.fn();
const button = createButton({ setDestructive, removeDestructive });
expect(setButtonDestructiveState(button, true)).toBe(button);
expect(setButtonDestructiveState(button, false)).toBe(button);
expect(setDestructive).toHaveBeenCalledOnce();
expect(removeDestructive).toHaveBeenCalledOnce();
expect(button.buttonEl.classList.toggle).not.toHaveBeenCalled();
});
it("uses the legacy warning class when the native API is unavailable", () => {
const button = createButton();
setButtonDestructiveState(button, true);
setButtonDestructiveState(button, false);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(1, "mod-warning", true);
expect(button.buttonEl.classList.toggle).toHaveBeenNthCalledWith(2, "mod-warning", false);
});
});
+19 -2
View File
@@ -536,14 +536,31 @@ div.workspace-leaf-content[data-type="bases"] .livesync-status {
} }
.sls-setting-panel-title { .sls-setting-panel-title {
position: sticky;
font-size: medium; font-size: medium;
top: 2.5em;
background-color: var(--background-secondary-alt); background-color: var(--background-secondary-alt);
border-radius: 10px; border-radius: 10px;
padding: 0.5em 1em; padding: 0.5em 1em;
} }
body.is-mobile .sls-setting button {
max-width: 100%;
white-space: normal;
}
body.is-mobile .sls-setting-with-additional-actions {
flex-wrap: wrap;
}
body.is-mobile .sls-setting-with-additional-actions .setting-item-control {
min-width: 0;
flex: 1 1 100%;
flex-wrap: wrap;
}
body.is-mobile .sls-setting .sls-setting-additional-action {
flex: 1 1 12rem;
}
.active-pane .sls-setting-panel-title { .active-pane .sls-setting-panel-title {
border: 1px solid var(--interactive-accent); border: 1px solid var(--interactive-accent);
} }
+336 -72
View File
@@ -1,8 +1,12 @@
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import {
assertMobileDialogueLayout,
setObsidianMobileTestMode,
setObsidianMobileTestModeBeforePluginStart,
} from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { import {
allowPendingObsidianTestVaultOpenAction, allowPendingObsidianTestVaultOpenAction,
@@ -14,7 +18,7 @@ import {
withObsidianPage, withObsidianPage,
} from "../runner/ui.ts"; } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.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 uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true"; const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true";
@@ -34,6 +38,11 @@ type LiveSyncTestPlugin = {
applySettings: () => Promise<void>; applySettings: () => Promise<void>;
isP2P: boolean; isP2P: boolean;
}) => Promise<unknown>; }) => Promise<unknown>;
settingTab?: {
editingSettings: { isConfigured: boolean };
initialSettings?: { isConfigured: boolean };
requestCatalogueRefresh(): void;
};
}[]; }[];
settings: { settings: {
handleFilenameCaseSensitive: boolean; handleFilenameCaseSensitive: boolean;
@@ -84,17 +93,14 @@ const settingsPageNames = [
"Change Log", "Change Log",
] as const; ] 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 = [ const labels = [
"Quick Setup", ...setup,
"Synchronisation",
"Remote Configuration",
"Sync Settings",
"General Settings",
"Appearance",
"Logging",
"Extra menus",
"📲 Set up other devices",
"Maintenance and recovery", "Maintenance and recovery",
"Maintenance", "Maintenance",
"Hatch", "Hatch",
@@ -131,45 +137,221 @@ async function assertDeclarativeLandingOrder(root: Locator): Promise<void> {
}, labels); }, labels);
} }
async function scrollDeclarativeLandingToTop(root: Locator): Promise<void> { async function scrollDeclarativeLandingToTop(root: Locator, configured: boolean): Promise<void> {
const quickSetupHeading = root.locator(".setting-item-heading").filter({ hasText: "Quick Setup" }).first(); const firstHeading = root
await quickSetupHeading.waitFor({ state: "visible", timeout: uiTimeoutMs }); .locator(".setting-item-heading")
await quickSetupHeading.scrollIntoViewIfNeeded(); .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(); const port = obsidianRemoteDebuggingPort();
await setObsidianMobileTestMode(port, true, uiTimeoutMs); return await withObsidianPage(port, async (page) => {
try { const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
return await withObsidianPage(port, async (page) => { if (settingsNavigator.renderer !== "declarative") {
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.");
}
await settingsNavigator.close(); 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 { if (remotePosition.top < 0 || remotePosition.bottom > remotePosition.viewportHeight) {
await setObsidianMobileTestMode(port, false, uiTimeoutMs); 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> { async function openSettingsInitialisationDialogueForInspection(isP2P: boolean): Promise<void> {
@@ -437,8 +619,8 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
}); });
} }
async function verifyEffectiveSettings(): Promise<void> { async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const sleepPreferences = await page.evaluate(() => { const sleepPreferences = await page.evaluate(() => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable"); 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); 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"]) { for (const hiddenPage of ["Selector", "Customisation sync", "Advanced", "Power users", "Patches"]) {
if (await settingsNavigator.isPageListed(hiddenPage)) { if (await settingsNavigator.isPageListed(hiddenPage)) {
throw new Error(`${hiddenPage} was visible before its feature level was enabled.`); 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") { if (settingsNavigator.renderer === "declarative") {
await settingsNavigator.returnToCatalogue(); await settingsNavigator.returnToCatalogue();
await scrollDeclarativeLandingToTop(settingsNavigator.dialogue); await scrollDeclarativeLandingToTop(settingsNavigator.dialogue, true);
await settingsNavigator.dialogue.screenshot({ await settingsNavigator.dialogue.screenshot({
...settingsScreenshotOptions, ...settingsScreenshotOptions,
path: `${diagnosticsDirectory}/settings-declarative-landing.png`, 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 const rerunOnboarding = settingsNavigator.dialogue
.locator(".setting-item-name") .locator(".setting-item-name")
.filter({ hasText: "Rerun Onboarding Wizard" }) .filter({ hasText: "Rerun Onboarding Wizard" })
@@ -647,7 +845,9 @@ async function verifyEffectiveSettings(): Promise<void> {
} }
} }
const renderer = settingsNavigator.renderer;
await settingsNavigator.close(); 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 }), has: settingsNavigator.page.getByText("Changes need to be applied!", { exact: true }),
}); });
await applySetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); 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( const choiceDialogue = await waitForVisibleObsidianDialogue(
settingsNavigator.page, 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> { async function main(): Promise<void> {
const binary = requireObsidianBinary(); const binary = requireObsidianBinary();
const cli = discoverObsidianCli(); const cli = discoverObsidianCli();
@@ -759,29 +1029,14 @@ async function main(): Promise<void> {
const vault = await createTemporaryVault(); const vault = await createTemporaryVault();
await mkdir(diagnosticsDirectory, { recursive: true }); await mkdir(diagnosticsDirectory, { recursive: true });
let session: ObsidianLiveSyncSession | undefined; let session: ObsidianLiveSyncSession | undefined;
let settingsRenderer: "declarative" | "imperative" | undefined;
try { try {
session = await startObsidianLiveSyncSession({ session = await startObsidianLiveSyncSession({
binary, binary,
cliBinary: cli.binary, cliBinary: cli.binary,
vault, vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: { pluginData: createSettingsPluginData(settingsOnly),
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,
},
lifecycle: settingsOnly lifecycle: settingsOnly
? { ? {
afterLaunch: async ({ remoteDebuggingPort }) => { afterLaunch: async ({ remoteDebuggingPort }) => {
@@ -798,11 +1053,9 @@ async function main(): Promise<void> {
await verifyCompatibilityReview(); await verifyCompatibilityReview();
await verifyConfigDoctorFollowsCompatibilityReview(); await verifyConfigDoctorFollowsCompatibilityReview();
} }
await verifyEffectiveSettings(); settingsRenderer = await verifyEffectiveSettings();
const initialisation = await verifyPendingSettingsInitialisationFlow(); const initialisation = await verifyPendingSettingsInitialisationFlow();
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue(); const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
const mobileLanding = await captureDeclarativeMobileLanding();
if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`);
console.log( console.log(
`Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}` `Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}`
); );
@@ -813,6 +1066,17 @@ async function main(): Promise<void> {
} }
await vault.dispose(); 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) => { main().catch((error: unknown) => {
+17 -18
View File
@@ -12,17 +12,32 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased ## Unreleased
## 1.0.19 ## 1.0.21
26th August, 2026
It is becoming more 'ordinary' with each release, but please let me know if anything has become less convenient.
### Interface and translation
#### 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 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 ### Interface and translation
#### Fixed #### Fixed
- Compatibility pause warnings now direct you to the dedicated compatibility review instead of the Change Log. - 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 #### Improved
@@ -78,19 +93,3 @@ I had grown quite fond of the settings screen, but it seems that a simpler, heal
- One-shot CouchDB synchronisation now releases stalled web-compatible connection checks before replication starts, so a later synchronisation can make a fresh attempt (Commonlib 0.1.16). - One-shot CouchDB synchronisation now releases stalled web-compatible connection checks before replication starts, so a later synchronisation can make a fresh attempt (Commonlib 0.1.16).
- The 60-second safeguard applies only to pre-replication checks. It does not limit ordinary synchronisation, and the **Use Internal API** path is unchanged. - The 60-second safeguard applies only to pre-replication checks. It does not limit ordinary synchronisation, and the **Use Internal API** path is unchanged.
## 1.0.15
15th August, 2026
### Synchronisation and storage
#### Improved
- Start-up offline scanning is now faster, especially for larger Vaults using path obfuscation (Commonlib 0.1.15).
### Interface and translation
#### 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!
+3 -1
View File
@@ -31,5 +31,7 @@
"1.0.16": "1.7.2", "1.0.16": "1.7.2",
"1.0.17": "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.19": "1.7.2",
"1.0.20": "1.7.2",
"1.0.21": "1.7.2"
} }