Compare commits

..
Author SHA1 Message Date
vorotamoroz 4b47ebbd4d fix(cli): install complete systemd runtime 2026-08-30 09:38:34 +00:00
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
26 changed files with 796 additions and 168 deletions
+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).
## 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
View File
@@ -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",
+5 -5
View File
@@ -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
View File
@@ -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",
+6 -4
View File
@@ -522,10 +522,12 @@ bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
```
The script:
1. Builds the CLI (`npm install` + `npm run build`).
2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`.
1. Installs the repository dependencies and builds the CLI.
2. Installs the complete CLI bundle and its production dependencies under `~/.local/lib/livesync-cli` (user) or `/usr/local/lib/livesync-cli` (system), then checks that the installed CLI can start.
3. Installs the command wrapper as `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
4. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
5. Reloads systemd, enables and starts the service, and reports success only after confirming that the service remains active.
**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory.
+57 -8
View File
@@ -8,7 +8,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)"
CLI_DIR="$REPO_ROOT/src/apps/cli"
SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service"
@@ -104,30 +104,70 @@ fi
# ── Install binary ───────────────────────────────────────────────────────────
if [[ "$INSTALL_MODE" == "user" ]]; then
BIN_DIR="$HOME/.local/bin"
LIB_DIR="$HOME/.local/lib/livesync-cli"
UNIT_DIR="$HOME/.config/systemd/user"
SYSTEMCTL_FLAGS="--user"
else
BIN_DIR="/usr/local/bin"
LIB_DIR="/usr/local/lib/livesync-cli"
UNIT_DIR="/etc/systemd/system"
SYSTEMCTL_FLAGS=""
fi
mkdir -p "$BIN_DIR"
LIB_PARENT="$(dirname -- "$LIB_DIR")"
mkdir -p "$BIN_DIR" "$LIB_PARENT"
LIVESYNC_BIN="$BIN_DIR/livesync-cli"
LIVESYNC_JS="$BIN_DIR/livesync-cli.js"
LIVESYNC_JS="$LIB_DIR/dist/index.cjs"
# Copy the CJS bundle so the wrapper is self-contained and independent of the
# build directory location.
cp "$BUILT_CJS" "$LIVESYNC_JS"
# Build a complete runtime payload before replacing any previous installation.
# The Vite output contains hashed sibling chunks, while some Node dependencies
# deliberately remain external and must be installed next to the bundle.
PAYLOAD_STAGING="$(mktemp -d "$LIB_PARENT/.livesync-cli.install.XXXXXX")"
cleanup_payload() {
if [[ -n "$PAYLOAD_STAGING" ]] && [[ -e "$PAYLOAD_STAGING" ]]; then
rm -rf -- "$PAYLOAD_STAGING"
fi
}
trap cleanup_payload EXIT
# Write a bash wrapper that invokes node on the installed bundle.
cp "$CLI_DIR/package.json" "$PAYLOAD_STAGING/package.json"
npm install --omit=dev --no-audit --no-fund --prefix "$PAYLOAD_STAGING"
cp -R "$CLI_DIR/dist" "$PAYLOAD_STAGING/dist"
if ! node "$PAYLOAD_STAGING/dist/index.cjs" --help >/dev/null; then
echo "Error: installed CLI failed its start-up check" >&2
exit 1
fi
PAYLOAD_BACKUP=""
if [[ -e "$LIB_DIR" ]] || [[ -L "$LIB_DIR" ]]; then
PAYLOAD_BACKUP="$(mktemp -d "$LIB_PARENT/.livesync-cli.backup.XXXXXX")"
rmdir "$PAYLOAD_BACKUP"
mv -- "$LIB_DIR" "$PAYLOAD_BACKUP"
fi
if ! mv -- "$PAYLOAD_STAGING" "$LIB_DIR"; then
if [[ -n "$PAYLOAD_BACKUP" ]]; then
mv -- "$PAYLOAD_BACKUP" "$LIB_DIR"
fi
echo "Error: failed to install the CLI files at $LIB_DIR" >&2
exit 1
fi
PAYLOAD_STAGING=""
if [[ -n "$PAYLOAD_BACKUP" ]]; then
rm -rf -- "$PAYLOAD_BACKUP"
fi
trap - EXIT
# Write a bash wrapper that invokes Node.js on the installed payload.
cat > "$LIVESYNC_BIN" <<WRAPPER
#!/usr/bin/env bash
exec node "$LIVESYNC_JS" "\$@"
WRAPPER
chmod +x "$LIVESYNC_BIN"
echo "[INFO] Installed bundle: $LIVESYNC_JS"
echo "[INFO] Installed CLI files: $LIB_DIR"
echo "[INFO] Installed binary: $LIVESYNC_BIN"
# ── Write systemd unit ───────────────────────────────────────────────────────
@@ -180,6 +220,15 @@ systemctl $SYSTEMCTL_FLAGS daemon-reload
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
sleep 1
# shellcheck disable=SC2086
if ! systemctl $SYSTEMCTL_FLAGS is-active --quiet livesync-cli; then
echo "Error: livesync-cli service did not remain active after startup." >&2
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true
exit 1
fi
echo ""
echo "[Done] livesync-cli service installed and started."
echo ""
+192
View File
@@ -0,0 +1,192 @@
import { spawnSync } from "node:child_process";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const deploySourceDirectory = dirname(fileURLToPath(import.meta.url));
const temporaryDirectories: string[] = [];
type InstallerFixture = {
cliDirectory: string;
environment: NodeJS.ProcessEnv;
homeDirectory: string;
installerPath: string;
npmCallLog: string;
repositoryRoot: string;
systemctlCallLog: string;
vaultDirectory: string;
};
async function writeExecutable(path: string, content: string): Promise<void> {
await writeFile(path, `${content}\n`, "utf8");
await chmod(path, 0o755);
}
async function createInstallerFixture(serviceActive: boolean): Promise<InstallerFixture> {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "livesync-cli-installer-"));
temporaryDirectories.push(temporaryDirectory);
const repositoryRoot = join(temporaryDirectory, "repository");
const cliDirectory = join(repositoryRoot, "src", "apps", "cli");
const deployDirectory = join(cliDirectory, "deploy");
const distDirectory = join(cliDirectory, "dist");
const fakeBinDirectory = join(temporaryDirectory, "fake-bin");
const homeDirectory = join(temporaryDirectory, "home");
const vaultDirectory = join(temporaryDirectory, "vault");
const npmCallLog = join(temporaryDirectory, "npm-calls.log");
const systemctlCallLog = join(temporaryDirectory, "systemctl-calls.log");
await Promise.all([
mkdir(deployDirectory, { recursive: true }),
mkdir(distDirectory, { recursive: true }),
mkdir(fakeBinDirectory, { recursive: true }),
mkdir(homeDirectory, { recursive: true }),
mkdir(vaultDirectory, { recursive: true }),
]);
await Promise.all([
copyFile(join(deploySourceDirectory, "install.sh"), join(deployDirectory, "install.sh")),
copyFile(join(deploySourceDirectory, "livesync-cli.service"), join(deployDirectory, "livesync-cli.service")),
writeFile(
join(repositoryRoot, "package.json"),
JSON.stringify({ private: true, workspaces: ["src/apps/*"] }),
"utf8"
),
writeFile(
join(cliDirectory, "package.json"),
JSON.stringify({
name: "self-hosted-livesync-cli",
private: true,
version: "0.0.0",
dependencies: { "fixture-runtime-dependency": "1.0.0" },
}),
"utf8"
),
writeFile(
join(distDirectory, "index.cjs"),
'const chunk = require("./chunk.cjs");\n' +
'const dependency = require("fixture-runtime-dependency");\n' +
"process.stdout.write(`${chunk}:${dependency}\\n`);\n",
"utf8"
),
writeFile(join(distDirectory, "chunk.cjs"), 'module.exports = "chunk-ready";\n', "utf8"),
]);
await writeExecutable(
join(fakeBinDirectory, "npm"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s|%s\\n\' "$PWD" "$*" >> "$NPM_CALL_LOG"',
'prefix=""',
"expect_prefix=0",
'for argument in "$@"; do',
' if [[ "$expect_prefix" -eq 1 ]]; then',
' prefix="$argument"',
" expect_prefix=0",
' elif [[ "$argument" == "--prefix" ]]; then',
" expect_prefix=1",
" fi",
"done",
'if [[ -n "$prefix" ]]; then',
' mkdir -p "$prefix/node_modules/fixture-runtime-dependency"',
" printf '%s\\n' 'module.exports = \"dependency-ready\";' > \"$prefix/node_modules/fixture-runtime-dependency/index.js\"",
"fi",
].join("\n")
);
await writeExecutable(
join(fakeBinDirectory, "systemctl"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s\\n\' "$*" >> "$SYSTEMCTL_CALL_LOG"',
'if [[ " $* " == *" is-active "* ]]; then',
' [[ "${FAKE_SYSTEMCTL_ACTIVE:-1}" == "1" ]]',
" exit",
"fi",
'if [[ " $* " == *" status "* ]]; then',
" printf '%s\\n' \"fixture service status\"",
"fi",
].join("\n")
);
await writeExecutable(join(fakeBinDirectory, "sleep"), ["#!/usr/bin/env bash", "exit 0"].join("\n"));
return {
cliDirectory,
environment: {
...process.env,
FAKE_SYSTEMCTL_ACTIVE: serviceActive ? "1" : "0",
HOME: homeDirectory,
NPM_CALL_LOG: npmCallLog,
PATH: `${fakeBinDirectory}${delimiter}${process.env.PATH ?? ""}`,
SYSTEMCTL_CALL_LOG: systemctlCallLog,
},
homeDirectory,
installerPath: join(deployDirectory, "install.sh"),
npmCallLog,
repositoryRoot,
systemctlCallLog,
vaultDirectory,
};
}
function runInstaller(fixture: InstallerFixture) {
return spawnSync("bash", [fixture.installerPath, "--vault", fixture.vaultDirectory], {
encoding: "utf8",
env: fixture.environment,
});
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
describe.skipIf(process.platform === "win32")("CLI systemd installer", () => {
it("installs a runnable CLI independently of the source repository", async () => {
const fixture = await createInstallerFixture(true);
const installation = runInstaller(fixture);
expect(installation.error).toBeUndefined();
expect(installation.status, installation.stderr).toBe(0);
expect(installation.stdout).toContain("[Done] livesync-cli service installed and started.");
const installedCommand = join(fixture.homeDirectory, ".local", "bin", "livesync-cli");
const installedPayload = join(fixture.homeDirectory, ".local", "lib", "livesync-cli", "dist", "index.cjs");
const installedUnit = join(fixture.homeDirectory, ".config", "systemd", "user", "livesync-cli.service");
expect(await readFile(installedPayload, "utf8")).toContain('require("./chunk.cjs")');
expect(await readFile(installedUnit, "utf8")).toContain("Type=exec");
await rm(fixture.repositoryRoot, { recursive: true });
const command = spawnSync(installedCommand, [], { encoding: "utf8", env: fixture.environment });
expect(command.error).toBeUndefined();
expect(command.status, command.stderr).toBe(0);
expect(command.stdout).toBe("chunk-ready:dependency-ready\n");
const npmCalls = await readFile(fixture.npmCallLog, "utf8");
expect(npmCalls).toContain(`${fixture.repositoryRoot}|install --silent`);
expect(npmCalls).toContain(`${fixture.cliDirectory}|run build`);
expect(npmCalls).toMatch(/install .*--omit=dev|install --omit=dev/);
const systemctlCalls = await readFile(fixture.systemctlCallLog, "utf8");
expect(systemctlCalls).toContain("--user enable --now livesync-cli");
expect(systemctlCalls).toContain("--user is-active --quiet livesync-cli");
});
it("does not report success when the service fails to remain active", async () => {
const fixture = await createInstallerFixture(false);
const installation = runInstaller(fixture);
const combinedOutput = `${installation.stdout}\n${installation.stderr}`;
expect(installation.error).toBeUndefined();
expect(installation.status).not.toBe(0);
expect(combinedOutput).toContain("service did not remain active after startup");
expect(combinedOutput).not.toContain("[Done]");
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Type=exec
ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH
Restart=on-failure
RestartSec=10
+2 -2
View File
@@ -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": {
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
+1 -1
View File
@@ -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 -1
View File
@@ -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);
});
});
+19 -2
View File
@@ -536,14 +536,31 @@ div.workspace-leaf-content[data-type="bases"] .livesync-status {
}
.sls-setting-panel-title {
position: sticky;
font-size: medium;
top: 2.5em;
background-color: var(--background-secondary-alt);
border-radius: 10px;
padding: 0.5em 1em;
}
body.is-mobile .sls-setting button {
max-width: 100%;
white-space: normal;
}
body.is-mobile .sls-setting-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);
}
+271 -52
View File
@@ -1,8 +1,12 @@
import { mkdir } from "node:fs/promises";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import {
assertMobileDialogueLayout,
setObsidianMobileTestMode,
setObsidianMobileTestModeBeforePluginStart,
} from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
allowPendingObsidianTestVaultOpenAction,
@@ -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
View File
@@ -12,6 +12,24 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased
### Command-line tool
#### Fixed
- The systemd installer now finds the repository root correctly, installs every generated bundle chunk and required production dependency, checks the installed command before activation, and reports success only when the service remains active.
## 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
View File
@@ -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"
}