mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-15 12:01:16 +00:00
Fixed: fixed P2P bugs and and implement new UI
This commit is contained in:
42
docs/p2p_sync_updates_2026.md
Normal file
42
docs/p2p_sync_updates_2026.md
Normal file
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
onSyncAndClose: (peerId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export class P2POpenReplicationModal extends Modal {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
callback?: P2POpenReplicationModalCallback;
|
||||
component?: ReturnType<typeof mount>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
288
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal file
288
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal file
@@ -0,0 +1,288 @@
|
||||
<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;
|
||||
}
|
||||
|
||||
let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator }: 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 handleSyncAndClose(peerId: string) {
|
||||
try {
|
||||
syncingPeerId = peerId;
|
||||
Logger(`Starting sync and close with ${peerId}`, logLevel);
|
||||
await onSyncAndClose(peerId);
|
||||
Logger(`Sync and close completed with ${peerId}`, logLevel);
|
||||
} catch (e) {
|
||||
Logger(`Error during sync and close: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||
} finally {
|
||||
syncingPeerId = null;
|
||||
}
|
||||
}
|
||||
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 Devices</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">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
disabled={syncingPeerId !== null}
|
||||
onclick={() => handleSync(peer.peerId)}
|
||||
>
|
||||
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
disabled={syncingPeerId !== null}
|
||||
onclick={() => handleSyncAndClose(peer.peerId)}
|
||||
>
|
||||
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync & Close"}
|
||||
</button>
|
||||
</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">
|
||||
<button class="btn btn-cancel" onclick={onClose}>Close</button>
|
||||
<button class="btn btn-cancel" onclick={onCloseAndDisconnect}>Close & Disconnect</button>
|
||||
</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;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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>
|
||||
73
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal file
73
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal file
@@ -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<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();
|
||||
});
|
||||
};
|
||||
}
|
||||
201
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal file
201
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal file
@@ -0,0 +1,201 @@
|
||||
<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 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>
|
||||
558
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
558
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
@@ -0,0 +1,558 @@
|
||||
<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";
|
||||
|
||||
interface Props {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
}
|
||||
|
||||
let { liveSyncReplicator }: 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;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
fireAndForget(async () => {
|
||||
await delay(100);
|
||||
await requestServerStatus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeReplicatorStatus();
|
||||
unsubscribeReplicatorProgress();
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p2p-container">
|
||||
<div class="pane-header">
|
||||
<h2>P2P Host</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>Known Devices</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>
|
||||
{: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>
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: ed4502e003...2868aae6fd
@@ -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<ObsidianServiceContext, LiveSyncCommands>;
|
||||
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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<EntryDoc>("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;
|
||||
|
||||
@@ -38,6 +38,20 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
}
|
||||
}
|
||||
|
||||
override async showWindowOnRight(viewType: string): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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 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 };
|
||||
|
||||
12
updates.md
12
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
|
||||
|
||||
Reference in New Issue
Block a user