diff --git a/docs/design_docs_of_remote_configurations.md b/docs/design_docs_of_remote_configurations.md new file mode 100644 index 0000000..1fdd2fb --- /dev/null +++ b/docs/design_docs_of_remote_configurations.md @@ -0,0 +1,206 @@ +# The design document of remote configuration management + +## Goal + +- Allow us to manage multiple remote connections in a single vault. +- Keep the existing synchronisation implementations working without requiring a large rewrite. +- Provide a safe migration path from the previous single-remote configuration model. +- Allow connections to be imported and exported in a compact and reusable format. + +## Motivation + +Historically, Self-hosted LiveSync stored one effective remote configuration directly in the main settings. This was simple, but it had several limitations. + +- We could only keep one CouchDB, one bucket, or one Peer-to-Peer target as the effective configuration at a time. +- Switching between same-type-remotes required manually rewriting the active settings. +- Setup URI, QR code, CLI setup, and similar entry points all restored settings differently, which made migration logic easy to miss. +- The internal settings shape had gradually become a mix of user-facing settings, transport-specific credentials, and compatibility-oriented values. + +Once multiple remotes of the same type became desirable, the previous model no longer scaled well enough. We therefore needed a structure that could store many remotes, still expose one effective remote to the replication logic, and keep migration and import behaviour consistent. + +## Prerequisite + +- Existing synchronisation features must continue to read an effective remote configuration from the current settings. +- Existing vaults must continue to work without requiring manual reconfiguration. +- Setup URI, QR code, CLI setup, protocol handlers, and other imported settings must be normalised in the same way. +- Import and export must be compact enough to be shared easily. +- We must be explicit that exported connection strings may contain credentials or secrets. + +## Outlined methods and implementation plans + +### Abstract + +The current settings now have two layers for remote configuration. + +1. A stored collection of named remotes. +2. One active remote projected into the legacy flat settings fields. + +This means the replication and database layers can continue to read the effective remote from the existing settings fields, while the settings dialogue and migration logic can manage many stored remotes. + +In short, the list is the source of truth for saved remotes, and the legacy fields remain the runtime compatibility layer. + +### Data model + +The main settings now contain the following properties. + +```typescript +type RemoteConfiguration = { + id: string; + name: string; + uri: string; + isEncrypted: boolean; +}; + +type RemoteConfigurations = { + remoteConfigurations: Record; + activeConfigurationId: string; +}; +``` + +Each entry stores a connection string in `uri`. + +- `sls+http://` or `sls+https://` for CouchDB-compatible remotes +- `sls+s3://` for bucket-style remotes +- `sls+p2p://` for Peer-to-Peer remotes + +This structure allows multiple remotes of the same type to be stored without adding a large number of duplicated settings fields. + +### Runtime compatibility + +The replication logic still reads the effective remote from legacy flat settings such as the following. + +- `remoteType` +- `couchDB_URI`, `couchDB_USER`, `couchDB_PASSWORD`, `couchDB_DBNAME` +- `endpoint`, `bucket`, `accessKey`, `secretKey`, and related bucket fields +- `P2P_roomID`, `P2P_passphrase`, and related Peer-to-Peer fields + +When a remote is activated, its connection string is parsed and projected into these legacy fields. Therefore, existing services do not need to know whether the remote came from an old vault, a Setup URI, or the new remote list. + +This projection is intentionally one-way at runtime. The stored remote list is the persistent catalogue, while the flat fields describe the remote currently in use. + +### Connection string format + +The connection string is the transport-neutral storage format for a remote entry. + +Benefits: + +- It is compact enough for clipboard-based workflows. +- It can be used for import and export in the settings dialogue. +- It avoids introducing a separate serialisation format only for the remote list. +- It can be parsed into the legacy settings shape whenever the active remote changes. + +This is not equivalent to Setup URI. + +- Setup URI represents a broader settings transfer workflow. +- A remote connection string represents one remote only. + +### Import and export + +The settings dialogue now supports the following workflows. + +- Add connection: create a new remote by using the remote setup dialogues. +- Import connection: paste a connection string, validate it, and save it as a named remote. +- Export: copy a stored remote connection string to the clipboard. + +Import normalises the string by parsing and serialising it again before saving. This ensures that equivalent but differently formatted URIs are saved in a canonical form. + +Export is intentionally simple. It copies the connection string itself, because this is the most direct representation of one remote entry. + +### Security note + +Connection strings may include credentials, secrets, JWT-related values, or Peer-to-Peer passphrases. + +Therefore: + +- Export is a deliberate clipboard operation. +- Import trusts the supplied connection string as-is after parsing. +- We should regard exported connection strings as sensitive information, much like Setup URI or a credentials-bearing configuration file. + +The `isEncrypted` field is currently reserved for future expansion. At present, the connection string itself is stored plainly inside the settings data, in the same sense that the effective runtime configuration can contain usable remote credentials. + +### Migration strategy + +Older vaults store only one effective remote in the flat settings fields. The migration creates a first remote list from those values. + +Rules: + +- If no remote list exists and the legacy fields contain a CouchDB configuration, create `legacy-couchdb`. +- If no remote list exists and the legacy fields contain a bucket configuration, create `legacy-s3`. +- If no remote list exists and the legacy fields contain a Peer-to-Peer configuration, create `legacy-p2p`. +- If more than one legacy remote is populated, create all possible entries and select the active one according to `remoteType`. + +This migration is intentionally additive. It does not remove the flat fields because they remain necessary as the active runtime projection. + +### Normalisation and application paths + +One important design lesson from this work is that migration cannot rely only on loading `data.json`. + +Settings may enter the system from several routes: + +- normal settings load +- Setup URI +- QR code +- protocol handler +- CLI setup +- Peer-to-Peer remote configuration retrieval +- red flag based remote adjustment +- settings markdown import + +To keep behaviour consistent, normalisation is centralised in the settings service. + +- `adjustSettings` is responsible for in-place normalisation and migration of a settings object. +- `applyExternalSettings` is responsible for applying imported or externally supplied settings after passing them through the same normalisation flow. + +This ensures that imported settings can migrate to the current remote list model even if they never passed through the ordinary `loadSettings` path. + +### Why not store only the remote list + +It would be possible to let all consumers parse the active remote every time and stop using the flat fields entirely. However, this would require broader changes across replication, diagnostics, and compatibility layers. + +The current design keeps the change set limited. + +- The remote list improves storage and UX. +- The flat fields preserve compatibility and reduce migration risk. + +This is a pragmatic transitional architecture, not an accidental duplication. + +## Test strategy + +The feature should be tested from four viewpoints. + +1. Migration from old settings. + - A vault with only legacy flat remote settings should gain a remote list automatically. + - The correct active remote should be selected according to `remoteType`. + +2. Runtime activation. + - Activating a stored remote should correctly project its values into the effective flat settings. + +3. External import paths. + - Setup URI, QR code, CLI setup, Peer-to-Peer remote config, red flag adjustment, and settings markdown import should all pass through the same normalisation path. + +4. Import and export. + - Imported connection strings should be parsed, canonicalised, named, and stored correctly. + - Export should copy the exact saved connection string. + +## Documentation strategy + +- This document explains the design and compatibility model of remote configuration management. +- User-facing setup documents should explain only how to add, import, export, and activate remotes. +- Release notes may refer to this document when changes in remote handling are significant. + +## Outlook + +Import/export configuration strings should also be encrypted in the future, but this is a separate feature that can be added on top of the current design. + +## Consideration and conclusion + +The remote configuration list solves the practical need to manage multiple remotes without forcing the whole codebase to abandon the previous effective-settings model at once. + +Its core idea is modest but effective. + +- Store named remotes as connection strings. +- Select one active remote. +- Project it into the legacy settings for runtime use. +- Normalise every imported settings object through the same path. + +This keeps the implementation understandable and migration-friendly. It also opens the door for future work, such as encrypted per-remote storage, richer remote metadata, or remote-scoped options, without forcing another large redesign of how remotes are represented. \ No newline at end of file diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index 7672d50..12a315b 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -166,7 +166,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext } as ObsidianLiveSyncSettings; console.log(`[Command] setup -> ${settingsPath}`); - await core.services.setting.applyPartial(nextSettings, true); + await core.services.setting.applyExternalSettings(nextSettings, true); await core.services.control.applySettings(); return true; } diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index a616656..1a5b3da 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -14,6 +14,7 @@ function createCoreMock() { applySettings: vi.fn(async () => {}), }, setting: { + applyExternalSettings: vi.fn(async () => {}), applyPartial: vi.fn(async () => {}), }, }, @@ -176,9 +177,9 @@ describe("runCommand abnormal cases", () => { }); expect(result).toBe(true); - expect(core.services.setting.applyPartial).toHaveBeenCalledTimes(1); + expect(core.services.setting.applyExternalSettings).toHaveBeenCalledTimes(1); expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); - const [appliedSettings, saveImmediately] = core.services.setting.applyPartial.mock.calls[0]; + const [appliedSettings, saveImmediately] = core.services.setting.applyExternalSettings.mock.calls[0]; expect(saveImmediately).toBe(true); expect(appliedSettings.couchDB_URI).toBe("http://127.0.0.1:5984"); expect(appliedSettings.couchDB_DBNAME).toBe("livesync-test-db"); @@ -198,7 +199,7 @@ describe("runCommand abnormal cases", () => { }) ).rejects.toThrow(); - expect(core.services.setting.applyPartial).not.toHaveBeenCalled(); + expect(core.services.setting.applyExternalSettings).not.toHaveBeenCalled(); expect(core.services.control.applySettings).not.toHaveBeenCalled(); }); }); diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index 0bd2999..4deaade 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -1,40 +1,40 @@ -{ - "name": "self-hosted-livesync-cli", - "private": true, - "version": "0.0.0", - "main": "dist/index.cjs", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "cli": "node dist/index.cjs", - "buildRun": "npm run build && npm run cli --", - "build:docker": "docker build -f Dockerfile -t livesync-cli ../../..", - "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json", - "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts", - "test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh", - "test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh", - "test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh", - "test:e2e:push-pull": "bash test/test-push-pull-linux.sh", - "test:e2e:setup-put-cat": "bash test/test-setup-put-cat-linux.sh", - "test:e2e:sync-two-local": "bash test/test-sync-two-local-databases-linux.sh", - "test:e2e:p2p": "bash test/test-p2p-three-nodes-conflict-linux.sh", - "test:e2e:p2p-upload-download-repro": "bash test/test-p2p-upload-download-repro-linux.sh", - "test:e2e:p2p-host": "bash test/test-p2p-host-linux.sh", - "test:e2e:p2p-sync": "bash test/test-p2p-sync-linux.sh", - "test:e2e:mirror": "bash test/test-mirror-linux.sh", - "pretest:e2e:all": "npm run build", - "test:e2e:all": " export RUN_BUILD=0 && npm run test:e2e:setup-put-cat && npm run test:e2e:push-pull && npm run test:e2e:sync-two-local && npm run test:e2e:p2p && npm run test:e2e:mirror && npm run test:e2e:two-vaults && npm run test:e2e:p2p", - "pretest:e2e:docker:all": "npm run build:docker", - "test:e2e:docker:push-pull": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-push-pull-linux.sh", - "test:e2e:docker:setup-put-cat": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-setup-put-cat-linux.sh", - "test:e2e:docker:mirror": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-mirror-linux.sh", - "test:e2e:docker:sync-two-local": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-sync-two-local-databases-linux.sh", - "test:e2e:docker:p2p": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-three-nodes-conflict-linux.sh", - "test:e2e:docker:p2p-sync": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-sync-linux.sh", - "test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror" - }, - "dependencies": {}, - "devDependencies": {} -} +{ + "name": "self-hosted-livesync-cli", + "private": true, + "version": "0.0.0", + "main": "dist/index.cjs", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "cli": "node dist/index.cjs", + "buildRun": "npm run build && npm run cli --", + "build:docker": "docker build -f Dockerfile -t livesync-cli ../../..", + "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json", + "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts", + "test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh", + "test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh", + "test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh", + "test:e2e:push-pull": "bash test/test-push-pull-linux.sh", + "test:e2e:setup-put-cat": "bash test/test-setup-put-cat-linux.sh", + "test:e2e:sync-two-local": "bash test/test-sync-two-local-databases-linux.sh", + "test:e2e:p2p": "bash test/test-p2p-three-nodes-conflict-linux.sh", + "test:e2e:p2p-upload-download-repro": "bash test/test-p2p-upload-download-repro-linux.sh", + "test:e2e:p2p-host": "bash test/test-p2p-host-linux.sh", + "test:e2e:p2p-sync": "bash test/test-p2p-sync-linux.sh", + "test:e2e:mirror": "bash test/test-mirror-linux.sh", + "pretest:e2e:all": "npm run build", + "test:e2e:all": " export RUN_BUILD=0 && npm run test:e2e:setup-put-cat && npm run test:e2e:push-pull && npm run test:e2e:sync-two-local && npm run test:e2e:p2p && npm run test:e2e:mirror && npm run test:e2e:two-vaults && npm run test:e2e:p2p", + "pretest:e2e:docker:all": "npm run build:docker", + "test:e2e:docker:push-pull": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-push-pull-linux.sh", + "test:e2e:docker:setup-put-cat": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-setup-put-cat-linux.sh", + "test:e2e:docker:mirror": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-mirror-linux.sh", + "test:e2e:docker:sync-two-local": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-sync-two-local-databases-linux.sh", + "test:e2e:docker:p2p": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-three-nodes-conflict-linux.sh", + "test:e2e:docker:p2p-sync": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-sync-linux.sh", + "test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror" + }, + "dependencies": {}, + "devDependencies": {} +} diff --git a/src/apps/cli/runtime-package.json b/src/apps/cli/runtime-package.json index 12f7920..5791992 100644 --- a/src/apps/cli/runtime-package.json +++ b/src/apps/cli/runtime-package.json @@ -1,24 +1,24 @@ -{ - "name": "livesync-cli-runtime", - "private": true, - "version": "0.0.0", - "description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image", - "dependencies": { - "commander": "^14.0.3", - "werift": "^0.22.9", - "pouchdb-adapter-http": "^9.0.0", - "pouchdb-adapter-idb": "^9.0.0", - "pouchdb-adapter-indexeddb": "^9.0.0", - "pouchdb-adapter-leveldb": "^9.0.0", - "pouchdb-adapter-memory": "^9.0.0", - "pouchdb-core": "^9.0.0", - "pouchdb-errors": "^9.0.0", - "pouchdb-find": "^9.0.0", - "pouchdb-mapreduce": "^9.0.0", - "pouchdb-merge": "^9.0.0", - "pouchdb-replication": "^9.0.0", - "pouchdb-utils": "^9.0.0", - "pouchdb-wrappers": "*", - "transform-pouch": "^2.0.0" - } -} +{ + "name": "livesync-cli-runtime", + "private": true, + "version": "0.0.0", + "description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image", + "dependencies": { + "commander": "^14.0.3", + "werift": "^0.22.9", + "pouchdb-adapter-http": "^9.0.0", + "pouchdb-adapter-idb": "^9.0.0", + "pouchdb-adapter-indexeddb": "^9.0.0", + "pouchdb-adapter-leveldb": "^9.0.0", + "pouchdb-adapter-memory": "^9.0.0", + "pouchdb-core": "^9.0.0", + "pouchdb-errors": "^9.0.0", + "pouchdb-find": "^9.0.0", + "pouchdb-mapreduce": "^9.0.0", + "pouchdb-merge": "^9.0.0", + "pouchdb-replication": "^9.0.0", + "pouchdb-utils": "^9.0.0", + "pouchdb-wrappers": "*", + "transform-pouch": "^2.0.0" + } +} diff --git a/src/apps/webpeer/src/P2PReplicatorShim.ts b/src/apps/webpeer/src/P2PReplicatorShim.ts index fd461ef..45b7631 100644 --- a/src/apps/webpeer/src/P2PReplicatorShim.ts +++ b/src/apps/webpeer/src/P2PReplicatorShim.ts @@ -276,7 +276,7 @@ export class P2PReplicatorShim implements P2PReplicatorBase { } } } - await this.services.setting.applyPartial(remoteConfig, true); + await this.services.setting.applyExternalSettings(remoteConfig, true); if (yn !== DROP) { await this.plugin.core.services.appLifecycle.scheduleRestart(); } diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts index 5ab5031..30073ad 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts @@ -87,7 +87,7 @@ And you can also drop the local database to rebuild from the remote device.`, // this.plugin.settings = remoteConfig; // await this.plugin.saveSettings(); - await this.core.services.setting.applyPartial(remoteConfig); + await this.core.services.setting.applyExternalSettings(remoteConfig); if (yn === DROP) { await this.core.rebuilder.scheduleFetch(); } else { diff --git a/src/lib b/src/lib index d14de2d..cdd8693 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit d14de2d8fc5b712354d30772a2422b0599916883 +Subproject commit cdd8693498388e06b9cf36469374f5688d207f77 diff --git a/src/modules/features/ModuleObsidianSettingAsMarkdown.ts b/src/modules/features/ModuleObsidianSettingAsMarkdown.ts index 21f1008..154e43e 100644 --- a/src/modules/features/ModuleObsidianSettingAsMarkdown.ts +++ b/src/modules/features/ModuleObsidianSettingAsMarkdown.ts @@ -162,8 +162,8 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule { result == APPLY_AND_REBUILD || result == APPLY_AND_FETCH ) { - this.core.settings = settingToApply; - await this.services.setting.saveSettingData(); + await this.services.setting.applyExternalSettings(settingToApply, true); + this.services.setting.clearUsedPassphrase(); if (result == APPLY_ONLY) { this._log("Loaded settings have been applied!", LOG_LEVEL_NOTICE); return; diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts index e3e3883..8c648de 100644 --- a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts @@ -3,8 +3,10 @@ import { REMOTE_MINIO, REMOTE_P2P, DEFAULT_SETTINGS, + LOG_LEVEL_NOTICE, type ObsidianLiveSyncSettings, } from "../../../lib/src/common/types.ts"; +import { Menu } from "@/deps.ts"; import { $msg } from "../../../lib/src/common/i18n.ts"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; @@ -24,6 +26,7 @@ import { SetupManager, UserMode } from "../SetupManager.ts"; import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts"; import { activateRemoteConfiguration } from "../../../lib/src/serviceFeatures/remoteConfig.ts"; import { ConnectionStringParser } from "../../../lib/src/common/ConnectionString.ts"; +import type { RemoteConfigurationResult } from "../../../lib/src/common/ConnectionString.ts"; import type { RemoteConfiguration } from "../../../lib/src/common/models/setting.type.ts"; import SetupRemote from "../SetupWizard/dialogs/SetupRemote.svelte"; import SetupRemoteCouchDB from "../SetupWizard/dialogs/SetupRemoteCouchDB.svelte"; @@ -67,6 +70,29 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin return ConnectionStringParser.serialize({ type: "couchdb", settings }); } +function setEmojiButton(button: any, emoji: string, tooltip: string) { + button.setButtonText(emoji); + button.setTooltip(tooltip, { delay: 10, placement: "top" }); + // button.buttonEl.addClass("clickable-icon"); + button.buttonEl.addClass("mod-muted"); + 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, @@ -237,11 +263,60 @@ export function paneRemoteConfig( await persistRemoteConfigurations(this.editingSettings.activeConfigurationId === id); refreshList(); }; + const importRemoteConfiguration = async () => { + const importedURI = await this.services.UI.confirm.askString( + "Import connection", + "Paste a connection string", + "" + ); + if (importedURI === false) { + return; + } + + const trimmedURI = importedURI.trim(); + if (trimmedURI === "") { + return; + } + + let parsed: RemoteConfigurationResult; + try { + parsed = ConnectionStringParser.parse(trimmedURI); + } catch (ex) { + this.services.API.addLog(`Failed to import remote configuration: ${ex}`, LOG_LEVEL_NOTICE); + return; + } + + const defaultName = suggestRemoteConfigurationName(parsed); + const name = await this.services.UI.confirm.askString("Remote name", "Display name", defaultName); + if (name === false) { + return; + } + + const id = createRemoteConfigurationId(); + const configs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations); + configs[id] = { + id, + name: name.trim() || defaultName, + uri: ConnectionStringParser.serialize(parsed), + isEncrypted: false, + }; + this.editingSettings.remoteConfigurations = configs; + if (!this.editingSettings.activeConfigurationId) { + this.editingSettings.activeConfigurationId = id; + } + await persistRemoteConfigurations(this.editingSettings.activeConfigurationId === id); + refreshList(); + }; actions.addButton((button) => - button.setButtonText("Add New Connection").onClick(async () => { + setEmojiButton(button, "➕", "Add new connection").onClick(async () => { await addRemoteConfiguration(); }) ); + actions.addButton((button) => + setEmojiButton(button, "📥", "Import connection").onClick(async () => { + await importRemoteConfiguration(); + }) + ); const refreshList = () => { listContainer.empty(); const configs = this.editingSettings.remoteConfigurations || {}; @@ -256,7 +331,7 @@ export function paneRemoteConfig( } row.addButton((btn) => - btn.setButtonText("Configure").onClick(async () => { + setEmojiButton(btn, "🔧", "Configure").onClick(async () => { const parsed = ConnectionStringParser.parse(config.uri); const workSettings = createBaseRemoteSettings(); if (parsed.type === "couchdb") { @@ -284,83 +359,10 @@ export function paneRemoteConfig( refreshList(); }) ); - row.addButton((btn) => - btn.setButtonText("Rename").onClick(async () => { - const nextName = await this.services.UI.confirm.askString( - "Remote name", - "Display name", - config.name - ); - if (nextName === false) { - return; - } - const nextConfigs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations); - nextConfigs[config.id] = { - ...config, - name: nextName.trim() || config.name, - }; - this.editingSettings.remoteConfigurations = nextConfigs; - await persistRemoteConfigurations(); - refreshList(); - }) - ); - row.addButton((btn) => - btn.setButtonText("Duplicate").onClick(async () => { - const nextName = await this.services.UI.confirm.askString( - "Duplicate remote", - "Display name", - `${config.name} (Copy)` - ); - if (nextName === false) { - return; - } - - const nextId = createRemoteConfigurationId(); - const nextConfigs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations); - nextConfigs[nextId] = { - ...config, - id: nextId, - name: nextName.trim() || `${config.name} (Copy)`, - }; - this.editingSettings.remoteConfigurations = nextConfigs; - await persistRemoteConfigurations(); - refreshList(); - }) - ); row.addButton((btn) => btn - .setButtonText("Delete") - .setWarning() - .onClick(async () => { - const confirmed = await this.services.UI.confirm.askYesNoDialog( - `Delete remote configuration '${config.name}'?`, - { title: "Delete Remote Configuration", defaultOption: "No" } - ); - if (confirmed !== "yes") { - return; - } - - const nextConfigs = cloneRemoteConfigurations( - this.editingSettings.remoteConfigurations - ); - delete nextConfigs[config.id]; - this.editingSettings.remoteConfigurations = nextConfigs; - - let syncActiveRemote = false; - if (this.editingSettings.activeConfigurationId === config.id) { - const nextActiveId = Object.keys(nextConfigs)[0] || ""; - this.editingSettings.activeConfigurationId = nextActiveId; - syncActiveRemote = nextActiveId !== ""; - } - - await persistRemoteConfigurations(syncActiveRemote); - refreshList(); - }) - ); - - row.addButton((btn) => - btn - .setButtonText("Activate") + .setButtonText("✅") + .setTooltip("Activate", { delay: 10, placement: "top" }) .setDisabled(config.id === this.editingSettings.activeConfigurationId) .onClick(async () => { this.editingSettings.activeConfigurationId = config.id; @@ -368,6 +370,97 @@ export function paneRemoteConfig( refreshList(); }) ); + + row.addButton((btn) => + setEmojiButton(btn, "…", "More actions").onClick(() => { + const menu = new Menu() + .addItem((item) => { + item.setTitle("🪪 Rename").onClick(async () => { + const nextName = await this.services.UI.confirm.askString( + "Remote name", + "Display name", + config.name + ); + if (nextName === false) { + return; + } + const nextConfigs = cloneRemoteConfigurations( + this.editingSettings.remoteConfigurations + ); + nextConfigs[config.id] = { + ...config, + name: nextName.trim() || config.name, + }; + this.editingSettings.remoteConfigurations = nextConfigs; + await persistRemoteConfigurations(); + refreshList(); + }); + }) + .addItem((item) => { + item.setTitle("📤 Export").onClick(async () => { + await this.services.UI.promptCopyToClipboard( + `Remote configuration: ${config.name}`, + config.uri + ); + }); + }) + .addItem((item) => { + item.setTitle("🧬 Duplicate").onClick(async () => { + const nextName = await this.services.UI.confirm.askString( + "Duplicate remote", + "Display name", + `${config.name} (Copy)` + ); + if (nextName === false) { + return; + } + + const nextId = createRemoteConfigurationId(); + const nextConfigs = cloneRemoteConfigurations( + this.editingSettings.remoteConfigurations + ); + nextConfigs[nextId] = { + ...config, + id: nextId, + name: nextName.trim() || `${config.name} (Copy)`, + }; + this.editingSettings.remoteConfigurations = nextConfigs; + await persistRemoteConfigurations(); + refreshList(); + }); + }) + .addSeparator() + .addItem((item) => { + item.setTitle("🗑 Delete").onClick(async () => { + const confirmed = await this.services.UI.confirm.askYesNoDialog( + `Delete remote configuration '${config.name}'?`, + { title: "Delete Remote Configuration", defaultOption: "No" } + ); + if (confirmed !== "yes") { + return; + } + + const nextConfigs = cloneRemoteConfigurations( + this.editingSettings.remoteConfigurations + ); + delete nextConfigs[config.id]; + this.editingSettings.remoteConfigurations = nextConfigs; + + let syncActiveRemote = false; + if (this.editingSettings.activeConfigurationId === config.id) { + const nextActiveId = Object.keys(nextConfigs)[0] || ""; + this.editingSettings.activeConfigurationId = nextActiveId; + syncActiveRemote = nextActiveId !== ""; + } + + await persistRemoteConfigurations(syncActiveRemote); + refreshList(); + }); + }); + const rect = btn.buttonEl.getBoundingClientRect(); + menu.showAtPosition({ x: rect.left, y: rect.bottom }); + }) + ); } }; refreshList(); diff --git a/src/modules/features/SetupManager.ts b/src/modules/features/SetupManager.ts index 02eeffc..1bd40be 100644 --- a/src/modules/features/SetupManager.ts +++ b/src/modules/features/SetupManager.ts @@ -275,6 +275,10 @@ export class SetupManager extends AbstractModule { activate: boolean = true, extra: () => void = () => {} ): Promise { + newConf = await this.services.setting.adjustSettings({ + ...this.settings, + ...newConf, + }); let userMode = _userMode; if (userMode === UserMode.Unknown) { if (isObjectDifferent(this.settings, newConf, true) === false) { @@ -368,13 +372,8 @@ export class SetupManager extends AbstractModule { * @returns Promise that resolves to true if settings applied successfully, false otherwise */ async applySetting(newConf: ObsidianLiveSyncSettings, userMode: UserMode) { - const newSetting = { - ...this.core.settings, - ...newConf, - }; - this.core.settings = newSetting; this.services.setting.clearUsedPassphrase(); - await this.services.setting.saveSettingData(); + await this.services.setting.applyExternalSettings(newConf, true); return true; } } diff --git a/src/modules/features/SetupManager.unit.spec.ts b/src/modules/features/SetupManager.unit.spec.ts new file mode 100644 index 0000000..ef94c4d --- /dev/null +++ b/src/modules/features/SetupManager.unit.spec.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "../../lib/src/common/types"; +import { SettingService } from "../../lib/src/services/base/SettingService"; +import { ServiceContext } from "../../lib/src/services/base/ServiceBase"; + +vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/SelectMethodExisting.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/ScanQRCode.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/UseSetupURI.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/OutroNewUser.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/OutroExistingUser.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/OutroAskUserMode.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} })); +vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} })); + +vi.mock("../../lib/src/API/processSetting.ts", () => ({ + decodeSettingsFromQRCodeData: vi.fn(), +})); + +import { decodeSettingsFromQRCodeData } from "../../lib/src/API/processSetting.ts"; +import { SetupManager, UserMode } from "./SetupManager"; + +class TestSettingService extends SettingService { + protected setItem(_key: string, _value: string): void {} + protected getItem(_key: string): string { + return ""; + } + protected deleteItem(_key: string): void {} + protected saveData(_setting: ObsidianLiveSyncSettings): Promise { + return Promise.resolve(); + } + protected loadData(): Promise { + return Promise.resolve(undefined); + } +} + +function createLegacyRemoteSetting(): ObsidianLiveSyncSettings { + return { + ...DEFAULT_SETTINGS, + remoteConfigurations: {}, + activeConfigurationId: "", + remoteType: REMOTE_COUCHDB, + couchDB_URI: "http://localhost:5984", + couchDB_USER: "user", + couchDB_PASSWORD: "password", + couchDB_DBNAME: "vault", + }; +} + +function createSetupManager() { + const setting = new TestSettingService(new ServiceContext(), { + APIService: { + getSystemVaultName: vi.fn(() => "vault"), + getAppID: vi.fn(() => "app"), + confirm: { + askString: vi.fn(() => Promise.resolve("")), + }, + addLog: vi.fn(), + addCommand: vi.fn(), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + } as any, + }); + setting.settings = { + ...DEFAULT_SETTINGS, + remoteConfigurations: {}, + activeConfigurationId: "", + }; + vi.spyOn(setting, "saveSettingData").mockResolvedValue(); + + const dialogManager = { + openWithExplicitCancel: vi.fn(), + open: vi.fn(), + }; + const services = { + API: { + addLog: vi.fn(), + addCommand: vi.fn(), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + }, + UI: { + dialogManager, + }, + setting, + } as any; + const core: any = { + _services: services, + rebuilder: { + scheduleRebuild: vi.fn(() => Promise.resolve()), + scheduleFetch: vi.fn(() => Promise.resolve()), + }, + }; + Object.defineProperty(core, "services", { + get() { + return services; + }, + }); + Object.defineProperty(core, "settings", { + get() { + return setting.settings; + }, + set(value: ObsidianLiveSyncSettings) { + setting.settings = value; + }, + }); + + return { + manager: new SetupManager(core), + setting, + dialogManager, + core, + }; +} + +describe("SetupManager", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it("onUseSetupURI should normalise imported legacy remote settings before applying", async () => { + const { manager, setting, dialogManager } = createSetupManager(); + dialogManager.openWithExplicitCancel + .mockResolvedValueOnce(createLegacyRemoteSetting()) + .mockResolvedValueOnce("compatible-existing-user"); + + const result = await manager.onUseSetupURI(UserMode.Unknown, "mock-config://settings"); + + expect(result).toBe(true); + expect(setting.currentSettings().remoteConfigurations["legacy-couchdb"]?.uri).toContain( + "sls+http://user:password@localhost:5984" + ); + expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb"); + }); + + it("decodeQR should normalise imported legacy remote settings before applying", async () => { + const { manager, setting, dialogManager } = createSetupManager(); + vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting()); + dialogManager.openWithExplicitCancel.mockResolvedValueOnce("compatible-existing-user"); + + const result = await manager.decodeQR("qr-data"); + + expect(result).toBe(true); + expect(decodeSettingsFromQRCodeData).toHaveBeenCalledWith("qr-data"); + expect(setting.currentSettings().remoteConfigurations["legacy-couchdb"]?.uri).toContain( + "sls+http://user:password@localhost:5984" + ); + expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb"); + }); +}); diff --git a/src/serviceFeatures/redFlag.ts b/src/serviceFeatures/redFlag.ts index e4f2b0e..90e052e 100644 --- a/src/serviceFeatures/redFlag.ts +++ b/src/serviceFeatures/redFlag.ts @@ -176,7 +176,7 @@ export async function adjustSettingToRemote( ...config, ...Object.fromEntries(differentItems), } satisfies ObsidianLiveSyncSettings; - await host.services.setting.applyPartial(config, true); + await host.services.setting.applyExternalSettings(config, true); log("Remote configuration applied.", LOG_LEVEL_NOTICE); canProceed = true; const updatedConfig = host.services.setting.currentSettings(); diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts index 65fcef4..2643642 100644 --- a/src/serviceFeatures/redFlag.unit.spec.ts +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -49,6 +49,10 @@ const createSettingServiceMock = () => { return { settings, currentSettings: vi.fn(() => settings), + applyExternalSettings: vi.fn((partial: any, _feedback?: boolean) => { + Object.assign(settings, partial); + return Promise.resolve(); + }), applyPartial: vi.fn((partial: any, _feedback?: boolean) => { Object.assign(settings, partial); return Promise.resolve(); @@ -552,7 +556,7 @@ describe("Red Flag Feature", () => { await adjustSettingToRemote(host as any, createLoggerMock(), config); expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled(); - expect(host.mocks.setting.applyPartial).toHaveBeenCalled(); + expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalled(); } ); const mismatchAcceptedKeys = Object.keys(TweakValuesRecommendedTemplate).filter( @@ -579,7 +583,7 @@ describe("Red Flag Feature", () => { await adjustSettingToRemote(host as any, createLoggerMock(), config); - expect(host.mocks.setting.applyPartial).toHaveBeenCalled(); + expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalled(); expect(host.mocks.ui.confirm.askSelectStringDialogue).not.toHaveBeenCalled(); } );