diff --git a/docs/settings.md b/docs/settings.md index ab24c417..3a1f60d5 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -41,10 +41,14 @@ Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted L Setup the Self-hosted LiveSync with the `setup URI` which is [copied from another device](#copy-current-settings-as-a-new-setup-uri) or the setup script. +A current Setup URI retains its remote profiles, display names, and separate main and P2P selections. Older Setup URIs containing only flat connection settings remain supported and are migrated to a remote profile when applied. + #### Manual setup Step-by-step setup for Self-hosted LiveSync. You can setup Self-hosted LiveSync manually with Minimal setting items. +Completing manual CouchDB, Object Storage, or P2P setup creates the corresponding remote profile without replacing profiles which are already saved. CouchDB and Object Storage setup select the new profile as the main remote. P2P setup selects it for P2P use and, when the wizard is enabling LiveSync, also selects it as the main remote. A descriptive display name is generated and can be changed later. + #### Enable LiveSync This button only appears when the setup was not completed. If you have completed the setup manually, you can enable LiveSync on this device by this button. @@ -158,6 +162,8 @@ Show verbose log. Please enable when you report the logs Self-hosted LiveSync supports multiple remote connection profiles under **Remote Server** -> **Remote Databases**. This allows you to save and switch between multiple databases or bucket configurations in a single vault. +Each profile has an opaque identifier and a presentation name. The name does not need to be unique and is not used to select the profile. The main remote and the P2P remote are selected independently, so code and settings imports must preserve both selections rather than relying on a special identifier such as `default`. + - **➕ Add new connection**: Create a new connection profile by launching the setup dialogue. - **📥 Import connection**: Paste a connection string (e.g., `sls+https://...`, `sls+s3://...`, `sls+p2p://...`) to import a remote configuration profile. - **🔧 Configure**: Open the setup dialogue to edit settings for the selected connection profile. diff --git a/src/modules/features/SetupManager.ts b/src/modules/features/SetupManager.ts index 5cbc3b23..1164dfe0 100644 --- a/src/modules/features/SetupManager.ts +++ b/src/modules/features/SetupManager.ts @@ -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 { - 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); } diff --git a/src/modules/features/SetupManager.unit.spec.ts b/src/modules/features/SetupManager.unit.spec.ts index 3aa7e133..0bd1d647 100644 --- a/src/modules/features/SetupManager.unit.spec.ts +++ b/src/modules/features/SetupManager.unit.spec.ts @@ -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®ion=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"); + }); }); diff --git a/updates.md b/updates.md index 4d66926d..20e6939d 100644 --- a/updates.md +++ b/updates.md @@ -15,6 +15,7 @@ Earlier releases remain available in the [0.25 release history](docs/releases/0. ### Improved - Wizard-driven new-device and existing-device setup now reserves Rebuild or Fetch before enabling imported settings, preventing ordinary start-up work from running ahead of the selected initialisation. +- Manual onboarding now creates and selects CouchDB, Object Storage, and P2P remote profiles directly while preserving existing profiles. Current Setup URIs retain profile names and selections, while older flat settings remain supported through compatibility migration. ## 1.0.0-rc.0