mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-16 20:41:18 +00:00
Compare commits
42 Commits
update_lib
...
enhance_fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d9364af36 | ||
|
|
83228e2077 | ||
|
|
a379b5bd78 | ||
|
|
4ed1749652 | ||
|
|
9a90256a8a | ||
|
|
f0628a0d2c | ||
|
|
d5e2f57781 | ||
|
|
91c9746886 | ||
|
|
75b44b1636 | ||
|
|
f1fe48c1ee | ||
|
|
437e7c0d9c | ||
|
|
5ffa7ec7ee | ||
|
|
a1859f5d2e | ||
|
|
785af8cb8f | ||
|
|
06e1f4aa4a | ||
|
|
767f22ce9c | ||
|
|
6a9bba702c | ||
|
|
de2397dc3f | ||
|
|
daaad9212e | ||
|
|
a6891374a1 | ||
|
|
b1cadf0549 | ||
|
|
95f40cc954 | ||
|
|
8deaf123d6 | ||
|
|
053813bffb | ||
|
|
cc7af03618 | ||
|
|
a130e3700e | ||
|
|
0549e901b2 | ||
|
|
e9afe06968 | ||
|
|
37715d4c9f | ||
|
|
106367fa41 | ||
|
|
538130aa91 | ||
|
|
c9d0357fec | ||
|
|
d05c76da36 | ||
|
|
d2eb6ecbaf | ||
|
|
25a6fde212 | ||
|
|
e8f8b680ef | ||
|
|
6c30f2b863 | ||
|
|
770d4af4a0 | ||
|
|
3b311248cb | ||
|
|
5772811a45 | ||
|
|
55529cd71e | ||
|
|
2e9b8b7b62 |
53
docs/p2p_sync_updates_2026.md
Normal file
53
docs/p2p_sync_updates_2026.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# User Guide: Peer-to-Peer Synchronisation (2026 Edition)
|
||||||
|
|
||||||
|
Peer-to-Peer (P2P) synchronisation has evolved significantly. This guide covers the essential setup and the new features introduced in the 2026 updates.
|
||||||
|
|
||||||
|
## 1. Core Concept: Server-less Freedom
|
||||||
|
P2P synchronisation allows your devices to talk directly to each other using WebRTC. A central server is not required for data storage, ensuring maximum privacy and "freedom."
|
||||||
|
|
||||||
|
## 2. Setting Up via P2P Status Pane
|
||||||
|
You no longer need to navigate through complex menus. Simply open the **P2P Status** (via the ribbon icon or command palette) and click the **⚙ (Cog)** icon.
|
||||||
|
|
||||||
|
This opens the **P2P Setup** dialogue where you can configure the essentials:
|
||||||
|
- **Room ID:** A unique identifier for your synchronisation group.
|
||||||
|
- **Passphrase:** Your encryption key. Ensure all your devices use the exact same passphrase.
|
||||||
|
- **Device Name:** A recognisable name for the current device (e.g., `iphone-16`).
|
||||||
|
|
||||||
|
Once you have saved the settings, return to the **P2P Status Pane** and click the **Connect** button to join the network.
|
||||||
|
|
||||||
|
*Tip: You can also toggle **Auto Connect** in the setup dialogue to automatically join the network whenever Obsidian starts.*
|
||||||
|
|
||||||
|
## 3. Real-time Control
|
||||||
|
The status pane in the right sidebar provides granular control over your synchronisation:
|
||||||
|
|
||||||
|
- **Signalling Status:** Shows if you are connected to the relay (🟢 Online).
|
||||||
|
- **Live-push (Broadcast):** Toggle "Broadcast changes" to notify other peers whenever you make an edit.
|
||||||
|
- **Watch:** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience.
|
||||||
|
- **Sync (🔄/🔁):** Mark specific peers as **sync targets**. Peers marked here will be included when you run the **"P2P: Sync with targets"** command (see section 5). Click the button next to a peer to toggle it on (🔄, highlighted) or off (🔁). This setting is persisted in your configuration.
|
||||||
|
|
||||||
|
## 4. Replication Dialogue
|
||||||
|
If you want to synchronise with a specific peer manually, use the **Replication** command or button. This opens the **Replication Dialogue** listing available devices.
|
||||||
|
|
||||||
|
Inside the dialogue, the **Server Status** card at the top confirms you are still connected while performing the sync.
|
||||||
|
|
||||||
|
Two actions are available per peer:
|
||||||
|
|
||||||
|
- **Sync** — Starts a bidirectional synchronisation (Pull then Push) and keeps the dialogue open so you can monitor progress or sync with additional peers.
|
||||||
|
- **Start Sync & Close** — Starts the same bidirectional sync in the background and **immediately closes the dialogue**, so you can continue working without waiting.
|
||||||
|
|
||||||
|
## 5. Syncing with Registered Targets via Command Palette
|
||||||
|
|
||||||
|
You can now trigger a synchronisation with all your pre-registered target peers in one step, without opening any UI.
|
||||||
|
|
||||||
|
1. Open the **Command Palette** (`Ctrl/Cmd + P`).
|
||||||
|
2. Run **"P2P: Sync with targets"**.
|
||||||
|
|
||||||
|
This command synchronises with every peer whose **SYNC** toggle is enabled in the **Detected Peers** list. If no targets are registered, or if the P2P server is not running, the command will notify you accordingly.
|
||||||
|
|
||||||
|
*Tip: Pair this command with a hotkey for a quick, keyboard-driven sync workflow.*
|
||||||
|
|
||||||
|
## 6. Technical Improvements in 2026
|
||||||
|
- **Decoupled Architecture:** The UI is now strictly separated from the core logic, making the plugin more stable across different platforms (Mobile, Desktop, and Web).
|
||||||
|
- **Svelte 5 UI:** The interface has been rebuilt for better responsiveness and clearer status indicators.
|
||||||
|
- **Security:** All data remains end-to-end encrypted. Even the signalling relay never sees your actual notes.
|
||||||
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import esbuild from "esbuild";
|
import esbuild from "esbuild";
|
||||||
import process from "process";
|
import process from "process";
|
||||||
import builtins from "builtin-modules";
|
|
||||||
import sveltePlugin from "esbuild-svelte";
|
import sveltePlugin from "esbuild-svelte";
|
||||||
import { sveltePreprocess } from "svelte-preprocess";
|
import { sveltePreprocess } from "svelte-preprocess";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|||||||
@@ -1,24 +1,11 @@
|
|||||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
|
||||||
import svelte from "eslint-plugin-svelte";
|
|
||||||
import _import from "eslint-plugin-import";
|
|
||||||
import { fixupPluginRules } from "@eslint/compat";
|
|
||||||
import tsParser from "@typescript-eslint/parser";
|
import tsParser from "@typescript-eslint/parser";
|
||||||
import path from "node:path";
|
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||||
import { fileURLToPath } from "node:url";
|
import globals from "globals";
|
||||||
import js from "@eslint/js";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import * as sveltePlugin from "eslint-plugin-svelte";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
export default defineConfig([
|
||||||
const __dirname = path.dirname(__filename);
|
globalIgnores([
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
recommendedConfig: js.configs.recommended,
|
|
||||||
allConfig: js.configs.all,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
ignores: [
|
|
||||||
"**/node_modules/*",
|
"**/node_modules/*",
|
||||||
"**/jest.config.js",
|
"**/jest.config.js",
|
||||||
"src/lib/coverage",
|
"src/lib/coverage",
|
||||||
@@ -27,6 +14,7 @@ export default [
|
|||||||
"**/tests.ts",
|
"**/tests.ts",
|
||||||
"**/**test.ts",
|
"**/**test.ts",
|
||||||
"**/**.test.ts",
|
"**/**.test.ts",
|
||||||
|
"**/*.unit.spec.ts",
|
||||||
"**/esbuild.*.mjs",
|
"**/esbuild.*.mjs",
|
||||||
"**/terser.*.mjs",
|
"**/terser.*.mjs",
|
||||||
"**/node_modules",
|
"**/node_modules",
|
||||||
@@ -44,60 +32,52 @@ export default [
|
|||||||
"src/apps/**/*",
|
"src/apps/**/*",
|
||||||
".prettierrc.*.mjs",
|
".prettierrc.*.mjs",
|
||||||
".prettierrc.mjs",
|
".prettierrc.mjs",
|
||||||
"*.config.mjs"
|
"*.config.mjs",
|
||||||
],
|
"src/apps/**/*",
|
||||||
},
|
"src/lib/src/services/implements/browser/**",
|
||||||
...compat.extends(
|
"src/lib/src/services/implements/headless/**",
|
||||||
"eslint:recommended",
|
"src/lib/src/API",
|
||||||
"plugin:@typescript-eslint/eslint-recommended",
|
]),
|
||||||
"plugin:@typescript-eslint/recommended"
|
...sveltePlugin.configs["flat/base"],
|
||||||
),
|
...obsidianmd.configs.recommended,
|
||||||
{
|
{
|
||||||
plugins: {
|
files: ["**/*.ts"],
|
||||||
"@typescript-eslint": typescriptEslint,
|
|
||||||
svelte,
|
|
||||||
import: fixupPluginRules(_import),
|
|
||||||
},
|
|
||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser },
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
ecmaVersion: 5,
|
|
||||||
sourceType: "module",
|
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: ["tsconfig.json"],
|
project: "./tsconfig.json",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
|
||||||
"@typescript-eslint/no-unused-vars": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
args: "none",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
"no-unused-labels": "off",
|
"no-unused-labels": "off",
|
||||||
"@typescript-eslint/ban-ts-comment": "off",
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
"no-prototype-builtins": "off",
|
"no-prototype-builtins": "off",
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
"require-await": "error",
|
"require-await": "error",
|
||||||
|
"obsidianmd/rule-custom-message": "off", // Temporary
|
||||||
|
"obsidianmd/ui/sentence-case": "off", // Temporary
|
||||||
"@typescript-eslint/require-await": "warn",
|
"@typescript-eslint/require-await": "warn",
|
||||||
"@typescript-eslint/no-misused-promises": "warn",
|
"@typescript-eslint/no-misused-promises": "warn",
|
||||||
"@typescript-eslint/no-floating-promises": "warn",
|
"@typescript-eslint/no-floating-promises": "warn",
|
||||||
"no-async-promise-executor": "warn",
|
"no-async-promise-executor": "warn",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||||
|
"no-constant-condition": ["error", { checkLoops: false }],
|
||||||
"no-constant-condition": [
|
},
|
||||||
"error",
|
},
|
||||||
{
|
{
|
||||||
checkLoops: false,
|
files: ["**/*.svelte"],
|
||||||
},
|
languageOptions: {
|
||||||
],
|
parserOptions: {
|
||||||
|
parser: tsParser,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
rules: {
|
||||||
|
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||||
|
"obsidianmd/no-plugin-as-component": "off", // Temporary
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "obsidian-livesync",
|
"id": "obsidian-livesync",
|
||||||
"name": "Self-hosted LiveSync",
|
"name": "Self-hosted LiveSync",
|
||||||
"version": "0.25.60",
|
"version": "0.25.63",
|
||||||
"minAppVersion": "0.9.12",
|
"minAppVersion": "1.7.2",
|
||||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
"authorUrl": "https://github.com/vrtmrz",
|
"authorUrl": "https://github.com/vrtmrz",
|
||||||
|
|||||||
1391
package-lock.json
generated
1391
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.25.60",
|
"version": "0.25.63",
|
||||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -54,15 +54,14 @@
|
|||||||
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
||||||
"test:docker-all:stop": "npm run test:docker-all:down",
|
"test:docker-all:stop": "npm run test:docker-all:down",
|
||||||
"test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop",
|
"test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop",
|
||||||
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh"
|
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh",
|
||||||
|
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chialab/esbuild-plugin-worker": "^0.19.0",
|
"@chialab/esbuild-plugin-worker": "^0.19.0",
|
||||||
"@eslint/compat": "^2.0.2",
|
|
||||||
"@eslint/eslintrc": "^3.3.4",
|
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tsconfig/svelte": "^5.0.8",
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
@@ -84,18 +83,15 @@
|
|||||||
"@vitest/browser": "^4.1.1",
|
"@vitest/browser": "^4.1.1",
|
||||||
"@vitest/browser-playwright": "^4.1.1",
|
"@vitest/browser-playwright": "^4.1.1",
|
||||||
"@vitest/coverage-v8": "^4.1.1",
|
"@vitest/coverage-v8": "^4.1.1",
|
||||||
"builtin-modules": "5.0.0",
|
|
||||||
"dotenv": "^17.3.1",
|
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "0.25.0",
|
"esbuild": "0.25.0",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
"esbuild-svelte": "^0.9.4",
|
"esbuild-svelte": "^0.9.4",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.3",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||||
"eslint-plugin-svelte": "^3.15.0",
|
"eslint-plugin-svelte": "^3.15.0",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"glob": "^13.0.6",
|
"globals": "^14.0.0",
|
||||||
"obsidian": "^1.12.3",
|
|
||||||
"playwright": "^1.58.2",
|
"playwright": "^1.58.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-load-config": "^6.0.1",
|
"postcss-load-config": "^6.0.1",
|
||||||
@@ -116,6 +112,7 @@
|
|||||||
"svelte-check": "^4.4.3",
|
"svelte-check": "^4.4.3",
|
||||||
"svelte-preprocess": "^6.0.3",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.39.0",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
"transform-pouch": "^2.0.0",
|
"transform-pouch": "^2.0.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
@@ -136,6 +133,7 @@
|
|||||||
"@trystero-p2p/nostr": "^0.23.0",
|
"@trystero-p2p/nostr": "^0.23.0",
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
|
"obsidian": "^1.12.3",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||||
|
import type PouchDB from "pouchdb-core";
|
||||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||||
import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types";
|
import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types";
|
||||||
import { __$checkInstanceBinding } from "./lib/src/dev/checks";
|
import { __$checkInstanceBinding } from "./lib/src/dev/checks";
|
||||||
@@ -123,7 +124,7 @@ export class LiveSyncBaseCore<
|
|||||||
for (const module of this.modules) {
|
for (const module of this.modules) {
|
||||||
if (module.constructor === constructor) return module as T;
|
if (module.constructor === constructor) return module as T;
|
||||||
}
|
}
|
||||||
throw new Error(`Module ${constructor} not found or not loaded.`);
|
throw new Error(`Module ${constructor.name} not found or not loaded.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,8 +161,10 @@ export class LiveSyncBaseCore<
|
|||||||
module.onBindFunction(this, this.services);
|
module.onBindFunction(this, this.services);
|
||||||
__$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not.
|
__$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not.
|
||||||
} else {
|
} else {
|
||||||
|
// module should not be never.
|
||||||
|
const moduleName = (module as unknown)?.constructor?.name ?? "unknown";
|
||||||
this.services.API.addLog(
|
this.services.API.addLog(
|
||||||
`Module ${(module as any)?.constructor?.name ?? "unknown"} does not have onBindFunction, skipping binding.`,
|
`Module ${moduleName} does not have onBindFunction, skipping binding.`,
|
||||||
LOG_LEVEL_INFO
|
LOG_LEVEL_INFO
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,15 @@ function validateP2PSettings(core: LiveSyncBaseCore<ServiceContext, any>) {
|
|||||||
settings.P2P_IsHeadless = true;
|
settings.P2P_IsHeadless = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createReplicator(core: LiveSyncBaseCore<ServiceContext, any>): LiveSyncTrysteroReplicator {
|
async function createReplicator(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> {
|
||||||
validateP2PSettings(core);
|
validateP2PSettings(core);
|
||||||
const replicator = new LiveSyncTrysteroReplicator({ services: core.services });
|
const replicator = await core.services.replicator.getNewReplicator();
|
||||||
addP2PEventHandlers(replicator);
|
if (!replicator) {
|
||||||
|
throw new Error("Failed to create replicator instance. Ensure P2P is enabled in settings.");
|
||||||
|
}
|
||||||
|
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
|
||||||
|
throw new Error("Unexpected replicator type. Expected LiveSyncTrysteroReplicator.");
|
||||||
|
}
|
||||||
return replicator;
|
return replicator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +54,7 @@ export async function collectPeers(
|
|||||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||||
timeoutSec: number
|
timeoutSec: number
|
||||||
): Promise<CLIP2PPeer[]> {
|
): Promise<CLIP2PPeer[]> {
|
||||||
const replicator = createReplicator(core);
|
const replicator = await createReplicator(core);
|
||||||
await replicator.open();
|
await replicator.open();
|
||||||
try {
|
try {
|
||||||
await delay(timeoutSec * 1000);
|
await delay(timeoutSec * 1000);
|
||||||
@@ -79,7 +84,7 @@ export async function syncWithPeer(
|
|||||||
peerToken: string,
|
peerToken: string,
|
||||||
timeoutSec: number
|
timeoutSec: number
|
||||||
): Promise<CLIP2PPeer> {
|
): Promise<CLIP2PPeer> {
|
||||||
const replicator = createReplicator(core);
|
const replicator = await createReplicator(core);
|
||||||
await replicator.open();
|
await replicator.open();
|
||||||
try {
|
try {
|
||||||
const timeoutMs = timeoutSec * 1000;
|
const timeoutMs = timeoutSec * 1000;
|
||||||
@@ -115,7 +120,7 @@ export async function syncWithPeer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> {
|
export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> {
|
||||||
const replicator = createReplicator(core);
|
const replicator = await createReplicator(core);
|
||||||
await replicator.open();
|
await replicator.open();
|
||||||
return replicator;
|
return replicator;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
|
|
||||||
// 3. Re-enable sync.
|
// 3. Re-enable sync.
|
||||||
const restoreSyncSettings = async () => {
|
const restoreSyncSettings = async () => {
|
||||||
await core.services.setting.applyPartial({
|
await core.services.setting.applyPartial(
|
||||||
|
{
|
||||||
...context.originalSyncSettings,
|
...context.originalSyncSettings,
|
||||||
suspendFileWatching: false,
|
suspendFileWatching: false,
|
||||||
}, true);
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||||
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
||||||
// via fireAndForget.
|
// via fireAndForget.
|
||||||
@@ -54,10 +57,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
||||||
// Clear them explicitly after the lifecycle completes. applyPartial
|
// Clear them explicitly after the lifecycle completes. applyPartial
|
||||||
// with true is a direct store write — it does not re-trigger lifecycle.
|
// with true is a direct store write — it does not re-trigger lifecycle.
|
||||||
await core.services.setting.applyPartial({
|
await core.services.setting.applyPartial(
|
||||||
|
{
|
||||||
suspendFileWatching: false,
|
suspendFileWatching: false,
|
||||||
suspendParseReplicationResult: false,
|
suspendParseReplicationResult: false,
|
||||||
}, true);
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
};
|
};
|
||||||
if (options.interval) {
|
if (options.interval) {
|
||||||
log(`Polling mode: syncing every ${options.interval}s`);
|
log(`Polling mode: syncing every ${options.interval}s`);
|
||||||
@@ -80,7 +86,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
|
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
|
||||||
console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
|
console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
|
||||||
if (consecutiveFailures >= 5) {
|
if (consecutiveFailures >= 5) {
|
||||||
console.error(`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`);
|
console.error(
|
||||||
|
`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pollTimer = setTimeout(poll, currentIntervalMs);
|
pollTimer = setTimeout(poll, currentIntervalMs);
|
||||||
@@ -99,9 +107,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
log("LiveSync active");
|
log("LiveSync active");
|
||||||
const currentSettings = core.services.setting.currentSettings();
|
const currentSettings = core.services.setting.currentSettings();
|
||||||
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
||||||
console.error("[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
|
console.error(
|
||||||
|
"[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
|
||||||
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
|
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
|
||||||
"or use --interval for polling mode.");
|
"or use --interval for polling mode."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,16 @@ export interface CLICommandContext {
|
|||||||
databasePath: string;
|
databasePath: string;
|
||||||
core: LiveSyncBaseCore<ServiceContext, any>;
|
core: LiveSyncBaseCore<ServiceContext, any>;
|
||||||
settingsPath: string;
|
settingsPath: string;
|
||||||
originalSyncSettings: Pick<ObsidianLiveSyncSettings, "liveSync" | "syncOnStart" | "periodicReplication" | "syncOnSave" | "syncOnEditorSave" | "syncOnFileOpen" | "syncAfterMerge">;
|
originalSyncSettings: Pick<
|
||||||
|
ObsidianLiveSyncSettings,
|
||||||
|
| "liveSync"
|
||||||
|
| "syncOnStart"
|
||||||
|
| "periodicReplication"
|
||||||
|
| "syncOnSave"
|
||||||
|
| "syncOnEditorSave"
|
||||||
|
| "syncOnFileOpen"
|
||||||
|
| "syncAfterMerge"
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VALID_COMMANDS = new Set([
|
export const VALID_COMMANDS = new Set([
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import * as path from "path";
|
|||||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
||||||
import { LiveSyncBaseCore } from "../../LiveSyncBaseCore";
|
import { LiveSyncBaseCore } from "../../LiveSyncBaseCore";
|
||||||
import { ModuleReplicatorP2P } from "../../modules/core/ModuleReplicatorP2P";
|
|
||||||
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
||||||
import { DEFAULT_SETTINGS, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings } from "@lib/common/types";
|
import { DEFAULT_SETTINGS, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||||
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub";
|
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub";
|
||||||
@@ -27,6 +26,7 @@ import type { CLICommand, CLIOptions } from "./commands/types";
|
|||||||
import { getPathFromUXFileInfo } from "@lib/common/typeUtils";
|
import { getPathFromUXFileInfo } from "@lib/common/typeUtils";
|
||||||
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
||||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||||
|
import { useP2PReplicatorFeature } from "@/lib/src/replication/trystero/useP2PReplicatorFeature";
|
||||||
|
|
||||||
const SETTINGS_FILE = ".livesync/settings.json";
|
const SETTINGS_FILE = ".livesync/settings.json";
|
||||||
ensureGlobalNodeLocalStorage();
|
ensureGlobalNodeLocalStorage();
|
||||||
@@ -280,16 +280,13 @@ export async function main() {
|
|||||||
// chokidar's ignored option is populated when beginWatch() fires during onLoad().
|
// chokidar's ignored option is populated when beginWatch() fires during onLoad().
|
||||||
const watchEnabled = options.command === "daemon";
|
const watchEnabled = options.command === "daemon";
|
||||||
const vaultPath =
|
const vaultPath =
|
||||||
options.command === "mirror" && options.commandArgs[0]
|
options.command === "mirror" && options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : databasePath;
|
||||||
? path.resolve(options.commandArgs[0])
|
|
||||||
: databasePath;
|
|
||||||
let ignoreRules: IgnoreRules | undefined;
|
let ignoreRules: IgnoreRules | undefined;
|
||||||
if (options.command === "daemon" || options.command === "mirror") {
|
if (options.command === "daemon" || options.command === "mirror") {
|
||||||
ignoreRules = new IgnoreRules(vaultPath);
|
ignoreRules = new IgnoreRules(vaultPath);
|
||||||
await ignoreRules.load();
|
await ignoreRules.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Create service context and hub
|
// Create service context and hub
|
||||||
const context = new NodeServiceContext(databasePath);
|
const context = new NodeServiceContext(databasePath);
|
||||||
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
||||||
@@ -371,12 +368,11 @@ export async function main() {
|
|||||||
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||||
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
|
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
|
||||||
},
|
},
|
||||||
(core) => [
|
(core) => [],
|
||||||
// No modules need to be registered for P2P replication in CLI. Directly using Replicators in p2p.ts
|
|
||||||
// new ModuleReplicatorP2P(core),
|
|
||||||
],
|
|
||||||
() => [], // No add-ons
|
() => [], // No add-ons
|
||||||
(core) => {
|
(core) => {
|
||||||
|
// Register P2P replicator feature.
|
||||||
|
const _replicator = useP2PReplicatorFeature(core);
|
||||||
// Add target filter to prevent internal files are handled
|
// Add target filter to prevent internal files are handled
|
||||||
core.services.vault.isTargetFile.addHandler(async (target) => {
|
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||||
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||||
@@ -427,7 +423,7 @@ export async function main() {
|
|||||||
// Save the settings file before any lifecycle events can mutate and persist them.
|
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||||
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
||||||
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
||||||
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null);
|
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!);
|
||||||
|
|
||||||
// Restore settings file on any exit to undo lifecycle mutations.
|
// Restore settings file on any exit to undo lifecycle mutations.
|
||||||
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
||||||
|
|||||||
@@ -97,7 +97,11 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
|
|||||||
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||||
private _watcher: FSWatcher | undefined;
|
private _watcher: FSWatcher | undefined;
|
||||||
|
|
||||||
constructor(private basePath: string, private ignoreRules?: IgnoreRules, private watchEnabled: boolean = false) {}
|
constructor(
|
||||||
|
private basePath: string,
|
||||||
|
private ignoreRules?: IgnoreRules,
|
||||||
|
private watchEnabled: boolean = false
|
||||||
|
) {}
|
||||||
|
|
||||||
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -60,10 +60,7 @@ describe("CLIStorageEventManagerAdapter", () => {
|
|||||||
await adapter.watch.beginWatch(handlers);
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
|
||||||
expect(chokidar.watch).toHaveBeenCalledTimes(1);
|
expect(chokidar.watch).toHaveBeenCalledTimes(1);
|
||||||
expect(chokidar.watch).toHaveBeenCalledWith(
|
expect(chokidar.watch).toHaveBeenCalledWith("/base", expect.objectContaining({ ignoreInitial: true }));
|
||||||
"/base",
|
|
||||||
expect.objectContaining({ ignoreInitial: true })
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("add event produces NodeFile with correct relative path via onCreate", async () => {
|
it("add event produces NodeFile with correct relative path via onCreate", async () => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function initialiseServiceModulesCLI(
|
|||||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||||
services: InjectableServiceHub<ServiceContext>,
|
services: InjectableServiceHub<ServiceContext>,
|
||||||
ignoreRules?: IgnoreRules,
|
ignoreRules?: IgnoreRules,
|
||||||
watchEnabled: boolean = false,
|
watchEnabled: boolean = false
|
||||||
): ServiceModules {
|
): ServiceModules {
|
||||||
const storageAccessManager = new StorageAccessManager();
|
const storageAccessManager = new StorageAccessManager();
|
||||||
|
|
||||||
@@ -39,13 +39,19 @@ export function initialiseServiceModulesCLI(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// CLI-specific storage event manager
|
// CLI-specific storage event manager
|
||||||
const storageEventManager = new StorageEventManagerCLI(basePath, core, {
|
const storageEventManager = new StorageEventManagerCLI(
|
||||||
|
basePath,
|
||||||
|
core,
|
||||||
|
{
|
||||||
fileProcessing: services.fileProcessing,
|
fileProcessing: services.fileProcessing,
|
||||||
setting: services.setting,
|
setting: services.setting,
|
||||||
vaultService: services.vault,
|
vaultService: services.vault,
|
||||||
storageAccessManager: storageAccessManager,
|
storageAccessManager: storageAccessManager,
|
||||||
APIService: services.API,
|
APIService: services.API,
|
||||||
}, ignoreRules, watchEnabled);
|
},
|
||||||
|
ignoreRules,
|
||||||
|
watchEnabled
|
||||||
|
);
|
||||||
|
|
||||||
// Close the file watcher during graceful shutdown so the process can exit cleanly.
|
// Close the file watcher during graceful shutdown so the process can exit cleanly.
|
||||||
services.appLifecycle.onUnload.addHandler(async () => {
|
services.appLifecycle.onUnload.addHandler(async () => {
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export class IgnoreRules {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("import:")) {
|
if (trimmed.startsWith("import:")) {
|
||||||
console.error(`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`);
|
console.error(
|
||||||
|
`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this._addPattern(trimmed);
|
this._addPattern(trimmed);
|
||||||
|
|||||||
@@ -122,10 +122,7 @@ describe("IgnoreRules", () => {
|
|||||||
describe("load() with comments and blank lines", () => {
|
describe("load() with comments and blank lines", () => {
|
||||||
it("skips # comment lines and blank lines", async () => {
|
it("skips # comment lines and blank lines", async () => {
|
||||||
const vaultPath = await createVault();
|
const vaultPath = await createVault();
|
||||||
await writeIgnoreFile(
|
await writeIgnoreFile(vaultPath, "# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n");
|
||||||
vaultPath,
|
|
||||||
"# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n"
|
|
||||||
);
|
|
||||||
const rules = new IgnoreRules(vaultPath);
|
const rules = new IgnoreRules(vaultPath);
|
||||||
await rules.load();
|
await rules.load();
|
||||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro
|
|||||||
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
|
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
|
||||||
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
|
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
|
||||||
|
|
||||||
const minioEndpoint = remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
const minioEndpoint =
|
||||||
|
remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
||||||
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
|
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
|
||||||
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
||||||
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ function injectBanner(): import("vite").Plugin {
|
|||||||
// Insert after the shebang line if present, otherwise at the top.
|
// Insert after the shebang line if present, otherwise at the top.
|
||||||
if (chunk.code.startsWith("#!")) {
|
if (chunk.code.startsWith("#!")) {
|
||||||
const newline = chunk.code.indexOf("\n");
|
const newline = chunk.code.indexOf("\n");
|
||||||
chunk.code = chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
|
chunk.code =
|
||||||
|
chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
|
||||||
} else {
|
} else {
|
||||||
chunk.code = fileReaderPolyfillBanner + chunk.code;
|
chunk.code = fileReaderPolyfillBanner + chunk.code;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ async function renderHistoryList(): Promise<VaultHistoryItem[]> {
|
|||||||
|
|
||||||
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
|
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
|
||||||
|
|
||||||
listEl.innerHTML = "";
|
listEl.replaceChildren();
|
||||||
emptyEl.classList.toggle("is-hidden", items.length > 0);
|
emptyEl.classList.toggle("is-hidden", items.length > 0);
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { App, Modal } from "@/deps.ts";
|
||||||
|
import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
|
|
||||||
|
export type P2POpenReplicationModalCallback = {
|
||||||
|
onSync: (peerId: string) => Promise<void>;
|
||||||
|
onSyncAndClose: (peerId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class P2POpenReplicationModal extends Modal {
|
||||||
|
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||||
|
callback?: P2POpenReplicationModalCallback;
|
||||||
|
component?: ReturnType<typeof mount>;
|
||||||
|
showResult: boolean;
|
||||||
|
title: string;
|
||||||
|
onClosed?: () => void;
|
||||||
|
rebuildMode: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
liveSyncReplicator: LiveSyncTrysteroReplicator,
|
||||||
|
callback?: P2POpenReplicationModalCallback,
|
||||||
|
showResult: boolean = false,
|
||||||
|
title: string = "P2P Replication",
|
||||||
|
onClosed?: () => void,
|
||||||
|
rebuildMode: boolean = false
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.liveSyncReplicator = liveSyncReplicator;
|
||||||
|
this.callback = callback;
|
||||||
|
this.showResult = showResult;
|
||||||
|
this.title = title;
|
||||||
|
this.onClosed = onClosed;
|
||||||
|
this.rebuildMode = rebuildMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSync(peerId: string) {
|
||||||
|
if (this.callback?.onSync) {
|
||||||
|
await this.callback.onSync(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSyncAndClose(peerId: string) {
|
||||||
|
if (this.callback?.onSyncAndClose) {
|
||||||
|
await this.callback.onSyncAndClose(peerId);
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
override onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
this.titleEl.setText(this.title);
|
||||||
|
contentEl.empty();
|
||||||
|
|
||||||
|
if (this.component === undefined) {
|
||||||
|
this.component = mount(P2POpenReplicationPane, {
|
||||||
|
target: contentEl,
|
||||||
|
props: {
|
||||||
|
liveSyncReplicator: this.liveSyncReplicator,
|
||||||
|
onSync: (peerId: string) => this.onSync(peerId),
|
||||||
|
onSyncAndClose: (peerId: string) => this.onSyncAndClose(peerId),
|
||||||
|
onClose: () => this.close(),
|
||||||
|
showResult: this.showResult,
|
||||||
|
rebuildMode: this.rebuildMode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
if (this.component !== undefined) {
|
||||||
|
void unmount(this.component);
|
||||||
|
this.component = undefined;
|
||||||
|
}
|
||||||
|
this.onClosed?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
313
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal file
313
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { eventHub } from "@/common/events";
|
||||||
|
import {
|
||||||
|
EVENT_SERVER_STATUS,
|
||||||
|
EVENT_REQUEST_STATUS,
|
||||||
|
type P2PServerInfo,
|
||||||
|
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||||
|
// import type { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator";
|
||||||
|
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
|
||||||
|
import { Logger } from "@lib/common/logger";
|
||||||
|
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
|
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||||
|
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||||
|
onSync: (_peerId: string) => Promise<void>;
|
||||||
|
onSyncAndClose: (_peerId: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
showResult: boolean;
|
||||||
|
rebuildMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator, rebuildMode = false }: Props = $props();
|
||||||
|
|
||||||
|
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||||
|
let syncingPeerId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||||
|
async function requestServerStatus() {
|
||||||
|
await liveSyncReplicator.requestStatus();
|
||||||
|
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
// ServerStatus
|
||||||
|
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||||
|
serverInfo = status;
|
||||||
|
});
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await delay(100);
|
||||||
|
await requestServerStatus();
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSync(peerId: string) {
|
||||||
|
try {
|
||||||
|
syncingPeerId = peerId;
|
||||||
|
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||||
|
await onSync(peerId);
|
||||||
|
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||||
|
} catch (e) {
|
||||||
|
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||||
|
} finally {
|
||||||
|
syncingPeerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function handleSyncThenClose(peerId: string) {
|
||||||
|
try {
|
||||||
|
syncingPeerId = peerId;
|
||||||
|
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||||
|
await onSyncAndClose(peerId);
|
||||||
|
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||||
|
} catch (e) {
|
||||||
|
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||||
|
} finally {
|
||||||
|
syncingPeerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncAndClose(peerId: string) {
|
||||||
|
fireAndForget(async () => {
|
||||||
|
try {
|
||||||
|
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||||
|
await onSync(peerId);
|
||||||
|
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||||
|
} catch (e) {
|
||||||
|
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
async function disconnect() {
|
||||||
|
try {
|
||||||
|
await liveSyncReplicator.close();
|
||||||
|
Logger("Signalling connection closed.", logLevel);
|
||||||
|
} catch (e) {
|
||||||
|
Logger(`Failed to close signalling connection: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function onCloseAndDisconnect() {
|
||||||
|
await disconnect();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
|
||||||
|
if (peer.isAccepted === true) return "ACCEPTED";
|
||||||
|
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
|
||||||
|
if (peer.isAccepted === false) return "DENIED";
|
||||||
|
return "NEW";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
|
||||||
|
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p2p-container">
|
||||||
|
<P2PServerStatusCard {liveSyncReplicator} showBroadcastToggle={false} />
|
||||||
|
|
||||||
|
<div class="peers-section">
|
||||||
|
<h3>Available Peers</h3>
|
||||||
|
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
|
||||||
|
<div class="peers-list">
|
||||||
|
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
|
||||||
|
<div class="peer-item">
|
||||||
|
<div class="peer-info">
|
||||||
|
<div class="peer-name">{peer.name}</div>
|
||||||
|
<div class="peer-meta">
|
||||||
|
<span class="badge">{peer.platform}</span>
|
||||||
|
<span class="peer-id-mini" title={peer.peerId}>
|
||||||
|
{peer.peerId.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||||
|
{getAcceptanceStatus(peer)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="peer-actions">
|
||||||
|
{#if !rebuildMode}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={syncingPeerId !== null}
|
||||||
|
onclick={() => handleSync(peer.peerId)}
|
||||||
|
>
|
||||||
|
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
|
||||||
|
disabled={syncingPeerId !== null}
|
||||||
|
onclick={() => handleSyncAndClose(peer.peerId)}
|
||||||
|
>
|
||||||
|
{syncingPeerId === peer.peerId ? "Syncing..." : "Start Sync & Close"}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
|
||||||
|
disabled={syncingPeerId !== null}
|
||||||
|
onclick={() => handleSyncThenClose(peer.peerId)}
|
||||||
|
>
|
||||||
|
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if serverInfo}
|
||||||
|
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
{#if rebuildMode}
|
||||||
|
<button class="btn btn-cancel" onclick={onClose} disabled={syncingPeerId !== null}>Skip and close</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn btn-cancel" onclick={onClose}>Close</button>
|
||||||
|
<button class="btn btn-cancel" onclick={onCloseAndDisconnect}>Close & Disconnect</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.p2p-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-section {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.accepted {
|
||||||
|
background-color: var(--background-modifier-success);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.denied {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.unknown {
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-id-mini {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-peers {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--divider-color);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
131
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal file
131
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { App } from "@/deps.ts";
|
||||||
|
import { Logger } from "@lib/common/logger";
|
||||||
|
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
|
||||||
|
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
|
import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an openReplicationUI factory for Obsidian environments.
|
||||||
|
* Returns a per-replicator closure that opens the P2P Replication modal
|
||||||
|
* and performs bidirectional sync (pull then push on success).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const factory = createOpenReplicationUI(app);
|
||||||
|
* useP2PReplicatorFeature(core, factory);
|
||||||
|
*/
|
||||||
|
export function createOpenReplicationUI(
|
||||||
|
app: App
|
||||||
|
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
|
||||||
|
return (replicator: LiveSyncTrysteroReplicator) =>
|
||||||
|
(showResult: boolean): Promise<boolean | void> => {
|
||||||
|
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||||
|
return new Promise<boolean | void>((resolve) => {
|
||||||
|
const modal = new P2POpenReplicationModal(
|
||||||
|
app,
|
||||||
|
replicator,
|
||||||
|
{
|
||||||
|
onSync: async (peerId: string) => {
|
||||||
|
try {
|
||||||
|
// pull (replicateFrom) first; push only on success
|
||||||
|
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||||
|
if (pullResult?.ok) {
|
||||||
|
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||||
|
resolve(pushResult?.ok ?? true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Logger(
|
||||||
|
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
logLevel
|
||||||
|
);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSyncAndClose: async (peerId: string) => {
|
||||||
|
try {
|
||||||
|
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||||
|
if (pullResult?.ok) {
|
||||||
|
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||||
|
if (pushResult?.ok ?? true) {
|
||||||
|
await replicator.close();
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Logger(
|
||||||
|
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
logLevel
|
||||||
|
);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showResult
|
||||||
|
);
|
||||||
|
modal.open();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an openRebuildUI factory for Obsidian environments.
|
||||||
|
* Opens the P2P Replication modal in "rebuild" mode — one-way pull only,
|
||||||
|
* with setOnSetup / clearOnSetup bracketing the replicateFrom call.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const factory = createOpenRebuildUI(app);
|
||||||
|
* useP2PReplicatorFeature(core, createOpenReplicationUI(app), factory);
|
||||||
|
*/
|
||||||
|
export function createOpenRebuildUI(
|
||||||
|
app: App
|
||||||
|
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
|
||||||
|
return (replicator: LiveSyncTrysteroReplicator) =>
|
||||||
|
(showResult: boolean): Promise<boolean | void> => {
|
||||||
|
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||||
|
return new Promise<boolean | void>((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
const safeResolve = (val: boolean) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
resolve(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doRebuild = async (peerId: string) => {
|
||||||
|
replicator.setOnSetup();
|
||||||
|
try {
|
||||||
|
Logger(`Rebuilding from peer ${peerId}`, logLevel);
|
||||||
|
const result = await replicator.replicateFrom(peerId, showResult);
|
||||||
|
safeResolve(result?.ok ?? false);
|
||||||
|
} catch (e) {
|
||||||
|
Logger(
|
||||||
|
`Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
logLevel
|
||||||
|
);
|
||||||
|
safeResolve(false);
|
||||||
|
} finally {
|
||||||
|
replicator.clearOnSetup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = new P2POpenReplicationModal(
|
||||||
|
app,
|
||||||
|
replicator,
|
||||||
|
{
|
||||||
|
onSync: doRebuild,
|
||||||
|
onSyncAndClose: doRebuild,
|
||||||
|
},
|
||||||
|
showResult,
|
||||||
|
"P2P Rebuild",
|
||||||
|
() => safeResolve(false),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
modal.open();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
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 Promise.resolve(liveSyncReplicator.requestStatus());
|
||||||
|
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenConnection() {
|
||||||
|
await liveSyncReplicator.makeSureOpened();
|
||||||
|
await requestServerStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDisconnect() {
|
||||||
|
await liveSyncReplicator.close();
|
||||||
|
await requestServerStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBroadcast() {
|
||||||
|
if (replicatorStatus?.isBroadcasting) {
|
||||||
|
liveSyncReplicator.disableBroadcastChanges();
|
||||||
|
} else {
|
||||||
|
liveSyncReplicator.enableBroadcastChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||||
|
serverInfo = status;
|
||||||
|
});
|
||||||
|
const unsubscribeStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||||
|
replicatorStatus = status;
|
||||||
|
});
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await delay(100);
|
||||||
|
await requestServerStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribeStatus();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const isConnected = $derived.by(() => serverInfo?.isConnected);
|
||||||
|
const isBroadcasting = $derived.by(() => replicatorStatus?.isBroadcasting ?? false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="server-status">
|
||||||
|
<h3>Signalling Status</h3>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<span>Connection:</span>
|
||||||
|
<span class="status-value {isConnected ? 'connected' : 'disconnected'}">
|
||||||
|
{isConnected ? "🟢 Connected" : "🔴 Disconnected"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item status-action">
|
||||||
|
{#if !isConnected}
|
||||||
|
<button onclick={onOpenConnection}>Open connection</button>
|
||||||
|
{:else}
|
||||||
|
<button onclick={onDisconnect}>Close connection</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if serverInfo}
|
||||||
|
<div class="status-item">
|
||||||
|
<span>Peer ID:</span>
|
||||||
|
<span class="peer-id-display" title={serverInfo.serverPeerId}>
|
||||||
|
{serverInfo.serverPeerId.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<span>Devices:</span>
|
||||||
|
<span>{serverInfo.knownAdvertisements.length}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showBroadcastToggle}
|
||||||
|
<div class="status-item status-action broadcast-row">
|
||||||
|
<!-- Live-push to peers: stream this device's changes to connected peers for LiveSync -->
|
||||||
|
<label class="broadcast-label" for="broadcast-toggle">
|
||||||
|
Live-push to peers
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="broadcast-toggle"
|
||||||
|
class="broadcast-button {isBroadcasting ? 'is-on' : 'is-off'}"
|
||||||
|
onclick={toggleBroadcast}
|
||||||
|
title={isBroadcasting ? 'Pushing changes to peers — click to stop' : 'Start pushing changes to peers'}
|
||||||
|
>
|
||||||
|
{isBroadcasting ? '📡 On' : '📡 Off'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.server-status {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-action {
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.connected {
|
||||||
|
color: var(--text-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.disconnected {
|
||||||
|
color: var(--text-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-id-display {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-row {
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-button {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-button.is-on {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-button.is-off {
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-button.is-off:hover {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
603
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
603
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
|
||||||
|
import {
|
||||||
|
EVENT_SERVER_STATUS,
|
||||||
|
EVENT_REQUEST_STATUS,
|
||||||
|
EVENT_P2P_REPLICATOR_STATUS,
|
||||||
|
EVENT_P2P_REPLICATOR_PROGRESS,
|
||||||
|
type P2PServerInfo,
|
||||||
|
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||||
|
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
|
import type { P2PReplicatorStatus, P2PReplicationReport } from "@/lib/src/replication/trystero/TrysteroReplicator";
|
||||||
|
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||||
|
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||||
|
import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
|
||||||
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||||
|
core: LiveSyncBaseCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { liveSyncReplicator, core }: Props = $props();
|
||||||
|
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||||
|
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||||
|
let decidingPeerId = $state<string | null>(null);
|
||||||
|
let communicatingUntil = $state<Record<string, number>>({});
|
||||||
|
const COMMUNICATION_HOLD_MS = 2500;
|
||||||
|
let syncOnReplicationSetting = $state(
|
||||||
|
core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
function addToList(item: string, list: string): string {
|
||||||
|
const items = list.split(",").map((e) => e.trim()).filter((e) => e);
|
||||||
|
if (!items.includes(item)) items.push(item);
|
||||||
|
return items.join(",");
|
||||||
|
}
|
||||||
|
function removeFromList(item: string, list: string): string {
|
||||||
|
return list.split(",").map((e) => e.trim()).filter((e) => e && e !== item).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCommunicating(peerId: string) {
|
||||||
|
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
|
||||||
|
communicatingUntil = { ...communicatingUntil, [peerId]: expiry };
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if ((communicatingUntil[peerId] ?? 0) <= Date.now()) {
|
||||||
|
const { [peerId]: _removed, ...rest } = communicatingUntil;
|
||||||
|
communicatingUntil = rest;
|
||||||
|
}
|
||||||
|
}, COMMUNICATION_HOLD_MS + 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestServerStatus() {
|
||||||
|
await liveSyncReplicator.requestStatus();
|
||||||
|
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||||
|
serverInfo = status;
|
||||||
|
});
|
||||||
|
const unsubscribeReplicatorStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||||
|
replicatorInfo = status;
|
||||||
|
for (const peerId of status.replicatingFrom) {
|
||||||
|
markCommunicating(peerId);
|
||||||
|
}
|
||||||
|
for (const peerId of status.replicatingTo) {
|
||||||
|
markCommunicating(peerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const unsubscribeReplicatorProgress = eventHub.onEvent(EVENT_P2P_REPLICATOR_PROGRESS, (report) => {
|
||||||
|
const rep = report as P2PReplicationReport;
|
||||||
|
if (("fetching" in rep && rep.fetching?.isActive) || ("sending" in rep && rep.sending?.isActive)) {
|
||||||
|
markCommunicating(rep.peerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||||
|
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
|
||||||
|
});
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await delay(100);
|
||||||
|
await requestServerStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribeReplicatorStatus();
|
||||||
|
unsubscribeReplicatorProgress();
|
||||||
|
unsubscribeSettings();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
|
||||||
|
if (peer.isAccepted === true) return "ACCEPTED";
|
||||||
|
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
|
||||||
|
if (peer.isAccepted === false) return "DENIED";
|
||||||
|
return "NEW";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
|
||||||
|
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConnectionSettings() {
|
||||||
|
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeDecision(
|
||||||
|
peer: P2PServerInfo["knownAdvertisements"][number],
|
||||||
|
decision: boolean,
|
||||||
|
isTemporary: boolean
|
||||||
|
) {
|
||||||
|
decidingPeerId = peer.peerId;
|
||||||
|
try {
|
||||||
|
await liveSyncReplicator.makeDecision({
|
||||||
|
peerId: peer.peerId,
|
||||||
|
name: peer.name,
|
||||||
|
decision,
|
||||||
|
isTemporary,
|
||||||
|
});
|
||||||
|
await requestServerStatus();
|
||||||
|
} finally {
|
||||||
|
decidingPeerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
decidingPeerId = peer.peerId;
|
||||||
|
try {
|
||||||
|
await liveSyncReplicator.revokeDecision({
|
||||||
|
peerId: peer.peerId,
|
||||||
|
name: peer.name,
|
||||||
|
});
|
||||||
|
await requestServerStatus();
|
||||||
|
} finally {
|
||||||
|
decidingPeerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccepted(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
return peer.isTemporaryAccepted === true || peer.isAccepted === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWatching(peerId: string) {
|
||||||
|
return replicatorInfo?.watchingPeers?.includes(peerId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWatch(peerId: string) {
|
||||||
|
if (isWatching(peerId)) {
|
||||||
|
liveSyncReplicator.unwatchPeer(peerId);
|
||||||
|
} else {
|
||||||
|
liveSyncReplicator.watchPeer(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommunicating(peerId: string) {
|
||||||
|
const to = replicatorInfo?.replicatingTo ?? [];
|
||||||
|
const from = replicatorInfo?.replicatingFrom ?? [];
|
||||||
|
const isLiveCommunicating = to.includes(peerId) || from.includes(peerId);
|
||||||
|
const isHeldCommunicating = (communicatingUntil[peerId] ?? 0) > Date.now();
|
||||||
|
return isLiveCommunicating || isHeldCommunicating;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSyncTarget(peerName: string) {
|
||||||
|
return syncOnReplicationSetting
|
||||||
|
.split(",")
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.filter((e) => e)
|
||||||
|
.includes(peerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
|
||||||
|
const newValue = isSyncTarget(peer.name)
|
||||||
|
? removeFromList(peer.name, currentValue)
|
||||||
|
: addToList(peer.name, currentValue);
|
||||||
|
await core.services.setting.applyPartial({ P2P_SyncOnReplication: newValue }, true);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p2p-container">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2>P2P Status</h2>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
onclick={openConnectionSettings}
|
||||||
|
title="Open P2P Setup..."
|
||||||
|
aria-label="Open P2P Setup..."
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<P2PServerStatusCard {liveSyncReplicator} />
|
||||||
|
|
||||||
|
<div class="peers-section">
|
||||||
|
<div class="peers-header">
|
||||||
|
<h3>Detected Peers</h3>
|
||||||
|
<button class="refresh" onclick={requestServerStatus}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
|
||||||
|
<div class="peers-list">
|
||||||
|
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
|
||||||
|
<div class="peer-item">
|
||||||
|
<div class="peer-info">
|
||||||
|
<div class="peer-name">
|
||||||
|
{peer.name} : <span class="peer-id-mini" title={peer.peerId}>({peer.peerId.slice(0, 8)})</span>
|
||||||
|
{#if isCommunicating(peer.peerId)}
|
||||||
|
<span class="comm-icon" title="Communicating" aria-label="Communicating">📡</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="peer-meta">
|
||||||
|
<span class="badge">{peer.platform}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="peer-actions">
|
||||||
|
{#if isAccepted(peer)}
|
||||||
|
<div class="decision-row accepted-row">
|
||||||
|
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||||
|
{getAcceptanceStatus(peer)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => revokeDecision(peer)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="decision-row watch-row">
|
||||||
|
<span class="decision-label">WATCH</span>
|
||||||
|
<button
|
||||||
|
class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}"
|
||||||
|
title={isWatching(peer.peerId) ? 'Watching this peer \u2014 click to stop' : 'Watch this peer\'s changes'}
|
||||||
|
aria-label={isWatching(peer.peerId) ? 'Stop watching' : 'Watch peer'}
|
||||||
|
onclick={() => toggleWatch(peer.peerId)}
|
||||||
|
>
|
||||||
|
{isWatching(peer.peerId) ? '👁' : '👁🗨'}
|
||||||
|
</button>
|
||||||
|
</div> <div class="decision-row watch-row">
|
||||||
|
<span class="decision-label">SYNC</span>
|
||||||
|
<button
|
||||||
|
class="emoji-button {isSyncTarget(peer.name) ? 'is-watching' : ''}"
|
||||||
|
title={isSyncTarget(peer.name) ? 'Sync target \u2014 click to remove' : 'Set as sync target'}
|
||||||
|
aria-label={isSyncTarget(peer.name) ? 'Remove sync target' : 'Set sync target'}
|
||||||
|
onclick={() => toggleSyncTarget(peer)}
|
||||||
|
>
|
||||||
|
{isSyncTarget(peer.name) ? '🔄' : '🔁'}
|
||||||
|
</button>
|
||||||
|
</div> {:else}
|
||||||
|
<div class="decision-status">
|
||||||
|
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||||
|
{getAcceptanceStatus(peer)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="decision-row">
|
||||||
|
<span class="decision-label">PERMANENT</span>
|
||||||
|
<button
|
||||||
|
class="emoji-button"
|
||||||
|
title="Allow permanently"
|
||||||
|
aria-label="Allow permanently"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => makeDecision(peer, true, false)}
|
||||||
|
>
|
||||||
|
✅
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="emoji-button mod-warning"
|
||||||
|
title="Deny permanently"
|
||||||
|
aria-label="Deny permanently"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => makeDecision(peer, false, false)}
|
||||||
|
>
|
||||||
|
🚫
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="decision-row">
|
||||||
|
<span class="decision-label">SESSION</span>
|
||||||
|
<button
|
||||||
|
class="emoji-button"
|
||||||
|
title="Allow in session"
|
||||||
|
aria-label="Allow in session"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => makeDecision(peer, true, true)}
|
||||||
|
>
|
||||||
|
✅
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="emoji-button mod-warning"
|
||||||
|
title="Deny in session"
|
||||||
|
aria-label="Deny in session"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => makeDecision(peer, false, true)}
|
||||||
|
>
|
||||||
|
🚫
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)}
|
||||||
|
<button
|
||||||
|
class="action-button revoke-inline"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => revokeDecision(peer)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if serverInfo}
|
||||||
|
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
|
||||||
|
{:else}
|
||||||
|
<p class="no-peers">Fetching status...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.p2p-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-section {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh:hover {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.accepted {
|
||||||
|
background-color: var(--background-modifier-success);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.denied {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.unknown {
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-id-mini {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-icon {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
animation: pulse-comm 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-comm {
|
||||||
|
0% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accepted-row {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
width: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 1.7rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button.mod-warning {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button.is-watching {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button.mod-warning:hover:not(:disabled) {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-row {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.mod-warning {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revoke-inline {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-peers {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { WorkspaceLeaf } from "@/deps.ts";
|
||||||
|
import { mount } from "svelte";
|
||||||
|
import { SvelteItemView } from "@/common/SvelteItemView.ts";
|
||||||
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||||
|
import type { P2PPaneParams } from "@/lib/src/replication/trystero/UseP2PReplicatorResult";
|
||||||
|
import P2PServerStatusPane from "./P2PServerStatusPane.svelte";
|
||||||
|
|
||||||
|
export const VIEW_TYPE_P2P_SERVER_STATUS = "p2p-server-status";
|
||||||
|
|
||||||
|
export class P2PServerStatusPaneView extends SvelteItemView {
|
||||||
|
core: LiveSyncBaseCore;
|
||||||
|
private _p2pResult: P2PPaneParams;
|
||||||
|
override icon = "waypoints";
|
||||||
|
override navigation = false;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams) {
|
||||||
|
super(leaf);
|
||||||
|
this.core = core;
|
||||||
|
this._p2pResult = p2pResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
override getIcon(): string {
|
||||||
|
return "waypoints";
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType() {
|
||||||
|
return VIEW_TYPE_P2P_SERVER_STATUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText() {
|
||||||
|
return "P2P Status";
|
||||||
|
}
|
||||||
|
|
||||||
|
instantiateComponent(target: HTMLElement) {
|
||||||
|
return mount(P2PServerStatusPane, {
|
||||||
|
target,
|
||||||
|
props: {
|
||||||
|
liveSyncReplicator: this._p2pResult.replicator,
|
||||||
|
core: this.core,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/lib
2
src/lib
Submodule src/lib updated: adcfe42522...07e287c531
17
src/main.ts
17
src/main.ts
@@ -1,5 +1,6 @@
|
|||||||
import { Notice, Plugin, type App, type PluginManifest } from "./deps";
|
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
|
||||||
|
import { setGetLanguage } from "./lib/src/common/coreEnvFunctions.ts";
|
||||||
|
setGetLanguage(getLanguage);
|
||||||
import { LiveSyncCommands } from "./features/LiveSyncCommands.ts";
|
import { LiveSyncCommands } from "./features/LiveSyncCommands.ts";
|
||||||
import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||||
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||||
@@ -43,6 +44,7 @@ import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/
|
|||||||
import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts";
|
import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts";
|
||||||
import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts";
|
import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts";
|
||||||
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
|
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
|
||||||
|
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
|
||||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||||
core: LiveSyncCore;
|
core: LiveSyncCore;
|
||||||
@@ -175,7 +177,13 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
|||||||
const curriedFeature = () => featuresInitialiser(core);
|
const curriedFeature = () => featuresInitialiser(core);
|
||||||
core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
|
core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
|
||||||
const setupManager = core.getModule(SetupManager);
|
const setupManager = core.getModule(SetupManager);
|
||||||
|
const replicator = useP2PReplicatorFeature(
|
||||||
|
core,
|
||||||
|
createOpenReplicationUI(this.app),
|
||||||
|
createOpenRebuildUI(this.app)
|
||||||
|
);
|
||||||
|
useP2PReplicatorCommands(core, replicator);
|
||||||
|
useP2PReplicatorUI(core, core, replicator);
|
||||||
useRemoteConfiguration(core);
|
useRemoteConfiguration(core);
|
||||||
|
|
||||||
useSetupProtocolFeature(core, setupManager);
|
useSetupProtocolFeature(core, setupManager);
|
||||||
@@ -189,9 +197,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
|||||||
// VIEW_TYPE_P2P,
|
// VIEW_TYPE_P2P,
|
||||||
// (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!),
|
// (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!),
|
||||||
// ]);
|
// ]);
|
||||||
const replicator = useP2PReplicatorFeature(core);
|
|
||||||
useP2PReplicatorCommands(core, replicator);
|
|
||||||
useP2PReplicatorUI(core, core, replicator);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type PouchDB from "pouchdb-core";
|
||||||
import { fireAndForget } from "octagonal-wheels/promises";
|
import { fireAndForget } from "octagonal-wheels/promises";
|
||||||
import { AbstractModule } from "../AbstractModule";
|
import { AbstractModule } from "../AbstractModule";
|
||||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { AbstractModule } from "../AbstractModule.ts";
|
|||||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
import { $msg } from "../../lib/src/common/i18n.ts";
|
||||||
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
||||||
import type { LiveSyncCore } from "../../main.ts";
|
import type { LiveSyncCore } from "../../main.ts";
|
||||||
|
import { REMOTE_P2P } from "@lib/common/models/setting.const.ts";
|
||||||
|
|
||||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||||
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||||
@@ -186,6 +187,9 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
|||||||
async _checkAndAskUseRemoteConfiguration(
|
async _checkAndAskUseRemoteConfiguration(
|
||||||
trialSetting: RemoteDBSettings
|
trialSetting: RemoteDBSettings
|
||||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||||
|
if (trialSetting.remoteType === REMOTE_P2P) {
|
||||||
|
return { result: false, requireFetch: false };
|
||||||
|
}
|
||||||
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
|
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
|
||||||
if (preferred) {
|
if (preferred) {
|
||||||
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred);
|
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.ts";
|
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.ts";
|
||||||
import { getPathFromTFile, isValidPath } from "../../../common/utils.ts";
|
import { getPathFromTFile, isValidPath } from "../../../common/utils.ts";
|
||||||
import { decodeBinary, escapeStringToHTML, readString } from "../../../lib/src/string_and_binary/convert.ts";
|
import { decodeBinary, readString } from "../../../lib/src/string_and_binary/convert.ts";
|
||||||
import ObsidianLiveSyncPlugin from "../../../main.ts";
|
import ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||||
import {
|
import {
|
||||||
type DocumentID,
|
type DocumentID,
|
||||||
@@ -145,22 +145,66 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepareContentView(usePreformatted = true) {
|
||||||
|
this.contentView.empty();
|
||||||
|
this.contentView.toggleClass("op-pre", usePreformatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendTextDiff(diff: [number, string][]) {
|
||||||
|
for (const [operation, text] of diff) {
|
||||||
|
if (operation == DIFF_DELETE) {
|
||||||
|
this.contentView.createSpan({ text, cls: "history-deleted" });
|
||||||
|
} else if (operation == DIFF_EQUAL) {
|
||||||
|
this.contentView.createSpan({ text, cls: "history-normal" });
|
||||||
|
} else if (operation == DIFF_INSERT) {
|
||||||
|
this.contentView.createSpan({ text, cls: "history-added" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendImageDiff(baseSrc: string, overlaySrc?: string) {
|
||||||
|
const wrap = this.contentView.createDiv({ cls: "ls-imgdiff-wrap" });
|
||||||
|
const overlay = wrap.createDiv({ cls: "overlay" });
|
||||||
|
overlay.createEl("img", { cls: "img-base" }, (img) => {
|
||||||
|
img.src = baseSrc;
|
||||||
|
});
|
||||||
|
if (overlaySrc) {
|
||||||
|
overlay.createEl("img", { cls: "img-overlay" }, (img) => {
|
||||||
|
img.src = overlaySrc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendDeletedNotice(usePreformatted = true) {
|
||||||
|
const notice = "(At this revision, the file has been deleted)";
|
||||||
|
if (usePreformatted) {
|
||||||
|
this.contentView.appendText(`${notice}\n`);
|
||||||
|
} else {
|
||||||
|
this.contentView.createDiv({ text: notice });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async showExactRev(rev: string) {
|
async showExactRev(rev: string) {
|
||||||
const db = this.core.localDatabase;
|
const db = this.core.localDatabase;
|
||||||
const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
|
const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
|
||||||
this.currentText = "";
|
this.currentText = "";
|
||||||
this.currentDeleted = false;
|
this.currentDeleted = false;
|
||||||
|
this.prepareContentView();
|
||||||
if (w === false) {
|
if (w === false) {
|
||||||
this.currentDeleted = true;
|
this.currentDeleted = true;
|
||||||
this.info.innerHTML = "";
|
this.info.empty();
|
||||||
this.contentView.innerHTML = `Could not read this revision<br>(${rev})`;
|
this.contentView.appendText("Could not read this revision");
|
||||||
|
this.contentView.createEl("br");
|
||||||
|
this.contentView.appendText(`(${rev})`);
|
||||||
} else {
|
} else {
|
||||||
this.currentDoc = w;
|
this.currentDoc = w;
|
||||||
this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`;
|
this.info.setText(`Modified:${new Date(w.mtime).toLocaleString()}`);
|
||||||
let result = undefined;
|
|
||||||
const w1data = readDocument(w);
|
const w1data = readDocument(w);
|
||||||
this.currentDeleted = !!w.deleted;
|
this.currentDeleted = !!w.deleted;
|
||||||
// this.currentText = w1data;
|
if (typeof w1data == "string") {
|
||||||
|
this.currentText = w1data;
|
||||||
|
}
|
||||||
|
let rendered = false;
|
||||||
if (this.showDiff) {
|
if (this.showDiff) {
|
||||||
const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1);
|
const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1);
|
||||||
if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) {
|
if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) {
|
||||||
@@ -168,58 +212,55 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true);
|
const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true);
|
||||||
if (w2 != false) {
|
if (w2 != false) {
|
||||||
if (typeof w1data == "string") {
|
if (typeof w1data == "string") {
|
||||||
result = "";
|
const w2data = readDocument(w2);
|
||||||
|
if (typeof w2data == "string") {
|
||||||
const dmp = new diff_match_patch();
|
const dmp = new diff_match_patch();
|
||||||
const w2data = readDocument(w2) as string;
|
|
||||||
const diff = dmp.diff_main(w2data, w1data);
|
const diff = dmp.diff_main(w2data, w1data);
|
||||||
dmp.diff_cleanupSemantic(diff);
|
dmp.diff_cleanupSemantic(diff);
|
||||||
for (const v of diff) {
|
if (this.currentDeleted) {
|
||||||
const x1 = v[0];
|
this.appendDeletedNotice();
|
||||||
const x2 = v[1];
|
|
||||||
if (x1 == DIFF_DELETE) {
|
|
||||||
result += "<span class='history-deleted'>" + escapeStringToHTML(x2) + "</span>";
|
|
||||||
} else if (x1 == DIFF_EQUAL) {
|
|
||||||
result += "<span class='history-normal'>" + escapeStringToHTML(x2) + "</span>";
|
|
||||||
} else if (x1 == DIFF_INSERT) {
|
|
||||||
result += "<span class='history-added'>" + escapeStringToHTML(x2) + "</span>";
|
|
||||||
}
|
}
|
||||||
|
this.appendTextDiff(diff);
|
||||||
|
rendered = true;
|
||||||
}
|
}
|
||||||
result = result.replace(/\n/g, "<br>");
|
|
||||||
} else if (isImage(this.file)) {
|
} else if (isImage(this.file)) {
|
||||||
const src = this.generateBlobURL("base", w1data);
|
const src = this.generateBlobURL("base", w1data);
|
||||||
const overlay = this.generateBlobURL(
|
const overlay = this.generateBlobURL(
|
||||||
"overlay",
|
"overlay",
|
||||||
readDocument(w2) as Uint8Array<ArrayBuffer>
|
readDocument(w2) as Uint8Array<ArrayBuffer>
|
||||||
);
|
);
|
||||||
result = `<div class='ls-imgdiff-wrap'>
|
this.prepareContentView(false);
|
||||||
<div class='overlay'>
|
if (this.currentDeleted) {
|
||||||
<img class='img-base' src="${src}">
|
this.appendDeletedNotice(false);
|
||||||
<img class='img-overlay' src='${overlay}'>
|
}
|
||||||
</div>
|
this.appendImageDiff(src, overlay);
|
||||||
</div>`;
|
rendered = true;
|
||||||
this.contentView.removeClass("op-pre");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result == undefined) {
|
if (!rendered) {
|
||||||
if (typeof w1data != "string") {
|
if (typeof w1data != "string") {
|
||||||
if (isImage(this.file)) {
|
if (isImage(this.file)) {
|
||||||
const src = this.generateBlobURL("base", w1data);
|
const src = this.generateBlobURL("base", w1data);
|
||||||
result = `<div class='ls-imgdiff-wrap'>
|
this.prepareContentView(false);
|
||||||
<div class='overlay'>
|
if (this.currentDeleted) {
|
||||||
<img class='img-base' src="${src}">
|
this.appendDeletedNotice(false);
|
||||||
</div>
|
}
|
||||||
</div>`;
|
this.appendImageDiff(src);
|
||||||
this.contentView.removeClass("op-pre");
|
} else {
|
||||||
|
if (this.currentDeleted) {
|
||||||
|
this.appendDeletedNotice();
|
||||||
|
}
|
||||||
|
this.contentView.appendText("Binary file");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = escapeStringToHTML(w1data);
|
if (this.currentDeleted) {
|
||||||
|
this.appendDeletedNotice();
|
||||||
|
}
|
||||||
|
this.contentView.appendText(w1data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file";
|
|
||||||
this.contentView.innerHTML =
|
|
||||||
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
|
||||||
}
|
}
|
||||||
// Reset diff navigation after content changes
|
// Reset diff navigation after content changes
|
||||||
this.resetDiffNavigation();
|
this.resetDiffNavigation();
|
||||||
@@ -245,8 +286,7 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
if (direction === "next") {
|
if (direction === "next") {
|
||||||
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
||||||
} else {
|
} else {
|
||||||
this.currentDiffIndex =
|
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||||
this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = diffElements[this.currentDiffIndex];
|
const target = diffElements[this.currentDiffIndex];
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { App, Modal } from "../../../deps.ts";
|
import { App, Modal } from "../../../deps.ts";
|
||||||
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
||||||
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "../../../lib/src/common/types.ts";
|
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "../../../lib/src/common/types.ts";
|
||||||
import { escapeStringToHTML } from "../../../lib/src/string_and_binary/convert.ts";
|
|
||||||
import { delay } from "../../../lib/src/common/utils.ts";
|
import { delay } from "../../../lib/src/common/utils.ts";
|
||||||
import { eventHub } from "../../../common/events.ts";
|
import { eventHub } from "../../../common/events.ts";
|
||||||
import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts";
|
import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts";
|
||||||
@@ -44,6 +43,25 @@ export class ConflictResolveModal extends Modal {
|
|||||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendDiffFragment(container: HTMLDivElement, text: string, cls: string) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const span = container.createSpan({ cls });
|
||||||
|
span.textContent = line;
|
||||||
|
if (index < lines.length - 1) {
|
||||||
|
container.createSpan({ cls: "ls-mark-cr" });
|
||||||
|
container.createEl("br");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
appendVersionInfo(container: HTMLDivElement, cls: string, name: string, date: string) {
|
||||||
|
const line = container.createSpan({ cls });
|
||||||
|
line.createSpan({ text: name, cls: "conflict-dev-name" });
|
||||||
|
line.appendText(`: ${date}`);
|
||||||
|
container.createEl("br");
|
||||||
|
}
|
||||||
|
|
||||||
override onOpen() {
|
override onOpen() {
|
||||||
const { contentEl } = this;
|
const { contentEl } = this;
|
||||||
// Send cancel signal for the previous merge dialogue
|
// Send cancel signal for the previous merge dialogue
|
||||||
@@ -64,25 +82,21 @@ export class ConflictResolveModal extends Modal {
|
|||||||
const div = contentEl.createDiv("");
|
const div = contentEl.createDiv("");
|
||||||
div.addClass("op-scrollable");
|
div.addClass("op-scrollable");
|
||||||
div.addClass("ls-dialog");
|
div.addClass("ls-dialog");
|
||||||
let diff = "";
|
let diffLength = 0;
|
||||||
for (const v of this.result.diff) {
|
for (const v of this.result.diff) {
|
||||||
const x1 = v[0];
|
const x1 = v[0];
|
||||||
const x2 = v[1];
|
const x2 = v[1];
|
||||||
|
diffLength += x2.length;
|
||||||
|
if (diffLength > 100 * 1024) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (x1 == DIFF_DELETE) {
|
if (x1 == DIFF_DELETE) {
|
||||||
diff +=
|
this.appendDiffFragment(div, x2, "deleted");
|
||||||
"<span class='deleted'>" +
|
div.createEl("span", { text: x2, cls: "deleted normal conflict-dev-name" });
|
||||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
|
||||||
"</span>";
|
|
||||||
} else if (x1 == DIFF_EQUAL) {
|
} else if (x1 == DIFF_EQUAL) {
|
||||||
diff +=
|
this.appendDiffFragment(div, x2, "normal");
|
||||||
"<span class='normal'>" +
|
|
||||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
|
||||||
"</span>";
|
|
||||||
} else if (x1 == DIFF_INSERT) {
|
} else if (x1 == DIFF_INSERT) {
|
||||||
diff +=
|
this.appendDiffFragment(div, x2, "added");
|
||||||
"<span class='added'>" +
|
|
||||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
|
||||||
"</span>";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +106,8 @@ export class ConflictResolveModal extends Modal {
|
|||||||
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
|
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
|
||||||
const date2 =
|
const date2 =
|
||||||
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
|
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
|
||||||
div2.innerHTML = `<span class='deleted'><span class='conflict-dev-name'>${this.localName}</span>: ${date1}</span><br>
|
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||||
<span class='added'><span class='conflict-dev-name'>${this.remoteName}</span>: ${date2}</span><br>`;
|
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||||
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) =>
|
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) =>
|
||||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
|
e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
|
||||||
).style.marginRight = "4px";
|
).style.marginRight = "4px";
|
||||||
@@ -108,11 +122,9 @@ export class ConflictResolveModal extends Modal {
|
|||||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
|
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
|
||||||
e.addEventListener("click", () => this.sendResponse(CANCELLED))
|
e.addEventListener("click", () => this.sendResponse(CANCELLED))
|
||||||
).style.marginRight = "4px";
|
).style.marginRight = "4px";
|
||||||
diff = diff.replace(/\n/g, "<br>");
|
if (diffLength > 100 * 1024) {
|
||||||
if (diff.length > 100 * 1024) {
|
div.empty();
|
||||||
div.innerText = "(Too large diff to display)";
|
div.innerText = "(Too large diff to display)";
|
||||||
} else {
|
|
||||||
div.innerHTML = diff;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElem
|
|||||||
// tmpDiv.addClass("sls-header-button");
|
// tmpDiv.addClass("sls-header-button");
|
||||||
tmpDiv.addClass("op-warn-info");
|
tmpDiv.addClass("op-warn-info");
|
||||||
|
|
||||||
tmpDiv.innerHTML = `<p>${$msg("obsidianLiveSyncSettingTab.msgNewVersionNote")}</p><button>${$msg("obsidianLiveSyncSettingTab.optionOkReadEverything")}</button>`;
|
tmpDiv.createEl("p", { text: $msg("obsidianLiveSyncSettingTab.msgNewVersionNote") });
|
||||||
|
const readEverythingButton = tmpDiv.createEl("button", {
|
||||||
|
text: $msg("obsidianLiveSyncSettingTab.optionOkReadEverything"),
|
||||||
|
});
|
||||||
if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) {
|
if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) {
|
||||||
const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
|
const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
|
||||||
informationButtonDiv.querySelector("button")?.addEventListener("click", () => {
|
readEverythingButton.addEventListener("click", () => {
|
||||||
fireAndForget(async () => {
|
fireAndForget(async () => {
|
||||||
this.editingSettings.lastReadUpdates = lastVersion;
|
this.editingSettings.lastReadUpdates = lastVersion;
|
||||||
await this.saveAllDirtySettings();
|
await this.saveAllDirtySettings();
|
||||||
|
|||||||
@@ -121,13 +121,13 @@ export function paneSetup(
|
|||||||
const repo = "vrtmrz/obsidian-livesync";
|
const repo = "vrtmrz/obsidian-livesync";
|
||||||
const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting");
|
const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting");
|
||||||
const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`;
|
const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`;
|
||||||
this.createEl(
|
this.createEl(paneEl, "div", "", (el) => {
|
||||||
paneEl,
|
el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => {
|
||||||
"div",
|
anchor.href = `https://github.com/${repo}/blob/main${topPath}`;
|
||||||
"",
|
anchor.target = "_blank";
|
||||||
(el) =>
|
anchor.rel = "noopener";
|
||||||
(el.innerHTML = `<a href='https://github.com/${repo}/blob/main${topPath}' target="_blank">${$msg("obsidianLiveSyncSettingTab.linkOpenInBrowser")}</a>`)
|
});
|
||||||
);
|
});
|
||||||
const troubleShootEl = this.createEl(paneEl, "div", {
|
const troubleShootEl = this.createEl(paneEl, "div", {
|
||||||
text: "",
|
text: "",
|
||||||
cls: "sls-troubleshoot-preview",
|
cls: "sls-troubleshoot-preview",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const checkConfig = async (
|
|||||||
Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO);
|
Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO);
|
||||||
let isSuccessful = true;
|
let isSuccessful = true;
|
||||||
const emptyDiv = createDiv();
|
const emptyDiv = createDiv();
|
||||||
emptyDiv.innerHTML = "<span></span>";
|
emptyDiv.createSpan();
|
||||||
checkResultDiv?.replaceChildren(...[emptyDiv]);
|
checkResultDiv?.replaceChildren(...[emptyDiv]);
|
||||||
const addResult = (msg: string, classes?: string[]) => {
|
const addResult = (msg: string, classes?: string[]) => {
|
||||||
const tmpDiv = createDiv();
|
const tmpDiv = createDiv();
|
||||||
@@ -21,7 +21,7 @@ export const checkConfig = async (
|
|||||||
if (classes) {
|
if (classes) {
|
||||||
tmpDiv.addClasses(classes);
|
tmpDiv.addClasses(classes);
|
||||||
}
|
}
|
||||||
tmpDiv.innerHTML = `${msg}`;
|
tmpDiv.textContent = msg;
|
||||||
checkResultDiv?.appendChild(tmpDiv);
|
checkResultDiv?.appendChild(tmpDiv);
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -47,9 +47,10 @@ export const checkConfig = async (
|
|||||||
if (!checkResultDiv) return;
|
if (!checkResultDiv) return;
|
||||||
const tmpDiv = createDiv();
|
const tmpDiv = createDiv();
|
||||||
tmpDiv.addClass("ob-btn-config-fix");
|
tmpDiv.addClass("ob-btn-config-fix");
|
||||||
tmpDiv.innerHTML = `<label>${title}</label><button>${$msg("obsidianLiveSyncSettingTab.btnFix")}</button>`;
|
tmpDiv.createEl("label", { text: title });
|
||||||
|
const fixButton = tmpDiv.createEl("button", { text: $msg("obsidianLiveSyncSettingTab.btnFix") });
|
||||||
const x = checkResultDiv.appendChild(tmpDiv);
|
const x = checkResultDiv.appendChild(tmpDiv);
|
||||||
x.querySelector("button")?.addEventListener("click", () => {
|
fixButton.addEventListener("click", () => {
|
||||||
fireAndForget(async () => {
|
fireAndForget(async () => {
|
||||||
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value }));
|
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value }));
|
||||||
const res = await requestToCouchDBWithCredentials(
|
const res = await requestToCouchDBWithCredentials(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
const TYPE_CLOSE = "close";
|
const TYPE_CLOSE = "close";
|
||||||
type ResultType = typeof TYPE_CLOSE;
|
type ResultType = typeof TYPE_CLOSE;
|
||||||
type Props = {
|
type Props = {
|
||||||
setResult: (result: ResultType) => void;
|
setResult: (_result: ResultType) => void;
|
||||||
};
|
};
|
||||||
const { setResult }: Props = $props();
|
const { setResult }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
bind:value={userType}
|
bind:value={userType}
|
||||||
>
|
>
|
||||||
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
|
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
|
||||||
|
You can also select this option if you intend to use <strong>P2P (Peer-to-Peer) synchronisation</strong>
|
||||||
|
instead of a CouchDB/S3 server — P2P requires no server setup at all.
|
||||||
</Option>
|
</Option>
|
||||||
</Options>
|
</Options>
|
||||||
</Instruction>
|
</Instruction>
|
||||||
|
|||||||
@@ -39,18 +39,20 @@
|
|||||||
|
|
||||||
const { setResult, getInitialData }: Props = $props();
|
const { setResult, getInitialData }: Props = $props();
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
let initialData: P2PSyncSetting | undefined = undefined;
|
||||||
if (getInitialData) {
|
if (getInitialData) {
|
||||||
const initialData = getInitialData();
|
initialData = getInitialData();
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
copyTo(initialData, syncSetting);
|
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() {
|
function generateSetting() {
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
const dummyPouch = new PouchDB<EntryDoc>("dummy");
|
const dummyPouch = new PouchDB<EntryDoc>("dummy");
|
||||||
const env: ReplicatorHostEnv = {
|
const env: ReplicatorHostEnv = {
|
||||||
settings: trialRemoteSetting,
|
settings: trialRemoteSetting,
|
||||||
processReplicatedDocs: async (docs: any[]) => {
|
processReplicatedDocs: async (_docs: any[]) => {
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
confirm: context.services.confirm,
|
confirm: context.services.confirm,
|
||||||
@@ -116,7 +118,7 @@
|
|||||||
await replicator.open();
|
await replicator.open();
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
// await delay(1000);
|
// await delay(1000);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
||||||
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
|
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
|
||||||
if (replicator.knownAdvertisements.length > 0) {
|
if (replicator.knownAdvertisements.length > 0) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override async showWindowOnRight(viewType: string): Promise<void> {
|
||||||
|
const existing = this.app.workspace.getLeavesOfType(viewType);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await this.app.workspace.revealLeaf(existing[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rightLeaf = this.app.workspace.getRightLeaf(false);
|
||||||
|
if (rightLeaf) {
|
||||||
|
await rightLeaf.setViewState({
|
||||||
|
type: viewType,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
await this.app.workspace.revealLeaf(rightLeaf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.showWindow(viewType);
|
||||||
|
}
|
||||||
|
|
||||||
private get app() {
|
private get app() {
|
||||||
return this.context.app;
|
return this.context.app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,22 @@ import { createServiceFeature } from "@lib/interfaces/ServiceModule";
|
|||||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta";
|
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta";
|
||||||
import { $msg, setLang } from "@lib/common/i18n";
|
import { $msg, setLang } from "@lib/common/i18n";
|
||||||
|
|
||||||
|
function tryGetLanguage() {
|
||||||
|
try {
|
||||||
|
// Note: 1.8.7+ is required. but it is 18, Feb., 2025. we want to fallback on earlier versions, so we catch the error here.
|
||||||
|
// eslint-disable-next-line obsidianmd/no-unsupported-api
|
||||||
|
return getLanguage();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to get Obsidian language, defaulting to 'def'", e);
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => {
|
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => {
|
||||||
let isChanged = false;
|
let isChanged = false;
|
||||||
const settings = setting.currentSettings();
|
const settings = setting.currentSettings();
|
||||||
if (settings.displayLanguage == "") {
|
if (settings.displayLanguage == "") {
|
||||||
const obsidianLanguage = getLanguage();
|
const obsidianLanguage = tryGetLanguage();
|
||||||
if (
|
if (
|
||||||
SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported
|
SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported
|
||||||
obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting
|
obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/re
|
|||||||
import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte";
|
import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte";
|
||||||
import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
|
import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
|
||||||
import { extractObject } from "octagonal-wheels/object";
|
import { extractObject } from "octagonal-wheels/object";
|
||||||
import { REMOTE_MINIO } from "@lib/common/models/setting.const";
|
import { REMOTE_MINIO, REMOTE_P2P } from "@lib/common/models/setting.const";
|
||||||
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
|
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
|
||||||
import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition";
|
import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition";
|
||||||
|
|
||||||
@@ -200,6 +200,13 @@ export async function adjustSettingToRemoteIfNeeded(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2P has no centralised remote configuration; skip to avoid a spurious
|
||||||
|
// "Failed to connect to the remote server" error dialog.
|
||||||
|
if (config.remoteType === REMOTE_P2P) {
|
||||||
|
log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Remote configuration fetched and applied.
|
// Remote configuration fetched and applied.
|
||||||
if (await adjustSettingToRemote(host, log, config)) {
|
if (await adjustSettingToRemote(host, log, config)) {
|
||||||
config = host.services.setting.currentSettings();
|
config = host.services.setting.currentSettings();
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
|
|||||||
import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP2PReplicatorResult";
|
import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP2PReplicatorResult";
|
||||||
import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector";
|
import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector";
|
||||||
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView";
|
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView";
|
||||||
|
import {
|
||||||
|
P2PServerStatusPaneView,
|
||||||
|
VIEW_TYPE_P2P_SERVER_STATUS,
|
||||||
|
} from "@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView";
|
||||||
import type { LiveSyncCore } from "@/main";
|
import type { LiveSyncCore } from "@/main";
|
||||||
|
import type { WorkspaceLeaf } from "@/deps";
|
||||||
|
import { REMOTE_P2P } from "@lib/common/models/setting.const";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ServiceFeature: P2P Replicator lifecycle management.
|
* ServiceFeature: P2P Replicator lifecycle management.
|
||||||
@@ -33,6 +39,19 @@ export function useP2PReplicatorUI(
|
|||||||
core: LiveSyncCore,
|
core: LiveSyncCore,
|
||||||
replicator: UseP2PReplicatorResult
|
replicator: UseP2PReplicatorResult
|
||||||
) {
|
) {
|
||||||
|
const api = host.services.API as {
|
||||||
|
showWindow: (type: string) => Promise<void>;
|
||||||
|
showWindowOnRight?: (type: string) => Promise<void>;
|
||||||
|
registerWindow: (type: string, factory: (leaf: WorkspaceLeaf) => unknown) => void;
|
||||||
|
addCommand: (command: { id: string; name: string; callback: () => void }) => unknown;
|
||||||
|
addRibbonIcon: (
|
||||||
|
icon: string,
|
||||||
|
title: string,
|
||||||
|
callback: () => void
|
||||||
|
) => { addClass?: (name: string) => unknown } | undefined;
|
||||||
|
getPlatform: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
// const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any };
|
// const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any };
|
||||||
const getReplicator = () => replicator.replicator;
|
const getReplicator = () => replicator.replicator;
|
||||||
const p2pLogCollector = new P2PLogCollector();
|
const p2pLogCollector = new P2PLogCollector();
|
||||||
@@ -43,33 +62,106 @@ export function useP2PReplicatorUI(
|
|||||||
|
|
||||||
// Register view, commands and ribbon if a view factory is provided
|
// Register view, commands and ribbon if a view factory is provided
|
||||||
const viewType = VIEW_TYPE_P2P;
|
const viewType = VIEW_TYPE_P2P;
|
||||||
const factory = (leaf: any) => {
|
const factory = (leaf: WorkspaceLeaf) => {
|
||||||
return new P2PReplicatorPaneView(leaf, core, {
|
return new P2PReplicatorPaneView(leaf, core, {
|
||||||
replicator: getReplicator(),
|
replicator: getReplicator(),
|
||||||
p2pLogCollector,
|
p2pLogCollector,
|
||||||
storeP2PStatusLine,
|
storeP2PStatusLine,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const openPane = () => host.services.API.showWindow(viewType);
|
const statusFactory = (leaf: WorkspaceLeaf) => {
|
||||||
host.services.API.registerWindow(viewType, factory);
|
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(() => {
|
host.services.appLifecycle.onInitialise.addHandler(() => {
|
||||||
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => {
|
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => {
|
||||||
void openPane();
|
void openPane();
|
||||||
});
|
});
|
||||||
|
|
||||||
host.services.API.addCommand({
|
api.addCommand({
|
||||||
id: "open-p2p-replicator",
|
id: "open-p2p-replicator",
|
||||||
name: "P2P Sync : Open P2P Replicator",
|
name: "P2P Sync : Open P2P Replicator (Old UI)",
|
||||||
callback: () => {
|
callback: () => {
|
||||||
void openPane();
|
void openPane();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
host.services.API.addRibbonIcon("waypoints", "P2P Replicator", () => {
|
api.addCommand({
|
||||||
void openPane();
|
id: "open-p2p-server-status",
|
||||||
})?.addClass?.("livesync-ribbon-replicate-p2p");
|
name: "P2P Sync : Open P2P Status",
|
||||||
|
callback: () => {
|
||||||
|
void openStatusPane();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
host.services.API.addCommand({
|
||||||
|
id: "replicate-now-by-p2p-default-peer",
|
||||||
|
name: "Replicate P2P to default peer",
|
||||||
|
checkCallback: (isChecking: boolean) => {
|
||||||
|
const settings = host.services.setting.currentSettings();
|
||||||
|
if (isChecking) {
|
||||||
|
if (settings.remoteType == REMOTE_P2P) return false;
|
||||||
|
return replicator.replicator?.server?.isServing ?? false;
|
||||||
|
}
|
||||||
|
void replicator.replicator?.openReplication(settings, false, true, false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
host.services.API.addCommand({
|
||||||
|
id: "replicate-now-by-p2p",
|
||||||
|
name: "Replicate now by P2P",
|
||||||
|
checkCallback: (isChecking: boolean) => {
|
||||||
|
const settings = host.services.setting.currentSettings();
|
||||||
|
if (isChecking) {
|
||||||
|
if (settings.remoteType == REMOTE_P2P) return false;
|
||||||
|
return replicator.replicator?.server?.isServing ?? false;
|
||||||
|
}
|
||||||
|
void replicator.replicator?.openReplication(settings, false, true, false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
host.services.API.addCommand({
|
||||||
|
id: "p2p-sync-targets",
|
||||||
|
name: "P2P: Sync with targets",
|
||||||
|
checkCallback: (isChecking: boolean) => {
|
||||||
|
if (isChecking) {
|
||||||
|
return replicator.replicator?.server?.isServing ?? false;
|
||||||
|
}
|
||||||
|
void replicator.replicator?.replicateFromCommand(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// api.addRibbonIcon("waypoints", "P2P Replicator", () => {
|
||||||
|
// void openPane();
|
||||||
|
// })?.addClass?.("livesync-ribbon-replicate-p2p");
|
||||||
|
|
||||||
|
api.addRibbonIcon("waypoints", "P2P Status", () => {
|
||||||
|
void openStatusPane();
|
||||||
|
})?.addClass?.("livesync-ribbon-p2p-server-status");
|
||||||
|
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
host.services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||||
|
if (api.getPlatform() !== "obsidian") {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
if (api.showWindowOnRight) {
|
||||||
|
void api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
|
||||||
|
} else {
|
||||||
|
void api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS);
|
||||||
|
}
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
});
|
});
|
||||||
return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine };
|
return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine };
|
||||||
|
|||||||
@@ -38,10 +38,20 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(file: TFile | TFolder, force = false): Promise<void> {
|
async delete(file: TFile | TFolder, force = false): Promise<void> {
|
||||||
|
// if ("trashFile" in this.app.fileManager) {
|
||||||
|
// // eslint-disable-next-line obsidianmd/no-unsupported-api
|
||||||
|
// return await this.app.fileManager.trashFile(file);
|
||||||
|
// }
|
||||||
|
//TODO: need fix
|
||||||
return await this.app.vault.delete(file, force);
|
return await this.app.vault.delete(file, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
async trash(file: TFile | TFolder, force = false): Promise<void> {
|
async trash(file: TFile | TFolder, force = false): Promise<void> {
|
||||||
|
// if ("trashFile" in this.app.fileManager) {
|
||||||
|
// // eslint-disable-next-line obsidianmd/no-unsupported-api
|
||||||
|
// return await this.app.fileManager.trashFile(file);
|
||||||
|
// }
|
||||||
|
//TODO: need fix
|
||||||
return await this.app.vault.trash(file, force);
|
return await this.app.vault.trash(file, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
55
updates.md
55
updates.md
@@ -3,7 +3,37 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
|||||||
|
|
||||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||||
|
|
||||||
## Unreleased
|
## 0.25.63
|
||||||
|
|
||||||
|
17th May, 2026
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The issue which cannot synchronise in Only-P2P mode has been fixed.
|
||||||
|
- Fixed an issue where "Failed to connect to the remote server" was shown during the redFlag rebuild flow when P2P was the primary remote type. Remote configuration fetch is now skipped for P2P.
|
||||||
|
|
||||||
|
### P2P Replication UI Improvements
|
||||||
|
- Brand-new P2P Server Status pane has been added to provide real-time visibility into your connection status and peer network.
|
||||||
|
- For detailed instructions on using the new P2P features, please refer to the updated [User Guide: Peer-to-Peer Synchronisation (2026 Edition)](./docs/p2p_sync_updates_2026.md).
|
||||||
|
- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click.
|
||||||
|
- The vault rebuild flow (`replicateAllFromServer`) now opens the redesigned P2P Replication modal instead of a plain text selection dialogue, providing a consistent UI experience.
|
||||||
|
|
||||||
|
## 0.25.62
|
||||||
|
|
||||||
|
14th May, 2026
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking (#660).
|
||||||
|
|
||||||
|
## 0.25.61
|
||||||
|
|
||||||
|
13th May, 2026
|
||||||
|
|
||||||
|
Reviews have started on the Obsidian Community, haven't they? It was quite a struggle, what with having to fix the outdated ESLint.
|
||||||
|
I am a bit nervous, but it is far better than just plodding along aimlessly, so let us get on with it. If you spot any issues, please let me know straight away.
|
||||||
|
|
||||||
|
From now on, I am avoiding committing directly to the main branch. This is because you lots have all been sending so much PRs. I wanted to keep things harmonious.
|
||||||
|
That said, I am still not used to rebasing, so there are some parts where the commit history is a right mess. I will work on improving that.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
@@ -14,6 +44,29 @@ This P2P synchronisation is not compatible with previous versions in terms of co
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation.
|
- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation.
|
||||||
|
- Network error notice pop-ups are now suppressed when 'NetworkWarningStyle' is set to 'Hidden'. (Thank you so much @SeleiXi!)
|
||||||
|
|
||||||
|
### New features
|
||||||
|
|
||||||
|
- Diff navigation buttons have been added to the diff view, making it easier to move between differences. (Thank you so much @SeleiXi! #871)
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
- Chinese (Simplified) translations for settings and the Setup Wizard have been added. (Thank you so much @zombiek731!)
|
||||||
|
- Common UI controls and signal words are now localised into Chinese (Simplified). (Thank you so much @zombiek731!)
|
||||||
|
- i18n runtime behaviour and locale coverage have been improved. (Thank you so much @52sanmao!)
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
#### New features
|
||||||
|
|
||||||
|
- Daemon synchronisation is now supported. (Thank you so much @andrewleech! #843)
|
||||||
|
- `HeadlessConfirm` has been implemented with sensible defaults, enabling unattended operation in headless environments. (Thank you so much @andrewleech!)
|
||||||
|
- The CLI onboarding experience has been improved. (Thank you so much @OriBoharon! #872)
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Sub-millisecond CLI mtimes are now truncated to prevent mobile crash. (Thank you so much @brian-spackman! #893)
|
||||||
|
|
||||||
## 0.25.60
|
## 0.25.60
|
||||||
|
|
||||||
|
|||||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const targetVersion = process.env.npm_package_version;
|
||||||
|
|
||||||
|
// read minAppVersion from manifest.json and bump version to target version
|
||||||
|
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||||
|
const { minAppVersion } = manifest;
|
||||||
|
manifest.version = targetVersion;
|
||||||
|
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||||
|
|
||||||
|
// update versions.json with target version and minAppVersion from manifest.json
|
||||||
|
// but only if the target version is not already in versions.json
|
||||||
|
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||||
|
if (!Object.values(versions).includes(minAppVersion)) {
|
||||||
|
versions[targetVersion] = minAppVersion;
|
||||||
|
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"0.25.61": "1.7.2",
|
||||||
|
"0.25.60": "1.7.2",
|
||||||
"1.0.1": "0.9.12",
|
"1.0.1": "0.9.12",
|
||||||
"1.0.0": "0.9.7"
|
"1.0.0": "0.9.7"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { defineConfig, mergeConfig } from "vitest/config";
|
|||||||
import { playwright } from "@vitest/browser-playwright";
|
import { playwright } from "@vitest/browser-playwright";
|
||||||
import viteConfig from "./vitest.config.common";
|
import viteConfig from "./vitest.config.common";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import dotenv from "dotenv";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { parseEnv } from "node:util";
|
||||||
import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands";
|
import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands";
|
||||||
|
|
||||||
// P2P test environment variables
|
// P2P test environment variables
|
||||||
@@ -22,8 +23,9 @@ import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./
|
|||||||
// General test options (also read from env):
|
// General test options (also read from env):
|
||||||
// ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests
|
// ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests
|
||||||
// ENABLE_UI - Set to "true" to open a visible browser window during tests
|
// ENABLE_UI - Set to "true" to open a visible browser window during tests
|
||||||
const defEnv = dotenv.config({ path: ".env" }).parsed;
|
const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
|
||||||
const testEnv = dotenv.config({ path: ".test.env" }).parsed;
|
const defEnv = loadEnvFile(".env");
|
||||||
|
const testEnv = loadEnvFile(".test.env");
|
||||||
// Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence)
|
// Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence)
|
||||||
const p2pEnv: Record<string, string> = {};
|
const p2pEnv: Record<string, string> = {};
|
||||||
if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID;
|
if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID;
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { defineConfig, mergeConfig } from "vitest/config";
|
|||||||
import { playwright } from "@vitest/browser-playwright";
|
import { playwright } from "@vitest/browser-playwright";
|
||||||
import viteConfig from "./vitest.config.common";
|
import viteConfig from "./vitest.config.common";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import dotenv from "dotenv";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { parseEnv } from "node:util";
|
||||||
import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands";
|
import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands";
|
||||||
const defEnv = dotenv.config({ path: ".env" }).parsed;
|
|
||||||
const testEnv = dotenv.config({ path: ".test.env" }).parsed;
|
const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
|
||||||
|
const defEnv = loadEnvFile(".env");
|
||||||
|
const testEnv = loadEnvFile(".test.env");
|
||||||
const env = Object.assign({}, defEnv, testEnv);
|
const env = Object.assign({}, defEnv, testEnv);
|
||||||
const debuggerEnabled = env?.ENABLE_DEBUGGER === "true";
|
const debuggerEnabled = env?.ENABLE_DEBUGGER === "true";
|
||||||
const enableUI = env?.ENABLE_UI === "true";
|
const enableUI = env?.ENABLE_UI === "true";
|
||||||
|
|||||||
Reference in New Issue
Block a user