mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-31 15:57:06 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b28871ab67 | ||
|
|
011a8405b5 | ||
|
|
f5f7aab11f | ||
|
|
9854319c96 | ||
|
|
4ab0689c7f | ||
|
|
edeac6f7e2 | ||
|
|
783fbb8f23 | ||
|
|
8644af6128 | ||
|
|
4ff5b4dfe8 | ||
|
|
0b1f5ca719 |
@@ -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).
|
||||
|
||||
## 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
|
||||
|
||||
14th August, 2026
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.21",
|
||||
"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",
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.21",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -15813,7 +15813,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.20-cli",
|
||||
"version": "1.0.21-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -15838,7 +15838,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.20-webapp",
|
||||
"version": "1.0.21-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
},
|
||||
@@ -15850,7 +15850,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.20-webpeer",
|
||||
"version": "1.0.21-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.53"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.20",
|
||||
"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.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.20-cli",
|
||||
"version": "1.0.21-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.20-webapp",
|
||||
"version": "1.0.21-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.20-webpeer",
|
||||
"version": "1.0.21-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
type AllBooleanItemKey,
|
||||
} from "./settingConstants.ts";
|
||||
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 {
|
||||
autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent;
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.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 {
|
||||
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 { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
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(
|
||||
this: ObsidianLiveSyncSettingTab,
|
||||
paneEl: HTMLElement,
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { setButtonAdditionalActionState, setSettingAdditionalActionsState } from "./settingComponentStyles.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 +178,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 = setSettingAdditionalActionsState(new Setting(paneEl));
|
||||
const dateEl = setting.controlEl.createSpan();
|
||||
setting
|
||||
.addText((text) => {
|
||||
@@ -215,6 +216,9 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
})
|
||||
.setAuto("maxMTimeForReflectEvents")
|
||||
.addApplyButton(["maxMTimeForReflectEvents"]);
|
||||
if (setting.applyButtonComponent) {
|
||||
setButtonAdditionalActionState(setting.applyButtonComponent);
|
||||
}
|
||||
|
||||
this.addOnSaved("maxMTimeForReflectEvents", async (key) => {
|
||||
const buttons = ["Restart Now", "Later"] as const;
|
||||
|
||||
@@ -12,17 +12,33 @@ const remediationHarness = vi.hoisted(() => {
|
||||
onChange: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
};
|
||||
const setButtonClassState = vi.fn();
|
||||
const setSettingClassState = vi.fn();
|
||||
|
||||
return {
|
||||
createSpan,
|
||||
dateElement,
|
||||
inputEl,
|
||||
setButtonClassState,
|
||||
setSettingClassState,
|
||||
textComponent,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class LiveSyncSetting {
|
||||
applyButtonComponent = {
|
||||
buttonEl: {
|
||||
classList: {
|
||||
toggle: remediationHarness.setButtonClassState,
|
||||
},
|
||||
},
|
||||
};
|
||||
settingEl = {
|
||||
classList: {
|
||||
toggle: remediationHarness.setSettingClassState,
|
||||
},
|
||||
};
|
||||
controlEl = {
|
||||
createSpan: remediationHarness.createSpan,
|
||||
};
|
||||
@@ -93,5 +109,10 @@ describe("panePatches remediation setting", () => {
|
||||
expect(createSpan).not.toHaveBeenCalled();
|
||||
expect(remediationHarness.createSpan).toHaveBeenCalledOnce();
|
||||
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 { LiveSyncSetting as Setting } from "./LiveSyncSetting.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 InfoPanel from "./InfoPanel.svelte";
|
||||
import { writable } from "svelte/store";
|
||||
@@ -105,7 +110,7 @@ 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 = setSettingAdditionalActionsState(new Setting(paneEl).setName("Configure E2EE"));
|
||||
setupButton
|
||||
.addButton((button) =>
|
||||
setButtonDestructiveState(button)
|
||||
@@ -118,7 +123,7 @@ export function paneRemoteConfig(
|
||||
.setButtonText("Configure")
|
||||
)
|
||||
.addButton((button) =>
|
||||
setButtonDestructiveState(button)
|
||||
setButtonDestructiveState(setButtonAdditionalActionState(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", () => ({
|
||||
@@ -21,6 +23,13 @@ vi.mock("@/common/translation", () => ({
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
nameEl = { addClass: vi.fn(), appendText: vi.fn() };
|
||||
settingEl = {
|
||||
classList: {
|
||||
toggle: (value: string, enabled: boolean) => {
|
||||
if (enabled) runtime.settingClasses.push(value);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setName() {
|
||||
return this;
|
||||
@@ -30,7 +39,26 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -85,7 +113,9 @@ function createPanelElement(): HTMLElement {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
runtime.buttonClasses.length = 0;
|
||||
runtime.panels.length = 0;
|
||||
runtime.settingClasses.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -96,7 +126,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 +141,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-with-additional-actions");
|
||||
expect(runtime.buttonClasses).toEqual(["sls-setting-additional-action"]);
|
||||
|
||||
lifetimeComponent.unload();
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type ConfigLevel,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { AllSettingItemKey, AllSettings } from "./settingConstants";
|
||||
import type { ButtonComponent } from "@/deps.ts";
|
||||
|
||||
export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => {
|
||||
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 {
|
||||
return () => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -291,6 +291,15 @@ export async function adjustSettingToRemote(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (operation === "rebuild") {
|
||||
// An overwrite makes this device authoritative for both the Vault contents and the
|
||||
// shared synchronisation settings. The remote lookup above remains a connection
|
||||
// preflight, but settings from the database which is about to be replaced must not
|
||||
// overwrite intentional local changes such as enabling E2EE.
|
||||
log("Rebuild will use this device's synchronisation settings.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
|
||||
const remoteTweaks = remoteResult.values;
|
||||
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
||||
// Check if any necessary tweak value is different from current config.
|
||||
|
||||
@@ -1149,6 +1149,33 @@ describe("Red Flag Feature", () => {
|
||||
});
|
||||
|
||||
describe("Remote configuration adjustment", () => {
|
||||
it("keeps this device's E2EE settings when preparing to overwrite the remote", async () => {
|
||||
const host = createHostMock();
|
||||
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
|
||||
encrypt: true,
|
||||
passphrase: "local-encryption-passphrase",
|
||||
});
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({
|
||||
...TweakValuesShouldMatchedTemplate,
|
||||
encrypt: false,
|
||||
})
|
||||
);
|
||||
|
||||
const result = await adjustSettingToRemote(
|
||||
host as any,
|
||||
createLoggerMock(),
|
||||
host.mocks.setting.currentSettings(),
|
||||
"rebuild"
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
|
||||
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
|
||||
expect(host.mocks.setting.currentSettings().passphrase).toBe("local-encryption-passphrase");
|
||||
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should skip remote configuration fetch when preventFetchingConfig is true", async () => {
|
||||
const host = createHostMock();
|
||||
const config = { preventFetchingConfig: true } as any;
|
||||
@@ -1855,8 +1882,15 @@ describe("Red Flag Feature", () => {
|
||||
it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => {
|
||||
const host = createHostMock();
|
||||
const log = createLoggerMock();
|
||||
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
|
||||
encrypt: true,
|
||||
passphrase: "local-encryption-passphrase",
|
||||
});
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
||||
availableRemoteTweaks({ customChunkSize: 1 })
|
||||
availableRemoteTweaks({
|
||||
...TweakValuesShouldMatchedTemplate,
|
||||
encrypt: false,
|
||||
})
|
||||
);
|
||||
|
||||
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
||||
@@ -1868,6 +1902,8 @@ describe("Red Flag Feature", () => {
|
||||
await Promise.resolve(eventHandler());
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalled();
|
||||
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
|
||||
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
|
||||
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+19
-2
@@ -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-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 {
|
||||
border: 1px solid var(--interactive-accent);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
deleteCouchDbDatabase,
|
||||
fetchCouchDbDocument,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
putCouchDbDocument,
|
||||
waitForCouchDbDocs,
|
||||
type CouchDbConfig,
|
||||
} from "../runner/couchdb.ts";
|
||||
@@ -28,7 +31,7 @@ import {
|
||||
continueWithoutRemoteSettings,
|
||||
type SetupArtifact,
|
||||
} from "../runner/setupUri.ts";
|
||||
import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
|
||||
import { captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
||||
@@ -44,6 +47,10 @@ const captures = {
|
||||
scenario: "couchdb-manual-setup-workflow",
|
||||
guide: "couchdb-manual",
|
||||
} as const;
|
||||
const e2eeRebuildCaptures = {
|
||||
scenario: "couchdb-manual-setup-workflow",
|
||||
guide: "couchdb-manual-e2ee-rebuild",
|
||||
} as const;
|
||||
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
@@ -111,9 +118,7 @@ async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig,
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const method = modalByTitle(page, "Connection Method");
|
||||
await selectRadioOption(method, "Configure a remote manually");
|
||||
await method
|
||||
.getByRole("button", { name: "Proceed with manual configuration" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await method.getByRole("button", { name: "Proceed with manual configuration" }).click({ timeout: uiTimeoutMs });
|
||||
|
||||
const encryption = modalByTitle(page, "End-to-End Encryption");
|
||||
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
@@ -246,6 +251,111 @@ async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; c
|
||||
});
|
||||
}
|
||||
|
||||
async function assertPersistedE2EE(vault: TemporaryVault): Promise<void> {
|
||||
const persisted = JSON.parse(
|
||||
await readFile(join(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"), "utf8")
|
||||
) as {
|
||||
encrypt?: unknown;
|
||||
encryptedPassphrase?: unknown;
|
||||
passphrase?: unknown;
|
||||
};
|
||||
assertEqual(persisted.encrypt, true, "Manual CouchDB setup did not persist E2EE as enabled.");
|
||||
assertEqual(persisted.passphrase, "", "Manual CouchDB setup persisted the E2EE passphrase in plain text.");
|
||||
if (typeof persisted.encryptedPassphrase !== "string" || persisted.encryptedPassphrase.length === 0) {
|
||||
throw new Error("Manual CouchDB setup did not persist an encrypted E2EE passphrase.");
|
||||
}
|
||||
}
|
||||
|
||||
async function setRemotePreferredE2EEDisabled(context: RunnerContext): Promise<void> {
|
||||
const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID);
|
||||
const tweakValues = milestone.tweak_values;
|
||||
if (typeof tweakValues !== "object" || tweakValues === null || Array.isArray(tweakValues)) {
|
||||
throw new Error("The existing CouchDB milestone did not contain synchronisation settings.");
|
||||
}
|
||||
const preferred = (tweakValues as Record<string, unknown>)[DEVICE_ID_PREFERRED];
|
||||
if (typeof preferred !== "object" || preferred === null || Array.isArray(preferred)) {
|
||||
throw new Error("The existing CouchDB milestone did not contain preferred synchronisation settings.");
|
||||
}
|
||||
await putCouchDbDocument(context.couchDb, context.dbName, {
|
||||
...milestone,
|
||||
tweak_values: {
|
||||
...tweakValues,
|
||||
[DEVICE_ID_PREFERRED]: {
|
||||
...(preferred as Record<string, unknown>),
|
||||
encrypt: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function assertRemotePreferredE2EE(context: RunnerContext, expected: boolean): Promise<void> {
|
||||
const milestone = await fetchCouchDbDocument(context.couchDb, context.dbName, MILESTONE_DOCID);
|
||||
const tweakValues = milestone.tweak_values;
|
||||
const preferred =
|
||||
typeof tweakValues === "object" && tweakValues !== null && !Array.isArray(tweakValues)
|
||||
? (tweakValues as Record<string, unknown>)[DEVICE_ID_PREFERRED]
|
||||
: undefined;
|
||||
const encrypt =
|
||||
typeof preferred === "object" && preferred !== null && !Array.isArray(preferred)
|
||||
? (preferred as Record<string, unknown>).encrypt
|
||||
: undefined;
|
||||
assertEqual(encrypt, expected, `The remote preferred E2EE setting was not ${expected ? "enabled" : "disabled"}.`);
|
||||
}
|
||||
|
||||
async function scheduleRemoteOverwrite(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const maintenance = await settingsNavigator.openPage("Maintenance");
|
||||
const overwrite = maintenance
|
||||
.locator(".setting-item")
|
||||
.filter({ hasText: "Overwrite Server Data with This Device's Files" });
|
||||
await overwrite
|
||||
.getByRole("button", { name: "Schedule and Restart", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function assertRemoteEntryEncrypted(
|
||||
context: RunnerContext,
|
||||
entry: { id: string; path: string; children: string[] },
|
||||
plaintextPath: string,
|
||||
plaintext: string
|
||||
): Promise<void> {
|
||||
const remoteMetadata = await fetchCouchDbDocument(context.couchDb, context.dbName, entry.id);
|
||||
const serialisedMetadata = JSON.stringify(remoteMetadata);
|
||||
if (
|
||||
!remoteMetadata._id.startsWith("f:") ||
|
||||
typeof remoteMetadata.path !== "string" ||
|
||||
!remoteMetadata.path.startsWith("/\\:") ||
|
||||
remoteMetadata.path === entry.path ||
|
||||
serialisedMetadata.includes(plaintextPath) ||
|
||||
!Array.isArray(remoteMetadata.children) ||
|
||||
remoteMetadata.children.length !== 0 ||
|
||||
remoteMetadata.mtime !== 0 ||
|
||||
remoteMetadata.ctime !== 0 ||
|
||||
remoteMetadata.size !== 0
|
||||
) {
|
||||
throw new Error("The directly fetched CouchDB Metadata document did not protect its properties.");
|
||||
}
|
||||
|
||||
const childId = entry.children[0];
|
||||
if (!childId) {
|
||||
throw new Error("The local E2EE test entry did not reference a Chunk document.");
|
||||
}
|
||||
if (!childId.startsWith("h:+")) {
|
||||
throw new Error(`The E2EE test entry used an unencrypted Chunk identifier: ${childId}`);
|
||||
}
|
||||
const remoteChunk = await fetchCouchDbDocument(context.couchDb, context.dbName, childId);
|
||||
assertEqual(remoteChunk.e_, true, "The directly fetched CouchDB Chunk was not marked as encrypted.");
|
||||
if (
|
||||
typeof remoteChunk.data !== "string" ||
|
||||
remoteChunk.data === plaintext ||
|
||||
remoteChunk.data.includes(plaintext)
|
||||
) {
|
||||
throw new Error("The directly fetched CouchDB Chunk contained readable Vault content.");
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -288,11 +398,37 @@ async function main(): Promise<void> {
|
||||
1,
|
||||
"Manual CouchDB setup did not persist exactly one remote profile."
|
||||
);
|
||||
await assertPersistedE2EE(vaultA);
|
||||
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
|
||||
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForRemoteEntry(context, entry);
|
||||
} catch (error) {
|
||||
await captureFailure(session, "first-device");
|
||||
throw error;
|
||||
} finally {
|
||||
await stopTrackedSession(context, session);
|
||||
}
|
||||
|
||||
await setRemotePreferredE2EEDisabled(context);
|
||||
await assertRemotePreferredE2EE(context, false);
|
||||
|
||||
session = await startUnconfiguredSession(context, vaultA);
|
||||
try {
|
||||
await scheduleRemoteOverwrite(session.remoteDebuggingPort);
|
||||
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, e2eeRebuildCaptures));
|
||||
screenshots.push(
|
||||
await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, e2eeRebuildCaptures)
|
||||
);
|
||||
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
||||
await assertPersistedE2EE(vaultA);
|
||||
|
||||
const rebuiltEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
|
||||
await waitForRemoteEntry(context, rebuiltEntry);
|
||||
await assertRemoteEntryEncrypted(context, rebuiltEntry, notePath, noteContent);
|
||||
await assertRemotePreferredE2EE(context, true);
|
||||
|
||||
const generated = await generateSetupURIFromDevice(
|
||||
session.remoteDebuggingPort,
|
||||
@@ -302,7 +438,7 @@ async function main(): Promise<void> {
|
||||
secondDeviceArtifact = generated.artifact;
|
||||
screenshots.push(...generated.screenshots);
|
||||
} catch (error) {
|
||||
await captureFailure(session, "first-device");
|
||||
await captureFailure(session, "e2ee-rebuild");
|
||||
throw error;
|
||||
} finally {
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
@@ -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,
|
||||
@@ -160,39 +164,194 @@ async function setConfiguredStateForLandingInspection(page: Page, configured: bo
|
||||
}, configured);
|
||||
}
|
||||
|
||||
async function captureDeclarativeMobileLanding(): Promise<string | undefined> {
|
||||
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, 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 };
|
||||
});
|
||||
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> {
|
||||
@@ -795,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();
|
||||
@@ -804,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 }) => {
|
||||
@@ -843,11 +1053,9 @@ async function main(): Promise<void> {
|
||||
await verifyCompatibilityReview();
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
}
|
||||
const settingsRenderer = await verifyEffectiveSettings();
|
||||
settingsRenderer = await verifyEffectiveSettings();
|
||||
const initialisation = await verifyPendingSettingsInitialisationFlow();
|
||||
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
|
||||
const mobileLanding = settingsRenderer === "declarative" ? await captureDeclarativeMobileLanding() : undefined;
|
||||
if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`);
|
||||
console.log(
|
||||
`Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}`
|
||||
);
|
||||
@@ -858,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) => {
|
||||
|
||||
+18
-16
@@ -12,6 +12,24 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **Overwrite Server Data with This Device's Files** now keeps this device's synchronisation settings instead of reapplying settings from the remote database which is about to be replaced. Enabling E2EE before a rebuild therefore remains enabled and uploads encrypted data. (#1146)
|
||||
|
||||
## 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.
|
||||
@@ -81,19 +99,3 @@ I know this is the second time I have said it, but I had grown quite fond of the
|
||||
|
||||
- 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.
|
||||
|
||||
## 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!
|
||||
|
||||
+2
-1
@@ -32,5 +32,6 @@
|
||||
"1.0.17": "1.7.2",
|
||||
"1.0.18": "1.7.2",
|
||||
"1.0.19": "1.7.2",
|
||||
"1.0.20": "1.7.2"
|
||||
"1.0.20": "1.7.2",
|
||||
"1.0.21": "1.7.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user