Refine P2P and manual setup workflows

This commit is contained in:
vorotamoroz
2026-07-23 15:23:47 +00:00
parent cd35858e01
commit 1df034f12a
60 changed files with 1884 additions and 770 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ export async function askSimpleFetchMode(
const msg = `We are about to retrieve the remote data.
Firstly, how shall we handle the data retrieved from this remote server?
Firstly, how shall we handle the data retrieved from this remote source?
- **${SIMPLE_FETCH_STAGE1_NEWER_WINS}**: Compares the modified time of files and takes the newer one.
If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time.
+3
View File
@@ -469,6 +469,9 @@ describe("Red Flag Feature", () => {
expect(result).toBe(true);
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalled();
expect(synchroniseAllFilesBetweenDBandStorage).toHaveBeenCalled();
const firstPrompt = host.mocks.ui.confirm.confirmWithMessage.mock.calls[0]?.[1];
expect(firstPrompt).toContain("data retrieved from this remote source");
expect(firstPrompt).not.toContain("remote server");
// We can't easily check performFullScan call here because it's imported,
// but we can verify rebuilder was called.
});
+84 -35
View File
@@ -3,7 +3,6 @@ import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView";
import {
P2PServerStatusPaneView,
VIEW_TYPE_P2P_SERVER_STATUS,
@@ -11,6 +10,34 @@ import {
import type { LiveSyncCore } from "@/main";
import type { WorkspaceLeaf } from "@/deps";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
export const LEGACY_VIEW_TYPE_P2P = "p2p-replicator";
class LegacyP2PStatusPaneView extends P2PServerStatusPaneView {
override getViewType() {
return LEGACY_VIEW_TYPE_P2P;
}
}
export function hasP2PConfiguration(settings: Partial<ObsidianLiveSyncSettings>): boolean {
if (
settings.remoteType === REMOTE_P2P ||
settings.P2P_Enabled === true ||
(settings.P2P_roomID ?? "").trim() !== "" ||
(settings.P2P_passphrase ?? "").trim() !== ""
) {
return true;
}
return Object.values(settings.remoteConfigurations ?? {}).some((configuration) => {
try {
return ConnectionStringParser.parse(configuration.uri).type === "p2p";
} catch {
return false;
}
});
}
/**
* Obsidian-specific P2P views, commands, status collection, and ribbon wiring.
@@ -45,8 +72,7 @@ export function useP2PReplicatorUI(
icon: string,
title: string,
callback: () => void
) => { addClass?: (name: string) => unknown } | undefined;
getPlatform: () => string;
) => { addClass?: (name: string) => unknown; remove?: () => void } | undefined;
};
// const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any };
@@ -64,15 +90,12 @@ export function useP2PReplicatorUI(
storeP2PStatusLine,
};
// Register view, commands and ribbon if a view factory is provided
const viewType = VIEW_TYPE_P2P;
const factory = (leaf: WorkspaceLeaf) => {
return new P2PReplicatorPaneView(leaf, core, p2pParams);
};
const statusFactory = (leaf: WorkspaceLeaf) => {
return new P2PServerStatusPaneView(leaf, core, p2pParams);
};
const openPane = () => api.showWindow(viewType);
const legacyStatusFactory = (leaf: WorkspaceLeaf) => {
return new LegacyP2PStatusPaneView(leaf, core, p2pParams);
};
const openStatusPane = () => {
if (api.showWindowOnRight) {
return api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
@@ -88,20 +111,36 @@ export function useP2PReplicatorUI(
{ label: "replication" }
);
};
api.registerWindow(viewType, factory);
// Keep the retired view type registered only long enough to restore an
// existing workspace leaf with the current status UI. Layout-ready
// migration below rewrites it to the current type without opening a leaf.
api.registerWindow(LEGACY_VIEW_TYPE_P2P, legacyStatusFactory);
api.registerWindow(VIEW_TYPE_P2P_SERVER_STATUS, statusFactory);
let ribbonElement: { addClass?: (name: string) => unknown; remove?: () => void } | undefined;
const updateRibbon = (settings: Partial<ObsidianLiveSyncSettings>) => {
if (hasP2PConfiguration(settings)) {
if (ribbonElement) return;
ribbonElement = api.addRibbonIcon("waypoints", "P2P Status", () => {
void openStatusPane();
});
ribbonElement?.addClass?.("livesync-ribbon-p2p-server-status");
return;
}
ribbonElement?.remove?.();
ribbonElement = undefined;
};
// Settings are loaded after onInitialise. Reading them from the earlier
// phase aborts the plug-in lifecycle before the local database can open.
host.services.appLifecycle.onSettingLoaded.addHandler(() => {
updateRibbon(host.services.setting.currentSettings());
return Promise.resolve(true);
});
host.services.appLifecycle.onInitialise.addHandler(() => {
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => {
void openPane();
});
api.addCommand({
id: "open-p2p-replicator",
name: "P2P Sync : Open P2P Replicator (Old UI)",
callback: () => {
void openPane();
},
void openStatusPane();
});
api.addCommand({
@@ -147,27 +186,37 @@ export function useP2PReplicatorUI(
},
});
// api.addRibbonIcon("waypoints", "P2P Replicator", () => {
// void openPane();
// })?.addClass?.("livesync-ribbon-replicate-p2p");
api.addRibbonIcon("waypoints", "P2P Status", () => {
void openStatusPane();
})?.addClass?.("livesync-ribbon-p2p-server-status");
host.services.setting.onSettingSaved?.addHandler((settings) => {
updateRibbon(settings);
return Promise.resolve(true);
});
return Promise.resolve(true);
});
host.services.appLifecycle.onLayoutReady.addHandler(() => {
if (api.getPlatform() !== "obsidian") {
return Promise.resolve(true);
host.services.appLifecycle.onLayoutReady.addHandler(async () => {
const workspace = (
host.services.context as {
app?: {
workspace?: {
getLeavesOfType(type: string): WorkspaceLeaf[];
};
};
}
).app?.workspace;
if (!workspace) {
return true;
}
if (api.showWindowOnRight) {
void api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
} else {
void api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS);
}
return Promise.resolve(true);
const legacyLeaves = workspace.getLeavesOfType(LEGACY_VIEW_TYPE_P2P);
await Promise.all(
legacyLeaves.map((leaf) =>
leaf.setViewState({
type: VIEW_TYPE_P2P_SERVER_STATUS,
active: false,
})
)
);
return true;
});
return p2pParams;
}
@@ -1,18 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { eventHub, EVENT_REQUEST_OPEN_P2P } from "@/common/events";
vi.mock("@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView", () => ({
P2PReplicatorPaneView: class {},
VIEW_TYPE_P2P: "p2p",
}));
vi.mock("@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView", () => ({
P2PServerStatusPaneView: class {},
P2PServerStatusPaneView: class {
getViewType() {
return "p2p-status";
}
},
VIEW_TYPE_P2P_SERVER_STATUS: "p2p-status",
}));
import { useP2PReplicatorUI } from "./useP2PReplicatorUI";
describe("useP2PReplicatorUI commands", () => {
it("waits for settings to load before deciding whether to show the P2P ribbon", async () => {
let initialise: (() => Promise<unknown>) | undefined;
let settingLoaded: (() => Promise<unknown>) | undefined;
let settings: Record<string, unknown> | undefined;
const currentSettings = vi.fn(() => settings);
const host = {
services: {
context: createServiceContext(),
API: {
showWindow: vi.fn(async () => undefined),
registerWindow: vi.fn(),
addCommand: vi.fn(),
addRibbonIcon: vi.fn(),
},
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler) => {
initialise = handler;
}),
},
onSettingLoaded: {
addHandler: vi.fn((handler) => {
settingLoaded = handler;
}),
},
onLayoutReady: { addHandler: vi.fn() },
},
setting: {
currentSettings,
onSettingSaved: { addHandler: vi.fn() },
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any);
await expect(initialise?.()).resolves.toBe(true);
expect(currentSettings).not.toHaveBeenCalled();
settings = {
remoteType: "COUCHDB",
remoteConfigurations: {},
};
await expect(settingLoaded?.()).resolves.toBe(true);
expect(currentSettings).toHaveBeenCalledOnce();
});
it("exposes a direct modal P2P replication command as finite replication activity", async () => {
const commands: Array<{ id: string; checkCallback?: (isChecking: boolean) => unknown }> = [];
let initialise: (() => Promise<unknown>) | undefined;
@@ -34,6 +82,7 @@ describe("useP2PReplicatorUI commands", () => {
initialise = handler;
}),
},
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: { addHandler: vi.fn() },
},
setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) },
@@ -79,6 +128,7 @@ describe("useP2PReplicatorUI commands", () => {
},
appLifecycle: {
onInitialise: { addHandler: vi.fn() },
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: { addHandler: vi.fn() },
},
setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) },
@@ -91,4 +141,226 @@ describe("useP2PReplicatorUI commands", () => {
expect(paneParams.replicator).toBe(second);
});
it("retains only the current P2P status command and routes existing open requests to it", async () => {
const commands: Array<{ id: string; callback?: () => void }> = [];
let initialise: (() => Promise<unknown>) | undefined;
const showWindow = vi.fn(async () => undefined);
const showWindowOnRight = vi.fn(async () => undefined);
const host = {
services: {
context: createServiceContext(),
API: {
showWindow,
showWindowOnRight,
registerWindow: vi.fn(),
addCommand: vi.fn((command) => commands.push(command)),
addRibbonIcon: vi.fn(),
getPlatform: vi.fn(() => "desktop"),
},
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler) => {
initialise = handler;
}),
},
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: { addHandler: vi.fn() },
},
setting: {
currentSettings: vi.fn(() => ({
remoteType: "COUCHDB",
remoteConfigurations: {},
})),
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
const p2p = { replicator: undefined } as any;
useP2PReplicatorUI(host, {} as any, p2p);
await initialise?.();
expect(commands.map((command) => command.id)).not.toContain("open-p2p-replicator");
expect(commands.map((command) => command.id)).toContain("open-p2p-server-status");
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P);
await vi.waitFor(() => expect(showWindowOnRight).toHaveBeenCalledWith("p2p-status"));
expect(showWindow).not.toHaveBeenCalledWith("p2p");
});
it("does not open the P2P status pane automatically when the workspace becomes ready", async () => {
let layoutReady: (() => Promise<unknown>) | undefined;
const showWindow = vi.fn(async () => undefined);
const showWindowOnRight = vi.fn(async () => undefined);
const host = {
services: {
context: createServiceContext(),
API: {
showWindow,
showWindowOnRight,
registerWindow: vi.fn(),
addCommand: vi.fn(),
addRibbonIcon: vi.fn(),
getPlatform: vi.fn(() => "obsidian"),
},
appLifecycle: {
onInitialise: { addHandler: vi.fn() },
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: {
addHandler: vi.fn((handler) => {
layoutReady = handler;
}),
},
},
setting: {
currentSettings: vi.fn(() => ({
remoteType: "COUCHDB",
remoteConfigurations: {},
})),
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any);
await layoutReady?.();
expect(showWindow).not.toHaveBeenCalled();
expect(showWindowOnRight).not.toHaveBeenCalled();
});
it("shows the ribbon only whilst a P2P configuration exists", async () => {
let initialise: (() => Promise<unknown>) | undefined;
let settingLoaded: (() => Promise<unknown>) | undefined;
let onSettingSaved: ((settings: unknown) => Promise<unknown>) | undefined;
let currentSettings: any = {
remoteType: "COUCHDB",
remoteConfigurations: {},
P2P_Enabled: false,
P2P_roomID: "",
P2P_passphrase: "",
};
const ribbon = { addClass: vi.fn(), remove: vi.fn() };
const addRibbonIcon = vi.fn(() => ribbon);
const host = {
services: {
context: createServiceContext(),
API: {
showWindow: vi.fn(async () => undefined),
showWindowOnRight: vi.fn(async () => undefined),
registerWindow: vi.fn(),
addCommand: vi.fn(),
addRibbonIcon,
getPlatform: vi.fn(() => "desktop"),
},
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler) => {
initialise = handler;
}),
},
onSettingLoaded: {
addHandler: vi.fn((handler) => {
settingLoaded = handler;
}),
},
onLayoutReady: { addHandler: vi.fn() },
},
setting: {
currentSettings: vi.fn(() => currentSettings),
onSettingSaved: {
addHandler: vi.fn((handler) => {
onSettingSaved = handler;
}),
},
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any);
await initialise?.();
await settingLoaded?.();
expect(addRibbonIcon).not.toHaveBeenCalled();
currentSettings = {
...currentSettings,
remoteConfigurations: {
peer: {
id: "peer",
name: "Peer",
uri: "sls+p2p://room?passphrase=secret",
isEncrypted: false,
},
},
};
await onSettingSaved?.(currentSettings);
expect(addRibbonIcon).toHaveBeenCalledOnce();
await onSettingSaved?.(currentSettings);
expect(addRibbonIcon).toHaveBeenCalledOnce();
currentSettings = {
...currentSettings,
remoteConfigurations: {},
};
await onSettingSaved?.(currentSettings);
expect(ribbon.remove).toHaveBeenCalledOnce();
});
it("replaces a restored legacy P2P leaf with the current status view without opening another leaf", async () => {
let layoutReady: (() => Promise<unknown>) | undefined;
const legacyLeaf = {
setViewState: vi.fn(async () => undefined),
};
const workspace = {
getLeavesOfType: vi.fn((type: string) => (type === "p2p-replicator" ? [legacyLeaf] : [])),
};
const context = createServiceContext() as ReturnType<typeof createServiceContext> & {
app: { workspace: typeof workspace };
};
context.app = { workspace };
const showWindow = vi.fn(async () => undefined);
const showWindowOnRight = vi.fn(async () => undefined);
const host = {
services: {
context,
API: {
showWindow,
showWindowOnRight,
registerWindow: vi.fn(),
addCommand: vi.fn(),
addRibbonIcon: vi.fn(),
getPlatform: vi.fn(() => "desktop"),
},
appLifecycle: {
onInitialise: { addHandler: vi.fn() },
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: {
addHandler: vi.fn((handler) => {
layoutReady = handler;
}),
},
},
setting: {
currentSettings: vi.fn(() => ({
remoteType: "COUCHDB",
remoteConfigurations: {},
})),
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any);
await layoutReady?.();
expect(legacyLeaf.setViewState).toHaveBeenCalledWith({
type: "p2p-status",
active: false,
});
expect(showWindow).not.toHaveBeenCalled();
expect(showWindowOnRight).not.toHaveBeenCalled();
});
});