fix: clarify device version notices

This commit is contained in:
vorotamoroz committed 2026-10-23 20:18:29 +00:00
1 parent 1a54d21e71
commit 916784c5c7
6 files changed
+144 -38

No files matched your search

@@ -42,10 +42,12 @@ export const liveSyncProvisionalEnglishMessages = {
Refresh: "Refresh", Refresh: "Refresh",
"Plug-in version": "Plug-in version", "Plug-in version": "Plug-in version",
Platform: "Platform", Platform: "Platform",
"An older plug-in version was detected in other device records: ${versions}. Reviewing and aligning the plug-in versions across devices is recommended.": "Older Self-hosted LiveSync versions detected: ${versions}.":
"An older plug-in version was detected in other device records: ${versions}. Reviewing and aligning the plug-in versions across devices is recommended.", "Older Self-hosted LiveSync versions detected: ${versions}.",
"A newer plug-in version was detected in other device records: ${versions}. Reviewing and aligning the plug-in versions across devices is recommended.": "Newer Self-hosted LiveSync versions detected: ${versions}.":
"A newer plug-in version was detected in other device records: ${versions}. Reviewing and aligning the plug-in versions across devices is recommended.", "Newer Self-hosted LiveSync versions detected: ${versions}.",
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.":
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.",
Open: "Open", Open: "Open",
"Not recorded": "Not recorded", "Not recorded": "Not recorded",
"Not accepted": "Not accepted", "Not accepted": "Not accepted",
+22 -21
View File
@@ -53,6 +53,7 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
const differences = differentDeviceVersions(snapshot, ownId, version); const differences = differentDeviceVersions(snapshot, ownId, version);
const seen = notified.get(identity) ?? new Set<string>(); const seen = notified.get(identity) ?? new Set<string>();
notified.set(identity, seen); notified.set(identity, seen);
let updated = false;
for (const direction of ["newer", "older"] as const) { for (const direction of ["newer", "older"] as const) {
const versions = differences[direction]; const versions = differences[direction];
if (versions.length === 0) continue; if (versions.length === 0) continue;
@@ -62,29 +63,29 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
notices.setItem(noticeGroup, direction, { notices.setItem(noticeGroup, direction, {
message: message:
direction === "newer" direction === "newer"
? $msg( ? $msg("Newer Self-hosted LiveSync versions detected: ${versions}.", {
"A newer plug-in version was detected in other device records: ${versions}. Reviewing and aligning the plug-in versions across devices is recommended.", versions: versions.join(", "),
{ })
versions: versions.join(", "), : $msg("Older Self-hosted LiveSync versions detected: ${versions}.", {
} versions: versions.join(", "),
) }),
: $msg(
"An older plug-in version was detected in other device records: ${versions}. Reviewing and aligning the plug-in versions across devices is recommended.",
{
versions: versions.join(", "),
}
),
action: {
label: $msg("Review devices"),
onSelect: () => {
if (!disposed && identity === remoteIdentity(services.setting.currentSettings())) {
void services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
}
},
},
}); });
notices.finish(noticeGroup, { durationMs: 15000 }); updated = true;
} }
if (!updated) return;
notices.removeItem(noticeGroup, "review");
notices.setItem(noticeGroup, "review", {
message: $msg("Reviewing and aligning Self-hosted LiveSync versions across devices is recommended."),
action: {
label: $msg("Review 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 createOperations = (controller: AbortController): JoinedDevicesDialogOptions => {
@@ -5,7 +5,10 @@ import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication"; import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes"; import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes";
vi.mock("@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte", () => ({ default: {} })); vi.mock("@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte", () => ({ default: {} }));
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message })); vi.mock("@/common/translation", () => ({
$msg: (message: string, values: Record<string, string> = {}) =>
message.replace(/\$\{([^}]+)\}/g, (placeholder, key: string) => values[key] ?? placeholder),
}));
import { useJoinedDevicesFeature } from "./useJoinedDevices"; import { useJoinedDevicesFeature } from "./useJoinedDevices";
import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand"; import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand";
@@ -37,7 +40,7 @@ function fixture() {
close = resolve; close = resolve;
}) })
); );
const notices = { setItem: vi.fn(), finish: vi.fn(), hide: vi.fn() }; const notices = { setItem: vi.fn(), removeItem: vi.fn(), finish: vi.fn(), hide: vi.fn() };
const palette: Array<{ id: string; checkCallback: (checking: boolean) => boolean }> = []; const palette: Array<{ id: string; checkCallback: (checking: boolean) => boolean }> = [];
const database = { localDatabase: { get: vi.fn(async () => ({ nodeid: "self" })) } }; const database = { localDatabase: { get: vi.fn(async () => ({ nodeid: "self" })) } };
const host = { const host = {
@@ -193,13 +196,100 @@ describe("joined devices service feature", () => {
data.close(); data.close();
await pending; await pending;
}); });
it("shares one review action across newer and older device versions", async () => {
const data = fixture();
data.read.mockResolvedValue({
milestonePresent: true,
locked: false,
cleaned: false,
devices: [
{ id: "newer-a", accepted: true, pluginVersion: "2.0.0" },
{ id: "newer-b", accepted: true, pluginVersion: "3.0.0" },
{ id: "older-a", accepted: true, pluginVersion: "0.9.0" },
{ id: "older-b", accepted: true, pluginVersion: "0.8.0" },
{ id: "older-duplicate", accepted: true, pluginVersion: "0.8.0" },
],
});
const { options, pending } = await open(data);
await options.refresh();
const items = data.notices.setItem.mock.calls.map(([, , item]) => item);
expect(items.filter((item) => item.action)).toHaveLength(1);
const messages = items.map((item) => item.message).join("\n");
expect(messages).toContain("Self-hosted LiveSync");
expect(messages).toMatch(/Newer.*2\.0\.0, 3\.0\.0/);
expect(messages).toMatch(/Older.*0\.8\.0, 0\.9\.0/);
expect(messages.match(/0\.8\.0/g)).toHaveLength(1);
expect(messages.match(/Reviewing and aligning/g)).toHaveLength(1);
expect(items[items.length - 1]?.action?.label).toBe("Review devices");
expect(data.notices.finish).toHaveBeenCalledExactlyOnceWith("joined-device-versions", {
durationMs: 15000,
});
data.close();
await pending;
});
it("keeps the shared action last when another version direction arrives", async () => {
const data = fixture();
const visible = new Map<string, { message: string; action?: unknown }>();
data.notices.setItem.mockImplementation((_group, key, item) => visible.set(key, item));
data.notices.removeItem.mockImplementation((_group, key) => visible.delete(key));
const { options, pending } = await open(data);
await options.refresh();
expect([...visible.keys()]).toEqual(["newer", "review"]);
data.read.mockResolvedValue({
milestonePresent: true,
locked: false,
cleaned: false,
devices: [
{ id: "other", accepted: true, pluginVersion: "2.0.0" },
{ id: "older", accepted: true, pluginVersion: "0.9.0" },
],
});
await options.refresh();
expect([...visible.keys()]).toEqual(["newer", "older", "review"]);
expect([...visible.values()].filter((item) => item.action)).toHaveLength(1);
visible.clear();
data.read.mockResolvedValue({
milestonePresent: true,
locked: false,
cleaned: false,
devices: [
{ id: "other", accepted: true, pluginVersion: "2.0.0" },
{ id: "older", accepted: true, pluginVersion: "0.8.0" },
],
});
await options.refresh();
expect([...visible.keys()]).toEqual(["older", "review"]);
expect([...visible.values()].filter((item) => item.action)).toHaveLength(1);
data.close();
await pending;
});
it("does not show a review action when no different version is reported", async () => {
const data = fixture();
data.read.mockResolvedValue({
milestonePresent: true,
locked: false,
cleaned: false,
devices: [{ id: "other", accepted: true, pluginVersion: "1.0.0" }],
});
const { options, pending } = await open(data);
await options.refresh();
expect(data.notices.setItem).not.toHaveBeenCalled();
expect(data.notices.removeItem).not.toHaveBeenCalled();
expect(data.notices.finish).not.toHaveBeenCalled();
data.close();
await pending;
});
it("deduplicates advisory Notices and fences their actions after configuration changes", async () => { it("deduplicates advisory Notices and fences their actions after configuration changes", async () => {
const data = fixture(); const data = fixture();
const { options, pending } = await open(data); const { options, pending } = await open(data);
await options.refresh(); await options.refresh();
await options.refresh(); await options.refresh();
expect(data.notices.setItem).toHaveBeenCalledTimes(1); expect(data.notices.setItem).toHaveBeenCalledTimes(2);
const action = data.notices.setItem.mock.calls[0][2].action; const actions = data.notices.setItem.mock.calls.map(([, , item]) => item.action).filter(Boolean);
expect(actions).toHaveLength(1);
const action = actions[0];
data.close(); data.close();
await pending; await pending;
data.settings.couchDB_DBNAME = "new-vault"; data.settings.couchDB_DBNAME = "new-vault";
+1 -1
View File
@@ -125,7 +125,7 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The P2P setup check delays the saved device-name read, requires the name field and both continuation controls to remain disabled, then verifies the saved name and selected connection settings reach the setup workflow. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database. `test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The P2P setup check delays the saved device-name read, requires the name field and both continuation controls to remain disabled, then verifies the saved name and selected connection settings reach the setup workflow. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
`test:e2e:obsidian:joined-devices` uses a temporary Vault and isolated CouchDB database to open Joined devices from Remote Configuration, a version Notice, and its registered command. `test:e2e:obsidian:joined-devices-object-storage` runs the same assertions against a unique Object Storage prefix, including the conditional removal supported by the test S3 service. It checks that the current device appears first with current metadata, reviews locked-out and other-device records, confirms a manual refresh reads an updated remote record, and verifies that cancelling the inline confirmation leaves the remote unchanged before forgetting one device. It verifies the raw milestone retains the other device, remote state, and preferred-ID tweaks, then checks mobile scrolling, safe-area bounds, and touch targets. It captures the version Notice, desktop list, forget confirmation, completed removal, and mobile dialogue; unrelated Notices are dismissed before the list captures. `test:e2e:obsidian:joined-devices` uses a temporary Vault and isolated CouchDB database to open Joined devices from Remote Configuration, a version Notice, and its registered command. `test:e2e:obsidian:joined-devices-object-storage` runs the same assertions against a unique Object Storage prefix, including the conditional removal supported by the test S3 service. It checks that newer and older Self-hosted LiveSync versions share one Notice with one recommendation and review action, that the current device appears first with current metadata, and that locked-out and other-device records are shown. It confirms that a manual refresh reads an updated remote record and that cancelling the inline confirmation leaves the remote unchanged before forgetting one device. It verifies the raw milestone retains the other device, remote state, and preferred-ID tweaks, then checks mobile scrolling, safe-area bounds, and touch targets. It captures the version Notice, desktop list, forget confirmation, completed removal, and mobile dialogue; unrelated Notices are dismissed before the list captures.
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour. `test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
+20 -7
View File
@@ -482,13 +482,27 @@ async function main(): Promise<void> {
const dialoguePage = dialogue.page(); const dialoguePage = dialogue.page();
await dialoguePage.setViewportSize({ width: 1280, height: 1100 }); await dialoguePage.setViewportSize({ width: 1280, height: 1100 });
await assertInitialDialogue(dialogue, self, dialoguePage); await assertInitialDialogue(dialogue, self, dialoguePage);
await dialoguePage const notice = dialoguePage.locator(".notice").filter({
.locator(".notice") hasText: "Newer Self-hosted LiveSync versions detected: 999.0.0.",
.getByText( });
"A newer plug-in version was detected in other device records: 999.0.0. Reviewing and aligning the plug-in versions across devices is recommended.", await notice.waitFor({ state: "visible", timeout: uiTimeoutMs });
{ exact: true } await notice
) .getByText("Older Self-hosted LiveSync versions detected: 0.1.0.", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs }); .waitFor({ state: "visible", timeout: uiTimeoutMs });
assertEqual(
await notice
.getByText("Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.", {
exact: true,
})
.count(),
1,
"The version Notice did not contain exactly one recommendation."
);
assertEqual(
await notice.getByRole("button", { name: "Review devices", exact: true }).count(),
1,
"The version Notice did not contain exactly one shared review action."
);
return dialogue; return dialogue;
}); });
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-desktop.png", async (page) => { await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-desktop.png", async (page) => {
@@ -498,7 +512,6 @@ async function main(): Promise<void> {
.page() .page()
.locator(".notice") .locator(".notice")
.getByRole("button", { name: "Review devices", exact: true }) .getByRole("button", { name: "Review devices", exact: true })
.first()
.click(); .click();
const reopened = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs); const reopened = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await reopened.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); await reopened.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
+1 -1
View File
@@ -29,7 +29,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
- We can choose **Review devices** in Remote Configuration, the command palette, or a version Notice to review participating devices. - We can choose **Review devices** in Remote Configuration, the command palette, or a version Notice to review participating devices.
- Our device appears first, with its lockout state. Other records show the platform, plug-in version, app version, and last recorded connection time when available. - Our device appears first, with its lockout state. Other records show the platform, plug-in version, app version, and last recorded connection time when available.
- Advisory Notices highlight newer or older plug-in versions detected in device records and recommend reviewing and aligning our versions. The dialogue explains possible storage costs and P2P compatibility limits. - An advisory Notice identifies Self-hosted LiveSync and lists newer and older versions separately, with one shared recommendation and **Review devices** action. The dialogue explains possible storage costs and P2P compatibility limits.
- We can forget records belonging to lost or retired devices while preserving the remote lock state and other devices. - We can forget records belonging to lost or retired devices while preserving the remote lock state and other devices.
- Forgetting a record does not delete Vault files or revoke credentials. A returning device can register again on an unlocked remote. - Forgetting a record does not delete Vault files or revoke credentials. A returning device can register again on an unlocked remote.