Use existing Setup URI and QR sharing for TURN settings

This commit is contained in:
vorotamoroz
2026-09-15 17:18:40 +00:00
parent 93bc161f20
commit ab55eb5aff
20 changed files with 89 additions and 116 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { configURIBase, configURIBaseV2 } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import {
DEFAULT_SETTINGS,
type FilePathWithPrefix,
@@ -298,7 +298,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("setup requires one argument: <setupURI>");
}
const setupURI = options.commandArgs[0].trim();
if (!setupURI.startsWith(configURIBase) && !setupURI.startsWith(configURIBaseV2)) {
if (!setupURI.startsWith(configURIBase)) {
throw new Error(`setup URI must start with ${configURIBase}`);
}
const passphrase = await standardIo.prompt("Enter setup URI passphrase: ");
@@ -1,7 +1,7 @@
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { configURIBase, configURIBaseV2 } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
@@ -419,7 +419,7 @@ describe("runCommand abnormal cases", () => {
expect(appliedSettings.useIndexedDBAdapter).toBe(false);
});
it("setup imports managed TURN through the versioned encrypted URI", async () => {
it("setup imports managed TURN through the existing encrypted URI", async () => {
const core = createCoreMock();
const source = {
version: 1,
@@ -434,7 +434,7 @@ describe("runCommand abnormal cases", () => {
},
passphrase
);
expect(setupURI.startsWith(configURIBaseV2)).toBe(true);
expect(setupURI.startsWith(configURIBase)).toBe(true);
expect(setupURI).not.toContain("private-token");
core.services.context.standardIo.prompt.mockResolvedValue(passphrase);
await runCommand(makeOptions("setup", [setupURI]), { ...context, core });
@@ -16,8 +16,8 @@ export const liveSyncProvisionalEnglishMessages = {
"TURN Key ID": "TURN Key ID",
"TURN Key API Token": "TURN Key API Token",
"Unsupported TURN configuration": "Unsupported TURN configuration",
"The API token is saved with this profile and included in encrypted Setup URI sharing. Temporary TURN credentials are kept in memory only.":
"The API token is saved with this profile and included in encrypted Setup URI sharing. Temporary TURN credentials are kept in memory only.",
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.":
"The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.",
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.":
"TURN relay only requires a TURN server or a configured credential source under Advanced Settings.",
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.":
+2 -2
View File
@@ -23,7 +23,7 @@ describe("TURN credentials in diagnostic reports", () => {
id: "inactive",
name: "Inactive TURN",
isEncrypted: false,
uri: `sls+p2p-v2://room?source=${encodeURIComponent(JSON.stringify(source))}`,
uri: `sls+p2p://room?source=${encodeURIComponent(JSON.stringify(source))}`,
},
},
};
@@ -34,7 +34,7 @@ describe("TURN credentials in diagnostic reports", () => {
expect(text).not.toContain(encodeURIComponent(token));
expect(text).not.toContain("private-key");
expect(text).not.toContain("encrypted-private-copy");
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p-v2://");
expect(report.pluginConfig.remoteConfigurations.inactive.uri).toBe("sls+p2p://");
expect(settings.P2P_iceServerSource).toEqual(source);
expect(settings.encryptedP2PIceServerSource).toBe("encrypted-private-copy");
});
+1 -1
View File
@@ -21,7 +21,7 @@ export function redactTurnSourceForReport(settings: Partial<ObsidianLiveSyncSett
}
}
/** Managed connection profiles are shared through encrypted Setup URIs. */
/** Managed connection profiles are shared through Setup URIs and QR codes. */
export function omitManagedTurnProfilesFromMarkdown(settings: Partial<ObsidianLiveSyncSettings>): void {
if (!hasManagedTurnSettings(settings)) return;
delete settings.P2P_iceServerSource;
+2 -2
View File
@@ -20,7 +20,7 @@ function configuredSettings() {
id: "managed",
name: "Managed TURN",
isEncrypted: false,
uri: "sls+p2p-v2://room?source=private-token",
uri: "sls+p2p://room?source=private-token",
},
},
activeConfigurationId: "central",
@@ -42,7 +42,7 @@ describe("managed TURN settings privacy", () => {
settings.P2P_iceServerSource.id = "manual";
expect(hasManagedTurnSettings(settings)).toBe(true);
omitManagedTurnProfilesFromMarkdown(settings);
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p-v2/);
expect(JSON.stringify(settings)).not.toMatch(/private-token|private-key-id|sls\+p2p/);
expect(settings).not.toHaveProperty("remoteConfigurations");
expect(settings).not.toHaveProperty("activeConfigurationId");
expect(settings).not.toHaveProperty("P2P_ActiveRemoteConfigurationId");
+1 -1
View File
@@ -51,7 +51,7 @@ export type queueItem = {
export const FileWatchEventQueueMax = 10;
export { configURIBase, configURIBaseV2, configURIBaseQR } from "@vrtmrz/livesync-commonlib/compat/common/types";
export { configURIBase, configURIBaseQR } from "@vrtmrz/livesync-commonlib/compat/common/types";
export {
CHeader,
@@ -68,7 +68,7 @@
autocomplete={field.secret ? "new-password" : "off"} autocapitalize="off" spellcheck="false" />
</label>
{/each}
<p>{translate("The API token is saved with this profile and included in encrypted Setup URI sharing. Temporary TURN credentials are kept in memory only.")}</p>
<p>{translate("The API token is saved with this profile and included in Setup URI and QR code sharing. Temporary TURN credentials are kept in memory only.")}</p>
{/if}
{#if error}
<p role="status" class="turn-error">{translateIfAvailable(error)}</p>
@@ -1,5 +1,3 @@
import { copySetupURI } from "@/serviceFeatures/setupObsidian/setupUri";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
@@ -418,15 +416,6 @@ export function paneRemoteConfig(
})
.addItem((item) => {
item.setTitle("📤 Export").onClick(async () => {
if (config.uri.startsWith("sls+p2p-v2://")) {
await copySetupURI(
this.core,
createInstanceLogFunction("TURN setup sharing", this.services.API),
true,
getSettingsFromEditingSettings(this.editingSettings)
);
return;
}
await this.services.UI.promptCopyToClipboard(
`Remote configuration: ${config.name}`,
config.uri
@@ -1,5 +1,5 @@
<script lang="ts">
import { configURIBase, configURIBaseV2 } from "@/common/types";
import { configURIBase } from "@/common/types";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
@@ -29,7 +29,7 @@
}
});
const seemsValid = $derived(setupURI.startsWith(configURIBase) || setupURI.startsWith(configURIBaseV2));
const seemsValid = $derived(setupURI.startsWith(configURIBase));
async function processSetupURI() {
error = "";
if (!seemsValid) return;
@@ -1,6 +1,3 @@
import { hasManagedTurnSettings } from "@/common/turnSettingsPrivacy";
import { copySetupURI } from "./setupUri";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import {
encodeQR,
@@ -13,10 +10,6 @@ import type { SetupFeatureHost } from "./types";
export async function encodeSetupSettingsAsQR(host: SetupFeatureHost) {
const settings = host.services.setting.currentSettings();
if (hasManagedTurnSettings(settings)) {
await copySetupURI(host, createInstanceLogFunction("SF:SetupQRCode", host.services.API));
return "";
}
const settingString = encodeSettingsToQRCodeData(settings);
const result = encodeQR(settingString, OutputFormat.SVG);
if (result === "") {
@@ -18,15 +18,36 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
});
describe("setupObsidian/qrCode", () => {
it("routes inactive managed profiles through encrypted Setup URI sharing", async () => {
const settings = {
remoteConfigurations: { managed: { uri: "sls+p2p-v2://room?source=private-token" } },
it("shows managed TURN settings and inactive profiles through the ordinary QR dialogue", async () => {
const source = {
version: 1,
id: "cloudflare",
configuration: { turnKeyId: "turn-key", apiToken: "private-token" },
};
const host = { services: { API: { addLog: vi.fn() }, setting: { currentSettings: () => settings } } } as any;
await encodeSetupSettingsAsQR(host);
expect(copySetupURI).toHaveBeenCalledWith(host, expect.any(Function));
expect(encodeSettingsToQRCodeData).not.toHaveBeenCalled();
expect(encodeQR).not.toHaveBeenCalled();
const settings = {
P2P_iceServerSource: source,
remoteConfigurations: {
managed: { uri: `sls+p2p://room?source=${encodeURIComponent(JSON.stringify(source))}` },
},
};
const confirmWithMessage = vi.fn();
const translate = vi.fn(() => "qr-message");
const host = {
services: {
API: { addLog: vi.fn() },
context: createServiceContext({ translate }),
setting: { currentSettings: () => settings },
UI: { confirm: { confirmWithMessage } },
},
} as any;
vi.mocked(encodeSettingsToQRCodeData).mockReturnValue("encoded-settings");
vi.mocked(encodeQR).mockReturnValue("<svg/>");
expect(await encodeSetupSettingsAsQR(host)).toBe("<svg/>");
expect(encodeSettingsToQRCodeData).toHaveBeenCalledWith(settings);
expect(translate).toHaveBeenCalledWith("Setup.QRCode", { qr_image: "<svg/>" });
expect(confirmWithMessage).toHaveBeenCalledWith("Settings QR Code", "qr-message", ["OK"], "OK");
expect(copySetupURI).not.toHaveBeenCalled();
});
afterEach(() => {
@@ -2,23 +2,20 @@ import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types";
import { configURIBase, configURIBaseV2 } from "@/common/types";
import { configURIBase } from "@/common/types";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { type SetupManager, UserMode } from "@/modules/features/SetupManager";
async function handleSetupProtocol(setupManager: SetupManager, conf: Record<string, string>, uriBase = configURIBase) {
async function handleSetupProtocol(setupManager: SetupManager, conf: Record<string, string>) {
if (conf.settings) {
await setupManager.onUseSetupURI(UserMode.Unknown, `${uriBase}${encodeURIComponent(conf.settings)}`);
} else if (conf.settingsQR && uriBase === configURIBase) {
await setupManager.onUseSetupURI(UserMode.Unknown, `${configURIBase}${encodeURIComponent(conf.settings)}`);
} else if (conf.settingsQR) {
await setupManager.decodeQR(conf.settingsQR);
}
}
export function registerSetupProtocolHandler(host: SetupFeatureHost, log: LogFunction, setupManager: SetupManager) {
try {
host.services.API.registerProtocolHandler("setuplivesync-v2", async (conf) => {
await handleSetupProtocol(setupManager, conf, configURIBaseV2);
});
host.services.API.registerProtocolHandler("setuplivesync", async (conf) => {
await handleSetupProtocol(setupManager, conf);
});
@@ -4,7 +4,6 @@ import { registerSetupProtocolHandler, useSetupProtocolFeature } from "./setupPr
vi.mock("@/common/types", () => {
return {
configURIBase: "mock-config://",
configURIBaseV2: "mock-config-v2://",
};
});
@@ -18,19 +17,6 @@ vi.mock("@/modules/features/SetupManager", () => {
});
describe("setupObsidian/setupProtocol", () => {
it("routes the versioned encrypted payload through the matching URI format", async () => {
const handlers = new Map<string, (params: Record<string, string>) => Promise<void>>();
const host = {
services: { API: { registerProtocolHandler: vi.fn((action, handler) => handlers.set(action, handler)) } },
} as any;
const setupManager = { onUseSetupURI: vi.fn(), decodeQR: vi.fn() } as any;
registerSetupProtocolHandler(host, vi.fn(), setupManager);
await handlers.get("setuplivesync-v2")!({ settings: "encrypted settings" });
expect(setupManager.onUseSetupURI).toHaveBeenCalledWith("unknown", "mock-config-v2://encrypted%20settings");
await handlers.get("setuplivesync-v2")!({ settingsQR: "plain settings" });
expect(setupManager.decodeQR).not.toHaveBeenCalled();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
@@ -16,16 +16,11 @@ export async function askEncryptingPassphrase(host: SetupFeatureHost): Promise<s
);
}
export async function copySetupURI(
host: SetupFeatureHost,
log: LogFunction,
stripExtra = true,
settings = host.services.setting.currentSettings()
) {
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
const encryptingPassphrase = await askEncryptingPassphrase(host);
if (encryptingPassphrase === false) return;
const encryptedURI = await encodeSettingsToSetupURI(
settings,
host.services.setting.currentSettings(),
encryptingPassphrase,
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
true