mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-09 02:42:30 +00:00
Prepare time-bound Setup URI integration for Commonlib release
Add Time-bound and Compatible generation to the Obsidian dialogue, browser peer check, and setup tools, with boundary and compatibility coverage. Show the fixed window end before sharing a Time-bound URI. This review branch depends on unpublished @vrtmrz/livesync-commonlib 0.1.32-next.0. The root npm pin and Deno lockfile remain unchanged until that candidate is available; a fresh install of this commit is not yet expected to build.
This commit is contained in:
@@ -419,6 +419,38 @@ describe("runCommand abnormal cases", () => {
|
||||
expect(appliedSettings.useIndexedDBAdapter).toBe(false);
|
||||
});
|
||||
|
||||
it("setup opens Ephemeral in its window and leaves settings untouched afterwards", async () => {
|
||||
const end = Date.parse("2026-10-01T00:00:00Z");
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(end - 1_000);
|
||||
const passphrase = "time-bound-passphrase";
|
||||
try {
|
||||
const { uri, usableUntil } = await processSetting.encodeTimeBoundSetupURI(
|
||||
{ ...DEFAULT_SETTINGS, isConfigured: true, couchDB_DBNAME: "time-bound-vault" },
|
||||
passphrase
|
||||
);
|
||||
expect(usableUntil).toBe(end);
|
||||
|
||||
const inWindow = createCoreMock();
|
||||
inWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase);
|
||||
await runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: inWindow });
|
||||
expect(inWindow.services.setting.applyExternalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ couchDB_DBNAME: "time-bound-vault" }),
|
||||
true
|
||||
);
|
||||
|
||||
clock.mockReturnValue(end);
|
||||
const outOfWindow = createCoreMock();
|
||||
outOfWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase);
|
||||
await expect(runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: outOfWindow })).rejects.toThrow(
|
||||
"Cannot open Setup URI"
|
||||
);
|
||||
expect(outOfWindow.services.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
expect(outOfWindow.services.control.applySettings).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("setup imports managed TURN through the existing encrypted URI", async () => {
|
||||
const core = createCoreMock();
|
||||
const profiles = {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import qrcode from "qrcode-generator";
|
||||
import { onDestroy, tick } from "svelte";
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { isTimeBoundSetupURIUsableNow } from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
|
||||
import {
|
||||
generateP2PCheckSetup,
|
||||
@@ -106,6 +107,7 @@
|
||||
let elapsedMilliseconds = $state(0);
|
||||
let copied = $state<"uri" | "passphrase">();
|
||||
let copyError = $state("");
|
||||
let currentTime = $state(Date.now());
|
||||
let freshCheckStarting = $state(false);
|
||||
let additionalDeviceAttempt = $state<AdditionalDeviceAttempt>();
|
||||
|
||||
@@ -132,6 +134,12 @@
|
||||
);
|
||||
let elapsedSeconds = $derived(Math.floor(elapsedMilliseconds / 1_000));
|
||||
let targetLabel = $derived(target === "desktop" ? "desktop" : "mobile");
|
||||
let setupURIUsable = $derived(
|
||||
setup !== undefined && currentTime >= 0 && isTimeBoundSetupURIUsableNow(setup.setupURIUsableUntil)
|
||||
);
|
||||
let remainingMinutes = $derived(
|
||||
setup ? Math.max(0, Math.ceil((setup.setupURIUsableUntil - currentTime) / 60_000)) : 0
|
||||
);
|
||||
let additionalElapsedMilliseconds = $derived(
|
||||
additionalDeviceAttempt
|
||||
? Math.max(
|
||||
@@ -178,6 +186,7 @@
|
||||
const generated = await generateP2PCheckSetup(target, { relay });
|
||||
qrDataURL = createQRCodeDataURL(generated.setupURI);
|
||||
setup = generated;
|
||||
currentTime = Date.now();
|
||||
} catch (error) {
|
||||
preparationError = formatError(error);
|
||||
} finally {
|
||||
@@ -215,6 +224,11 @@
|
||||
|
||||
async function copyText(value: string, kind: "uri" | "passphrase"): Promise<void> {
|
||||
copyError = "";
|
||||
if (kind === "uri" && (!setup || !isTimeBoundSetupURIUsableNow(setup.setupURIUsableUntil))) {
|
||||
currentTime = Date.now();
|
||||
copyError = "This Setup URI is outside its time window. Start a fresh check.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = kind;
|
||||
@@ -254,6 +268,7 @@
|
||||
|
||||
async function startAdditionalDeviceAttempt(): Promise<void> {
|
||||
if (
|
||||
!setupURIUsable ||
|
||||
!monitorActive ||
|
||||
outcome !== "connected" ||
|
||||
activeConnections === 0 ||
|
||||
@@ -268,6 +283,38 @@
|
||||
await showSetupQRCode();
|
||||
}
|
||||
|
||||
function formatSetupURIEnd(usableUntil: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZoneName: "short",
|
||||
}).format(new Date(usableUntil));
|
||||
}
|
||||
|
||||
function openSetupURI(event: MouseEvent): void {
|
||||
if (!setup || !isTimeBoundSetupURIUsableNow(setup.setupURIUsableUntil)) {
|
||||
event.preventDefault();
|
||||
currentTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const refresh = () => (currentTime = Date.now());
|
||||
const timer = setInterval(refresh, 1_000);
|
||||
window.addEventListener("focus", refresh);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener("focus", refresh);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (elapsedTimer !== undefined) {
|
||||
clearInterval(elapsedTimer);
|
||||
@@ -372,6 +419,7 @@
|
||||
|
||||
<div class="setup-grid">
|
||||
<div class="qr-panel">
|
||||
{#if setupURIUsable}
|
||||
<img
|
||||
src={qrDataURL}
|
||||
alt={additionalDeviceAttempt
|
||||
@@ -383,6 +431,12 @@
|
||||
? "This is the original encrypted Setup URI; it was not regenerated."
|
||||
: "QR contains the encrypted Setup URI only."}
|
||||
</p>
|
||||
{:else}
|
||||
<p role="alert">This Setup URI is outside its time window. Start a fresh check to set up another device. A device that already imported it can still be monitored below.</p>
|
||||
<button type="button" onclick={startFreshCheck} disabled={freshCheckStarting}>
|
||||
{freshCheckStarting ? "Starting…" : "Start a fresh check"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="credential-panel">
|
||||
@@ -401,6 +455,16 @@
|
||||
</div>
|
||||
<p class="field-help">Type this when LiveSync asks to decrypt the Setup URI.</p>
|
||||
|
||||
<p class="field-help">
|
||||
{#if setupURIUsable}
|
||||
Ephemeral Setup URI: usable until {formatSetupURIEnd(setup.setupURIUsableUntil)}
|
||||
({remainingMinutes < 1 ? "less than 1 minute" : `${remainingMinutes} minutes`} remaining).
|
||||
{:else}
|
||||
This Setup URI is outside its time window. Generate a new one before sharing it.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if setupURIUsable}
|
||||
<label for="setup-uri">Setup URI</label>
|
||||
<textarea
|
||||
id="setup-uri"
|
||||
@@ -414,8 +478,9 @@
|
||||
<button type="button" onclick={() => copyText(setup!.setupURI, "uri")}>
|
||||
{copied === "uri" ? "Copied URI" : "Copy Setup URI"}
|
||||
</button>
|
||||
<a class="button-link" href={setup.setupURI}>Open in Obsidian</a>
|
||||
<a class="button-link" href={setup.setupURI} onclick={openSetupURI}>Open in Obsidian</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if copyError}
|
||||
<p class="inline-error" role="alert">{copyError}</p>
|
||||
{/if}
|
||||
@@ -550,9 +615,11 @@
|
||||
class="primary-action"
|
||||
type="button"
|
||||
onclick={startAdditionalDeviceAttempt}
|
||||
disabled={activeConnections === 0}
|
||||
disabled={!setupURIUsable || activeConnections === 0}
|
||||
>
|
||||
{activeConnections === 0
|
||||
{!setupURIUsable
|
||||
? "Setup URI window ended; start a fresh check"
|
||||
: activeConnections === 0
|
||||
? "Waiting for the first device to reconnect…"
|
||||
: "Try another device without resetting"}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { encodeTimeBoundSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import {
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
@@ -17,6 +17,7 @@ export type P2PCheckTarget = "desktop" | "mobile";
|
||||
export interface GeneratedP2PCheckSetup {
|
||||
readonly target: P2PCheckTarget;
|
||||
readonly setupURI: string;
|
||||
readonly setupURIUsableUntil: number;
|
||||
readonly setupPassphrase: string;
|
||||
readonly groupId: string;
|
||||
readonly relay: string;
|
||||
@@ -147,16 +148,26 @@ export async function generateP2PCheckSetup(
|
||||
browserSettings.suspendParseReplicationResult = true;
|
||||
|
||||
const setupPassphrase = generateSetupPassphrase();
|
||||
const setupURI = await encodeSettingsToSetupURI(
|
||||
const { uri: setupURI, usableUntil } = await encodeTimeBoundSetupURI(
|
||||
deviceSettings,
|
||||
setupPassphrase,
|
||||
["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks", "P2P_DevicePeerName", "deviceAndVaultName"],
|
||||
true
|
||||
{
|
||||
mode: "ephemeral",
|
||||
removeProperties: [
|
||||
"pluginSyncExtendedSetting",
|
||||
"doNotUseFixedRevisionForChunks",
|
||||
"P2P_DevicePeerName",
|
||||
"deviceAndVaultName",
|
||||
],
|
||||
skipDefaultValue: true,
|
||||
}
|
||||
);
|
||||
if (usableUntil === null) throw new Error("Ephemeral Setup URI has no time window");
|
||||
|
||||
return {
|
||||
target,
|
||||
setupURI: setupURI.trim(),
|
||||
setupURIUsableUntil: usableUntil,
|
||||
setupPassphrase,
|
||||
groupId: credentials.groupId,
|
||||
relay: deviceSettings.P2P_relays,
|
||||
|
||||
@@ -57,4 +57,9 @@
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { registerSetupProtocolHandler, useSetupProtocolFeature } from "./setupProtocol";
|
||||
import { encodeTimeBoundSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/common/types", () => {
|
||||
return {
|
||||
@@ -52,6 +54,35 @@ describe("setupObsidian/setupProtocol", () => {
|
||||
expect(setupManager.decodeQR).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["ephemeral", "persistent"] as const)(
|
||||
"reconstructs the exact encrypted %s URI after protocol query decoding",
|
||||
async (mode) => {
|
||||
let protocolHandler: ((params: Record<string, string>) => Promise<void>) | undefined;
|
||||
const host = {
|
||||
services: {
|
||||
API: {
|
||||
registerProtocolHandler: vi.fn(
|
||||
(_action: string, handler: (params: Record<string, string>) => Promise<void>) => {
|
||||
protocolHandler = handler;
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const setupManager = { onUseSetupURI: vi.fn(async () => true), decodeQR: vi.fn() } as any;
|
||||
registerSetupProtocolHandler(host, vi.fn(), setupManager);
|
||||
|
||||
const generated = await encodeTimeBoundSetupURI(DEFAULT_SETTINGS, "protocol-passphrase", { mode });
|
||||
const decodedQuery = new URL(generated.uri.trim()).searchParams.get("settings");
|
||||
expect(decodedQuery?.startsWith("%$")).toBe(true);
|
||||
await protocolHandler!({ settings: decodedQuery! });
|
||||
const reconstructed = setupManager.onUseSetupURI.mock.calls[0][1] as string;
|
||||
expect(reconstructed.replace("mock-config://", "obsidian://setuplivesync?settings=")).toBe(
|
||||
generated.uri.trim()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("registerSetupProtocolHandler should route settingsQR payload to decodeQR", async () => {
|
||||
let protocolHandler: ((params: Record<string, string>) => Promise<void>) | undefined;
|
||||
const host = {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { LOG_LEVEL_NOTICE, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import {
|
||||
encodeTimeBoundSetupURI,
|
||||
getTimeBoundSetupURIUsableUntil,
|
||||
isTimeBoundSetupURIUsableNow,
|
||||
type TimeBoundSetupURIMode,
|
||||
} from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
@@ -16,32 +21,89 @@ export async function askEncryptingPassphrase(host: SetupFeatureHost): Promise<s
|
||||
);
|
||||
}
|
||||
|
||||
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
|
||||
const encryptingPassphrase = await askEncryptingPassphrase(host);
|
||||
if (encryptingPassphrase === false) return;
|
||||
const encryptedURI = await encodeSettingsToSetupURI(
|
||||
host.services.setting.currentSettings(),
|
||||
encryptingPassphrase,
|
||||
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
|
||||
true
|
||||
);
|
||||
if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
|
||||
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
function formatWindowEnd(usableUntil: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZoneName: "short",
|
||||
}).format(new Date(usableUntil));
|
||||
}
|
||||
|
||||
async function askSetupURIMode(
|
||||
host: SetupFeatureHost
|
||||
): Promise<{ mode: TimeBoundSetupURIMode; usableUntil: number | null } | false> {
|
||||
let usableUntil: number | null = null;
|
||||
try {
|
||||
usableUntil = getTimeBoundSetupURIUsableUntil();
|
||||
} catch {
|
||||
// Compatible generation remains available when the device clock is invalid.
|
||||
}
|
||||
const timeBound = "Time-bound";
|
||||
const compatible = "Compatible (no time limit)";
|
||||
const cancel = "Cancel";
|
||||
const buttons = usableUntil === null ? [compatible, cancel] : [timeBound, compatible, cancel];
|
||||
const message =
|
||||
usableUntil === null
|
||||
? "Time-bound Setup URIs require a valid device clock. Compatible URIs have no time limit and work with older clients."
|
||||
: `Time-bound Setup URIs can be opened until ${formatWindowEnd(usableUntil)}. This is the end of the current fixed seven-day UTC window, not seven days from now. Compatible URIs have no time limit and work with older clients.`;
|
||||
const selected = await host.services.UI.confirm.askSelectStringDialogue(message, buttons, {
|
||||
title: "Setup URI availability",
|
||||
defaultAction: usableUntil === null ? compatible : timeBound,
|
||||
});
|
||||
if (selected === timeBound && usableUntil !== null) return { mode: "ephemeral", usableUntil };
|
||||
if (selected === compatible) return { mode: "persistent", usableUntil: null };
|
||||
return false;
|
||||
}
|
||||
|
||||
async function generateAndCopySetupURI(
|
||||
host: SetupFeatureHost,
|
||||
log: LogFunction,
|
||||
removeProperties: (keyof ObsidianLiveSyncSettings)[],
|
||||
skipDefaultValue: boolean
|
||||
) {
|
||||
const passphrase = await askEncryptingPassphrase(host);
|
||||
if (passphrase === false) return;
|
||||
while (true) {
|
||||
const choice = await askSetupURIMode(host);
|
||||
if (choice === false) return;
|
||||
let result;
|
||||
try {
|
||||
result = await encodeTimeBoundSetupURI(host.services.setting.currentSettings(), passphrase, {
|
||||
mode: choice.mode,
|
||||
removeProperties,
|
||||
skipDefaultValue,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
choice.mode === "ephemeral" &&
|
||||
error instanceof Error &&
|
||||
error.message === "Setup URI window changed during generation"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (result.usableUntil !== choice.usableUntil || !isTimeBoundSetupURIUsableNow(result.usableUntil)) {
|
||||
continue;
|
||||
}
|
||||
if (await host.services.UI.promptCopyToClipboard("Setup URI", result.uri)) {
|
||||
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
|
||||
await generateAndCopySetupURI(host, log, stripExtra ? ["pluginSyncExtendedSetting"] : [], true);
|
||||
}
|
||||
|
||||
export async function copySetupURIFull(host: SetupFeatureHost, log: LogFunction) {
|
||||
const encryptingPassphrase = await askEncryptingPassphrase(host);
|
||||
if (encryptingPassphrase === false) return;
|
||||
const encryptedURI = await encodeSettingsToSetupURI(
|
||||
host.services.setting.currentSettings(),
|
||||
encryptingPassphrase,
|
||||
[],
|
||||
false
|
||||
);
|
||||
if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
|
||||
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await generateAndCopySetupURI(host, log, [], false);
|
||||
}
|
||||
|
||||
export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>) {
|
||||
|
||||
@@ -1,119 +1,203 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
encodeTimeBoundSetupURI,
|
||||
getTimeBoundSetupURIUsableUntil,
|
||||
isTimeBoundSetupURIUsableNow,
|
||||
} from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { askEncryptingPassphrase, copySetupURI, copySetupURIFull, useSetupURIFeature } from "./setupUri";
|
||||
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
|
||||
return {
|
||||
encodeSettingsToSetupURI: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@vrtmrz/livesync-commonlib/setup-uri", () => ({
|
||||
encodeTimeBoundSetupURI: vi.fn(),
|
||||
getTimeBoundSetupURIUsableUntil: vi.fn(),
|
||||
isTimeBoundSetupURIUsableNow: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("setupObsidian/setupUri", () => {
|
||||
const usableUntil = Date.parse("2026-10-01T00:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTimeBoundSetupURIUsableUntil).mockReturnValue(usableUntil);
|
||||
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "obsidian://setup-time-bound ", usableUntil });
|
||||
vi.mocked(isTimeBoundSetupURIUsableNow).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("askEncryptingPassphrase should delegate to confirm.askString", async () => {
|
||||
const askString = vi.fn(() => "secret");
|
||||
const host = {
|
||||
services: {
|
||||
UI: {
|
||||
confirm: {
|
||||
askString,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
it("uses the existing password prompt", async () => {
|
||||
const askString = vi.fn(async () => "secret");
|
||||
const host = { services: { UI: { confirm: { askString } } } } as any;
|
||||
|
||||
const result = await askEncryptingPassphrase(host);
|
||||
expect(result).toBe("secret");
|
||||
expect(askString).toHaveBeenCalled();
|
||||
await expect(askEncryptingPassphrase(host)).resolves.toBe("secret");
|
||||
expect(askString).toHaveBeenCalledWith(
|
||||
"Encrypt your settings",
|
||||
"The passphrase to encrypt the setup URI",
|
||||
"",
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("copySetupURI should return early when user cancels passphrase", async () => {
|
||||
const promptCopyToClipboard = vi.fn();
|
||||
const host = {
|
||||
services: {
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => ({ foo: "bar" })),
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => false),
|
||||
},
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const log = vi.fn();
|
||||
|
||||
await copySetupURI(host, log);
|
||||
|
||||
expect(encodeSettingsToSetupURI).not.toHaveBeenCalled();
|
||||
expect(promptCopyToClipboard).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copySetupURI should encode with short mode by default", async () => {
|
||||
const promptCopyToClipboard = vi.fn(() => true);
|
||||
it("shows the exact Time-bound end at selection and uses it by default", async () => {
|
||||
const askSelectStringDialogue = vi.fn(async () => "Time-bound");
|
||||
const promptCopyToClipboard = vi.fn(async () => true);
|
||||
const currentSettings = { pluginSyncExtendedSetting: true, x: 1 };
|
||||
const host = {
|
||||
services: {
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => currentSettings),
|
||||
},
|
||||
setting: { currentSettings: vi.fn(() => currentSettings) },
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => "pass"),
|
||||
},
|
||||
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const log = vi.fn();
|
||||
vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://value" as any);
|
||||
|
||||
await copySetupURI(host, log);
|
||||
|
||||
expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(
|
||||
currentSettings,
|
||||
"pass",
|
||||
["pluginSyncExtendedSetting"],
|
||||
true
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
expect.stringContaining("2026"),
|
||||
["Time-bound", "Compatible (no time limit)", "Cancel"],
|
||||
{
|
||||
title: "Setup URI availability",
|
||||
defaultAction: "Time-bound",
|
||||
}
|
||||
);
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://value");
|
||||
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith(currentSettings, "pass", {
|
||||
mode: "ephemeral",
|
||||
removeProperties: ["pluginSyncExtendedSetting"],
|
||||
skipDefaultValue: true,
|
||||
});
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "obsidian://setup-time-bound ");
|
||||
expect(log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copySetupURIFull should encode with full mode", async () => {
|
||||
const promptCopyToClipboard = vi.fn(() => true);
|
||||
it("selects old-reader-compatible output without a time limit and preserves full export settings", async () => {
|
||||
const promptCopyToClipboard = vi.fn(async () => true);
|
||||
const currentSettings = { pluginSyncExtendedSetting: true, x: 1 };
|
||||
const host = {
|
||||
services: {
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => currentSettings),
|
||||
},
|
||||
setting: { currentSettings: vi.fn(() => currentSettings) },
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => "pass-full"),
|
||||
askString: vi.fn(async () => "pass"),
|
||||
askSelectStringDialogue: vi.fn(async () => "Compatible (no time limit)"),
|
||||
},
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const log = vi.fn();
|
||||
vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://full" as any);
|
||||
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({
|
||||
uri: "obsidian://setup-compatible ",
|
||||
usableUntil: null,
|
||||
});
|
||||
|
||||
await copySetupURIFull(host, log);
|
||||
|
||||
expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(currentSettings, "pass-full", [], false);
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://full");
|
||||
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith(currentSettings, "pass", {
|
||||
mode: "persistent",
|
||||
removeProperties: [],
|
||||
skipDefaultValue: false,
|
||||
});
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "obsidian://setup-compatible ");
|
||||
expect(log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Compatible available when the clock is invalid", async () => {
|
||||
vi.mocked(getTimeBoundSetupURIUsableUntil).mockImplementation(() => {
|
||||
throw new Error("Invalid Setup URI clock");
|
||||
});
|
||||
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "compatible", usableUntil: null });
|
||||
const askSelectStringDialogue = vi.fn(async () => "Compatible (no time limit)");
|
||||
const host = {
|
||||
services: {
|
||||
setting: { currentSettings: vi.fn(() => ({})) },
|
||||
UI: {
|
||||
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
|
||||
promptCopyToClipboard: vi.fn(async () => false),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await copySetupURI(host, vi.fn());
|
||||
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
expect.stringContaining("valid device clock"),
|
||||
["Compatible (no time limit)", "Cancel"],
|
||||
expect.objectContaining({ defaultAction: "Compatible (no time limit)" })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not generate after password or mode cancellation", async () => {
|
||||
const askString = vi.fn(async (): Promise<string | false> => false);
|
||||
const askSelectStringDialogue = vi.fn(async () => "Cancel");
|
||||
const promptCopyToClipboard = vi.fn();
|
||||
const host = {
|
||||
services: {
|
||||
setting: { currentSettings: vi.fn(() => ({})) },
|
||||
UI: { confirm: { askString, askSelectStringDialogue }, promptCopyToClipboard },
|
||||
},
|
||||
} as any;
|
||||
|
||||
await copySetupURI(host, vi.fn());
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
askString.mockResolvedValueOnce("pass");
|
||||
await copySetupURI(host, vi.fn());
|
||||
expect(encodeTimeBoundSetupURI).not.toHaveBeenCalled();
|
||||
expect(promptCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an empty passphrase distinct from cancelling the existing prompt", async () => {
|
||||
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "compatible", usableUntil: null });
|
||||
const promptCopyToClipboard = vi.fn(async () => false);
|
||||
const host = {
|
||||
services: {
|
||||
setting: { currentSettings: vi.fn(() => ({})) },
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(async () => ""),
|
||||
askSelectStringDialogue: vi.fn(async () => "Compatible (no time limit)"),
|
||||
},
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await copySetupURI(host, vi.fn());
|
||||
|
||||
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith({}, "", expect.objectContaining({ mode: "persistent" }));
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "compatible");
|
||||
});
|
||||
|
||||
it("asks again if the window changes after selection", async () => {
|
||||
const nextUntil = usableUntil + 604_800_000;
|
||||
vi.mocked(getTimeBoundSetupURIUsableUntil).mockReturnValueOnce(usableUntil).mockReturnValueOnce(nextUntil);
|
||||
vi.mocked(encodeTimeBoundSetupURI)
|
||||
.mockResolvedValueOnce({ uri: "old-window", usableUntil: nextUntil })
|
||||
.mockResolvedValueOnce({ uri: "new-window", usableUntil: nextUntil });
|
||||
const askSelectStringDialogue = vi.fn(async () => "Time-bound");
|
||||
const promptCopyToClipboard = vi.fn(async () => false);
|
||||
const host = {
|
||||
services: {
|
||||
setting: { currentSettings: vi.fn(() => ({})) },
|
||||
UI: {
|
||||
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await copySetupURI(host, vi.fn());
|
||||
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledTimes(2);
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledTimes(1);
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "new-window");
|
||||
});
|
||||
|
||||
it("useSetupURIFeature should register onLoaded handler that wires commands and event", async () => {
|
||||
const addHandler = vi.fn();
|
||||
const addCommand = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user