Add Joined devices dialogue and version notices

This commit is contained in:
vorotamoroz committed 2026-10-23 10:13:46 +00:00
1 parent ce0524d49f
commit 404d4279c0
23 files changed
+1589 -6

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import type { JoinedDevice, JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
import type { JoinedDevicesView } from "./joinedDevicesDialogTypes";
/** Compare recognised SemVer values; unknown development labels have no ordering. */
export function comparePluginVersions(left: string, right: string): number | undefined {
const parse = (value: string) => {
const match =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
value
);
if (!match) return undefined;
const prerelease = match[4]?.split(".");
if (prerelease?.some((part) => /^0\d+$/.test(part))) return undefined;
return { core: match.slice(1, 4).map(BigInt), prerelease };
};
const a = parse(left);
const b = parse(right);
if (!a || !b) return undefined;
for (let i = 0; i < 3; i++) {
if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i] ? 1 : -1;
}
if (!a.prerelease || !b.prerelease) return a.prerelease ? -1 : b.prerelease ? 1 : 0;
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
const x = a.prerelease[i];
const y = b.prerelease[i];
if (x === undefined) return -1;
if (y === undefined) return 1;
if (x === y) continue;
const xn = /^\d+$/.test(x);
const yn = /^\d+$/.test(y);
if (xn && yn) return BigInt(x) > BigInt(y) ? 1 : -1;
if (xn !== yn) return xn ? -1 : 1;
return x > y ? 1 : -1;
}
return 0;
}
export function joinedDevicesView(
snapshot: JoinedDevicesSnapshot,
self: Omit<JoinedDevice, "accepted">
): JoinedDevicesView {
const ownRecord = snapshot.devices.find((device) => device.id === self.id);
const ownDevice: JoinedDevice = { ...ownRecord, ...self, accepted: ownRecord?.accepted ?? false };
return {
...snapshot,
devices: [ownDevice, ...snapshot.devices.filter((device) => device.id !== self.id)].map((device) => ({
...device,
self: device.id === self.id,
lockedOut: snapshot.locked && !device.accepted,
})),
};
}
export function differentDeviceVersions(snapshot: JoinedDevicesSnapshot, ownId: string, ownVersion: string) {
const newer = new Set<string>();
const older = new Set<string>();
for (const device of snapshot.devices) {
if (device.id === ownId || !device.pluginVersion) continue;
const comparison = comparePluginVersions(device.pluginVersion, ownVersion);
if (comparison !== undefined && comparison > 0) newer.add(device.pluginVersion);
if (comparison !== undefined && comparison < 0) older.add(device.pluginVersion);
}
return { newer: [...newer].sort(), older: [...older].sort() };
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { comparePluginVersions, differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
const empty: JoinedDevicesSnapshot = { milestonePresent: false, locked: false, cleaned: false, devices: [] };
describe("joined device presentation", () => {
it("always shows this device first, including before its remote record exists", () => {
const view = joinedDevicesView(
{ ...empty, devices: [{ id: "another", accepted: true }] },
{ id: "self", platform: "ios", pluginVersion: "1.0.39" }
);
expect(view.devices.map((device) => device.id)).toEqual(["self", "another"]);
expect(view.devices[0]).toMatchObject({ self: true, accepted: false, lockedOut: false, platform: "ios" });
});
it("keeps the remote last-recorded date and acceptance while showing current local versions", () => {
const view = joinedDevicesView(
{
...empty,
milestonePresent: true,
locked: true,
devices: [
{ id: "other", accepted: true },
{ id: "self", accepted: false, lastConnected: 123, pluginVersion: "1.0.0" },
],
},
{ id: "self", pluginVersion: "1.1.0" }
);
expect(view.devices[0]).toMatchObject({
self: true,
lockedOut: true,
lastConnected: 123,
pluginVersion: "1.1.0",
});
expect(view.devices[1].lockedOut).toBe(false);
});
});
describe("version advisories", () => {
it.each([
["1.10.0", "1.9.9", 1],
["1.0.0", "1.0.0-rc.1", 1],
["1.0.0-beta.2", "1.0.0-beta.10", -1],
["1.0.0-1", "1.0.0-alpha", -1],
["1.0.0-alpha", "1.0.0-alpha.1", -1],
["1.0.0+first", "1.0.0+second", 0],
["99999999999999999.0.0", "99999999999999998.0.0", 1],
])("compares %s with %s", (a, b, result) => expect(comparePluginVersions(a, b)).toBe(result));
it.each(["", "development", "1.0", "01.0.0", "1.0.0-01", "v1.0.0"])(
"does not order unknown version %s",
(version) => {
expect(comparePluginVersions(version, "1.0.0")).toBeUndefined();
expect(comparePluginVersions("1.0.0", version)).toBeUndefined();
}
);
it("excludes this device and unknown versions, and deduplicates versions", () => {
const snapshot = {
...empty,
devices: [
{ id: "self", accepted: false, pluginVersion: "9.0.0" },
{ id: "a", accepted: true, pluginVersion: "2.0.0" },
{ id: "b", accepted: false, pluginVersion: "2.0.0" },
{ id: "c", accepted: false, pluginVersion: "0.9.0" },
{ id: "d", accepted: true, pluginVersion: "custom" },
],
};
expect(differentDeviceVersions(snapshot, "self", "1.0.0")).toEqual({ newer: ["2.0.0"], older: ["0.9.0"] });
});
});
@@ -0,0 +1,5 @@
import { declareUICommand } from "@vrtmrz/livesync-commonlib/ui-commands";
export const OPEN_JOINED_DEVICES = declareUICommand<void, "closed" | "already-open" | "unavailable">(
"livesync:joined-devices"
);
@@ -0,0 +1,19 @@
import type { ForgetJoinedDeviceResult, JoinedDevice } from "@vrtmrz/livesync-commonlib/replication";
export interface JoinedDeviceRow extends JoinedDevice {
readonly self: boolean;
readonly lockedOut: boolean;
}
export interface JoinedDevicesView {
readonly milestonePresent: boolean;
readonly locked: boolean;
readonly cleaned: boolean;
readonly devices: readonly JoinedDeviceRow[];
}
export interface JoinedDevicesDialogOptions {
refresh(): Promise<JoinedDevicesView>;
forget(id: string): Promise<ForgetJoinedDeviceResult>;
cancel(): void;
}
+212
View File
@@ -0,0 +1,212 @@
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import {
NODEINFO_DOCID,
REMOTE_COUCHDB,
REMOTE_MINIO,
type EntryNodeInfo,
type RemoteDBSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { REMOTE_RESOURCE_KINDS, type JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
import JoinedDevicesDialog from "@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte";
import {
getCouchDBReplicatorConfigurationIdentity,
getObjectStorageReplicatorConfigurationIdentity,
} from "@/common/replicatorConfigurationIdentity";
import { $msg } from "@/common/translation";
import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand";
import { differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes";
type JoinedDevicesHost = NecessaryServices<
"API" | "UI" | "setting" | "database" | "replicator" | "replication" | "appLifecycle",
never
>;
const noticeGroup = "joined-device-versions";
function remoteIdentity(settings: RemoteDBSettings): string | undefined {
if (settings.remoteType === REMOTE_COUCHDB && settings.couchDB_URI && settings.couchDB_DBNAME) {
return getCouchDBReplicatorConfigurationIdentity(settings);
}
if (settings.remoteType === REMOTE_MINIO && settings.endpoint && settings.bucket) {
return getObjectStorageReplicatorConfigurationIdentity(settings);
}
return undefined;
}
/** Own the dialogue, advisory Notices, and their per-session state for one host. */
export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: ObsidianNoticeGroups): void {
const { services } = host;
let disposed = false;
let active: AbortController | undefined;
let checking = false;
let lastCheck: { identity: string; at: number } | undefined;
const notified = new Map<string, Set<string>>();
const available = () =>
!disposed &&
services.database.isDatabaseReady() &&
remoteIdentity(services.setting.currentSettings()) !== undefined;
const notify = (snapshot: JoinedDevicesSnapshot, ownId: string, identity: string) => {
if (disposed || identity !== remoteIdentity(services.setting.currentSettings())) return;
const version = services.API.getPluginVersion();
const differences = differentDeviceVersions(snapshot, ownId, version);
const seen = notified.get(identity) ?? new Set<string>();
notified.set(identity, seen);
for (const direction of ["newer", "older"] as const) {
const versions = differences[direction];
if (versions.length === 0) continue;
const key = JSON.stringify([version, direction, versions]);
if (seen.has(key)) continue;
seen.add(key);
notices.setItem(noticeGroup, direction, {
message:
direction === "newer"
? $msg("Other joined devices last reported a newer plug-in version: ${versions}.", {
versions: versions.join(", "),
})
: $msg("Other joined devices last reported an older plug-in version: ${versions}.", {
versions: versions.join(", "),
}),
action: {
label: $msg("Joined devices"),
onSelect: () => {
if (!disposed && identity === remoteIdentity(services.setting.currentSettings())) {
void services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
}
},
},
});
notices.finish(noticeGroup, { durationMs: 15000 });
}
};
const createOperations = (controller: AbortController): JoinedDevicesDialogOptions => {
const settings = { ...services.setting.currentSettings() };
const identity = remoteIdentity(settings);
const database = services.database.localDatabase;
const assertCurrent = () => {
if (
disposed ||
controller.signal.aborted ||
!identity ||
identity !== remoteIdentity(services.setting.currentSettings()) ||
!services.database.isDatabaseReady() ||
services.database.localDatabase !== database
) {
throw new Error("Joined devices are no longer available for this dialogue");
}
};
const ownId = async () => {
assertCurrent();
const info = await database.localDatabase.get<EntryNodeInfo>(NODEINFO_DOCID);
assertCurrent();
if (typeof info.nodeid !== "string" || !info.nodeid) throw new Error("Device identity is unavailable");
return info.nodeid;
};
return {
refresh: async () => {
const id = await ownId();
const resource = await services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.JOINED_DEVICES,
settings
);
if (!resource) throw new Error("Joined devices are unavailable");
try {
assertCurrent();
const snapshot = await resource.read();
assertCurrent();
notify(snapshot, id, identity!);
return joinedDevicesView(snapshot, {
id,
platform: services.API.getPlatform(),
pluginVersion: services.API.getPluginVersion(),
appVersion: services.API.getAppVersion(),
});
} finally {
await resource.dispose();
}
},
forget: async (id) => {
if (id === (await ownId())) throw new Error("This device cannot be forgotten");
const resource = await services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.JOINED_DEVICES,
settings
);
if (!resource) throw new Error("Joined devices are unavailable");
try {
assertCurrent();
const result = await resource.forget(id);
assertCurrent();
return result;
} finally {
await resource.dispose();
}
},
cancel: () => controller.abort(),
};
};
const unregister = services.UI.commands.register(OPEN_JOINED_DEVICES, async () => {
if (!available()) return "unavailable";
if (active) return "already-open";
const controller = new AbortController();
active = controller;
try {
await services.UI.dialogManager.open<"close", JoinedDevicesDialogOptions>(
JoinedDevicesDialog,
createOperations(controller)
);
} catch {
// The host may close its dialogues during unload.
} finally {
controller.abort();
if (active === controller) active = undefined;
}
return "closed";
});
const checkVersions = () => {
if (!available() || checking) return;
const identity = remoteIdentity(services.setting.currentSettings())!;
if (lastCheck?.identity === identity && Date.now() - lastCheck.at < 60000) return;
lastCheck = { identity, at: Date.now() };
checking = true;
const controller = new AbortController();
void Promise.resolve()
.then(() => createOperations(controller).refresh())
.catch(() => {
// Advisory inspection never changes the outcome of synchronisation.
})
.finally(() => {
controller.abort();
checking = false;
});
};
const stopBeforeReplicate = services.replication.onBeforeReplicate.addHandler(() => {
checkVersions();
return Promise.resolve(true);
});
services.appLifecycle.onInitialise.addHandler(() => {
services.API.addCommand({
id: "joined-devices",
name: $msg("Joined devices"),
icon: "monitor-smartphone",
checkCallback: (checking) => {
if (!available()) return false;
if (!checking) void services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
return true;
},
});
return Promise.resolve(true);
});
services.appLifecycle.onUnload.addHandler(() => {
disposed = true;
active?.abort();
unregister();
stopBeforeReplicate();
notices.hide(noticeGroup);
notified.clear();
return Promise.resolve(true);
});
}
@@ -0,0 +1,174 @@
import { describe, expect, it, vi } from "vitest";
import { UICommandRegistry } from "@vrtmrz/livesync-commonlib/ui-commands";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes";
vi.mock("@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte", () => ({ default: {} }));
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
import { useJoinedDevicesFeature } from "./useJoinedDevices";
import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand";
function fixture() {
const settings = {
...createNewVaultSettings(),
remoteType: REMOTE_COUCHDB,
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
};
const initialise: Array<() => Promise<boolean>> = [];
const unload: Array<() => Promise<boolean>> = [];
const before: Array<() => Promise<boolean>> = [];
const stopBefore = vi.fn();
const commands = new UICommandRegistry();
const read = vi.fn<() => Promise<JoinedDevicesSnapshot>>(async () => ({
milestonePresent: true,
locked: false,
cleaned: false,
devices: [{ id: "other", accepted: true, pluginVersion: "2.0.0" }],
}));
const forget = vi.fn(async (_id: string) => "forgotten" as const);
const dispose = vi.fn(async () => {});
const createResource = vi.fn(async () => ({ read, forget, dispose }));
let close!: () => void;
const dialog = vi.fn(
(_component: unknown, _options: JoinedDevicesDialogOptions) =>
new Promise<void>((resolve) => {
close = resolve;
})
);
const notices = { setItem: vi.fn(), finish: vi.fn(), hide: vi.fn() };
const palette: Array<{ id: string; checkCallback: (checking: boolean) => boolean }> = [];
const database = { localDatabase: { get: vi.fn(async () => ({ nodeid: "self" })) } };
const host = {
services: {
API: {
getPluginVersion: () => "1.0.0",
getAppVersion: () => "1.9.0",
getPlatform: () => "desktop",
addCommand: (command: (typeof palette)[number]) => palette.push(command),
},
UI: { commands, dialogManager: { open: dialog } },
setting: { currentSettings: () => settings },
database: { isDatabaseReady: () => true, localDatabase: database },
replicator: { createRemoteResource: createResource },
replication: {
onBeforeReplicate: {
addHandler: (handler: (typeof before)[number]) => {
before.push(handler);
return stopBefore;
},
},
},
appLifecycle: {
onInitialise: { addHandler: (handler: (typeof initialise)[number]) => initialise.push(handler) },
onUnload: { addHandler: (handler: (typeof unload)[number]) => unload.push(handler) },
},
},
serviceModules: {},
} as unknown as Parameters<typeof useJoinedDevicesFeature>[0];
useJoinedDevicesFeature(host, notices as unknown as Parameters<typeof useJoinedDevicesFeature>[1]);
return {
settings,
initialise,
unload,
before,
stopBefore,
commands,
read,
forget,
dispose,
createResource,
dialog,
notices,
palette,
database,
close: () => close(),
};
}
async function open(data: ReturnType<typeof fixture>) {
const pending = data.commands.invoke(OPEN_JOINED_DEVICES, undefined);
const options = data.dialog.mock.calls[0][1];
return { options, pending };
}
describe("joined devices service feature", () => {
it("opens one dialogue through the command and disposes each completed read", async () => {
const data = fixture();
const { options, pending } = await open(data);
expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({
status: "handled",
value: "already-open",
});
expect((await options.refresh()).devices[0]).toMatchObject({ id: "self", self: true });
expect(data.dispose).toHaveBeenCalledTimes(1);
data.close();
expect(await pending).toEqual({ status: "handled", value: "closed" });
await expect(options.refresh()).rejects.toThrow();
});
it("prevents self removal and fences an old dialogue after a remote switch", async () => {
const data = fixture();
const { options, pending } = await open(data);
await expect(options.forget("self")).rejects.toThrow();
data.settings.couchDB_DBNAME = "new-vault";
await expect(options.forget("other")).rejects.toThrow();
expect(data.forget).not.toHaveBeenCalled();
expect(data.createResource).not.toHaveBeenCalled();
data.close();
await pending;
});
it("discards a stale read, releases the resource, and emits no stale Notice", async () => {
const data = fixture();
let resolve!: (value: JoinedDevicesSnapshot) => void;
data.read.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
})
);
const { options, pending } = await open(data);
const reading = options.refresh();
await vi.waitFor(() => expect(data.read).toHaveBeenCalled());
data.settings.couchDB_DBNAME = "new-vault";
resolve({ milestonePresent: true, locked: false, cleaned: false, devices: [] });
await expect(reading).rejects.toThrow();
expect(data.dispose).toHaveBeenCalledOnce();
expect(data.notices.setItem).not.toHaveBeenCalled();
data.close();
await pending;
});
it("deduplicates advisory Notices and fences their actions after configuration changes", async () => {
const data = fixture();
const { options, pending } = await open(data);
await options.refresh();
await options.refresh();
expect(data.notices.setItem).toHaveBeenCalledTimes(1);
const action = data.notices.setItem.mock.calls[0][2].action;
data.close();
await pending;
data.settings.couchDB_DBNAME = "new-vault";
action.onSelect();
expect(data.dialog).toHaveBeenCalledTimes(1);
});
it("keeps inspection failures out of the replication result and unregisters on unload", async () => {
const data = fixture();
data.read.mockRejectedValue(new Error("unavailable"));
expect(await data.before[0]()).toBe(true);
await vi.waitFor(() => expect(data.dispose).toHaveBeenCalledOnce());
expect(await data.before[0]()).toBe(true);
expect(data.read).toHaveBeenCalledTimes(1);
for (const unload of data.unload) await unload();
expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({ status: "unavailable" });
expect(data.stopBefore).toHaveBeenCalledOnce();
expect(data.notices.hide).toHaveBeenCalled();
});
it("uses the same UI command from the command palette", async () => {
const data = fixture();
for (const initialise of data.initialise) await initialise();
expect(data.palette[0].checkCallback(true)).toBe(true);
expect(data.dialog).not.toHaveBeenCalled();
data.palette[0].checkCallback(false);
expect(data.dialog).toHaveBeenCalledOnce();
data.close();
});
});