diff --git a/docs/quick_setup.md b/docs/quick_setup.md index a77b3834..afac78e8 100644 --- a/docs/quick_setup.md +++ b/docs/quick_setup.md @@ -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) -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) @@ -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. 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. -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. 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. diff --git a/docs/setup_object_storage.md b/docs/setup_object_storage.md index f29f60f1..0244a766 100644 --- a/docs/setup_object_storage.md +++ b/docs/setup_object_storage.md @@ -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) -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) diff --git a/images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png b/images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png index 79f205fc..6d816e23 100644 Binary files a/images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png and b/images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png differ diff --git a/images/quick-setup/guide-quick-setup-missing-remote-configuration.png b/images/quick-setup/guide-quick-setup-missing-remote-configuration.png index 79f205fc..6d816e23 100644 Binary files a/images/quick-setup/guide-quick-setup-missing-remote-configuration.png and b/images/quick-setup/guide-quick-setup-missing-remote-configuration.png differ diff --git a/package-lock.json b/package-lock.json index ebb164f8..c2b58f75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@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/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", @@ -4775,9 +4775,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.4.tgz", - "integrity": "sha512-qQst1QCZEgfxwpdjnjFmXE3JNy4ATKhN8ihJSUUho4mdWC1O4zyRRhmQjg0GHu5hQPZokufr2mrP4Hur8niDNg==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.5.tgz", + "integrity": "sha512-DJBzVWevZ/8ZLTPmweMxRZAvNU9Aaxz6Ld3Def2uioBv6grUmpebey7+yT93ddR6v3aRiva2+yNf96xq4qttQA==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", diff --git a/package.json b/package.json index 18040e36..3299a184 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@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/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 0392fae4..09d3d707 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -49,6 +49,18 @@ export const liveSyncProvisionalEnglishMessages = { "Connect to existing database and continue": "Connect to existing database and continue", "Test connection and save": "Test connection and save", "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.", "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.", diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts index 36e5b543..b93fb3d9 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts @@ -10,6 +10,8 @@ import { type RemoteDBSettings, IncompatibleChangesInSpecificPattern, CompatibleButLossyChanges, + type RemotePreferredTweakResult, + RemotePreferredTweakStatuses, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { escapeMarkdownValue } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { AbstractModule } from "@/modules/AbstractModule.ts"; @@ -256,22 +258,21 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { return "IGNORE"; } - async _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise { - const replicator = await this.services.replicator.getNewReplicator(trialSetting); - if (!replicator) { - this._log("The remote type is not supported for fetching preferred tweak values.", LOG_LEVEL_NOTICE); - return false; - } - if (await replicator.tryConnectRemote(trialSetting)) { - const preferred = await replicator.getRemotePreferredTweakValues(trialSetting); - if (preferred) { - return preferred; + async _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise { + try { + const replicator = await this.services.replicator.getNewReplicator(trialSetting); + if (!replicator) { + this._log("The remote type does not support preferred tweak values.", LOG_LEVEL_NOTICE); + return { status: RemotePreferredTweakStatuses.UNSUPPORTED }; } - this._log("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE); - return false; + return await replicator.getRemotePreferredTweakValues(trialSetting); + } 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( @@ -281,8 +282,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { return { result: false, requireFetch: false }; } const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting); - if (preferred) { - return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred); + if (preferred.status === RemotePreferredTweakStatuses.AVAILABLE) { + return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred.values); } return { result: false, requireFetch: false }; } diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts index 21f773e0..0f394d8b 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts @@ -54,6 +54,36 @@ function createModule(settingsOverride: Partial = {}) { } 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 () => { const { module, core, askSelectStringDialogue, applyPartial } = createModule({ autoAcceptCompatibleTweak: undefined, diff --git a/src/modules/features/SetupWizard/dialogs/FetchEverything.svelte b/src/modules/features/SetupWizard/dialogs/FetchEverything.svelte index c62a1792..36ea945c 100644 --- a/src/modules/features/SetupWizard/dialogs/FetchEverything.svelte +++ b/src/modules/features/SetupWizard/dialogs/FetchEverything.svelte @@ -141,10 +141,11 @@ - + + + {translateMessage("Skips checking and applying synchronisation settings from the remote.")} + + diff --git a/src/modules/features/SetupWizard/dialogs/RebuildEverything.svelte b/src/modules/features/SetupWizard/dialogs/RebuildEverything.svelte index 68221d1e..093df286 100644 --- a/src/modules/features/SetupWizard/dialogs/RebuildEverything.svelte +++ b/src/modules/features/SetupWizard/dialogs/RebuildEverything.svelte @@ -129,7 +129,9 @@ {#if !isP2P} - + + {msg("Skips checking and applying synchronisation settings from the remote.")} + {/if} diff --git a/src/serviceFeatures/redFlag.simpleFetch.ts b/src/serviceFeatures/redFlag.simpleFetch.ts index 214cad2e..c629cdfe 100644 --- a/src/serviceFeatures/redFlag.simpleFetch.ts +++ b/src/serviceFeatures/redFlag.simpleFetch.ts @@ -10,7 +10,11 @@ import { synchroniseAllFilesBetweenDBandStorage, type FullScanOptions, } 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_NEWER_WINS = "Compare time and take newer"; @@ -194,9 +198,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( if (result === "cancelled") { log("Fetch cancelled by user.", LOG_LEVEL_NOTICE); clearRememberedSimpleFetchMode(host); - await cleanupFlag(); - host.services.appLifecycle.performRestart(); - return false; + return await cancelScheduledInitialisation(host, cleanupFlag); } if (result === "aborted") { 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. } + 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 () => { - 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) await host.serviceModules.rebuilder.$fetchLocalDBFast(false); diff --git a/src/serviceFeatures/redFlag.ts b/src/serviceFeatures/redFlag.ts index 67399057..ae42b509 100644 --- a/src/serviceFeatures/redFlag.ts +++ b/src/serviceFeatures/redFlag.ts @@ -10,7 +10,10 @@ import RebuildEverything from "@/modules/features/SetupWizard/dialogs/RebuildEve import { extractObject } from "octagonal-wheels/object"; import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; 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 { FetchEverythingResult, RebuildEverythingResult, @@ -19,6 +22,7 @@ import { askAndPerformFastSetupOnScheduledFetchAll } from "./redFlag.simpleFetch import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; import { isP2PMainRemote } from "@/common/remoteConfiguration"; +import { $msg } from "@/common/translation"; /** * Flag file handler interface, similar to target filter pattern. @@ -29,6 +33,8 @@ interface FlagFileHandler { handle: () => Promise; } +type InitialisationOperation = "fetch" | "rebuild"; + export async function isFlagFileExist(host: NecessaryServices, path: string) { const redFlagExist = await host.serviceModules.storageAccess.isExists( host.serviceModules.storageAccess.normalisePath(path) @@ -158,9 +164,7 @@ export function createFetchAllFlagHandler( await host.services.UI.dialogManager.openWithExplicitCancel(FetchEverything); if (method === "cancelled") { log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE); - await cleanupFlag(); - host.services.appLifecycle.performRestart(); - return false; + return await cancelScheduledInitialisation(host, cleanupFlag); } const { vault, extra } = method; const settings = await Promise.resolve(host.services.setting.currentSettings()); @@ -185,9 +189,11 @@ export function createFetchAllFlagHandler( }, } 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 () => { - const settings = host.services.setting.currentSettings(); - await adjustSettingToRemoteIfNeeded(host, log, extra, settings); const vaultStateToAction = mapVaultStateToAction[vault]; const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction; log( @@ -217,93 +223,153 @@ export function createFetchAllFlagHandler( /** * Adjust setting to remote configuration. * @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( host: NecessaryServices<"tweakValue" | "UI" | "setting", never>, log: LogFunction, - config: ObsidianLiveSyncSettings -) { - // Fetch remote configuration unless prevented. - const SKIP_FETCH = "Skip and proceed"; - const RETRY_FETCH = "Retry (recommended)"; - let canProceed = false; - do { - const remoteTweaks = await host.services.tweakValue.fetchRemotePreferred(config); - if (!remoteTweaks) { + config: ObsidianLiveSyncSettings, + operation: InitialisationOperation = "fetch" +): Promise { + while (true) { + const remoteResult = await host.services.tweakValue.fetchRemotePreferred(config); + if (remoteResult.status === RemotePreferredTweakStatuses.NOT_CONFIGURED) { + const useDeviceSettings = $msg("Use this device's settings"); + const cancelInitialisation = $msg("Cancel"); + log(`Remote synchronisation settings are not configured (${remoteResult.reason}).`, LOG_LEVEL_INFO); 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.", - [SKIP_FETCH, RETRY_FETCH] as const, + $msg( + "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, - title: "Fetch Remote Configuration Failed", + title: $msg("No Synchronisation Settings Found"), } ); - if (choice === SKIP_FETCH) { - canProceed = true; - } - } else { - 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, + return choice === useDeviceSettings; + } + if (remoteResult.status === RemotePreferredTweakStatuses.UNAVAILABLE) { + const retryRemoteSettings = $msg("Retry"); + const useDeviceSettings = $msg("Use this device's settings"); + const cancelInitialisation = $msg("Cancel"); + log("Could not read synchronisation settings from the remote.", LOG_LEVEL_NOTICE); + log(remoteResult.error, LOG_LEVEL_VERBOSE); + if (operation === "rebuild") { + const choice = await host.services.UI.confirm.askSelectStringDialogue( + $msg( + "Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required." + ), + [retryRemoteSettings, useDeviceSettings, cancelInitialisation] as const, { - defaultAction: "OK", + defaultAction: retryRemoteSettings, timeout: 0, + title: $msg("Could Not Read Synchronisation Settings"), } ); + if (choice === retryRemoteSettings) continue; + return choice === useDeviceSettings; } - - config = { - ...config, - ...(Object.fromEntries(differentItems) as Partial), - } satisfies ObsidianLiveSyncSettings; - await host.services.setting.applyExternalSettings(config, true); - log("Remote configuration applied.", LOG_LEVEL_NOTICE); - canProceed = true; - const updatedConfig = host.services.setting.currentSettings(); - return updatedConfig; + const choice = await host.services.UI.confirm.askSelectStringDialogue( + $msg( + "Could not read the remote's synchronisation settings. Check the connection and credentials, then retry." + ), + [retryRemoteSettings, cancelInitialisation] as const, + { + defaultAction: retryRemoteSettings, + timeout: 0, + title: $msg("Could Not Read Synchronisation Settings"), + } + ); + 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), + } satisfies ObsidianLiveSyncSettings; + await host.services.setting.applyExternalSettings(config, true); + log("Remote configuration applied.", LOG_LEVEL_NOTICE); + return true; + } } /** * Adjust setting to remote if needed. * @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 operation operation which will consume the selected configuration */ export async function adjustSettingToRemoteIfNeeded( host: NecessaryServices<"tweakValue" | "UI" | "setting", never>, log: LogFunction, - extra: { preventFetchingConfig: boolean }, - config: ObsidianLiveSyncSettings -) { - if (extra && extra.preventFetchingConfig) { - return; + extra: { preventFetchingConfig: boolean } | null, + config: ObsidianLiveSyncSettings, + operation: InitialisationOperation = "fetch" +): Promise { + if (extra?.preventFetchingConfig) { + return true; } // P2P has no centralised remote configuration; skip to avoid a spurious // "Failed to connect to the remote server" error dialog. if (config.remoteType === REMOTE_P2P) { log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO); - return; + return true; } - // Remote configuration fetched and applied. - if (await adjustSettingToRemote(host, log, config)) { - config = host.services.setting.currentSettings(); - } else { + const canProceed = await adjustSettingToRemote(host, log, config, operation); + if (!canProceed) { 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 +): Promise { + 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) }); if (method === "cancelled") { log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE); - await cleanupFlag(); - host.services.appLifecycle.performRestart(); - return false; + return await cancelScheduledInitialisation(host, cleanupFlag); } 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 () => { await host.serviceModules.rebuilder.$rebuildEverything(); await cleanupFlag(); diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts index de8d998c..85803c56 100644 --- a/src/serviceFeatures/redFlag.unit.spec.ts +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -68,6 +68,8 @@ const createLoggerMock = (): LogFunction => { return vi.fn(); }; +const availableRemoteTweaks = (values: Record) => ({ status: "available", values }) as const; + const createStorageAccessMock = () => { const files: Set = new Set(); return { @@ -149,7 +151,9 @@ const createRebuilderMock = () => { const createTweakValueMock = () => { return { - fetchRemotePreferred: vi.fn(() => Promise.resolve(null)), + fetchRemotePreferred: vi.fn(() => + Promise.resolve({ status: "unavailable", error: new Error("Remote unavailable") }) + ), }; }; @@ -459,9 +463,9 @@ describe("Red Flag Feature", () => { .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); const handler = createFetchAllFlagHandler(host as any, log); const result = await handler.handle(); @@ -487,9 +491,9 @@ describe("Red Flag Feature", () => { backup: "backup_skipped", extra: { preventFetchingConfig: false }, }); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); const handler = createFetchAllFlagHandler(host as any, log); const result = await handler.handle(); @@ -506,9 +510,9 @@ describe("Red Flag Feature", () => { host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false); const handler = createFetchAllFlagHandler(host as any, log); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); const result = await handler.handle(); expect(result).toBe(false); @@ -525,9 +529,9 @@ describe("Red Flag Feature", () => { .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); const handler = createFetchAllFlagHandler(host as any, log); const result = await handler.handle(); @@ -763,7 +767,7 @@ describe("Red Flag Feature", () => { host.mocks.ui.confirm.confirmWithMessage .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS) .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")); await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag); @@ -781,7 +785,7 @@ describe("Red Flag Feature", () => { host.mocks.ui.confirm.confirmWithMessage .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .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); @@ -831,6 +835,65 @@ describe("Red Flag Feature", () => { 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 () => { const host = createHostMock(); const log = createLoggerMock(); @@ -839,7 +902,9 @@ describe("Red Flag Feature", () => { host.mocks.ui.confirm.confirmWithMessage .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .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); 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 .mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS) .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); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce( "Finalise the process and resume normal operation" @@ -1017,9 +1084,9 @@ describe("Red Flag Feature", () => { const host = createHostMock(); const config = { batchSave: true } as any; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); await adjustSettingToRemoteIfNeeded( host as any, @@ -1047,7 +1114,9 @@ describe("Red Flag Feature", () => { const differentConfig = { [key]: differentValue, }; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks(differentConfig) + ); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK"); await adjustSettingToRemote(host as any, createLoggerMock(), config); @@ -1074,7 +1143,9 @@ describe("Red Flag Feature", () => { const differentConfig = { [key]: differentValue, }; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(differentConfig as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks(differentConfig) + ); host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK"); 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 log = createLoggerMock(); const config = { batchSave: true } as any; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null); - host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed"); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + 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 log = createLoggerMock(); const config = { batchSave: true } as any; + const failure = new Error("network failed"); host.mocks.tweakValue.fetchRemotePreferred - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ batchSave: false } as any); - host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Retry (recommended)"); + .mockResolvedValueOnce({ status: "unavailable", error: failure }) + .mockResolvedValueOnce({ status: "available", values: { batchSave: false } } as any); + 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.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 () => { @@ -1117,9 +1227,9 @@ describe("Red Flag Feature", () => { const log = createLoggerMock(); const config = { batchSave: false } as any; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - batchSave: false, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ batchSave: false }) + ); await adjustSettingToRemote(host as any, log, config); @@ -1131,8 +1241,11 @@ describe("Red Flag Feature", () => { const log = createLoggerMock(); const config = { batchSave: true } as any; - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(null); - host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Skip and proceed"); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ + 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); @@ -1460,6 +1573,93 @@ describe("Red Flag Feature", () => { }); 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 () => { const host = createHostMock(); const log = createLoggerMock(); @@ -1476,7 +1676,7 @@ describe("Red Flag Feature", () => { const log = createLoggerMock(); 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.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); @@ -1554,9 +1754,7 @@ describe("Red Flag Feature", () => { it("should handle fetchAll flag with flagHandlerToEventHandler identical", async () => { const host = createHostMock(); const log = createLoggerMock(); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ - customChunkSize: 1, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ customChunkSize: 1 })); host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); 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 () => { const host = createHostMock(); const log = createLoggerMock(); - host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({ - customChunkSize: 1, - } as any); + host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce( + availableRemoteTweaks({ customChunkSize: 1 }) + ); host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ extra: {} }); diff --git a/test/e2e-obsidian/runner/setupUri.ts b/test/e2e-obsidian/runner/setupUri.ts index 0c1697be..10764fbc 100644 --- a/test/e2e-obsidian/runner/setupUri.ts +++ b/test/e2e-obsidian/runner/setupUri.ts @@ -254,17 +254,23 @@ export async function confirmRebuild(port: number, captures: SetupCaptureNames): return screenshot; } -export async function skipMissingRemoteConfiguration(port: number, captures: SetupCaptureNames): Promise { - const title = "Fetch Remote Configuration Failed"; +export async function continueWithoutRemoteSettings(port: number, captures: SetupCaptureNames): Promise { + const title = "No Synchronisation Settings Found"; const screenshot = await captureGuideDialogue( port, `guide-${captures.guide}-missing-remote-configuration.png`, title ); await withObsidianPage(port, async (page) => { - await modalByTitle(page, title) - .getByRole("button", { name: "Skip and proceed" }) - .click({ timeout: uiTimeoutMs }); + const modal = modalByTitle(page, title); + await modal + .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; } diff --git a/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts b/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts index 2a662a6f..ef44ae87 100644 --- a/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts +++ b/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts @@ -25,7 +25,7 @@ import { modalByTitle, resumeCompatibilityReviewIfShown, selectRadioOption, - skipMissingRemoteConfiguration, + continueWithoutRemoteSettings, type SetupArtifact, } from "../runner/setupUri.ts"; import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts"; @@ -278,7 +278,7 @@ async function main(): Promise { screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName))); screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", 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)); const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); diff --git a/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts b/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts index 19276252..40d1b63c 100644 --- a/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts +++ b/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts @@ -29,7 +29,7 @@ import { finishInitialisation, generateSetupURIFromDevice, resumeCompatibilityReviewIfShown, - skipMissingRemoteConfiguration, + continueWithoutRemoteSettings, type SetupArtifact, type SetupCaptureNames, } from "../runner/setupUri.ts"; @@ -226,7 +226,7 @@ async function main(): Promise { screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures)); screenshots.push(await captureAndStartInitialisation(portA, "new", 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)); const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv); await resumeCompatibilityReviewIfShown(portA); diff --git a/test/e2e-obsidian/scripts/setup-uri-workflow.ts b/test/e2e-obsidian/scripts/setup-uri-workflow.ts index a53ca5ac..3a42db44 100644 --- a/test/e2e-obsidian/scripts/setup-uri-workflow.ts +++ b/test/e2e-obsidian/scripts/setup-uri-workflow.ts @@ -300,8 +300,8 @@ async function confirmRebuild(port: number): Promise { return screenshot; } -async function skipMissingRemoteConfiguration(port: number): Promise { - const title = "Fetch Remote Configuration Failed"; +async function continueWithoutRemoteSettings(port: number): Promise { + const title = "No Synchronisation Settings Found"; const screenshot = await captureObsidianDialogue( port, "setup-uri-first-missing-remote-configuration.png", @@ -309,16 +309,20 @@ async function skipMissingRemoteConfiguration(port: number): Promise { const modal = modalByTitle(page, title); await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); 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, }) .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 withObsidianPage(port, async (page) => { await modalByTitle(page, title) - .getByRole("button", { name: "Skip and proceed" }) + .getByRole("button", { name: "Use this device's settings" }) .click({ timeout: uiTimeoutMs }); }); return screenshot; @@ -736,7 +740,7 @@ async function main(): Promise { await enterSetupURI(session.remoteDebuggingPort, "new", artifact); screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new")); screenshots.push(await confirmRebuild(session.remoteDebuggingPort)); - screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort)); + screenshots.push(await continueWithoutRemoteSettings(session.remoteDebuggingPort)); screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort)); const firstCompletion = await finishInitialisation( session.remoteDebuggingPort, diff --git a/updates.md b/updates.md index 2d52c0d4..d7af0250 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ## Unreleased +### 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 5th August, 2026