Compare commits

..
14 changed files with 494 additions and 267 deletions
+3 -2
View File
@@ -15,13 +15,13 @@ import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/co
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { useJournalSyncFeature } from "@vrtmrz/livesync-commonlib/journal-sync";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { AbstractModule } from "./modules/AbstractModule";
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
@@ -139,7 +139,6 @@ export class LiveSyncBaseCore<
public registerModules(extraModules: AbstractModule[] = []) {
this._registerModule(new ModuleLiveSyncMain(this));
this._registerModule(new ModuleConflictChecker(this));
this._registerModule(new ModuleReplicatorMinIO(this));
this._registerModule(new ModuleReplicatorCouchDB(this));
this._registerModule(new ModuleReplicator(this));
this._registerModule(new ModuleConflictResolver(this));
@@ -279,6 +278,8 @@ export class LiveSyncBaseCore<
usePrepareDatabaseForUse(this);
// Migration to multiple remote configurations
useRemoteConfigurationMigration(this);
// Journal providers are selected through the composed remote-provider registry.
useJournalSyncFeature(this);
}
}
+4 -4
View File
@@ -1,5 +1,6 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import { AbstractModule } from "@/modules/AbstractModule";
@@ -8,8 +9,7 @@ import type { LiveSyncCore } from "@/main";
export class ModuleReplicatorCouchDB extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
if (!defaultRemoteProviderRegistry.isRemoteTypeInFamily(settings.remoteType, "couchdb")) {
return Promise.resolve(false);
}
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
@@ -17,7 +17,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
_everyAfterResumeProcess(): Promise<boolean> {
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
if (defaultRemoteProviderRegistry.isRemoteTypeInFamily(this.settings.remoteType, "couchdb")) {
const LiveSyncEnabled = this.settings.liveSync;
const continuous = LiveSyncEnabled;
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isReplicationReady = true) {
function createModule(
settings: { liveSync: boolean; syncOnStart: boolean; remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO },
isReplicationReady = true
) {
const openReplication = vi.fn(async () => true);
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
const services = {
@@ -30,7 +34,7 @@ function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isR
_services: services,
services,
settings: {
remoteType: "",
remoteType: REMOTE_COUCHDB,
...settings,
},
replicator: { openReplication },
@@ -86,4 +90,18 @@ describe("ModuleReplicatorCouchDB resume replication activity", () => {
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
expect(openReplication).not.toHaveBeenCalled();
});
it("does not start CouchDB replication for a registered Journal provider", async () => {
const { module, openReplication } = createModule({
liveSync: true,
syncOnStart: true,
remoteType: REMOTE_MINIO,
});
await expect(module._anyNewReplicator()).resolves.toBe(false);
await module._everyAfterResumeProcess();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(openReplication).not.toHaveBeenCalled();
});
});
-18
View File
@@ -1,18 +0,0 @@
import { REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import type { LiveSyncCore } from "@/main";
import { AbstractModule } from "@/modules/AbstractModule";
export class ModuleReplicatorMinIO extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
if (settings.remoteType == REMOTE_MINIO) {
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
}
return Promise.resolve(false);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
}
}
@@ -1,7 +1,4 @@
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
type ObsidianLiveSyncSettings,
@@ -21,18 +18,12 @@ import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
import {
activateRemoteConfiguration,
createRemoteConfigurationId,
defaultRemoteProviderRegistry,
suggestRemoteConfigurationName,
type BuiltInRemoteConfiguration,
type RemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import type { RemoteConfigurationResult } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import type {
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes.ts";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
@@ -43,10 +34,6 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
}
return workObj;
}
function createRemoteConfigurationId(): string {
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function cloneRemoteConfigurations(
configs: Record<string, RemoteConfiguration> | undefined
): Record<string, RemoteConfiguration> {
@@ -54,13 +41,10 @@ function cloneRemoteConfigurations(
}
function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string {
if (settings.remoteType === REMOTE_MINIO) {
return ConnectionStringParser.serialize({ type: "s3", settings });
}
if (settings.remoteType === REMOTE_P2P) {
return ConnectionStringParser.serialize({ type: "p2p", settings });
}
return ConnectionStringParser.serialize({ type: "couchdb", settings });
const type = defaultRemoteProviderRegistry.typeForRemoteType(settings.remoteType);
if (!type) throw new Error(`Unsupported remote type: ${settings.remoteType}`);
const configuration = defaultRemoteProviderRegistry.configurationFromSettings(type, settings);
return defaultRemoteProviderRegistry.serialise(configuration);
}
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
@@ -71,21 +55,6 @@ function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string)
return button;
}
function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): string {
if (parsed.type === "couchdb") {
try {
const url = new URL(parsed.settings.couchDB_URI);
return `CouchDB ${url.host}`;
} catch {
return "Imported CouchDB";
}
}
if (parsed.type === "s3") {
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
}
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
}
export function paneRemoteConfig(
this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement,
@@ -189,48 +158,10 @@ export function paneRemoteConfig(
};
const runRemoteSetup = async (
baseSettings: ObsidianLiveSyncSettings,
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
type?: BuiltInRemoteConfiguration["type"]
): Promise<ObsidianLiveSyncSettings | false> => {
const setupManager = this.core.getModule(SetupManager);
const dialogManager = setupManager.dialogManager;
let targetRemoteType = remoteType;
if (targetRemoteType === undefined) {
const method = await dialogManager.openWithExplicitCancel(SetupRemote);
if (method === "cancelled") {
return false;
}
targetRemoteType =
method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB;
}
if (targetRemoteType === REMOTE_MINIO) {
const bucketConf = await dialogManager.openWithExplicitCancel(SetupRemoteBucket, baseSettings);
if (bucketConf === "cancelled" || typeof bucketConf !== "object") {
return false;
}
return { ...baseSettings, ...bucketConf, remoteType: REMOTE_MINIO };
}
if (targetRemoteType === REMOTE_P2P) {
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, baseSettings);
if (p2pConf === "cancelled" || typeof p2pConf !== "object") {
return false;
}
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
}
const couchConf = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: baseSettings,
mode: "settings",
});
if (couchConf === "cancelled" || typeof couchConf !== "object") {
return false;
}
return { ...baseSettings, ...couchConf, remoteType: REMOTE_COUCHDB };
return await setupManager.configureRemoteForSettings(baseSettings, type);
};
const createBaseRemoteSettings = (): ObsidianLiveSyncSettings => ({
...DEFAULT_SETTINGS,
@@ -282,9 +213,9 @@ export function paneRemoteConfig(
return;
}
let parsed: RemoteConfigurationResult;
let parsed: BuiltInRemoteConfiguration;
try {
parsed = ConnectionStringParser.parse(trimmedURI);
parsed = defaultRemoteProviderRegistry.parse(trimmedURI);
} catch (ex) {
this.services.API.addLog(`Failed to import remote configuration!`, LOG_LEVEL_NOTICE);
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
@@ -302,7 +233,7 @@ export function paneRemoteConfig(
configs[id] = {
id,
name: name.trim() || defaultName,
uri: ConnectionStringParser.serialize(parsed),
uri: defaultRemoteProviderRegistry.serialise(parsed),
isEncrypted: false,
};
this.editingSettings.remoteConfigurations = configs;
@@ -337,9 +268,9 @@ export function paneRemoteConfig(
row.addButton((btn) =>
setEmojiButton(btn, "🔧", "Configure").onClick(async () => {
let parsed: RemoteConfigurationResult;
let parsed: BuiltInRemoteConfiguration;
try {
parsed = ConnectionStringParser.parse(config.uri);
parsed = defaultRemoteProviderRegistry.parse(config.uri);
} catch (ex) {
this.services.API.addLog(
`Failed to parse remote configuration '${config.id}' for editing!`,
@@ -349,16 +280,9 @@ export function paneRemoteConfig(
return;
}
const workSettings = createBaseRemoteSettings();
if (parsed.type === "couchdb") {
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else {
workSettings.remoteType = REMOTE_P2P;
}
Object.assign(workSettings, parsed.settings);
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
const nextSettings = await runRemoteSetup(workSettings, workSettings.remoteType);
const nextSettings = await runRemoteSetup(workSettings, parsed.type);
if (!nextSettings) {
return;
}
@@ -447,9 +371,9 @@ export function paneRemoteConfig(
.addSeparator()
.addItem((item) => {
item.setTitle("📡 Fetch remote settings").onClick(async () => {
let parsed: RemoteConfigurationResult;
let parsed: BuiltInRemoteConfiguration;
try {
parsed = ConnectionStringParser.parse(config.uri);
parsed = defaultRemoteProviderRegistry.parse(config.uri);
} catch (ex) {
this.services.API.addLog(
`Failed to parse remote configuration '${config.id}' for fetching settings!`,
@@ -459,14 +383,7 @@ export function paneRemoteConfig(
return;
}
const workSettings = createBaseRemoteSettings();
if (parsed.type === "couchdb") {
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else {
workSettings.remoteType = REMOTE_P2P;
}
Object.assign(workSettings, parsed.settings);
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
const newTweaks =
await this.services.tweakValue.checkAndAskUseRemoteConfiguration(
workSettings
+87 -82
View File
@@ -1,15 +1,15 @@
import {
type BucketSyncSetting,
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
REMOTE_MINIO,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import {
defaultRemoteProviderRegistry,
upsertRemoteConfigurationInPlace,
type BuiltInRemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import Intro from "./SetupWizard/dialogs/Intro.svelte";
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
@@ -20,9 +20,6 @@ import OutroNewUser from "./SetupWizard/dialogs/OutroNewUser.svelte";
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { AbstractModule } from "@/modules/AbstractModule.ts";
@@ -31,11 +28,8 @@ import type {
OutroExistingUserResultType,
OutroNewUserResultType,
ScanQRCodeResultType,
SetupRemoteBucketResultType,
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType,
SetupRemoteP2PResultType,
SetupRemoteInitialData,
SetupRemoteResultType,
UseSetupURIResultType,
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
@@ -44,6 +38,8 @@ import {
applySettingsWithScheduledInitialisation,
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { builtInRemoteSetupRegistry } from "./SetupWizard/builtInRemoteSetupProviders.ts";
import type { RemoteSetupIntent } from "./SetupWizard/RemoteSetupRegistry.ts";
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
return {
@@ -79,6 +75,8 @@ export const enum UserMode {
* Setup Manager to handle onboarding and configuration setup
*/
export class SetupManager extends AbstractModule {
readonly remoteSetupRegistry = builtInRemoteSetupRegistry;
// /**
// * Dialog manager for handling Svelte dialogs
// */
@@ -157,6 +155,76 @@ export class SetupManager extends AbstractModule {
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
}
private setupIntent(userMode: UserMode): RemoteSetupIntent {
if (userMode === UserMode.NewUser) return "create-or-connect";
if (userMode === UserMode.ExistingUser) return "connect-existing";
return "settings";
}
private async selectRemoteSetupProvider(): Promise<BuiltInRemoteConfiguration["type"] | false> {
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType, SetupRemoteInitialData>(
SetupRemote,
this.remoteSetupRegistry.choices()
);
return method === "cancelled" ? false : method;
}
/**
* Opens a provider-specific dialogue without applying or storing the resulting settings.
*
* The settings pane uses this boundary to prepare a remote profile in its own edit buffer.
*/
async configureRemoteForSettings(
currentSetting: ObsidianLiveSyncSettings,
type?: BuiltInRemoteConfiguration["type"]
): Promise<ObsidianLiveSyncSettings | false> {
const selectedType = type ?? (await this.selectRemoteSetupProvider());
if (selectedType === false) return false;
const configuration = await this.remoteSetupRegistry.open(selectedType, {
dialogManager: this.dialogManager,
intent: "settings",
settings: currentSetting,
});
if (configuration === "cancelled") return false;
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
return newSetting;
}
private async onRemoteManualSetup(
type: BuiltInRemoteConfiguration["type"],
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate: boolean
): Promise<boolean> {
const configuration = await this.remoteSetupRegistry.open(type, {
dialogManager: this.dialogManager,
intent: this.setupIntent(userMode),
settings: currentSetting,
});
if (configuration === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
if (activate) {
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
} else {
Object.assign(newSetting, configuration.settings);
}
const activateForP2P = defaultRemoteProviderRegistry.supportsActivationRole(type, "p2p");
upsertRemoteConfigurationInPlace(newSetting, type, {
id: activateForP2P ? newSetting.P2P_ActiveRemoteConfigurationId || undefined : undefined,
activate,
activateForP2P,
});
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for CouchDB
* @param userMode
@@ -169,31 +237,7 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const couchConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: currentSetting,
mode:
userMode === UserMode.NewUser
? "create-or-connect"
: userMode === UserMode.ExistingUser
? "connect-existing"
: "settings",
});
if (couchConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...couchConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_COUCHDB;
}
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
return await this.onRemoteManualSetup("couchdb", userMode, currentSetting, activate);
}
/**
@@ -208,23 +252,7 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const bucketConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteBucketResultType,
BucketSyncSetting
>(SetupRemoteBucket, currentSetting);
if (bucketConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...bucketConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_MINIO;
}
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
return await this.onRemoteManualSetup("s3", userMode, currentSetting, activate);
}
/**
@@ -239,24 +267,7 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const p2pConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
SetupRemoteP2P,
currentSetting
);
if (p2pConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...p2pConf,
} as ObsidianLiveSyncSettings;
upsertRemoteConfigurationInPlace(newSetting, "p2p", {
id: newSetting.P2P_ActiveRemoteConfigurationId || undefined,
activate,
activateForP2P: true,
});
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
return await this.onRemoteManualSetup("p2p", userMode, currentSetting, activate);
}
/**
@@ -310,21 +321,15 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType>(SetupRemote);
if (method === "couchdb") {
return await this.onCouchDBManualSetup(userMode, currentSetting, true);
} else if (method === "bucket") {
return await this.onBucketManualSetup(userMode, currentSetting, true);
} else if (method === "p2p") {
return await this.onP2PManualSetup(userMode, currentSetting, true);
} else if (method === "cancelled") {
const method = await this.selectRemoteSetupProvider();
if (method === false) {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
if (userMode !== UserMode.Unknown) {
return await this.onOnboard(userMode);
}
return false;
}
// Should not reach here.
return false;
return await this.onRemoteManualSetup(method, userMode, currentSetting, true);
}
/**
* Confirms and applies settings obtained from the wizard
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
@@ -421,6 +422,40 @@ describe("SetupManager", () => {
expect(activeProfile?.uri).toContain("sls+s3://key:secret@storage.example");
});
it("uses the registered provider choices when configuring a Settings edit buffer", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const bucketSettings = {
endpoint: "https://storage.example",
accessKey: "key",
secretKey: "secret",
bucket: "notes",
region: "auto",
bucketPrefix: "",
useCustomRequestHandler: false,
bucketCustomHeaders: "",
forcePathStyle: true,
};
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("s3").mockResolvedValueOnce(bucketSettings);
const nextSettings = await manager.configureRemoteForSettings(setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ type: "couchdb" }),
expect.objectContaining({ type: "s3" }),
expect.objectContaining({ type: "p2p" }),
])
);
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
2,
expect.anything(),
setting.currentSettings()
);
expect(nextSettings).toEqual(expect.objectContaining({ ...bucketSettings, remoteType: REMOTE_MINIO }));
});
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
@@ -0,0 +1,84 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type {
BuiltInRemoteConfiguration,
RemoteProviderConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { ComponentHasResult } from "@/modules/services/LiveSyncUI/svelteDialog";
export type RemoteSetupIntent = "create-or-connect" | "connect-existing" | "settings";
export interface RemoteSetupDialogManager {
openWithExplicitCancel<TResult, TInitial = TResult>(
component: ComponentHasResult<TResult, TInitial>,
initialData?: TInitial
): Promise<TResult>;
}
export interface RemoteSetupContext {
readonly dialogManager: RemoteSetupDialogManager;
readonly intent: RemoteSetupIntent;
readonly settings: ObsidianLiveSyncSettings;
}
export interface RemoteSetupChoice<TType extends string = string> {
readonly description: string;
readonly proceedTitle: string;
readonly title: string;
readonly type: TType;
}
export interface RemoteSetupProviderDescriptor<
TConfiguration extends RemoteProviderConfiguration = RemoteProviderConfiguration,
> {
readonly type: TConfiguration["type"];
choice(): Omit<RemoteSetupChoice<TConfiguration["type"]>, "type">;
open(context: RemoteSetupContext): Promise<TConfiguration | "cancelled">;
}
/**
* Host-side presentation for the remote providers compiled into this plug-in.
*
* Connection semantics remain in Commonlib's remote provider registry. This registry only owns
* the Obsidian-specific choice text and setup dialogue for each provider.
*/
export class RemoteSetupRegistry<TConfiguration extends RemoteProviderConfiguration = BuiltInRemoteConfiguration> {
private readonly providersByType = new Map<string, RemoteSetupProviderDescriptor<TConfiguration>>();
private frozen = false;
register<TProviderConfiguration extends TConfiguration>(
descriptor: RemoteSetupProviderDescriptor<TProviderConfiguration>
): this {
if (this.frozen) throw new Error("The remote setup registry is frozen");
if (this.providersByType.has(descriptor.type)) {
throw new Error(`Remote setup provider '${descriptor.type}' is already registered`);
}
this.providersByType.set(descriptor.type, descriptor);
return this;
}
freeze(): this {
this.frozen = true;
return this;
}
isFrozen(): boolean {
return this.frozen;
}
choices(): RemoteSetupChoice<TConfiguration["type"]>[] {
return [...this.providersByType.values()].map((provider) => ({
...provider.choice(),
type: provider.type,
}));
}
has(type: string): type is TConfiguration["type"] {
return this.providersByType.has(type);
}
open(type: TConfiguration["type"], context: RemoteSetupContext): Promise<TConfiguration | "cancelled"> {
const provider = this.providersByType.get(type);
if (!provider) throw new Error(`Unsupported remote setup provider: ${type}`);
return provider.open(context);
}
}
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
type TestConfiguration =
| { type: "alpha"; settings: { value: string } }
| { type: "beta"; settings: { enabled: boolean } };
function alphaProvider(
open: RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>>["open"] = async () => ({
type: "alpha",
settings: { value: "configured" },
})
): RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>> {
return {
type: "alpha",
choice: () => ({
title: "Alpha",
description: "Alpha description",
proceedTitle: "Configure Alpha",
}),
open,
};
}
describe("RemoteSetupRegistry", () => {
it("preserves registration order and dispatches setup through the selected provider", async () => {
const openAlpha = vi.fn(alphaProvider().open);
const registry = new RemoteSetupRegistry<TestConfiguration>()
.register(alphaProvider(openAlpha))
.register({
type: "beta",
choice: () => ({
title: "Beta",
description: "Beta description",
proceedTitle: "Configure Beta",
}),
open: async () => ({ type: "beta", settings: { enabled: true } }),
})
.freeze();
const context = {
dialogManager: { openWithExplicitCancel: vi.fn() },
intent: "settings" as const,
settings: {} as never,
};
expect(registry.choices().map((choice) => choice.type)).toEqual(["alpha", "beta"]);
await expect(registry.open("alpha", context)).resolves.toEqual({
type: "alpha",
settings: { value: "configured" },
});
expect(openAlpha).toHaveBeenCalledWith(context);
});
it("rejects duplicate and late registrations", () => {
const registry = new RemoteSetupRegistry<TestConfiguration>().register(alphaProvider());
expect(() => registry.register(alphaProvider())).toThrow("already registered");
registry.freeze();
expect(registry.isFrozen()).toBe(true);
expect(() =>
registry.register({
type: "beta",
choice: () => ({ title: "Beta", description: "", proceedTitle: "" }),
open: async () => "cancelled",
})
).toThrow("frozen");
});
});
@@ -0,0 +1,102 @@
import type { BucketSyncSetting, P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { $msg as translateMessage } from "@/common/translation";
import SetupRemoteBucket from "./dialogs/SetupRemoteBucket.svelte";
import SetupRemoteCouchDB from "./dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteP2P from "./dialogs/SetupRemoteP2P.svelte";
import type {
SetupRemoteBucketResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
SetupRemoteP2PResultType,
} from "./dialogs/setupDialogTypes";
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
type ConfigurationOf<TType extends BuiltInRemoteConfiguration["type"]> = Extract<
BuiltInRemoteConfiguration,
{ type: TType }
>;
function assertSemanticProvider(type: BuiltInRemoteConfiguration["type"]): void {
const isRegistered = defaultRemoteProviderRegistry.providerSummaries().some((provider) => provider.type === type);
if (!isRegistered) throw new Error(`Remote setup provider '${type}' has no Commonlib provider`);
}
export function useCouchDBRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"couchdb">> = {
type: "couchdb",
choice: () => ({
title: "CouchDB",
description: translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc"),
proceedTitle: translateMessage("Continue to CouchDB setup"),
}),
open: async ({ dialogManager, intent, settings }) => {
const result = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, { settings, mode: intent });
return result === "cancelled" ? result : { type: "couchdb", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function useS3RemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"s3">> = {
type: "s3",
choice: () => ({
title: translateMessage("Ui.SetupWizard.SetupRemote.BucketOption"),
description: translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc"),
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket"),
}),
open: async ({ dialogManager, settings }) => {
const result = await dialogManager.openWithExplicitCancel<SetupRemoteBucketResultType, BucketSyncSetting>(
SetupRemoteBucket,
settings
);
return result === "cancelled" ? result : { type: "s3", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function useP2PRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"p2p">> = {
type: "p2p",
choice: () => ({
title: translateMessage("Ui.SetupWizard.SetupRemote.P2POption"),
description: translateMessage(
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
),
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P"),
}),
open: async ({ dialogManager, settings }) => {
const result = await dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
SetupRemoteP2P,
settings
);
return result === "cancelled" ? result : { type: "p2p", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
useCouchDBRemoteSetup(registry);
useS3RemoteSetup(registry);
useP2PRemoteSetup(registry);
return registry;
}
export const builtInRemoteSetupRegistry = createBuiltInRemoteSetupRegistry().freeze();
@@ -7,58 +7,33 @@
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import {
TYPE_COUCHDB,
TYPE_BUCKET,
TYPE_P2P,
TYPE_CANCELLED,
type SetupRemoteResultType,
} from "./setupDialogTypes";
import { onMount } from "svelte";
import { type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { TYPE_CANCELLED, type SetupRemoteInitialData, type SetupRemoteResultType } from "./setupDialogTypes";
type Props = {
setResult: (result: SetupRemoteResultType) => void;
};
const { setResult }: Props = $props();
type Props = GuestDialogProps<SetupRemoteResultType, SetupRemoteInitialData>;
const { setResult, getInitialData }: Props = $props();
let choices = $state<SetupRemoteInitialData>([]);
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_COUCHDB) {
return translateMessage("Continue to CouchDB setup");
} else if (userType === TYPE_BUCKET) {
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
} else if (userType === TYPE_P2P) {
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
} else {
return translateMessage("Please select an option to proceed");
}
});
const canProceed = $derived.by(() => {
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
onMount(() => {
choices = getInitialData?.() ?? [];
});
const selectedChoice = $derived(choices.find((choice) => choice.type === userType));
const proceedTitle = $derived(
selectedChoice?.proceedTitle ?? translateMessage("Please select an option to proceed")
);
const canProceed = $derived(selectedChoice !== undefined);
</script>
<DialogHeader title={translateMessage("Ui.SetupWizard.SetupRemote.Title")} />
<Instruction>
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
<Options>
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
{translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc")}
</Option>
<Option
selectedValue={TYPE_BUCKET}
title={translateMessage("Ui.SetupWizard.SetupRemote.BucketOption")}
bind:value={userType}
>
{translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc")}
</Option>
<Option
selectedValue={TYPE_P2P}
title={translateMessage("Ui.SetupWizard.SetupRemote.P2POption")}
bind:value={userType}
>
{translateMessage(
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
)}
</Option>
{#each choices as choice (choice.type)}
<Option selectedValue={choice.type} title={choice.title} bind:value={userType}>
{choice.description}
</Option>
{/each}
</Options>
</Instruction>
<UserDecisions>
@@ -5,6 +5,8 @@ import type {
ObsidianLiveSyncSettings,
P2PConnectionInfo,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { RemoteSetupChoice } from "@/modules/features/SetupWizard/RemoteSetupRegistry";
export const TYPE_IDENTICAL = "identical";
export const TYPE_INDEPENDENT = "independent";
@@ -38,7 +40,7 @@ export const TYPE_CLOSE = "close";
// SetupRemote
export const TYPE_COUCHDB = "couchdb";
export const TYPE_BUCKET = "bucket";
export const TYPE_BUCKET = "s3";
export const TYPE_P2P = "p2p";
export type ResultTypeVault =
@@ -93,7 +95,8 @@ export type SelectMethodExistingResultType =
| typeof TYPE_CONFIGURE_MANUALLY
| typeof TYPE_CANCELLED;
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
export type SetupRemoteResultType = BuiltInRemoteConfiguration["type"] | typeof TYPE_CANCELLED;
export type SetupRemoteInitialData = RemoteSetupChoice<BuiltInRemoteConfiguration["type"]>[];
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
+6 -9
View File
@@ -147,14 +147,9 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
: req instanceof Request && typeof req.method === "string"
? req.method
: "GET";
if (typeof req !== "string") {
if (opts?.body) {
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
} else if (req.body) {
body = await new Response(req.body).arrayBuffer();
}
} else {
body = opts?.body as string;
const suppliedBody = opts?.body ?? (req instanceof Request ? req.body : undefined);
if (suppliedBody !== undefined && suppliedBody !== null) {
body = typeof suppliedBody === "string" ? suppliedBody : await new Response(suppliedBody).arrayBuffer();
}
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
@@ -192,7 +187,9 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
contentType: contentType,
};
const r = await requestUrl({ ...requestParam, throw: false });
return new Response(r.arrayBuffer, {
const responseHasNoBody =
method.toUpperCase() === "HEAD" || r.status === 204 || r.status === 205 || r.status === 304;
return new Response(responseHasNoBody ? null : r.arrayBuffer, {
headers: r.headers,
status: r.status,
statusText: `${r.status}`,
@@ -4,16 +4,17 @@ const mocks = vi.hoisted(() => ({
platform: {
isMobile: false,
},
requestUrl: vi.fn(),
}));
vi.mock("@/deps.ts", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/deps", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
@@ -65,3 +66,41 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
});
});
describe("ObsidianAPIService.nativeFetch", () => {
it("normalises a typed-array body when the request URL is a string", async () => {
mocks.requestUrl.mockResolvedValue({
arrayBuffer: new Uint8Array([9, 8, 7]).buffer,
headers: { etag: '"created"' },
status: 201,
});
const source = new Uint8Array([0, 1, 2, 3, 4]);
const body = source.subarray(1, 4);
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
body: body as unknown as BodyInit,
headers: { "Content-Type": "application/octet-stream" },
method: "PUT",
});
expect(response.status).toBe(201);
const request = mocks.requestUrl.mock.calls[0][0] as { body?: unknown };
expect(request.body).toBeInstanceOf(ArrayBuffer);
expect([...new Uint8Array(request.body as ArrayBuffer)]).toEqual([1, 2, 3]);
});
it("constructs a bodyless response for a successful DELETE", async () => {
mocks.requestUrl.mockResolvedValue({
arrayBuffer: new ArrayBuffer(0),
headers: {},
status: 204,
});
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
method: "DELETE",
});
expect(response.status).toBe(204);
expect(await response.text()).toBe("");
});
});