mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-06 19:55:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
741551be3a | ||
|
|
5f69d301f0 | ||
|
|
b42df389fb | ||
|
|
6a46966957 |
+2
-2
@@ -39,7 +39,7 @@ Use this path only when the remote database is new, or when this device is inten
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,7 +50,7 @@ Use a new bucket prefix, or a prefix whose contents you deliberately intend to r
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
||||
BIN
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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -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",
|
||||
@@ -15924,7 +15924,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.5-cli",
|
||||
"version": "1.0.6-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -15949,7 +15949,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.5-webapp",
|
||||
"version": "1.0.6-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
@@ -15961,7 +15961,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.5-webpeer",
|
||||
"version": "1.0.6-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.5-cli",
|
||||
"version": "1.0.6-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.5-webapp",
|
||||
"version": "1.0.6-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.5-webpeer",
|
||||
"version": "1.0.6-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<TweakValues | false> {
|
||||
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<RemotePreferredTweakResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -54,6 +54,36 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -141,10 +141,11 @@
|
||||
</Instruction>
|
||||
<Instruction>
|
||||
<ExtraItems title={translateMessage("Advanced")}>
|
||||
<Check
|
||||
title={translateMessage("Prevent fetching configuration from server")}
|
||||
bind:value={preventFetchingConfig}
|
||||
/>
|
||||
<Check title={translateMessage("Use this device's settings")} bind:value={preventFetchingConfig}>
|
||||
<InfoNote>
|
||||
{translateMessage("Skips checking and applying synchronisation settings from the remote.")}
|
||||
</InfoNote>
|
||||
</Check>
|
||||
</ExtraItems>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
|
||||
@@ -129,7 +129,9 @@
|
||||
{#if !isP2P}
|
||||
<Instruction>
|
||||
<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>
|
||||
</Instruction>
|
||||
{/if}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+129
-62
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
type InitialisationOperation = "fetch" | "rebuild";
|
||||
|
||||
export async function isFlagFileExist(host: NecessaryServices<never, "storageAccess">, 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<FetchEverythingResult>(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<boolean> {
|
||||
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<ObsidianLiveSyncSettings>),
|
||||
} 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<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.
|
||||
* @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<boolean> {
|
||||
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<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) });
|
||||
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();
|
||||
|
||||
@@ -68,6 +68,8 @@ const createLoggerMock = (): LogFunction => {
|
||||
return vi.fn();
|
||||
};
|
||||
|
||||
const availableRemoteTweaks = (values: Record<string, unknown>) => ({ status: "available", values }) as const;
|
||||
|
||||
const createStorageAccessMock = () => {
|
||||
const files: Set<string> = new Set();
|
||||
return {
|
||||
@@ -149,7 +151,9 @@ const createRebuilderMock = () => {
|
||||
|
||||
const createTweakValueMock = () => {
|
||||
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_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: {} });
|
||||
|
||||
@@ -254,17 +254,23 @@ export async function confirmRebuild(port: number, captures: SetupCaptureNames):
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
export async function skipMissingRemoteConfiguration(port: number, captures: SetupCaptureNames): Promise<string> {
|
||||
const title = "Fetch Remote Configuration Failed";
|
||||
export async function continueWithoutRemoteSettings(port: number, captures: SetupCaptureNames): Promise<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -300,8 +300,8 @@ async function confirmRebuild(port: number): Promise<string> {
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function skipMissingRemoteConfiguration(port: number): Promise<string> {
|
||||
const title = "Fetch Remote Configuration Failed";
|
||||
async function continueWithoutRemoteSettings(port: number): Promise<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
+12
@@ -12,6 +12,18 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## 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
|
||||
|
||||
5th August, 2026
|
||||
|
||||
+2
-1
@@ -17,5 +17,6 @@
|
||||
"1.0.2": "1.7.2",
|
||||
"1.0.3": "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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user