mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-16 12:31:16 +00:00
Compare commits
36 Commits
update_lib
...
enhance_fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
42
docs/p2p_sync_updates_2026.md
Normal file
42
docs/p2p_sync_updates_2026.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# User Guide: Peer-to-Peer Synchronisation (2026 Edition)
|
||||||
|
|
||||||
|
Peer-to-Peer (P2P) synchronisation has evolved significantly. This guide covers the essential setup and the new features introduced in the 2026 updates.
|
||||||
|
|
||||||
|
## 1. Core Concept: Server-less Freedom
|
||||||
|
P2P synchronisation allows your devices to talk directly to each other using WebRTC. A central server is not required for data storage, ensuring maximum privacy and "freedom."
|
||||||
|
|
||||||
|
## 2. Setting Up via P2P Status Pane
|
||||||
|
You no longer need to navigate through complex menus. Simply open the **P2P Server Status** (via the ribbon icon or command palette) and click the **⚙ (Cog)** icon.
|
||||||
|
|
||||||
|
This opens the **P2P Setup** dialogue where you can configure the essentials:
|
||||||
|
- **Room ID:** A unique identifier for your synchronisation group.
|
||||||
|
- **Password:** Your encryption key. Ensure all your devices use the exact same password.
|
||||||
|
- **Device Name:** A recognisable name for the current device (e.g., `iphone-16`).
|
||||||
|
|
||||||
|
Once you have saved the settings, return to the **P2P Status Pane** and click the **Connect** button to join the network.
|
||||||
|
|
||||||
|
*Tip: You can also toggle **Auto Connect** in the setup dialogue to automatically join the network whenever Obsidian starts.*
|
||||||
|
|
||||||
|
## 3. Real-time Control
|
||||||
|
The status pane in the right sidebar provides granular control over your synchronisation:
|
||||||
|
|
||||||
|
- **Signalling Status:** Shows if you are connected to the relay (🟢 Online).
|
||||||
|
- **Live-push (Broadcast):** Toggle "Broadcast changes" to notify other peers whenever you make an edit.
|
||||||
|
- **Watch:** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience.
|
||||||
|
|
||||||
|
## 4. Enhanced Replication Dialogue (Bidirectional Sync)
|
||||||
|
If you want to synchronise manually, click the **🔄 (Replicate)** button next to a peer in the device list. This opens the **Replication Dialogue**.
|
||||||
|
|
||||||
|
Inside the dialogue, you can still see the **Server Status** at the top, so you will know if you are still connected while performing manual synchronisations.
|
||||||
|
|
||||||
|
When you trigger a synchronisation this way, the system now performs a **Bidirectional Synchronisation**:
|
||||||
|
1. **Pull:** It first fetches changes from the peer.
|
||||||
|
2. **Push:** If the pull is successful, it immediately pushes your local changes to that peer.
|
||||||
|
|
||||||
|
This "one-click" approach ensures both devices are perfectly in synchronisation without manual back-and-forth.
|
||||||
|
|
||||||
|
## 5. Technical Improvements in 2026
|
||||||
|
- **Decoupled Architecture:** The UI is now strictly separated from the core logic, making the plugin more stable across different platforms (Mobile, Desktop, and Web).
|
||||||
|
- **Svelte 5 UI:** The interface has been rebuilt for better responsiveness and clearer status indicators.
|
||||||
|
- **Security:** All data remains end-to-end encrypted. Even the signalling relay never sees your actual notes.
|
||||||
|
|
||||||
@@ -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,103 +1,83 @@
|
|||||||
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({
|
"**/node_modules/*",
|
||||||
baseDirectory: __dirname,
|
"**/jest.config.js",
|
||||||
recommendedConfig: js.configs.recommended,
|
"src/lib/coverage",
|
||||||
allConfig: js.configs.all,
|
"src/lib/browsertest",
|
||||||
});
|
"**/test.ts",
|
||||||
|
"**/tests.ts",
|
||||||
export default [
|
"**/**test.ts",
|
||||||
|
"**/**.test.ts",
|
||||||
|
"**/*.unit.spec.ts",
|
||||||
|
"**/esbuild.*.mjs",
|
||||||
|
"**/terser.*.mjs",
|
||||||
|
"**/node_modules",
|
||||||
|
"**/build",
|
||||||
|
"**/.eslintrc.js.bak",
|
||||||
|
"src/lib/src/patches/pouchdb-utils",
|
||||||
|
"**/esbuild.config.mjs",
|
||||||
|
"**/rollup.config.js",
|
||||||
|
"modules/octagonal-wheels/rollup.config.js",
|
||||||
|
"modules/octagonal-wheels/dist/**/*",
|
||||||
|
"src/lib/test",
|
||||||
|
"src/lib/_tools",
|
||||||
|
"src/lib/src/cli",
|
||||||
|
"**/main.js",
|
||||||
|
"src/apps/**/*",
|
||||||
|
".prettierrc.*.mjs",
|
||||||
|
".prettierrc.mjs",
|
||||||
|
"*.config.mjs",
|
||||||
|
"src/apps/**/*",
|
||||||
|
"src/lib/src/services/implements/browser/**",
|
||||||
|
"src/lib/src/services/implements/headless/**",
|
||||||
|
"src/lib/src/API",
|
||||||
|
]),
|
||||||
|
...sveltePlugin.configs["flat/base"],
|
||||||
|
...obsidianmd.configs.recommended,
|
||||||
{
|
{
|
||||||
ignores: [
|
files: ["**/*.ts"],
|
||||||
"**/node_modules/*",
|
|
||||||
"**/jest.config.js",
|
|
||||||
"src/lib/coverage",
|
|
||||||
"src/lib/browsertest",
|
|
||||||
"**/test.ts",
|
|
||||||
"**/tests.ts",
|
|
||||||
"**/**test.ts",
|
|
||||||
"**/**.test.ts",
|
|
||||||
"**/esbuild.*.mjs",
|
|
||||||
"**/terser.*.mjs",
|
|
||||||
"**/node_modules",
|
|
||||||
"**/build",
|
|
||||||
"**/.eslintrc.js.bak",
|
|
||||||
"src/lib/src/patches/pouchdb-utils",
|
|
||||||
"**/esbuild.config.mjs",
|
|
||||||
"**/rollup.config.js",
|
|
||||||
"modules/octagonal-wheels/rollup.config.js",
|
|
||||||
"modules/octagonal-wheels/dist/**/*",
|
|
||||||
"src/lib/test",
|
|
||||||
"src/lib/_tools",
|
|
||||||
"src/lib/src/cli",
|
|
||||||
"**/main.js",
|
|
||||||
"src/apps/**/*",
|
|
||||||
".prettierrc.*.mjs",
|
|
||||||
".prettierrc.mjs",
|
|
||||||
"*.config.mjs"
|
|
||||||
],
|
|
||||||
},
|
|
||||||
...compat.extends(
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/eslint-recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended"
|
|
||||||
),
|
|
||||||
{
|
|
||||||
plugins: {
|
|
||||||
"@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.62",
|
||||||
"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.62",
|
||||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,12 +257,12 @@ describe("daemon command", () => {
|
|||||||
// failure 1: 30000*2=60000, failure 2: 30000*4=120000,
|
// failure 1: 30000*2=60000, failure 2: 30000*4=120000,
|
||||||
// failure 3: 30000*8=240000, failure 4: 30000*16=480000→capped, 5→cap, 6→cap
|
// failure 3: 30000*8=240000, failure 4: 30000*16=480000→capped, 5→cap, 6→cap
|
||||||
const expectedIntervals = [
|
const expectedIntervals = [
|
||||||
baseMs * 2, // after failure 1: 60000
|
baseMs * 2, // after failure 1: 60000
|
||||||
baseMs * 4, // after failure 2: 120000
|
baseMs * 4, // after failure 2: 120000
|
||||||
baseMs * 8, // after failure 3: 240000
|
baseMs * 8, // after failure 3: 240000
|
||||||
300_000, // after failure 4 (would be 480000, capped)
|
300_000, // after failure 4 (would be 480000, capped)
|
||||||
300_000, // after failure 5 (cap)
|
300_000, // after failure 5 (cap)
|
||||||
300_000, // after failure 6 (cap)
|
300_000, // after failure 6 (cap)
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const expected of expectedIntervals) {
|
for (const expected of expectedIntervals) {
|
||||||
|
|||||||
@@ -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,
|
{
|
||||||
suspendFileWatching: false,
|
...context.originalSyncSettings,
|
||||||
}, true);
|
suspendFileWatching: false,
|
||||||
|
},
|
||||||
|
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,
|
{
|
||||||
suspendParseReplicationResult: false,
|
suspendFileWatching: false,
|
||||||
}, true);
|
suspendParseReplicationResult: false,
|
||||||
|
},
|
||||||
|
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(
|
||||||
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
|
"[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
|
||||||
"or use --interval for polling mode.");
|
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
|
||||||
|
"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([
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
fileProcessing: services.fileProcessing,
|
basePath,
|
||||||
setting: services.setting,
|
core,
|
||||||
vaultService: services.vault,
|
{
|
||||||
storageAccessManager: storageAccessManager,
|
fileProcessing: services.fileProcessing,
|
||||||
APIService: services.API,
|
setting: services.setting,
|
||||||
}, ignoreRules, watchEnabled);
|
vaultService: services.vault,
|
||||||
|
storageAccessManager: storageAccessManager,
|
||||||
|
APIService: services.API,
|
||||||
|
},
|
||||||
|
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);
|
||||||
@@ -105,7 +107,7 @@ export class IgnoreRules {
|
|||||||
if (raw.startsWith("!")) {
|
if (raw.startsWith("!")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[IgnoreRules] Negation pattern '${raw}' is not supported. ` +
|
`[IgnoreRules] Negation pattern '${raw}' is not supported. ` +
|
||||||
`Remove it from .livesync/ignore or use a separate include/exclude file.`
|
`Remove it from .livesync/ignore or use a separate include/exclude file.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.patterns.push(this._normalisePattern(raw));
|
this.patterns.push(this._normalisePattern(raw));
|
||||||
|
|||||||
@@ -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,69 @@
|
|||||||
|
import { App, Modal } from "@/deps.ts";
|
||||||
|
import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
|
|
||||||
|
export type P2POpenReplicationModalCallback = {
|
||||||
|
onSync: (peerId: string) => Promise<void>;
|
||||||
|
onSyncAndClose: (peerId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class P2POpenReplicationModal extends Modal {
|
||||||
|
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||||
|
callback?: P2POpenReplicationModalCallback;
|
||||||
|
component?: ReturnType<typeof mount>;
|
||||||
|
showResult: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
liveSyncReplicator: LiveSyncTrysteroReplicator,
|
||||||
|
callback?: P2POpenReplicationModalCallback,
|
||||||
|
showResult: boolean = false
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.liveSyncReplicator = liveSyncReplicator;
|
||||||
|
this.callback = callback;
|
||||||
|
this.showResult = showResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSync(peerId: string) {
|
||||||
|
if (this.callback?.onSync) {
|
||||||
|
await this.callback.onSync(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSyncAndClose(peerId: string) {
|
||||||
|
if (this.callback?.onSyncAndClose) {
|
||||||
|
await this.callback.onSyncAndClose(peerId);
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
override onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
this.titleEl.setText("P2P Replication");
|
||||||
|
contentEl.empty();
|
||||||
|
|
||||||
|
if (this.component === undefined) {
|
||||||
|
this.component = mount(P2POpenReplicationPane, {
|
||||||
|
target: contentEl,
|
||||||
|
props: {
|
||||||
|
liveSyncReplicator: this.liveSyncReplicator,
|
||||||
|
onSync: (peerId: string) => this.onSync(peerId),
|
||||||
|
onSyncAndClose: (peerId: string) => this.onSyncAndClose(peerId),
|
||||||
|
onClose: () => this.close(),
|
||||||
|
showResult: this.showResult,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
if (this.component !== undefined) {
|
||||||
|
void unmount(this.component);
|
||||||
|
this.component = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
288
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal file
288
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { eventHub } from "@/common/events";
|
||||||
|
import {
|
||||||
|
EVENT_SERVER_STATUS,
|
||||||
|
EVENT_REQUEST_STATUS,
|
||||||
|
type P2PServerInfo,
|
||||||
|
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||||
|
// import type { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator";
|
||||||
|
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
|
||||||
|
import { Logger } from "@lib/common/logger";
|
||||||
|
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
|
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||||
|
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||||
|
onSync: (_peerId: string) => Promise<void>;
|
||||||
|
onSyncAndClose: (_peerId: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
showResult: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator }: Props = $props();
|
||||||
|
|
||||||
|
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||||
|
let syncingPeerId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||||
|
async function requestServerStatus() {
|
||||||
|
await liveSyncReplicator.requestStatus();
|
||||||
|
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
// ServerStatus
|
||||||
|
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||||
|
serverInfo = status;
|
||||||
|
});
|
||||||
|
fireAndForget(async ()=>{
|
||||||
|
await delay(100);
|
||||||
|
await requestServerStatus();
|
||||||
|
})
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSync(peerId: string) {
|
||||||
|
try {
|
||||||
|
syncingPeerId = peerId;
|
||||||
|
Logger(`Starting sync with ${peerId}`, logLevel);
|
||||||
|
await onSync(peerId);
|
||||||
|
Logger(`Sync completed with ${peerId}`, logLevel);
|
||||||
|
} catch (e) {
|
||||||
|
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||||
|
} finally {
|
||||||
|
syncingPeerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncAndClose(peerId: string) {
|
||||||
|
try {
|
||||||
|
syncingPeerId = peerId;
|
||||||
|
Logger(`Starting sync and close with ${peerId}`, logLevel);
|
||||||
|
await onSyncAndClose(peerId);
|
||||||
|
Logger(`Sync and close completed with ${peerId}`, logLevel);
|
||||||
|
} catch (e) {
|
||||||
|
Logger(`Error during sync and close: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||||
|
} finally {
|
||||||
|
syncingPeerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function disconnect(){
|
||||||
|
try {
|
||||||
|
await liveSyncReplicator.close();
|
||||||
|
Logger("Signalling connection closed.", logLevel);
|
||||||
|
} catch (e) {
|
||||||
|
Logger(`Failed to close signalling connection: ${e instanceof Error ? e.message : String(e)}`, logLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function onCloseAndDisconnect(){
|
||||||
|
await disconnect();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
|
||||||
|
if (peer.isAccepted === true) return "ACCEPTED";
|
||||||
|
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
|
||||||
|
if (peer.isAccepted === false) return "DENIED";
|
||||||
|
return "NEW";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
|
||||||
|
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p2p-container">
|
||||||
|
<P2PServerStatusCard
|
||||||
|
{liveSyncReplicator}
|
||||||
|
showBroadcastToggle={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="peers-section">
|
||||||
|
<h3>Available Devices</h3>
|
||||||
|
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
|
||||||
|
<div class="peers-list">
|
||||||
|
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
|
||||||
|
<div class="peer-item">
|
||||||
|
<div class="peer-info">
|
||||||
|
<div class="peer-name">{peer.name}</div>
|
||||||
|
<div class="peer-meta">
|
||||||
|
<span class="badge">{peer.platform}</span>
|
||||||
|
<span class="peer-id-mini" title={peer.peerId}>
|
||||||
|
{peer.peerId.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||||
|
{getAcceptanceStatus(peer)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="peer-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={syncingPeerId !== null}
|
||||||
|
onclick={() => handleSync(peer.peerId)}
|
||||||
|
>
|
||||||
|
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
disabled={syncingPeerId !== null}
|
||||||
|
onclick={() => handleSyncAndClose(peer.peerId)}
|
||||||
|
>
|
||||||
|
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync & Close"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if serverInfo}
|
||||||
|
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<button class="btn btn-cancel" onclick={onClose}>Close</button>
|
||||||
|
<button class="btn btn-cancel" onclick={onCloseAndDisconnect}>Close & Disconnect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.p2p-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-section {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.accepted {
|
||||||
|
background-color: var(--background-modifier-success);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.denied {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.unknown {
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-id-mini {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-peers {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--divider-color);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal file
73
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { App } from "@/deps.ts";
|
||||||
|
import { Logger } from "@lib/common/logger";
|
||||||
|
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
|
||||||
|
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
|
import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an openReplicationUI factory for Obsidian environments.
|
||||||
|
* Returns a per-replicator closure that opens the P2P Replication modal
|
||||||
|
* and performs bidirectional sync (pull then push on success).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const factory = createOpenReplicationUI(app);
|
||||||
|
* useP2PReplicatorFeature(core, factory);
|
||||||
|
*/
|
||||||
|
export function createOpenReplicationUI(
|
||||||
|
app: App
|
||||||
|
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
|
||||||
|
return (replicator: LiveSyncTrysteroReplicator) =>
|
||||||
|
(showResult: boolean): Promise<boolean | void> => {
|
||||||
|
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||||
|
return new Promise<boolean | void>((resolve) => {
|
||||||
|
const modal = new P2POpenReplicationModal(
|
||||||
|
app,
|
||||||
|
replicator,
|
||||||
|
{
|
||||||
|
onSync: async (peerId: string) => {
|
||||||
|
try {
|
||||||
|
// pull (replicateFrom) first; push only on success
|
||||||
|
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||||
|
if (pullResult?.ok) {
|
||||||
|
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||||
|
resolve(pushResult?.ok ?? true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Logger(
|
||||||
|
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
logLevel
|
||||||
|
);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSyncAndClose: async (peerId: string) => {
|
||||||
|
try {
|
||||||
|
const pullResult = await replicator.replicateFrom(peerId, showResult);
|
||||||
|
if (pullResult?.ok) {
|
||||||
|
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
|
||||||
|
if (pushResult?.ok ?? true) {
|
||||||
|
await replicator.close();
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Logger(
|
||||||
|
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
logLevel
|
||||||
|
);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showResult
|
||||||
|
);
|
||||||
|
modal.open();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
201
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal file
201
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { eventHub } from "@/common/events";
|
||||||
|
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||||
|
import type { P2PServerInfo } from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||||
|
import {
|
||||||
|
EVENT_SERVER_STATUS,
|
||||||
|
EVENT_REQUEST_STATUS,
|
||||||
|
EVENT_P2P_REPLICATOR_STATUS,
|
||||||
|
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||||
|
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
|
import type { P2PReplicatorStatus } from "@/lib/src/replication/trystero/TrysteroReplicator";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||||
|
showBroadcastToggle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { liveSyncReplicator, showBroadcastToggle = true }: Props = $props();
|
||||||
|
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||||
|
let replicatorStatus = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||||
|
|
||||||
|
async function requestServerStatus() {
|
||||||
|
await liveSyncReplicator.requestStatus();
|
||||||
|
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenConnection() {
|
||||||
|
await liveSyncReplicator.makeSureOpened();
|
||||||
|
await requestServerStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDisconnect() {
|
||||||
|
await liveSyncReplicator.close();
|
||||||
|
await requestServerStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBroadcast() {
|
||||||
|
if (replicatorStatus?.isBroadcasting) {
|
||||||
|
liveSyncReplicator.disableBroadcastChanges();
|
||||||
|
} else {
|
||||||
|
liveSyncReplicator.enableBroadcastChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||||
|
serverInfo = status;
|
||||||
|
});
|
||||||
|
const unsubscribeStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||||
|
replicatorStatus = status;
|
||||||
|
});
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await delay(100);
|
||||||
|
await requestServerStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribeStatus();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const isConnected = $derived.by(() => serverInfo?.isConnected);
|
||||||
|
const isBroadcasting = $derived.by(() => replicatorStatus?.isBroadcasting ?? false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="server-status">
|
||||||
|
<h3>Signalling Status</h3>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<span>Connection:</span>
|
||||||
|
<span class="status-value {isConnected ? 'connected' : 'disconnected'}">
|
||||||
|
{isConnected ? "🟢 Connected" : "🔴 Disconnected"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item status-action">
|
||||||
|
{#if !isConnected}
|
||||||
|
<button onclick={onOpenConnection}>Open connection</button>
|
||||||
|
{:else}
|
||||||
|
<button onclick={onDisconnect}>Close connection</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if serverInfo}
|
||||||
|
<div class="status-item">
|
||||||
|
<span>Peer ID:</span>
|
||||||
|
<span class="peer-id-display" title={serverInfo.serverPeerId}>
|
||||||
|
{serverInfo.serverPeerId.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<span>Devices:</span>
|
||||||
|
<span>{serverInfo.knownAdvertisements.length}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showBroadcastToggle}
|
||||||
|
<div class="status-item status-action broadcast-row">
|
||||||
|
<!-- Live-push to peers: stream this device's changes to connected peers for LiveSync -->
|
||||||
|
<label class="broadcast-label" for="broadcast-toggle">
|
||||||
|
Live-push to peers
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="broadcast-toggle"
|
||||||
|
class="broadcast-button {isBroadcasting ? 'is-on' : 'is-off'}"
|
||||||
|
onclick={toggleBroadcast}
|
||||||
|
title={isBroadcasting ? 'Pushing changes to peers — click to stop' : 'Start pushing changes to peers'}
|
||||||
|
>
|
||||||
|
{isBroadcasting ? '📡 On' : '📡 Off'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.server-status {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-action {
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.connected {
|
||||||
|
color: var(--text-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.disconnected {
|
||||||
|
color: var(--text-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-id-display {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-row {
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-button {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-button.is-on {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-button.is-off {
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-button.is-off:hover {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
558
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
558
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
|
||||||
|
import {
|
||||||
|
EVENT_SERVER_STATUS,
|
||||||
|
EVENT_REQUEST_STATUS,
|
||||||
|
EVENT_P2P_REPLICATOR_STATUS,
|
||||||
|
EVENT_P2P_REPLICATOR_PROGRESS,
|
||||||
|
type P2PServerInfo,
|
||||||
|
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||||
|
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||||
|
import type { P2PReplicatorStatus, P2PReplicationReport } from "@/lib/src/replication/trystero/TrysteroReplicator";
|
||||||
|
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||||
|
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { liveSyncReplicator }: Props = $props();
|
||||||
|
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||||
|
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||||
|
let decidingPeerId = $state<string | null>(null);
|
||||||
|
let communicatingUntil = $state<Record<string, number>>({});
|
||||||
|
const COMMUNICATION_HOLD_MS = 2500;
|
||||||
|
|
||||||
|
function markCommunicating(peerId: string) {
|
||||||
|
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
|
||||||
|
communicatingUntil = { ...communicatingUntil, [peerId]: expiry };
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if ((communicatingUntil[peerId] ?? 0) <= Date.now()) {
|
||||||
|
const { [peerId]: _removed, ...rest } = communicatingUntil;
|
||||||
|
communicatingUntil = rest;
|
||||||
|
}
|
||||||
|
}, COMMUNICATION_HOLD_MS + 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestServerStatus() {
|
||||||
|
await liveSyncReplicator.requestStatus();
|
||||||
|
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||||
|
serverInfo = status;
|
||||||
|
});
|
||||||
|
const unsubscribeReplicatorStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||||
|
replicatorInfo = status;
|
||||||
|
for (const peerId of status.replicatingFrom) {
|
||||||
|
markCommunicating(peerId);
|
||||||
|
}
|
||||||
|
for (const peerId of status.replicatingTo) {
|
||||||
|
markCommunicating(peerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const unsubscribeReplicatorProgress = eventHub.onEvent(EVENT_P2P_REPLICATOR_PROGRESS, (report) => {
|
||||||
|
const rep = report as P2PReplicationReport;
|
||||||
|
if (("fetching" in rep && rep.fetching?.isActive) || ("sending" in rep && rep.sending?.isActive)) {
|
||||||
|
markCommunicating(rep.peerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await delay(100);
|
||||||
|
await requestServerStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribeReplicatorStatus();
|
||||||
|
unsubscribeReplicatorProgress();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
|
||||||
|
if (peer.isAccepted === true) return "ACCEPTED";
|
||||||
|
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
|
||||||
|
if (peer.isAccepted === false) return "DENIED";
|
||||||
|
return "NEW";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
|
||||||
|
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConnectionSettings() {
|
||||||
|
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeDecision(
|
||||||
|
peer: P2PServerInfo["knownAdvertisements"][number],
|
||||||
|
decision: boolean,
|
||||||
|
isTemporary: boolean
|
||||||
|
) {
|
||||||
|
decidingPeerId = peer.peerId;
|
||||||
|
try {
|
||||||
|
await liveSyncReplicator.makeDecision({
|
||||||
|
peerId: peer.peerId,
|
||||||
|
name: peer.name,
|
||||||
|
decision,
|
||||||
|
isTemporary,
|
||||||
|
});
|
||||||
|
await requestServerStatus();
|
||||||
|
} finally {
|
||||||
|
decidingPeerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
decidingPeerId = peer.peerId;
|
||||||
|
try {
|
||||||
|
await liveSyncReplicator.revokeDecision({
|
||||||
|
peerId: peer.peerId,
|
||||||
|
name: peer.name,
|
||||||
|
});
|
||||||
|
await requestServerStatus();
|
||||||
|
} finally {
|
||||||
|
decidingPeerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccepted(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||||
|
return peer.isTemporaryAccepted === true || peer.isAccepted === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWatching(peerId: string) {
|
||||||
|
return replicatorInfo?.watchingPeers?.includes(peerId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWatch(peerId: string) {
|
||||||
|
if (isWatching(peerId)) {
|
||||||
|
liveSyncReplicator.unwatchPeer(peerId);
|
||||||
|
} else {
|
||||||
|
liveSyncReplicator.watchPeer(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommunicating(peerId: string) {
|
||||||
|
const to = replicatorInfo?.replicatingTo ?? [];
|
||||||
|
const from = replicatorInfo?.replicatingFrom ?? [];
|
||||||
|
const isLiveCommunicating = to.includes(peerId) || from.includes(peerId);
|
||||||
|
const isHeldCommunicating = (communicatingUntil[peerId] ?? 0) > Date.now();
|
||||||
|
return isLiveCommunicating || isHeldCommunicating;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p2p-container">
|
||||||
|
<div class="pane-header">
|
||||||
|
<h2>P2P Host</h2>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
onclick={openConnectionSettings}
|
||||||
|
title="Open P2P Setup..."
|
||||||
|
aria-label="Open P2P Setup..."
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<P2PServerStatusCard {liveSyncReplicator} />
|
||||||
|
|
||||||
|
<div class="peers-section">
|
||||||
|
<div class="peers-header">
|
||||||
|
<h3>Known Devices</h3>
|
||||||
|
<button class="refresh" onclick={requestServerStatus}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
|
||||||
|
<div class="peers-list">
|
||||||
|
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
|
||||||
|
<div class="peer-item">
|
||||||
|
<div class="peer-info">
|
||||||
|
<div class="peer-name">
|
||||||
|
{peer.name} : <span class="peer-id-mini" title={peer.peerId}>({peer.peerId.slice(0, 8)})</span>
|
||||||
|
{#if isCommunicating(peer.peerId)}
|
||||||
|
<span class="comm-icon" title="Communicating" aria-label="Communicating">📡</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="peer-meta">
|
||||||
|
<span class="badge">{peer.platform}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="peer-actions">
|
||||||
|
{#if isAccepted(peer)}
|
||||||
|
<div class="decision-row accepted-row">
|
||||||
|
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||||
|
{getAcceptanceStatus(peer)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => revokeDecision(peer)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="decision-row watch-row">
|
||||||
|
<span class="decision-label">WATCH</span>
|
||||||
|
<button
|
||||||
|
class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}"
|
||||||
|
title={isWatching(peer.peerId) ? 'Watching this peer \u2014 click to stop' : 'Watch this peer\'s changes'}
|
||||||
|
aria-label={isWatching(peer.peerId) ? 'Stop watching' : 'Watch peer'}
|
||||||
|
onclick={() => toggleWatch(peer.peerId)}
|
||||||
|
>
|
||||||
|
{isWatching(peer.peerId) ? '👁' : '👁🗨'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="decision-status">
|
||||||
|
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||||
|
{getAcceptanceStatus(peer)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="decision-row">
|
||||||
|
<span class="decision-label">PERMANENT</span>
|
||||||
|
<button
|
||||||
|
class="emoji-button"
|
||||||
|
title="Allow permanently"
|
||||||
|
aria-label="Allow permanently"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => makeDecision(peer, true, false)}
|
||||||
|
>
|
||||||
|
✅
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="emoji-button mod-warning"
|
||||||
|
title="Deny permanently"
|
||||||
|
aria-label="Deny permanently"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => makeDecision(peer, false, false)}
|
||||||
|
>
|
||||||
|
🚫
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="decision-row">
|
||||||
|
<span class="decision-label">SESSION</span>
|
||||||
|
<button
|
||||||
|
class="emoji-button"
|
||||||
|
title="Allow in session"
|
||||||
|
aria-label="Allow in session"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => makeDecision(peer, true, true)}
|
||||||
|
>
|
||||||
|
✅
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="emoji-button mod-warning"
|
||||||
|
title="Deny in session"
|
||||||
|
aria-label="Deny in session"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => makeDecision(peer, false, true)}
|
||||||
|
>
|
||||||
|
🚫
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)}
|
||||||
|
<button
|
||||||
|
class="action-button revoke-inline"
|
||||||
|
disabled={decidingPeerId !== null}
|
||||||
|
onclick={() => revokeDecision(peer)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if serverInfo}
|
||||||
|
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
|
||||||
|
{:else}
|
||||||
|
<p class="no-peers">Fetching status...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.p2p-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-section {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh:hover {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background-color: var(--background-tertiary);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.accepted {
|
||||||
|
background-color: var(--background-modifier-success);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.denied {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.unknown {
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-id-mini {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comm-icon {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
animation: pulse-comm 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-comm {
|
||||||
|
0% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accepted-row {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
width: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 1.7rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button.mod-warning {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button.is-watching {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button.mod-warning:hover:not(:disabled) {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-row {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.mod-warning {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revoke-inline {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-peers {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { WorkspaceLeaf } from "@/deps.ts";
|
||||||
|
import { mount } from "svelte";
|
||||||
|
import { SvelteItemView } from "@/common/SvelteItemView.ts";
|
||||||
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||||
|
import type { P2PPaneParams } from "@/lib/src/replication/trystero/UseP2PReplicatorResult";
|
||||||
|
import P2PServerStatusPane from "./P2PServerStatusPane.svelte";
|
||||||
|
|
||||||
|
export const VIEW_TYPE_P2P_SERVER_STATUS = "p2p-server-status";
|
||||||
|
|
||||||
|
export class P2PServerStatusPaneView extends SvelteItemView {
|
||||||
|
core: LiveSyncBaseCore;
|
||||||
|
private _p2pResult: P2PPaneParams;
|
||||||
|
override icon = "waypoints";
|
||||||
|
override navigation = false;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams) {
|
||||||
|
super(leaf);
|
||||||
|
this.core = core;
|
||||||
|
this._p2pResult = p2pResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
override getIcon(): string {
|
||||||
|
return "waypoints";
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType() {
|
||||||
|
return VIEW_TYPE_P2P_SERVER_STATUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText() {
|
||||||
|
return "P2P Server Status";
|
||||||
|
}
|
||||||
|
|
||||||
|
instantiateComponent(target: HTMLElement) {
|
||||||
|
return mount(P2PServerStatusPane, {
|
||||||
|
target,
|
||||||
|
props: {
|
||||||
|
liveSyncReplicator: this._p2pResult.replicator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/lib
2
src/lib
Submodule src/lib updated: adcfe42522...2868aae6fd
13
src/main.ts
13
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 } 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,9 @@ 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));
|
||||||
|
useP2PReplicatorCommands(core, replicator);
|
||||||
|
useP2PReplicatorUI(core, core, replicator);
|
||||||
useRemoteConfiguration(core);
|
useRemoteConfiguration(core);
|
||||||
|
|
||||||
useSetupProtocolFeature(core, setupManager);
|
useSetupProtocolFeature(core, setupManager);
|
||||||
@@ -189,9 +193,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";
|
||||||
|
|||||||
@@ -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);
|
||||||
const dmp = new diff_match_patch();
|
if (typeof w2data == "string") {
|
||||||
const w2data = readDocument(w2) as string;
|
const dmp = new diff_match_patch();
|
||||||
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(
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||||
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>
|
||||||
|
|||||||
@@ -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(
|
const initialPeerName = (initialData?.P2P_DevicePeerName ?? "").trim();
|
||||||
SETTING_KEY_P2P_DEVICE_NAME
|
if (initialPeerName !== "") {
|
||||||
) as string;
|
return;
|
||||||
} else {
|
}
|
||||||
syncSetting.P2P_DevicePeerName = "";
|
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,20 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override async showWindowOnRight(viewType: string): Promise<void> {
|
||||||
|
const rightLeaf = this.app.workspace.getRightLeaf(false);
|
||||||
|
if (rightLeaf) {
|
||||||
|
await rightLeaf.setViewState({
|
||||||
|
type: viewType,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
await this.app.workspace.revealLeaf(rightLeaf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.showWindow(viewType);
|
||||||
|
}
|
||||||
|
|
||||||
private get app() {
|
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
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ServiceFeature: P2P Replicator lifecycle management.
|
* ServiceFeature: P2P Replicator lifecycle management.
|
||||||
@@ -33,6 +38,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 +61,71 @@ 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 Server Status",
|
||||||
|
callback: () => {
|
||||||
|
void openStatusPane();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// api.addRibbonIcon("waypoints", "P2P Replicator", () => {
|
||||||
|
// void openPane();
|
||||||
|
// })?.addClass?.("livesync-ribbon-replicate-p2p");
|
||||||
|
|
||||||
|
api.addRibbonIcon("waypoints", "P2P Server Status", () => {
|
||||||
|
void openStatusPane();
|
||||||
|
})?.addClass?.("livesync-ribbon-p2p-server-status");
|
||||||
|
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
host.services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||||
|
if (api.getPlatform() !== "obsidian") {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
if (api.showWindowOnRight) {
|
||||||
|
void api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
|
||||||
|
} else {
|
||||||
|
void api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS);
|
||||||
|
}
|
||||||
return Promise.resolve(true);
|
return 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
updates.md
51
updates.md
@@ -5,6 +5,34 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
15th May, 2026
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The issue which cannot synchronise in Only-P2P mode has been fixed.
|
||||||
|
|
||||||
|
### P2P Replication UI Improvements
|
||||||
|
- Brand-new P2P Server Status pane has been added to provide real-time visibility into your connection status and peer network.
|
||||||
|
- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click.
|
||||||
|
|
||||||
|
## 0.25.62
|
||||||
|
|
||||||
|
14th May, 2026
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
- P2P synchronisation has been made more robust
|
- P2P synchronisation has been made more robust
|
||||||
@@ -14,6 +42,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