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
@@ -7,6 +7,60 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.":
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
"Setup Complete: Preparing to Fetch from Another Device":
"Setup Complete: Preparing to Fetch from Another Device",
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.":
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.",
"Restart this device, then choose the source device when P2P Rebuild opens.":
"Restart this device, then choose the source device when P2P Rebuild opens.",
"Restart and Select Source Device": "Restart and Select Source Device",
"P2P Status pane": "P2P Status pane",
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited.":
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited.",
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery.":
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery.",
"Signalling relay URLs": "Signalling relay URLs",
"Peer discovery uses Nostr-compatible signalling relays.":
"Peer discovery uses Nostr-compatible signalling relays.",
"Use the project's public signalling relay": "Use the project's public signalling relay",
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.":
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.",
"Learn more about P2P connections": "Learn more about P2P connections",
"Learn more about signalling and TURN": "Learn more about signalling and TURN",
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.":
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.",
"Announce changes": "Announce changes",
"Announce changes automatically after connecting": "Announce changes automatically after connecting",
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection.":
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection.",
"Stop announcing changes": "Stop announcing changes",
"Start announcing changes": "Start announcing changes",
"Follow changes": "Follow changes",
"Stop following changes from this device": "Stop following changes from this device",
"Follow changes from this device": "Follow changes from this device",
"Synchronise when this device connects": "Synchronise when this device connects",
"Follow whenever this device connects": "Follow whenever this device connects",
"Include in the P2P synchronisation command": "Include in the P2P synchronisation command",
"More actions for ${DEVICE}": "More actions for ${DEVICE}",
"Create or connect to database and continue": "Create or connect to database and continue",
"Connect to existing database and continue": "Connect to existing database and continue",
"Test connection and save": "Test connection and save",
"Save without connecting": "Save without connecting",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"CouchDB validates the database name when you connect. The name must not be empty.":
"CouchDB validates the database name when you connect. The name must not be empty.",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.":
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.":
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
"Check server requirements": "Check server requirements",
"Change CouchDB server setting": "Change CouchDB server setting",
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?":
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?",
"This file has unresolved conflicts.": "This file has unresolved conflicts.",
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.":
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.",
+17
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { englishMessageTranslator } from "@vrtmrz/livesync-commonlib/context";
import { $msg, setLang, translateLiveSyncMessage } from "@/common/translation";
import { SUPPORTED_I18N_LANGS } from "@/common/rosetta";
import { liveSyncProvisionalEnglishMessages } from "@/common/messages/LiveSyncProvisionalMessages";
describe("LiveSync-owned translation catalogue", () => {
afterEach(() => setLang("def"));
@@ -28,6 +29,7 @@ describe("LiveSync-owned translation catalogue", () => {
it("uses LiveSync-owned provisional English without extending Commonlib's message contract", () => {
expect($msg("This file has unresolved conflicts.")).toBe("This file has unresolved conflicts.");
expect($msg("More actions for ${DEVICE}", { DEVICE: "phone" })).toBe("More actions for phone");
expect(
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: "3",
@@ -37,4 +39,19 @@ describe("LiveSync-owned translation catalogue", () => {
"This file has unresolved conflicts."
);
});
it("keeps the additional-device P2P Fetch explanation in the LiveSync-owned provisional catalogue", () => {
expect(liveSyncProvisionalEnglishMessages).toMatchObject({
"Setup Complete: Preparing to Fetch from Another Device":
"Setup Complete: Preparing to Fetch from Another Device",
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.":
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.",
"Restart this device, then choose the source device when P2P Rebuild opens.":
"Restart this device, then choose the source device when P2P Rebuild opens.",
"Restart and Select Source Device": "Restart and Select Source Device",
"P2P Status pane": "P2P Status pane",
});
});
});
@@ -163,4 +163,16 @@ describe("createOpenRebuildUI", () => {
expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true);
expect(replicator.clearOnSetup).toHaveBeenCalledOnce();
});
it("does not complete Fetch when the rebuild dialogue closes without selecting a peer", async () => {
const replicator = createReplicator();
const session = createOpenRebuildUI({} as any)(replicator)(true);
const modal = modalState.instances[0];
modal.onClosed?.();
await expect(session).resolves.toBe(false);
expect(replicator.replicateFrom).not.toHaveBeenCalled();
expect(replicator.setOnSetup).not.toHaveBeenCalled();
});
});
@@ -13,6 +13,7 @@
import type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { $msg as translateMessage } from "@/common/translation";
interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
@@ -134,15 +135,16 @@
{#if showBroadcastToggle}
<div class="status-item status-action broadcast-row">
<!-- Live-push to peers: stream this device's changes to connected peers for LiveSync -->
<label class="broadcast-label" for="broadcast-toggle">
Live-push to peers
{translateMessage("Announce changes")}
</label>
<button
id="broadcast-toggle"
class="broadcast-button {isBroadcasting ? 'is-on' : 'is-off'}"
onclick={toggleBroadcast}
title={isBroadcasting ? 'Pushing changes to peers — click to stop' : 'Start pushing changes to peers'}
title={isBroadcasting
? translateMessage("Stop announcing changes")
: translateMessage("Start announcing changes")}
>
{isBroadcasting ? '📡 On' : '📡 Off'}
</button>
@@ -24,6 +24,13 @@
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { SetupManager } from "@/modules/features/SetupManager";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import { Menu } from "@/deps";
import { $msg as translateMessage } from "@/common/translation";
import {
hasExactP2PPeer,
togglePersistedP2PPeer,
type PersistedP2PPeerSetting,
} from "./p2pPeerSettings";
interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
@@ -40,7 +47,6 @@
// Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time.
const readCurrentSettings = () => core.services.setting.currentSettings();
const initialSettings = readCurrentSettings();
let syncOnReplicationSetting = $state(initialSettings?.P2P_SyncOnReplication ?? "");
type P2PRemoteOption = {
id: string;
name: string;
@@ -50,21 +56,7 @@
let selectedP2PRemoteConfigurationId = $state(initialSettings?.P2P_ActiveRemoteConfigurationId ?? "");
let selectingP2PRemote = $state(false);
function addToList(item: string, list: string): string {
const items = list
.split(",")
.map((e) => e.trim())
.filter((e) => e);
if (!items.includes(item)) items.push(item);
return items.join(",");
}
function removeFromList(item: string, list: string): string {
return list
.split(",")
.map((e) => e.trim())
.filter((e) => e && e !== item)
.join(",");
}
let peerMenu: Menu | undefined;
function markCommunicating(peerId: string) {
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
@@ -154,7 +146,6 @@
});
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
});
const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
@@ -174,6 +165,7 @@
unsubscribeReplicatorProgress();
unsubscribeSettings();
unsubscribeLayoutReady();
peerMenu?.hide();
};
});
@@ -206,8 +198,6 @@
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
} finally {
selectingP2PRemote = false;
@@ -252,8 +242,6 @@
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
}
@@ -299,7 +287,6 @@
const activated = activateP2PRemoteConfiguration(settings, selectedId);
return activated || settings;
}, true);
syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
}
async function makeDecision(
@@ -374,23 +361,49 @@
return isLiveCommunicating || isHeldCommunicating;
}
function isSyncTarget(peerName: string) {
return syncOnReplicationSetting
.split(",")
.map((e) => e.trim())
.filter((e) => e)
.includes(peerName);
function isPersistedPeerSettingEnabled(setting: PersistedP2PPeerSetting, peerName: string) {
const settings = core.services.setting.currentSettings();
return hasExactP2PPeer(settings[setting] ?? "", peerName);
}
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
async function togglePersistedPeerSetting(
peer: P2PServerInfo["knownAdvertisements"][number],
setting: PersistedP2PPeerSetting
) {
if (!canEditP2PSettings()) {
return;
}
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
const newValue = isSyncTarget(peer.name)
? removeFromList(peer.name, currentValue)
: addToList(peer.name, currentValue);
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue });
const currentSettings = core.services.setting.currentSettings();
await updateSelectedP2PRemote(togglePersistedP2PPeer(currentSettings, setting, peer.name));
}
function openPeerMenu(event: MouseEvent, peer: P2PServerInfo["knownAdvertisements"][number]) {
peerMenu?.hide();
peerMenu = new Menu()
.addItem((item) => {
item.setTitle(translateMessage("Synchronise when this device connects"))
.setChecked(isPersistedPeerSettingEnabled("P2P_AutoSyncPeers", peer.name))
.onClick(() => {
void togglePersistedPeerSetting(peer, "P2P_AutoSyncPeers");
});
})
.addItem((item) => {
item.setTitle(translateMessage("Follow whenever this device connects"))
.setChecked(isPersistedPeerSettingEnabled("P2P_AutoWatchPeers", peer.name))
.onClick(() => {
void togglePersistedPeerSetting(peer, "P2P_AutoWatchPeers");
});
})
.addItem((item) => {
item.setTitle(translateMessage("Include in the P2P synchronisation command"))
.setChecked(isPersistedPeerSettingEnabled("P2P_SyncOnReplication", peer.name))
.onClick(() => {
void togglePersistedPeerSetting(peer, "P2P_SyncOnReplication");
});
});
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
peerMenu.showAtPosition({ x: rect.left, y: rect.bottom });
}
</script>
@@ -486,35 +499,34 @@
>
Revoke
</button>
<button
class="emoji-button"
title={translateMessage("More actions for ${DEVICE}", { DEVICE: peer.name })}
aria-label={translateMessage("More actions for ${DEVICE}", {
DEVICE: peer.name,
})}
disabled={!canEditP2PSettings()}
onclick={(event) => openPeerMenu(event, peer)}
>
</button>
</div>
<div class="decision-row watch-row">
<span class="decision-label">WATCH</span>
<span class="decision-label">{translateMessage("Follow changes")}</span>
<button
class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}"
title={isWatching(peer.peerId)
? "Watching this peer \u2014 click to stop"
: "Watch this peer's changes"}
aria-label={isWatching(peer.peerId) ? "Stop watching" : "Watch peer"}
? translateMessage("Stop following changes from this device")
: translateMessage("Follow changes from this device")}
aria-label={isWatching(peer.peerId)
? translateMessage("Stop following changes from this device")
: translateMessage("Follow changes from this device")}
disabled={!canEditP2PSettings()}
onclick={() => toggleWatch(peer.peerId)}
>
{isWatching(peer.peerId) ? "🔔" : "🔕"}
</button>
</div>
<div class="decision-row watch-row">
<span class="decision-label">SYNC</span>
<button
class="emoji-button {isSyncTarget(peer.name) ? 'is-watching' : ''}"
title={isSyncTarget(peer.name)
? "Sync target \u2014 click to remove"
: "Set as sync target"}
aria-label={isSyncTarget(peer.name) ? "Remove sync target" : "Set sync target"}
disabled={!canEditP2PSettings()}
onclick={() => toggleSyncTarget(peer)}
>
{isSyncTarget(peer.name) ? "🔗" : "⛓️‍💥"}
</button>
</div>
{:else}
<div class="decision-status">
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
@@ -805,7 +817,7 @@
}
.accepted-row {
grid-template-columns: 1fr auto auto;
grid-template-columns: 1fr auto auto auto;
}
.decision-label {
@@ -0,0 +1,38 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
export type PersistedP2PPeerSetting =
| "P2P_AutoSyncPeers"
| "P2P_AutoWatchPeers"
| "P2P_SyncOnReplication";
function splitPeerSetting(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter((item) => item !== "");
}
export function hasExactP2PPeer(value: string, peerName: string): boolean {
return splitPeerSetting(value).includes(peerName);
}
export function toggleExactP2PPeer(value: string, peerName: string): string {
const items = splitPeerSetting(value);
const existingIndex = items.indexOf(peerName);
if (existingIndex >= 0) {
items.splice(existingIndex, 1);
} else {
items.push(peerName);
}
return [...new Set(items)].join(",");
}
export function togglePersistedP2PPeer(
settings: ObsidianLiveSyncSettings,
setting: PersistedP2PPeerSetting,
peerName: string
): Partial<ObsidianLiveSyncSettings> {
return {
[setting]: toggleExactP2PPeer(settings[setting] ?? "", peerName),
};
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import {
hasExactP2PPeer,
toggleExactP2PPeer,
togglePersistedP2PPeer,
} from "./p2pPeerSettings";
describe("persisted P2P peer controls", () => {
it("adds and removes only the selected peer without disturbing advanced patterns", () => {
const original = "~^phone-,desktop";
const added = toggleExactP2PPeer(original, "phone-main");
expect(added).toBe("~^phone-,desktop,phone-main");
expect(hasExactP2PPeer(added, "phone-main")).toBe(true);
const removed = toggleExactP2PPeer(added, "phone-main");
expect(removed).toBe(original);
});
it.each([
"P2P_AutoSyncPeers",
"P2P_AutoWatchPeers",
"P2P_SyncOnReplication",
] as const)("updates %s through the same profile-backed setting boundary", (setting) => {
const settings = {
P2P_AutoSyncPeers: "",
P2P_AutoWatchPeers: "",
P2P_SyncOnReplication: "",
} as ObsidianLiveSyncSettings;
expect(togglePersistedP2PPeer(settings, setting, "peer-a")).toEqual({
[setting]: "peer-a",
});
});
});
@@ -29,6 +29,10 @@ import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svel
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import type {
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes.ts";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
@@ -216,7 +220,13 @@ export function paneRemoteConfig(
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
}
const couchConf = await dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, baseSettings);
const couchConf = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: baseSettings,
mode: "settings",
});
if (couchConf === "cancelled" || typeof couchConf !== "object") {
return false;
}
+11 -3
View File
@@ -1,6 +1,5 @@
import {
type BucketSyncSetting,
type CouchDBConnection,
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
@@ -34,6 +33,7 @@ import type {
ScanQRCodeResultType,
SetupRemoteBucketResultType,
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType,
SetupRemoteP2PResultType,
SetupRemoteResultType,
@@ -171,8 +171,16 @@ export class SetupManager extends AbstractModule {
): Promise<boolean> {
const couchConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
CouchDBConnection
>(SetupRemoteCouchDB, currentSetting);
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: currentSetting,
mode:
userMode === UserMode.NewUser
? "create-or-connect"
: userMode === UserMode.ExistingUser
? "connect-existing"
: "settings",
});
if (couchConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
@@ -348,6 +348,39 @@ describe("SetupManager", () => {
expect(activeProfile?.uri).toContain("sls+https://alice:secret@couch.example");
});
it.each([
[UserMode.NewUser, "create-or-connect"],
[UserMode.ExistingUser, "connect-existing"],
[UserMode.Update, "settings"],
] as const)(
"passes the %s CouchDB database policy to the manual setup dialogue",
async (userMode, expectedMode) => {
const { manager, setting, dialogManager } = createSetupManager();
const couchConf = {
couchDB_URI: "https://couch.example",
couchDB_USER: "alice",
couchDB_PASSWORD: "secret",
couchDB_DBNAME: "notes",
couchDB_CustomHeaders: "",
useJWT: false,
jwtAlgorithm: "",
jwtKey: "",
jwtKid: "",
jwtSub: "",
jwtExpDuration: 5,
useRequestAPI: false,
};
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(couchConf).mockResolvedValueOnce("cancelled");
await manager.onCouchDBManualSetup(userMode, setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(1, expect.anything(), {
settings: setting.currentSettings(),
mode: expectedMode,
});
}
);
it("adds and activates a manually configured Object Storage profile without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
@@ -1,6 +1,7 @@
<script lang="ts">
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
@@ -8,6 +9,7 @@
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { TYPE_NEW_USER, TYPE_EXISTING_USER, TYPE_CANCELLED, type IntroResultType } from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
type Props = {
setResult: (result: IntroResultType) => void;
@@ -30,6 +32,11 @@
<DialogHeader title="Welcome to Self-hosted LiveSync" />
<Guidance>We will now guide you through a few questions to simplify the synchronisation setup.</Guidance>
<InfoNote>
{translateMessage(
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI."
)}
</InfoNote>
<Instruction>
<Question>First, please select the option that best describes your current situation.</Question>
<Options>
@@ -5,32 +5,66 @@
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import { TYPE_CANCELLED, TYPE_APPLY, type OutroExistingUserResultType } from "./setupDialogTypes";
type Props = {
setResult: (result: OutroExistingUserResultType) => void;
getInitialData?: () => { isP2P?: boolean } | undefined;
};
const { setResult }: Props = $props();
const { setResult, getInitialData }: Props = $props();
const isP2P = $derived(getInitialData?.()?.isP2P === true);
</script>
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" />
<Guidance>
<p>
The connection to the server has been configured successfully. As the next step, <strong
>the latest synchronisation data will be downloaded from the server to this device.</strong
>
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised
files in this vault, conflicts may occur with the server data.
</p>
</Guidance>
<Instruction>
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question>
</Instruction>
<UserDecisions>
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{#if isP2P}
<DialogHeader title={translateMessage("Setup Complete: Preparing to Fetch from Another Device")} />
<Guidance>
<p>
{translateMessage(
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device."
)}
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
{translateMessage(
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data."
)}
</p>
</Guidance>
<Instruction>
<Question>
{translateMessage("Restart this device, then choose the source device when P2P Rebuild opens.")}
</Question>
</Instruction>
<UserDecisions>
<Decision
title={translateMessage("Restart and Select Source Device")}
important={true}
commit={() => setResult(TYPE_APPLY)}
/>
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{:else}
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" />
<Guidance>
<p>
The connection to the server has been configured successfully. As the next step, <strong
>the latest synchronisation data will be downloaded from the server to this device.</strong
>
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised
files in this vault, conflicts may occur with the server data.
</p>
</Guidance>
<Instruction>
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question>
</Instruction>
<UserDecisions>
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{/if}
@@ -7,10 +7,14 @@
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB";
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
import { $msg as translateMessage } from "@/common/translation";
import { getDialogContext } from "@/modules/services/LiveSyncUI/svelteDialog";
import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation";
type Props = {
trialRemoteSetting: ObsidianLiveSyncSettings;
};
const { trialRemoteSetting }: Props = $props();
const context = getDialogContext();
let detectedIssues = $state<ConfigCheckResult[]>([]);
async function testAndFixSettings() {
detectedIssues = [];
@@ -33,14 +37,23 @@
}
let processing = $state(false);
async function fixIssue(issue: ResultError<unknown>) {
const confirmation = getCouchDBServerFixConfirmation(issue.settingKey, issue.expectedValue);
const confirmed = await context.services.confirm.askYesNoDialog(confirmation.message, {
title: confirmation.title,
defaultOption: "No",
});
if (confirmed !== "yes") {
return;
}
try {
processing = true;
await issue.fix();
} catch (e) {
Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-fix");
} finally {
await testAndFixSettings();
processing = false;
}
await testAndFixSettings();
processing = false;
}
const errorIssueCount = $derived.by(() => {
return detectedIssues.filter((issue) => isErrorResult(issue)).length;
@@ -64,7 +77,7 @@
</div>
{/snippet}
<UserDecisions>
<Decision title="Detect and Fix CouchDB Issues" important={true} commit={testAndFixSettings} />
<Decision title={translateMessage("Check server requirements")} important={true} commit={testAndFixSettings} />
</UserDecisions>
<div class="check-results">
<details open={!isAllSuccess}>
@@ -7,6 +7,7 @@
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import {
TYPE_USE_SETUP_URI,
TYPE_CONFIGURE_MANUALLY,
@@ -49,7 +50,10 @@
>
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
You can also select this option if you intend to use <strong>P2P (Peer-to-Peer) synchronisation</strong>
instead of a CouchDB/S3 server — P2P requires no server setup at all.
instead of a CouchDB/S3 server.
{translateMessage(
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery."
)}
</Option>
</Options>
</Instruction>
@@ -6,6 +6,7 @@
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import {
TYPE_COUCHDB,
TYPE_BUCKET,
@@ -47,9 +48,9 @@
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.
</Option>
<Option selectedValue={TYPE_P2P} title="Peer-to-Peer only" bind:value={userType}>
This feature enables direct synchronisation between devices. No server is required, but both devices must be
online at the same time for synchronisation to occur, and some features may be limited. Internet connection
is only required to signalling (detecting peers) and not for data transfer.
{translateMessage(
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
)}
</Option>
</Options>
</Instruction>
@@ -21,18 +21,27 @@
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { copyTo, pickCouchDBSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import PanelCouchDBCheck from "./PanelCouchDBCheck.svelte";
import { TYPE_CANCELLED, type SetupRemoteCouchDBResultType } from "./setupDialogTypes";
import {
TYPE_CANCELLED,
type CouchDBSetupMode,
type SetupRemoteCouchDBInitialData,
type SetupRemoteCouchDBResultType,
} from "./setupDialogTypes";
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
import { $msg as translateMessage } from "@/common/translation";
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<CouchDBConnection>({ ...default_setting });
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, CouchDBConnection>;
let setupMode = $state<CouchDBSetupMode>("settings");
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, SetupRemoteCouchDBInitialData>;
const { setResult, getInitialData }: Props = $props();
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, syncSetting);
setupMode = initialData.mode;
copyTo(initialData.settings, syncSetting);
}
}
});
@@ -69,11 +78,15 @@
return "Failed to create replicator instance.";
}
try {
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
if (result) {
const result = await probeCouchDBConnection(
replicator,
trialRemoteSetting,
setupMode === "create-or-connect"
);
if (result.ok) {
return "";
} else {
return "Failed to connect to the server. Please check your settings.";
return `Failed to connect to the server: ${result.reason}`;
}
} catch (e) {
return `Failed to connect to the server: ${e}`;
@@ -122,7 +135,7 @@
});
const canProceed = $derived.by(() => {
return (
syncSetting.couchDB_URI.trim().length > 0 &&
isValidCouchDBServerURL(syncSetting.couchDB_URI.trim()) &&
syncSetting.couchDB_USER.trim().length > 0 &&
syncSetting.couchDB_PASSWORD.trim().length > 0 &&
syncSetting.couchDB_DBNAME.trim().length > 0 &&
@@ -132,6 +145,18 @@
const testSettings = $derived.by(() => {
return generateSetting();
});
const isURLInvalid = $derived.by(
() => syncSetting.couchDB_URI.trim() !== "" && !isValidCouchDBServerURL(syncSetting.couchDB_URI.trim())
);
const primaryActionTitle = $derived.by(() => {
if (setupMode === "create-or-connect") {
return translateMessage("Create or connect to database and continue");
}
if (setupMode === "connect-existing") {
return translateMessage("Connect to existing database and continue");
}
return translateMessage("Test connection and save");
});
</script>
<DialogHeader title="CouchDB Configuration" />
@@ -150,6 +175,7 @@
/>
</InputRow>
<InfoNote warning visible={isURIInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote>
<InfoNote warning visible={isURLInvalid}>{translateMessage("Enter a complete HTTP or HTTPS URL.")}</InfoNote>
<InputRow label="Username">
<input
type="text"
@@ -180,13 +206,11 @@
autocapitalize="off"
spellcheck="false"
required
pattern="^[a-z][a-z0-9_$()+/-]*$"
bind:value={syncSetting.couchDB_DBNAME}
/>
</InputRow>
<InfoNote>
You cannot use capital letters, spaces, or special characters in the database name. And not allowed to start with an
underscore (_).
{translateMessage("CouchDB validates the database name when you connect. The name must not be empty.")}
</InfoNote>
<InputRow label="Use Internal API">
<input type="checkbox" name="couchdb-use-internal-api" bind:checked={syncSetting.useRequestAPI} />
@@ -270,6 +294,11 @@
</InfoNote>
</ExtraItems>
<InfoNote warning>
{translateMessage(
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required."
)}
</InfoNote>
<PanelCouchDBCheck trialRemoteSetting={testSettings}></PanelCouchDBCheck>
<hr />
@@ -281,8 +310,19 @@
Checking connection... Please wait.
{:else}
<UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" commit={() => commit()} />
<Decision title={primaryActionTitle} important disabled={!canProceed} commit={() => checkAndCommit()} />
{#if setupMode === "settings"}
<InfoNote warning>
{translateMessage(
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
)}
</InfoNote>
<Decision
title={translateMessage("Save without connecting")}
disabled={!canProceed}
commit={() => commit()}
/>
{/if}
<Decision title="Cancel" commit={() => cancel()} />
</UserDecisions>
{/if}
@@ -33,6 +33,8 @@
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes";
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
import { $msg as translateMessage } from "@/common/translation";
import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
@@ -119,29 +121,15 @@
};
const replicator = new TrysteroReplicator(env);
try {
await replicator.setOnSetup();
await replicator.allowReconnection();
await replicator.open();
for (let i = 0; i < 10; i++) {
// await delay(1000);
await new Promise((resolve) => window.setTimeout(resolve, 1000));
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
if (replicator.knownAdvertisements.length > 0) {
break;
}
}
// context.holdingSettings = trialRemoteSetting;
if (replicator.knownAdvertisements.length === 0) {
return "Your settings seem correct, but no other peers were found.";
const result = await probeP2PSetupConnection(replicator);
if (!result.ok) {
return `Failed to connect to the signalling relay: ${result.reason}`;
}
return "";
} catch (e) {
return `Failed to connect to other peers: ${e}`;
} finally {
try {
replicator.close();
dummyPouch.destroy();
await replicator.close();
await dummyPouch.destroy();
} catch (e) {
Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-cleanup");
}
@@ -195,18 +183,31 @@
<InputRow label="Enabled">
<input type="checkbox" name="p2p-enabled" bind:checked={syncSetting.P2P_Enabled} />
</InputRow>
<InputRow label="Relay URL">
<InputRow label={translateMessage("Signalling relay URLs")}>
<input
type="text"
name="p2p-relay-url"
placeholder="Enter the Relay URL)"
placeholder="wss://relay.example.com"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_relays}
/>
<button class="button" onclick={() => setDefaultRelay()}>Use vrtmrz's relay</button>
<button class="button" onclick={() => setDefaultRelay()}>
{translateMessage("Use the project's public signalling relay")}
</button>
</InputRow>
<InfoNote>
{translateMessage("Peer discovery uses Nostr-compatible signalling relays.")}
{translateMessage(
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay."
)}
<a
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md"
target="_blank"
rel="noopener noreferrer">{translateMessage("Learn more about P2P connections")}</a
>.
</InfoNote>
<InputRow label="Group ID">
<input
type="text"
@@ -245,12 +246,13 @@
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in
launches.
</InfoNote>
<InputRow label="Auto Broadcast Changes">
<InputRow label={translateMessage("Announce changes automatically after connecting")}>
<input type="checkbox" name="p2p-auto-broadcast" bind:checked={syncSetting.P2P_AutoBroadcast} />
</InputRow>
<InfoNote>
If "Auto Broadcast Changes" is enabled, changes will be automatically broadcasted to connected peers without
requiring manual intervention. This requests peers to fetch this device's changes.
{translateMessage(
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection."
)}
</InfoNote>
<ExtraItems title="Advanced Settings">
<InfoNote>
@@ -258,10 +260,14 @@
connections. In most cases, you can leave these fields blank.
</InfoNote>
<InfoNote warning>
Using public TURN servers may have privacy implications, as your data will be relayed through third-party
servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN
server provider before using their services. Also your `network administrator` too. You should consider setting
up your own TURN server for your FQDN, if possible.
{translateMessage(
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust."
)}
<a
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md#signalling-relay-and-turn-server"
target="_blank"
rel="noopener noreferrer">{translateMessage("Learn more about signalling and TURN")}</a
>.
</InfoNote>
<InputRow label="TURN Server URLs (comma-separated)">
<textarea
@@ -0,0 +1,63 @@
import type {
ObsidianLiveSyncSettings,
RemoteDBSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
export type CouchDBConnectionProbeResult = { ok: true } | { ok: false; reason: string };
type CouchDBConnectionResult =
| string
| {
db: unknown;
info: unknown;
};
export interface CouchDBConnectionProbe {
isMobile(): boolean;
connectRemoteCouchDBWithSetting(
settings: RemoteDBSettings,
isMobile: boolean,
performSetup: boolean,
skipInfo: boolean
): CouchDBConnectionResult | Promise<CouchDBConnectionResult>;
}
export function isCouchDBConnectionProbe(value: unknown): value is CouchDBConnectionProbe {
return (
typeof value === "object" &&
value !== null &&
"isMobile" in value &&
typeof value.isMobile === "function" &&
"connectRemoteCouchDBWithSetting" in value &&
typeof value.connectRemoteCouchDBWithSetting === "function"
);
}
export async function probeCouchDBConnection(
replicator: unknown,
settings: ObsidianLiveSyncSettings,
createIfMissing: boolean
): Promise<CouchDBConnectionProbeResult> {
if (!isCouchDBConnectionProbe(replicator)) {
return { ok: false, reason: "The CouchDB connection probe is unavailable." };
}
const result = await replicator.connectRemoteCouchDBWithSetting(
settings,
replicator.isMobile(),
createIfMissing,
false
);
if (typeof result === "string") {
return { ok: false, reason: result };
}
return { ok: true };
}
export function isValidCouchDBServerURL(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
} catch {
return false;
}
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
const settings = {
couchDB_URI: "https://couch.example",
couchDB_DBNAME: "notes",
} as ObsidianLiveSyncSettings;
describe("CouchDB setup connection policy", () => {
it.each([
[false, "connect to an existing database"],
[true, "create or connect to a database"],
] as const)(
"%s can %s without changing the Commonlib connection contract",
async (createIfMissing, _description) => {
const connectRemoteCouchDBWithSetting = vi.fn(async () => ({
db: {},
info: { db_name: "notes" },
}));
const replicator = {
isMobile: vi.fn(() => false),
connectRemoteCouchDBWithSetting,
tryConnectRemote: vi.fn(),
};
await expect(probeCouchDBConnection(replicator, settings, createIfMissing)).resolves.toEqual({ ok: true });
expect(connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(settings, false, createIfMissing, false);
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
}
);
it("returns the connection error without saving or creating through another path", async () => {
const replicator = {
isMobile: vi.fn(() => true),
connectRemoteCouchDBWithSetting: vi.fn(() => "database does not exist"),
};
await expect(probeCouchDBConnection(replicator, settings, false)).resolves.toEqual({
ok: false,
reason: "database does not exist",
});
});
it.each([
["https://couch.example", true],
["http://127.0.0.1:5984", true],
["ftp://couch.example", false],
["couch.example", false],
["https://", false],
])("validates the saved server URL %s", (value, expected) => {
expect(isValidCouchDBServerURL(value)).toBe(expected);
});
});
@@ -0,0 +1,11 @@
import { $msg } from "@/common/translation";
export function getCouchDBServerFixConfirmation(settingKey: string, expectedValue: string) {
return {
title: $msg("Change CouchDB server setting"),
message: $msg("Change CouchDB server setting '${SETTING}' to '${VALUE}'?", {
SETTING: settingKey,
VALUE: expectedValue,
}),
};
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation";
describe("CouchDB server requirement fixes", () => {
it("identifies the exact server setting and value before a fix is applied", () => {
expect(getCouchDBServerFixConfirmation("chttpd/require_valid_user", "true")).toEqual({
title: "Change CouchDB server setting",
message: "Change CouchDB server setting 'chttpd/require_valid_user' to 'true'?",
});
});
});
@@ -0,0 +1,23 @@
export type P2PSetupConnectionProbeResult = { ok: true } | { ok: false; reason: string };
export interface P2PSetupConnectionProbe {
setOnSetup(): void | Promise<void>;
allowReconnection(): void | Promise<void>;
open(): Promise<void>;
}
export async function probeP2PSetupConnection(
replicator: P2PSetupConnectionProbe
): Promise<P2PSetupConnectionProbeResult> {
try {
await replicator.setOnSetup();
await replicator.allowReconnection();
await replicator.open();
return { ok: true };
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
describe("P2P setup connection probe", () => {
it("accepts an empty room after the signalling connection opens", async () => {
const replicator = {
knownAdvertisements: [],
setOnSetup: vi.fn(),
allowReconnection: vi.fn(),
open: vi.fn(async () => undefined),
};
await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({ ok: true });
expect(replicator.setOnSetup).toHaveBeenCalledOnce();
expect(replicator.allowReconnection).toHaveBeenCalledOnce();
expect(replicator.open).toHaveBeenCalledOnce();
});
it("reports a signalling connection failure", async () => {
const replicator = {
knownAdvertisements: [],
setOnSetup: vi.fn(),
allowReconnection: vi.fn(),
open: vi.fn(async () => {
throw new Error("relay unavailable");
}),
};
await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({
ok: false,
reason: "relay unavailable",
});
});
});
@@ -102,6 +102,11 @@ export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettin
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings";
export type SetupRemoteCouchDBInitialData = {
settings: CouchDBConnection;
mode: CouchDBSetupMode;
};
export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo;
@@ -12,7 +12,15 @@ import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
export type ResultMessage = { message: string; classes: string[] };
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
export type ResultOk<T> = { message: string; result: "ok"; value?: T };
export type ResultError<T> = { message: string; result: "error"; value: T; fixMessage: string; fix(): Promise<void> };
export type ResultError<T> = {
message: string;
result: "error";
value: T;
fixMessage: string;
settingKey: string;
expectedValue: string;
fix(): Promise<void>;
};
export type ConfigCheckResult<T = unknown, U = unknown> =
| ResultOk<T>
| ResultError<U>
@@ -79,8 +87,15 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
const addSuccess = <T>(msg: string, value?: T) => {
result.push({ message: msg, result: "ok", value });
};
const _addError = <T>(message: string, fixMessage: string, fix: () => Promise<void>, value?: T) => {
result.push({ message, result: "error", fixMessage, fix, value });
const _addError = <T>(
message: string,
fixMessage: string,
settingKey: string,
expectedValue: string,
fix: () => Promise<void>,
value?: T
) => {
result.push({ message, result: "error", fixMessage, settingKey, expectedValue, fix, value });
};
const addErrorMessage = (msg: string, classes: string[] = []) => {
result.push({ message: msg, result: "error", classes });
@@ -90,6 +105,8 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
_addError(
message,
fixMessage,
key,
expected,
async () => {
await updateRemoteSetting(editingSettings, key, expected);
},
+8 -2
View File
@@ -128,12 +128,18 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect");
// Commonlib owns the transport decision, while LiveSync owns the
// concrete Obsidian view which lets users revise that decision.
const presentedMessage =
opt.title === "P2P Connection Request"
? message.replace("Peer-to-Peer Replicator Pane", $msg("P2P Status pane"))
: message;
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
message: presentedMessage,
actions: buttons,
actionLayout: "vertical",
defaultAction: opt.defaultAction,
@@ -146,7 +152,7 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
return confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
message,
presentedMessage,
buttons,
opt.defaultAction,
opt.timeout
@@ -214,6 +214,31 @@ describe("ObsidianConfirm Fancy Kit adapter", () => {
);
});
it("refers P2P connection approvals to the current P2P Status pane", async () => {
const { confirm, plugin } = createConfirm();
const actions = ["Accept", "Ignore"] as const;
mocks.legacyWideConfirm.mockResolvedValueOnce("Ignore");
await confirm.askSelectStringDialogue(
"You can revoke your decision from the Peer-to-Peer Replicator Pane.",
actions,
{
title: "P2P Connection Request",
defaultAction: "Ignore",
timeout: 30,
}
);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
"P2P Connection Request",
"You can revoke your decision from the P2P Status pane.",
actions,
"Ignore",
30
);
});
it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => {
const { confirm, events } = createConfirm();
let observedSignal: AbortSignal | undefined;
+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();
});
});