diff --git a/docs/p2p_sync_updates_2026.md b/docs/p2p_sync_updates_2026.md new file mode 100644 index 0000000..5a8f9e4 --- /dev/null +++ b/docs/p2p_sync_updates_2026.md @@ -0,0 +1,42 @@ +# User Guide: Peer-to-Peer Synchronisation (2026 Edition) + +Peer-to-Peer (P2P) synchronisation has evolved significantly. This guide covers the essential setup and the new features introduced in the 2026 updates. + +## 1. Core Concept: Server-less Freedom +P2P synchronisation allows your devices to talk directly to each other using WebRTC. A central server is not required for data storage, ensuring maximum privacy and "freedom." + +## 2. Setting Up via P2P Status Pane +You no longer need to navigate through complex menus. Simply open the **P2P Server Status** (via the ribbon icon or command palette) and click the **⚙ (Cog)** icon. + +This opens the **P2P Setup** dialogue where you can configure the essentials: +- **Room ID:** A unique identifier for your synchronisation group. +- **Password:** Your encryption key. Ensure all your devices use the exact same password. +- **Device Name:** A recognisable name for the current device (e.g., `iphone-16`). + +Once you have saved the settings, return to the **P2P Status Pane** and click the **Connect** button to join the network. + +*Tip: You can also toggle **Auto Connect** in the setup dialogue to automatically join the network whenever Obsidian starts.* + +## 3. Real-time Control +The status pane in the right sidebar provides granular control over your synchronisation: + +- **Signalling Status:** Shows if you are connected to the relay (🟢 Online). +- **Live-push (Broadcast):** Toggle "Broadcast changes" to notify other peers whenever you make an edit. +- **Watch:** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience. + +## 4. Enhanced Replication Dialogue (Bidirectional Sync) +If you want to synchronise manually, click the **🔄 (Replicate)** button next to a peer in the device list. This opens the **Replication Dialogue**. + +Inside the dialogue, you can still see the **Server Status** at the top, so you will know if you are still connected while performing manual synchronisations. + +When you trigger a synchronisation this way, the system now performs a **Bidirectional Synchronisation**: +1. **Pull:** It first fetches changes from the peer. +2. **Push:** If the pull is successful, it immediately pushes your local changes to that peer. + +This "one-click" approach ensures both devices are perfectly in synchronisation without manual back-and-forth. + +## 5. Technical Improvements in 2026 +- **Decoupled Architecture:** The UI is now strictly separated from the core logic, making the plugin more stable across different platforms (Mobile, Desktop, and Web). +- **Svelte 5 UI:** The interface has been rebuilt for better responsiveness and clearer status indicators. +- **Security:** All data remains end-to-end encrypted. Even the signalling relay never sees your actual notes. + diff --git a/manifest.json b/manifest.json index dded2c4..66379e4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { - "id": "obsidian-livesync", - "name": "Self-hosted LiveSync", - "version": "0.25.62", - "minAppVersion": "1.7.2", - "description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", - "author": "vorotamoroz", - "authorUrl": "https://github.com/vrtmrz", - "isDesktopOnly": false -} \ No newline at end of file + "id": "obsidian-livesync", + "name": "Self-hosted LiveSync", + "version": "0.25.62", + "minAppVersion": "1.7.2", + "description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", + "author": "vorotamoroz", + "authorUrl": "https://github.com/vrtmrz", + "isDesktopOnly": false +} diff --git a/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts b/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts new file mode 100644 index 0000000..8be0ab3 --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts @@ -0,0 +1,69 @@ +import { App, Modal } from "@/deps.ts"; +import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte"; +import { mount, unmount } from "svelte"; +import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator"; + +export type P2POpenReplicationModalCallback = { + onSync: (peerId: string) => Promise; + onSyncAndClose: (peerId: string) => Promise; +}; + +export class P2POpenReplicationModal extends Modal { + liveSyncReplicator: LiveSyncTrysteroReplicator; + callback?: P2POpenReplicationModalCallback; + component?: ReturnType; + showResult: boolean; + + constructor( + app: App, + liveSyncReplicator: LiveSyncTrysteroReplicator, + callback?: P2POpenReplicationModalCallback, + showResult: boolean = false + ) { + super(app); + this.liveSyncReplicator = liveSyncReplicator; + this.callback = callback; + this.showResult = showResult; + } + + async onSync(peerId: string) { + if (this.callback?.onSync) { + await this.callback.onSync(peerId); + } + } + + async onSyncAndClose(peerId: string) { + if (this.callback?.onSyncAndClose) { + await this.callback.onSyncAndClose(peerId); + } + this.close(); + } + + override onOpen() { + const { contentEl } = this; + this.titleEl.setText("P2P Replication"); + contentEl.empty(); + + if (this.component === undefined) { + this.component = mount(P2POpenReplicationPane, { + target: contentEl, + props: { + liveSyncReplicator: this.liveSyncReplicator, + onSync: (peerId: string) => this.onSync(peerId), + onSyncAndClose: (peerId: string) => this.onSyncAndClose(peerId), + onClose: () => this.close(), + showResult: this.showResult, + }, + }); + } + } + + override onClose() { + const { contentEl } = this; + contentEl.empty(); + if (this.component !== undefined) { + void unmount(this.component); + this.component = undefined; + } + } +} diff --git a/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte new file mode 100644 index 0000000..8247b67 --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte @@ -0,0 +1,288 @@ + + +
+ + +
+

Available Devices

+ {#if serverInfo && serverInfo.knownAdvertisements.length > 0} +
+ {#each serverInfo.knownAdvertisements as peer (peer.peerId)} +
+
+
{peer.name}
+
+ {peer.platform} + + {peer.peerId.slice(0, 8)} + + + {getAcceptanceStatus(peer)} + +
+
+
+ + +
+
+ {/each} +
+ {:else if serverInfo} +

No devices available. Waiting for other devices to connect...

+ {/if} +
+ + +
+ + diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts new file mode 100644 index 0000000..28907db --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts @@ -0,0 +1,73 @@ +import { App } from "@/deps.ts"; +import { Logger } from "@lib/common/logger"; +import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types"; +import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator"; +import { P2POpenReplicationModal } from "./P2POpenReplicationModal"; + +/** + * Creates an openReplicationUI factory for Obsidian environments. + * Returns a per-replicator closure that opens the P2P Replication modal + * and performs bidirectional sync (pull then push on success). + * + * Usage: + * const factory = createOpenReplicationUI(app); + * useP2PReplicatorFeature(core, factory); + */ +export function createOpenReplicationUI( + app: App +): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise { + return (replicator: LiveSyncTrysteroReplicator) => + (showResult: boolean): Promise => { + const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; + return new Promise((resolve) => { + const modal = new P2POpenReplicationModal( + app, + replicator, + { + onSync: async (peerId: string) => { + try { + // pull (replicateFrom) first; push only on success + const pullResult = await replicator.replicateFrom(peerId, showResult); + if (pullResult?.ok) { + const pushResult = await replicator.requestSynchroniseToPeer(peerId); + resolve(pushResult?.ok ?? true); + } else { + resolve(false); + } + } catch (e) { + Logger( + `Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`, + logLevel + ); + resolve(false); + } + }, + onSyncAndClose: async (peerId: string) => { + try { + const pullResult = await replicator.replicateFrom(peerId, showResult); + if (pullResult?.ok) { + const pushResult = await replicator.requestSynchroniseToPeer(peerId); + if (pushResult?.ok ?? true) { + await replicator.close(); + resolve(true); + } else { + resolve(false); + } + } else { + resolve(false); + } + } catch (e) { + Logger( + `Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`, + logLevel + ); + resolve(false); + } + }, + }, + showResult + ); + modal.open(); + }); + }; +} diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte new file mode 100644 index 0000000..cf64a30 --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte @@ -0,0 +1,201 @@ + + +
+

Signalling Status

+ +
+ Connection: + + {isConnected ? "🟢 Connected" : "🔴 Disconnected"} + +
+ +
+ {#if !isConnected} + + {:else} + + {/if} +
+ + {#if serverInfo} +
+ Peer ID: + + {serverInfo.serverPeerId.slice(0, 12)}... + +
+ +
+ Devices: + {serverInfo.knownAdvertisements.length} +
+ {/if} + + {#if showBroadcastToggle} +
+ + + +
+ {/if} +
+ + \ No newline at end of file diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte new file mode 100644 index 0000000..cfcc206 --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte @@ -0,0 +1,558 @@ + + +
+
+

P2P Host

+ +
+ + + +
+
+

Known Devices

+ +
+ + {#if serverInfo && serverInfo.knownAdvertisements.length > 0} +
+ {#each serverInfo.knownAdvertisements as peer (peer.peerId)} +
+
+
+ {peer.name} : ({peer.peerId.slice(0, 8)}) + {#if isCommunicating(peer.peerId)} + 📡 + {/if} +
+
+ {peer.platform} +
+
+
+ {#if isAccepted(peer)} +
+ + {getAcceptanceStatus(peer)} + + +
+
+ WATCH + +
+ {:else} +
+ + {getAcceptanceStatus(peer)} + +
+
+ PERMANENT + + +
+
+ SESSION + + +
+ {/if} + {#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)} + + {/if} +
+
+ {/each} +
+ {:else if serverInfo} +

No devices available. Waiting for other devices to connect...

+ {:else} +

Fetching status...

+ {/if} +
+
+ + \ No newline at end of file diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts new file mode 100644 index 0000000..d6cc66a --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts @@ -0,0 +1,42 @@ +import { WorkspaceLeaf } from "@/deps.ts"; +import { mount } from "svelte"; +import { SvelteItemView } from "@/common/SvelteItemView.ts"; +import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; +import type { P2PPaneParams } from "@/lib/src/replication/trystero/UseP2PReplicatorResult"; +import P2PServerStatusPane from "./P2PServerStatusPane.svelte"; + +export const VIEW_TYPE_P2P_SERVER_STATUS = "p2p-server-status"; + +export class P2PServerStatusPaneView extends SvelteItemView { + core: LiveSyncBaseCore; + private _p2pResult: P2PPaneParams; + override icon = "waypoints"; + override navigation = false; + + constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams) { + super(leaf); + this.core = core; + this._p2pResult = p2pResult; + } + + override getIcon(): string { + return "waypoints"; + } + + getViewType() { + return VIEW_TYPE_P2P_SERVER_STATUS; + } + + getDisplayText() { + return "P2P Server Status"; + } + + instantiateComponent(target: HTMLElement) { + return mount(P2PServerStatusPane, { + target, + props: { + liveSyncReplicator: this._p2pResult.replicator, + }, + }); + } +} diff --git a/src/lib b/src/lib index ed4502e..2868aae 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit ed4502e0035bfee88eca5f311d09ffc239ab9734 +Subproject commit 2868aae6fdd9ca7738f4a69c88fa6d7a01842e4d diff --git a/src/main.ts b/src/main.ts index 63242b0..102d120 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,6 +44,7 @@ import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/ import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts"; import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts"; import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts"; +import { createOpenReplicationUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts"; export type LiveSyncCore = LiveSyncBaseCore; export default class ObsidianLiveSyncPlugin extends Plugin { core: LiveSyncCore; @@ -176,7 +177,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin { const curriedFeature = () => featuresInitialiser(core); core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature); const setupManager = core.getModule(SetupManager); - + const replicator = useP2PReplicatorFeature(core, createOpenReplicationUI(this.app)); + useP2PReplicatorCommands(core, replicator); + useP2PReplicatorUI(core, core, replicator); useRemoteConfiguration(core); useSetupProtocolFeature(core, setupManager); @@ -190,9 +193,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin { // VIEW_TYPE_P2P, // (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!), // ]); - const replicator = useP2PReplicatorFeature(core); - useP2PReplicatorCommands(core, replicator); - useP2PReplicatorUI(core, core, replicator); } ); } diff --git a/src/modules/core/ModuleReplicator.ts b/src/modules/core/ModuleReplicator.ts index 79b7b49..ddc59d0 100644 --- a/src/modules/core/ModuleReplicator.ts +++ b/src/modules/core/ModuleReplicator.ts @@ -1,3 +1,4 @@ +import type PouchDB from "pouchdb-core"; import { fireAndForget } from "octagonal-wheels/promises"; import { AbstractModule } from "../AbstractModule"; import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte index b07c157..64a794f 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte @@ -39,18 +39,20 @@ const { setResult, getInitialData }: Props = $props(); onMount(() => { + let initialData: P2PSyncSetting | undefined = undefined; if (getInitialData) { - const initialData = getInitialData(); + initialData = getInitialData(); if (initialData) { copyTo(initialData, syncSetting); } - if (context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME)) { - syncSetting.P2P_DevicePeerName = context.services.config.getSmallConfig( - SETTING_KEY_P2P_DEVICE_NAME - ) as string; - } else { - syncSetting.P2P_DevicePeerName = ""; - } + } + const initialPeerName = (initialData?.P2P_DevicePeerName ?? "").trim(); + if (initialPeerName !== "") { + return; + } + const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME); + if (cachedPeerName) { + syncSetting.P2P_DevicePeerName = cachedPeerName as string; } }); function generateSetting() { @@ -100,7 +102,7 @@ const dummyPouch = new PouchDB("dummy"); const env: ReplicatorHostEnv = { settings: trialRemoteSetting, - processReplicatedDocs: async (docs: any[]) => { + processReplicatedDocs: async (_docs: any[]) => { return; }, confirm: context.services.confirm, @@ -116,7 +118,7 @@ await replicator.open(); for (let i = 0; i < 10; i++) { // await delay(1000); - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => window.setTimeout(resolve, 1000)); // Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO); if (replicator.knownAdvertisements.length > 0) { break; diff --git a/src/modules/services/ObsidianAPIService.ts b/src/modules/services/ObsidianAPIService.ts index f543c30..19784e4 100644 --- a/src/modules/services/ObsidianAPIService.ts +++ b/src/modules/services/ObsidianAPIService.ts @@ -38,6 +38,20 @@ export class ObsidianAPIService extends InjectableAPIService { + const rightLeaf = this.app.workspace.getRightLeaf(false); + if (rightLeaf) { + await rightLeaf.setViewState({ + type: viewType, + active: false, + }); + await this.app.workspace.revealLeaf(rightLeaf); + return; + } + + await this.showWindow(viewType); + } + private get app() { return this.context.app; } diff --git a/src/serviceFeatures/useP2PReplicatorUI.ts b/src/serviceFeatures/useP2PReplicatorUI.ts index a2ea2d1..b55a895 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.ts @@ -4,6 +4,10 @@ import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP2PReplicatorResult"; import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector"; import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView"; +import { + P2PServerStatusPaneView, + VIEW_TYPE_P2P_SERVER_STATUS, +} from "@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView"; import type { LiveSyncCore } from "@/main"; import type { WorkspaceLeaf } from "@/deps"; @@ -34,6 +38,19 @@ export function useP2PReplicatorUI( core: LiveSyncCore, replicator: UseP2PReplicatorResult ) { + const api = host.services.API as { + showWindow: (type: string) => Promise; + showWindowOnRight?: (type: string) => Promise; + registerWindow: (type: string, factory: (leaf: WorkspaceLeaf) => unknown) => void; + addCommand: (command: { id: string; name: string; callback: () => void }) => unknown; + addRibbonIcon: ( + icon: string, + title: string, + callback: () => void + ) => { addClass?: (name: string) => unknown } | undefined; + getPlatform: () => string; + }; + // const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any }; const getReplicator = () => replicator.replicator; const p2pLogCollector = new P2PLogCollector(); @@ -51,26 +68,64 @@ export function useP2PReplicatorUI( storeP2PStatusLine, }); }; - const openPane = () => host.services.API.showWindow(viewType); - host.services.API.registerWindow(viewType, factory); + const statusFactory = (leaf: WorkspaceLeaf) => { + return new P2PServerStatusPaneView(leaf, core, { + replicator: getReplicator(), + p2pLogCollector, + storeP2PStatusLine, + }); + }; + const openPane = () => api.showWindow(viewType); + const openStatusPane = () => { + if (api.showWindowOnRight) { + return api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS); + } + return api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS); + }; + api.registerWindow(viewType, factory); + api.registerWindow(VIEW_TYPE_P2P_SERVER_STATUS, statusFactory); host.services.appLifecycle.onInitialise.addHandler(() => { eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => { void openPane(); }); - host.services.API.addCommand({ + api.addCommand({ id: "open-p2p-replicator", - name: "P2P Sync : Open P2P Replicator", + name: "P2P Sync : Open P2P Replicator (Old UI)", callback: () => { void openPane(); }, }); - host.services.API.addRibbonIcon("waypoints", "P2P Replicator", () => { - void openPane(); - })?.addClass?.("livesync-ribbon-replicate-p2p"); + api.addCommand({ + id: "open-p2p-server-status", + name: "P2P Sync : Open P2P Server Status", + callback: () => { + void openStatusPane(); + }, + }); + // api.addRibbonIcon("waypoints", "P2P Replicator", () => { + // void openPane(); + // })?.addClass?.("livesync-ribbon-replicate-p2p"); + + api.addRibbonIcon("waypoints", "P2P Server Status", () => { + void openStatusPane(); + })?.addClass?.("livesync-ribbon-p2p-server-status"); + + return Promise.resolve(true); + }); + + host.services.appLifecycle.onLayoutReady.addHandler(() => { + if (api.getPlatform() !== "obsidian") { + return Promise.resolve(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); }); return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine }; diff --git a/updates.md b/updates.md index 621065b..a392a7f 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,18 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. +## Unreleased + +15th May, 2026 + + +### Fixed +- The issue which cannot synchronise in Only-P2P mode has been fixed. + +### P2P Replication UI Improvements +- Brand-new P2P Server Status pane has been added to provide real-time visibility into your connection status and peer network. +- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click. + ## 0.25.62 14th May, 2026