Create remote profiles during onboarding

This commit is contained in:
vorotamoroz
2026-07-20 14:33:09 +00:00
parent 40a4987b0b
commit 893c08ad2a
4 changed files with 299 additions and 27 deletions
+28 -27
View File
@@ -8,9 +8,9 @@ import {
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { upsertRemoteConfigurationInPlace } 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";
@@ -27,7 +27,6 @@ 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";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import type {
OutroAskUserModeResultType,
OutroExistingUserResultType,
@@ -45,6 +44,13 @@ import {
applySettingsWithScheduledInitialisation,
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
return {
...settings,
remoteConfigurations: { ...(settings.remoteConfigurations ?? {}) },
};
}
/**
* User modes for onboarding and setup
*/
@@ -162,20 +168,22 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const originalSetting = JSON.parse(JSON.stringify(currentSetting)) as ObsidianLiveSyncSettings;
const baseSetting = JSON.parse(JSON.stringify(originalSetting)) as ObsidianLiveSyncSettings;
const couchConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
CouchDBConnection
>(SetupRemoteCouchDB, originalSetting);
>(SetupRemoteCouchDB, currentSetting);
if (couchConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = { ...baseSetting, ...couchConf } as ObsidianLiveSyncSettings;
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...couchConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_COUCHDB;
}
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
@@ -199,10 +207,14 @@ export class SetupManager extends AbstractModule {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = { ...currentSetting, ...bucketConf } as ObsidianLiveSyncSettings;
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...bucketConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_MINIO;
}
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
@@ -226,26 +238,15 @@ export class SetupManager extends AbstractModule {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = { ...currentSetting, ...p2pConf } as ObsidianLiveSyncSettings;
// Apply remoteConfigurations
if (newSetting.P2P_ActiveRemoteConfigurationId) {
const id = newSetting.P2P_ActiveRemoteConfigurationId;
const merged = {
...newSetting,
...p2pConf,
} as ObsidianLiveSyncSettings;
const uri = ConnectionStringParser.serialize({ type: "p2p", settings: merged });
newSetting.remoteConfigurations[id] = {
...newSetting.remoteConfigurations[id],
uri,
isEncrypted: false,
};
newSetting.P2P_ActiveRemoteConfigurationId = id;
}
if (activate) {
newSetting.remoteType = REMOTE_P2P;
newSetting.activeConfigurationId = newSetting.P2P_ActiveRemoteConfigurationId;
}
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);
}
@@ -240,4 +240,268 @@ describe("SetupManager", () => {
expect(applyExternalSettings).not.toHaveBeenCalled();
expect(setting.currentSettings().isConfigured).toBe(false);
});
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const imported = {
...DEFAULT_SETTINGS,
remoteConfigurations: {
couch: {
id: "couch",
name: "Office CouchDB",
uri: "sls+https://alice:secret@couch.example/?db=notes",
isEncrypted: false,
},
archive: {
id: "archive",
name: "Archive bucket",
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive&region=auto",
isEncrypted: false,
},
},
activeConfigurationId: "archive",
} as ObsidianLiveSyncSettings;
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
const current = setting.currentSettings();
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
expect(current.activeConfigurationId).toBe("archive");
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
});
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: true,
remoteConfigurations: {
existing: {
id: "existing",
name: "Existing remote",
uri: "sls+http://old:secret@old.example/?db=old",
isEncrypted: false,
},
},
activeConfigurationId: "existing",
};
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
couchDB_URI: "https://couch.example",
couchDB_USER: "alice",
couchDB_PASSWORD: "secret",
couchDB_DBNAME: "notes",
couchDB_CustomHeaders: "",
useJWT: false,
jwtAlgorithm: "",
jwtKey: "",
jwtKid: "",
jwtSub: "",
jwtExpDuration: 5,
useRequestAPI: false,
})
.mockResolvedValueOnce(true);
await manager.onCouchDBManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.remoteConfigurations.existing).toBeDefined();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
expect(current.activeConfigurationId).not.toBe("existing");
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.name).toBe("CouchDB couch.example");
expect(activeProfile?.uri).toContain("sls+https://alice:secret@couch.example");
});
it("adds and activates a manually configured Object Storage profile without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: true,
remoteConfigurations: {
existing: {
id: "existing",
name: "Existing remote",
uri: "sls+http://old:secret@old.example/?db=old",
isEncrypted: false,
},
},
activeConfigurationId: "existing",
};
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
endpoint: "https://storage.example",
accessKey: "key",
secretKey: "secret",
bucket: "notes",
region: "auto",
bucketPrefix: "",
useCustomRequestHandler: false,
bucketCustomHeaders: "",
forcePathStyle: true,
})
.mockResolvedValueOnce(true);
await manager.onBucketManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.remoteConfigurations.existing).toBeDefined();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
expect(current.activeConfigurationId).not.toBe("existing");
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.name).toBe("S3 notes");
expect(activeProfile?.uri).toContain("sls+s3://key:secret@storage.example");
});
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: false,
remoteConfigurations: {},
activeConfigurationId: "",
P2P_ActiveRemoteConfigurationId: "",
};
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
P2P_Enabled: true,
P2P_roomID: "team-room",
P2P_passphrase: "secret",
P2P_relays: "wss://relay.example",
P2P_AppID: "self-hosted-livesync",
P2P_AutoStart: true,
P2P_AutoBroadcast: false,
P2P_turnServers: "",
P2P_turnUsername: "",
P2P_turnCredential: "",
})
.mockResolvedValueOnce(true);
await manager.onP2PManualSetup(UserMode.NewUser, setting.currentSettings());
const current = setting.currentSettings();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(1);
expect(current.activeConfigurationId).not.toBe("");
expect(current.P2P_ActiveRemoteConfigurationId).toBe(current.activeConfigurationId);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.name).toBe("P2P team-room");
expect(activeProfile?.uri).toContain("sls+p2p://");
});
it("selects a configured P2P profile without replacing the active main remote", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: true,
remoteConfigurations: {
main: {
id: "main",
name: "Main CouchDB",
uri: "sls+http://old:secret@old.example/?db=old",
isEncrypted: false,
},
},
activeConfigurationId: "main",
P2P_ActiveRemoteConfigurationId: "",
};
dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
P2P_Enabled: true,
P2P_roomID: "team-room",
P2P_passphrase: "secret",
P2P_relays: "wss://relay.example",
P2P_AppID: "self-hosted-livesync",
P2P_AutoStart: true,
P2P_AutoBroadcast: false,
P2P_turnServers: "",
P2P_turnUsername: "",
P2P_turnCredential: "",
});
await manager.onP2PManualSetup(UserMode.Unknown, setting.currentSettings(), false);
const current = setting.currentSettings();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
expect(current.activeConfigurationId).toBe("main");
expect(current.P2P_ActiveRemoteConfigurationId).not.toBe("");
expect(current.P2P_ActiveRemoteConfigurationId).not.toBe("main");
expect(current.remoteConfigurations[current.P2P_ActiveRemoteConfigurationId]?.name).toBe("P2P team-room");
});
it("does not register Object Storage when final confirmation is cancelled", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: true,
remoteConfigurations: {
existing: {
id: "existing",
name: "Existing remote",
uri: "sls+http://old:secret@old.example/?db=old",
isEncrypted: false,
},
},
activeConfigurationId: "existing",
};
const before = structuredClone(setting.currentSettings().remoteConfigurations);
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
endpoint: "https://storage.example",
accessKey: "key",
secretKey: "secret",
bucket: "notes",
region: "auto",
bucketPrefix: "",
useCustomRequestHandler: false,
bucketCustomHeaders: "",
forcePathStyle: true,
})
.mockResolvedValueOnce("cancelled");
await manager.onBucketManualSetup(UserMode.ExistingUser, setting.currentSettings());
expect(setting.currentSettings().remoteConfigurations).toEqual(before);
expect(setting.currentSettings().activeConfigurationId).toBe("existing");
});
it("does not mutate an existing P2P profile when final confirmation is cancelled", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: true,
remoteConfigurations: {
existing: {
id: "existing",
name: "Existing P2P remote",
uri: "sls+p2p://old-room?passphrase=old-secret",
isEncrypted: false,
},
},
activeConfigurationId: "existing",
P2P_ActiveRemoteConfigurationId: "existing",
};
const before = structuredClone(setting.currentSettings().remoteConfigurations);
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
P2P_Enabled: true,
P2P_roomID: "new-room",
P2P_passphrase: "new-secret",
P2P_relays: "wss://relay.example",
P2P_AppID: "self-hosted-livesync",
P2P_AutoStart: true,
P2P_AutoBroadcast: false,
P2P_turnServers: "",
P2P_turnUsername: "",
P2P_turnCredential: "",
})
.mockResolvedValueOnce("cancelled");
await manager.onP2PManualSetup(UserMode.ExistingUser, setting.currentSettings());
expect(setting.currentSettings().remoteConfigurations).toEqual(before);
expect(setting.currentSettings().activeConfigurationId).toBe("existing");
expect(setting.currentSettings().P2P_ActiveRemoteConfigurationId).toBe("existing");
});
});