Compare commits

..

6 Commits

29 changed files with 655 additions and 1610 deletions

4
.gitignore vendored
View File

@@ -29,3 +29,7 @@ cov_profile/**
coverage coverage
src/apps/cli/dist/* src/apps/cli/dist/*
# Obsidian E2E test artefacts
test_e2e/playwright-report/
test_e2e/test-results/

View File

@@ -1,53 +0,0 @@
# 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 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.
- **Passphrase:** Your encryption key. Ensure all your devices use the exact same passphrase.
- **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.
- **Sync (🔄/🔁):** Mark specific peers as **sync targets**. Peers marked here will be included when you run the **"P2P: Sync with targets"** command (see section 5). Click the button next to a peer to toggle it on (🔄, highlighted) or off (🔁). This setting is persisted in your configuration.
## 4. Replication Dialogue
If you want to synchronise with a specific peer manually, use the **Replication** command or button. This opens the **Replication Dialogue** listing available devices.
Inside the dialogue, the **Server Status** card at the top confirms you are still connected while performing the sync.
Two actions are available per peer:
- **Sync** — Starts a bidirectional synchronisation (Pull then Push) and keeps the dialogue open so you can monitor progress or sync with additional peers.
- **Start Sync & Close** — Starts the same bidirectional sync in the background and **immediately closes the dialogue**, so you can continue working without waiting.
## 5. Syncing with Registered Targets via Command Palette
You can now trigger a synchronisation with all your pre-registered target peers in one step, without opening any UI.
1. Open the **Command Palette** (`Ctrl/Cmd + P`).
2. Run **"P2P: Sync with targets"**.
This command synchronises with every peer whose **SYNC** toggle is enabled in the **Detected Peers** list. If no targets are registered, or if the P2P server is not running, the command will notify you accordingly.
*Tip: Pair this command with a hotkey for a quick, keyboard-driven sync workflow.*
## 6. 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.

View File

@@ -1,10 +1,10 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "0.25.63", "version": "0.25.62",
"minAppVersion": "1.7.2", "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.", "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", "author": "vorotamoroz",
"authorUrl": "https://github.com/vrtmrz", "authorUrl": "https://github.com/vrtmrz",
"isDesktopOnly": false "isDesktopOnly": false
} }

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.63", "version": "0.25.62",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.63", "version": "0.25.62",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.808.0", "@aws-sdk/client-s3": "^3.808.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.63", "version": "0.25.62",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js", "main": "main.js",
"type": "module", "type": "module",
@@ -55,6 +55,9 @@
"test:docker-all:stop": "npm run test:docker-all:down", "test:docker-all:stop": "npm run test:docker-all:down",
"test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop", "test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop",
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh", "test:p2p": "bash test/suitep2p/run-p2p-tests.sh",
"test:obsidian:e2e": "npx playwright test --config test_e2e/playwright.config.ts",
"test:obsidian:e2e:headed": "npx playwright test --config test_e2e/playwright.config.ts --headed",
"test:obsidian:build-and-e2e": "npm run buildDev && npm run test:obsidian:e2e",
"version": "node version-bump.mjs && git add manifest.json versions.json" "version": "node version-bump.mjs && git add manifest.json versions.json"
}, },
"keywords": [], "keywords": [],

View File

@@ -1,80 +0,0 @@
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<void>;
onSyncAndClose: (peerId: string) => Promise<void>;
};
export class P2POpenReplicationModal extends Modal {
liveSyncReplicator: LiveSyncTrysteroReplicator;
callback?: P2POpenReplicationModalCallback;
component?: ReturnType<typeof mount>;
showResult: boolean;
title: string;
onClosed?: () => void;
rebuildMode: boolean;
constructor(
app: App,
liveSyncReplicator: LiveSyncTrysteroReplicator,
callback?: P2POpenReplicationModalCallback,
showResult: boolean = false,
title: string = "P2P Replication",
onClosed?: () => void,
rebuildMode: boolean = false
) {
super(app);
this.liveSyncReplicator = liveSyncReplicator;
this.callback = callback;
this.showResult = showResult;
this.title = title;
this.onClosed = onClosed;
this.rebuildMode = rebuildMode;
}
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(this.title);
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,
rebuildMode: this.rebuildMode,
},
});
}
}
override onClose() {
const { contentEl } = this;
contentEl.empty();
if (this.component !== undefined) {
void unmount(this.component);
this.component = undefined;
}
this.onClosed?.();
}
}

View File

@@ -1,313 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { eventHub } from "@/common/events";
import {
EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS,
type P2PServerInfo,
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
// import type { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
import { Logger } from "@lib/common/logger";
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
import { delay, fireAndForget } from "octagonal-wheels/promises";
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
interface Props {
liveSyncReplicator: LiveSyncTrysteroReplicator;
onSync: (_peerId: string) => Promise<void>;
onSyncAndClose: (_peerId: string) => Promise<void>;
onClose: () => void;
showResult: boolean;
rebuildMode?: boolean;
}
let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator, rebuildMode = false }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let syncingPeerId = $state<string | null>(null);
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
async function requestServerStatus() {
await liveSyncReplicator.requestStatus();
eventHub.emitEvent(EVENT_REQUEST_STATUS);
}
onMount(() => {
// ServerStatus
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
serverInfo = status;
});
fireAndForget(async () => {
await delay(100);
await requestServerStatus();
});
return unsubscribe;
});
async function handleSync(peerId: string) {
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSync(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
syncingPeerId = null;
}
}
async function handleSyncThenClose(peerId: string) {
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSyncAndClose(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
syncingPeerId = null;
}
}
async function handleSyncAndClose(peerId: string) {
fireAndForget(async () => {
try {
Logger(`Starting sync with ${peerId}`, logLevel);
await onSync(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
}
});
onClose();
}
async function disconnect() {
try {
await liveSyncReplicator.close();
Logger("Signalling connection closed.", logLevel);
} catch (e) {
Logger(`Failed to close signalling connection: ${e instanceof Error ? e.message : String(e)}`, logLevel);
}
}
async function onCloseAndDisconnect() {
await disconnect();
onClose();
}
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
if (peer.isAccepted === true) return "ACCEPTED";
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
if (peer.isAccepted === false) return "DENIED";
return "NEW";
}
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
return "unknown";
}
</script>
<div class="p2p-container">
<P2PServerStatusCard {liveSyncReplicator} showBroadcastToggle={false} />
<div class="peers-section">
<h3>Available Peers</h3>
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
<div class="peers-list">
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
<div class="peer-item">
<div class="peer-info">
<div class="peer-name">{peer.name}</div>
<div class="peer-meta">
<span class="badge">{peer.platform}</span>
<span class="peer-id-mini" title={peer.peerId}>
{peer.peerId.slice(0, 8)}
</span>
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
{getAcceptanceStatus(peer)}
</span>
</div>
</div>
<div class="peer-actions">
{#if !rebuildMode}
<button
class="btn btn-primary"
disabled={syncingPeerId !== null}
onclick={() => handleSync(peer.peerId)}
>
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
</button>
<button
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
disabled={syncingPeerId !== null}
onclick={() => handleSyncAndClose(peer.peerId)}
>
{syncingPeerId === peer.peerId ? "Syncing..." : "Start Sync & Close"}
</button>
{:else}
<button
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
disabled={syncingPeerId !== null}
onclick={() => handleSyncThenClose(peer.peerId)}
>
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
</button>
{/if}
</div>
</div>
{/each}
</div>
{:else if serverInfo}
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
{/if}
</div>
<div class="footer">
{#if rebuildMode}
<button class="btn btn-cancel" onclick={onClose} disabled={syncingPeerId !== null}>Skip and close</button>
{:else}
<button class="btn btn-cancel" onclick={onClose}>Close</button>
<button class="btn btn-cancel" onclick={onCloseAndDisconnect}>Close & Disconnect</button>
{/if}
</div>
</div>
<style>
.p2p-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-height: 70vh;
overflow-y: auto;
}
.peers-section {
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
padding: 1rem;
}
h3 {
margin: 0 0 0.75rem 0;
font-weight: 600;
font-size: 1rem;
}
.peers-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.peer-item {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
padding: 0.75rem;
background-color: var(--background-secondary);
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
}
.peer-info {
flex: 1;
}
.peer-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.peer-meta {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
}
.badge {
background-color: var(--background-tertiary);
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
}
.status-chip {
font-weight: 600;
}
.status-chip.accepted {
background-color: var(--background-modifier-success);
color: var(--text-normal);
}
.status-chip.denied {
background-color: var(--background-modifier-error);
color: var(--text-normal);
}
.status-chip.unknown {
background-color: var(--background-modifier-border);
color: var(--text-muted);
}
.peer-id-mini {
font-family: monospace;
color: var(--text-muted);
}
.peer-actions {
flex-wrap: wrap;
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.4rem 0.8rem;
border: 1px solid var(--divider-color);
border-radius: 0.3rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: background-color 0.2s;
}
.btn:hover:not(:disabled) {
background-color: var(--interactive-hover);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.btn-secondary {
background-color: var(--background-tertiary);
}
.btn-cancel {
width: 100%;
margin-top: 0.5rem;
}
.no-peers {
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem;
}
.footer {
border-top: 1px solid var(--divider-color);
padding-top: 0.75rem;
}
</style>

View File

@@ -1,131 +0,0 @@
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/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<boolean | void> {
return (replicator: LiveSyncTrysteroReplicator) =>
(showResult: boolean): Promise<boolean | void> => {
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
return new Promise<boolean | void>((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();
});
};
}
/**
* Creates an openRebuildUI factory for Obsidian environments.
* Opens the P2P Replication modal in "rebuild" mode — one-way pull only,
* with setOnSetup / clearOnSetup bracketing the replicateFrom call.
*
* Usage:
* const factory = createOpenRebuildUI(app);
* useP2PReplicatorFeature(core, createOpenReplicationUI(app), factory);
*/
export function createOpenRebuildUI(
app: App
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
return (replicator: LiveSyncTrysteroReplicator) =>
(showResult: boolean): Promise<boolean | void> => {
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
return new Promise<boolean | void>((resolve) => {
let resolved = false;
const safeResolve = (val: boolean) => {
if (!resolved) {
resolved = true;
resolve(val);
}
};
const doRebuild = async (peerId: string) => {
replicator.setOnSetup();
try {
Logger(`Rebuilding from peer ${peerId}`, logLevel);
const result = await replicator.replicateFrom(peerId, showResult);
safeResolve(result?.ok ?? false);
} catch (e) {
Logger(
`Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
safeResolve(false);
} finally {
replicator.clearOnSetup();
}
};
const modal = new P2POpenReplicationModal(
app,
replicator,
{
onSync: doRebuild,
onSyncAndClose: doRebuild,
},
showResult,
"P2P Rebuild",
() => safeResolve(false),
true
);
modal.open();
});
};
}

View File

@@ -1,201 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { eventHub } from "@/common/events";
import { delay, fireAndForget } from "octagonal-wheels/promises";
import type { P2PServerInfo } from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
import {
EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS,
EVENT_P2P_REPLICATOR_STATUS,
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PReplicatorStatus } from "@/lib/src/replication/trystero/TrysteroReplicator";
interface Props {
liveSyncReplicator: LiveSyncTrysteroReplicator;
showBroadcastToggle?: boolean;
}
let { liveSyncReplicator, showBroadcastToggle = true }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let replicatorStatus = $state<P2PReplicatorStatus | undefined>(undefined);
async function requestServerStatus() {
await Promise.resolve(liveSyncReplicator.requestStatus());
eventHub.emitEvent(EVENT_REQUEST_STATUS);
}
async function onOpenConnection() {
await liveSyncReplicator.makeSureOpened();
await requestServerStatus();
}
async function onDisconnect() {
await liveSyncReplicator.close();
await requestServerStatus();
}
function toggleBroadcast() {
if (replicatorStatus?.isBroadcasting) {
liveSyncReplicator.disableBroadcastChanges();
} else {
liveSyncReplicator.enableBroadcastChanges();
}
}
onMount(() => {
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
serverInfo = status;
});
const unsubscribeStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorStatus = status;
});
fireAndForget(async () => {
await delay(100);
await requestServerStatus();
});
return () => {
unsubscribe();
unsubscribeStatus();
};
});
const isConnected = $derived.by(() => serverInfo?.isConnected);
const isBroadcasting = $derived.by(() => replicatorStatus?.isBroadcasting ?? false);
</script>
<div class="server-status">
<h3>Signalling Status</h3>
<div class="status-item">
<span>Connection:</span>
<span class="status-value {isConnected ? 'connected' : 'disconnected'}">
{isConnected ? "🟢 Connected" : "🔴 Disconnected"}
</span>
</div>
<div class="status-item status-action">
{#if !isConnected}
<button onclick={onOpenConnection}>Open connection</button>
{:else}
<button onclick={onDisconnect}>Close connection</button>
{/if}
</div>
{#if serverInfo}
<div class="status-item">
<span>Peer ID:</span>
<span class="peer-id-display" title={serverInfo.serverPeerId}>
{serverInfo.serverPeerId.slice(0, 12)}...
</span>
</div>
<div class="status-item">
<span>Devices:</span>
<span>{serverInfo.knownAdvertisements.length}</span>
</div>
{/if}
{#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
</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'}
>
{isBroadcasting ? '📡 On' : '📡 Off'}
</button>
</div>
{/if}
</div>
<style>
.server-status {
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
padding: 1rem;
}
h3 {
margin: 0 0 0.75rem 0;
font-weight: 600;
font-size: 1rem;
}
.status-item {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.status-action {
align-items: center;
gap: 0.5rem;
}
.status-value {
font-weight: 500;
}
.status-value.connected {
color: var(--text-success);
}
.status-value.disconnected {
color: var(--text-error);
}
.peer-id-display {
font-family: monospace;
font-size: 0.85rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
.broadcast-row {
align-items: center;
margin-top: 0.25rem;
}
.broadcast-label {
font-size: 0.9rem;
color: var(--text-normal);
cursor: pointer;
}
.broadcast-button {
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
cursor: pointer;
font-weight: 600;
transition: background-color 0.15s;
}
.broadcast-button.is-on {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.broadcast-button.is-off {
background-color: var(--interactive-normal);
color: var(--text-muted);
}
.broadcast-button.is-off:hover {
background-color: var(--interactive-hover);
color: var(--text-normal);
}
</style>

View File

@@ -1,603 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
import {
EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS,
EVENT_P2P_REPLICATOR_STATUS,
EVENT_P2P_REPLICATOR_PROGRESS,
type P2PServerInfo,
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PReplicatorStatus, P2PReplicationReport } from "@/lib/src/replication/trystero/TrysteroReplicator";
import { delay, fireAndForget } from "octagonal-wheels/promises";
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
interface Props {
liveSyncReplicator: LiveSyncTrysteroReplicator;
core: LiveSyncBaseCore;
}
let { liveSyncReplicator, core }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
let decidingPeerId = $state<string | null>(null);
let communicatingUntil = $state<Record<string, number>>({});
const COMMUNICATION_HOLD_MS = 2500;
let syncOnReplicationSetting = $state(
core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? ""
);
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(",");
}
function markCommunicating(peerId: string) {
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
communicatingUntil = { ...communicatingUntil, [peerId]: expiry };
window.setTimeout(() => {
if ((communicatingUntil[peerId] ?? 0) <= Date.now()) {
const { [peerId]: _removed, ...rest } = communicatingUntil;
communicatingUntil = rest;
}
}, COMMUNICATION_HOLD_MS + 100);
}
async function requestServerStatus() {
await liveSyncReplicator.requestStatus();
eventHub.emitEvent(EVENT_REQUEST_STATUS);
}
onMount(() => {
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
serverInfo = status;
});
const unsubscribeReplicatorStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorInfo = status;
for (const peerId of status.replicatingFrom) {
markCommunicating(peerId);
}
for (const peerId of status.replicatingTo) {
markCommunicating(peerId);
}
});
const unsubscribeReplicatorProgress = eventHub.onEvent(EVENT_P2P_REPLICATOR_PROGRESS, (report) => {
const rep = report as P2PReplicationReport;
if (("fetching" in rep && rep.fetching?.isActive) || ("sending" in rep && rep.sending?.isActive)) {
markCommunicating(rep.peerId);
}
});
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
});
fireAndForget(async () => {
await delay(100);
await requestServerStatus();
});
return () => {
unsubscribe();
unsubscribeReplicatorStatus();
unsubscribeReplicatorProgress();
unsubscribeSettings();
};
});
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
if (peer.isAccepted === true) return "ACCEPTED";
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
if (peer.isAccepted === false) return "DENIED";
return "NEW";
}
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
return "unknown";
}
function openConnectionSettings() {
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS);
}
async function makeDecision(
peer: P2PServerInfo["knownAdvertisements"][number],
decision: boolean,
isTemporary: boolean
) {
decidingPeerId = peer.peerId;
try {
await liveSyncReplicator.makeDecision({
peerId: peer.peerId,
name: peer.name,
decision,
isTemporary,
});
await requestServerStatus();
} finally {
decidingPeerId = null;
}
}
async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) {
decidingPeerId = peer.peerId;
try {
await liveSyncReplicator.revokeDecision({
peerId: peer.peerId,
name: peer.name,
});
await requestServerStatus();
} finally {
decidingPeerId = null;
}
}
function isAccepted(peer: P2PServerInfo["knownAdvertisements"][number]) {
return peer.isTemporaryAccepted === true || peer.isAccepted === true;
}
function isWatching(peerId: string) {
return replicatorInfo?.watchingPeers?.includes(peerId) ?? false;
}
function toggleWatch(peerId: string) {
if (isWatching(peerId)) {
liveSyncReplicator.unwatchPeer(peerId);
} else {
liveSyncReplicator.watchPeer(peerId);
}
}
function isCommunicating(peerId: string) {
const to = replicatorInfo?.replicatingTo ?? [];
const from = replicatorInfo?.replicatingFrom ?? [];
const isLiveCommunicating = to.includes(peerId) || from.includes(peerId);
const isHeldCommunicating = (communicatingUntil[peerId] ?? 0) > Date.now();
return isLiveCommunicating || isHeldCommunicating;
}
function isSyncTarget(peerName: string) {
return syncOnReplicationSetting
.split(",")
.map((e) => e.trim())
.filter((e) => e)
.includes(peerName);
}
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
const newValue = isSyncTarget(peer.name)
? removeFromList(peer.name, currentValue)
: addToList(peer.name, currentValue);
await core.services.setting.applyPartial({ P2P_SyncOnReplication: newValue }, true);
}
</script>
<div class="p2p-container">
<div class="pane-header">
<h2>P2P Status</h2>
<button
class="icon-button"
onclick={openConnectionSettings}
title="Open P2P Setup..."
aria-label="Open P2P Setup..."
>
</button>
</div>
<P2PServerStatusCard {liveSyncReplicator} />
<div class="peers-section">
<div class="peers-header">
<h3>Detected Peers</h3>
<button class="refresh" onclick={requestServerStatus}>Refresh</button>
</div>
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
<div class="peers-list">
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
<div class="peer-item">
<div class="peer-info">
<div class="peer-name">
{peer.name} : <span class="peer-id-mini" title={peer.peerId}>({peer.peerId.slice(0, 8)})</span>
{#if isCommunicating(peer.peerId)}
<span class="comm-icon" title="Communicating" aria-label="Communicating">📡</span>
{/if}
</div>
<div class="peer-meta">
<span class="badge">{peer.platform}</span>
</div>
</div>
<div class="peer-actions">
{#if isAccepted(peer)}
<div class="decision-row accepted-row">
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
{getAcceptanceStatus(peer)}
</span>
<button
class="action-button"
disabled={decidingPeerId !== null}
onclick={() => revokeDecision(peer)}
>
Revoke
</button>
</div>
<div class="decision-row watch-row">
<span class="decision-label">WATCH</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'}
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'}
onclick={() => toggleSyncTarget(peer)}
>
{isSyncTarget(peer.name) ? '🔄' : '🔁'}
</button>
</div> {:else}
<div class="decision-status">
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
{getAcceptanceStatus(peer)}
</span>
</div>
<div class="decision-row">
<span class="decision-label">PERMANENT</span>
<button
class="emoji-button"
title="Allow permanently"
aria-label="Allow permanently"
disabled={decidingPeerId !== null}
onclick={() => makeDecision(peer, true, false)}
>
</button>
<button
class="emoji-button mod-warning"
title="Deny permanently"
aria-label="Deny permanently"
disabled={decidingPeerId !== null}
onclick={() => makeDecision(peer, false, false)}
>
🚫
</button>
</div>
<div class="decision-row">
<span class="decision-label">SESSION</span>
<button
class="emoji-button"
title="Allow in session"
aria-label="Allow in session"
disabled={decidingPeerId !== null}
onclick={() => makeDecision(peer, true, true)}
>
</button>
<button
class="emoji-button mod-warning"
title="Deny in session"
aria-label="Deny in session"
disabled={decidingPeerId !== null}
onclick={() => makeDecision(peer, false, true)}
>
🚫
</button>
</div>
{/if}
{#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)}
<button
class="action-button revoke-inline"
disabled={decidingPeerId !== null}
onclick={() => revokeDecision(peer)}
>
Revoke
</button>
{/if}
</div>
</div>
{/each}
</div>
{:else if serverInfo}
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
{:else}
<p class="no-peers">Fetching status...</p>
{/if}
</div>
</div>
<style>
.p2p-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.75rem;
}
.peers-section {
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
padding: 1rem;
}
.pane-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: nowrap;
gap: 0.5rem;
}
.pane-header h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
white-space: nowrap;
}
.icon-button {
width: 1.9rem;
height: 1.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
flex-shrink: 0;
}
.icon-button:hover {
background-color: var(--interactive-hover);
}
.peers-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
h3 {
margin: 0;
font-weight: 600;
font-size: 1rem;
}
.refresh {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
border: 1px solid var(--divider-color);
border-radius: 0.3rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
}
.refresh:hover {
background-color: var(--interactive-hover);
}
.peers-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.peer-item {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
padding: 0.75rem;
background-color: var(--background-secondary);
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
}
.peer-info {
flex: 1;
min-width: 0;
}
.peer-name {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.peer-meta {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
flex-wrap: wrap;
}
.badge {
background-color: var(--background-tertiary);
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
}
.status-chip {
font-weight: 600;
}
.status-chip.accepted {
background-color: var(--background-modifier-success);
color: var(--text-normal);
}
.status-chip.denied {
background-color: var(--background-modifier-error);
color: var(--text-normal);
}
.status-chip.unknown {
background-color: var(--background-modifier-border);
color: var(--text-muted);
}
.peer-id-mini {
font-family: monospace;
color: var(--text-muted);
font-size: 0.75rem;
}
.comm-icon {
font-size: 0.8rem;
line-height: 1;
animation: pulse-comm 1.2s ease-in-out infinite;
}
@keyframes pulse-comm {
0% {
opacity: 0.55;
transform: scale(0.95);
}
50% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0.55;
transform: scale(0.95);
}
}
.peer-actions {
display: flex;
flex-direction: column;
gap: 0.35rem;
width: 100%;
min-width: 0;
}
.decision-status {
display: flex;
justify-content: flex-start;
}
.decision-row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.35rem;
}
.accepted-row {
grid-template-columns: 1fr auto;
}
.decision-label {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.03em;
}
.action-button {
font-size: 0.75rem;
padding: 0.2rem 0.45rem;
border: 1px solid var(--divider-color);
border-radius: 0.3rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
width: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.emoji-button {
width: 2rem;
height: 1.7rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--divider-color);
border-radius: 0.3rem;
background-color: var(--interactive-normal);
cursor: pointer;
padding: 0;
line-height: 1;
}
.emoji-button.mod-warning {
background-color: var(--background-modifier-error);
}
.emoji-button.is-watching {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.emoji-button:hover:not(:disabled) {
background-color: var(--interactive-hover);
}
.emoji-button.mod-warning:hover:not(:disabled) {
filter: brightness(0.95);
}
.watch-row {
margin-top: 0.25rem;
}
.action-button:hover:not(:disabled) {
background-color: var(--interactive-hover);
}
.action-button.mod-warning {
background-color: var(--background-modifier-error);
}
.action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.emoji-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.revoke-inline {
justify-self: start;
}
.no-peers {
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem;
}
</style>

View File

@@ -1,43 +0,0 @@
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 Status";
}
instantiateComponent(target: HTMLElement) {
return mount(P2PServerStatusPane, {
target,
props: {
liveSyncReplicator: this._p2pResult.replicator,
core: this.core,
},
});
}
}

Submodule src/lib updated: 07e287c531...ed4502e003

View File

@@ -44,7 +44,6 @@ import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/
import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts"; import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts";
import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts"; import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts";
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts"; import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>; export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin { export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore; core: LiveSyncCore;
@@ -177,13 +176,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
const curriedFeature = () => featuresInitialiser(core); const curriedFeature = () => featuresInitialiser(core);
core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature); core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
const setupManager = core.getModule(SetupManager); const setupManager = core.getModule(SetupManager);
const replicator = useP2PReplicatorFeature(
core,
createOpenReplicationUI(this.app),
createOpenRebuildUI(this.app)
);
useP2PReplicatorCommands(core, replicator);
useP2PReplicatorUI(core, core, replicator);
useRemoteConfiguration(core); useRemoteConfiguration(core);
useSetupProtocolFeature(core, setupManager); useSetupProtocolFeature(core, setupManager);
@@ -197,6 +190,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
// VIEW_TYPE_P2P, // VIEW_TYPE_P2P,
// (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!), // (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!),
// ]); // ]);
const replicator = useP2PReplicatorFeature(core);
useP2PReplicatorCommands(core, replicator);
useP2PReplicatorUI(core, core, replicator);
} }
); );
} }

View File

@@ -1,4 +1,3 @@
import type PouchDB from "pouchdb-core";
import { fireAndForget } from "octagonal-wheels/promises"; import { fireAndForget } from "octagonal-wheels/promises";
import { AbstractModule } from "../AbstractModule"; import { AbstractModule } from "../AbstractModule";
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";

View File

@@ -14,7 +14,6 @@ import { AbstractModule } from "../AbstractModule.ts";
import { $msg } from "../../lib/src/common/i18n.ts"; import { $msg } from "../../lib/src/common/i18n.ts";
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts"; import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
import type { LiveSyncCore } from "../../main.ts"; import type { LiveSyncCore } from "../../main.ts";
import { REMOTE_P2P } from "@lib/common/models/setting.const.ts";
export class ModuleResolvingMismatchedTweaks extends AbstractModule { export class ModuleResolvingMismatchedTweaks extends AbstractModule {
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> { async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
@@ -187,9 +186,6 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
async _checkAndAskUseRemoteConfiguration( async _checkAndAskUseRemoteConfiguration(
trialSetting: RemoteDBSettings trialSetting: RemoteDBSettings
): Promise<{ result: false | TweakValues; requireFetch: boolean }> { ): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
if (trialSetting.remoteType === REMOTE_P2P) {
return { result: false, requireFetch: false };
}
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting); const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
if (preferred) { if (preferred) {
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred); return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred);

View File

@@ -48,8 +48,6 @@
bind:value={userType} bind:value={userType}
> >
This is an advanced option for users who do not have a URI or who wish to configure detailed settings. 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.
</Option> </Option>
</Options> </Options>
</Instruction> </Instruction>

View File

@@ -39,20 +39,18 @@
const { setResult, getInitialData }: Props = $props(); const { setResult, getInitialData }: Props = $props();
onMount(() => { onMount(() => {
let initialData: P2PSyncSetting | undefined = undefined;
if (getInitialData) { if (getInitialData) {
initialData = getInitialData(); const initialData = getInitialData();
if (initialData) { if (initialData) {
copyTo(initialData, syncSetting); copyTo(initialData, syncSetting);
} }
} if (context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME)) {
const initialPeerName = (initialData?.P2P_DevicePeerName ?? "").trim(); syncSetting.P2P_DevicePeerName = context.services.config.getSmallConfig(
if (initialPeerName !== "") { SETTING_KEY_P2P_DEVICE_NAME
return; ) as string;
} } else {
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME); syncSetting.P2P_DevicePeerName = "";
if (cachedPeerName) { }
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
} }
}); });
function generateSetting() { function generateSetting() {
@@ -102,7 +100,7 @@
const dummyPouch = new PouchDB<EntryDoc>("dummy"); const dummyPouch = new PouchDB<EntryDoc>("dummy");
const env: ReplicatorHostEnv = { const env: ReplicatorHostEnv = {
settings: trialRemoteSetting, settings: trialRemoteSetting,
processReplicatedDocs: async (_docs: any[]) => { processReplicatedDocs: async (docs: any[]) => {
return; return;
}, },
confirm: context.services.confirm, confirm: context.services.confirm,
@@ -118,7 +116,7 @@
await replicator.open(); await replicator.open();
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
// await delay(1000); // await delay(1000);
await new Promise((resolve) => window.setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO); // Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
if (replicator.knownAdvertisements.length > 0) { if (replicator.knownAdvertisements.length > 0) {
break; break;

View File

@@ -38,25 +38,6 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
} }
} }
override async showWindowOnRight(viewType: string): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(viewType);
if (existing.length > 0) {
await this.app.workspace.revealLeaf(existing[0]);
return;
}
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() { private get app() {
return this.context.app; return this.context.app;
} }

View File

@@ -5,7 +5,7 @@ import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/re
import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte"; import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte";
import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte"; import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
import { extractObject } from "octagonal-wheels/object"; import { extractObject } from "octagonal-wheels/object";
import { REMOTE_MINIO, REMOTE_P2P } from "@lib/common/models/setting.const"; import { REMOTE_MINIO } from "@lib/common/models/setting.const";
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type"; import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition"; import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition";
@@ -200,13 +200,6 @@ export async function adjustSettingToRemoteIfNeeded(
return; return;
} }
// P2P has no centralised remote configuration; skip to avoid a spurious
// "Failed to connect to the remote server" error dialog.
if (config.remoteType === REMOTE_P2P) {
log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO);
return;
}
// Remote configuration fetched and applied. // Remote configuration fetched and applied.
if (await adjustSettingToRemote(host, log, config)) { if (await adjustSettingToRemote(host, log, config)) {
config = host.services.setting.currentSettings(); config = host.services.setting.currentSettings();

View File

@@ -4,13 +4,8 @@ import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP2PReplicatorResult"; import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP2PReplicatorResult";
import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector"; import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector";
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView"; 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 { LiveSyncCore } from "@/main";
import type { WorkspaceLeaf } from "@/deps"; import type { WorkspaceLeaf } from "@/deps";
import { REMOTE_P2P } from "@lib/common/models/setting.const";
/** /**
* ServiceFeature: P2P Replicator lifecycle management. * ServiceFeature: P2P Replicator lifecycle management.
@@ -39,19 +34,6 @@ export function useP2PReplicatorUI(
core: LiveSyncCore, core: LiveSyncCore,
replicator: UseP2PReplicatorResult replicator: UseP2PReplicatorResult
) { ) {
const api = host.services.API as {
showWindow: (type: string) => Promise<void>;
showWindowOnRight?: (type: string) => Promise<void>;
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 env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any };
const getReplicator = () => replicator.replicator; const getReplicator = () => replicator.replicator;
const p2pLogCollector = new P2PLogCollector(); const p2pLogCollector = new P2PLogCollector();
@@ -69,99 +51,26 @@ export function useP2PReplicatorUI(
storeP2PStatusLine, storeP2PStatusLine,
}); });
}; };
const statusFactory = (leaf: WorkspaceLeaf) => { const openPane = () => host.services.API.showWindow(viewType);
return new P2PServerStatusPaneView(leaf, core, { host.services.API.registerWindow(viewType, factory);
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(() => { host.services.appLifecycle.onInitialise.addHandler(() => {
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => { eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => {
void openPane(); void openPane();
}); });
api.addCommand({ host.services.API.addCommand({
id: "open-p2p-replicator", id: "open-p2p-replicator",
name: "P2P Sync : Open P2P Replicator (Old UI)", name: "P2P Sync : Open P2P Replicator",
callback: () => { callback: () => {
void openPane(); void openPane();
}, },
}); });
api.addCommand({ host.services.API.addRibbonIcon("waypoints", "P2P Replicator", () => {
id: "open-p2p-server-status", void openPane();
name: "P2P Sync : Open P2P Status", })?.addClass?.("livesync-ribbon-replicate-p2p");
callback: () => {
void openStatusPane();
},
});
host.services.API.addCommand({
id: "replicate-now-by-p2p-default-peer",
name: "Replicate P2P to default peer",
checkCallback: (isChecking: boolean) => {
const settings = host.services.setting.currentSettings();
if (isChecking) {
if (settings.remoteType == REMOTE_P2P) return false;
return replicator.replicator?.server?.isServing ?? false;
}
void replicator.replicator?.openReplication(settings, false, true, false);
},
});
host.services.API.addCommand({
id: "replicate-now-by-p2p",
name: "Replicate now by P2P",
checkCallback: (isChecking: boolean) => {
const settings = host.services.setting.currentSettings();
if (isChecking) {
if (settings.remoteType == REMOTE_P2P) return false;
return replicator.replicator?.server?.isServing ?? false;
}
void replicator.replicator?.openReplication(settings, false, true, false);
},
});
host.services.API.addCommand({
id: "p2p-sync-targets",
name: "P2P: Sync with targets",
checkCallback: (isChecking: boolean) => {
if (isChecking) {
return replicator.replicator?.server?.isServing ?? false;
}
void replicator.replicator?.replicateFromCommand(true);
},
});
// 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");
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 Promise.resolve(true);
}); });
return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine }; return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine };

View File

@@ -0,0 +1,35 @@
import type { Locator, Page } from "playwright";
import { type ObsidianHandle, launchObsidian } from "./obsidian";
import { type VaultSettingsOptions, type VaultSetupResult, setupTestVaultWithSettings } from "./vault";
// ---------------------------------------------------------------------------
// Helpers (vault setup, test scaffolding, etc.)
// ---------------------------------------------------------------------------
export async function withSeededVault(
options: VaultSettingsOptions,
run: (context: { app: ObsidianHandle; vault: VaultSetupResult }) => Promise<void>
): Promise<void> {
const vault = setupTestVaultWithSettings(options);
const app = await launchObsidian(vault.fakeAppData, vault.vaultDir);
try {
await run({ app, vault });
} finally {
await app.close().catch(() => {});
vault.cleanup();
}
}
// ---------------------------------------------------------------------------
// Selectors
// ---------------------------------------------------------------------------
/** CSS selector for the settings-tab content area. */
export const SELECTOR_SETTINGS_CONTENT = ".vertical-tab-content-container";
/** CSS selector for Obsidian notice toasts. */
export const SELECTOR_NOTICE = ".notice-container .notice";
export function locateModalByTitle(page: Page, title: string): Locator {
return page.locator(".modal-container .modal-title").filter({ hasText: title });
}

View File

@@ -0,0 +1,294 @@
/* eslint-disable obsidianmd/prefer-window-timers */
/* eslint-disable import/no-nodejs-modules */
/* eslint-disable import/no-extraneous-dependencies */
/**
* helpers/obsidian.ts
*
* Launch / teardown helpers for the Obsidian Electron application and
* common UI interactions needed across test files.
*
* Launch strategy
* ---------------
* Playwright's `_electron.launch()` cannot reliably connect to Obsidian.exe
* via CDP because Obsidian's startup sequence does not expose the DevTools
* URL on stdout/stderr in a way Playwright can detect. Instead, we:
* 1. Spawn Obsidian with a fixed `--remote-debugging-port`.
* 2. Poll `http://127.0.0.1:<port>/json/version` until the port is ready.
* 3. Connect with `chromium.connectOverCDP()`.
*/
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import http from "node:http";
import path from "node:path";
import os from "node:os";
import type { Browser, Page } from "playwright";
import type { ChildProcess } from "node:child_process";
import process from "node:process";
import { enablePlugin, isPluginEnabled } from "./obsidianFunctions";
// ---------------------------------------------------------------------------
// Executable path resolution
// ---------------------------------------------------------------------------
function defaultObsidianPath(): string {
switch (os.platform()) {
case "win32":
return path.join(os.homedir(), "AppData", "Local", "Obsidian", "Obsidian.exe");
case "darwin":
return "/Applications/Obsidian.app/Contents/MacOS/Obsidian";
default:
return process.env["OBSIDIAN_PATH"] ?? "/usr/bin/obsidian";
}
}
/**
* Path to the Obsidian executable.
* Override with the `OBSIDIAN_PATH` environment variable if needed.
*/
export const OBSIDIAN_EXECUTABLE: string = process.env["OBSIDIAN_PATH"] ?? defaultObsidianPath();
/** Fixed CDP port used for all test runs (workers: 1, so no collisions). */
const CDP_PORT = 19222;
// ---------------------------------------------------------------------------
// Launch
// ---------------------------------------------------------------------------
/**
* Handle returned by `launchObsidian`. Provides just enough surface to drive
* the Obsidian window and shut it down cleanly.
*/
export interface ObsidianHandle {
/** Returns the main Obsidian renderer page. */
firstWindow(): Promise<Page>;
/** Closes the CDP connection and kills the Obsidian process. */
close(): Promise<void>;
}
/** Poll `http://127.0.0.1:<port>/json/version` until Obsidian is ready. */
async function waitForCDP(port: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ready = await new Promise<boolean>((resolve) => {
const req = http.get(`http://127.0.0.1:${port}/json/version`, (res: http.IncomingMessage) => {
res.resume();
resolve(res.statusCode === 200);
});
req.on("error", () => resolve(false));
req.setTimeout(1_000, () => {
req.destroy();
resolve(false);
});
});
if (ready) return;
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Obsidian CDP port ${port} was not ready within ${timeoutMs}ms`);
}
/**
* Launches Obsidian with an isolated user-data directory and opens the
* given vault via the `obsidian://open` URI scheme.
*
* Uses a fixed `--remote-debugging-port` so we can poll and connect via
* `chromium.connectOverCDP()` without relying on Playwright's electron
* startup detection, which does not work with Obsidian.exe.
*/
export async function launchObsidian(fakeAppData: string, vaultDir: string): Promise<ObsidianHandle> {
const proc: ChildProcess = spawn(
OBSIDIAN_EXECUTABLE,
[
`--remote-debugging-port=${CDP_PORT}`,
`--user-data-dir=${fakeAppData}`,
"--no-sandbox",
"--lang=en",
`obsidian://open?path=${encodeURIComponent(vaultDir)}`,
],
{ env: { ...process.env, LIBGL_ALWAYS_SOFTWARE: "1" } }
);
proc.on("error", (err: Error) => {
console.error("[launchObsidian] spawn error:", err.message);
});
await waitForCDP(CDP_PORT, 60_000);
const browser: Browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`);
const waitForProcessExit = async (): Promise<void> => {
if (proc.exitCode !== null || proc.killed) {
return;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
proc.removeListener("exit", onExit);
proc.removeListener("close", onExit);
resolve();
}, 5_000);
const onExit = () => {
clearTimeout(timer);
proc.removeListener("exit", onExit);
proc.removeListener("close", onExit);
resolve();
};
proc.once("exit", onExit);
proc.once("close", onExit);
});
};
return {
close: async () => {
try {
await browser.close();
} catch {
/* ignore */
}
try {
proc.kill();
} catch {
/* ignore */
}
await waitForProcessExit();
},
firstWindow: async (): Promise<Page> => {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
for (const ctx of browser.contexts()) {
const pages = ctx.pages().filter((p: Page) => !p.isClosed());
if (pages.length > 0) return pages[0];
}
await new Promise((r) => setTimeout(r, 300));
}
throw new Error("No Obsidian window found after 30s");
},
};
}
// ---------------------------------------------------------------------------
// Window helpers
// ---------------------------------------------------------------------------
/**
* Returns the main Obsidian window and waits for its DOM to be ready.
*/
export async function getMainWindow(app: ObsidianHandle): Promise<Page> {
const page = await app.firstWindow();
await page.waitForLoadState("domcontentloaded", { timeout: 30_000 });
return page;
}
/**
* Waits until the Obsidian vault workspace has finished loading.
*
* Handles the 'Trust author and enable plugins' prompt and the
* community-plugins information modal that appear on a first-time vault open.
*/
export async function waitForVaultReady(page: Page): Promise<void> {
// Trust prompt — must be dismissed before the workspace renders.
const trustButton = page.getByRole("button", { name: /trust author and enable plugins/i });
try {
await trustButton.waitFor({ state: "visible", timeout: 15_000 });
await trustButton.click();
await page.waitForTimeout(1_500);
} catch {
// Not shown — vault already trusted or safe mode off.
}
// Once the trust prompt is handled, then the plugin dialogues may appear. Wait a bit for them to show up and log them if they do, to help diagnose blocked flows.
// await page.waitForTimeout(100);
// Community-plugins modal — dismiss with Escape.
try {
const modal = page.locator(".modal-container").filter({ hasText: /community plugins/i });
await modal.waitFor({ state: "visible", timeout: 5_000 });
await page.keyboard.press("Escape");
await page.waitForTimeout(10);
} catch {
// Modal not shown.
}
await page.waitForSelector(".workspace-ribbon", { timeout: 60_000 });
}
export async function enablePluginInObsidian(page: Page, pluginName: string) {
const handled = await page.evaluateHandle(enablePlugin, pluginName);
return handled;
}
export function isPluginEnabledInObsidian(page: Page, pluginName: string): Promise<boolean> {
const handled = page.evaluate(isPluginEnabled, pluginName);
return handled;
}
// ---------------------------------------------------------------------------
// Settings modal helpers
// ---------------------------------------------------------------------------
/**
* Opens the Obsidian Settings modal via the standard keyboard shortcut and
* waits for the navigation panel to become visible.
*/
export async function openSettings(page: Page): Promise<void> {
await page.keyboard.press("Control+,");
await page.waitForSelector(".modal-container .vertical-tab-nav-item", { timeout: 15_000 });
}
/**
* Clicks a settings navigation tab identified by its visible text label.
*/
export async function clickSettingsTab(page: Page, label: string): Promise<void> {
const tab = page.locator(".vertical-tab-nav-item", { hasText: label });
await tab.first().click();
await page.waitForTimeout(300);
}
/**
* Opens Settings and navigates directly to the Self-hosted LiveSync tab.
*/
export async function openLiveSyncSettings(page: Page): Promise<void> {
await openSettings(page);
await clickSettingsTab(page, "Self-hosted LiveSync");
}
/**
* Logs visible modal/dialog-like UI elements to help diagnose blocked flows.
*/
export async function logVisibleDialogs(page: Page, label = "dialogs"): Promise<void> {
const summaries = await page
.locator(".modal-container, [role='dialog'], .notice-container .notice")
.evaluateAll((nodes) => {
return nodes
.map((node) => {
const element = node as HTMLElement;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
rect.width > 0 &&
rect.height > 0 &&
!!element.textContent?.trim();
if (!visible) {
return undefined;
}
return {
classes: element.className,
text: element.textContent?.replace(/\s+/g, " ").trim().slice(0, 240) ?? "",
};
})
.filter((item): item is { classes: string; text: string } => !!item);
});
if (summaries.length === 0) {
console.log(`[obsidian:${label}] no visible dialogs`);
return;
}
for (const [index, summary] of summaries.entries()) {
console.log(`[obsidian:${label}] #${index + 1} class=${summary.classes} text=${summary.text}`);
}
}

View File

@@ -0,0 +1,19 @@
/* eslint-disable no-restricted-globals */
import type { App } from "obsidian";
declare global {
var app: App & {
plugins: {
enabledPlugins: Set<string>;
enablePlugin: (name: string) => Promise<void>;
};
};
}
export const enablePlugin = async (pluginName: string) => {
return await window.app.plugins.enablePlugin(pluginName);
};
export const isPluginEnabled = (pluginName: string) => {
return window.app.plugins.enabledPlugins.has(pluginName);
};

165
test_e2e/helpers/vault.ts Normal file
View File

@@ -0,0 +1,165 @@
/* eslint-disable obsidianmd/prefer-window-timers */
// This file is a test helper and is allowed to use Node.js modules.
/* eslint-disable obsidianmd/hardcoded-config-path */
// This file is a test helper and is allowed to use Node.js modules.
/* eslint-disable import/no-nodejs-modules */
/**
* helpers/vault.ts
*
* Creates a fully-isolated, throwaway Obsidian vault for each test run.
*
* Directory layout produced by `setupTestVault()`:
*
* <tmpdir>/livesync-e2e-<id>/
* obsidian.json <- registered vault list (Obsidian userData config)
* vault/
* .obsidian/
* app.json <- safe-mode disabled
* community-plugins.json
* plugins/
* obsidian-livesync/
* main.js <- built plugin (copied from repo root)
* manifest.json
* styles.css
*/
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import path from "node:path";
import os from "node:os";
/** Absolute path to the repository root (two levels above helpers/). */
// eslint-disable-next-line no-undef
const REPO_ROOT = path.resolve(__dirname, "../..");
export interface VaultSetupResult {
/** The vault directory that Obsidian will open. */
vaultDir: string;
/**
* The directory used as `--user-data-dir` for the Obsidian process.
* Obsidian reads its vault registry from `<fakeAppData>/obsidian.json`.
*/
fakeAppData: string;
/** Removes the entire temporary tree. */
cleanup: () => void;
}
export interface VaultSettingsOptions {
/** Optional custom app.json content under <vault>/.obsidian/app.json */
appJson?: Record<string, unknown>;
/** Community plugin IDs to mark as enabled. */
communityPlugins?: string[];
/** Per-plugin configuration keyed by plugin ID. */
pluginData?: Record<string, unknown>;
}
/**
* Creates a throw-away vault with the built plugin pre-installed and
* registered in an isolated Obsidian configuration directory.
*
* Call `cleanup()` (or use `test.afterAll`) to delete the temporary files.
*/
export function setupTestVault(): VaultSetupResult {
return setupTestVaultWithSettings({});
}
/**
* Creates a throw-away vault with optional initial Obsidian/plugin settings.
*
* This helper is intended for real-Obsidian e2e tests that need to open a
* vault in a known configuration state.
*/
export function setupTestVaultWithSettings(options: VaultSettingsOptions = {}): VaultSetupResult {
const id = randomBytes(4).toString("hex");
const baseDir = path.join(os.tmpdir(), `livesync-e2e-${id}`);
const fakeAppData = baseDir;
const vaultDir = path.join(baseDir, "vault");
// ------------------------------------------------------------------ vault
const dotObsidian = path.join(vaultDir, ".obsidian");
const pluginDir = path.join(dotObsidian, "plugins", "obsidian-livesync");
mkdirSync(pluginDir, { recursive: true });
// Copy the built plugin artefacts from the repository root.
for (const file of ["main.js", "manifest.json", "styles.css"]) {
const src = path.join(REPO_ROOT, file);
if (existsSync(src)) {
copyFileSync(src, path.join(pluginDir, file));
} else {
console.warn(`[vault setup] Expected file not found: ${src}`);
}
}
// Disable Obsidian safe mode so community plugins are allowed to load.
writeFileSync(
path.join(dotObsidian, "app.json"),
JSON.stringify({ promptDelete: false, ...(options.appJson ?? {}) }, null, 2),
"utf-8"
);
// Tell Obsidian which community plugins are enabled.
writeFileSync(
path.join(dotObsidian, "community-plugins.json"),
// JSON.stringify(options.communityPlugins ?? ["obsidian-livesync"], null, 2),
// You should enable the plugin(s) explicitly
JSON.stringify(options.communityPlugins ?? [], null, 2),
"utf-8"
);
if (options.pluginData) {
for (const [pluginId, value] of Object.entries(options.pluginData)) {
const target = path.join(dotObsidian, "plugins", pluginId, "data.json");
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, JSON.stringify(value, null, 2), "utf-8");
}
}
// ------------------------------------------------ Obsidian global config
// With --user-data-dir=<fakeAppData>, Obsidian reads its vault registry
// directly from <fakeAppData>/obsidian.json.
mkdirSync(fakeAppData, { recursive: true });
const vaultId = randomBytes(8).toString("hex");
writeFileSync(
path.join(fakeAppData, "obsidian.json"),
JSON.stringify(
{
vaults: {
[vaultId]: {
path: vaultDir,
ts: Date.now(),
open: true,
},
},
updateDisabled: true,
},
null,
2
),
"utf-8"
);
return {
vaultDir,
fakeAppData,
cleanup: () =>
void (async () => {
for (let attempt = 1; attempt <= 5; attempt++) {
try {
rmSync(baseDir, { recursive: true, force: true });
console.log(`[vault cleanup] Successfully removed temporary directory: ${baseDir}`);
return;
} catch {
console.warn(
`[vault cleanup] Attempt ${attempt} failed to remove temporary directory: ${baseDir}`
);
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
}
}
console.error(
`[vault cleanup] Failed to remove temporary directory after multiple attempts: ${baseDir}`
);
})(),
};
}

View File

@@ -0,0 +1,3 @@
// Example wrapper for Playwright test functions and assertions, this file is not used in Self-hosted LiveSync.
// eslint-disable-next-line import/no-extraneous-dependencies
export { test, expect } from "playwright/test";

3
test_e2e/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "commonjs"
}

View File

@@ -0,0 +1,24 @@
import { defineConfig } from "playwright/test";
import path from "node:path";
export default defineConfig({
testDir: path.join(__dirname, "tests"),
outputDir: path.join(__dirname, "test-results"),
// Each test may need to cold-start Obsidian and wait for the vault to load.
timeout: 120_000,
expect: { timeout: 20_000 },
// Tests are stateful (one Obsidian process per test file), so no parallelism.
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"], ["html", { open: "never", outputFolder: path.join(__dirname, "playwright-report") }]],
use: {
// Artefacts are kept only when a test fails.
screenshot: "only-on-failure",
video: "retain-on-failure",
trace: "retain-on-failure",
},
});

View File

@@ -0,0 +1,69 @@
/**
* tests/sample.spec.ts
*
* Example e2e test that opens a vault with pre-seeded settings.
*/
import {
getMainWindow,
waitForVaultReady,
enablePluginInObsidian,
isPluginEnabledInObsidian,
} from "../helpers/obsidian";
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
import { PartialMessages } from "@lib/common/messages/def";
import { locateModalByTitle, withSeededVault } from "test_e2e/helpers/helpers";
import { test, expect } from "test_e2e/helpers/wrapper";
const def = PartialMessages.def;
test("show Welcome when isConfigured is false", async () => {
await withSeededVault(
{
appJson: {
promptDelete: false,
},
communityPlugins: [],
pluginData: {
"obsidian-livesync": {
deviceAndVaultName: "e2e-configured-device",
isConfigured: true,
notifyThresholdOfRemoteStorageSize: 10000,
} satisfies Partial<ObsidianLiveSyncSettings>,
},
},
async ({ app }) => {
const page = await getMainWindow(app);
await waitForVaultReady(page);
await expect(enablePluginInObsidian(page, "obsidian-livesync")).resolves.not.toThrow();
expect(isPluginEnabledInObsidian(page, "obsidian-livesync")).toBeTruthy();
const welcome = locateModalByTitle(page, def["moduleMigration.titleWelcome"]);
await expect(welcome).toBeHidden({ timeout: 1_000 });
}
);
});
test("does not show Welcome when isConfigured is true", async () => {
await withSeededVault(
{
appJson: {
promptDelete: false,
},
communityPlugins: [],
pluginData: {
"obsidian-livesync": {
deviceAndVaultName: "e2e-configured-device",
isConfigured: true,
notifyThresholdOfRemoteStorageSize: 10000,
} satisfies Partial<ObsidianLiveSyncSettings>,
},
},
async ({ app }) => {
const page = await getMainWindow(app);
await waitForVaultReady(page);
await expect(enablePluginInObsidian(page, "obsidian-livesync")).resolves.not.toThrow();
expect(isPluginEnabledInObsidian(page, "obsidian-livesync")).toBeTruthy();
const welcome = locateModalByTitle(page, def["moduleMigration.titleWelcome"]);
await expect(welcome).toBeHidden({ timeout: 1_000 });
}
);
});

View File

@@ -3,26 +3,6 @@ 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. 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.
## 0.25.63
17th May, 2026
### Fixed
- The issue which cannot synchronise in Only-P2P mode has been fixed.
- Fixed an issue where "Failed to connect to the remote server" was shown during the redFlag rebuild flow when P2P was the primary remote type. Remote configuration fetch is now skipped for P2P.
### 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.
- For detailed instructions on using the new P2P features, please refer to the updated [User Guide: Peer-to-Peer Synchronisation (2026 Edition)](./docs/p2p_sync_updates_2026.md).
- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click.
- The vault rebuild flow (`replicateAllFromServer`) now opens the redesigned P2P Replication modal instead of a plain text selection dialogue, providing a consistent UI experience.
## Unreleased
15th May, 2026
## 0.25.62 ## 0.25.62
14th May, 2026 14th May, 2026