mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-28 14:27:08 +00:00
refactor: compose browser application runtimes
This commit is contained in:
+14
-25
@@ -1,37 +1,26 @@
|
||||
# A pseudo client for Self-hosted LiveSync Peer-to-Peer Sync mode
|
||||
# Self-hosted LiveSync WebPeer
|
||||
|
||||
## What is it for?
|
||||
WebPeer is a browser-hosted, P2P-only Self-hosted LiveSync peer. It can receive database changes from one peer and provide them to another without materialising ordinary Vault files.
|
||||
|
||||
This is a pseudo client for the Self-hosted LiveSync Peer-to-Peer Sync mode. It is a simple pure-client-side web-application that can be connected to the Self-hosted LiveSync in peer-to-peer.
|
||||
|
||||
As long as you have a browser, it starts up, so if you leave it opened some device, it can replace your existing remote servers such as CouchDB.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Of course, it has not been fully tested. Rather, it was created to be tested.
|
||||
|
||||
This pseudo client actually receives the data from other devices, and sends if some device requests it. However, it does not store **files** in the local storage. If you want to purge the data, please purge the browser's cache and indexedDB, local storage, etc.
|
||||
|
||||
## How to use it?
|
||||
|
||||
We can build the application from the repository root by running the following command:
|
||||
Build it from the repository root:
|
||||
|
||||
```bash
|
||||
npm run build -w webpeer
|
||||
npm run build --workspace webpeer
|
||||
```
|
||||
|
||||
Or from the package directory:
|
||||
Serve `src/apps/webpeer/dist/` over HTTPS, or from `localhost`, open `index.html`, and configure the same Group ID, passphrase, signalling relay, and optional TURN settings as the other peers. Keep the page open while it is expected to announce or transfer changes.
|
||||
|
||||
WebPeer stores its settings, metadata, and chunks in origin-scoped browser storage. Clearing site data removes this state. Browser suspension, storage eviction, tab lifecycle, and network policy mean that WebPeer is not an always-on server.
|
||||
|
||||
The app-owned unit and Chromium tests can be run with:
|
||||
|
||||
```bash
|
||||
cd src/apps/webpeer
|
||||
npm run build
|
||||
npm run test:unit --workspace webpeer
|
||||
npm run test:browser --workspace webpeer
|
||||
```
|
||||
|
||||
Then, open `dist/index.html` in the browser. It can be configured in the same way as Self-hosted LiveSync (the same components are used[^1]).
|
||||
The unit tests are stored in `test/apps/webpeer/`, outside the Community Review source boundary.
|
||||
|
||||
## Some notes
|
||||
|
||||
I will launch this application in the github pages later, so will be able to use it without building it. However, that shares the origin. Hence, the application that your have built and deployed would be more secure.
|
||||
|
||||
|
||||
[^1]: Congrats! I made it modular. Finally...
|
||||
## Licence
|
||||
|
||||
The same licence as the main Self-hosted LiveSync project applies.
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-webpeer ../../..",
|
||||
"run:docker": "docker run -p 8001:80 livesync-webpeer",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
|
||||
"test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webpeer",
|
||||
"pretest:browser": "npm run build",
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import { defaultLoggerEnv, setGlobalLogFunction } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const logs = writable([] as string[]);
|
||||
|
||||
let _logs = [] as string[];
|
||||
|
||||
const maxLines = 10000;
|
||||
setGlobalLogFunction((msg, level) => {
|
||||
const msgstr = typeof msg === "string" ? msg : JSON.stringify(msg);
|
||||
const strLog = `${new Date().toISOString()}\u2001${msgstr}`;
|
||||
_logs.push(strLog);
|
||||
if (_logs.length > maxLines) {
|
||||
_logs = _logs.slice(_logs.length - maxLines);
|
||||
}
|
||||
logs.set(_logs);
|
||||
});
|
||||
defaultLoggerEnv.minLogLevel = LOG_LEVEL_VERBOSE;
|
||||
|
||||
export const storeP2PStatusLine = writable("");
|
||||
@@ -1,341 +0,0 @@
|
||||
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
||||
import {
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
REMOTE_P2P,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import { LOG_LEVEL_NOTICE, Logger, type LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import {
|
||||
EVENT_P2P_PEER_SHOW_EXTRA_MENU,
|
||||
type PeerStatus,
|
||||
type PluginShim,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import { useP2PReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorCore";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import type { P2PReplicatorBase } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorBase";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { Menu } from "@/apps/browser/BrowserMenu";
|
||||
import { SimpleStoreIDBv2 } from "octagonal-wheels/databases/SimpleStoreIDBv2";
|
||||
import type { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
|
||||
import type { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { createLiveSyncBrowserServiceHub } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
|
||||
function addToList(item: string, list: string) {
|
||||
return unique(
|
||||
list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.concat(item)
|
||||
.filter((p) => p)
|
||||
).join(",");
|
||||
}
|
||||
function removeFromList(item: string, list: string) {
|
||||
return list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((p) => p !== item)
|
||||
.filter((p) => p)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export class P2PReplicatorShim implements P2PReplicatorBase {
|
||||
storeP2PStatusLine = reactiveSource("");
|
||||
plugin!: PluginShim;
|
||||
confirm!: Confirm;
|
||||
db?: PouchDB.Database<EntryDoc>;
|
||||
services: InjectableServiceHub<ServiceContext>;
|
||||
|
||||
getDB() {
|
||||
if (!this.db) {
|
||||
throw new Error("DB not initialized");
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
_simpleStore!: SimpleStore<unknown>;
|
||||
|
||||
async closeDB() {
|
||||
if (this.db) {
|
||||
await this.db.close();
|
||||
this.db = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _liveSyncReplicator?: LiveSyncTrysteroReplicator;
|
||||
p2pLogCollector!: P2PLogCollector;
|
||||
|
||||
private _initP2PReplicator() {
|
||||
const {
|
||||
replicator,
|
||||
p2pLogCollector,
|
||||
storeP2PStatusLine: p2pStatusLine,
|
||||
} = useP2PReplicator({ services: this.services } as unknown as Parameters<typeof useP2PReplicator>[0]);
|
||||
this._liveSyncReplicator = replicator;
|
||||
this.p2pLogCollector = p2pLogCollector;
|
||||
p2pLogCollector.p2pReplicationLine.onChanged((line) => {
|
||||
p2pStatusLine.value = line.value;
|
||||
});
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const browserServiceHub = createLiveSyncBrowserServiceHub<ServiceContext>();
|
||||
this.services = browserServiceHub;
|
||||
|
||||
(this.services.API as BrowserAPIService<ServiceContext>).getSystemVaultName.setHandler(
|
||||
() => "p2p-livesync-web-peer"
|
||||
);
|
||||
const repStore = SimpleStoreIDBv2.open<unknown>("p2p-livesync-web-peer");
|
||||
this._simpleStore = repStore;
|
||||
let _settings = { ...P2P_DEFAULT_SETTINGS, additionalSuffixOfDatabaseName: "" } as ObsidianLiveSyncSettings;
|
||||
this.services.setting.settings = _settings;
|
||||
(this.services.setting as InjectableSettingService<ServiceContext>).saveData.setHandler(async (data) => {
|
||||
await repStore.set("settings", data);
|
||||
this.services.context.events.emitEvent(EVENT_SETTING_SAVED, data);
|
||||
});
|
||||
(this.services.setting as InjectableSettingService<ServiceContext>).loadData.setHandler(async () => {
|
||||
const settings = { ..._settings, ...((await repStore.get("settings")) as ObsidianLiveSyncSettings) };
|
||||
return settings;
|
||||
});
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.services.setting.currentSettings() as P2PSyncSetting;
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.confirm = this.services.UI.confirm;
|
||||
|
||||
if (this.db) {
|
||||
try {
|
||||
await this.closeDB();
|
||||
} catch (ex) {
|
||||
Logger("Error closing db", LOG_LEVEL_VERBOSE);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
await this.services.setting.loadSettings();
|
||||
this.plugin = {
|
||||
services: this.services,
|
||||
core: {
|
||||
services: this.services,
|
||||
},
|
||||
};
|
||||
const database_name = this.settings.P2P_AppID + "-" + this.settings.P2P_roomID + "p2p-livesync-web-peer";
|
||||
this.db = new PouchDB<EntryDoc>(database_name);
|
||||
|
||||
this._initP2PReplicator();
|
||||
|
||||
compatGlobal.setTimeout(() => {
|
||||
if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) {
|
||||
void this.open();
|
||||
}
|
||||
}, 1000);
|
||||
return this;
|
||||
}
|
||||
|
||||
_log(msg: unknown, level?: LOG_LEVEL): void {
|
||||
Logger(msg, level);
|
||||
}
|
||||
_notice(msg: string, key?: string): void {
|
||||
Logger(msg, LOG_LEVEL_NOTICE, key);
|
||||
}
|
||||
getSettings(): P2PSyncSetting {
|
||||
return this.settings;
|
||||
}
|
||||
simpleStore(): SimpleStore<unknown> {
|
||||
return this._simpleStore;
|
||||
}
|
||||
handleReplicatedDocuments(_docs: EntryDoc[]): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
getConfig(key: string) {
|
||||
const vaultName = this.services.vault.getVaultName();
|
||||
const dbKey = `${vaultName}-${key}`;
|
||||
return compatGlobal.localStorage.getItem(dbKey);
|
||||
}
|
||||
setConfig(key: string, value: string) {
|
||||
const vaultName = this.services.vault.getVaultName();
|
||||
const dbKey = `${vaultName}-${key}`;
|
||||
compatGlobal.localStorage.setItem(dbKey, value);
|
||||
}
|
||||
|
||||
getDeviceName(): string {
|
||||
return this.getConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? this.plugin.services.vault.getVaultName();
|
||||
}
|
||||
|
||||
m?: Menu;
|
||||
afterConstructor(): void {
|
||||
this.services.context.events.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => {
|
||||
if (this.m) {
|
||||
this.m.hide();
|
||||
}
|
||||
this.m = new Menu()
|
||||
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer)))
|
||||
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer)))
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.watchOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Watch on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "watchOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnReplicationCommand ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on `Replicate now` command")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnReplicationCommand");
|
||||
})
|
||||
.setIcon(mark);
|
||||
});
|
||||
void this.m.showAtPosition({ x: event.x, y: event.y });
|
||||
});
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this._liveSyncReplicator?.open();
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this._liveSyncReplicator?.close();
|
||||
}
|
||||
|
||||
enableBroadcastCastings() {
|
||||
return this._liveSyncReplicator?.enableBroadcastChanges();
|
||||
}
|
||||
disableBroadcastCastings() {
|
||||
return this._liveSyncReplicator?.disableBroadcastChanges();
|
||||
}
|
||||
|
||||
enableBroadcastChanges() {
|
||||
return this._liveSyncReplicator?.enableBroadcastChanges();
|
||||
}
|
||||
|
||||
disableBroadcastChanges() {
|
||||
return this._liveSyncReplicator?.disableBroadcastChanges();
|
||||
}
|
||||
|
||||
async makeDecision(decision: Parameters<LiveSyncTrysteroReplicator["makeDecision"]>[0]): Promise<void> {
|
||||
await this._liveSyncReplicator?.makeDecision(decision);
|
||||
}
|
||||
|
||||
async revokeDecision(decision: Parameters<LiveSyncTrysteroReplicator["revokeDecision"]>[0]): Promise<void> {
|
||||
await this._liveSyncReplicator?.revokeDecision(decision);
|
||||
}
|
||||
|
||||
watchPeer(peerId: string): void {
|
||||
this._liveSyncReplicator?.watchPeer(peerId);
|
||||
}
|
||||
|
||||
unwatchPeer(peerId: string): void {
|
||||
this._liveSyncReplicator?.unwatchPeer(peerId);
|
||||
}
|
||||
|
||||
async sync(peerId: string, showNotice?: boolean): Promise<unknown> {
|
||||
return await this._liveSyncReplicator?.sync(peerId, showNotice);
|
||||
}
|
||||
|
||||
get replicator() {
|
||||
return this._liveSyncReplicator;
|
||||
}
|
||||
|
||||
async replicateFrom(peer: PeerStatus) {
|
||||
const r = this._liveSyncReplicator;
|
||||
if (!r) return;
|
||||
await r.replicateFrom(peer.peerId);
|
||||
}
|
||||
|
||||
async replicateTo(peer: PeerStatus) {
|
||||
await this._liveSyncReplicator?.requestSynchroniseToPeer(peer.peerId);
|
||||
}
|
||||
|
||||
async getRemoteConfig(peer: PeerStatus) {
|
||||
Logger(
|
||||
`Requesting remote config for ${peer.name}. Please input the passphrase on the remote device`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const remoteConfig = await this._liveSyncReplicator?.getRemoteConfig(peer.peerId);
|
||||
if (remoteConfig) {
|
||||
Logger(`Remote config for ${peer.name} is retrieved successfully`);
|
||||
const DROP = "Yes, and drop local database";
|
||||
const KEEP = "Yes, but keep local database";
|
||||
const CANCEL = "No, cancel";
|
||||
const yn = await this.confirm.askSelectStringDialogue(
|
||||
`Do you really want to apply the remote config? This will overwrite your current config immediately and restart.
|
||||
And you can also drop the local database to rebuild from the remote device.`,
|
||||
[DROP, KEEP, CANCEL] as const,
|
||||
{
|
||||
defaultAction: CANCEL,
|
||||
title: "Apply Remote Config ",
|
||||
}
|
||||
);
|
||||
if (yn === DROP || yn === KEEP) {
|
||||
if (yn === DROP) {
|
||||
if (remoteConfig.remoteType !== REMOTE_P2P) {
|
||||
const yn2 = await this.confirm.askYesNoDialog(
|
||||
`Do you want to set the remote type to "P2P Sync" to rebuild by "P2P replication"?`,
|
||||
{ title: "Rebuild from remote device" }
|
||||
);
|
||||
if (yn2 === "yes") {
|
||||
remoteConfig.remoteType = REMOTE_P2P;
|
||||
remoteConfig.P2P_RebuildFrom = peer.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.services.setting.applyExternalSettings(remoteConfig, true);
|
||||
if (yn !== DROP) {
|
||||
this.plugin.core.services.appLifecycle.scheduleRestart();
|
||||
}
|
||||
} else {
|
||||
Logger(`Cancelled\nRemote config for ${peer.name} is not applied`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
} else {
|
||||
Logger(`Cannot retrieve remote config for ${peer.peerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleProp(peer: PeerStatus, prop: "syncOnConnect" | "watchOnConnect" | "syncOnReplicationCommand") {
|
||||
const settingMap = {
|
||||
syncOnConnect: "P2P_AutoSyncPeers",
|
||||
watchOnConnect: "P2P_AutoWatchPeers",
|
||||
syncOnReplicationCommand: "P2P_SyncOnReplication",
|
||||
} as const;
|
||||
|
||||
const targetSetting = settingMap[prop];
|
||||
const currentSettingAll = this.plugin.core.services.setting.currentSettings();
|
||||
const currentSetting = {
|
||||
[targetSetting]: currentSettingAll ? currentSettingAll[targetSetting] : "",
|
||||
};
|
||||
if (peer[prop]) {
|
||||
currentSetting[targetSetting] = removeFromList(peer.name, currentSetting[targetSetting]);
|
||||
} else {
|
||||
currentSetting[targetSetting] = addToList(peer.name, currentSetting[targetSetting]);
|
||||
}
|
||||
await this.plugin.core.services.setting.applyPartial(currentSetting, true);
|
||||
}
|
||||
}
|
||||
|
||||
export const cmdSyncShim = new P2PReplicatorShim();
|
||||
@@ -1,34 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { storeP2PStatusLine, logs } from "./CommandsShim";
|
||||
import { logs } from "./WebPeerLogs";
|
||||
import BrowserP2PTransportSettings from "@/apps/browser/BrowserP2PTransportSettings.svelte";
|
||||
import P2PReplicatorPane from "@/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { cmdSyncShim } from "./P2PReplicatorShim";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { WebPeerRuntime } from "./WebPeerRuntime";
|
||||
|
||||
let synchronised = $state(cmdSyncShim.init());
|
||||
const runtime = new WebPeerRuntime();
|
||||
const synchronised = runtime.start();
|
||||
let elP: HTMLDivElement;
|
||||
let statusLine = $state(runtime.statusLine.value);
|
||||
|
||||
onMount(() => {
|
||||
void synchronised.then((shim) => shim.services.context.events.emitEvent(EVENT_LAYOUT_READY));
|
||||
const onStatusLineChanged = (line: { readonly value: string }) => {
|
||||
statusLine = line.value;
|
||||
};
|
||||
runtime.statusLine.onChanged(onStatusLineChanged);
|
||||
const unsubscribeLogs = logs.subscribe(() => {
|
||||
void tick().then(() => elP?.scrollTo({ top: elP.scrollHeight }));
|
||||
});
|
||||
return () => {
|
||||
synchronised.then((e) => e.close());
|
||||
runtime.statusLine.offChanged(onStatusLineChanged);
|
||||
unsubscribeLogs();
|
||||
void runtime.shutdown();
|
||||
};
|
||||
});
|
||||
let elP: HTMLDivElement;
|
||||
logs.subscribe((log) => {
|
||||
tick().then(() => elP?.scrollTo({ top: elP.scrollHeight }));
|
||||
});
|
||||
let statusLine = $state("");
|
||||
storeP2PStatusLine.subscribe((status) => {
|
||||
statusLine = status;
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="control">
|
||||
{#await synchronised then cmdSync}
|
||||
<P2PReplicatorPane {cmdSync} core={cmdSync.plugin.core}></P2PReplicatorPane>
|
||||
{#await synchronised then activeRuntime}
|
||||
<BrowserP2PTransportSettings host={activeRuntime.paneHost} />
|
||||
<P2PReplicatorPane host={activeRuntime.paneHost}></P2PReplicatorPane>
|
||||
{:catch error}
|
||||
<p>{error.message}</p>
|
||||
<p>{error instanceof Error ? error.message : String(error)}</p>
|
||||
{/await}
|
||||
</div>
|
||||
<div class="log">
|
||||
|
||||
@@ -19,16 +19,16 @@
|
||||
|
||||
async function testMenu(event: MouseEvent) {
|
||||
const m = new Menu()
|
||||
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => {}))
|
||||
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => {}))
|
||||
.addItem((item) => item.setTitle("📥 Only fetch").onClick(() => {}))
|
||||
.addItem((item) => item.setTitle("📤 Only send").onClick(() => {}))
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("🔧 Get Configuration").onClick(async () => {});
|
||||
item.setTitle("🔧 Get configuration").onClick(async () => {});
|
||||
})
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
const mark = "checkmark";
|
||||
item.setTitle("Toggle Sync on connect")
|
||||
item.setTitle("Toggle sync on connect")
|
||||
.onClick(async () => {
|
||||
// await this.toggleProp(peer, "syncOnConnect");
|
||||
})
|
||||
@@ -36,7 +36,7 @@
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = null;
|
||||
item.setTitle("Toggle Watch on connect")
|
||||
item.setTitle("Toggle watch on connect")
|
||||
.onClick(async () => {
|
||||
// await this.toggleProp(peer, "watchOnConnect");
|
||||
})
|
||||
@@ -44,7 +44,7 @@
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = null;
|
||||
item.setTitle("Toggle Sync on `Replicate now` command")
|
||||
item.setTitle("Toggle sync on `Replicate now` command")
|
||||
.onClick(async () => {})
|
||||
.setIcon(mark);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { defaultLoggerEnv, setGlobalLogFunction } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const logs = writable<string[]>([]);
|
||||
|
||||
let bufferedLogs: string[] = [];
|
||||
const maxLines = 10_000;
|
||||
|
||||
setGlobalLogFunction((message) => {
|
||||
const messageText = typeof message === "string" ? message : JSON.stringify(message);
|
||||
bufferedLogs.push(`${new Date().toISOString()}\u2001${messageText}`);
|
||||
if (bufferedLogs.length > maxLines) {
|
||||
bufferedLogs = bufferedLogs.slice(bufferedLogs.length - maxLines);
|
||||
}
|
||||
logs.set(bufferedLogs);
|
||||
});
|
||||
defaultLoggerEnv.minLogLevel = LOG_LEVEL_VERBOSE;
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
REMOTE_P2P,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { SimpleStoreIDBv2 } from "octagonal-wheels/databases/SimpleStoreIDBv2";
|
||||
|
||||
import type { LiveSyncBrowserSettingsPersistence } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
|
||||
export const WEBPEER_STORE_NAME = "p2p-livesync-web-peer";
|
||||
export const WEBPEER_SETTINGS_KEY = "settings";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Creates WebPeer-owned settings persistence without retaining legacy database names. */
|
||||
export function createWebPeerPersistence(
|
||||
store: SimpleStore<unknown> = SimpleStoreIDBv2.open<unknown>(WEBPEER_STORE_NAME)
|
||||
): {
|
||||
readonly store: SimpleStore<unknown>;
|
||||
readonly settings: LiveSyncBrowserSettingsPersistence;
|
||||
} {
|
||||
const settings: LiveSyncBrowserSettingsPersistence = {
|
||||
async load() {
|
||||
const savedSettings = await store.get(WEBPEER_SETTINGS_KEY);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...P2P_DEFAULT_SETTINGS,
|
||||
additionalSuffixOfDatabaseName: "",
|
||||
suspendParseReplicationResult: true,
|
||||
...(isRecord(savedSettings) ? savedSettings : {}),
|
||||
remoteType: REMOTE_P2P,
|
||||
isConfigured: true,
|
||||
};
|
||||
},
|
||||
async save(currentSettings) {
|
||||
await store.set(WEBPEER_SETTINGS_KEY, currentSettings);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
settings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { type P2PSyncSetting, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
import {
|
||||
createLiveSyncBrowserServiceHub,
|
||||
type LiveSyncBrowserServiceHub,
|
||||
} from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
import { Menu } from "@/apps/browser/BrowserMenu";
|
||||
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
|
||||
import { translateLiveSyncMessage } from "@/common/translation";
|
||||
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
|
||||
|
||||
export interface WebPeerRuntimeOptions {
|
||||
context?: ServiceContext;
|
||||
store?: SimpleStore<unknown>;
|
||||
}
|
||||
|
||||
function addToList(item: string, list: string): string {
|
||||
return unique(
|
||||
list
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.concat(item)
|
||||
.filter(Boolean)
|
||||
).join(",");
|
||||
}
|
||||
|
||||
function removeFromList(item: string, list: string): string {
|
||||
return list
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry !== item)
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export class WebPeerRuntime {
|
||||
readonly context: ServiceContext;
|
||||
readonly services: LiveSyncBrowserServiceHub<ServiceContext>;
|
||||
readonly p2p: UseP2PReplicatorResult;
|
||||
readonly p2pLogCollector: P2PLogCollector;
|
||||
readonly paneHost: P2PReplicatorPaneHost;
|
||||
|
||||
private menu?: Menu;
|
||||
private restartScheduled = false;
|
||||
private startPromise?: Promise<this>;
|
||||
private shutdownPromise?: Promise<void>;
|
||||
|
||||
constructor(options: WebPeerRuntimeOptions = {}) {
|
||||
const persistence = createWebPeerPersistence(options.store);
|
||||
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
||||
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
||||
context: this.context,
|
||||
getSystemVaultName: () => WEBPEER_STORE_NAME,
|
||||
settings: persistence.settings,
|
||||
restart: {
|
||||
schedule: () => this.scheduleRestart(),
|
||||
perform: () => this.scheduleRestart(),
|
||||
ask: () => this.scheduleRestart(),
|
||||
isScheduled: () => this.restartScheduled,
|
||||
},
|
||||
});
|
||||
this.p2p = useP2PReplicatorFeature({
|
||||
services: this.services,
|
||||
serviceModules: {},
|
||||
});
|
||||
this.p2pLogCollector = new P2PLogCollector(this.events);
|
||||
this.paneHost = {
|
||||
services: this.services,
|
||||
p2p: this.p2p,
|
||||
showPeerMenu: (peer, event) => this.showPeerMenu(peer, event),
|
||||
};
|
||||
}
|
||||
|
||||
get events(): LiveSyncEventHub {
|
||||
return this.context.events;
|
||||
}
|
||||
|
||||
get currentReplicator(): LiveSyncTrysteroReplicator {
|
||||
return this.p2p.replicator;
|
||||
}
|
||||
|
||||
get settings(): P2PSyncSetting {
|
||||
return this.services.setting.currentSettings();
|
||||
}
|
||||
|
||||
get statusLine() {
|
||||
return this.p2pLogCollector.p2pReplicationLine;
|
||||
}
|
||||
|
||||
start(): Promise<this> {
|
||||
this.startPromise ??= this.startRuntime();
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
private async startRuntime(): Promise<this> {
|
||||
await this.services.setting.loadSettings();
|
||||
const opened = await this.services.database.openDatabase({
|
||||
replicator: this.services.replicator,
|
||||
databaseEvents: this.services.databaseEvents,
|
||||
});
|
||||
if (!opened) {
|
||||
throw new Error("WebPeer local database could not be opened");
|
||||
}
|
||||
this.services.appLifecycle.markIsReady();
|
||||
this.events.emitEvent(EVENT_LAYOUT_READY);
|
||||
if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) {
|
||||
compatGlobal.setTimeout(() => void this.currentReplicator.open(), 100);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
shutdown(): Promise<void> {
|
||||
this.shutdownPromise ??= this.shutdownRuntime();
|
||||
return this.shutdownPromise;
|
||||
}
|
||||
|
||||
private async shutdownRuntime(): Promise<void> {
|
||||
this.menu?.hide();
|
||||
this.menu = undefined;
|
||||
if (!this.services.control.hasUnloaded()) {
|
||||
await this.services.control.onUnload();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRestart(): void {
|
||||
if (this.restartScheduled) {
|
||||
return;
|
||||
}
|
||||
this.restartScheduled = true;
|
||||
compatGlobal.setTimeout(() => compatGlobal.location.reload(), 0);
|
||||
}
|
||||
|
||||
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
||||
this.menu?.hide();
|
||||
this.menu = new Menu()
|
||||
.addItem((item) =>
|
||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||
await this.currentReplicator.replicateFrom(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addItem((item) =>
|
||||
item.setTitle("📤 Only send").onClick(async () => {
|
||||
await this.currentReplicator.requestSynchroniseToPeer(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("Toggle sync on connect")
|
||||
.onClick(() => this.togglePeerSetting(peer, "syncOnConnect"))
|
||||
.setIcon(peer.syncOnConnect ? "checkmark" : null);
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle("Toggle watch on connect")
|
||||
.onClick(() => this.togglePeerSetting(peer, "watchOnConnect"))
|
||||
.setIcon(peer.watchOnConnect ? "checkmark" : null);
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle("Toggle sync on `Replicate now` command")
|
||||
.onClick(() => this.togglePeerSetting(peer, "syncOnReplicationCommand"))
|
||||
.setIcon(peer.syncOnReplicationCommand ? "checkmark" : null);
|
||||
});
|
||||
void this.menu.showAtPosition({ x: event.x, y: event.y });
|
||||
}
|
||||
|
||||
private async togglePeerSetting(
|
||||
peer: PeerStatus,
|
||||
property: "syncOnConnect" | "watchOnConnect" | "syncOnReplicationCommand"
|
||||
): Promise<void> {
|
||||
const settingMap = {
|
||||
syncOnConnect: "P2P_AutoSyncPeers",
|
||||
watchOnConnect: "P2P_AutoWatchPeers",
|
||||
syncOnReplicationCommand: "P2P_SyncOnReplication",
|
||||
} as const;
|
||||
const settingKey = settingMap[property];
|
||||
const currentValue = this.services.setting.currentSettings()[settingKey] ?? "";
|
||||
await this.services.setting.applyPartial(
|
||||
{
|
||||
[settingKey]: peer[property]
|
||||
? removeFromList(peer.name, currentValue)
|
||||
: addToList(peer.name, currentValue),
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
getDeviceName(): string {
|
||||
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user