P2P: Enhance status pane and card with active remote selection and replication features

- Added active P2P remote selector and creation option in the status pane.
- Introduced immediate replication action for accepted peers.
- Updated status control icons for clarity.
- Display stable Room ID suffix above Peer ID in the status card.
- Implemented dedicated active remote configuration for P2P features.
- Added migration support for P2P active remote selection.
- Improved unit test coverage for P2P settings.
This commit is contained in:
vorotamoroz
2026-05-17 13:08:51 +09:00
parent eea26dee74
commit 6ef866a77c
7 changed files with 378 additions and 54 deletions

View File

@@ -5,20 +5,21 @@
AcceptedStatus,
ConnectionStatus,
type PeerStatus,
} from "../../../lib/src/replication/trystero/P2PReplicatorPaneCommon";
import type { LiveSyncTrysteroReplicator } from "../../../lib/src/replication/trystero/LiveSyncTrysteroReplicator";
} from "@lib/replication/trystero/P2PReplicatorPaneCommon";
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
import PeerStatusRow from "../P2PReplicator/PeerStatusRow.svelte";
import { EVENT_LAYOUT_READY, eventHub } from "../../../common/events";
import { EVENT_LAYOUT_READY, eventHub } from "@/common/events";
import {
type PeerInfo,
type P2PServerInfo,
EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS,
EVENT_P2P_REPLICATOR_STATUS,
} from "../../../lib/src/replication/trystero/TrysteroReplicatorP2PServer";
import { type P2PReplicatorStatus } from "../../../lib/src/replication/trystero/TrysteroReplicator";
import { $msg as _msg } from "../../../lib/src/common/i18n";
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../lib/src/common/types";
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
import { type P2PReplicatorStatus } from "@lib/replication/trystero/TrysteroReplicator";
import { $msg as _msg } from "@lib/common/i18n";
import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
import { generateP2PRoomId } from "@lib/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
interface Props {
@@ -148,6 +149,7 @@
eventHub.emitEvent(EVENT_REQUEST_STATUS);
return () => {
r();
rx();
r2();
r3();
};
@@ -216,18 +218,8 @@
function useDefaultRelay() {
eRelay = DEFAULT_SETTINGS.P2P_relays;
}
function _generateRandom() {
return (Math.floor(Math.random() * 1000) + 1000).toString().substring(1);
}
function generateRandom(length: number) {
let buf = "";
while (buf.length < length) {
buf += "-" + _generateRandom();
}
return buf.substring(1, length);
}
function chooseRandom() {
eRoomId = generateRandom(12) + "-" + Math.random().toString(36).substring(2, 5);
eRoomId = generateP2PRoomId();
}
async function openServer() {
@@ -251,7 +243,7 @@
setting?: boolean;
};
return initialDialogStatus;
} catch (e) {
} catch {
return {};
}
};

View File

@@ -8,17 +8,22 @@
EVENT_REQUEST_STATUS,
EVENT_P2P_REPLICATOR_STATUS,
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PReplicatorStatus } from "@/lib/src/replication/trystero/TrysteroReplicator";
import { extractP2PRoomSuffix } from "@/lib/src/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
interface Props {
liveSyncReplicator: LiveSyncTrysteroReplicator;
showBroadcastToggle?: boolean;
core?: LiveSyncBaseCore;
}
let { liveSyncReplicator, showBroadcastToggle = true }: Props = $props();
let { liveSyncReplicator, showBroadcastToggle = true, core }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let replicatorStatus = $state<P2PReplicatorStatus | undefined>(undefined);
let roomSuffix = $state<string>(extractP2PRoomSuffix(core?.services.setting.currentSettings()?.P2P_roomID ?? ""));
async function requestServerStatus() {
await Promise.resolve(liveSyncReplicator.requestStatus());
@@ -46,10 +51,14 @@
onMount(() => {
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
serverInfo = status;
roomSuffix = extractP2PRoomSuffix(status?.roomId ?? "");
});
const unsubscribeStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorStatus = status;
});
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
roomSuffix = extractP2PRoomSuffix(settings?.P2P_roomID ?? "");
});
fireAndForget(async () => {
await delay(100);
@@ -59,6 +68,7 @@
return () => {
unsubscribe();
unsubscribeStatus();
unsubscribeSettings();
};
});
@@ -85,6 +95,13 @@
</div>
{#if serverInfo}
<div class="status-item">
<span>Room ID suffix:</span>
<span class="room-suffix-display" title={roomSuffix || "Not configured"}>
{roomSuffix || "-"}
</span>
</div>
<div class="status-item">
<span>Peer ID:</span>
<span class="peer-id-display" title={serverInfo.serverPeerId}>
@@ -162,6 +179,12 @@
text-overflow: ellipsis;
}
.room-suffix-display {
font-family: monospace;
font-size: 0.85rem;
font-weight: 600;
}
.broadcast-row {
align-items: center;
margin-top: 0.25rem;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
import { EVENT_LAYOUT_READY, EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
import {
EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS,
@@ -8,12 +8,21 @@
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 type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PReplicatorStatus, P2PReplicationReport } from "@lib/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";
import { ConnectionStringParser } from "@lib/common/ConnectionString";
import type { P2PSyncSetting, RemoteConfiguration } from "@lib/common/models/setting.type";
import {
activateP2PRemoteConfiguration,
createRemoteConfigurationId,
} from "@lib/serviceFeatures/remoteConfig";
import { extractP2PRoomSuffix } from "@lib/common/utils";
import { SetupManager } from "@/modules/features/SetupManager";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
interface Props {
liveSyncReplicator: LiveSyncTrysteroReplicator;
@@ -24,11 +33,22 @@
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
let decidingPeerId = $state<string | null>(null);
let replicatingPeerId = $state<string | null>(null);
let communicatingUntil = $state<Record<string, number>>({});
const COMMUNICATION_HOLD_MS = 2500;
let syncOnReplicationSetting = $state(
core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? ""
);
type P2PRemoteOption = {
id: string;
name: string;
roomSuffix: string;
};
let p2pRemoteOptions = $state<P2PRemoteOption[]>([]);
let selectedP2PRemoteConfigurationId = $state(
core.services.setting.currentSettings()?.P2P_ActiveRemoteConfigurationId ?? ""
);
let selectingP2PRemote = $state(false);
function addToList(item: string, list: string): string {
const items = list.split(",").map((e) => e.trim()).filter((e) => e);
@@ -50,6 +70,57 @@
}, COMMUNICATION_HOLD_MS + 100);
}
function listP2PRemoteOptions(
remoteConfigurations: Record<string, RemoteConfiguration> | undefined
): P2PRemoteOption[] {
return Object.values(remoteConfigurations ?? {})
.map((config) => {
try {
const parsed = ConnectionStringParser.parse(config.uri);
if (parsed.type !== "p2p") {
return undefined;
}
return {
id: config.id,
name: config.name,
roomSuffix: extractP2PRoomSuffix(parsed.settings.P2P_roomID ?? ""),
} as P2PRemoteOption;
} catch {
return undefined;
}
})
.filter((e): e is P2PRemoteOption => !!e);
}
function refreshP2PRemoteOptions() {
const settings = core.services.setting.currentSettings();
const options = listP2PRemoteOptions(settings.remoteConfigurations);
p2pRemoteOptions = options;
const currentSelected = settings.P2P_ActiveRemoteConfigurationId ?? "";
const isCurrentSelectedValid = options.some((option) => option.id === currentSelected);
if (options.length === 0) {
selectedP2PRemoteConfigurationId = "";
return;
}
if (currentSelected.trim() === "" || !isCurrentSelectedValid) {
const fallbackId = options[0].id;
selectedP2PRemoteConfigurationId = fallbackId;
if (currentSelected !== fallbackId) {
fireAndForget(() => applyP2PActiveRemoteSelection(fallbackId));
}
return;
}
selectedP2PRemoteConfigurationId = currentSelected;
}
function canEditP2PSettings() {
const selected = selectedP2PRemoteConfigurationId.trim();
if (selected === "") {
return false;
}
return p2pRemoteOptions.some((e) => e.id === selected);
}
async function requestServerStatus() {
await liveSyncReplicator.requestStatus();
eventHub.emitEvent(EVENT_REQUEST_STATUS);
@@ -77,10 +148,16 @@
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
});
const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
refreshP2PRemoteOptions();
void requestServerStatus();
});
fireAndForget(async () => {
await delay(100);
refreshP2PRemoteOptions();
await requestServerStatus();
});
@@ -89,6 +166,7 @@
unsubscribeReplicatorStatus();
unsubscribeReplicatorProgress();
unsubscribeSettings();
unsubscribeLayoutReady();
};
});
@@ -110,6 +188,113 @@
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS);
}
async function applyP2PActiveRemoteSelection(id: string) {
selectingP2PRemote = true;
try {
await core.services.setting.updateSettings((settings) => {
settings.P2P_ActiveRemoteConfigurationId = id;
if (id.trim() === "") {
return settings;
}
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
} finally {
selectingP2PRemote = false;
}
}
async function onP2PRemoteSelected(event: Event) {
const target = event.currentTarget as HTMLSelectElement;
const id = target.value;
selectedP2PRemoteConfigurationId = id;
await applyP2PActiveRemoteSelection(id);
}
async function createAndSelectP2PRemote() {
const setupManager = core.getModule(SetupManager);
const dialogManager = setupManager.dialogManager;
const currentSettings = core.services.setting.currentSettings();
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSettings);
if (p2pConf === "cancelled" || typeof p2pConf !== "object" || !p2pConf) {
return;
}
const p2pSettings = p2pConf as Partial<P2PSyncSetting>;
const id = createRemoteConfigurationId();
const roomSuffix = extractP2PRoomSuffix(p2pSettings.P2P_roomID ?? "");
const name = roomSuffix ? `P2P Remote (${roomSuffix})` : "P2P Remote";
await core.services.setting.updateSettings((settings) => {
const merged = {
...settings,
...p2pSettings,
};
const uri = ConnectionStringParser.serialize({ type: "p2p", settings: merged });
settings.remoteConfigurations = {
...(settings.remoteConfigurations ?? {}),
[id]: {
id,
name,
uri,
isEncrypted: false,
},
};
settings.P2P_ActiveRemoteConfigurationId = id;
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
}
async function updateSelectedP2PRemote(partial: Partial<P2PSyncSetting>) {
const selectedId = core.services.setting.currentSettings().P2P_ActiveRemoteConfigurationId?.trim() ?? "";
if (selectedId === "") {
return;
}
await core.services.setting.updateSettings((settings) => {
const config = settings.remoteConfigurations?.[selectedId];
if (!config) {
return settings;
}
let parsed;
try {
parsed = ConnectionStringParser.parse(config.uri);
} catch {
return settings;
}
if (parsed.type !== "p2p") {
return settings;
}
const mergedP2P = {
...parsed.settings,
...partial,
};
const uri = ConnectionStringParser.serialize({
type: "p2p",
settings: {
...settings,
...mergedP2P,
},
});
settings.remoteConfigurations = {
...(settings.remoteConfigurations ?? {}),
[selectedId]: {
...config,
uri,
isEncrypted: false,
},
};
Object.assign(settings, partial);
const activated = activateP2PRemoteConfiguration(settings, selectedId);
return activated || settings;
}, true);
syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
}
async function makeDecision(
peer: P2PServerInfo["knownAdvertisements"][number],
decision: boolean,
@@ -142,6 +327,19 @@
}
}
async function startReplication(peer: P2PServerInfo["knownAdvertisements"][number]) {
replicatingPeerId = peer.peerId;
try {
const pullResult = await liveSyncReplicator.replicateFrom(peer.peerId, true);
if (pullResult?.ok) {
await liveSyncReplicator.requestSynchroniseToPeer(peer.peerId);
}
await requestServerStatus();
} finally {
replicatingPeerId = null;
}
}
function isAccepted(peer: P2PServerInfo["knownAdvertisements"][number]) {
return peer.isTemporaryAccepted === true || peer.isAccepted === true;
}
@@ -151,6 +349,9 @@
}
function toggleWatch(peerId: string) {
if (!canEditP2PSettings()) {
return;
}
if (isWatching(peerId)) {
liveSyncReplicator.unwatchPeer(peerId);
} else {
@@ -175,28 +376,59 @@
}
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (!canEditP2PSettings()) {
return;
}
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
const newValue = isSyncTarget(peer.name)
? removeFromList(peer.name, currentValue)
: addToList(peer.name, currentValue);
await core.services.setting.applyPartial({ P2P_SyncOnReplication: newValue }, true);
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue });
}
</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 class="pane-header-actions">
<div class="remote-picker-wrap">
<select
class="remote-picker"
value={selectedP2PRemoteConfigurationId}
onchange={onP2PRemoteSelected}
disabled={selectingP2PRemote}
aria-label="Select active P2P remote"
title="Select active P2P remote"
>
{#if p2pRemoteOptions.length === 0}
<option value="">Select P2P remote...</option>
{/if}
{#each p2pRemoteOptions as option}
<option value={option.id}>
{option.name}{option.roomSuffix ? ` (${option.roomSuffix})` : ""}
</option>
{/each}
</select>
<button class="icon-button" onclick={() => createAndSelectP2PRemote()} title="Create P2P remote" aria-label="Create P2P remote">
+
</button>
</div>
<button
class="icon-button"
onclick={openConnectionSettings}
title="Open P2P Setup..."
aria-label="Open P2P Setup..."
>
</button>
</div>
</div>
<P2PServerStatusCard {liveSyncReplicator} />
{#if !canEditP2PSettings()}
<p class="warning-line">Please select an active P2P remote configuration to change P2P sync targets.</p>
{/if}
<P2PServerStatusCard {liveSyncReplicator} {core} />
<div class="peers-section">
<div class="peers-header">
@@ -225,6 +457,15 @@
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
{getAcceptanceStatus(peer)}
</span>
<button
class="emoji-button"
disabled={replicatingPeerId !== null}
title={replicatingPeerId === peer.peerId ? 'Replicating...' : 'Replicate now'}
aria-label={replicatingPeerId === peer.peerId ? 'Replicating' : 'Replicate now'}
onclick={() => startReplication(peer)}
>
{replicatingPeerId === peer.peerId ? '⏳' : '🔄'}
</button>
<button
class="action-button"
disabled={decidingPeerId !== null}
@@ -239,9 +480,10 @@
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'}
disabled={!canEditP2PSettings()}
onclick={() => toggleWatch(peer.peerId)}
>
{isWatching(peer.peerId) ? '👁' : '👁‍🗨'}
{isWatching(peer.peerId) ? '🔔' : '🔕'}
</button>
</div> <div class="decision-row watch-row">
<span class="decision-label">SYNC</span>
@@ -249,9 +491,10 @@
class="emoji-button {isSyncTarget(peer.name) ? 'is-watching' : ''}"
title={isSyncTarget(peer.name) ? 'Sync target \u2014 click to remove' : 'Set as sync target'}
aria-label={isSyncTarget(peer.name) ? 'Remove sync target' : 'Set sync target'}
disabled={!canEditP2PSettings()}
onclick={() => toggleSyncTarget(peer)}
>
{isSyncTarget(peer.name) ? '🔄' : '🔁'}
{isSyncTarget(peer.name) ? '🔗' : '⛓️‍💥'}
</button>
</div> {:else}
<div class="decision-status">
@@ -345,6 +588,37 @@
gap: 0.5rem;
}
.pane-header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.remote-picker-wrap {
display: inline-flex;
gap: 0.3rem;
align-items: center;
min-width: 0;
}
.remote-picker {
max-width: 14rem;
min-width: 8rem;
height: 1.9rem;
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
padding: 0 0.45rem;
}
.warning-line {
margin: -0.2rem 0 0;
font-size: 0.82rem;
color: var(--text-warning);
}
.pane-header h2 {
margin: 0;
font-size: 1.1rem;
@@ -511,7 +785,7 @@
}
.accepted-row {
grid-template-columns: 1fr auto;
grid-template-columns: 1fr auto auto;
}
.decision-label {