Compare commits

...
4 Commits
Author SHA1 Message Date
vorotamoroz 741551be3a Releasing 1.0.6 2026-08-06 10:29:58 +00:00
vorotamoroz 5f69d301f0 Merge pull request #1080 from vrtmrz/fix/setup-synchronisation-settings-guidance
Distinguish absent and unreadable remote settings during setup
2026-08-06 18:57:52 +09:00
vorotamoroz b42df389fb Distinguish absent and unreadable remote settings during setup
Use typed Commonlib outcomes to guide Fetch and Rebuild without treating a new remote as a failed read. Preserve automatic synchronisation choices when scheduled initialisation is cancelled.
2026-08-06 08:34:41 +00:00
vorotamoroz 6a46966957 Merge pull request #1079 from vrtmrz/1_0_5
Releasing 1.0.5
2026-08-05 20:22:10 +09:00
24 changed files with 507 additions and 166 deletions
+2 -2
View File
@@ -39,7 +39,7 @@ Use this path only when the remote database is new, or when this device is inten
![Final server overwrite warning](../images/quick-setup/guide-quick-setup-first-rebuild-confirmation.png) ![Final server overwrite warning](../images/quick-setup/guide-quick-setup-first-rebuild-confirmation.png)
8. A newly provisioned database may show `Fetch Remote Configuration Failed` because it does not contain a saved preferred configuration yet. If this is a genuinely new setup, select `Skip and proceed`. Otherwise, stop and investigate before continuing. 8. A newly provisioned database may show `No Synchronisation Settings Found`. Select `Use this device's settings` for the intended new database. If you expected existing settings, cancel and check the selected remote. Cancellation restarts with Vault and database reflection paused; keep LiveSync disabled until the remote has been checked. If `Could Not Read Synchronisation Settings` appears, retry, or use this device's settings to continue the overwrite. The overwrite still requires a working connection.
![Expected missing remote configuration choice for a new database](../images/quick-setup/guide-quick-setup-missing-remote-configuration.png) ![Expected missing remote configuration choice for a new database](../images/quick-setup/guide-quick-setup-missing-remote-configuration.png)
@@ -134,7 +134,7 @@ Use this path when CouchDB is ready but a Setup URI is unavailable. It configure
8. Select `Create or connect to database and continue`. Onboarding requires this connection test to succeed. 8. Select `Create or connect to database and continue`. Onboarding requires this connection test to succeed.
9. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`. 9. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`.
10. Read the final overwrite warning. Select `I Understand, Overwrite Server` only when this device is intentionally the source of truth and a current backup exists. 10. Read the final overwrite warning. Select `I Understand, Overwrite Server` only when this device is intentionally the source of truth and a current backup exists.
11. A newly created database can show `Fetch Remote Configuration Failed` because it does not yet contain a saved preferred configuration. Select `Skip and proceed` only for this known new database. 11. A newly created database can show `No Synchronisation Settings Found`. Select `Use this device's settings` for this known new database. If you expected existing settings, cancel and check the selected remote. Cancellation restarts with Vault and database reflection paused; keep LiveSync disabled until the remote has been checked. If `Could Not Read Synchronisation Settings` appears, retry, or use this device's settings to continue the overwrite. The overwrite still requires a working connection.
12. Acknowledge `All optional features are disabled`, then keep Obsidian open until the initialisation progress has cleared. 12. Acknowledge `All optional features are disabled`, then keep Obsidian open until the initialisation progress has cleared.
Create and synchronise an ordinary test note. Once it has reached CouchDB, follow [Create a Setup URI for another device](#create-a-setup-uri-for-another-device), then [Add another device](#add-another-device). This keeps the second device aligned with the remote profile and encryption settings which the first device actually applied. Create and synchronise an ordinary test note. Once it has reached CouchDB, follow [Create a Setup URI for another device](#create-a-setup-uri-for-another-device), then [Add another device](#add-another-device). This keeps the second device aligned with the remote profile and encryption settings which the first device actually applied.
+1 -1
View File
@@ -50,7 +50,7 @@ Use a new bucket prefix, or a prefix whose contents you deliberately intend to r
![Final Object Storage overwrite confirmation](../images/object-storage-setup/guide-object-storage-setup-first-rebuild-confirmation.png) ![Final Object Storage overwrite confirmation](../images/object-storage-setup/guide-object-storage-setup-first-rebuild-confirmation.png)
6. A new prefix may show `Fetch Remote Configuration Failed` because it has no saved configuration. Select `Skip and proceed` only for a genuinely new prefix. Otherwise, stop and check the endpoint, credentials, bucket, and prefix. 6. A new prefix may show `No Synchronisation Settings Found`. Select `Use this device's settings` for a genuinely new prefix. If you expected existing settings, cancel and check the endpoint, credentials, bucket, and prefix. Cancellation restarts with Vault and database reflection paused; keep LiveSync disabled until the remote has been checked. If `Could Not Read Synchronisation Settings` appears, retry, or use this device's settings to continue the overwrite. The overwrite still requires a working connection.
![Expected missing remote configuration for a new Object Storage prefix](../images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png) ![Expected missing remote configuration for a new Object Storage prefix](../images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "1.0.5", "version": "1.0.6",
"minAppVersion": "1.7.2", "minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz", "author": "vorotamoroz",
+9 -9
View File
@@ -1,12 +1,12 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.5", "version": "1.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.5", "version": "1.0.6",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"src/apps/cli", "src/apps/cli",
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.4", "@vrtmrz/livesync-commonlib": "0.1.5",
"@vrtmrz/obsidian-plugin-kit": "0.1.3", "@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
@@ -4775,9 +4775,9 @@
} }
}, },
"node_modules/@vrtmrz/livesync-commonlib": { "node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.4", "version": "0.1.5",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.4.tgz", "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.5.tgz",
"integrity": "sha512-qQst1QCZEgfxwpdjnjFmXE3JNy4ATKhN8ihJSUUho4mdWC1O4zyRRhmQjg0GHu5hQPZokufr2mrP4Hur8niDNg==", "integrity": "sha512-DJBzVWevZ/8ZLTPmweMxRZAvNU9Aaxz6Ld3Def2uioBv6grUmpebey7+yT93ddR6v3aRiva2+yNf96xq4qttQA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.808.0", "@aws-sdk/client-s3": "^3.808.0",
@@ -15924,7 +15924,7 @@
}, },
"src/apps/cli": { "src/apps/cli": {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"version": "1.0.5-cli", "version": "1.0.6-cli",
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"minimatch": "^10.2.5", "minimatch": "^10.2.5",
@@ -15949,7 +15949,7 @@
}, },
"src/apps/webapp": { "src/apps/webapp": {
"name": "livesync-webapp", "name": "livesync-webapp",
"version": "1.0.5-webapp", "version": "1.0.6-webapp",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.52" "octagonal-wheels": "^0.1.52"
}, },
@@ -15961,7 +15961,7 @@
} }
}, },
"src/apps/webpeer": { "src/apps/webpeer": {
"version": "1.0.5-webpeer", "version": "1.0.6-webpeer",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.52" "octagonal-wheels": "^0.1.52"
}, },
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.5", "version": "1.0.6",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js", "main": "main.js",
"type": "module", "type": "module",
@@ -177,7 +177,7 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.4", "@vrtmrz/livesync-commonlib": "0.1.5",
"@vrtmrz/obsidian-plugin-kit": "0.1.3", "@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"private": true, "private": true,
"version": "1.0.5-cli", "version": "1.0.6-cli",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "livesync-webapp", "name": "livesync-webapp",
"private": true, "private": true,
"version": "1.0.5-webapp", "version": "1.0.6-webapp",
"type": "module", "type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API", "description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "webpeer", "name": "webpeer",
"private": true, "private": true,
"version": "1.0.5-webpeer", "version": "1.0.6-webpeer",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -49,6 +49,18 @@ export const liveSyncProvisionalEnglishMessages = {
"Connect to existing database and continue": "Connect to existing database and continue", "Connect to existing database and continue": "Connect to existing database and continue",
"Test connection and save": "Test connection and save", "Test connection and save": "Test connection and save",
"Save without connecting": "Save without connecting", "Save without connecting": "Save without connecting",
"Use this device's settings": "Use this device's settings",
Retry: "Retry",
"No Synchronisation Settings Found": "No Synchronisation Settings Found",
"The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings.":
"The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings.",
"Could Not Read Synchronisation Settings": "Could Not Read Synchronisation Settings",
"Could not read the remote's synchronisation settings. Check the connection and credentials, then retry.":
"Could not read the remote's synchronisation settings. Check the connection and credentials, then retry.",
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.":
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.",
"Skips checking and applying synchronisation settings from the remote.":
"Skips checking and applying synchronisation settings from the remote.",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.", "Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"CouchDB validates the database name when you connect. The name must not be empty.": "CouchDB validates the database name when you connect. The name must not be empty.":
"CouchDB validates the database name when you connect. The name must not be empty.", "CouchDB validates the database name when you connect. The name must not be empty.",
@@ -10,6 +10,8 @@ import {
type RemoteDBSettings, type RemoteDBSettings,
IncompatibleChangesInSpecificPattern, IncompatibleChangesInSpecificPattern,
CompatibleButLossyChanges, CompatibleButLossyChanges,
type RemotePreferredTweakResult,
RemotePreferredTweakStatuses,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { escapeMarkdownValue } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { escapeMarkdownValue } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { AbstractModule } from "@/modules/AbstractModule.ts"; import { AbstractModule } from "@/modules/AbstractModule.ts";
@@ -256,22 +258,21 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
return "IGNORE"; return "IGNORE";
} }
async _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise<TweakValues | false> { async _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise<RemotePreferredTweakResult> {
const replicator = await this.services.replicator.getNewReplicator(trialSetting); try {
if (!replicator) { const replicator = await this.services.replicator.getNewReplicator(trialSetting);
this._log("The remote type is not supported for fetching preferred tweak values.", LOG_LEVEL_NOTICE); if (!replicator) {
return false; this._log("The remote type does not support preferred tweak values.", LOG_LEVEL_NOTICE);
} return { status: RemotePreferredTweakStatuses.UNSUPPORTED };
if (await replicator.tryConnectRemote(trialSetting)) {
const preferred = await replicator.getRemotePreferredTweakValues(trialSetting);
if (preferred) {
return preferred;
} }
this._log("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE); return await replicator.getRemotePreferredTweakValues(trialSetting);
return false; } catch (ex) {
this._log("Failed to get the preferred tweak values from the remote.", LOG_LEVEL_NOTICE);
return {
status: RemotePreferredTweakStatuses.UNAVAILABLE,
error: ex,
};
} }
this._log("Failed to connect to the remote server.", LOG_LEVEL_NOTICE);
return false;
} }
async _checkAndAskUseRemoteConfiguration( async _checkAndAskUseRemoteConfiguration(
@@ -281,8 +282,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
return { result: false, requireFetch: false }; return { result: false, requireFetch: false };
} }
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting); const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
if (preferred) { if (preferred.status === RemotePreferredTweakStatuses.AVAILABLE) {
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred); return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred.values);
} }
return { result: false, requireFetch: false }; return { result: false, requireFetch: false };
} }
@@ -54,6 +54,36 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
} }
describe("ModuleResolvingMismatchedTweaks", () => { describe("ModuleResolvingMismatchedTweaks", () => {
it("returns an unconfigured remote result without a separate connection preflight", async () => {
const { module, core } = createModule();
const tryConnectRemote = vi.fn(async () => true);
const getRemotePreferredTweakValues = vi.fn(async () => ({
status: "not-configured" as const,
reason: "milestone-missing" as const,
}));
core._services.replicator = {
getNewReplicator: vi.fn(async () => ({ tryConnectRemote, getRemotePreferredTweakValues })),
};
await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({
status: "not-configured",
reason: "milestone-missing",
});
expect(getRemotePreferredTweakValues).toHaveBeenCalledOnce();
expect(tryConnectRemote).not.toHaveBeenCalled();
});
it("returns unsupported when no replicator implements the remote type", async () => {
const { module, core } = createModule();
core._services.replicator = {
getNewReplicator: vi.fn(async () => undefined),
};
await expect(module._fetchRemotePreferredTweakValues(core.settings)).resolves.toEqual({
status: "unsupported",
});
});
it("should enable and auto-accept compatible mismatches when the preference is undefined", async () => { it("should enable and auto-accept compatible mismatches when the preference is undefined", async () => {
const { module, core, askSelectStringDialogue, applyPartial } = createModule({ const { module, core, askSelectStringDialogue, applyPartial } = createModule({
autoAcceptCompatibleTweak: undefined, autoAcceptCompatibleTweak: undefined,
@@ -141,10 +141,11 @@
</Instruction> </Instruction>
<Instruction> <Instruction>
<ExtraItems title={translateMessage("Advanced")}> <ExtraItems title={translateMessage("Advanced")}>
<Check <Check title={translateMessage("Use this device's settings")} bind:value={preventFetchingConfig}>
title={translateMessage("Prevent fetching configuration from server")} <InfoNote>
bind:value={preventFetchingConfig} {translateMessage("Skips checking and applying synchronisation settings from the remote.")}
/> </InfoNote>
</Check>
</ExtraItems> </ExtraItems>
</Instruction> </Instruction>
<UserDecisions> <UserDecisions>
@@ -129,7 +129,9 @@
{#if !isP2P} {#if !isP2P}
<Instruction> <Instruction>
<ExtraItems title={msg("Advanced")}> <ExtraItems title={msg("Advanced")}>
<Check title={msg("Prevent fetching configuration from server")} bind:value={preventFetchingConfig} /> <Check title={msg("Use this device's settings")} bind:value={preventFetchingConfig}>
<InfoNote>{msg("Skips checking and applying synchronisation settings from the remote.")}</InfoNote>
</Check>
</ExtraItems> </ExtraItems>
</Instruction> </Instruction>
{/if} {/if}
+13 -6
View File
@@ -10,7 +10,11 @@ import {
synchroniseAllFilesBetweenDBandStorage, synchroniseAllFilesBetweenDBandStorage,
type FullScanOptions, type FullScanOptions,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { adjustSettingToRemoteIfNeeded, processVaultInitialisation } from "./redFlag"; import {
adjustSettingToRemoteIfNeeded,
cancelScheduledInitialisation,
processVaultInitialisation,
} from "./redFlag";
export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files"; export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files";
export const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer"; export const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer";
@@ -194,9 +198,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
if (result === "cancelled") { if (result === "cancelled") {
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE); log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host); clearRememberedSimpleFetchMode(host);
await cleanupFlag(); return await cancelScheduledInitialisation(host, cleanupFlag);
host.services.appLifecycle.performRestart();
return false;
} }
if (result === "aborted") { if (result === "aborted") {
log("Fetch exited by user.", LOG_LEVEL_NOTICE); log("Fetch exited by user.", LOG_LEVEL_NOTICE);
@@ -208,9 +210,14 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
return undefined; // Let the detailed setup flow handle it. return undefined; // Let the detailed setup flow handle it.
} }
const settings = host.services.setting.currentSettings();
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host);
return await cancelScheduledInitialisation(host, cleanupFlag);
}
return await processVaultInitialisation(host, log, async () => { return await processVaultInitialisation(host, log, async () => {
const settings = host.services.setting.currentSettings();
await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings);
// 1. Perform fast DB fetch (download remote DB content to local DB) // 1. Perform fast DB fetch (download remote DB content to local DB)
await host.serviceModules.rebuilder.$fetchLocalDBFast(false); await host.serviceModules.rebuilder.$fetchLocalDBFast(false);
+129 -62
View File
@@ -10,7 +10,10 @@ import RebuildEverything from "@/modules/features/SetupWizard/dialogs/RebuildEve
import { extractObject } from "octagonal-wheels/object"; import { extractObject } from "octagonal-wheels/object";
import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import { TweakValuesShouldMatchedTemplate } from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition"; import {
RemotePreferredTweakStatuses,
TweakValuesShouldMatchedTemplate,
} from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition";
import type { import type {
FetchEverythingResult, FetchEverythingResult,
RebuildEverythingResult, RebuildEverythingResult,
@@ -19,6 +22,7 @@ import { askAndPerformFastSetupOnScheduledFetchAll } from "./redFlag.simpleFetch
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isP2PMainRemote } from "@/common/remoteConfiguration"; import { isP2PMainRemote } from "@/common/remoteConfiguration";
import { $msg } from "@/common/translation";
/** /**
* Flag file handler interface, similar to target filter pattern. * Flag file handler interface, similar to target filter pattern.
@@ -29,6 +33,8 @@ interface FlagFileHandler {
handle: () => Promise<boolean>; handle: () => Promise<boolean>;
} }
type InitialisationOperation = "fetch" | "rebuild";
export async function isFlagFileExist(host: NecessaryServices<never, "storageAccess">, path: string) { export async function isFlagFileExist(host: NecessaryServices<never, "storageAccess">, path: string) {
const redFlagExist = await host.serviceModules.storageAccess.isExists( const redFlagExist = await host.serviceModules.storageAccess.isExists(
host.serviceModules.storageAccess.normalisePath(path) host.serviceModules.storageAccess.normalisePath(path)
@@ -158,9 +164,7 @@ export function createFetchAllFlagHandler(
await host.services.UI.dialogManager.openWithExplicitCancel<FetchEverythingResult>(FetchEverything); await host.services.UI.dialogManager.openWithExplicitCancel<FetchEverythingResult>(FetchEverything);
if (method === "cancelled") { if (method === "cancelled") {
log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE); log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
await cleanupFlag(); return await cancelScheduledInitialisation(host, cleanupFlag);
host.services.appLifecycle.performRestart();
return false;
} }
const { vault, extra } = method; const { vault, extra } = method;
const settings = await Promise.resolve(host.services.setting.currentSettings()); const settings = await Promise.resolve(host.services.setting.currentSettings());
@@ -185,9 +189,11 @@ export function createFetchAllFlagHandler(
}, },
} as const; } as const;
if (!(await adjustSettingToRemoteIfNeeded(host, log, extra, settings))) {
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
return await cancelScheduledInitialisation(host, cleanupFlag);
}
return await processVaultInitialisation(host, log, async () => { return await processVaultInitialisation(host, log, async () => {
const settings = host.services.setting.currentSettings();
await adjustSettingToRemoteIfNeeded(host, log, extra, settings);
const vaultStateToAction = mapVaultStateToAction[vault]; const vaultStateToAction = mapVaultStateToAction[vault];
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction; const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction;
log( log(
@@ -217,93 +223,153 @@ export function createFetchAllFlagHandler(
/** /**
* Adjust setting to remote configuration. * Adjust setting to remote configuration.
* @param config current configuration to retrieve remote preferred config * @param config current configuration to retrieve remote preferred config
* @returns updated configuration if applied, otherwise null. * @param operation operation which will consume the selected configuration
* @returns whether initialisation may continue.
*/ */
export async function adjustSettingToRemote( export async function adjustSettingToRemote(
host: NecessaryServices<"tweakValue" | "UI" | "setting", never>, host: NecessaryServices<"tweakValue" | "UI" | "setting", never>,
log: LogFunction, log: LogFunction,
config: ObsidianLiveSyncSettings config: ObsidianLiveSyncSettings,
) { operation: InitialisationOperation = "fetch"
// Fetch remote configuration unless prevented. ): Promise<boolean> {
const SKIP_FETCH = "Skip and proceed"; while (true) {
const RETRY_FETCH = "Retry (recommended)"; const remoteResult = await host.services.tweakValue.fetchRemotePreferred(config);
let canProceed = false; if (remoteResult.status === RemotePreferredTweakStatuses.NOT_CONFIGURED) {
do { const useDeviceSettings = $msg("Use this device's settings");
const remoteTweaks = await host.services.tweakValue.fetchRemotePreferred(config); const cancelInitialisation = $msg("Cancel");
if (!remoteTweaks) { log(`Remote synchronisation settings are not configured (${remoteResult.reason}).`, LOG_LEVEL_INFO);
const choice = await host.services.UI.confirm.askSelectStringDialogue( const choice = await host.services.UI.confirm.askSelectStringDialogue(
"Could not fetch configuration from remote. If you are new to the Self-hosted LiveSync, this might be expected. If not, you should check your network or server settings.", $msg(
[SKIP_FETCH, RETRY_FETCH] as const, "The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings."
),
[useDeviceSettings, cancelInitialisation] as const,
{ {
defaultAction: RETRY_FETCH, defaultAction: useDeviceSettings,
timeout: 0, timeout: 0,
title: "Fetch Remote Configuration Failed", title: $msg("No Synchronisation Settings Found"),
} }
); );
if (choice === SKIP_FETCH) { return choice === useDeviceSettings;
canProceed = true; }
} if (remoteResult.status === RemotePreferredTweakStatuses.UNAVAILABLE) {
} else { const retryRemoteSettings = $msg("Retry");
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks); const useDeviceSettings = $msg("Use this device's settings");
// Check if any necessary tweak value is different from current config. const cancelInitialisation = $msg("Cancel");
const differentItems = Object.entries(necessary).filter(([key, value]) => { log("Could not read synchronisation settings from the remote.", LOG_LEVEL_NOTICE);
return config[key as keyof ObsidianLiveSyncSettings] !== value; log(remoteResult.error, LOG_LEVEL_VERBOSE);
}); if (operation === "rebuild") {
if (differentItems.length === 0) { const choice = await host.services.UI.confirm.askSelectStringDialogue(
log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE); $msg(
} else { "Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required."
await host.services.UI.confirm.askSelectStringDialogue( ),
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!", [retryRemoteSettings, useDeviceSettings, cancelInitialisation] as const,
["OK"] as const,
{ {
defaultAction: "OK", defaultAction: retryRemoteSettings,
timeout: 0, timeout: 0,
title: $msg("Could Not Read Synchronisation Settings"),
} }
); );
if (choice === retryRemoteSettings) continue;
return choice === useDeviceSettings;
} }
const choice = await host.services.UI.confirm.askSelectStringDialogue(
config = { $msg(
...config, "Could not read the remote's synchronisation settings. Check the connection and credentials, then retry."
...(Object.fromEntries(differentItems) as Partial<ObsidianLiveSyncSettings>), ),
} satisfies ObsidianLiveSyncSettings; [retryRemoteSettings, cancelInitialisation] as const,
await host.services.setting.applyExternalSettings(config, true); {
log("Remote configuration applied.", LOG_LEVEL_NOTICE); defaultAction: retryRemoteSettings,
canProceed = true; timeout: 0,
const updatedConfig = host.services.setting.currentSettings(); title: $msg("Could Not Read Synchronisation Settings"),
return updatedConfig; }
);
if (choice === retryRemoteSettings) continue;
return false;
} }
} while (!canProceed); if (remoteResult.status === RemotePreferredTweakStatuses.UNSUPPORTED) {
log("Remote synchronisation settings are not supported by this remote type.", LOG_LEVEL_INFO);
return true;
}
const remoteTweaks = remoteResult.values;
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
// Check if any necessary tweak value is different from current config.
const differentItems = Object.entries(necessary).filter(([key, value]) => {
return config[key as keyof ObsidianLiveSyncSettings] !== value;
});
if (differentItems.length === 0) {
log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE);
} else {
await host.services.UI.confirm.askSelectStringDialogue(
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
["OK"] as const,
{
defaultAction: "OK",
timeout: 0,
}
);
}
config = {
...config,
...(Object.fromEntries(differentItems) as Partial<ObsidianLiveSyncSettings>),
} satisfies ObsidianLiveSyncSettings;
await host.services.setting.applyExternalSettings(config, true);
log("Remote configuration applied.", LOG_LEVEL_NOTICE);
return true;
}
} }
/** /**
* Adjust setting to remote if needed. * Adjust setting to remote if needed.
* @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything) * @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything)
* @param config current configuration to retrieve remote preferred config * @param config current configuration to retrieve remote preferred config
* @param operation operation which will consume the selected configuration
*/ */
export async function adjustSettingToRemoteIfNeeded( export async function adjustSettingToRemoteIfNeeded(
host: NecessaryServices<"tweakValue" | "UI" | "setting", never>, host: NecessaryServices<"tweakValue" | "UI" | "setting", never>,
log: LogFunction, log: LogFunction,
extra: { preventFetchingConfig: boolean }, extra: { preventFetchingConfig: boolean } | null,
config: ObsidianLiveSyncSettings config: ObsidianLiveSyncSettings,
) { operation: InitialisationOperation = "fetch"
if (extra && extra.preventFetchingConfig) { ): Promise<boolean> {
return; if (extra?.preventFetchingConfig) {
return true;
} }
// P2P has no centralised remote configuration; skip to avoid a spurious // P2P has no centralised remote configuration; skip to avoid a spurious
// "Failed to connect to the remote server" error dialog. // "Failed to connect to the remote server" error dialog.
if (config.remoteType === REMOTE_P2P) { if (config.remoteType === REMOTE_P2P) {
log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO); log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO);
return; return true;
} }
// Remote configuration fetched and applied. const canProceed = await adjustSettingToRemote(host, log, config, operation);
if (await adjustSettingToRemote(host, log, config)) { if (!canProceed) {
config = host.services.setting.currentSettings();
} else {
log("Remote configuration not applied.", LOG_LEVEL_NOTICE); log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
} }
// log(JSON.stringify(config), LOG_LEVEL_VERBOSE); return canProceed;
}
/**
* Cancel a scheduled Fetch or Rebuild without changing the selected automatic
* synchronisation mode. The persisted Scram switches keep both reflection
* directions paused until the existing start-up dialogue resumes them.
*/
export async function cancelScheduledInitialisation(
host: NecessaryServices<"setting" | "appLifecycle", never>,
cleanupFlag: () => Promise<void>
): Promise<false> {
await host.services.setting.applyPartial(
{
suspendFileWatching: true,
suspendParseReplicationResult: true,
},
true
);
await cleanupFlag();
host.services.appLifecycle.performRestart();
return false;
} }
/** /**
@@ -393,12 +459,13 @@ export function createRebuildFlagHandler(
>(RebuildEverything, { isP2P: isP2PMainRemote(settings) }); >(RebuildEverything, { isP2P: isP2PMainRemote(settings) });
if (method === "cancelled") { if (method === "cancelled") {
log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE); log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
await cleanupFlag(); return await cancelScheduledInitialisation(host, cleanupFlag);
host.services.appLifecycle.performRestart();
return false;
} }
const { extra } = method; const { extra } = method;
await adjustSettingToRemoteIfNeeded(host, log, extra, settings); if (!(await adjustSettingToRemoteIfNeeded(host, log, extra, settings, "rebuild"))) {
log("Rebuild initialisation cancelled by user.", LOG_LEVEL_NOTICE);
return await cancelScheduledInitialisation(host, cleanupFlag);
}
return await processVaultInitialisation(host, log, async () => { return await processVaultInitialisation(host, log, async () => {
await host.serviceModules.rebuilder.$rebuildEverything(); await host.serviceModules.rebuilder.$rebuildEverything();
await cleanupFlag(); await cleanupFlag();
+242 -44
View File
@@ -68,6 +68,8 @@ const createLoggerMock = (): LogFunction => {
return vi.fn(); return vi.fn();
}; };
const availableRemoteTweaks = (values: Record<string, unknown>) => ({ status: "available", values }) as const;
const createStorageAccessMock = () => { const createStorageAccessMock = () => {
const files: Set<string> = new Set(); const files: Set<string> = new Set();
return { return {
@@ -149,7 +151,9 @@ const createRebuilderMock = () => {
const createTweakValueMock = () => { const createTweakValueMock = () => {
return { return {
fetchRemotePreferred: vi.fn(() => Promise.resolve<any>(null)), fetchRemotePreferred: vi.fn(() =>
Promise.resolve<any>({ status: "unavailable", error: new Error("Remote unavailable") })
),
}; };
}; };
@@ -459,9 +463,9 @@ describe("Red Flag Feature", () => {
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
batchSave: false, availableRemoteTweaks({ batchSave: false })
} as any); );
const handler = createFetchAllFlagHandler(host as any, log); const handler = createFetchAllFlagHandler(host as any, log);
const result = await handler.handle(); const result = await handler.handle();
@@ -487,9 +491,9 @@ describe("Red Flag Feature", () => {
backup: "backup_skipped", backup: "backup_skipped",
extra: { preventFetchingConfig: false }, extra: { preventFetchingConfig: false },
}); });
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
batchSave: false, availableRemoteTweaks({ batchSave: false })
} as any); );
const handler = createFetchAllFlagHandler(host as any, log); const handler = createFetchAllFlagHandler(host as any, log);
const result = await handler.handle(); const result = await handler.handle();
@@ -506,9 +510,9 @@ describe("Red Flag Feature", () => {
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false); host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
const handler = createFetchAllFlagHandler(host as any, log); const handler = createFetchAllFlagHandler(host as any, log);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
batchSave: false, availableRemoteTweaks({ batchSave: false })
} as any); );
const result = await handler.handle(); const result = await handler.handle();
expect(result).toBe(false); expect(result).toBe(false);
@@ -525,9 +529,9 @@ describe("Red Flag Feature", () => {
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
batchSave: false, availableRemoteTweaks({ batchSave: false })
} as any); );
const handler = createFetchAllFlagHandler(host as any, log); const handler = createFetchAllFlagHandler(host as any, log);
const result = await handler.handle(); const result = await handler.handle();
@@ -763,7 +767,7 @@ describe("Red Flag Feature", () => {
host.mocks.ui.confirm.confirmWithMessage host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP); .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ batchSave: false } as any); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline")); host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag); await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
@@ -781,7 +785,7 @@ describe("Red Flag Feature", () => {
host.mocks.ui.confirm.confirmWithMessage host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ batchSave: false } as any); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag); await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
@@ -831,6 +835,65 @@ describe("Red Flag Feature", () => {
expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled(); expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled();
}); });
it("should preserve automatic synchronisation choices and enter Scram when quick Fetch is cancelled", async () => {
const host = createHostMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
Object.assign(host.mocks.setting.settings, {
liveSync: true,
periodicReplication: true,
syncOnSave: true,
syncOnEditorSave: true,
syncOnStart: true,
syncOnFileOpen: true,
syncAfterMerge: true,
suspendParseReplicationResult: false,
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
status: "not-configured",
reason: "milestone-missing",
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
const result = await askAndPerformFastSetupOnScheduledFetchAll(
host as any,
createLoggerMock(),
cleanupFlag
);
expect(result).toBe(false);
expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled();
expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled();
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
{
suspendFileWatching: true,
suspendParseReplicationResult: true,
},
true
);
expect(host.mocks.setting.currentSettings()).toMatchObject({
liveSync: true,
periodicReplication: true,
syncOnSave: true,
syncOnEditorSave: true,
syncOnStart: true,
syncOnFileOpen: true,
syncAfterMerge: true,
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
expect(cleanupFlag).toHaveBeenCalledOnce();
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
expect(host.mocks.setting.applyPartial.mock.invocationCallOrder[0]).toBeLessThan(
cleanupFlag.mock.invocationCallOrder[0]
);
expect(cleanupFlag.mock.invocationCallOrder[0]).toBeLessThan(
host.mocks.appLifecycle.performRestart.mock.invocationCallOrder[0]
);
});
it("should reboot and return false when sync has failures and user chooses rerun", async () => { it("should reboot and return false when sync has failures and user chooses rerun", async () => {
const host = createHostMock(); const host = createHostMock();
const log = createLoggerMock(); const log = createLoggerMock();
@@ -839,7 +902,9 @@ describe("Red Flag Feature", () => {
host.mocks.ui.confirm.confirmWithMessage host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ batchSave: false } as any); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
availableRemoteTweaks({ batchSave: false })
);
(synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false); (synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false);
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Reboot to re-run the process"); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Reboot to re-run the process");
@@ -859,7 +924,9 @@ describe("Red Flag Feature", () => {
host.mocks.ui.confirm.confirmWithMessage host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ batchSave: false } as any); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
availableRemoteTweaks({ batchSave: false })
);
(synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false); (synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false);
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce( host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce(
"Finalise the process and resume normal operation" "Finalise the process and resume normal operation"
@@ -1017,9 +1084,9 @@ describe("Red Flag Feature", () => {
const host = createHostMock(); const host = createHostMock();
const config = { batchSave: true } as any; const config = { batchSave: true } as any;
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
batchSave: false, availableRemoteTweaks({ batchSave: false })
} as any); );
await adjustSettingToRemoteIfNeeded( await adjustSettingToRemoteIfNeeded(
host as any, host as any,
@@ -1047,7 +1114,9 @@ describe("Red Flag Feature", () => {
const differentConfig = { const differentConfig = {
[key]: differentValue, [key]: differentValue,
}; };
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
availableRemoteTweaks(differentConfig)
);
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK"); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK");
await adjustSettingToRemote(host as any, createLoggerMock(), config); await adjustSettingToRemote(host as any, createLoggerMock(), config);
@@ -1074,7 +1143,9 @@ describe("Red Flag Feature", () => {
const differentConfig = { const differentConfig = {
[key]: differentValue, [key]: differentValue,
}; };
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
availableRemoteTweaks(differentConfig)
);
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK"); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK");
await adjustSettingToRemote(host as any, createLoggerMock(), config); await adjustSettingToRemote(host as any, createLoggerMock(), config);
@@ -1084,32 +1155,71 @@ describe("Red Flag Feature", () => {
} }
); );
it("should show dialog when remote fetch fails", async () => { it("should explain that missing remote settings are normal for a new database without offering retry", async () => {
const host = createHostMock(); const host = createHostMock();
const log = createLoggerMock(); const log = createLoggerMock();
const config = { batchSave: true } as any; const config = { batchSave: true } as any;
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed"); status: "not-configured",
reason: "milestone-missing",
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings");
await adjustSettingToRemote(host as any, log, config); await expect(adjustSettingToRemote(host as any, log, config)).resolves.toBe(true);
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled(); expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
"The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings.",
["Use this device's settings", "Cancel"],
{
defaultAction: "Use this device's settings",
timeout: 0,
title: "No Synchronisation Settings Found",
}
);
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
}); });
it("should retry when user selects retry option", async () => { it("should retry only when remote settings are unavailable", async () => {
const host = createHostMock(); const host = createHostMock();
const log = createLoggerMock(); const log = createLoggerMock();
const config = { batchSave: true } as any; const config = { batchSave: true } as any;
const failure = new Error("network failed");
host.mocks.tweakValue.fetchRemotePreferred host.mocks.tweakValue.fetchRemotePreferred
.mockResolvedValueOnce(null) .mockResolvedValueOnce({ status: "unavailable", error: failure })
.mockResolvedValueOnce({ batchSave: false } as any); .mockResolvedValueOnce({ status: "available", values: { batchSave: false } } as any);
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Retry (recommended)"); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Retry");
await adjustSettingToRemote(host as any, log, config); await expect(adjustSettingToRemote(host as any, log, config)).resolves.toBe(true);
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledTimes(2); expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledTimes(2);
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
"Could not read the remote's synchronisation settings. Check the connection and credentials, then retry.",
["Retry", "Cancel"],
{
defaultAction: "Retry",
timeout: 0,
title: "Could Not Read Synchronisation Settings",
}
);
});
it("should cancel initialisation instead of proceeding after an unavailable remote", async () => {
const host = createHostMock();
const config = { batchSave: true } as any;
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
status: "unavailable",
error: new Error("network failed"),
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
await expect(adjustSettingToRemote(host as any, createLoggerMock(), config)).resolves.toBe(false);
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
}); });
it("should log when no changes needed", async () => { it("should log when no changes needed", async () => {
@@ -1117,9 +1227,9 @@ describe("Red Flag Feature", () => {
const log = createLoggerMock(); const log = createLoggerMock();
const config = { batchSave: false } as any; const config = { batchSave: false } as any;
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
batchSave: false, availableRemoteTweaks({ batchSave: false })
} as any); );
await adjustSettingToRemote(host as any, log, config); await adjustSettingToRemote(host as any, log, config);
@@ -1131,8 +1241,11 @@ describe("Red Flag Feature", () => {
const log = createLoggerMock(); const log = createLoggerMock();
const config = { batchSave: true } as any; const config = { batchSave: true } as any;
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed"); status: "not-configured",
reason: "preferred-values-missing",
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings");
await adjustSettingToRemoteIfNeeded(host as any, log, null as any, config); await adjustSettingToRemoteIfNeeded(host as any, log, null as any, config);
@@ -1460,6 +1573,93 @@ describe("Red Flag Feature", () => {
}); });
describe("flagHandlerToEventHandler integration", () => { describe("flagHandlerToEventHandler integration", () => {
it("should stop a detailed Fetch when remote-setting initialisation is cancelled", async () => {
const host = createHostMock();
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
vault: "independent",
extra: { preventFetchingConfig: false },
});
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
status: "not-configured",
reason: "milestone-missing",
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
const result = await createFetchAllFlagHandler(host as any, createLoggerMock()).handle();
expect(result).toBe(false);
expect(host.mocks.rebuilder.$fetchLocal).not.toHaveBeenCalled();
expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled();
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
{
suspendFileWatching: true,
suspendParseReplicationResult: true,
},
true
);
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.FETCH_ALL)).toBe(false);
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
});
it("should stop Rebuild before deleting local data when remote-setting initialisation is cancelled", async () => {
const host = createHostMock();
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
extra: { preventFetchingConfig: false },
});
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
status: "not-configured",
reason: "milestone-missing",
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
const result = await createRebuildFlagHandler(host as any, createLoggerMock()).handle();
expect(result).toBe(false);
expect(host.mocks.rebuilder.$rebuildEverything).not.toHaveBeenCalled();
expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled();
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
{
suspendFileWatching: true,
suspendParseReplicationResult: true,
},
true
);
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.REBUILD_ALL)).toBe(false);
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
});
it("should let Rebuild use this device's settings when remote settings are unavailable", async () => {
const host = createHostMock();
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
extra: { preventFetchingConfig: false },
});
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
status: "unavailable",
error: new Error("network failed"),
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings");
const result = await createRebuildFlagHandler(host as any, createLoggerMock()).handle();
expect(result).toBe(true);
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.",
["Retry", "Use this device's settings", "Cancel"],
{
defaultAction: "Retry",
timeout: 0,
title: "Could Not Read Synchronisation Settings",
}
);
expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalledOnce();
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.REBUILD_ALL)).toBe(false);
expect(host.mocks.appLifecycle.performRestart).not.toHaveBeenCalled();
});
it("should return true when flag does not exist", async () => { it("should return true when flag does not exist", async () => {
const host = createHostMock(); const host = createHostMock();
const log = createLoggerMock(); const log = createLoggerMock();
@@ -1476,7 +1676,7 @@ describe("Red Flag Feature", () => {
const log = createLoggerMock(); const log = createLoggerMock();
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({}); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(availableRemoteTweaks({}));
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
@@ -1554,9 +1754,7 @@ describe("Red Flag Feature", () => {
it("should handle fetchAll flag with flagHandlerToEventHandler identical", async () => { it("should handle fetchAll flag with flagHandlerToEventHandler identical", async () => {
const host = createHostMock(); const host = createHostMock();
const log = createLoggerMock(); const log = createLoggerMock();
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ customChunkSize: 1 }));
customChunkSize: 1,
} as any);
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
@@ -1574,9 +1772,9 @@ describe("Red Flag Feature", () => {
it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => { it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => {
const host = createHostMock(); const host = createHostMock();
const log = createLoggerMock(); const log = createLoggerMock();
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
customChunkSize: 1, availableRemoteTweaks({ customChunkSize: 1 })
} as any); );
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ extra: {} }); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ extra: {} });
+11 -5
View File
@@ -254,17 +254,23 @@ export async function confirmRebuild(port: number, captures: SetupCaptureNames):
return screenshot; return screenshot;
} }
export async function skipMissingRemoteConfiguration(port: number, captures: SetupCaptureNames): Promise<string> { export async function continueWithoutRemoteSettings(port: number, captures: SetupCaptureNames): Promise<string> {
const title = "Fetch Remote Configuration Failed"; const title = "No Synchronisation Settings Found";
const screenshot = await captureGuideDialogue( const screenshot = await captureGuideDialogue(
port, port,
`guide-${captures.guide}-missing-remote-configuration.png`, `guide-${captures.guide}-missing-remote-configuration.png`,
title title
); );
await withObsidianPage(port, async (page) => { await withObsidianPage(port, async (page) => {
await modalByTitle(page, title) const modal = modalByTitle(page, title);
.getByRole("button", { name: "Skip and proceed" }) await modal
.click({ timeout: uiTimeoutMs }); .getByText("This is normal for a new remote.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByRole("button", { name: "Cancel", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Use this device's settings" }).click({ timeout: uiTimeoutMs });
}); });
return screenshot; return screenshot;
} }
@@ -25,7 +25,7 @@ import {
modalByTitle, modalByTitle,
resumeCompatibilityReviewIfShown, resumeCompatibilityReviewIfShown,
selectRadioOption, selectRadioOption,
skipMissingRemoteConfiguration, continueWithoutRemoteSettings,
type SetupArtifact, type SetupArtifact,
} from "../runner/setupUri.ts"; } from "../runner/setupUri.ts";
import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts"; import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
@@ -278,7 +278,7 @@ async function main(): Promise<void> {
screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName))); screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName)));
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures)); screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures));
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures)); screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures));
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort, captures)); screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures)); screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures));
const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
@@ -29,7 +29,7 @@ import {
finishInitialisation, finishInitialisation,
generateSetupURIFromDevice, generateSetupURIFromDevice,
resumeCompatibilityReviewIfShown, resumeCompatibilityReviewIfShown,
skipMissingRemoteConfiguration, continueWithoutRemoteSettings,
type SetupArtifact, type SetupArtifact,
type SetupCaptureNames, type SetupCaptureNames,
} from "../runner/setupUri.ts"; } from "../runner/setupUri.ts";
@@ -226,7 +226,7 @@ async function main(): Promise<void> {
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures)); screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
screenshots.push(await captureAndStartInitialisation(portA, "new", captures)); screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
screenshots.push(await confirmRebuild(portA, captures)); screenshots.push(await confirmRebuild(portA, captures));
screenshots.push(await skipMissingRemoteConfiguration(portA, captures)); screenshots.push(await continueWithoutRemoteSettings(portA, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures)); screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv); const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA); await resumeCompatibilityReviewIfShown(portA);
@@ -300,8 +300,8 @@ async function confirmRebuild(port: number): Promise<string> {
return screenshot; return screenshot;
} }
async function skipMissingRemoteConfiguration(port: number): Promise<string> { async function continueWithoutRemoteSettings(port: number): Promise<string> {
const title = "Fetch Remote Configuration Failed"; const title = "No Synchronisation Settings Found";
const screenshot = await captureObsidianDialogue( const screenshot = await captureObsidianDialogue(
port, port,
"setup-uri-first-missing-remote-configuration.png", "setup-uri-first-missing-remote-configuration.png",
@@ -309,16 +309,20 @@ async function skipMissingRemoteConfiguration(port: number): Promise<string> {
const modal = modalByTitle(page, title); const modal = modalByTitle(page, title);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal await modal
.getByText("If you are new to the Self-hosted LiveSync, this might be expected.", { .getByText("This is normal for a new remote.", {
exact: false, exact: false,
}) })
.waitFor({ state: "visible", timeout: uiTimeoutMs }); .waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByRole("button", { name: "Cancel", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
} }
); );
await captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title); await captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title);
await withObsidianPage(port, async (page) => { await withObsidianPage(port, async (page) => {
await modalByTitle(page, title) await modalByTitle(page, title)
.getByRole("button", { name: "Skip and proceed" }) .getByRole("button", { name: "Use this device's settings" })
.click({ timeout: uiTimeoutMs }); .click({ timeout: uiTimeoutMs });
}); });
return screenshot; return screenshot;
@@ -736,7 +740,7 @@ async function main(): Promise<void> {
await enterSetupURI(session.remoteDebuggingPort, "new", artifact); await enterSetupURI(session.remoteDebuggingPort, "new", artifact);
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new")); screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new"));
screenshots.push(await confirmRebuild(session.remoteDebuggingPort)); screenshots.push(await confirmRebuild(session.remoteDebuggingPort));
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort)); screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort));
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort)); screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort));
const firstCompletion = await finishInitialisation( const firstCompletion = await finishInitialisation(
session.remoteDebuggingPort, session.remoteDebuggingPort,
+12
View File
@@ -12,6 +12,18 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
## Unreleased ## Unreleased
## 1.0.6
6th August, 2026
I know that onboarding, and other parts which feel unclear or confusing, still need improvement. Please do report any such cases.
### Setup and compatibility
#### Fixed
- Initial setup now distinguishes an empty remote with no saved synchronisation settings from a failed remote read. New remotes can use this device's settings without an unnecessary retry; Fetch pauses on unreadable settings, while Rebuild can explicitly continue with this device's settings. Cancelling preserves the selected automatic synchronisation mode and restarts with Vault and database reflection paused (#1064). Thank you to @mateus2k2 for the follow-up report!
## 1.0.5 ## 1.0.5
5th August, 2026 5th August, 2026
+2 -1
View File
@@ -17,5 +17,6 @@
"1.0.2": "1.7.2", "1.0.2": "1.7.2",
"1.0.3": "1.7.2", "1.0.3": "1.7.2",
"1.0.4": "1.7.2", "1.0.4": "1.7.2",
"1.0.5": "1.7.2" "1.0.5": "1.7.2",
"1.0.6": "1.7.2"
} }