Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions[bot] b281fe9fe4 Releasing 1.0.0-beta.3 2026-07-24 11:07:41 +00:00
vorotamoroz 6afeb0b409 Record beta.2 history and remove release-note links 2026-07-24 09:54:44 +00:00
vorotamoroz d489452583 Improve troubleshooting and compatible setting handling 2026-07-24 08:51:30 +00:00
vorotamoroz 058da1f34c Polish setup guide wording 2026-07-24 05:55:37 +00:00
vorotamoroz 32b72e4b10 Normalise Chinese setup guide line endings 2026-07-24 05:39:40 +00:00
vorotamoroz bdc44920ac Refresh setup guides and capture fixtures 2026-07-24 05:37:13 +00:00
vorotamoroz 24a4ebb8dd Use obsidian-test-session 0.2.5 2026-07-24 03:04:39 +00:00
vorotamoroz 68d22ade76 Use established terms in setup guidance 2026-07-23 17:38:16 +00:00
vorotamoroz bf0fc0aea8 Retire orphaned WebApp tests and classify regression guards 2026-07-23 17:21:16 +00:00
vorotamoroz 24b941f594 Group Hidden File Sync initialisation progress 2026-07-23 16:47:30 +00:00
vorotamoroz c8050322e4 Clarify remote setup and verify manual CouchDB onboarding 2026-07-23 16:46:50 +00:00
vorotamoroz 1df034f12a Refine P2P and manual setup workflows 2026-07-23 15:23:47 +00:00
115 changed files with 3968 additions and 2150 deletions
+5
View File
@@ -38,6 +38,11 @@ Before submitting a pull request, you must run verification scripts locally to e
```bash
npm run test:unit
```
- When changing the troubleshooting or recovery guides, inspect their current English UI labels and local references:
```bash
npm run inspect:troubleshooting
```
This read-only Inspector prints JSON containing `ok`, `checkedFiles`, `checkedLocalReferences`, and `errors`, and exits unsuccessfully when a contract is stale.
If you have the capability and a suitable environment (such as Linux and Docker), running the CLI End-to-End (E2E) tests is also highly appreciated. Instructions are detailed in [devs.md](devs.md). If you cannot run E2E tests locally, please explicitly ask to run the tests on the CI by stating 'Please run CI tests' in your pull request description.
+9 -12
View File
@@ -4,7 +4,7 @@
Self-hosted LiveSync is a community-developed synchronisation plug-in available on all Obsidian-compatible platforms. It leverages robust server solutions such as CouchDB or object storage systems (e.g., MinIO, S3, R2, etc.) to ensure reliable data synchronisation.
Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling you to synchronise your notes directly between devices without relying on a server. Documentation is available for [Peer-to-Peer Synchronisation](./docs/p2p_sync_updates_2026.md).
Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling devices to exchange notes without a central data-storage server. A signalling relay is still required for peer discovery. See [How peer-to-peer synchronisation works](./docs/p2p.md).
![obsidian_live_sync_demo](https://user-images.githubusercontent.com/45774780/137355323-f57a8b09-abf2-4501-836c-8cb7d2ff24a3.gif)
@@ -19,14 +19,10 @@ Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling yo
- Compatible solutions are supported.
- Support end-to-end encryption.
- Synchronise settings, snippets, themes, and plug-ins via [Customisation Sync (Beta)](docs/settings.md#6-customisation-sync-advanced) or [Hidden File Sync](docs/tips/hidden-file-sync.md).
- Enable WebRTC peer-to-peer synchronisation without requiring a `host` (Experimental).
- This feature is still in the experimental stage. Please exercise caution when using it.
- WebRTC is a peer-to-peer synchronisation method, so **at least one device must be online to synchronise**.
- Instead of keeping your device online as a stable peer, you can use two pseudo-peers:
- [livesync-serverpeer](https://github.com/vrtmrz/livesync-serverpeer): A pseudo-client running on the server for receiving and sending data between devices.
- [webpeer](https://github.com/vrtmrz/obsidian-livesync/tree/main/src/apps/webpeer): A pseudo-client for receiving and sending data between devices.
- A pre-built instance is available at [fancy-syncing.vrtmrz.net/webpeer](https://fancy-syncing.vrtmrz.net/webpeer/) (hosted on the vrtmrz's blog site). This is also peer-to-peer. Feel free to use it.
- For more information, refer to the [English explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync-en.html) or the [Japanese explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync).
- Enable supported, opt-in WebRTC peer-to-peer synchronisation.
- No central data-storage server is required, but a signalling relay is still required for peer discovery.
- At least one device containing the required data must be online while another device synchronises.
- Follow the [Peer-to-Peer Setup](docs/setup_p2p.md) after reviewing the [P2P communication model](docs/p2p.md).
This plug-in may be particularly useful for researchers, engineers, and developers who need to keep their notes fully self-hosted for security reasons. It is also suitable for anyone seeking the peace of mind that comes with knowing their notes remain entirely private.
@@ -59,14 +55,14 @@ Choose a synchronisation method, prepare its server where required, then follow
1. Prepare the server. A maintained MinIO server installation guide is not currently available here, so set up an S3-compatible service or server of your choice.
2. Configure the clients by following [Object Storage Setup](docs/setup_object_storage.md).
3. Peer-to-Peer
1. No server setup is required.
1. No central data-storage server is required. The project's public signalling relay requires no server provisioning; controlled deployments can provide another compatible relay.
2. Configure the clients by following [Peer-to-Peer Setup](docs/setup_p2p.md).
Each workflow establishes ordinary note synchronisation on the first device, generates the additional-device Setup URI from that working device, and verifies synchronisation in both directions.
Each workflow establishes ordinary note synchronisation on the first device, generates a Setup URI for each additional device from that working device, and verifies synchronisation in both directions.
> [!TIP]
> Fly.io is no longer free. Fortunately, we can still use IBM Cloudant despite some limitations. Refer to [Set up IBM Cloudant](docs/setup_cloudant.md).
> We can also use peer-to-peer synchronisation without a server. Alternatively, cheap object storage like Cloudflare R2 can be used for free.
> We can also use peer-to-peer synchronisation without a central data-storage server; a signalling relay is still used for peer discovery. Alternatively, cheap object storage like Cloudflare R2 can be used for free.
> However, most importantly, we can use a server that we trust. Therefore, please set up your own server.
> CouchDB can also be run on a Raspberry Pi (please be mindful of your server's security).
@@ -102,6 +98,7 @@ To prevent file and database corruption, please avoid closing Obsidian until all
## Tips and Troubleshooting
- If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md).
- Configure [Hidden File Sync](docs/tips/hidden-file-sync.md) only after ordinary note synchronisation works.
- If Obsidian or LiveSync cannot start normally, use [Recovery and flag files](docs/recovery.md) before changing or resetting a remote database.
- Self-hosted LiveSync 1.0 requires Obsidian 1.7.2 or later. If you need to use 1.0 on an earlier Obsidian version, please [open an issue](https://github.com/vrtmrz/obsidian-livesync/issues/new?template=issue-report.md) with your version, platform, and reason for remaining on it so that we can assess whether extending support is practical. The standard Community Plugins installer otherwise selects an older compatible plug-in release.
- If you are having problems getting the plug-in working, see [Tips and Troubleshooting](docs/troubleshooting.md).
+138
View File
@@ -0,0 +1,138 @@
import { access, readFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
type InspectionError = {
check: "current-label" | "local-reference" | "retired-label";
file: string;
detail: string;
};
export type TroubleshootingDocsInspection = {
ok: boolean;
checkedFiles: string[];
checkedLocalReferences: number;
errors: InspectionError[];
};
const guidePaths = ["docs/troubleshooting.md", "docs/recovery.md", "docs/tips/p2p-sync-tips.md"] as const;
const messageCataloguePath = "src/common/messagesJson/en.json";
const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu;
function repositoryRootFromThisFile(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
}
function normaliseReferenceTarget(rawTarget: string): string {
const withoutAngles = rawTarget.startsWith("<") && rawTarget.endsWith(">") ? rawTarget.slice(1, -1) : rawTarget;
return decodeURIComponent(withoutAngles);
}
function isExternalReference(target: string): boolean {
return /^(?:https?:|mailto:|obsidian:)/u.test(target);
}
async function inspectLocalReferences(
repositoryRoot: string,
documentPath: string,
document: string,
errors: InspectionError[]
): Promise<number> {
let checked = 0;
for (const match of document.matchAll(markdownLinkPattern)) {
const rawTarget = match[1];
if (!rawTarget) continue;
const target = normaliseReferenceTarget(rawTarget);
if (isExternalReference(target) || target.startsWith("#")) continue;
const [pathPart] = target.split("#", 1);
if (!pathPart) continue;
checked++;
const referencedPath = resolve(repositoryRoot, dirname(documentPath), pathPart);
try {
await access(referencedPath);
} catch {
errors.push({
check: "local-reference",
file: documentPath,
detail: `Missing local reference: ${relative(repositoryRoot, referencedPath)}`,
});
}
}
return checked;
}
export async function inspectTroubleshootingDocs(
repositoryRoot = repositoryRootFromThisFile()
): Promise<TroubleshootingDocsInspection> {
const errors: InspectionError[] = [];
const documents = new Map<string, string>();
for (const guidePath of guidePaths) {
documents.set(guidePath, await readFile(resolve(repositoryRoot, guidePath), "utf8"));
}
const troubleshooting = documents.get("docs/troubleshooting.md")!;
const catalogue = JSON.parse(await readFile(resolve(repositoryRoot, messageCataloguePath), "utf8")) as Record<
string,
string
>;
const requiredMessageKeys = [
"TweakMismatchResolve.Action.UseConfigured",
"TweakMismatchResolve.Action.UseMine",
"TweakMismatchResolve.Action.UseRemote",
"TweakMismatchResolve.Action.Dismiss",
"obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown",
] as const;
for (const messageKey of requiredMessageKeys) {
const label = catalogue[messageKey];
if (!label) {
errors.push({
check: "current-label",
file: messageCataloguePath,
detail: `The English message catalogue does not define ${messageKey}.`,
});
continue;
}
if (!troubleshooting.includes(label)) {
errors.push({
check: "current-label",
file: "docs/troubleshooting.md",
detail: `The guide does not include the current UI label '${label}'.`,
});
}
}
for (const retiredLabel of ["`Update with mine`", "`Use configured`", "`Sync settings via Markdown files`"]) {
if (troubleshooting.includes(retiredLabel)) {
errors.push({
check: "retired-label",
file: "docs/troubleshooting.md",
detail: `The guide still includes the retired label ${retiredLabel}.`,
});
}
}
let checkedLocalReferences = 0;
for (const [guidePath, document] of documents) {
checkedLocalReferences += await inspectLocalReferences(repositoryRoot, guidePath, document, errors);
}
return {
ok: errors.length === 0,
checkedFiles: [...guidePaths],
checkedLocalReferences,
errors,
};
}
async function runCli(): Promise<void> {
const result = await inspectTroubleshootingDocs();
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (!result.ok) process.exitCode = 1;
}
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined;
if (invokedPath === import.meta.url) {
await runCli();
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { inspectTroubleshootingDocs } from "./inspect-troubleshooting-docs";
describe("troubleshooting documentation contract", () => {
it("uses current English UI labels and resolves every local guide reference", async () => {
const result = await inspectTroubleshootingDocs();
expect(result.checkedFiles).toEqual([
"docs/troubleshooting.md",
"docs/recovery.md",
"docs/tips/p2p-sync-tips.md",
]);
expect(result.checkedLocalReferences).toBeGreaterThan(0);
expect(result.errors).toEqual([]);
expect(result.ok).toBe(true);
});
});
+4 -3
View File
@@ -60,13 +60,14 @@ To facilitate development and testing, the build process can automatically copy
### Testing Infrastructure
- ~~**Deno Tests**: Unit tests for platform-independent code (e.g., `HashManager.test.ts`)~~
- This is now obsolete, migrated to vitest.
- **Vitest**:
- **Unit Tests** (`vitest.config.unit.ts`): Unit tests run in Node.js (excluding harnesses and integration tests). Unit tests should be `*.unit.spec.ts` and placed alongside the implementation file (e.g., `ChunkFetcher.unit.spec.ts`). Executed via `npm run test:unit`.
- **Integration Tests** (`vitest.config.integration.ts`): Tests run in Node.js against a real CouchDB instance. Integration tests should be `*.integration.spec.ts` or `*.integration.test.ts` and placed alongside the implementation file (e.g., `StreamingFetch.integration.spec.ts`). Executed via `npm run test:integration`.
- If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you strongly expected to write an integration test to verify the behaviour against a real CouchDB server.
- If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you are strongly expected to write an integration test to verify the behaviour against a real CouchDB server.
- **Commonlib Tests**: Commonlib owns unit and package tests for shared RPC, storage, replication, and platform contracts. LiveSync CI verifies the exact packed dependency as a downstream consumer.
Regression tests remain beside the implementation which owns their contract. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate.
- **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO fixtures managed by the wrapper.
+1 -1
View File
@@ -200,7 +200,7 @@ Current implementation status:
Current implementation status:
- The mocked Vitest browser suites, their P2P runner, their root-level relay helpers, and the manual `harness-ci` workflow have been removed after maintained suites covered the critical flows.
- Headless CouchDB and Object Storage combinations, with and without encryption, remain owned by the CLI two-Vault matrix. P2P transport replacement and relay lifecycle remain owned by the CLI Compose E2E suite. Real Obsidian owns the visible CouchDB, Object Storage, and P2P Setup URI workflows, including first-device URI generation, second-device import, and two-way Vault synchronisation.
- Headless CouchDB and Object Storage combinations, with and without encryption, remain owned by the CLI two-Vault matrix. P2P transport replacement and relay lifecycle remain owned by the CLI Compose E2E suite. Real Obsidian owns the visible CouchDB, Object Storage, and P2P Setup URI workflows, including URI generation on the first device, import on the second device, and two-way Vault synchronisation.
- The Obsidian compatibility implementation still needed by the Webapp has moved to `src/apps/webapp/obsidianMock.ts`; it is not a retained browser E2E Harness.
- Remaining high-value scenarios, including RedFlag and Fast Setup (Simple Fetch) variants, should be added according to their owning integration boundary rather than copied line by line from the retired suite.
+2 -2
View File
@@ -46,7 +46,7 @@ Lifecycle operations on one `LiveSyncTrysteroReplicator` are serialised. A close
Relay sockets retain their Trystero-provided close handlers. LiveSync pauses relay reconnection, closes the sockets, and later resumes reconnection through Trystero's public functions. It does not replace `socket.onclose`, because Trystero uses that handler to retire and recreate shared relay clients correctly.
P2P setup follows the transport's actual ownership model. First-device initialisation resets and scans the local database, but does not attempt to lock, reset, or upload to a non-existent central remote database. Its confirmation dialogues therefore describe preparing this device and do not present the central-server overwrite warnings or remote-configuration option. An additional device performs one explicit peer-selection and finite Fetch pass, then resumes database and Vault reflection. The generic second convergence pass remains reserved for central remote types because repeating it for P2P would ask the user to select the same peer twice.
P2P setup follows the transport's actual ownership model. Initialising the first device resets and scans the local database, but does not attempt to lock, reset, or upload to a non-existent central remote database. Its confirmation dialogues therefore describe preparing this device and do not present warnings about overwriting a central server or an option to fetch its configuration. An additional device selects a peer once, performs Fetch once, then resumes database and Vault reflection. The generic second convergence pass remains reserved for central remote types because repeating it for P2P would ask the user to select the same peer twice.
## Ownership
@@ -74,7 +74,7 @@ This interferes with Trystero's shared relay clients. The public pause and resum
## Verification
Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, serialised open and close operations, local-only first-device initialisation, and one-pass additional-device Fetch.
Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, serialised open and close operations, initialisation of the first device without a central remote, and Fetch running once for an additional device.
Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication.
+98
View File
@@ -0,0 +1,98 @@
# How peer-to-peer synchronisation works
Peer-to-peer (P2P) synchronisation transfers Vault data between LiveSync devices through WebRTC. It does not require a central database containing a copy of the Vault. It does require a signalling relay so that devices can discover one another and establish a connection.
For the procedure for the first and additional devices, see [Set up peer-to-peer synchronisation](setup_p2p.md). For connection problems, see [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md).
## Connection model
```mermaid
flowchart LR
A["Device A"] <-->|"Discovery and connection signalling"| S["Signalling relay"]
S <-->|"Discovery and connection signalling"| B["Device B"]
A <-->|"Encrypted Vault synchronisation"| B
A -.->|"Fallback encrypted WebRTC traffic"| T["TURN server"]
T -.-> B
```
The signalling relay and TURN server have different roles:
- The **signalling relay** is required for peer discovery and connection negotiation. LiveSync uses Nostr-compatible WebSocket relays for this role. The relay does not store or transfer Vault contents.
- A **TURN server** is an optional fallback. WebRTC uses it to relay the encrypted peer connection only when the devices cannot establish a direct path through their networks.
## The project's public signalling relay
The project author operates a public signalling relay as a best-effort convenience. Selecting **Use the project's public signalling relay** means that no signalling server needs to be provisioned for an ordinary setup.
The public relay:
- is not a Vault storage service;
- may observe signalling metadata, such as connection timing and network addresses;
- has no availability or log-retention guarantee; and
- can be replaced with another compatible relay at any time by updating every device in the P2P group.
Use a signalling relay which is acceptable for your privacy and availability requirements. A controlled deployment may use its own Nostr-compatible relay.
## Signalling relay and TURN server
Both settings contain server addresses, but they are not interchangeable.
| Setting | Required | Carries Vault contents | Purpose |
| --- | --- | --- | --- |
| **Signalling relay URLs** | Yes | No | Finds peers and exchanges the information needed to establish WebRTC connections. |
| **TURN server URLs** | Only when direct WebRTC connectivity fails | Encrypted WebRTC traffic | Relays traffic between peers when NAT or firewall rules prevent a direct path. |
A TURN provider cannot read LiveSync's encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust. The project does not operate an official TURN service.
## P2P Status
The **P2P Status** pane is the current Obsidian interface for P2P connections.
- The command **Self-hosted LiveSync: P2P Sync : Open P2P Status** remains available from the command palette.
- The P2P ribbon icon appears only after a P2P configuration exists.
- LiveSync does not open the pane merely because Obsidian has started. If the pane was already part of the saved Obsidian workspace, Obsidian may restore it.
- Workspaces containing the retired P2P pane are migrated to the current status pane. The retired command is no longer exposed.
The active P2P remote is selected independently from the main CouchDB or Object Storage remote. Devices can therefore use P2P alongside their main remote without replacing it.
![P2P Status on desktop](../images/p2p-setup/p2p-status-pane.png)
![P2P Status in a mobile layout](../images/p2p-setup/p2p-status-pane-mobile.png)
**Open connection** joins the signalling room and makes the device available for discovery. **Disconnect** leaves the LiveSync room, stops its P2P replication service, and closes the signalling connections. It does not delete the saved P2P profile.
Every participating device must use the same signalling relay set, Group ID, and P2P passphrase. Each device should have a distinct device name. A peer which joins after another device is already connected is advertised to that device; use **Refresh**, or reconnect the device which should be discovered, if a peer is not yet listed.
## Manual and automatic data movement
**Replicate now** performs an explicit bidirectional synchronisation with the selected peer. This is the clearest option when proving a new configuration.
**Announce changes** and **Follow changes** provide a more continuous experience:
- The source device must enable **Announce changes** before it dispatches change notifications.
- A receiving device must enable **Follow changes** for that peer before it fetches in response to those notifications.
- A notification contains no Vault data. It only asks the following peer to fetch through the encrypted P2P connection.
- Missing a notification does not make an explicit later synchronisation unsafe; **Replicate now** still compares the available data.
The peer's **More actions** menu can save these choices for that device:
- **Synchronise when this device connects** runs one synchronisation when that named peer is discovered.
- **Follow whenever this device connects** restores following for that named peer.
- **Include in the P2P synchronisation command** includes that peer when the command for registered targets is run.
![Persistent actions for a detected peer](../images/p2p-setup/guide-p2p-setup-peer-actions-menu.png)
Configure these only after a manual round trip has succeeded. Device names used by persistent rules should remain unique and stable.
## Approval and privacy
A device must approve a peer before serving its data. Permanent approval is stored; session approval lasts only for the current Obsidian session. Check the displayed device name before approving a request.
The encrypted Setup URI contains the shared P2P configuration but deliberately omits the device-specific name. Store the Setup URI and its passphrase separately, and generate a Setup URI for another device from a first device which has completed setup.
## Operational limits
- At least one device which already has the required data must be online while another device fetches it.
- P2P does not provide the continuously available central copy offered by CouchDB or Object Storage. Keep independent backups.
- Mobile operating systems may pause Obsidian in the background. Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large synchronisation.
- Changing from CouchDB to P2P is not a repair operation for a stopped CouchDB setup. Diagnose the existing transport first.
+5 -60
View File
@@ -1,62 +1,7 @@
# User Guide: Peer-to-Peer Synchronisation (2026 Edition)
# Peer-to-peer synchronisation
Peer-to-Peer (P2P) synchronisation has evolved significantly. This guide covers the essential setup and the new features introduced in the 2026 updates.
This address is retained for links to an earlier P2P guide. The time-specific interface description has been replaced by stable documentation:
## 1. Core Concept: Server-less Freedom
P2P synchronisation allows your devices to talk directly to each other using WebRTC. A central server is not required for data storage, ensuring maximum privacy and "freedom."
## 2. Setting Up via P2P Status Pane
You no longer need to navigate through complex menus. Simply open the **P2P Status** (via the ribbon icon or command palette) and click the **⚙ (Cog)** icon.
This opens the **P2P Setup** dialogue where you can configure the essentials:
- **Room ID:** A unique identifier for your synchronisation group.
- **Passphrase:** Your encryption key. Ensure all your devices use the exact same passphrase.
- **Device Name:** A recognisable name for the current device (e.g., `iphone-16`).
Once you have saved the settings, return to the **P2P Status Pane** and click the **Connect** button to join the network.
*Tip: You can also toggle **Auto Connect** in the setup dialogue to automatically join the network whenever Obsidian starts.*
## 3. Real-time Control
The status pane in the right sidebar provides granular control over your synchronisation:
- **Active P2P Remote (new):** P2P now has its own active remote selection, separate from the normal active remote for database replication. Use the combo box next to the cog icon to choose which P2P remote configuration is active for P2P features.
- **Create P2P Remote (new):** Use the **+** button to open the P2P setup dialogue and create a dedicated P2P remote configuration. This is recommended when no P2P active remote has been selected yet.
- **Selection required (new):** If no P2P active remote is selected, the pane asks for selection before P2P target-related changes are saved.
- **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.
- **Replicate now (🔄):** Start immediate bidirectional replication with a visible peer (Pull, then Push).
- **Watch (🔔/🔕):** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience.
- **Sync target (🔗/⛓️‍💥):** Mark specific peers as **sync targets**. Peers marked here will be included when you run the **"P2P: Sync with targets"** command (see section 5). Click the button next to a peer to toggle it on (🔗, highlighted) or off (⛓️‍💥). This setting is persisted in your configuration.
## 4. Replication Dialogue
If you want to synchronise with a specific peer manually, use the **Replication** command or button. This opens the **Replication Dialogue** listing available devices.
Inside the dialogue, the **Server Status** card at the top confirms you are still connected while performing the sync.
The status card now shows a stable **Room ID suffix** above **Peer ID**. The Room ID suffix is better for identifying your P2P group, while Peer ID may change between connections.
Two actions are available per peer:
- **Sync** — Starts a bidirectional synchronisation (Pull then Push) and keeps the dialogue open so you can monitor progress or sync with additional peers.
- **Start Sync & Close** — Runs the same bidirectional synchronisation, waits for it to settle, then closes the dialogue. After a successful synchronisation, it also closes the signalling connection.
On supported mobile and desktop devices, LiveSync keeps the screen awake while this peer-selection dialogue is open and while its synchronisations finish. This is intentional: display sleep can interrupt peer discovery or connection establishment and require detection to start again. Wake Lock support remains best effort, does not keep a hidden application running in the background, and does not override operating-system sleep or suspension.
During a P2P rebuild, peer discovery and selection remain protected after the rebuild has started. Keep Obsidian visible until a peer is selected and the transfer finishes, because mobile platform restrictions can still pause or terminate a hidden application.
## 5. Syncing with Registered Targets via Command Palette
You can now trigger a synchronisation with all your pre-registered target peers in one step, without opening any UI.
1. Open the **Command Palette** (`Ctrl/Cmd + P`).
2. Run **"P2P: Sync with targets"**.
This command synchronises with every peer whose **SYNC** toggle is enabled in the **Detected Peers** list. If no targets are registered, or if the P2P server is not running, the command will notify you accordingly.
*Tip: Pair this command with a hotkey for a quick, keyboard-driven sync workflow.*
## 6. Technical Improvements in 2026
- **Decoupled Architecture:** The UI is now strictly separated from the core logic, making the plug-in 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.
- [Set up peer-to-peer synchronisation](setup_p2p.md) for configuring the first device, generating a Setup URI for another device, approving the connection, and verifying synchronisation in both directions.
- [How peer-to-peer synchronisation works](p2p.md) for signalling, TURN, privacy, the P2P Status pane, and automatic behaviour.
- [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md) for connection troubleshooting.
+41 -6
View File
@@ -50,13 +50,13 @@ Create an ordinary test note and allow it to upload before adding another device
## Create a Setup URI for another device
Generate the additional-device Setup URI from the working first device. This captures the settings which that device is actually using, rather than asking another device to reuse the bootstrap URI produced during server provisioning.
Generate a Setup URI for another device from the working first device. This captures the settings which that device is actually using, rather than asking another device to reuse the Setup URI produced during server provisioning.
1. Open the Obsidian command palette on the first device.
2. Run `Self-hosted LiveSync: Copy settings as a new Setup URI`.
3. Enter a new passphrase which will protect this Setup URI, then select `OK`.
![Masked passphrase for a new additional-device Setup URI](../images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png)
![Masked passphrase for a new Setup URI for another device](../images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png)
4. Copy the resulting Setup URI, then select `OK`.
@@ -75,7 +75,7 @@ Start with a new or separately backed-up Vault. Do not use a production Vault co
5. Paste the new Setup URI generated by the first device, enter its Setup URI passphrase, and select `Test Settings and Continue`.
6. Review `Setup Complete: Preparing to Fetch Synchronisation Data`, then select `Restart and Fetch Data`.
![Additional-device Fetch confirmation](../images/quick-setup/guide-quick-setup-second-fetch.png)
![Fetch confirmation on the additional device](../images/quick-setup/guide-quick-setup-second-fetch.png)
7. For a new or empty Vault, select `Overwrite all with remote files`. For a Vault with local work, stop and choose the appropriate strategy from the [Fast Setup guide](./tips/fast-setup.md).
@@ -83,7 +83,7 @@ Start with a new or separately backed-up Vault. Do not use a production Vault co
8. When asked how to handle extra local files, the conservative choice is `Keep local files even if not on remote`. Select the delete option only when the local Vault is disposable and an exact remote copy is intended.
![Additional-device local file policy](../images/quick-setup/guide-quick-setup-local-file-policy.png)
![Local file policy on the additional device](../images/quick-setup/guide-quick-setup-local-file-policy.png)
9. Allow retrieval, file reflection, and any requested restart to finish. Keep Obsidian open until the LiveSync progress indicators have cleared.
@@ -100,6 +100,41 @@ Add optional features separately so that their ownership and initialisation dire
Do not enable both features for the same files.
## Manual configuration
## Configure CouchDB manually on the first device
If a Setup URI is unavailable, choose `Enter the server information manually` during onboarding. Manual configuration is an advanced path: verify the connection, encryption, remote profile, and synchronisation preset before initialising either side. After the first device works, use `Copy settings as a new Setup URI` from the command palette to add later devices through the recommended path.
Use this path when CouchDB is ready but a Setup URI is unavailable. It configures one first device through the visible onboarding dialogue; it does not provision or repair the CouchDB server. Add later devices with a Setup URI generated by this working first device instead of entering the credentials again.
1. Install and enable Self-hosted LiveSync in the intended Vault.
2. Select the `Welcome to Self-hosted LiveSync` Notice, choose `I am setting this up for the first time`, then confirm that you want to set up a new synchronisation.
3. On `Connection Method`, select `Configure a remote manually`, then select `Proceed with manual configuration`.
![Manual remote configuration option during onboarding](../images/couchdb-manual/guide-couchdb-manual-connection-method.png)
4. On `End-to-End Encryption`, decide how the synchronised data will be protected.
- For an ordinary new Vault, enable `End-to-End Encryption` and enter a strong Vault encryption passphrase.
- Enable `Obfuscate Properties` if remote document properties should also be concealed.
- Store the Vault encryption passphrase securely. It is separate from the passphrase used to protect a Setup URI.
![CouchDB Vault encryption settings with the passphrase masked](../images/couchdb-manual/guide-couchdb-manual-encryption.png)
5. On `Choose a synchronisation remote`, select `CouchDB`, then select `Continue to CouchDB setup`.
![CouchDB option in the list of synchronisation remotes](../images/couchdb-manual/guide-couchdb-manual-remote-selection.png)
6. Enter the complete CouchDB URL, username, password, and database name.
- Obsidian Mobile requires HTTPS. Plain HTTP is suitable only for a trusted local connection from a desktop device.
- Use credentials which are allowed to connect to the selected database and, when configuring the first device, create it if it does not exist.
![Manual CouchDB connection fields with the password masked](../images/couchdb-manual/guide-couchdb-manual-connection-details.png)
7. `Check server requirements` is optional. It sends the displayed credentials to the configured server through Obsidian's internal request API, and some checks require CouchDB administrator access. The initial check is read-only. If it offers a server change, review and confirm that individual change separately.
![Successful optional CouchDB server requirements check](../images/couchdb-manual/guide-couchdb-manual-server-requirements.png)
8. Select `Create or connect to database and continue`. Onboarding requires this connection test to succeed.
9. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`.
10. Read the final overwrite warning. Select `I Understand, Overwrite Server` only when this device is intentionally the source of truth and a current backup exists.
11. A newly created database can show `Fetch Remote Configuration Failed` because it does not yet contain a saved preferred configuration. Select `Skip and proceed` only for this known new database.
12. Acknowledge `All optional features are disabled`, then keep Obsidian open until the initialisation progress has cleared.
Create and synchronise an ordinary test note. Once it has reached CouchDB, follow [Create a Setup URI for another device](#create-a-setup-uri-for-another-device), then [Add another device](#add-another-device). This keeps the second device aligned with the remote profile and encryption settings which the first device actually applied.
+98
View File
@@ -0,0 +1,98 @@
# Recovery and flag files
This guide covers emergency suspension, local database recovery, and deliberate remote reconstruction. These operations are not ordinary synchronisation.
> [!IMPORTANT]
> Back up every available Vault before recovery. If a central remote is involved, back up that database or bucket as well. Stop or suspend other LiveSync devices until you have chosen the authoritative copy.
If Obsidian will not start normally, do not give up. Flag files can be created or removed with the operating system's file manager while Obsidian is closed. They are the only supported way to intervene before the ordinary LiveSync boot-up sequence reaches its database and synchronisation work.
## First choose the authoritative copy
Use the least destructive operation which matches the evidence:
- If the correct data is uncertain, suspend all work with `redflag.md`, preserve every copy, and inspect them before proceeding.
- If the central remote is healthy and should win, use **Reset Synchronisation on This Device** or `flag_fetch.md`.
- If this device's Vault is healthy and should replace a damaged or unwanted central remote, use **Overwrite Server Data with This Device's Files** or `flag_rebuild.md`.
- If both the Vault and local database are healthy and the only concern is unused storage, Garbage Collection may be appropriate. It does not repair a damaged database.
Do not switch transport, enable P2P, or run Garbage Collection as a substitute for diagnosing a stopped CouchDB or Object Storage setup.
## Suspend before diagnosis
Close Obsidian completely, then create an empty file or directory named `redflag.md` at the root of the Vault. On the next start, LiveSync enters its emergency suspension state before ordinary database, file-watching, and synchronisation work continues.
While suspended:
1. Back up the Vault and any available remote data.
2. Check which device or remote contains the intended files.
3. Correct only the identified configuration or storage problem.
4. Remove `redflag.md`.
5. Start Obsidian and review the remaining suspension controls under `Hatch` -> `Scram Switches`.
The flag deliberately enables file logging, which may affect performance. Remove it after the emergency has been understood.
## Reset synchronisation on this device
Use this when the remote copy is trusted but this device's local LiveSync database is incomplete, corrupt, or no longer aligned with it.
The readable flag is `flag_fetch.md`; the legacy name `redflag3.md` remains accepted.
On the next start, LiveSync:
1. pauses ordinary start-up work;
2. asks which remote to use when more than one remote profile exists;
3. asks how to treat existing Vault files;
4. discards and reconstructs the local LiveSync database from the selected remote; and
5. resumes only after the scheduled operation has completed or been cancelled safely.
For P2P, a source peer must be online, discovered, and selected in `P2P Rebuild`. Merely opening an empty signalling room does not complete Fetch. Closing the rebuild dialogue without selecting a peer reports failure and does not treat the local database as restored.
Review the [Fast Setup guide](tips/fast-setup.md) before using this operation on a Vault which contains unsynchronised local work.
## Overwrite server data with this device's files
Use this only when this device's Vault is the authoritative copy and the central remote should be reconstructed from it.
The readable flag is `flag_rebuild.md`; the legacy name `redflag2.md` remains accepted.
For CouchDB and Object Storage, this is destructive to the selected remote state. Other devices may still contain revisions or files which are not present in the authoritative Vault, so keep them stopped until the new remote has been verified and then reset them from that remote.
For a P2P-only setup, there is no central remote database to overwrite. Preparing the first device instead rebuilds its local LiveSync database from its Vault.
## Garbage Collection is not Rebuild
Garbage Collection removes unreferenced chunks while preserving the current database and its revision model. Use it only when:
- the Vault is healthy;
- the local LiveSync database is healthy;
- all relevant devices have synchronised; and
- the remaining historical and deletion state is understood.
Deleted documents and tombstones are not free, and historical revisions may keep chunks reachable. Garbage Collection therefore cannot promise the smallest possible remote.
Rebuild is a different operation. It reconstructs the database from a chosen authoritative state and is the more certain way to remove unwanted history or repair a damaged remote, but it is also more disruptive and can discard changes which exist only elsewhere.
## Flag-file reference
Create only the flag required for the chosen operation.
| File at the Vault root | Effect |
| --- | --- |
| `redflag.md` | Suspend ordinary LiveSync work for diagnosis. It remains until removed manually. |
| `flag_fetch.md` or `redflag3.md` | Schedule **Reset Synchronisation on This Device** from the selected remote. |
| `flag_rebuild.md` or `redflag2.md` | Schedule **Overwrite Server Data with This Device's Files**, or local P2P preparation when no central remote exists. |
Flag files themselves are excluded from synchronisation. Fetch and rebuild flags are removed by the scheduled workflow after completion or cancellation; `redflag.md` is a manual emergency stop.
## When the warning continues
If LiveSync still reports emergency suspension after a recovery dialogue has closed:
1. close Obsidian completely;
2. inspect the Vault root for every name in the table above;
3. remove only flags whose intended operation has finished or been abandoned;
4. restart Obsidian; and
5. check `Hatch` -> `Scram Switches` for remaining suspended file watching or database reflection.
If the intended authoritative copy is still uncertain, leave synchronisation suspended and collect a [full report](troubleshooting.md#collect-a-report) before changing the databases again.
+25 -13
View File
@@ -314,7 +314,7 @@ These settings are configured within the CouchDB Setup dialogue when adding (`
Setting key: couchDB_URI
The URI of the CouchDB server.
Note: Only Secure (HTTPS) connections can be used on Obsidian Mobile. The URI must not end with a trailing slash.
Only secure HTTPS connections can be used on Obsidian Mobile. The setup dialogue accepts a complete HTTP or HTTPS URL and normalises it when the settings are applied.
#### Username
@@ -332,14 +332,13 @@ The password used to authenticate with CouchDB.
Setting key: couchDB_DBNAME
The name of the database.
Note: The database name cannot contain capital letters, spaces, or special characters other than `_$()+/-`, and cannot start with an underscore (`_`).
The name of the database. It must not be empty. CouchDB validates the name when the connection is attempted; the setup dialogue does not apply a narrower client-side naming rule.
#### Use Request API to avoid inevitable CORS problem
Setting key: useRequestAPI
This option is labeled **Use Internal API** in the setup dialogue. If enabled, Obsidian's internal request API will be used to bypass CORS restrictions. This is a workaround that may not be compliant with web standards and is less secure. Note that this might break in future Obsidian versions.
This option is labelled **Use Internal API** in the setup dialogue. If enabled, Obsidian's internal request API is used to bypass CORS restrictions. It sends the configured credentials to the CouchDB server through an Obsidian-owned API, so use it only with a server you trust. Configure CouchDB CORS correctly where possible; this compatibility workaround may change in future Obsidian versions.
#### Custom Headers
@@ -383,13 +382,20 @@ Setting key: jwtSub
The subject (`sub`) claim of the JWT, which should match your CouchDB username.
#### Test Database Connection
#### Connection and save actions
Open database connection. If the remote database is not found and you have permission to create a database, the database will be created.
The action depends on why the dialogue was opened:
#### Validate Database Configuration
- Onboarding for the first device uses **Create or connect to database and continue**. It may create the database when it does not exist and the supplied account has permission.
- Onboarding for an additional device uses **Connect to existing database and continue**. It does not create a missing database.
- Adding or editing a saved remote profile uses **Test connection and save**. It does not create a missing database.
- Settings mode also offers **Save without connecting**. The existing profile is updated, but automatic synchronisation may fail until the connection is corrected.
Checks and fixes any potential issues with the database config.
Onboarding requires a successful connection. It does not expose an unverified continuation action.
#### Check server requirements
This optional check reads the CouchDB server configuration through Obsidian's internal request API and sends the configured credentials to that server. Administrator access may be required. The initial check is read-only. Each offered fix names the exact CouchDB setting and proposed value, and requires separate confirmation before making that change.
#### Apply Settings
@@ -401,11 +407,11 @@ Setting key: P2P_Enabled
Enable direct peer-to-peer synchronisation via WebRTC.
#### Relay URL
#### Signalling relay URLs
Setting key: P2P_relays
The WebSocket relay server URL(s) used for coordinating P2P connections via WebRTC. Multiple URLs can be separated by commas.
The Nostr-compatible WebSocket relay URL or URLs used for peer discovery and WebRTC connection negotiation. Multiple URLs can be separated by commas. A signalling relay does not store or transfer Vault contents. See [How peer-to-peer synchronisation works](p2p.md).
#### Group ID
@@ -435,17 +441,17 @@ This option is labeled **Auto Start P2P Connection** in the setup dialogue. If e
Closing a P2P connection leaves the LiveSync P2P room, stops its replication service, closes the signalling relay sockets, and pauses their automatic reconnection. An idle WebRTC connection may remain temporarily under the transport's ownership so that it can be reused, but it cannot carry traffic for the room which has been left. Connecting again resumes relay reconnection and joins a new LiveSync room.
#### Automatically broadcast changes to connected peers
#### Announce changes automatically after connecting
Setting key: P2P_AutoBroadcast
This option is labeled **Auto Broadcast Changes** in the setup dialogue. If enabled, changes will be automatically broadcasted to connected peers, requesting them to fetch the changes.
When enabled, this device notifies connected peers after a local change. The notification contains no Vault data. A receiving peer fetches the change only when it follows this device.
#### TURN Server URLs (comma-separated)
Setting key: P2P_turnServers
A comma-separated list of TURN/STUN server URLs. Used to relay P2P connections when direct WebRTC connection fails due to strict NAT or firewalls. In most cases, these can be left blank.
A comma-separated list of TURN server URLs. TURN is an optional fallback which relays encrypted WebRTC traffic when strict NAT or firewall rules prevent a direct peer connection. It is distinct from the required signalling relay. In most environments, this field can remain blank.
#### TURN Username
@@ -953,6 +959,12 @@ If disabled(toggled), chunks will be split on the UI thread (Previous behaviour)
Setting key: processSmallFilesInUIThread
If enabled, the file under 1kb will be processed in the UI thread.
#### Automatically align compatible chunk settings
Setting key: autoAcceptCompatibleTweak
Current releases enable this by default when the differences are limited to compatible chunk settings. The side with the newer recorded modification time is used for the chunk hash algorithm, chunk size, or splitter version; the remote value is used when neither side has a recorded time or the times are equal. No dialogue or database reconstruction is required. Existing content remains readable, but changing these values can reduce chunk reuse. Turn this off to review compatible differences manually. Any difference which also involves an incompatible setting always requires an explicit decision.
### 8. Compatibility (Trouble addressed)
#### Do not check configuration mismatch before replication
+8 -8
View File
@@ -1,6 +1,6 @@
# Set up Object Storage
This guide establishes Object Storage synchronisation on a first device, generates an additional-device Setup URI from that working device, and verifies synchronisation in both directions.
This guide establishes Object Storage synchronisation on a first device, generates a Setup URI for another device from that working device, and verifies synchronisation in both directions.
Object Storage uses the S3-compatible API. Prepare the following before starting:
@@ -12,7 +12,7 @@ Object Storage uses the S3-compatible API. Prepare the following before starting
Back up every Vault involved, and do not use Obsidian Sync, iCloud synchronisation, or another synchronisation service on the same Vault.
## Generate the bootstrap Setup URI
## Generate the initial Setup URI
The public generator applies the Object Storage preset and records the connection as the selected remote profile. Run it from a trusted terminal:
@@ -40,13 +40,13 @@ Use a new bucket prefix, or a prefix whose contents you deliberately intend to r
1. Install and enable Self-hosted LiveSync in the intended Vault.
2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice.
3. Select `I am setting this up for the first time`, then choose the recommended Setup URI method.
4. Paste the bootstrap Setup URI, enter its passphrase, and select `Test Settings and Continue`.
4. Paste the initial Setup URI, enter its passphrase, and select `Test Settings and Continue`.
![Object Storage Setup URI on the first device](../images/object-storage-setup/guide-object-storage-setup-first-setup-uri.png)
5. Select `Restart and Initialise Server`, then read and accept the final overwrite confirmation only when this Vault is the intended source of truth.
![Object Storage first-device initialisation](../images/object-storage-setup/guide-object-storage-setup-first-initialise.png)
![Object Storage initialisation on the first device](../images/object-storage-setup/guide-object-storage-setup-first-initialise.png)
![Final Object Storage overwrite confirmation](../images/object-storage-setup/guide-object-storage-setup-first-rebuild-confirmation.png)
@@ -64,7 +64,7 @@ Generate a fresh Setup URI from the working first device:
1. Run `Self-hosted LiveSync: Copy settings as a new Setup URI` from the command palette.
2. Enter a new Setup URI passphrase.
![Masked passphrase for the additional-device Object Storage Setup URI](../images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png)
![Masked passphrase for the Object Storage Setup URI for another device](../images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png)
3. Copy the resulting URI.
@@ -80,7 +80,7 @@ Start with a new or separately backed-up Vault.
2. Open onboarding, select `I am adding a device to an existing synchronisation setup`, and choose the recommended Setup URI method.
3. Enter the URI generated by the first device and its passphrase.
![First-device Object Storage Setup URI entered on the second device](../images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png)
![Object Storage Setup URI from the first device entered on the second device](../images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png)
4. Select `Restart and Fetch Data`.
@@ -96,7 +96,7 @@ Start with a new or separately backed-up Vault.
Confirm that the first device's test note appears unchanged. Create a second ordinary note on the new device, wait for its journal synchronisation to finish, and confirm that it reaches the first device. Configure optional features only after this two-way check passes.
![First-device note received by the second Object Storage device](../images/object-storage-setup/guide-object-storage-setup-first-to-second.png)
![Note from the first device received by the second Object Storage device](../images/object-storage-setup/guide-object-storage-setup-first-to-second.png)
![Second-device note received by the first Object Storage device](../images/object-storage-setup/guide-object-storage-setup-second-to-first.png)
@@ -104,5 +104,5 @@ Confirm that the first device's test note appears unchanged. Create a second ord
- Treat the endpoint, bucket, prefix, access key, secret key, Vault passphrase, Setup URI, and Setup URI passphrase as sensitive.
- Use a distinct prefix per synchronisation set unless shared data is explicitly intended.
- Do not select first-device initialisation against an existing prefix unless replacing its contents is deliberate.
- Do not initialise the first device against an existing prefix unless replacing its contents is deliberate.
- Object Storage is not a Vault backup. Keep independent backups and test restoration separately.
+3 -3
View File
@@ -167,7 +167,7 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv
## 4. Client Setup
> [!TIP]
> Now manual configuration is not recommended for some reasons. However, if you want to do so, please use `Setup wizard`. The recommended extra configurations will be also set.
> A generated Setup URI is the recommended path because it carries the current defaults for a new Vault and the selected remote profile. If a Setup URI cannot be generated, follow [Configure CouchDB manually on the first device](./quick_setup.md#configure-couchdb-manually-on-the-first-device), then generate a new Setup URI from that working device for every additional device.
### 1. Generate the setup URI on a desktop device or server
```bash
@@ -185,7 +185,7 @@ deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.co
>
> If `uri_passphrase` is omitted, the generator creates a cryptographically random value and prints it once.
The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current new-Vault defaults, and encodes them with Commonlib's Setup URI contract.
The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current defaults for a new Vault, and encodes them with Commonlib's Setup URI contract.
You will then get the following output:
@@ -202,7 +202,7 @@ Store the Setup URI and its passphrase separately.
Follow [Quick setup](./quick_setup.md#set-up-the-first-device) for the first device. It covers the current onboarding Notice, Setup URI import, server initialisation, and the safety prompts shown for a newly provisioned database.
After ordinary note synchronisation works, [generate a new Setup URI on that first device](./quick_setup.md#create-a-setup-uri-for-another-device), then follow [Add another device](./quick_setup.md#add-another-device). Do not make the second device depend on retaining the provisioning-time bootstrap URI. Configure optional features only after the normal path is verified; [Hidden File Sync has its own guide](./tips/hidden-file-sync.md).
After ordinary note synchronisation works, [generate a new Setup URI on that first device](./quick_setup.md#create-a-setup-uri-for-another-device), then follow [Add another device](./quick_setup.md#add-another-device). Do not make the second device depend on retaining the initial Setup URI produced during provisioning. Configure optional features only after the normal path is verified; [Hidden File Sync has its own guide](./tips/hidden-file-sync.md).
---
+152 -152
View File
@@ -1,152 +1,152 @@
# 在你自己的服务器上设置 CouchDB
## 目录
- [配置 CouchDB](#配置-CouchDB)
- [运行 CouchDB](#运行-CouchDB)
- [Docker CLI](#docker-cli)
- [Docker Compose](#docker-compose)
- [创建数据库](#创建数据库)
- [从移动设备访问](#从移动设备访问)
- [移动设备测试](#移动设备测试)
- [设置你的域名](#设置你的域名)
---
> 注:提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。推荐直接使用该 docker-compose 配置进行搭建。(若使用,请查阅链接中的文档,而不是这个文档)
## 配置 CouchDB
设置 CouchDB 的最简单方法是使用 [CouchDB docker image]((https://hub.docker.com/_/couchdb)).
需要修改一些 `local.ini` 中的配置,以让它可以用于 Self-hosted LiveSync,如下:
```
[couchdb]
single_node=true
max_document_size = 50000000
[chttpd]
require_valid_user = true
max_http_request_size = 4294967296
[chttpd_auth]
require_valid_user = true
authentication_redirect = /_utils/session.html
[httpd]
WWW-Authenticate = Basic realm="couchdb"
enable_cors = true
[cors]
origins = app://obsidian.md,capacitor://localhost,http://localhost
credentials = true
headers = accept, authorization, content-type, origin, referer
methods = GET, PUT, POST, HEAD, DELETE
max_age = 3600
```
## 运行 CouchDB
### Docker CLI
你可以通过指定 `local.ini` 配置运行 CouchDB:
```
$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
```
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
后台运行:
```
$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
```
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
### Docker Compose
创建一个文件夹, 将你的 `local.ini` 放在文件夹内, 然后在文件夹内创建 `docker-compose.yml`. 请确保对 `local.ini` 有读写权限并且确保在容器运行后能创建 `data` 文件夹. 文件夹结构大概如下:
```
obsidian-livesync
├── docker-compose.yml
└── local.ini
```
可以参照以下内容编辑 `docker-compose.yml`:
```yaml
services:
couchdb:
image: couchdb
container_name: obsidian-livesync
user: 1000:1000
environment:
- COUCHDB_USER=admin
- COUCHDB_PASSWORD=password
volumes:
- ./data:/opt/couchdb/data
- ./local.ini:/opt/couchdb/etc/local.ini
ports:
- 5984:5984
restart: unless-stopped
```
最后, 创建并启动容器:
```
# -d will launch detached so the container runs in background
docker-compose up -d
```
## 创建数据库
CouchDB 部署成功后, 需要手动创建一个数据库, 方便插件连接并同步.
1. 访问 `http://localhost:5984/_utils`, 输入帐号密码后进入管理页面
2. 点击 Create Database, 然后根据个人喜好创建数据库
## 从移动设备访问
如果你想要从移动设备访问 Self-hosted LiveSync,你需要一个合法的 SSL 证书。
### 移动设备测试
测试时,[localhost.run](http://localhost.run/) 这一类的反向隧道服务很实用。(非必须,只是用于终端设备不方便 ssh 的时候的备选方案)
```
$ ssh -R 80:localhost:5984 nokey@localhost.run
Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts.
===============================================================================
Welcome to localhost.run!
Follow your favourite reverse tunnel at [https://twitter.com/localhost_run].
**You need a SSH key to access this service.**
If you get a permission denied follow Gitlab's most excellent howto:
https://docs.gitlab.com/ee/ssh/
*Only rsa and ed25519 keys are supported*
To set up and manage custom domains go to https://admin.localhost.run/
More details on custom domains (and how to enable subdomains of your custom
domain) at https://localhost.run/docs/custom-domains
To explore using localhost.run visit the documentation site:
https://localhost.run/docs/
===============================================================================
** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. **
xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run
Connection to localhost.run closed by remote host.
Connection to localhost.run closed.
```
https://xxxxxxxx.localhost.run 即为临时服务器地址。
### 设置你的域名
设置一个指向你服务器的 A 记录,并根据需要设置反向代理。
Note: 不推荐将 CouchDB 挂载到根目录
可以使用 Caddy 很方便的给服务器加上 SSL 功能
提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。
注意检查服务器日志,当心恶意访问。
# 在你自己的服务器上设置 CouchDB
## 目录
- [配置 CouchDB](#配置-CouchDB)
- [运行 CouchDB](#运行-CouchDB)
- [Docker CLI](#docker-cli)
- [Docker Compose](#docker-compose)
- [创建数据库](#创建数据库)
- [从移动设备访问](#从移动设备访问)
- [移动设备测试](#移动设备测试)
- [设置你的域名](#设置你的域名)
---
> 注:提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。推荐直接使用该 docker-compose 配置进行搭建。(若使用,请查阅链接中的文档,而不是这个文档)
## 配置 CouchDB
设置 CouchDB 的最简单方法是使用 [CouchDB docker image]((https://hub.docker.com/_/couchdb)).
需要修改一些 `local.ini` 中的配置,以让它可以用于 Self-hosted LiveSync,如下:
```
[couchdb]
single_node=true
max_document_size = 50000000
[chttpd]
require_valid_user = true
max_http_request_size = 4294967296
[chttpd_auth]
require_valid_user = true
authentication_redirect = /_utils/session.html
[httpd]
WWW-Authenticate = Basic realm="couchdb"
enable_cors = true
[cors]
origins = app://obsidian.md,capacitor://localhost,http://localhost
credentials = true
headers = accept, authorization, content-type, origin, referer
methods = GET, PUT, POST, HEAD, DELETE
max_age = 3600
```
## 运行 CouchDB
### Docker CLI
你可以通过指定 `local.ini` 配置运行 CouchDB:
```
$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
```
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
后台运行:
```
$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
```
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
### Docker Compose
创建一个文件夹, 将你的 `local.ini` 放在文件夹内, 然后在文件夹内创建 `docker-compose.yml`. 请确保对 `local.ini` 有读写权限并且确保在容器运行后能创建 `data` 文件夹. 文件夹结构大概如下:
```
obsidian-livesync
├── docker-compose.yml
└── local.ini
```
可以参照以下内容编辑 `docker-compose.yml`:
```yaml
services:
couchdb:
image: couchdb
container_name: obsidian-livesync
user: 1000:1000
environment:
- COUCHDB_USER=admin
- COUCHDB_PASSWORD=password
volumes:
- ./data:/opt/couchdb/data
- ./local.ini:/opt/couchdb/etc/local.ini
ports:
- 5984:5984
restart: unless-stopped
```
最后, 创建并启动容器:
```
# -d will launch detached so the container runs in background
docker-compose up -d
```
## 创建数据库
CouchDB 部署成功后, 需要手动创建一个数据库, 方便插件连接并同步.
1. 访问 `http://localhost:5984/_utils`, 输入帐号密码后进入管理页面
2. 点击 Create Database, 然后根据个人喜好创建数据库
## 从移动设备访问
如果你想要从移动设备访问 Self-hosted LiveSync,你需要一个合法的 SSL 证书。
### 移动设备测试
测试时,[localhost.run](http://localhost.run/) 这一类的反向隧道服务很实用。(非必须,只是用于终端设备不方便 ssh 的时候的备选方案)
```
$ ssh -R 80:localhost:5984 nokey@localhost.run
Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts.
===============================================================================
Welcome to localhost.run!
Follow your favourite reverse tunnel at [https://twitter.com/localhost_run].
**You need a SSH key to access this service.**
If you get a permission denied follow Gitlab's most excellent howto:
https://docs.gitlab.com/ee/ssh/
*Only rsa and ed25519 keys are supported*
To set up and manage custom domains go to https://admin.localhost.run/
More details on custom domains (and how to enable subdomains of your custom
domain) at https://localhost.run/docs/custom-domains
To explore using localhost.run visit the documentation site:
https://localhost.run/docs/
===============================================================================
** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. **
xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run
Connection to localhost.run closed by remote host.
Connection to localhost.run closed.
```
https://xxxxxxxx.localhost.run 即为临时服务器地址。
### 设置你的域名
设置一个指向你服务器的 A 记录,并根据需要设置反向代理。
Note: 不推荐将 CouchDB 挂载到根目录
可以使用 Caddy 很方便的给服务器加上 SSL 功能
提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。
注意检查服务器日志,当心恶意访问。
+55 -38
View File
@@ -1,54 +1,44 @@
# Set up peer-to-peer synchronisation
This guide establishes a peer-to-peer synchronisation set, generates the second-device Setup URI on the working first device, and verifies synchronisation in both directions with explicit peer approval.
This guide configures a working first device through the ordinary user interface, generates a Setup URI for an additional device, and verifies synchronisation in both directions with explicit peer approval.
Peer-to-peer synchronisation has no central copy of the Vault. At least one device containing the required data must be online when another device fetches it. A Nostr-compatible signalling relay helps devices discover each other, while the Vault data travels through the peer connection.
Peer-to-peer synchronisation has no central data-storage server containing a copy of the Vault. A signalling relay is still required for peer discovery. The project's public signalling relay avoids the need to provision one for an ordinary setup; a controlled setup can use another Nostr-compatible relay. Vault data travels through the encrypted peer connection, not through the signalling relay.
See [How peer-to-peer synchronisation works](p2p.md) for the communication model, the public relay policy, and the distinction between signalling and TURN.
Before starting:
- back up both Vaults;
- prepare a signalling relay reachable by both devices;
- decide whether to use the project's public signalling relay or another relay reachable by both devices;
- ensure the networks permit a WebRTC connection, or review the [P2P troubleshooting guidance](./tips/p2p-sync-tips.md);
- disable every other synchronisation service for these Vaults; and
- keep both devices awake and Obsidian open during the initial transfer.
## Generate the bootstrap Setup URI
Run the public generator from a trusted terminal. Supply your own relay for a controlled self-hosted setup:
```sh
export remote_type=p2p
export p2p_relays=wss://relay.example.com
export p2p_room_id=<A PRIVATE ROOM ID> # Optional; generated when omitted
export p2p_passphrase=<A PRIVATE P2P PASSPHRASE> # Optional; generated when omitted
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
```
The generated Setup URI contains the encrypted room, relay, and Vault settings. It deliberately omits the device-specific peer name. Store the URI and its passphrase separately.
## Set up the first device
1. Install and enable Self-hosted LiveSync in the intended Vault.
2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice.
3. Select `I am setting this up for the first time`, then choose the recommended Setup URI method.
4. Paste the bootstrap Setup URI, enter its passphrase, and select `Test Settings and Continue`.
![P2P Setup URI on the first device](../images/p2p-setup/guide-p2p-setup-first-setup-uri.png)
5. Complete the first-device initialisation and final confirmation. This initialises the local LiveSync database; P2P has no central remote database to erase.
3. Select `I am setting this up for the first time`, choose manual configuration, then select `Peer-to-Peer only`.
4. In `P2P Configuration`:
- enable P2P;
- select `Use the project's public signalling relay`, or enter your own signalling relay URLs;
- generate or enter a private Group ID;
- enter a strong P2P passphrase;
- enter a unique name for this device; and
- leave automatic start and automatic announcements disabled until the manual round trip succeeds.
5. Select `Test Settings and Continue`. The test joins the signalling relay; it does not require another peer to be online.
6. Complete the initialisation and final confirmation on the first device. This initialises the local LiveSync database; P2P has no central remote database to erase.
![P2P local initialisation on the first device](../images/p2p-setup/guide-p2p-setup-first-initialise.png)
![P2P local database confirmation on the first device](../images/p2p-setup/guide-p2p-setup-first-rebuild-confirmation.png)
6. Keep optional features disabled until ordinary note synchronisation works.
7. Open `Self-hosted LiveSync: Open P2P Replicator` from the command palette. Select `Open connection` if signalling is disconnected.
7. Keep optional features disabled until ordinary note synchronisation works.
8. Open `Self-hosted LiveSync: P2P Sync : Open P2P Status` from the command palette. After a P2P profile exists, the P2P ribbon icon provides the same destination. Select `Open connection` if signalling is disconnected.
![First P2P device connected to the signalling relay](../images/p2p-setup/guide-p2p-setup-first-device-connected.png)
8. Create an ordinary test note and wait for the local LiveSync progress indicators to clear.
9. Create an ordinary test note and wait for the local LiveSync progress indicators to clear.
## Generate the second-device Setup URI
@@ -57,7 +47,7 @@ On the working first device:
1. Run `Self-hosted LiveSync: Copy settings as a new Setup URI` from the command palette.
2. Enter a new Setup URI passphrase.
![Masked passphrase for the additional-device P2P Setup URI](../images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png)
![Masked passphrase for the P2P Setup URI for another device](../images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png)
3. Copy the resulting URI.
@@ -71,7 +61,7 @@ Keep the first device online. Store the new URI and its passphrase separately.
2. Open onboarding, select `I am adding a device to an existing synchronisation setup`, and choose the recommended Setup URI method.
3. Enter the Setup URI generated by the first device and its passphrase.
![First-device P2P Setup URI entered on the second device](../images/p2p-setup/guide-p2p-setup-second-setup-uri.png)
![P2P Setup URI from the first device entered on the second device](../images/p2p-setup/guide-p2p-setup-second-setup-uri.png)
4. Select `Restart and Fetch Data`.
@@ -83,7 +73,7 @@ Keep the first device online. Store the new URI and its passphrase separately.
![P2P local-file policy](../images/p2p-setup/guide-p2p-setup-local-file-policy.png)
6. In `P2P Rebuild`, confirm that the expected first-device name is shown, then select `Sync`.
6. In `P2P Rebuild`, confirm that the expected name of the first device is shown, then select `Sync`.
![Selecting the first device for P2P Rebuild](../images/p2p-setup/guide-p2p-setup-select-first-device.png)
@@ -93,16 +83,16 @@ Keep the first device online. Store the new URI and its passphrase separately.
8. Keep both devices open until the test note appears on the second device.
![First-device note received by the second P2P device](../images/p2p-setup/guide-p2p-setup-first-to-second.png)
![Note from the first device received by the second P2P device](../images/p2p-setup/guide-p2p-setup-first-to-second.png)
## Verify the return journey
Create a second ordinary note on the second device. With automatic broadcast disabled, start the next finite synchronisation explicitly:
Create a second ordinary note on the second device. Keep automatic announcements disabled, then run and verify the next synchronisation explicitly:
1. Open the P2P Replicator pane on both devices.
2. If the previous finite peer connection no longer appears, select `Disconnect` and then `Open connection` on the first device, followed by the second device. The device which joins last is advertised to devices already in the room.
1. Open `P2P Status` on both devices.
2. If a peer no longer appears, select `Disconnect` and then `Open connection` on the first device, followed by the second device. The device which joins last is advertised to devices which are already in the room.
3. On the first device, select `Refresh`, verify the second-device name, then select `Replicate now`.
4. On the second device, verify the requesting first-device name and select `Accept` or `Accept Temporarily`.
4. On the second device, verify the name of the requesting first device and select `Accept` or `Accept Temporarily`.
![Explicit return-journey connection request on the second device](../images/p2p-setup/guide-p2p-setup-connection-request-2.png)
@@ -110,7 +100,15 @@ Create a second ordinary note on the second device. With automatic broadcast dis
![Second-device note received by the first P2P device](../images/p2p-setup/guide-p2p-setup-second-to-first.png)
The two devices are now proven to share the same room, encryption settings, and data format in both directions. Configure automatic start, automatic broadcast, or optional features separately after this manual path works.
The two devices are now proven to share the same room, encryption settings, and data format in both directions.
After this manual path works, configure automatic behaviour deliberately:
- `Announce changes` on a source device dispatches change notifications while it is connected.
- `Follow changes` on the receiving device fetches after notifications from that peer.
- The peer's `More actions` menu can synchronise or follow whenever that named device connects, or include it in the P2P synchronisation command.
An announcement contains no Vault data and does not transfer a change by itself. The source must announce, the receiver must follow, and both devices must be connected.
## If a peer does not appear
@@ -118,4 +116,23 @@ The two devices are now proven to share the same room, encryption settings, and
- Select `Refresh` after the other device joins.
- Reconnect the device which should be discovered last.
- Check that the Setup URI came from the working first device and that neither device copied a peer name manually.
- Check relay reachability, WebRTC restrictions, VPNs, and TURN considerations in [Peer-to-Peer Synchronisation Tips](./tips/p2p-sync-tips.md).
- Check signalling relay reachability separately from WebRTC connectivity.
- Review VPN and TURN options in [Peer-to-Peer Synchronisation Tips](./tips/p2p-sync-tips.md).
## Controlled or self-hosted setup
The ordinary route above starts in the plug-in UI and can use the project's public signalling relay. For a controlled deployment, prepare your own Nostr-compatible relay and enter it in `Signalling relay URLs` on every device.
The public Setup URI generator is also available when configuration must be created outside Obsidian. Run it from a trusted terminal:
```sh
export remote_type=p2p
export p2p_relays=wss://relay.example.com
export p2p_room_id=<A PRIVATE ROOM ID> # Optional; generated when omitted
export p2p_passphrase=<A PRIVATE P2P PASSPHRASE> # Optional; generated when omitted
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
```
The generated Setup URI contains the encrypted room, relay, and Vault settings. It deliberately omits the device-specific name. Store the URI and its passphrase separately. After importing it on the first device, continue from the initialisation step above, then generate a fresh Setup URI for an additional device from that working device.
+3 -3
View File
@@ -74,8 +74,8 @@ All guidelines and conventions listed below are disclosed and maintained solely
- A privacy option that encrypts file paths and folder names on the remote server.
- plug-in
- We use the hyphenated form `plug-in` in user-facing messages and general documentation, while `plugin` may appear in codebase files, configuration settings, or technical contexts.
- Relay Server (P2P relays)
- A WebSocket-based coordination server used to establish direct WebRTC peer-to-peer connections. The default relay is provided by the plug-in author.
- Signalling relay (P2P)
- A Nostr-compatible WebSocket relay used for peer discovery and WebRTC connection negotiation. It does not store or transfer Vault contents. The project author operates a public relay as a best-effort convenience, and users can provide another compatible relay.
- Remediation (maxMTimeForReflectEvents)
- A recovery setting that restricts the propagation of changes from the database to local storage, ignoring any file events (such as accidental mass deletions) that occurred after a specified date and time.
- Reset Synchronisation on This Device
@@ -95,7 +95,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
- Sync Mode
- The replication trigger mechanism. Users can select from `On Events` (synchronising on local file changes), `Periodic and Events` (synchronising at fixed intervals as well as on events), or `LiveSync` (continuous, real-time synchronisation).
- TURN Server (WebRTC P2P)
- A server type (Traversal Using Relays around NAT) used as a fallback to relay traffic when direct WebRTC peer-to-peer connection is blocked by strict NAT or firewalls.
- A Traversal Using Relays around NAT server used as an optional fallback to relay encrypted WebRTC traffic when strict NAT or firewall rules block a direct peer connection. It is distinct from the signalling relay.
- Update Thinning (Batch database update)
- An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations.
- WebRTC P2P (Peer-to-Peer)
+4 -1
View File
@@ -47,7 +47,10 @@ A pattern containing only `snippets` does not admit the `.obsidian` parent, so t
![Hidden File Sync initialisation choices](../../images/hidden-file-sync/guide-hidden-file-enable.png)
2. Under `Enable Hidden File Sync`, select the initialisation direction chosen above.
3. Keep Obsidian open while the initial scan and synchronisation finish.
3. Keep Obsidian open while the initial scan and synchronisation finish. A progress Notice appears when preparation begins and remains visible until the initial scan has finished.
![Hidden File Sync initial scan progress Notice](../../images/hidden-file-sync/guide-hidden-file-initial-scan-progress.png)
4. Restart Obsidian when the completion Notice recommends it.
5. Confirm that the expected hidden files, and only those files, are present in the remote synchronisation state.
+44 -11
View File
@@ -10,22 +10,55 @@ authors:
# Peer-to-Peer Synchronisation Tips
For the complete first-device, Setup URI, second-device, and two-way verification procedure, see [Set up peer-to-peer synchronisation](../setup_p2p.md).
For the first device, Setup URI, additional device, and two-way verification procedure, see [Set up peer-to-peer synchronisation](../setup_p2p.md). For the communication and privacy model, see [How peer-to-peer synchronisation works](../p2p.md).
> [!IMPORTANT]
> Peer-to-peer synchronisation is a supported opt-in feature. WebRTC connectivity still depends on the networks, relays, and optional TURN servers available to every device, so a working connection cannot be guaranteed in every environment.
> P2P is a supported opt-in feature, but WebRTC connectivity still depends on the networks available to every device. A direct connection cannot be guaranteed in every environment.
## Difficulties with Peer-to-Peer Synchronisation
## A peer does not appear
It is often the case that peer-to-peer connections do not function correctly, for instance, when using mobile data services.
In such circumstances, we recommend connecting all devices to a single Virtual Private Network (VPN). It is advisable to select a service, such as Tailscale, which facilitates direct communication between peers wherever possible.
Should one be in an environment where even Tailscale is unable to connect, or where it cannot be lawfully installed, please continue reading.
Check discovery before changing any Vault settings:
## A More Detailed Explanation
1. Confirm that both devices use the same **Signalling relay URLs**, Group ID, and P2P passphrase.
2. Confirm that each device has a distinct device name.
3. Open `P2P Status` on both devices and confirm that each shows `Connected`.
4. Select `Refresh` after the other device joins.
5. If the peer remains absent, select `Disconnect`, then `Open connection` on the device which should be advertised again.
The failure of a Peer-to-Peer connection via WebRTC can be attributed to several factors. These may include an unsuccessful UDP hole-punching attempt, or an intermediary gateway intentionally terminating the connection. Troubleshooting this matter is not a simple undertaking. Furthermore, and rather unfortunately, gateway administrators are typically aware of this type of network behaviour. Whilst a legitimate purpose for such traffic can be cited, such as for web conferencing, this is often insufficient to prevent it from being blocked.
The signalling relay discovers peers; it does not prove that the networks can carry a WebRTC data connection.
This situation, however, is the primary reason that our project does not provide a TURN server. Although it is said that a TURN server within WebRTC does not decrypt communications, the project holds the view that the risk of a malicious party impersonating a TURN server must be avoided. Consequently, configuring a TURN server for relay communication is not currently possible through the user interface. Furthermore, there is no official project TURN server, which is to say, one that could be monitored by a third party.
## A peer appears but synchronisation cannot connect
We request that you provide your own server, using your own Fully Qualified Domain Name (FQDN), and subsequently enter its details into the advanced settings.
For testing purposes, Cloudflare's Real-Time TURN Service is exceedingly convenient and offers a generous amount of free data. However, it must be noted that because it is a well-known destination, such traffic is highly conspicuous. There is also a significant possibility that it may be blocked by default. We advise proceeding with caution.
WebRTC may fail when UDP hole punching is blocked by carrier-grade NAT, a firewall, a VPN policy, or an intermediary gateway.
Try these in order:
1. Put both devices on the same ordinary network and retry.
2. Remove a VPN temporarily if it blocks peer traffic, or use a trusted VPN such as Tailscale when it provides a reachable path between the devices.
3. In `P2P Configuration` -> `Advanced Settings`, configure a trusted TURN service.
TURN is a fallback for encrypted WebRTC traffic. It is different from the required signalling relay. The project does not operate an official TURN service. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume.
## A connected peer does not receive later edits
An open signalling connection does not automatically move every change.
- Use `Replicate now` to prove an explicit bidirectional round trip.
- Enable `Announce changes` on the source device before it dispatches notifications.
- Enable `Follow changes` for that source on the receiving device before it fetches in response.
- Use the peer's `More actions` menu only after the manual round trip works.
If the device was asleep, Obsidian was in the background, or the peer disconnected, run an explicit synchronisation after both devices are visible and connected.
## Mobile limitations
Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large synchronisation. Wake Lock support is best effort and cannot prevent the operating system from suspending or terminating a background application.
## Collect evidence
If the same room works on one network but not another, include both network types in the report. Run `Generate full report for opening the issue with debug info`, remove credentials and private relay details, and state whether:
- both devices reached `Connected`;
- each device appeared in `Detected Peers`;
- a connection request appeared; and
- a TURN server or VPN was in use.
+131 -424
View File
@@ -1,454 +1,161 @@
# Tips and Troubleshooting
- [Tips and Troubleshooting](#tips-and-troubleshooting)
- [Tips](#tips)
- [CORS avoidance](#cors-avoidance)
- [CORS configuration with reverse proxy](#cors-configuration-with-reverse-proxy)
- [Nginx](#nginx)
- [Nginx and subdirectory](#nginx-and-subdirectory)
- [Caddy](#caddy)
- [Caddy and subdirectory](#caddy-and-subdirectory)
- [Apache](#apache)
- [Show all setting panes](#show-all-setting-panes)
- [How to resolve `Tweaks Mismatched of Changed`](#how-to-resolve-tweaks-mismatched-of-changed)
- [Notable bugs and fixes](#notable-bugs-and-fixes)
- [Binary files get bigger on iOS](#binary-files-get-bigger-on-ios)
- [Some setting name has been changed](#some-setting-name-has-been-changed)
- [Questions and Answers](#questions-and-answers)
- [How should I share the settings between multiple devices?](#how-should-i-share-the-settings-between-multiple-devices)
- [What should I enter for the passphrase of Setup-URI?](#what-should-i-enter-for-the-passphrase-of-setup-uri)
- [Why the settings of Self-hosted LiveSync itself is disabled in default?](#why-the-settings-of-self-hosted-livesync-itself-is-disabled-in-default)
- [The plug-in says `something went wrong`.](#the-plug-in-says-something-went-wrong)
- [A large number of files were deleted, and were synchronised!](#a-large-number-of-files-were-deleted-and-were-synchronised)
- [Why `Use an old adapter for compatibility` is somehow enabled in my vault?](#why-use-an-old-adapter-for-compatibility-is-somehow-enabled-in-my-vault)
- [ZIP (or any extensions) files were not synchronised. Why?](#zip-or-any-extensions-files-were-not-synchronised-why)
- [I hope to report the issue, but you said you needs `Report`. How to make it?](#i-hope-to-report-the-issue-but-you-said-you-needs-report-how-to-make-it)
- [Where can I check the log?](#where-can-i-check-the-log)
- [Why are the logs volatile and ephemeral?](#why-are-the-logs-volatile-and-ephemeral)
- [Some network logs are not written into the file.](#some-network-logs-are-not-written-into-the-file)
- [If a file were deleted or trimmed, the capacity of the database should be reduced, right?](#if-a-file-were-deleted-or-trimmed-the-capacity-of-the-database-should-be-reduced-right)
- [How to launch the DevTools](#how-to-launch-the-devtools)
- [On Desktop Devices](#on-desktop-devices)
- [On Android](#on-android)
- [On iOS, iPadOS devices](#on-ios-ipados-devices)
- [How can I use the DevTools?](#how-can-i-use-the-devtools)
- [Checking the network log](#checking-the-network-log)
- [Troubleshooting](#troubleshooting)
- [While using Cloudflare Tunnels, often Obsidian API fallback and `524` error occurs.](#while-using-cloudflare-tunnels-often-obsidian-api-fallback-and-524-error-occurs)
- [On the mobile device, cannot synchronise on the local network!](#on-the-mobile-device-cannot-synchronise-on-the-local-network)
- [I think that something bad happening on the vault...](#i-think-that-something-bad-happening-on-the-vault)
- [Flag Files](#flag-files)
- [Old tips](#old-tips)
# Troubleshooting
<!-- - -->
## Tips
### CORS avoidance
If we are unable to configure CORS properly for any reason (for example, if we cannot configure non-administered network devices), we may choose to ignore CORS.
To use the Obsidian API (also known as the Non-Native API) to bypass CORS, we can enable the toggle ``Use Request API to avoid `inevitable` CORS problem``.
<!-- Add **Long explanation of CORS** here for integrity -->
### CORS configuration with reverse proxy
- IMPORTANT: CouchDB handles CORS by itself. Do not process CORS on the reverse
proxy.
- Do not process `Option` requests on the reverse proxy!
- Make sure `host` and `X-Forwarded-For` headers are forwarded to the CouchDB.
- If you are using a subdirectory, make sure to handle it properly. More
detailed information is in the
[CouchDB documentation](https://docs.couchdb.org/en/stable/best-practices/reverse-proxies.html).
Minimal configurations are as follows:
#### Nginx
```nginx
location / {
proxy_pass http://localhost:5984;
proxy_redirect off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
#### Nginx and subdirectory
```nginx
location /couchdb {
rewrite ^ $request_uri;
rewrite ^/couchdb/(.*) /$1 break;
proxy_pass http://localhost:5984$uri;
proxy_redirect off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /_session {
proxy_pass http://localhost:5984/_session;
proxy_redirect off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
#### Caddy
```caddyfile
domain.com {
reverse_proxy localhost:5984
}
```
#### Caddy and subdirectory
```caddyfile
domain.com {
reverse_proxy /couchdb/* localhost:5984
reverse_proxy /_session/* localhost:5984/_session
}
```
#### Apache
Sorry, Apache is not recommended for CouchDB. Omit the configuration from here.
Please refer to the
[Official documentation](https://docs.couchdb.org/en/stable/best-practices/reverse-proxies.html#reverse-proxying-with-apache-http-server).
### Show all setting panes
Full pane is not shown by default. To show all panes, please toggle all in
`🧙‍♂️ Wizard` -> `Enable extra and advanced features`.
For your information, the all panes are as follows:
![All Panes](all_toggles.png)
### How to resolve `Tweaks Mismatched of Changed`
(Since v0.23.17)
If you have changed some configurations or tweaks which should be unified
between the devices, you will be asked how to reflect (or not) other devices at
the next synchronisation. It also occurs on the device itself, where changes are
made, to prevent unexpected configuration changes from unwanted propagation.\
(We may thank this behaviour if we have synchronised or backed up and restored
Self-hosted LiveSync. At least, for me so).
Following dialogue will be shown: ![Dialogue](tweak_mismatch_dialogue.png)
- If we want to propagate the setting of the device, we should choose
`Update with mine`.
- On other devices, we should choose `Use configured` to accept and use the
configured configuration.
- `Dismiss` can postpone a decision. However, we cannot synchronise until we
have decided.
Rest assured that in most cases we can choose `Use configured`. (Unless you are
certain that you have not changed the configuration).
If we see it for the first time, it reflects the settings of the device that has
been synchronised with the remote for the first time since the upgrade.
Probably, we can accept that.
<!-- Add here -->
## Notable bugs and fixes
### Binary files get bigger on iOS
- Reported at: v0.20.x
- Fixed at: v0.21.2 (Fixed but not reviewed)
- Required action: larger files will not be fixed automatically, please perform
`Verify and repair all files`. If our local database and storage are not
matched, we will be asked to apply which one.
### Some setting name has been changed
- Fixed at: v0.22.6
| Previous name | New name |
| ---------------------------- | ---------------------------------------- |
| Open setup URI | Use the copied setup URI |
| Copy setup URI | Copy current settings as a new setup URI |
| Setup Wizard | Minimal Setup |
| Check database configuration | Check and Fix database configuration |
## Questions and Answers
### How should I share the settings between multiple devices?
- Device setup:
- Using `Setup URI` is the most straightforward way.
- Setting changes during use:
- Use `Sync settings via Markdown files` on the `🔄️ Sync settings` pane.
### What should I enter for the passphrase of Setup-URI?
- Anything you like is OK. However, the recommendation is as follows:
- Include the vault (group) information.
- Include the date of operation.
- Anything random for your security.
- For example, `MyVault-20240901-r4nd0mStr1ng`.
- Why?
- The Setup-URI is encoded; that means it cannot indicate the actual settings. Hence, if you use the same passphrase for multiple vaults, you may accidentally mix up vaults.
### Why the settings of Self-hosted LiveSync itself is disabled in default?
Basically, if we configure all `additionalSuffixOfDatabaseName` the same, we can synchronise this file between multiple devices.
(`additionalSuffixOfDatabaseName` should be unique in each device, not in the synchronised vaults).
However, if we synchronise the settings of Self-hosted LiveSync itself, we may encounter some unexpected behaviours.
For example, if a setting that 'let Self-hosted LiveSync setting be excluded' is synced, it is very unlikely that things will recover automatically after this, and there is little chance we will even notice this. Even if we change our minds and change the settings back on other devices. It could get even worse if incompatible changes are automatically reflected; everything will break.
### The plug-in says `something went wrong`.
There are many cases where this is really unclear. One possibility is that the chunk fetch did not go well.
1. Restarting Obsidian sometimes helps (fetch-order problem).
2. If actually there are no chunks, please perform `Recreate missing chunks for all files` on the `🧰 Hatch` pane at the other devices. And synchronise again. (also restart Obsidian may effect).
3. If the problem persists, please perform `Verify and repair all files` on the `🧰 Hatch` pane. If our local database and storage are not matched, we will be asked to apply which one.
### A large number of files were deleted, and were synchronised!
1. Backup everything important.
- Your local vault.
- Your CouchDB database (this can be done by replicating to another database).
2. Prepare the empty vault
3. Place `redflag.md` at the top of the vault.
4. Apply the settings **BUT DO NOT PROCEED TO RESTORE YET**.
- You can use `Setup URI`, QR Code, or manually apply the settings.
5. Set `Maximum file modification time for reflected file events` in `Remediation` on the `🩹 Patches` pane.
- If you know when the files were deleted, set the time a bit before that.
- If not, bisecting may help us.
6. Delete `redflag.md`.
7. Perform `Reset Synchronisation on This Device` on the `🎛️ Maintenance` pane.
This mode is very fragile. Please be careful.
### Why `Use an old adapter for compatibility` is somehow enabled in my vault?
Because you are a compassionate and experienced user. Before v0.17.16, we used
an old adapter for the local database. At that time, current default adapter has
not been stable. The new adapter has better performance and has a new feature
like purging. Therefore, we should use new adapters and current default is so.
However, when switching from an old adapter to a new adapter, some converting or
local database rebuilding is required, and it takes some time. It was a long
time ago now, but we once inconvenienced everyone in a hurry when we changed the
format of our database. For these reasons, this toggle is automatically on if we
have upgraded from vault which using an old adapter.
When you overwrite server data with this device's files or reset synchronisation on this device again, you will be asked to
switch this.
Therefore, experienced users (especially those stable enough not to have to
overwrite server data) may have this toggle enabled in their Vault. Please
disable it when you have enough time.
### ZIP (or any extensions) files were not synchronised. Why?
It depends on Obsidian detects. May toggling `Detect all extensions` of
`File and links` (setting of Obsidian) will help us.
### I hope to report the issue, but you said you needs `Report`. How to make it?
We can copy the report to the clipboard, by performing
`Generate full report for opening the issue with debug info` command!
### Where can I check the log?
We can launch the log pane by `Show log` on the command palette. And if you have
troubled something, please enable the `Verbose Log` on the `General Setting`
pane.
`Generate full report for opening the issue with debug info` command also contains
the recent 1000 log lines, which is very helpful for debugging. Full-report is
already set to the verbose level, so it contains all the logs without enabling the
`Verbose Log` toggle.
Let me note that please be sure to remove any sensitive information before sharing the report.
However, the logs would not be kept so long and cleared when restarted. If you
want to check the logs, please enable `Write logs into the file` temporarily.
![ScreenShot](../images/write_logs_into_the_file.png)
Start with the symptom which is visible now. Do not reset a database, change transport, or enable P2P merely to see whether the problem disappears.
> [!IMPORTANT]
>
> - Writing logs into the file will impact the performance.
> - Please make sure that you have erased all your confidential information
> before reporting issue.
> If Obsidian will not start, do not give up. Close it, create `redflag.md` at the Vault root with the operating system's file manager, then follow [Recovery and flag files](recovery.md). This is the supported route for intervening before ordinary LiveSync start-up work.
### Why are the logs volatile and ephemeral?
Before changing settings:
To avoid unexpected exposure to our confidential things.
1. Back up the affected Vaults and, where possible, the remote database or bucket.
2. Stop editing on other devices.
3. Confirm that every participating device uses the intended plug-in version.
4. Identify whether the active main remote is CouchDB, Object Storage, or P2P.
5. Open `Show log` and note the first error, rather than only the final summary.
### Some network logs are not written into the file.
For a report, run `Generate full report for opening the issue with debug info`, remove credentials and private server details, and include the steps which caused the symptom.
Especially the CORS error will be reported as a general error to the plug-in for
security reasons. So we cannot detect and log it. We are only able to
investigate them by [Checking the network log](#checking-the-network-log).
## CouchDB does not connect
### If a file were deleted or trimmed, the capacity of the database should be reduced, right?
Check the connection in this order:
No, even though if files were deleted, chunks were not deleted. Self-hosted
LiveSync splits the files into multiple chunks and transfers only newly created.
This behaviour enables us to less traffic. And, the chunks will be shared
between the files to reduce the total usage of the database.
1. Confirm that the URL is complete and points to the intended server.
2. On mobile, use HTTPS with a certificate trusted by the operating system. Plain HTTP and self-signed certificates are not supported.
3. Confirm the username, password, database name, and any custom headers.
4. Confirm that the server responds outside the plug-in and that the database exists on additional devices.
5. Use the setup dialogue's connection test.
6. If basic access works, run **Check server requirements**. Its initial check is read-only. Each offered server change requires separate confirmation.
And one more thing, we can handle the conflicts on any device even though it has
happened on other devices. This means that conflicts will happen in the past,
after the time we have synchronised. Hence we cannot collect and delete the
unused chunks even though if we are not currently referenced.
Configure CouchDB CORS first. Reverse-proxy examples belong in [Set up your own CouchDB server](setup_own_server.md), alongside the rest of the server configuration.
To shrink the database size, `Overwrite Server Data with This Device's Files` is the only reliable and effective way.
But do not worry, if we have synchronised well. We have the actual and real
files. Only it takes a bit of time and traffic.
`Use Internal API` is a compatibility workaround for a trusted server. It sends the configured credentials through Obsidian's internal request API. Enable it only after checking the destination, and do not treat a fallback through that API as proof that the server or proxy is correctly configured.
### How to launch the DevTools
A Cloudflare `524` response means that Cloudflare timed out while waiting for the origin. The response may also lack the CORS headers which would have been present on an ordinary CouchDB response. Correct the long-running server or proxy request first. The advanced CouchDB option `Use timeouts instead of heartbeats` may help only when the underlying operation is otherwise healthy.
#### On Desktop Devices
For JWT-specific setup and key-format errors, see [JWT Authentication on CouchDB](tips/jwt-on-couchdb.md).
We can launch the DevTools by pressing `ctrl`+`shift`+`i` (`Command`+`shift`+`i` on Mac).
## CouchDB was working but synchronisation stopped
#### On Android
Do not switch to P2P or reset the database as the first response. Check:
Please refer to [Remote debug Android devices](https://developer.chrome.com/docs/devtools/remote-debugging/).
Once the DevTools have been launched, everything operates the same as on a PC.
1. the active remote profile and connection state;
2. the plug-in version on every device;
3. the CouchDB response and server logs;
4. pending LiveSync progress indicators;
5. `Check server requirements`; and
6. the LiveSync log and full report.
#### On iOS, iPadOS devices
If the remote is healthy but one device's local database is not, use [Reset Synchronisation on This Device](recovery.md#reset-synchronisation-on-this-device) only after backing up unsynchronised local files.
If we have a Mac, we can inspect from Safari on the Mac. Please refer to [Inspecting iOS and iPadOS](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios).
## Files are missing or excluded
### How can I use the DevTools?
Check Obsidian's `Detect all file extensions`, LiveSync selectors, ignore files, file-size limits, modification-time limits, and Hidden File Sync rules. A filtered file is different from a file which reached the database but could not be reconstructed from its chunks.
#### Checking the network log
If the log reports missing chunks or a size mismatch:
1. restart Obsidian once to rule out an interrupted fetch;
2. on a device which has the correct file, run `Recreate missing chunks for all files`, then synchronise; and
3. if the mismatch remains, run `Verify and repair all files` from `Hatch` and review which copy is authoritative.
## A configuration mismatch dialogue blocks synchronisation
Some settings must match across devices. LiveSync pauses synchronisation when the local and remote values differ rather than propagating an unexpected change silently.
Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision.
The available actions depend on when the mismatch is found:
- While checking a remote profile, `Use configured settings` accepts the shared values already stored in that remote. `Dismiss` leaves this device's settings unchanged.
- For a mismatch found before synchronisation, `Apply settings to this device` accepts the remote values. Choose `Update remote database settings` only when this device's values are intended to become the shared values.
- When the change requires local or remote reconstruction, the action itself states that Fetch or Rebuild will follow. Make sure that the intended authoritative copy is available before choosing it.
- `Dismiss` postpones a mismatch found before synchronisation. Synchronisation remains paused until the mismatch is resolved.
![Configuration mismatch dialogue](tweak_mismatch_dialogue.png)
Historic defect notices and renamed controls are retained in the [0.25 release history](releases/0.25.md) and [legacy release history](releases/legacy.md), rather than in the current troubleshooting path.
## Setup and settings questions
### Share a configuration with another device
Generate an encrypted Setup URI from a working device. This preserves the intended remote profiles and selections while allowing the additional device to keep its own device-specific name. Store the URI and its passphrase separately.
For deliberate setting changes during normal use, use `Sync Settings via Markdown` under `Sync settings`.
### Choose a Setup URI passphrase
Use a strong passphrase which is distinct from the Vault encryption passphrase. Record enough context outside the encrypted URI to identify the intended Vault and date, but do not rely on a reused human-readable pattern alone.
### Why synchronising LiveSync's own settings is disabled by default
An automatically propagated transport, database, or exclusion setting can disable the mechanism needed to reverse it. LiveSync therefore keeps its own settings out of Customisation Sync by default. Enable that advanced behaviour only with an independent recovery path and device-specific database suffixes.
### The plug-in reports that something went wrong
Use the first specific error in `Show log` to choose the relevant section. When it names chunks or a size mismatch, follow [Files are missing or excluded](#files-are-missing-or-excluded). Do not rebuild solely from the generic final message.
### A large deletion propagated
Stop every device, preserve the available copies, and follow [Recovery and flag files](recovery.md). If the deletion time is known, `Maximum file modification time for reflected file events` under `Remediation` can limit which remote events are applied while recovering into a separate, backed-up Vault. Treat that as a forensic recovery constraint, not as an ordinary synchronisation setting.
### An old database adapter is still selected
Very old Vaults may retain the compatibility adapter until a deliberate local database migration or reset. Do not toggle it merely to troubleshoot an unrelated current failure. The history and migration notes are in the [legacy release history](releases/legacy.md).
### ZIP or another extension is not synchronised
Enable Obsidian's `Detect all file extensions`, then check LiveSync selectors, ignore rules, and size limits as described in [Files are missing or excluded](#files-are-missing-or-excluded).
## Collect a report
Run `Generate full report for opening the issue with debug info` to copy the current settings summary and recent verbose log lines. Remove credentials, remote URLs, Vault names, file contents, and other private information before sharing it.
Use `Show log` for live inspection. Logs are intentionally kept in memory for a limited time to reduce accidental disclosure. Enable `Write logs into the file` only while reproducing a problem, then disable it and remove the file after review because persistent logging affects performance and may contain private data.
![Write logs into the file](../images/write_logs_into_the_file.png)
Browser security errors, particularly CORS failures, may reach the plug-in only as a general network error. Use the network inspector when the ordinary log cannot show the rejected response.
## The database remains large after files are deleted
LiveSync stores file metadata, chunks, revision history, conflicts, deletions, and tombstones. Deleting or shortening a file therefore does not immediately remove every object which once represented it.
Garbage Collection can remove unreferenced chunks, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Tombstones and retained revisions are not free, so Garbage Collection does not guarantee a minimal database.
`Overwrite Server Data with This Device's Files` is a separate rebuild operation and is the more certain way to reconstruct a central remote from a chosen authoritative Vault. It is also destructive and may discard changes which exist only on another device. Review [Recovery and flag files](recovery.md#garbage-collection-is-not-rebuild) before choosing between them.
## Inspect a network failure
### Desktop
Open Developer Tools with `Ctrl`+`Shift`+`I`, or `Command`+`Option`+`I` on macOS.
### Android
Follow Chrome's [Remote debug Android devices](https://developer.chrome.com/docs/devtools/remote-debugging/) guide.
### iOS and iPadOS
Use Safari on a Mac and follow Apple's [Inspecting iOS and iPadOS](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios) guide.
### Network evidence
1. Open the network pane.
2. Find the requests marked in red.\
2. Reproduce the failure and select the request marked in red.
![Errored](../images/devtools1.png)
3. Capture the `Headers`, `Payload`, and, `Response`. **Please be sure to keep
important information confidential**. If the `Response` contains secrets, you
can omitted that. Note: Headers contains a some credentials. **The path of
the request URL, Remote Address, authority, and authorization must be
concealed.**\
3. Record the status, timing, and a sanitised version of the headers, payload, and response.
4. Remove the request path, remote address, authority, authorisation, cookies, credentials, and response secrets before sharing.
![Concealed sample](../images/devtools2.png)
## Troubleshooting
## P2P does not connect or transfer changes
<!-- Add here -->
Use [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). Check signalling discovery separately from the WebRTC data path, and confirm which devices announce and follow changes. P2P is not a repair step for another transport.
### While using Cloudflare Tunnels, often Obsidian API fallback and `524` error occurs.
## Obsidian or LiveSync remains suspended
A `524` error occurs when the request to the server is not completed within a
`specified time`. This is a timeout error from Cloudflare. From the reported
issue, it seems to be 100 seconds. (#627).
Follow [Recovery and flag files](recovery.md). A `redflag.md` emergency stop remains active until it is removed outside Obsidian. Fetch and rebuild flags have different, potentially destructive meanings; do not create them merely to clear a warning.
Therefore, this error returns from Cloudflare, not from the server. Hence, the
result contains no CORS field. It means that this response makes the Obsidian
API fallback.
## Further technical context
However, even if the Obsidian API fallback occurs, the request is still not
completed within the `specified time`, 100 seconds.
To solve this issue, we need to configure the timeout settings.
Please enable the toggle in `💪 Power users` -> `CouchDB Connection Tweak` ->
`Use timeouts instead of heartbeats`.
### On the mobile device, cannot synchronise on the local network!
Obsidian mobile is not able to connect to the non-secure end-point, such as
starting with `http://`. Make sure your URI of CouchDB. Also not able to use a
self-signed certificate.
### I think that something bad happening on the vault...
Place the [flag file](#flag-files) on top of the vault, and restart Obsidian. The most simple
way is to create a new note and rename it to `redflag`. Of course, we can put it
without Obsidian.
For example, if there is `redflag.md`, Self-hosted LiveSync suspends all database and storage
processes.
### Scram State and Flag Files (SCRAM Warning Loop)
The plug-in uses a **Scram state** (emergency suspension of all synchronisation processes) to prevent database corruption when severe errors or conflicts are detected. This state is often triggered or persisted by **flag files** placed at the root of the vault.
If you encounter a warning saying **"Scram detected, all sync operations are suspended per SCRAM"** or get caught in an infinite loop where the warning persists even after clicking "Resume", it is likely due to a flag file in your vault.
#### Flag Files
A flag file is a simple Markdown file located at the root of your vault. Its very existence is significant; it may be left blank or contain any text. These files are used so they can be easily placed or deleted from outside Obsidian (e.g., when Obsidian fails to launch).
| Filename | Human-Friendly Name | Description |
| ------------- | ------------------- | --------------------------------------------------------------------------------------- |
| `redflag.md` | - | Suspends all processes (activates Scram). |
| `redflag2.md` | `flag_rebuild.md` | Suspends all processes, and overwrites server data with this device's files. |
| `redflag3.md` | `flag_fetch.md` | Suspends all processes, discards the local database, and resets synchronisation on this device. |
When resetting synchronisation on this device or overwriting server data, restarting Obsidian is performed once for safety reasons. At that time, Self-hosted LiveSync uses these files to determine whether the process should be carried out. (This mechanism is especially useful on mobile devices to force cancellation if the database rebuilding fails). These files are not subject to synchronisation.
Flag-file recovery is handled before the compatibility-review dialogue. `redflag.md` keeps start-up stopped, while a cancelled recovery or one which schedules a restart also prevents a second dialogue from competing with it in that process. When fetch-all or rebuild-all completes and start-up continues, Self-hosted LiveSync can still ask for compatibility review before ordinary synchronisation resumes. Completing a recovery does not automatically acknowledge a database or settings version; use the explicit resume action after reviewing the explanation.
#### How to Resolve the Scram Loop
If you cannot disable Scram, please follow these steps:
1. Close Obsidian completely.
2. Open your system's file manager and check the root directory of your vault.
3. Locate and delete any flag files (such as `redflag.md`, `redflag2.md`, or `redflag3.md`).
4. Launch Obsidian.
5. Go to the settings dialogue -> **Hatch** -> **Scram Switches**, and manually toggle **Suspend file watching** and **Suspend database reflecting** to `false` (disabled) if they have not been reset automatically.
> [!TIP]
> This is the reason why flag files are standard `.md` files: it allows you to manage them externally. On mobile devices, you can use system file manager applications (such as the native **Files** app on iOS/iPadOS or **Files by Google** on Android) to find and delete these files to resolve a lock, or conversely, create/place a new `redflag.md` file (or directory) at the root of your vault to force-suspend synchronisation and stop Obsidian's boot-up sequence if you need to fix a database issue.
### JWT Authentication Errors
#### DataError when configuring JWT authentication
If you encounter a `DataError:` with no additional information in the logs when configuring JWT authentication, this usually indicates a private key formatting issue.
Self-hosted LiveSync requires the private key (for ES256/ES512 algorithms) to be in the **PKCS#8 PEM** format. Standard SEC1 EC private keys (which begin with `-----BEGIN EC PRIVATE KEY-----`) will trigger this error.
To resolve this, convert your private key to PKCS#8 format using the following `openssl` command:
```bash
openssl pkcs8 -topk8 -inform PEM -nocrypt -in private.key -out pkcs8.key
```
Then paste the contents of `pkcs8.key` (which begins with `-----BEGIN PRIVATE KEY-----`) into the JWT Key field.
### Old tips
- Rarely, a file in the database could be corrupted. The plug-in will not write
to local storage when a file looks corrupted. If a local version of the file
is on your device, the corruption could be fixed by editing the local file and
synchronising it. But if the file does not exist on any of your devices, then
it can not be rescued. In this case, you can delete these items from the
settings dialogue.
- To stop the boot-up sequence (eg. for fixing problems on databases), you can
put a `redflag.md` file (or directory) at the root of your vault. Tip for iOS:
a redflag directory can be created at the root of the vault using the File
application.
- Also, with `redflag2.md` placed, we can automatically overwrite server data with this device's files during the boot-up sequence. With `redflag3.md`, we
can discard only the local database and reset synchronisation on this device.
- Q: The database is growing, how can I shrink it down? A: each of the docs is
saved with their past 100 revisions for detecting and resolving conflicts.
Picturing that one device has been offline for a while, and comes online
again. The device has to compare its notes with the remotely saved ones. If
there exists a historic revision in which the note used to be identical, it
could be updated safely (like git fast-forward). Even if that is not in
revision histories, we only have to check the differences after the revision
that both devices commonly have. This is like git's conflict-resolving method.
So, We have to make the database again like an enlarged git repo if you want
to solve the root of the problem.
- And more technical Information is in the [Technical Information](tech_info.md)
- If you want to synchronise files without obsidian, you can use
[filesystem-livesync](https://github.com/vrtmrz/filesystem-livesync).
- WebClipper is also available on Chrome Web
Store:[obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
Repo is here:
[obsidian-livesync-webclip](https://github.com/vrtmrz/obsidian-livesync-webclip).
(Docs are a work in progress.)
See [Technical Information](tech_info.md) for database and synchronisation internals. Current behaviour belongs in this guide; instructions for older defects remain in the release histories.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.0-beta.0",
"version": "1.0.0-beta.3",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+11 -312
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.0-beta.0",
"version": "1.0.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.0-beta.0",
"version": "1.0.0-beta.3",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -39,7 +39,6 @@
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@eslint/js": "^9.39.3",
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tsconfig/svelte": "^5.0.8",
"@types/deno": "^2.5.0",
@@ -57,7 +56,7 @@
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.4",
"@vrtmrz/obsidian-test-session": "0.2.5",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
@@ -2201,123 +2200,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
"integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "^3.13.1",
"resolve-from": "^5.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2732,22 +2614,6 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@promptbook/utils": {
"version": "0.69.5",
"resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz",
@@ -4973,9 +4839,9 @@
}
},
"node_modules/@vrtmrz/obsidian-test-session": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.4.tgz",
"integrity": "sha512-fyb/6xHea/w9WwWi5u9ZJol1rBnVEk+fa1yM1RzUcf6VUAzmwn5zELWtOw8ZsFTdo9QpwVuAYlRAs35AdmUzqQ==",
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.5.tgz",
"integrity": "sha512-ZsI+Yx3z6IEFfh5Ey5mEUBNI0SUD6oDhP7D9LSZVEqPmdJz2JMiag7d8u/p1i6KpsoI2HbDvtw4RHpB+BQReAw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6268,16 +6134,6 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
@@ -9025,16 +8881,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/get-port": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz",
@@ -9124,24 +8970,6 @@
"node": ">= 14"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -10112,23 +9940,6 @@
"node": ">=8"
}
},
"node_modules/istanbul-lib-instrument": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz",
"integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@babel/core": "^7.23.9",
"@babel/parser": "^7.23.9",
"@istanbuljs/schema": "^0.1.3",
"istanbul-lib-coverage": "^3.2.0",
"semver": "^7.5.4"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
@@ -11176,16 +10987,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/ltgt": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz",
@@ -11991,23 +11792,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -13634,13 +13418,6 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -14286,21 +14063,6 @@
"node": ">=10"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
@@ -15155,67 +14917,6 @@
}
}
},
"node_modules/vite-plugin-istanbul": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vite-plugin-istanbul/-/vite-plugin-istanbul-9.0.1.tgz",
"integrity": "sha512-zgcdcqa4r3urX+xqhMQG2uLR2s6IdklQW+acBEtLw6fvUWhuvXswYqxjHAZZ5HCfHEgRs7RH7qIZCklTLRsKLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.29.7",
"@istanbuljs/load-nyc-config": "^1.1.0",
"@types/babel__generator": "7.27.0",
"espree": "^11.2.0",
"istanbul-lib-instrument": "^6.0.3",
"picocolors": "^1.1.1",
"source-map": "^0.7.6",
"test-exclude": "^8.0.0"
},
"peerDependencies": {
"vite": ">=7"
}
},
"node_modules/vite-plugin-istanbul/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vite-plugin-istanbul/node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vite-plugin-istanbul/node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 12"
}
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -15270,6 +14971,7 @@
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
@@ -16211,7 +15913,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.0-beta.0-cli",
"version": "1.0.0-beta.3-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -16236,22 +15938,19 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.0-beta.0-webapp",
"version": "1.0.0-beta.3-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"playwright": "^1.58.2",
"svelte": "5.56.3",
"typescript": "5.9.3",
"vite": "^8.0.16",
"vite-plugin-istanbul": "^9.0.1"
"vite": "^8.0.16"
}
},
"src/apps/webpeer": {
"version": "1.0.0-beta.0-webpeer",
"version": "1.0.0-beta.3-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.0-beta.0",
"version": "1.0.0-beta.3",
"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",
"type": "module",
@@ -28,8 +28,8 @@
"i18n:format": "prettier --config .prettierrc.mjs --write --log-level error 'src/common/messagesJson/*.json' 'src/common/messages/*.ts'",
"i18n:json2yaml": "tsx _tools/json2yaml.ts",
"i18n:yaml2json": "tsx _tools/yaml2json.ts",
"unittest": "deno test -A --no-check --coverage=cov_profile --v8-flags=--expose-gc --trace-leaks ./src/",
"test:unit": "vitest run --config vitest.config.unit.ts",
"inspect:troubleshooting": "tsx _tools/inspect-troubleshooting-docs.ts",
"test:setup-tools": "bash utils/flyio/setenv.test.sh && deno test -A --config=utils/flyio/deno.jsonc --frozen --lock=utils/flyio/deno.lock utils/livesync-commonlib-version.test.ts utils/couchdb/provision.test.ts utils/setup/generate_setup_uri.test.ts utils/flyio/generate_setupuri.test.ts",
"test:contract:contexts": "vitest run --config vitest.config.unit.ts src/modules/services/ObsidianServiceContext.unit.spec.ts src/apps/cli/services/NodeServiceContext.unit.spec.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts",
"test:contract:context:webapp": "vitest run --config vitest.config.unit.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts",
@@ -55,6 +55,7 @@
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
"test:e2e:obsidian:couchdb-manual-setup-workflow": "tsx test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts",
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
@@ -96,7 +97,6 @@
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@eslint/js": "^9.39.3",
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tsconfig/svelte": "^5.0.8",
"@types/deno": "^2.5.0",
@@ -114,7 +114,7 @@
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.4",
"@vrtmrz/obsidian-test-session": "0.2.5",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.0-beta.0-cli",
"version": "1.0.0-beta.3-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+2 -5
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.0-beta.0-webapp",
"version": "1.0.0-beta.3-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
@@ -15,12 +15,9 @@
"octagonal-wheels": "^0.1.51"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"playwright": "^1.58.2",
"svelte": "5.56.3",
"typescript": "5.9.3",
"vite": "^8.0.16",
"vite-plugin-istanbul": "^9.0.1"
"vite": "^8.0.16"
}
}
-79
View File
@@ -1,79 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ---------------------------------------------------------------------------
// Load environment variables from .test.env (root) so that CouchDB
// connection details are visible to the test process.
// ---------------------------------------------------------------------------
function loadEnvFile(envPath: string): Record<string, string> {
const result: Record<string, string> = {};
if (!fs.existsSync(envPath)) return result;
const lines = fs.readFileSync(envPath, "utf-8").split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const val = trimmed.slice(eq + 1).trim();
result[key] = val;
}
return result;
}
// __dirname is src/apps/webapp — root is three levels up
const ROOT = path.resolve(__dirname, "../../..");
const envVars = {
...loadEnvFile(path.join(ROOT, ".env")),
...loadEnvFile(path.join(ROOT, ".test.env")),
};
// Make the loaded variables available to all test files via process.env.
for (const [k, v] of Object.entries(envVars)) {
if (!(k in process.env)) {
process.env[k] = v;
}
}
export default defineConfig({
testDir: "./test",
// Give each test plenty of time for replication round-trips.
timeout: 120_000,
expect: { timeout: 30_000 },
// Run test files sequentially; the tests themselves manage two contexts.
fullyParallel: false,
workers: 1,
reporter: "list",
use: {
baseURL: "http://localhost:3000",
// Use Chromium for OPFS and FileSystem API support.
...devices["Desktop Chrome"],
headless: true,
// Launch args to match the main vitest browser config.
launchOptions: {
args: ["--js-flags=--expose-gc"],
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
// Start the vite dev server before running the tests.
webServer: {
command: "npx vite --port 3000",
url: "http://localhost:3000",
// Re-use a running dev server when developing locally.
reuseExistingServer: !process.env.CI,
timeout: 30_000,
// Run from the webapp directory so vite finds its config.
cwd: __dirname,
},
});
-238
View File
@@ -1,238 +0,0 @@
/**
* LiveSync WebApp E2E test entry point.
*
* When served by vite dev server (at /test.html), this module wires up
* `window.livesyncTest`, a plain JS API that Playwright tests can call via
* `page.evaluate()`. All methods are async and serialisation-safe.
*
* Vault storage is backed by OPFS so no `showDirectoryPicker()` interaction
* is required, making it fully headless-compatible.
*/
import { LiveSyncWebApp } from "./main";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
// --------------------------------------------------------------------------
// Internal state one app instance per page / browser context
// --------------------------------------------------------------------------
let app: LiveSyncWebApp | null = null;
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
/** Strip the "plain:" / "enc:" / … prefix used internally in PouchDB paths. */
function stripPrefix(raw: string): string {
return raw.replace(/^[^:]+:/, "");
}
interface TestCore {
serviceModules: {
databaseFileAccess: {
storeContent(path: FilePathWithPrefix, content: string): Promise<unknown>;
delete(path: FilePathWithPrefix): Promise<unknown>;
};
};
services?: {
replication?: {
databaseQueueCount?: { value: number };
storageApplyingCount?: { value: number };
replicate(showMessage: boolean): Promise<unknown>;
};
fileProcessing?: {
totalQueued?: { value: number };
batched?: { value: number };
processing?: { value: number };
};
database?: {
localDatabase: {
findAllNormalDocs(options?: { conflicts?: boolean }): AsyncIterable<{
_deleted?: boolean;
deleted?: boolean;
path?: string;
_rev?: string;
_conflicts?: string[];
size?: number;
mtime?: number;
}>;
};
};
};
}
/**
* Poll every 300 ms until all known processing queues are drained, or until
* the timeout elapses. Mirrors `waitForIdle` in the existing vitest harness.
*/
async function waitForIdle(core: TestCore, timeoutMs = 60_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const q =
(core.services?.replication?.databaseQueueCount?.value ?? 0) +
(core.services?.fileProcessing?.totalQueued?.value ?? 0) +
(core.services?.fileProcessing?.batched?.value ?? 0) +
(core.services?.fileProcessing?.processing?.value ?? 0) +
(core.services?.replication?.storageApplyingCount?.value ?? 0);
if (q === 0) return;
await new Promise<void>((r) => compatGlobal.setTimeout(r, 300));
}
throw new Error(`waitForIdle timed out after ${timeoutMs} ms`);
}
function getCore(): TestCore {
const core = (app as unknown as { core: TestCore | null })?.core;
if (!core) throw new Error("Vault not initialised call livesyncTest.init() first");
return core;
}
// --------------------------------------------------------------------------
// Public test API
// --------------------------------------------------------------------------
export interface LiveSyncTestAPI {
/**
* Initialise a vault in OPFS under the given name and apply `settings`.
* Any previous contents of the OPFS directory are wiped first so each
* test run starts clean.
*/
init(vaultName: string, settings: Partial<ObsidianLiveSyncSettings>): Promise<void>;
/**
* Write `content` to the local PouchDB under `vaultPath` (equivalent to
* the CLI `put` command). Waiting for the DB write to finish is
* included; you still need to call `replicate()` to push to remote.
*/
putFile(vaultPath: string, content: string): Promise<boolean>;
/**
* Mark `vaultPath` as deleted in the local PouchDB (equivalent to CLI
* `rm`). Call `replicate()` afterwards to propagate to remote.
*/
deleteFile(vaultPath: string): Promise<boolean>;
/**
* Run one full replication cycle (push + pull) against the remote CouchDB,
* then wait for the local storage-application queue to drain.
*/
replicate(): Promise<boolean>;
/**
* Wait until all processing queues are idle. Usually not needed after
* `putFile` / `deleteFile` since those already await, but useful when
* testing results after `replicate()`.
*/
waitForIdle(timeoutMs?: number): Promise<void>;
/**
* Return metadata for `vaultPath` from the local database, or `null` if
* not found / deleted.
*/
getInfo(vaultPath: string): Promise<{
path: string;
revision: string;
conflicts: string[];
size: number;
mtime: number;
} | null>;
/** Convenience wrapper: returns true when the doc has ≥1 conflict revision. */
hasConflict(vaultPath: string): Promise<boolean>;
/** Tear down the current app instance. */
shutdown(): Promise<void>;
}
// --------------------------------------------------------------------------
// Implementation
// --------------------------------------------------------------------------
const livesyncTest: LiveSyncTestAPI = {
async init(vaultName: string, settings: Partial<ObsidianLiveSyncSettings>): Promise<void> {
// Clean up any stale OPFS data from previous runs.
const opfsRoot = await compatGlobal.navigator.storage.getDirectory();
try {
await opfsRoot.removeEntry(vaultName, { recursive: true });
} catch {
// directory did not exist that's fine
}
const vaultDir = await opfsRoot.getDirectoryHandle(vaultName, { create: true });
// Pre-write settings so they are loaded during initialise().
const livesyncDir = await vaultDir.getDirectoryHandle(".livesync", { create: true });
const settingsFile = await livesyncDir.getFileHandle("settings.json", { create: true });
const writable = await settingsFile.createWritable();
await writable.write(JSON.stringify(settings));
await writable.close();
app = new LiveSyncWebApp(vaultDir);
await app.initialize();
// Give background startup tasks a moment to settle.
await waitForIdle(getCore(), 30_000);
},
async putFile(vaultPath: string, content: string): Promise<boolean> {
const core = getCore();
const result = await core.serviceModules.databaseFileAccess.storeContent(
vaultPath as FilePathWithPrefix,
content
);
await waitForIdle(core);
return result !== false;
},
async deleteFile(vaultPath: string): Promise<boolean> {
const core = getCore();
const result = await core.serviceModules.databaseFileAccess.delete(vaultPath as FilePathWithPrefix);
await waitForIdle(core);
return result !== false;
},
async replicate(): Promise<boolean> {
const core = getCore();
const result = await core.services.replication.replicate(true);
// After replicate() resolves, remote docs may still be queued for
// local storage application wait until all queues are drained.
await waitForIdle(core);
return result !== false;
},
async waitForIdle(timeoutMs?: number): Promise<void> {
await waitForIdle(getCore(), timeoutMs ?? 60_000);
},
async getInfo(vaultPath: string) {
const core = getCore();
const db = core.services?.database;
for await (const doc of db.localDatabase.findAllNormalDocs({ conflicts: true })) {
if (doc._deleted || doc.deleted) continue;
const docPath = stripPrefix(doc.path ?? "");
if (docPath !== vaultPath) continue;
return {
path: docPath,
revision: doc._rev ?? "",
conflicts: doc._conflicts ?? [],
size: doc.size ?? 0,
mtime: doc.mtime ?? 0,
};
}
return null;
},
async hasConflict(vaultPath: string): Promise<boolean> {
const info = await livesyncTest.getInfo(vaultPath);
return (info?.conflicts?.length ?? 0) > 0;
},
async shutdown(): Promise<void> {
if (app) {
await app.shutdown();
app = null;
}
},
};
// Expose on window for Playwright page.evaluate() calls.
(compatGlobal as unknown as Record<string, unknown>).livesyncTest = livesyncTest;
-26
View File
@@ -1,26 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LiveSync WebApp E2E Test Page</title>
<style>
body {
font-family: monospace;
padding: 1rem;
}
#status {
margin-top: 1rem;
padding: 0.5rem;
border: 1px solid #ccc;
}
</style>
</head>
<body>
<h2>LiveSync WebApp E2E</h2>
<p>This page is used by Playwright tests only. <code>window.livesyncTest</code> is exposed by the script below.</p>
<!-- status div required by LiveSyncWebApp internal helpers -->
<div id="status">Loading…</div>
<script type="module" src="/test-entry.ts"></script>
</body>
</html>
-292
View File
@@ -1,292 +0,0 @@
/**
* WebApp E2E tests two-vault scenarios.
*
* Each vault (A and B) runs in its own browser context so that JavaScript
* global state (including Trystero's global signalling tables) is fully
* isolated. The two vaults communicate only through the shared remote
* CouchDB database.
*
* Vault storage is OPFS-backed no file-picker interaction needed.
*
* Prerequisites:
* - A reachable CouchDB instance whose connection details are in .test.env
* (read automatically by playwright.config.ts).
*
* How to run:
* cd src/apps/webapp && npm run test:e2e
*/
import { test, expect, type BrowserContext, type Page, type TestInfo } from "@playwright/test";
import type { LiveSyncTestAPI } from "@/apps/webapp/test-entry";
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ---------------------------------------------------------------------------
// Settings helpers
// ---------------------------------------------------------------------------
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env variable: ${name}`);
return v;
}
async function ensureCouchDbDatabase(uri: string, user: string, pass: string, dbName: string): Promise<void> {
const base = uri.replace(/\/+$/, "");
const dbUrl = `${base}/${encodeURIComponent(dbName)}`;
const auth = Buffer.from(`${user}:${pass}`, "utf-8").toString("base64");
const response = await fetch(dbUrl, {
method: "PUT",
headers: {
Authorization: `Basic ${auth}`,
},
});
// 201: created, 202: accepted, 412: already exists
if (response.status === 201 || response.status === 202 || response.status === 412) {
return;
}
const body = await response.text().catch(() => "");
throw new Error(`Failed to ensure CouchDB database (${response.status}): ${body}`);
}
function buildSettings(dbName: string): Record<string, unknown> {
return {
// Remote database (shared between A and B this is the replication target)
couchDB_URI: requireEnv("hostname").replace(/\/+$/, ""),
couchDB_USER: process.env["username"] ?? "",
couchDB_PASSWORD: process.env["password"] ?? "",
couchDB_DBNAME: dbName,
// Core behaviour
isConfigured: true,
liveSync: false,
syncOnSave: false,
syncOnStart: false,
periodicReplication: false,
gcDelay: 0,
savingDelay: 0,
notifyThresholdOfRemoteStorageSize: 0,
// Encryption off for test simplicity
encrypt: false,
// Disable plugin/hidden-file sync (not needed in webapp)
usePluginSync: false,
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
//Auto accept perr
P2P_AutoAcceptingPeers: "~.*",
};
}
// ---------------------------------------------------------------------------
// Test-page helpers
// ---------------------------------------------------------------------------
/** Navigate to the test entry page and wait for `window.livesyncTest`. */
async function openTestPage(ctx: BrowserContext): Promise<Page> {
const page = await ctx.newPage();
await page.goto("/test.html");
await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 });
return page;
}
/** Type-safe wrapper calls `window.livesyncTest.<method>(...args)` in the page. */
async function call<M extends keyof LiveSyncTestAPI>(
page: Page,
method: M,
...args: Parameters<LiveSyncTestAPI[M]>
): Promise<Awaited<ReturnType<LiveSyncTestAPI[M]>>> {
const invoke = () =>
page.evaluate(([m, a]) => (window as any).livesyncTest[m](...a), [method, args] as [
string,
unknown[],
]) as Promise<Awaited<ReturnType<LiveSyncTestAPI[M]>>>;
try {
return await invoke();
} catch (ex: any) {
const message = String(ex?.message ?? ex);
// Some startup flows may trigger one page reload; recover once.
if (
message.includes("Execution context was destroyed") ||
message.includes("Most likely the page has been closed")
) {
await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 });
return await invoke();
}
throw ex;
}
}
async function dumpCoverage(page: Page | undefined, label: string, testInfo: TestInfo): Promise<void> {
if (!process.env.PW_COVERAGE || !page || page.isClosed()) {
return;
}
const cov = await page
.evaluate(() => {
const data = (window as any).__coverage__;
if (!data) return null;
// Reset between tests to avoid runaway accumulation.
(window as any).__coverage__ = {};
return data;
})
.catch((): null => null);
if (!cov) return;
if (typeof cov === "object" && Object.keys(cov as Record<string, unknown>).length === 0) {
return;
}
const outDir = path.resolve(__dirname, "../.nyc_output");
fs.mkdirSync(outDir, { recursive: true });
const name = `${testInfo.testId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${label}.json`;
fs.writeFileSync(path.join(outDir, name), JSON.stringify(cov), "utf-8");
}
// ---------------------------------------------------------------------------
// Two-vault E2E suite
// ---------------------------------------------------------------------------
test.describe("WebApp two-vault E2E", () => {
let ctxA: BrowserContext;
let ctxB: BrowserContext;
let pageA: Page;
let pageB: Page;
const DB_SUFFIX = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const dbName = `${requireEnv("dbname")}-${DB_SUFFIX}`;
const settings = buildSettings(dbName);
test.beforeAll(async ({ browser }) => {
await ensureCouchDbDatabase(
String(settings.couchDB_URI ?? ""),
String(settings.couchDB_USER ?? ""),
String(settings.couchDB_PASSWORD ?? ""),
dbName
);
// Open Vault A and Vault B in completely separate browser contexts.
// Each context has its own JS runtime, IndexedDB and OPFS root, so
// Trystero global state and PouchDB instance names cannot collide.
ctxA = await browser.newContext();
ctxB = await browser.newContext();
pageA = await openTestPage(ctxA);
pageB = await openTestPage(ctxB);
await call(pageA, "init", "testvault_a", settings as any);
await call(pageB, "init", "testvault_b", settings as any);
});
test.afterAll(async () => {
await call(pageA, "shutdown").catch(() => {});
await call(pageB, "shutdown").catch(() => {});
await ctxA.close();
await ctxB.close();
});
test.afterEach(async ({}, testInfo) => {
await dumpCoverage(pageA, "vaultA", testInfo);
await dumpCoverage(pageB, "vaultB", testInfo);
});
// -----------------------------------------------------------------------
// Case 1: Vault A writes a file and can read its metadata back from the
// local database (no replication yet).
// -----------------------------------------------------------------------
test("Case 1: A writes a file and can get its info", async () => {
const FILE = "e2e/case1-a-only.md";
const CONTENT = "hello from vault A";
const ok = await call(pageA, "putFile", FILE, CONTENT);
expect(ok).toBe(true);
const info = await call(pageA, "getInfo", FILE);
expect(info).not.toBeNull();
expect(info!.path).toBe(FILE);
expect(info!.revision).toBeTruthy();
expect(info!.conflicts).toHaveLength(0);
});
// -----------------------------------------------------------------------
// Case 2: Vault A writes a file, both vaults replicate, and Vault B ends
// up with the file in its local database.
// -----------------------------------------------------------------------
test("Case 2: A writes a file, both replicate, B receives the file", async () => {
const FILE = "e2e/case2-sync.md";
const CONTENT = "content from A should appear in B";
await call(pageA, "putFile", FILE, CONTENT);
// A pushes to remote, B pulls from remote.
await call(pageA, "replicate");
await call(pageB, "replicate");
const infoB = await call(pageB, "getInfo", FILE);
expect(infoB).not.toBeNull();
expect(infoB!.path).toBe(FILE);
});
// -----------------------------------------------------------------------
// Case 3: Vault A deletes the file it synced in case 2. After both
// vaults replicate, Vault B no longer sees the file.
// -----------------------------------------------------------------------
test("Case 3: A deletes the file, both replicate, B no longer sees it", async () => {
// This test depends on Case 2 having put e2e/case2-sync.md into both vaults.
const FILE = "e2e/case2-sync.md";
await call(pageA, "deleteFile", FILE);
await call(pageA, "replicate");
await call(pageB, "replicate");
const infoB = await call(pageB, "getInfo", FILE);
// The file should be gone (null means not found or deleted).
expect(infoB).toBeNull();
});
// -----------------------------------------------------------------------
// Case 4: A and B each independently edit the same file that was already
// synced. After both vaults replicate the editing cycle, both
// vaults report a conflict on that file.
// -----------------------------------------------------------------------
test("Case 4: concurrent edits from A and B produce a conflict on both sides", async () => {
const FILE = "e2e/case4-conflict.md";
// 1) Write a baseline and synchronise so both vaults start from the
// same revision.
await call(pageA, "putFile", FILE, "base content");
await call(pageA, "replicate");
await call(pageB, "replicate");
// Confirm B has the base file with no conflicts yet.
const baseInfoB = await call(pageB, "getInfo", FILE);
expect(baseInfoB).not.toBeNull();
expect(baseInfoB!.conflicts).toHaveLength(0);
// 2) Both vaults write diverging content without syncing in between
// this creates two competing revisions.
await call(pageA, "putFile", FILE, "content from A (conflict side)");
await call(pageB, "putFile", FILE, "content from B (conflict side)");
// 3) Run replication on both sides. The order mirrors the pattern
// from the CLI two-vault tests (A → remote → B → remote → A).
await call(pageA, "replicate");
await call(pageB, "replicate");
await call(pageA, "replicate"); // re-check from A to pick up B's revision
// 4) At least one side must report a conflict.
const hasConflictA = await call(pageA, "hasConflict", FILE);
const hasConflictB = await call(pageB, "hasConflict", FILE);
expect(
hasConflictA || hasConflictB,
"Expected a conflict to appear on vault A or vault B after diverging edits"
).toBe(true);
});
});
+1 -31
View File
@@ -1,6 +1,5 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import istanbul from "vite-plugin-istanbul";
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "../../..");
@@ -15,35 +14,9 @@ function readVersion(filePath: string): string | undefined {
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
const enableCoverage = process.env.PW_COVERAGE === "1";
// https://vite.dev/config/
export default defineConfig({
plugins: [
svelte(),
...(enableCoverage
? [
istanbul({
cwd: repoRoot,
include: ["src/**/*.ts", "src/**/*.svelte"],
exclude: [
"node_modules",
"dist",
"test",
"coverage",
"src/apps/webapp/test/**",
"playwright.config.ts",
"vite.config.ts",
"**/*.spec.ts",
"**/*.test.ts",
],
extension: [".js", ".ts", ".svelte"],
requireEnv: false,
cypress: false,
checkProd: false,
}),
]
: []),
],
plugins: [svelte()],
resolve: {
alias: {
"@": path.resolve(__dirname, "../../"),
@@ -55,12 +28,9 @@ export default defineConfig({
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
// test.html is used by the Playwright dev-server; include it here
// so the production build doesn't emit warnings about unused inputs.
input: {
index: path.resolve(__dirname, "index.html"),
webapp: path.resolve(__dirname, "webapp.html"),
test: path.resolve(__dirname, "test.html"),
},
external: ["crypto"],
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.0-beta.0-webpeer",
"version": "1.0.0-beta.3-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -139,7 +139,7 @@ describe("database compatibility evaluation", () => {
});
});
it("retains an existing legacy review when no structured reason can be reconstructed", () => {
it("compatibility: retains an earlier unstructured review when no structured reason can be reconstructed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
@@ -159,7 +159,7 @@ describe("database compatibility evaluation", () => {
});
});
it("scopes the legacy marker to the Vault", () => {
it("compatibility: scopes the earlier review marker to the Vault", () => {
expect(legacyDatabaseCompatibilityVersionKey("Example Vault")).toBe("obsidian-live-sync-verExample Vault");
});
});
@@ -7,6 +7,60 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.":
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
"Setup Complete: Preparing to Fetch from Another Device":
"Setup Complete: Preparing to Fetch from Another Device",
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.":
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.",
"Restart this device, then choose the source device when P2P Rebuild opens.":
"Restart this device, then choose the source device when P2P Rebuild opens.",
"Restart and Select Source Device": "Restart and Select Source Device",
"P2P Status pane": "P2P Status pane",
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited.":
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited.",
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery.":
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery.",
"Signalling relay URLs": "Signalling relay URLs",
"Peer discovery uses Nostr-compatible signalling relays.":
"Peer discovery uses Nostr-compatible signalling relays.",
"Use the project's public signalling relay": "Use the project's public signalling relay",
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.":
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.",
"Learn more about P2P connections": "Learn more about P2P connections",
"Learn more about signalling and TURN": "Learn more about signalling and TURN",
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.":
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.",
"Announce changes": "Announce changes",
"Announce changes automatically after connecting": "Announce changes automatically after connecting",
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection.":
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection.",
"Stop announcing changes": "Stop announcing changes",
"Start announcing changes": "Start announcing changes",
"Follow changes": "Follow changes",
"Stop following changes from this device": "Stop following changes from this device",
"Follow changes from this device": "Follow changes from this device",
"Synchronise when this device connects": "Synchronise when this device connects",
"Follow whenever this device connects": "Follow whenever this device connects",
"Include in the P2P synchronisation command": "Include in the P2P synchronisation command",
"More actions for ${DEVICE}": "More actions for ${DEVICE}",
"Create or connect to database and continue": "Create or connect to database and continue",
"Connect to existing database and continue": "Connect to existing database and continue",
"Test connection and save": "Test connection and save",
"Save without connecting": "Save without connecting",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"CouchDB validates the database name when you connect. The name must not be empty.":
"CouchDB validates the database name when you connect. The name must not be empty.",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.":
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.":
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
"Check server requirements": "Check server requirements",
"Change CouchDB server setting": "Change CouchDB server setting",
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?":
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?",
"This file has unresolved conflicts.": "This file has unresolved conflicts.",
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.":
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.",
+16 -16
View File
@@ -8810,15 +8810,15 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "你正在将此设备加入已有同步配置。",
},
"Ui.SetupWizard.SelectExisting.ManualOption": {
def: "Enter the server information manually",
def: "Configure a remote manually",
zh: "手动输入服务器信息",
},
"Ui.SetupWizard.SelectExisting.ManualOptionDesc": {
def: "Configure the same server information as your other devices again manually. This is intended only for advanced users.",
def: "Configure the same remote as your other devices again manually. This is intended only for advanced users.",
zh: "手动重新配置与你其他设备相同的服务器信息。此方式仅适用于高级用户。",
},
"Ui.SetupWizard.SelectExisting.ProceedManual": {
def: "I know my server details, let me enter them",
def: "Proceed with manual configuration",
zh: "我知道服务器信息,让我手动输入",
},
"Ui.SetupWizard.SelectExisting.ProceedQr": {
@@ -8854,19 +8854,19 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "设备设置方式",
},
"Ui.SetupWizard.SelectNew.Guidance": {
def: "We will now proceed with the server configuration.",
def: "We will now configure the synchronisation connection.",
zh: "接下来将继续配置服务器连接信息。",
},
"Ui.SetupWizard.SelectNew.ManualOption": {
def: "Enter the server information manually",
def: "Configure a remote manually",
zh: "手动输入服务器信息",
},
"Ui.SetupWizard.SelectNew.ManualOptionDesc": {
def: "This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings.",
def: "This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings. You can also use it for P2P synchronisation instead of CouchDB or S3-compatible Object Storage.",
zh: "如果你没有 Setup URI,或希望自行配置更详细的参数,可选择此高级选项。",
},
"Ui.SetupWizard.SelectNew.ProceedManual": {
def: "I know my server details, let me enter them",
def: "Proceed with manual configuration",
zh: "我知道服务器信息,让我手动输入",
},
"Ui.SetupWizard.SelectNew.ProceedSetupUri": {
@@ -8874,7 +8874,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "使用 Setup URI 继续",
},
"Ui.SetupWizard.SelectNew.Question": {
def: "How would you like to configure the connection to your server?",
def: "How would you like to configure this synchronisation connection?",
zh: "你希望如何配置服务器连接?",
},
"Ui.SetupWizard.SelectNew.SetupUriOption": {
@@ -8882,7 +8882,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "使用 Setup URI(推荐)",
},
"Ui.SetupWizard.SelectNew.SetupUriOptionDesc": {
def: "A Setup URI is a single string containing your server address and authentication details. If one was generated by your server installation script, it provides a simple and secure configuration method.",
def: "A Setup URI is a single string containing connection and authentication details. When one is available from a setup script, it provides a simple and secure configuration method.",
zh: "Setup URI 是一段包含服务器地址和认证信息的文本。如果你的服务器安装脚本已经生成了它,这是最简单且安全的配置方式。",
},
"Ui.SetupWizard.SelectNew.Title": {
@@ -8890,11 +8890,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "连接方式",
},
"Ui.SetupWizard.SetupRemote.BucketOption": {
def: "S3/MinIO/R2 Object Storage",
def: "S3-compatible Object Storage",
zh: "S3/MinIO/R2 对象存储",
},
"Ui.SetupWizard.SetupRemote.BucketOptionDesc": {
def: "Synchronisation using journal files. You must already have an S3/MinIO/R2 compatible object storage service set up.",
def: "Synchronisation using journal files. You must already have an S3-compatible Object Storage service set up, such as Amazon S3, MinIO, or Cloudflare R2.",
zh: "使用日志文件进行同步。你需要先准备好兼容 S3/MinIO/R2 的对象存储服务。",
},
"Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": {
@@ -8902,11 +8902,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "这是当前设计下最适合的同步方式,所有功能都可用。你需要先准备好 CouchDB 实例。",
},
"Ui.SetupWizard.SetupRemote.Guidance": {
def: "Please select the type of server you are connecting to.",
def: "Select the remote type for this synchronisation setup.",
zh: "请选择你要连接的服务器类型。",
},
"Ui.SetupWizard.SetupRemote.P2POption": {
def: "Peer-to-Peer only",
def: "Peer-to-Peer (P2P)",
zh: "仅点对点",
},
"Ui.SetupWizard.SetupRemote.P2POptionDesc": {
@@ -8914,7 +8914,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "启用设备之间的直接同步。无需服务器,但两台设备必须同时在线,且部分功能可能受限。互联网连接仅用于信令,不用于传输数据。",
},
"Ui.SetupWizard.SetupRemote.ProceedBucket": {
def: "Continue to S3/MinIO/R2 setup",
def: "Continue to Object Storage setup",
zh: "继续配置 S3/MinIO/R2",
},
"Ui.SetupWizard.SetupRemote.ProceedCouchDb": {
@@ -8922,11 +8922,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "继续配置 CouchDB",
},
"Ui.SetupWizard.SetupRemote.ProceedP2P": {
def: "Continue to Peer-to-Peer only setup",
def: "Continue to P2P setup",
zh: "继续配置仅点对点模式",
},
"Ui.SetupWizard.SetupRemote.Title": {
def: "Enter Server Information",
def: "Choose a synchronisation remote",
zh: "输入服务器信息",
},
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": {
+16 -16
View File
@@ -1074,9 +1074,9 @@
"Ui.SetupWizard.RebuildEverythingP2P.Proceed": "I Understand, Prepare This Device",
"Ui.SetupWizard.RebuildEverythingP2P.Title": "Final Confirmation: Prepare This Device for P2P",
"Ui.SetupWizard.SelectExisting.Guidance": "You are adding this device to an existing synchronisation setup.",
"Ui.SetupWizard.SelectExisting.ManualOption": "Enter the server information manually",
"Ui.SetupWizard.SelectExisting.ManualOptionDesc": "Configure the same server information as your other devices again manually. This is intended only for advanced users.",
"Ui.SetupWizard.SelectExisting.ProceedManual": "I know my server details, let me enter them",
"Ui.SetupWizard.SelectExisting.ManualOption": "Configure a remote manually",
"Ui.SetupWizard.SelectExisting.ManualOptionDesc": "Configure the same remote as your other devices again manually. This is intended only for advanced users.",
"Ui.SetupWizard.SelectExisting.ProceedManual": "Proceed with manual configuration",
"Ui.SetupWizard.SelectExisting.ProceedQr": "Scan the QR code displayed on an active device using this device's camera.",
"Ui.SetupWizard.SelectExisting.ProceedSetupUri": "Proceed with Setup URI",
"Ui.SetupWizard.SelectExisting.QrOption": "Scan a QR Code (Recommended for mobile)",
@@ -1085,25 +1085,25 @@
"Ui.SetupWizard.SelectExisting.SetupUriOption": "Use a Setup URI (Recommended)",
"Ui.SetupWizard.SelectExisting.SetupUriOptionDesc": "Paste the Setup URI generated from one of your active devices.",
"Ui.SetupWizard.SelectExisting.Title": "Device Setup Method",
"Ui.SetupWizard.SelectNew.Guidance": "We will now proceed with the server configuration.",
"Ui.SetupWizard.SelectNew.ManualOption": "Enter the server information manually",
"Ui.SetupWizard.SelectNew.ManualOptionDesc": "This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings.",
"Ui.SetupWizard.SelectNew.ProceedManual": "I know my server details, let me enter them",
"Ui.SetupWizard.SelectNew.Guidance": "We will now configure the synchronisation connection.",
"Ui.SetupWizard.SelectNew.ManualOption": "Configure a remote manually",
"Ui.SetupWizard.SelectNew.ManualOptionDesc": "This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings. You can also use it for P2P synchronisation instead of CouchDB or S3-compatible Object Storage.",
"Ui.SetupWizard.SelectNew.ProceedManual": "Proceed with manual configuration",
"Ui.SetupWizard.SelectNew.ProceedSetupUri": "Proceed with Setup URI",
"Ui.SetupWizard.SelectNew.Question": "How would you like to configure the connection to your server?",
"Ui.SetupWizard.SelectNew.Question": "How would you like to configure this synchronisation connection?",
"Ui.SetupWizard.SelectNew.SetupUriOption": "Use a Setup URI (Recommended)",
"Ui.SetupWizard.SelectNew.SetupUriOptionDesc": "A Setup URI is a single string containing your server address and authentication details. If one was generated by your server installation script, it provides a simple and secure configuration method.",
"Ui.SetupWizard.SelectNew.SetupUriOptionDesc": "A Setup URI is a single string containing connection and authentication details. When one is available from a setup script, it provides a simple and secure configuration method.",
"Ui.SetupWizard.SelectNew.Title": "Connection Method",
"Ui.SetupWizard.SetupRemote.BucketOption": "S3/MinIO/R2 Object Storage",
"Ui.SetupWizard.SetupRemote.BucketOptionDesc": "Synchronisation using journal files. You must already have an S3/MinIO/R2 compatible object storage service set up.",
"Ui.SetupWizard.SetupRemote.BucketOption": "S3-compatible Object Storage",
"Ui.SetupWizard.SetupRemote.BucketOptionDesc": "Synchronisation using journal files. You must already have an S3-compatible Object Storage service set up, such as Amazon S3, MinIO, or Cloudflare R2.",
"Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": "This is the most suitable synchronisation method for the current design. All features are available. You must already have a CouchDB instance set up.",
"Ui.SetupWizard.SetupRemote.Guidance": "Please select the type of server you are connecting to.",
"Ui.SetupWizard.SetupRemote.P2POption": "Peer-to-Peer only",
"Ui.SetupWizard.SetupRemote.Guidance": "Select the remote type for this synchronisation setup.",
"Ui.SetupWizard.SetupRemote.P2POption": "Peer-to-Peer (P2P)",
"Ui.SetupWizard.SetupRemote.P2POptionDesc": "This enables direct synchronisation between devices. No server is required, but both devices must be online at the same time and some features may be limited. Internet connectivity is required only for signalling, not for data transfer.",
"Ui.SetupWizard.SetupRemote.ProceedBucket": "Continue to S3/MinIO/R2 setup",
"Ui.SetupWizard.SetupRemote.ProceedBucket": "Continue to Object Storage setup",
"Ui.SetupWizard.SetupRemote.ProceedCouchDb": "Continue to CouchDB setup",
"Ui.SetupWizard.SetupRemote.ProceedP2P": "Continue to Peer-to-Peer only setup",
"Ui.SetupWizard.SetupRemote.Title": "Enter Server Information",
"Ui.SetupWizard.SetupRemote.ProceedP2P": "Continue to P2P setup",
"Ui.SetupWizard.SetupRemote.Title": "Choose a synchronisation remote",
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.",
"Use a custom passphrase": "Use a custom passphrase",
"Use a Setup URI (Recommended)": "Use a Setup URI (Recommended)",
+30 -30
View File
@@ -934,25 +934,25 @@ P2P:
NoKnownPeers: No peers has been detected, waiting incoming other peers...
Note:
description: >2-
This replicator allows us to synchronise our vault with other devices
using a peer-to-peer connection. We can use this to synchronise our vault
with our other devices without using a cloud service.
This replicator allows us to synchronise our vault with other devices
using a peer-to-peer connection. We can use this to synchronise our vault
with our other devices without using a cloud service.
This replicator is based on Trystero. It also uses a signalling server to
establish a connection between devices. The signalling server is used to
exchange connection information between devices. It does (or,should) not
know or store any of our data.
This replicator is based on Trystero. It also uses a signalling server to
establish a connection between devices. The signalling server is used to
exchange connection information between devices. It does (or,should) not
know or store any of our data.
The signalling server can be hosted by anyone. This is just a Nostr relay.
For the sake of simplicity and checking the behaviour of the replicator,
an instance of the signalling server is hosted by vrtmrz. You can use the
experimental server provided by vrtmrz, or you can use any other server.
The signalling server can be hosted by anyone. This is just a Nostr relay.
For the sake of simplicity and checking the behaviour of the replicator,
an instance of the signalling server is hosted by vrtmrz. You can use the
experimental server provided by vrtmrz, or you can use any other server.
By the way, even if the signalling server does not store our data, it can
see the connection information of some of our devices. Please be aware of
this. Also, be cautious when using the server provided by someone else.
By the way, even if the signalling server does not store our data, it can
see the connection information of some of our devices. Please be aware of
this. Also, be cautious when using the server provided by someone else.
important_note: Peer-to-Peer Replicator.
important_note_sub: This feature is still on the bleeding edge. Please be aware
that ensure your data is backed up before using this feature. And, we
@@ -1895,9 +1895,9 @@ Ui:
Guidance: We will now guide you through a few questions to simplify the synchronisation setup.
SelectExisting:
Guidance: You are adding this device to an existing synchronisation setup.
ManualOption: Enter the server information manually
ManualOptionDesc: Configure the same server information as your other devices again manually. This is intended only for advanced users.
ProceedManual: I know my server details, let me enter them
ManualOption: Configure a remote manually
ManualOptionDesc: Configure the same remote as your other devices again manually. This is intended only for advanced users.
ProceedManual: Proceed with manual configuration
ProceedQr: Scan the QR code displayed on an active device using this device's camera.
ProceedSetupUri: Proceed with Setup URI
Question: Please select a method to import the settings from another device.
@@ -1907,14 +1907,14 @@ Ui:
SetupUriOptionDesc: Paste the Setup URI generated from one of your active devices.
Title: Device Setup Method
SelectNew:
Guidance: We will now proceed with the server configuration.
ManualOption: Enter the server information manually
ManualOptionDesc: This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings.
ProceedManual: I know my server details, let me enter them
Guidance: We will now configure the synchronisation connection.
ManualOption: Configure a remote manually
ManualOptionDesc: This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings. You can also use it for P2P synchronisation instead of CouchDB or S3-compatible Object Storage.
ProceedManual: Proceed with manual configuration
ProceedSetupUri: Proceed with Setup URI
Question: How would you like to configure the connection to your server?
Question: How would you like to configure this synchronisation connection?
SetupUriOption: Use a Setup URI (Recommended)
SetupUriOptionDesc: A Setup URI is a single string containing your server address and authentication details. If one was generated by your server installation script, it provides a simple and secure configuration method.
SetupUriOptionDesc: A Setup URI is a single string containing connection and authentication details. When one is available from a setup script, it provides a simple and secure configuration method.
Title: Connection Method
OutroAskUserMode:
CompatibleOption: The remote is already set up, and the configuration is compatible (or became compatible through this operation).
@@ -1951,13 +1951,13 @@ Ui:
Proceed: I Understand, Prepare This Device
Title: "Final Confirmation: Prepare This Device for P2P"
SetupRemote:
BucketOption: S3/MinIO/R2 Object Storage
BucketOptionDesc: Synchronisation using journal files. You must already have an S3/MinIO/R2 compatible object storage service set up.
BucketOption: S3-compatible Object Storage
BucketOptionDesc: Synchronisation using journal files. You must already have an S3-compatible Object Storage service set up, such as Amazon S3, MinIO, or Cloudflare R2.
CouchDbOptionDesc: This is the most suitable synchronisation method for the current design. All features are available. You must already have a CouchDB instance set up.
Guidance: Please select the type of server you are connecting to.
P2POption: Peer-to-Peer only
Guidance: Select the remote type for this synchronisation setup.
P2POption: Peer-to-Peer (P2P)
P2POptionDesc: This enables direct synchronisation between devices. No server is required, but both devices must be online at the same time and some features may be limited. Internet connectivity is required only for signalling, not for data transfer.
ProceedBucket: Continue to S3/MinIO/R2 setup
ProceedBucket: Continue to Object Storage setup
ProceedCouchDb: Continue to CouchDB setup
ProceedP2P: Continue to Peer-to-Peer only setup
Title: Enter Server Information
ProceedP2P: Continue to P2P setup
Title: Choose a synchronisation remote
+17
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { englishMessageTranslator } from "@vrtmrz/livesync-commonlib/context";
import { $msg, setLang, translateLiveSyncMessage } from "@/common/translation";
import { SUPPORTED_I18N_LANGS } from "@/common/rosetta";
import { liveSyncProvisionalEnglishMessages } from "@/common/messages/LiveSyncProvisionalMessages";
describe("LiveSync-owned translation catalogue", () => {
afterEach(() => setLang("def"));
@@ -28,6 +29,7 @@ describe("LiveSync-owned translation catalogue", () => {
it("uses LiveSync-owned provisional English without extending Commonlib's message contract", () => {
expect($msg("This file has unresolved conflicts.")).toBe("This file has unresolved conflicts.");
expect($msg("More actions for ${DEVICE}", { DEVICE: "phone" })).toBe("More actions for phone");
expect(
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: "3",
@@ -37,4 +39,19 @@ describe("LiveSync-owned translation catalogue", () => {
"This file has unresolved conflicts."
);
});
it("keeps the additional-device P2P Fetch explanation in the LiveSync-owned provisional catalogue", () => {
expect(liveSyncProvisionalEnglishMessages).toMatchObject({
"Setup Complete: Preparing to Fetch from Another Device":
"Setup Complete: Preparing to Fetch from Another Device",
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.":
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.",
"Restart this device, then choose the source device when P2P Rebuild opens.":
"Restart this device, then choose the source device when P2P Rebuild opens.",
"Restart and Select Source Device": "Restart and Select Source Device",
"P2P Status pane": "P2P Status pane",
});
});
});
@@ -54,11 +54,20 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
import {
configureHiddenFileSyncMode,
type ConfigureHiddenFileSyncResult,
} from "./configureHiddenFileSyncMode.ts";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
type HiddenFileInitialisationProgress = {
log(message: string): void;
once(message: string): void;
done(message?: string): void;
};
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
@@ -1421,13 +1430,18 @@ Offline Changed files: ${files.length}`;
direction: SyncDirection,
showMessage: boolean,
// filesAll: InternalFileInfo[] | false = false,
targetFilesSrc: string[] | false = false
targetFilesSrc: string[] | false = false,
initialisationProgress?: HiddenFileInitialisationProgress
) {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
const p = this._progress("[⚙ Initialise]\n", logLevel);
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
// p.log("Resolving conflicts before starting...");
// await this.resolveConflictOnInternalFiles();
p.log("Initialising hidden files sync...");
// The initialisation progress owns the user-visible Notice. Its child
// rebuild and scan operations still write ordinary log entries, but
// must not each create another keep-alive Notice.
const showChildNotices = false;
// TODO: Handling ignore files cannot be performed to the hidden files.
const targetFiles = targetFilesSrc
@@ -1436,35 +1450,38 @@ Offline Changed files: ${files.length}`;
if (direction == "pushForce" || direction == "push") {
const onlyNew = direction == "push";
p.log(`Started: Storage --> Database ${onlyNew ? "(Only New)" : ""}`);
const updatedFiles = await this.rebuildFromStorage(showMessage, targetFiles, onlyNew);
const updatedFiles = await this.rebuildFromStorage(showChildNotices, targetFiles, onlyNew);
// making doubly sure, No more losing files.
// I did so many times during the development.
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
await this.scanAllStorageChanges(showMessage, true, false);
await this.scanAllDatabaseChanges(showMessage, true, false);
p.log("Checking for remaining storage and database changes...");
await this.scanAllStorageChanges(showChildNotices, true, false);
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
if (direction == "pullForce" || direction == "pull") {
const onlyNew = direction == "pull";
p.log(`Started: Database --> Storage ${onlyNew ? "(Only New)" : ""}`);
const updatedEntries = await this.rebuildFromDatabase(showMessage, targetFiles, onlyNew);
const updatedEntries = await this.rebuildFromDatabase(showChildNotices, targetFiles, onlyNew);
const updatedFiles = updatedEntries.map((e) => stripAllPrefixes(this.getPath(e)));
// making doubly sure, No more losing files.
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
await this.scanAllDatabaseChanges(showMessage, true, false);
await this.scanAllStorageChanges(showMessage, true, false);
p.log("Checking for remaining database and storage changes...");
await this.scanAllDatabaseChanges(showChildNotices, true, false);
await this.scanAllStorageChanges(showChildNotices, true, false);
}
if (direction == "safe") {
p.log(`Started: Database <--> Storage (by modified date)`);
const updatedFiles = await this.rebuildMerging(showMessage, targetFiles);
const updatedFiles = await this.rebuildMerging(showChildNotices, targetFiles);
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
await this.scanAllStorageChanges(showMessage, true, false);
await this.scanAllDatabaseChanges(showMessage, true, false);
p.log("Checking for remaining storage and database changes...");
await this.scanAllStorageChanges(showChildNotices, true, false);
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
p.done();
}
@@ -1789,32 +1806,44 @@ Offline Changed files: ${files.length}`;
}
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
const result = await configureHiddenFileSyncMode(mode, {
disable: async () => {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
},
enable: async () => {
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true);
},
});
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
let result: ConfigureHiddenFileSyncResult;
try {
result = await configureHiddenFileSyncMode(mode, {
disable: async () => {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
},
enable: async () => {
// Open the one user-visible progress Notice before saving
// the setting. Large Vaults can otherwise appear idle
// before the initial file enumeration begins.
initialisationProgress = this._progress("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
initialisationProgress.log("Preparing Hidden File Sync...");
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
initialisationProgress = undefined;
},
});
} catch (error) {
initialisationProgress?.done("Failed");
throw error;
}
if (result == "ignored" || result == "disabled") {
return;
}
@@ -1822,7 +1851,7 @@ Offline Changed files: ${files.length}`;
// this.plugin.settings.syncInternalFiles = true;
// await this.plugin.saveSettings();
this._log(`Done! Restarting the app is strongly recommended!`, LOG_LEVEL_NOTICE);
this._log("Hidden File Sync initialisation completed.", LOG_LEVEL_INFO);
}
// <-- Configuration handling
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
@@ -21,6 +22,7 @@ vi.mock("./configureHiddenFileSyncMode.ts", () => ({
}));
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
describe("HiddenFileSync configuration-change notices", () => {
it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => {
@@ -102,4 +104,106 @@ describe("HiddenFileSync configuration-change notices", () => {
expect(core.services.appLifecycle.scheduleRestart).toHaveBeenCalledOnce();
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart");
});
it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => {
const progress = {
log: vi.fn(),
once: vi.fn(),
done: vi.fn(),
};
const rebuildMerging = vi.fn(async () => []);
const adoptCurrentStorageFilesAsProcessed = vi.fn(async () => undefined);
const adoptCurrentDatabaseFilesAsProcessed = vi.fn(async () => undefined);
const scanAllStorageChanges = vi.fn(async () => undefined);
const scanAllDatabaseChanges = vi.fn(async () => undefined);
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
_progress: vi.fn(() => progress),
rebuildMerging,
adoptCurrentStorageFilesAsProcessed,
adoptCurrentDatabaseFilesAsProcessed,
scanAllStorageChanges,
scanAllDatabaseChanges,
});
await hiddenFileSync.initialiseInternalFileSync("safe", true);
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
expect(progress.done).toHaveBeenCalledOnce();
});
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
await handlers.initialise("safe");
return "enabled";
});
const events: string[] = [];
const progress = {
log: vi.fn((message: string) => {
events.push(`progress:${message}`);
}),
once: vi.fn(),
done: vi.fn(),
};
const createProgress = vi.fn(() => progress);
const applyPartial = vi.fn(async () => {
events.push("apply-settings");
});
const initialiseInternalFileSync = vi.fn(async () => undefined);
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
services: {
setting: { applyPartial },
},
},
initialiseInternalFileSync,
_progress: createProgress,
_log: log,
});
await hiddenFileSync.configureHiddenFileSync("MERGE");
expect(createProgress).toHaveBeenCalledWith("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
expect(events[0]).toBe("progress:Preparing Hidden File Sync...");
expect(initialiseInternalFileSync).toHaveBeenCalledWith("safe", true, false, progress);
expect(log).not.toHaveBeenCalledWith("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
expect(log).not.toHaveBeenCalledWith("Done! Restarting the app is strongly recommended!", LOG_LEVEL_NOTICE);
expect(log).toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
});
it("closes the preparation Notice when enabling Hidden File Sync fails", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
return "enabled";
});
const error = new Error("setting persistence failed");
const progress = {
log: vi.fn(),
once: vi.fn(),
done: vi.fn(),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
services: {
setting: {
applyPartial: vi.fn(async () => {
throw error;
}),
},
},
},
_progress: vi.fn(() => progress),
_log: vi.fn(),
});
await expect(hiddenFileSync.configureHiddenFileSync("MERGE")).rejects.toBe(error);
expect(progress.done).toHaveBeenCalledWith("Failed");
});
});
@@ -88,7 +88,7 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
expect(applyPartial).not.toHaveBeenCalled();
});
it("does not treat the obsolete fixed-revision key as a maintenance prerequisite", async () => {
it("retirement guard: ignores the obsolete fixed-revision key as a maintenance prerequisite", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({
doNotUseFixedRevisionForChunks: false,
readChunksOnline: false,
@@ -163,4 +163,16 @@ describe("createOpenRebuildUI", () => {
expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true);
expect(replicator.clearOnSetup).toHaveBeenCalledOnce();
});
it("does not complete Fetch when the rebuild dialogue closes without selecting a peer", async () => {
const replicator = createReplicator();
const session = createOpenRebuildUI({} as any)(replicator)(true);
const modal = modalState.instances[0];
modal.onClosed?.();
await expect(session).resolves.toBe(false);
expect(replicator.replicateFrom).not.toHaveBeenCalled();
expect(replicator.setOnSetup).not.toHaveBeenCalled();
});
});
@@ -13,6 +13,7 @@
import type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { $msg as translateMessage } from "@/common/translation";
interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
@@ -134,15 +135,16 @@
{#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
{translateMessage("Announce changes")}
</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'}
title={isBroadcasting
? translateMessage("Stop announcing changes")
: translateMessage("Start announcing changes")}
>
{isBroadcasting ? '📡 On' : '📡 Off'}
</button>
@@ -24,6 +24,13 @@
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { SetupManager } from "@/modules/features/SetupManager";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import { Menu } from "@/deps";
import { $msg as translateMessage } from "@/common/translation";
import {
hasExactP2PPeer,
togglePersistedP2PPeer,
type PersistedP2PPeerSetting,
} from "./p2pPeerSettings";
interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
@@ -40,7 +47,6 @@
// Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time.
const readCurrentSettings = () => core.services.setting.currentSettings();
const initialSettings = readCurrentSettings();
let syncOnReplicationSetting = $state(initialSettings?.P2P_SyncOnReplication ?? "");
type P2PRemoteOption = {
id: string;
name: string;
@@ -50,21 +56,7 @@
let selectedP2PRemoteConfigurationId = $state(initialSettings?.P2P_ActiveRemoteConfigurationId ?? "");
let selectingP2PRemote = $state(false);
function addToList(item: string, list: string): string {
const items = list
.split(",")
.map((e) => e.trim())
.filter((e) => e);
if (!items.includes(item)) items.push(item);
return items.join(",");
}
function removeFromList(item: string, list: string): string {
return list
.split(",")
.map((e) => e.trim())
.filter((e) => e && e !== item)
.join(",");
}
let peerMenu: Menu | undefined;
function markCommunicating(peerId: string) {
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
@@ -154,7 +146,6 @@
});
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
});
const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
@@ -174,6 +165,7 @@
unsubscribeReplicatorProgress();
unsubscribeSettings();
unsubscribeLayoutReady();
peerMenu?.hide();
};
});
@@ -206,8 +198,6 @@
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
} finally {
selectingP2PRemote = false;
@@ -252,8 +242,6 @@
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
}
@@ -299,7 +287,6 @@
const activated = activateP2PRemoteConfiguration(settings, selectedId);
return activated || settings;
}, true);
syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
}
async function makeDecision(
@@ -374,23 +361,49 @@
return isLiveCommunicating || isHeldCommunicating;
}
function isSyncTarget(peerName: string) {
return syncOnReplicationSetting
.split(",")
.map((e) => e.trim())
.filter((e) => e)
.includes(peerName);
function isPersistedPeerSettingEnabled(setting: PersistedP2PPeerSetting, peerName: string) {
const settings = core.services.setting.currentSettings();
return hasExactP2PPeer(settings[setting] ?? "", peerName);
}
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
async function togglePersistedPeerSetting(
peer: P2PServerInfo["knownAdvertisements"][number],
setting: PersistedP2PPeerSetting
) {
if (!canEditP2PSettings()) {
return;
}
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
const newValue = isSyncTarget(peer.name)
? removeFromList(peer.name, currentValue)
: addToList(peer.name, currentValue);
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue });
const currentSettings = core.services.setting.currentSettings();
await updateSelectedP2PRemote(togglePersistedP2PPeer(currentSettings, setting, peer.name));
}
function openPeerMenu(event: MouseEvent, peer: P2PServerInfo["knownAdvertisements"][number]) {
peerMenu?.hide();
peerMenu = new Menu()
.addItem((item) => {
item.setTitle(translateMessage("Synchronise when this device connects"))
.setChecked(isPersistedPeerSettingEnabled("P2P_AutoSyncPeers", peer.name))
.onClick(() => {
void togglePersistedPeerSetting(peer, "P2P_AutoSyncPeers");
});
})
.addItem((item) => {
item.setTitle(translateMessage("Follow whenever this device connects"))
.setChecked(isPersistedPeerSettingEnabled("P2P_AutoWatchPeers", peer.name))
.onClick(() => {
void togglePersistedPeerSetting(peer, "P2P_AutoWatchPeers");
});
})
.addItem((item) => {
item.setTitle(translateMessage("Include in the P2P synchronisation command"))
.setChecked(isPersistedPeerSettingEnabled("P2P_SyncOnReplication", peer.name))
.onClick(() => {
void togglePersistedPeerSetting(peer, "P2P_SyncOnReplication");
});
});
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
peerMenu.showAtPosition({ x: rect.left, y: rect.bottom });
}
</script>
@@ -486,35 +499,34 @@
>
Revoke
</button>
<button
class="emoji-button"
title={translateMessage("More actions for ${DEVICE}", { DEVICE: peer.name })}
aria-label={translateMessage("More actions for ${DEVICE}", {
DEVICE: peer.name,
})}
disabled={!canEditP2PSettings()}
onclick={(event) => openPeerMenu(event, peer)}
>
</button>
</div>
<div class="decision-row watch-row">
<span class="decision-label">WATCH</span>
<span class="decision-label">{translateMessage("Follow changes")}</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"}
? translateMessage("Stop following changes from this device")
: translateMessage("Follow changes from this device")}
aria-label={isWatching(peer.peerId)
? translateMessage("Stop following changes from this device")
: translateMessage("Follow changes from this device")}
disabled={!canEditP2PSettings()}
onclick={() => toggleWatch(peer.peerId)}
>
{isWatching(peer.peerId) ? "🔔" : "🔕"}
</button>
</div>
<div class="decision-row watch-row">
<span class="decision-label">SYNC</span>
<button
class="emoji-button {isSyncTarget(peer.name) ? 'is-watching' : ''}"
title={isSyncTarget(peer.name)
? "Sync target \u2014 click to remove"
: "Set as sync target"}
aria-label={isSyncTarget(peer.name) ? "Remove sync target" : "Set sync target"}
disabled={!canEditP2PSettings()}
onclick={() => toggleSyncTarget(peer)}
>
{isSyncTarget(peer.name) ? "🔗" : "⛓️‍💥"}
</button>
</div>
{:else}
<div class="decision-status">
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
@@ -805,7 +817,7 @@
}
.accepted-row {
grid-template-columns: 1fr auto auto;
grid-template-columns: 1fr auto auto auto;
}
.decision-label {
@@ -0,0 +1,38 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
export type PersistedP2PPeerSetting =
| "P2P_AutoSyncPeers"
| "P2P_AutoWatchPeers"
| "P2P_SyncOnReplication";
function splitPeerSetting(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter((item) => item !== "");
}
export function hasExactP2PPeer(value: string, peerName: string): boolean {
return splitPeerSetting(value).includes(peerName);
}
export function toggleExactP2PPeer(value: string, peerName: string): string {
const items = splitPeerSetting(value);
const existingIndex = items.indexOf(peerName);
if (existingIndex >= 0) {
items.splice(existingIndex, 1);
} else {
items.push(peerName);
}
return [...new Set(items)].join(",");
}
export function togglePersistedP2PPeer(
settings: ObsidianLiveSyncSettings,
setting: PersistedP2PPeerSetting,
peerName: string
): Partial<ObsidianLiveSyncSettings> {
return {
[setting]: toggleExactP2PPeer(settings[setting] ?? "", peerName),
};
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import {
hasExactP2PPeer,
toggleExactP2PPeer,
togglePersistedP2PPeer,
} from "./p2pPeerSettings";
describe("persisted P2P peer controls", () => {
it("adds and removes only the selected peer without disturbing advanced patterns", () => {
const original = "~^phone-,desktop";
const added = toggleExactP2PPeer(original, "phone-main");
expect(added).toBe("~^phone-,desktop,phone-main");
expect(hasExactP2PPeer(added, "phone-main")).toBe(true);
const removed = toggleExactP2PPeer(added, "phone-main");
expect(removed).toBe(original);
});
it.each([
"P2P_AutoSyncPeers",
"P2P_AutoWatchPeers",
"P2P_SyncOnReplication",
] as const)("updates %s through the same profile-backed setting boundary", (setting) => {
const settings = {
P2P_AutoSyncPeers: "",
P2P_AutoWatchPeers: "",
P2P_SyncOnReplication: "",
} as ObsidianLiveSyncSettings;
expect(togglePersistedP2PPeer(settings, setting, "peer-a")).toEqual({
[setting]: "peer-a",
});
});
});
+2 -1
View File
@@ -145,7 +145,8 @@ export class ModuleReplicator extends AbstractModule {
}
/**
* obsolete method. No longer maintained and will be removed in the future.
* Reconciles local chunks when an older IndexedDB client reports that the remote database was cleaned.
* This compatibility path remains reachable while those clients can still set `remoteCleaned`.
* @deprecated v0.24.17
* @param showMessage If true, show message to the user.
*/
@@ -116,7 +116,7 @@ describe("ModuleReplicator", () => {
});
});
describe("ModuleReplicator legacy cleanup", () => {
describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", () => {
it("keeps its finite replication and balancing work inside the shared activity boundary", async () => {
const activityFinished = vi.fn();
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => {
@@ -29,8 +29,6 @@ function valueToString(value: string | number | boolean | object | undefined): s
}
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
private _hasNotifiedAutoAcceptCompatibleUndefined = false;
private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial<TweakValues>) {
const items = Object.keys(
TweakValuesShouldMatchedTemplate
@@ -64,33 +62,17 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
);
if (!hasOnlyCompatibleLossyMismatches) return undefined;
let autoAcceptCompatibleTweak = this.settings.autoAcceptCompatibleTweak;
if (this.settings.autoAcceptCompatibleTweak === undefined) {
if (this._hasNotifiedAutoAcceptCompatibleUndefined) {
return undefined;
}
this._hasNotifiedAutoAcceptCompatibleUndefined = true;
const CHOICE_ENABLE = $msg("TweakMismatchResolve.Action.EnableAutoAcceptCompatible");
const CHOICE_DISABLE = $msg("TweakMismatchResolve.Action.DisableAutoAcceptCompatible");
const CHOICES = [CHOICE_ENABLE, CHOICE_DISABLE] as const;
const message = $msg("TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined");
const ret = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
title: $msg("TweakMismatchResolve.Title.AutoAcceptCompatible"),
timeout: 0,
defaultAction: CHOICE_ENABLE,
});
if (ret !== CHOICE_ENABLE) {
return undefined;
}
await this.services.setting.applyPartial(
{
autoAcceptCompatibleTweak: true,
},
true
);
Logger("Auto-accept for compatible tweak mismatch has been enabled.");
// Keep the settings object stable: settings panes and an in-flight replication retry can
// retain this reference while the default is persisted.
this.settings.autoAcceptCompatibleTweak = true;
await this.services.setting.saveSettingData();
autoAcceptCompatibleTweak = true;
Logger("Automatic alignment of compatible chunk settings has been enabled.");
}
if (this.settings.autoAcceptCompatibleTweak !== true) return undefined;
if (autoAcceptCompatibleTweak !== true) return undefined;
return this._selectNewerTweakSide(current, preferred);
}
@@ -215,7 +197,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
} else if (rebuildRecommended) {
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [true, true]]);
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
} else {
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
@@ -255,9 +237,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
return "CHECKAGAIN";
}
if (conf) {
this.settings = { ...this.settings, ...conf };
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
// ReplicationService retains the current settings object while it performs the immediate
// CHECKAGAIN retry. Update that object in place so the retry observes the accepted values.
Object.assign(this.settings, extractObject(TweakValuesTemplate, conf));
await this.services.setting.saveSettingData();
if (!rebuildRequired) {
// The failed replication has settled before mismatch resolution runs. Reinitialise the
// chunk-generation managers now so hash and splitter changes take effect before retrying.
await this.localDatabase.managers.reinitialise();
}
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
if (rebuildRequired) {
await this.core.rebuilder.$fetchLocal();
}
@@ -1,9 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type RemoteDBSettings, type TweakValues } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
type RemoteDBSettings,
type TweakValues,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
const askSelectStringDialogue = vi.fn(async () => undefined);
const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise<string | undefined> => undefined);
const applyPartial = vi.fn(async (_partial: Record<string, unknown>): Promise<void> => undefined);
const reinitialise = vi.fn(async () => undefined);
const core = {
_services: {
API: {
@@ -15,6 +22,12 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
},
setting: {
saveSettingData: vi.fn(async () => undefined),
applyPartial,
},
},
localDatabase: {
managers: {
reinitialise,
},
},
settings: {
@@ -26,6 +39,9 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
askSelectStringDialogue,
},
} as any;
applyPartial.mockImplementation(async (partial: Record<string, unknown>) => {
core.settings = { ...core.settings, ...partial };
});
Object.defineProperty(core, "services", {
get() {
@@ -34,10 +50,35 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
});
const module = new ModuleResolvingMismatchedTweaks(core);
return { module, core, askSelectStringDialogue };
return { module, core, askSelectStringDialogue, applyPartial, reinitialise };
}
describe("ModuleResolvingMismatchedTweaks", () => {
it("should enable and auto-accept compatible mismatches when the preference is undefined", async () => {
const { module, core, askSelectStringDialogue, applyPartial } = createModule({
autoAcceptCompatibleTweak: undefined,
hashAlg: "xxhash64",
tweakModified: 100,
});
const initialSettings = core.settings;
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
tweakModified: 200,
} as Partial<TweakValues>;
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
expect(conf).toEqual(preferred);
expect(rebuild).toBe(false);
expect(core.settings).toBe(initialSettings);
expect(core.settings.autoAcceptCompatibleTweak).toBe(true);
expect(core._services.setting.saveSettingData).toHaveBeenCalledTimes(1);
expect(applyPartial).not.toHaveBeenCalled();
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
@@ -58,6 +99,28 @@ describe("ModuleResolvingMismatchedTweaks", () => {
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it.each([
{ label: "neither side has a recorded time", currentModified: 0, preferredModified: 0 },
{ label: "the recorded times are equal", currentModified: 200, preferredModified: 200 },
])("should use the remote compatible value when $label", async ({ currentModified, preferredModified }) => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash64",
tweakModified: currentModified,
});
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
tweakModified: preferredModified,
} as Partial<TweakValues>;
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
expect(conf).toEqual(preferred);
expect(rebuild).toBe(false);
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it("should fallback to manual confirmation when mismatches are mixed on connect check", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
@@ -80,6 +143,24 @@ describe("ModuleResolvingMismatchedTweaks", () => {
expect(askSelectStringDialogue).toHaveBeenCalledTimes(1);
});
it("should fetch after applying a compatible remote setting when the user selects the rebuild option", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: false,
hashAlg: "xxhash64",
});
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device, and fetch again");
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
} as TweakValues;
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
expect(conf).toEqual(preferred);
expect(rebuild).toBe(true);
});
it("should auto-accept compatible mismatches on remote-config check using newer local tweakModified", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
@@ -105,4 +186,42 @@ describe("ModuleResolvingMismatchedTweaks", () => {
expect(result).toEqual({ result: false, requireFetch: false });
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it("should apply remote compatible settings in place and reinitialise managers before retrying", async () => {
const { module, core, reinitialise } = createModule({
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash64",
tweakModified: 100,
});
const initialSettings = core.settings;
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
tweakModified: 200,
} as TweakValues;
const calls: string[] = [];
core._services.tweakValue = {
checkAndAskResolvingMismatched: vi.fn(async () => [preferred, false]),
};
core._services.setting.saveSettingData = vi.fn(async () => {
calls.push("save");
});
core.replicator = {
tweakSettingsMismatched: true,
preferredTweakValue: preferred,
setPreferredRemoteTweakSettings: vi.fn(async () => {
calls.push("set-preferred");
}),
};
reinitialise.mockImplementation(async () => {
calls.push("reinitialise");
});
const result = await module._askResolvingMismatchedTweaks();
expect(result).toBe("CHECKAGAIN");
expect(core.settings).toBe(initialSettings);
expect(core.settings.hashAlg).toBe("xxhash32");
expect(calls).toEqual(["save", "reinitialise", "set-preferred"]);
});
});
@@ -206,7 +206,8 @@ export class LiveSyncSetting extends Setting {
const setValue = wrapMemo((value: boolean) => {
toggle.setValue(opt?.invert ? !value : value);
});
this.invalidateValue = () => setValue(LiveSyncSetting.env.editingSettings[key] ?? false);
this.invalidateValue = () =>
setValue(LiveSyncSetting.env.editingSettings[key] ?? opt?.defaultToggleValue ?? false);
this.invalidateValue();
toggle.onChange(async (value) => {
@@ -35,7 +35,9 @@ export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
clampMin: 10,
onUpdate: this.onlyOnCouchDB,
});
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak");
new Setting(paneEl)
.setClass("wizardHidden")
.autoWireToggle("autoAcceptCompatibleTweak", { defaultToggleValue: true });
// new Setting(paneEl)
// .setClass("wizardHidden")
// .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB })
@@ -29,6 +29,10 @@ import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svel
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import type {
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes.ts";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
@@ -216,7 +220,13 @@ export function paneRemoteConfig(
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
}
const couchConf = await dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, baseSettings);
const couchConf = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: baseSettings,
mode: "settings",
});
if (couchConf === "cancelled" || typeof couchConf !== "object") {
return false;
}
@@ -75,6 +75,7 @@ export type AutoWireOption = {
holdValue?: boolean;
isPassword?: boolean;
invert?: boolean;
defaultToggleValue?: boolean;
onUpdate?: OnUpdateFunc;
obsolete?: boolean;
};
+11 -3
View File
@@ -1,6 +1,5 @@
import {
type BucketSyncSetting,
type CouchDBConnection,
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
@@ -34,6 +33,7 @@ import type {
ScanQRCodeResultType,
SetupRemoteBucketResultType,
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType,
SetupRemoteP2PResultType,
SetupRemoteResultType,
@@ -171,8 +171,16 @@ export class SetupManager extends AbstractModule {
): Promise<boolean> {
const couchConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
CouchDBConnection
>(SetupRemoteCouchDB, currentSetting);
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: currentSetting,
mode:
userMode === UserMode.NewUser
? "create-or-connect"
: userMode === UserMode.ExistingUser
? "connect-existing"
: "settings",
});
if (couchConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
+35 -2
View File
@@ -147,7 +147,7 @@ describe("SetupManager", () => {
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
});
it("onUseSetupURI should normalise imported legacy remote settings before applying", async () => {
it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(createLegacyRemoteSetting())
@@ -162,7 +162,7 @@ describe("SetupManager", () => {
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
});
it("decodeQR should normalise imported legacy remote settings before applying", async () => {
it("compatibility: normalises imported flat remote settings from QR data before applying", async () => {
const { manager, setting, dialogManager } = createSetupManager();
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting());
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("compatible-existing-user");
@@ -348,6 +348,39 @@ describe("SetupManager", () => {
expect(activeProfile?.uri).toContain("sls+https://alice:secret@couch.example");
});
it.each([
[UserMode.NewUser, "create-or-connect"],
[UserMode.ExistingUser, "connect-existing"],
[UserMode.Update, "settings"],
] as const)(
"passes the %s CouchDB database policy to the manual setup dialogue",
async (userMode, expectedMode) => {
const { manager, setting, dialogManager } = createSetupManager();
const couchConf = {
couchDB_URI: "https://couch.example",
couchDB_USER: "alice",
couchDB_PASSWORD: "secret",
couchDB_DBNAME: "notes",
couchDB_CustomHeaders: "",
useJWT: false,
jwtAlgorithm: "",
jwtKey: "",
jwtKid: "",
jwtSub: "",
jwtExpDuration: 5,
useRequestAPI: false,
};
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(couchConf).mockResolvedValueOnce("cancelled");
await manager.onCouchDBManualSetup(userMode, setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(1, expect.anything(), {
settings: setting.currentSettings(),
mode: expectedMode,
});
}
);
it("adds and activates a manually configured Object Storage profile without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
@@ -1,6 +1,7 @@
<script lang="ts">
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
@@ -8,6 +9,7 @@
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { TYPE_NEW_USER, TYPE_EXISTING_USER, TYPE_CANCELLED, type IntroResultType } from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
type Props = {
setResult: (result: IntroResultType) => void;
@@ -30,6 +32,11 @@
<DialogHeader title="Welcome to Self-hosted LiveSync" />
<Guidance>We will now guide you through a few questions to simplify the synchronisation setup.</Guidance>
<InfoNote>
{translateMessage(
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI."
)}
</InfoNote>
<Instruction>
<Question>First, please select the option that best describes your current situation.</Question>
<Options>
@@ -5,32 +5,66 @@
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import { TYPE_CANCELLED, TYPE_APPLY, type OutroExistingUserResultType } from "./setupDialogTypes";
type Props = {
setResult: (result: OutroExistingUserResultType) => void;
getInitialData?: () => { isP2P?: boolean } | undefined;
};
const { setResult }: Props = $props();
const { setResult, getInitialData }: Props = $props();
const isP2P = $derived(getInitialData?.()?.isP2P === true);
</script>
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" />
<Guidance>
<p>
The connection to the server has been configured successfully. As the next step, <strong
>the latest synchronisation data will be downloaded from the server to this device.</strong
>
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised
files in this vault, conflicts may occur with the server data.
</p>
</Guidance>
<Instruction>
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question>
</Instruction>
<UserDecisions>
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{#if isP2P}
<DialogHeader title={translateMessage("Setup Complete: Preparing to Fetch from Another Device")} />
<Guidance>
<p>
{translateMessage(
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device."
)}
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
{translateMessage(
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data."
)}
</p>
</Guidance>
<Instruction>
<Question>
{translateMessage("Restart this device, then choose the source device when P2P Rebuild opens.")}
</Question>
</Instruction>
<UserDecisions>
<Decision
title={translateMessage("Restart and Select Source Device")}
important={true}
commit={() => setResult(TYPE_APPLY)}
/>
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{:else}
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" />
<Guidance>
<p>
The connection to the server has been configured successfully. As the next step, <strong
>the latest synchronisation data will be downloaded from the server to this device.</strong
>
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised
files in this vault, conflicts may occur with the server data.
</p>
</Guidance>
<Instruction>
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question>
</Instruction>
<UserDecisions>
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{/if}
@@ -7,10 +7,14 @@
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB";
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
import { $msg as translateMessage } from "@/common/translation";
import { getDialogContext } from "@/modules/services/LiveSyncUI/svelteDialog";
import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation";
type Props = {
trialRemoteSetting: ObsidianLiveSyncSettings;
};
const { trialRemoteSetting }: Props = $props();
const context = getDialogContext();
let detectedIssues = $state<ConfigCheckResult[]>([]);
async function testAndFixSettings() {
detectedIssues = [];
@@ -33,14 +37,23 @@
}
let processing = $state(false);
async function fixIssue(issue: ResultError<unknown>) {
const confirmation = getCouchDBServerFixConfirmation(issue.settingKey, issue.expectedValue);
const confirmed = await context.services.confirm.askYesNoDialog(confirmation.message, {
title: confirmation.title,
defaultOption: "No",
});
if (confirmed !== "yes") {
return;
}
try {
processing = true;
await issue.fix();
} catch (e) {
Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-fix");
} finally {
await testAndFixSettings();
processing = false;
}
await testAndFixSettings();
processing = false;
}
const errorIssueCount = $derived.by(() => {
return detectedIssues.filter((issue) => isErrorResult(issue)).length;
@@ -64,7 +77,7 @@
</div>
{/snippet}
<UserDecisions>
<Decision title="Detect and Fix CouchDB Issues" important={true} commit={testAndFixSettings} />
<Decision title={translateMessage("Check server requirements")} important={true} commit={testAndFixSettings} />
</UserDecisions>
<div class="check-results">
<details open={!isAllSuccess}>
@@ -7,6 +7,7 @@
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import {
TYPE_USE_SETUP_URI,
TYPE_SCAN_QR_CODE,
@@ -24,7 +25,7 @@
if (userType === TYPE_USE_SETUP_URI) {
return "Proceed with Setup URI";
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
return "I know my server details, let me enter them";
return translateMessage("Ui.SetupWizard.SelectExisting.ProceedManual");
} else if (userType === TYPE_SCAN_QR_CODE) {
return "Scan the QR code displayed on an active device using this device's camera.";
} else {
@@ -49,10 +50,10 @@
</Option>
<Option
selectedValue={TYPE_CONFIGURE_MANUALLY}
title="Enter the server information manually"
title={translateMessage("Ui.SetupWizard.SelectExisting.ManualOption")}
bind:value={userType}
>
Configure the same server information as your other devices again, manually, very advanced users only.
{translateMessage("Ui.SetupWizard.SelectExisting.ManualOptionDesc")}
</Option>
</Options>
</Instruction>
@@ -7,6 +7,7 @@
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import {
TYPE_USE_SETUP_URI,
TYPE_CONFIGURE_MANUALLY,
@@ -23,7 +24,7 @@
if (userType === TYPE_USE_SETUP_URI) {
return "Proceed with Setup URI";
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
return "I know my server details, let me enter them";
return translateMessage("Ui.SetupWizard.SelectNew.ProceedManual");
} else {
return "Please select an option to proceed";
}
@@ -34,22 +35,22 @@
</script>
<DialogHeader title="Connection Method" />
<Guidance>We will now proceed with the server configuration.</Guidance>
<Guidance>{translateMessage("Ui.SetupWizard.SelectNew.Guidance")}</Guidance>
<Instruction>
<Question>How would you like to configure the connection to your server?</Question>
<Question>{translateMessage("Ui.SetupWizard.SelectNew.Question")}</Question>
<Options>
<Option selectedValue={TYPE_USE_SETUP_URI} title="Use a Setup URI (Recommended)" bind:value={userType}>
A Setup URI is a single string of text containing your server address and authentication details. Using a
URI, if one was generated by your server installation script, provides a simple and secure configuration.
{translateMessage("Ui.SetupWizard.SelectNew.SetupUriOptionDesc")}
</Option>
<Option
selectedValue={TYPE_CONFIGURE_MANUALLY}
title="Enter the server information manually"
title={translateMessage("Ui.SetupWizard.SelectNew.ManualOption")}
bind:value={userType}
>
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
You can also select this option if you intend to use <strong>P2P (Peer-to-Peer) synchronisation</strong>
instead of a CouchDB/S3 server — P2P requires no server setup at all.
{translateMessage("Ui.SetupWizard.SelectNew.ManualOptionDesc")}
{translateMessage(
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery."
)}
</Option>
</Options>
</Instruction>
@@ -6,6 +6,7 @@
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import {
TYPE_COUCHDB,
TYPE_BUCKET,
@@ -23,9 +24,9 @@
if (userType === TYPE_COUCHDB) {
return "Continue to CouchDB setup";
} else if (userType === TYPE_BUCKET) {
return "Continue to S3/MinIO/R2 setup";
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
} else if (userType === TYPE_P2P) {
return "Continue to Peer-to-Peer only setup";
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
} else {
return "Please select an option to proceed";
}
@@ -35,21 +36,29 @@
});
</script>
<DialogHeader title="Enter Server Information" />
<DialogHeader title={translateMessage("Ui.SetupWizard.SetupRemote.Title")} />
<Instruction>
<Question>Please select the type of server to which you are connecting.</Question>
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
<Options>
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
This is the most suitable synchronisation method for the design. All functions are available. You must have
set up a CouchDB instance.
</Option>
<Option selectedValue={TYPE_BUCKET} title="S3/MinIO/R2 Object Storage" bind:value={userType}>
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.
<Option
selectedValue={TYPE_BUCKET}
title={translateMessage("Ui.SetupWizard.SetupRemote.BucketOption")}
bind:value={userType}
>
{translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc")}
</Option>
<Option selectedValue={TYPE_P2P} title="Peer-to-Peer only" bind:value={userType}>
This feature enables direct synchronisation between devices. No server is required, but both devices must be
online at the same time for synchronisation to occur, and some features may be limited. Internet connection
is only required to signalling (detecting peers) and not for data transfer.
<Option
selectedValue={TYPE_P2P}
title={translateMessage("Ui.SetupWizard.SetupRemote.P2POption")}
bind:value={userType}
>
{translateMessage(
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
)}
</Option>
</Options>
</Instruction>
@@ -21,18 +21,27 @@
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { copyTo, pickCouchDBSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import PanelCouchDBCheck from "./PanelCouchDBCheck.svelte";
import { TYPE_CANCELLED, type SetupRemoteCouchDBResultType } from "./setupDialogTypes";
import {
TYPE_CANCELLED,
type CouchDBSetupMode,
type SetupRemoteCouchDBInitialData,
type SetupRemoteCouchDBResultType,
} from "./setupDialogTypes";
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
import { $msg as translateMessage } from "@/common/translation";
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<CouchDBConnection>({ ...default_setting });
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, CouchDBConnection>;
let setupMode = $state<CouchDBSetupMode>("settings");
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, SetupRemoteCouchDBInitialData>;
const { setResult, getInitialData }: Props = $props();
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, syncSetting);
setupMode = initialData.mode;
copyTo(initialData.settings, syncSetting);
}
}
});
@@ -69,11 +78,15 @@
return "Failed to create replicator instance.";
}
try {
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
if (result) {
const result = await probeCouchDBConnection(
replicator,
trialRemoteSetting,
setupMode === "create-or-connect"
);
if (result.ok) {
return "";
} else {
return "Failed to connect to the server. Please check your settings.";
return `Failed to connect to the server: ${result.reason}`;
}
} catch (e) {
return `Failed to connect to the server: ${e}`;
@@ -122,7 +135,7 @@
});
const canProceed = $derived.by(() => {
return (
syncSetting.couchDB_URI.trim().length > 0 &&
isValidCouchDBServerURL(syncSetting.couchDB_URI.trim()) &&
syncSetting.couchDB_USER.trim().length > 0 &&
syncSetting.couchDB_PASSWORD.trim().length > 0 &&
syncSetting.couchDB_DBNAME.trim().length > 0 &&
@@ -132,6 +145,18 @@
const testSettings = $derived.by(() => {
return generateSetting();
});
const isURLInvalid = $derived.by(
() => syncSetting.couchDB_URI.trim() !== "" && !isValidCouchDBServerURL(syncSetting.couchDB_URI.trim())
);
const primaryActionTitle = $derived.by(() => {
if (setupMode === "create-or-connect") {
return translateMessage("Create or connect to database and continue");
}
if (setupMode === "connect-existing") {
return translateMessage("Connect to existing database and continue");
}
return translateMessage("Test connection and save");
});
</script>
<DialogHeader title="CouchDB Configuration" />
@@ -150,6 +175,7 @@
/>
</InputRow>
<InfoNote warning visible={isURIInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote>
<InfoNote warning visible={isURLInvalid}>{translateMessage("Enter a complete HTTP or HTTPS URL.")}</InfoNote>
<InputRow label="Username">
<input
type="text"
@@ -180,13 +206,11 @@
autocapitalize="off"
spellcheck="false"
required
pattern="^[a-z][a-z0-9_$()+/-]*$"
bind:value={syncSetting.couchDB_DBNAME}
/>
</InputRow>
<InfoNote>
You cannot use capital letters, spaces, or special characters in the database name. And not allowed to start with an
underscore (_).
{translateMessage("CouchDB validates the database name when you connect. The name must not be empty.")}
</InfoNote>
<InputRow label="Use Internal API">
<input type="checkbox" name="couchdb-use-internal-api" bind:checked={syncSetting.useRequestAPI} />
@@ -270,6 +294,11 @@
</InfoNote>
</ExtraItems>
<InfoNote warning>
{translateMessage(
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required."
)}
</InfoNote>
<PanelCouchDBCheck trialRemoteSetting={testSettings}></PanelCouchDBCheck>
<hr />
@@ -281,8 +310,19 @@
Checking connection... Please wait.
{:else}
<UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" commit={() => commit()} />
<Decision title={primaryActionTitle} important disabled={!canProceed} commit={() => checkAndCommit()} />
{#if setupMode === "settings"}
<InfoNote warning>
{translateMessage(
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
)}
</InfoNote>
<Decision
title={translateMessage("Save without connecting")}
disabled={!canProceed}
commit={() => commit()}
/>
{/if}
<Decision title="Cancel" commit={() => cancel()} />
</UserDecisions>
{/if}
@@ -33,6 +33,8 @@
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes";
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
import { $msg as translateMessage } from "@/common/translation";
import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
@@ -119,29 +121,15 @@
};
const replicator = new TrysteroReplicator(env);
try {
await replicator.setOnSetup();
await replicator.allowReconnection();
await replicator.open();
for (let i = 0; i < 10; i++) {
// await delay(1000);
await new Promise((resolve) => window.setTimeout(resolve, 1000));
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
if (replicator.knownAdvertisements.length > 0) {
break;
}
}
// context.holdingSettings = trialRemoteSetting;
if (replicator.knownAdvertisements.length === 0) {
return "Your settings seem correct, but no other peers were found.";
const result = await probeP2PSetupConnection(replicator);
if (!result.ok) {
return `Failed to connect to the signalling relay: ${result.reason}`;
}
return "";
} catch (e) {
return `Failed to connect to other peers: ${e}`;
} finally {
try {
replicator.close();
dummyPouch.destroy();
await replicator.close();
await dummyPouch.destroy();
} catch (e) {
Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-cleanup");
}
@@ -195,18 +183,31 @@
<InputRow label="Enabled">
<input type="checkbox" name="p2p-enabled" bind:checked={syncSetting.P2P_Enabled} />
</InputRow>
<InputRow label="Relay URL">
<InputRow label={translateMessage("Signalling relay URLs")}>
<input
type="text"
name="p2p-relay-url"
placeholder="Enter the Relay URL)"
placeholder="wss://relay.example.com"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_relays}
/>
<button class="button" onclick={() => setDefaultRelay()}>Use vrtmrz's relay</button>
<button class="button" onclick={() => setDefaultRelay()}>
{translateMessage("Use the project's public signalling relay")}
</button>
</InputRow>
<InfoNote>
{translateMessage("Peer discovery uses Nostr-compatible signalling relays.")}
{translateMessage(
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay."
)}
<a
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md"
target="_blank"
rel="noopener noreferrer">{translateMessage("Learn more about P2P connections")}</a
>.
</InfoNote>
<InputRow label="Group ID">
<input
type="text"
@@ -245,12 +246,13 @@
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in
launches.
</InfoNote>
<InputRow label="Auto Broadcast Changes">
<InputRow label={translateMessage("Announce changes automatically after connecting")}>
<input type="checkbox" name="p2p-auto-broadcast" bind:checked={syncSetting.P2P_AutoBroadcast} />
</InputRow>
<InfoNote>
If "Auto Broadcast Changes" is enabled, changes will be automatically broadcasted to connected peers without
requiring manual intervention. This requests peers to fetch this device's changes.
{translateMessage(
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection."
)}
</InfoNote>
<ExtraItems title="Advanced Settings">
<InfoNote>
@@ -258,10 +260,14 @@
connections. In most cases, you can leave these fields blank.
</InfoNote>
<InfoNote warning>
Using public TURN servers may have privacy implications, as your data will be relayed through third-party
servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN
server provider before using their services. Also your `network administrator` too. You should consider setting
up your own TURN server for your FQDN, if possible.
{translateMessage(
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust."
)}
<a
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md#signalling-relay-and-turn-server"
target="_blank"
rel="noopener noreferrer">{translateMessage("Learn more about signalling and TURN")}</a
>.
</InfoNote>
<InputRow label="TURN Server URLs (comma-separated)">
<textarea
@@ -0,0 +1,63 @@
import type {
ObsidianLiveSyncSettings,
RemoteDBSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
export type CouchDBConnectionProbeResult = { ok: true } | { ok: false; reason: string };
type CouchDBConnectionResult =
| string
| {
db: unknown;
info: unknown;
};
export interface CouchDBConnectionProbe {
isMobile(): boolean;
connectRemoteCouchDBWithSetting(
settings: RemoteDBSettings,
isMobile: boolean,
performSetup: boolean,
skipInfo: boolean
): CouchDBConnectionResult | Promise<CouchDBConnectionResult>;
}
export function isCouchDBConnectionProbe(value: unknown): value is CouchDBConnectionProbe {
return (
typeof value === "object" &&
value !== null &&
"isMobile" in value &&
typeof value.isMobile === "function" &&
"connectRemoteCouchDBWithSetting" in value &&
typeof value.connectRemoteCouchDBWithSetting === "function"
);
}
export async function probeCouchDBConnection(
replicator: unknown,
settings: ObsidianLiveSyncSettings,
createIfMissing: boolean
): Promise<CouchDBConnectionProbeResult> {
if (!isCouchDBConnectionProbe(replicator)) {
return { ok: false, reason: "The CouchDB connection probe is unavailable." };
}
const result = await replicator.connectRemoteCouchDBWithSetting(
settings,
replicator.isMobile(),
createIfMissing,
false
);
if (typeof result === "string") {
return { ok: false, reason: result };
}
return { ok: true };
}
export function isValidCouchDBServerURL(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
} catch {
return false;
}
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
const settings = {
couchDB_URI: "https://couch.example",
couchDB_DBNAME: "notes",
} as ObsidianLiveSyncSettings;
describe("CouchDB setup connection policy", () => {
it.each([
[false, "connect to an existing database"],
[true, "create or connect to a database"],
] as const)(
"%s can %s without changing the Commonlib connection contract",
async (createIfMissing, _description) => {
const connectRemoteCouchDBWithSetting = vi.fn(async () => ({
db: {},
info: { db_name: "notes" },
}));
const replicator = {
isMobile: vi.fn(() => false),
connectRemoteCouchDBWithSetting,
tryConnectRemote: vi.fn(),
};
await expect(probeCouchDBConnection(replicator, settings, createIfMissing)).resolves.toEqual({ ok: true });
expect(connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(settings, false, createIfMissing, false);
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
}
);
it("returns the connection error without saving or creating through another path", async () => {
const replicator = {
isMobile: vi.fn(() => true),
connectRemoteCouchDBWithSetting: vi.fn(() => "database does not exist"),
};
await expect(probeCouchDBConnection(replicator, settings, false)).resolves.toEqual({
ok: false,
reason: "database does not exist",
});
});
it.each([
["https://couch.example", true],
["http://127.0.0.1:5984", true],
["ftp://couch.example", false],
["couch.example", false],
["https://", false],
])("validates the saved server URL %s", (value, expected) => {
expect(isValidCouchDBServerURL(value)).toBe(expected);
});
});
@@ -0,0 +1,11 @@
import { $msg } from "@/common/translation";
export function getCouchDBServerFixConfirmation(settingKey: string, expectedValue: string) {
return {
title: $msg("Change CouchDB server setting"),
message: $msg("Change CouchDB server setting '${SETTING}' to '${VALUE}'?", {
SETTING: settingKey,
VALUE: expectedValue,
}),
};
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation";
describe("CouchDB server requirement fixes", () => {
it("identifies the exact server setting and value before a fix is applied", () => {
expect(getCouchDBServerFixConfirmation("chttpd/require_valid_user", "true")).toEqual({
title: "Change CouchDB server setting",
message: "Change CouchDB server setting 'chttpd/require_valid_user' to 'true'?",
});
});
});
@@ -0,0 +1,23 @@
export type P2PSetupConnectionProbeResult = { ok: true } | { ok: false; reason: string };
export interface P2PSetupConnectionProbe {
setOnSetup(): void | Promise<void>;
allowReconnection(): void | Promise<void>;
open(): Promise<void>;
}
export async function probeP2PSetupConnection(
replicator: P2PSetupConnectionProbe
): Promise<P2PSetupConnectionProbeResult> {
try {
await replicator.setOnSetup();
await replicator.allowReconnection();
await replicator.open();
return { ok: true };
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
describe("P2P setup connection probe", () => {
it("accepts an empty room after the signalling connection opens", async () => {
const replicator = {
knownAdvertisements: [],
setOnSetup: vi.fn(),
allowReconnection: vi.fn(),
open: vi.fn(async () => undefined),
};
await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({ ok: true });
expect(replicator.setOnSetup).toHaveBeenCalledOnce();
expect(replicator.allowReconnection).toHaveBeenCalledOnce();
expect(replicator.open).toHaveBeenCalledOnce();
});
it("reports a signalling connection failure", async () => {
const replicator = {
knownAdvertisements: [],
setOnSetup: vi.fn(),
allowReconnection: vi.fn(),
open: vi.fn(async () => {
throw new Error("relay unavailable");
}),
};
await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({
ok: false,
reason: "relay unavailable",
});
});
});
@@ -102,6 +102,11 @@ export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettin
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings";
export type SetupRemoteCouchDBInitialData = {
settings: CouchDBConnection;
mode: CouchDBSetupMode;
};
export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo;
@@ -12,7 +12,15 @@ import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
export type ResultMessage = { message: string; classes: string[] };
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
export type ResultOk<T> = { message: string; result: "ok"; value?: T };
export type ResultError<T> = { message: string; result: "error"; value: T; fixMessage: string; fix(): Promise<void> };
export type ResultError<T> = {
message: string;
result: "error";
value: T;
fixMessage: string;
settingKey: string;
expectedValue: string;
fix(): Promise<void>;
};
export type ConfigCheckResult<T = unknown, U = unknown> =
| ResultOk<T>
| ResultError<U>
@@ -79,8 +87,15 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
const addSuccess = <T>(msg: string, value?: T) => {
result.push({ message: msg, result: "ok", value });
};
const _addError = <T>(message: string, fixMessage: string, fix: () => Promise<void>, value?: T) => {
result.push({ message, result: "error", fixMessage, fix, value });
const _addError = <T>(
message: string,
fixMessage: string,
settingKey: string,
expectedValue: string,
fix: () => Promise<void>,
value?: T
) => {
result.push({ message, result: "error", fixMessage, settingKey, expectedValue, fix, value });
};
const addErrorMessage = (msg: string, classes: string[] = []) => {
result.push({ message: msg, result: "error", classes });
@@ -90,6 +105,8 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
_addError(
message,
fixMessage,
key,
expected,
async () => {
await updateRemoteSetting(editingSettings, key, expected);
},
+8 -2
View File
@@ -128,12 +128,18 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect");
// Commonlib owns the transport decision, while LiveSync owns the
// concrete Obsidian view which lets users revise that decision.
const presentedMessage =
opt.title === "P2P Connection Request"
? message.replace("Peer-to-Peer Replicator Pane", $msg("P2P Status pane"))
: message;
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
message: presentedMessage,
actions: buttons,
actionLayout: "vertical",
defaultAction: opt.defaultAction,
@@ -146,7 +152,7 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
return confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
message,
presentedMessage,
buttons,
opt.defaultAction,
opt.timeout
@@ -214,6 +214,31 @@ describe("ObsidianConfirm Fancy Kit adapter", () => {
);
});
it("refers P2P connection approvals to the current P2P Status pane", async () => {
const { confirm, plugin } = createConfirm();
const actions = ["Accept", "Ignore"] as const;
mocks.legacyWideConfirm.mockResolvedValueOnce("Ignore");
await confirm.askSelectStringDialogue(
"You can revoke your decision from the Peer-to-Peer Replicator Pane.",
actions,
{
title: "P2P Connection Request",
defaultAction: "Ignore",
timeout: 30,
}
);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
"P2P Connection Request",
"You can revoke your decision from the P2P Status pane.",
actions,
"Ignore",
30
);
});
it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => {
const { confirm, events } = createConfirm();
let observedSignal: AbortSignal | undefined;
+9 -9
View File
@@ -14,7 +14,7 @@ import { adjustSettingToRemoteIfNeeded, processVaultInitialisation } from "./red
export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files";
export const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer";
export const SIMPLE_FETCH_STAGE1_LEGACY = "Use the detailed flow";
export const SIMPLE_FETCH_STAGE1_DETAILED = "Use the detailed flow";
export const SIMPLE_FETCH_STAGE1_CANCEL = "Cancel";
export const SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE = "Keep local files even if not on remote";
@@ -27,8 +27,8 @@ export const STAGE2_ABORT = "Cancel all and reboot";
const SIMPLE_FETCH_MODE_KEY = "simple-fetch-mode";
function buildSimpleFetchResult(stage1: string, stage2?: string) {
if (stage1 === SIMPLE_FETCH_STAGE1_LEGACY) {
return { mode: "legacy", options: {} };
if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) {
return { mode: "detailed", options: {} };
}
if (stage1 === SIMPLE_FETCH_STAGE1_REMOTE_WINS && stage2) {
if (![SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL, SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE].includes(stage2)) {
@@ -93,14 +93,14 @@ export async function askSimpleFetchMode(
const msg = `We are about to retrieve the remote data.
Firstly, how shall we handle the data retrieved from this remote server?
Firstly, how shall we handle the data retrieved from this remote source?
- **${SIMPLE_FETCH_STAGE1_NEWER_WINS}**: Compares the modified time of files and takes the newer one.
If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time.
- **${SIMPLE_FETCH_STAGE1_REMOTE_WINS}**: Remote data is the source of truth.
If you are new to using Self-hosted LiveSync. This option may be easiest to understand and get started with.
It will overwrite all your local files with the remote data, so please make sure you have a backup if there is any important data in your vault.
- **${SIMPLE_FETCH_STAGE1_LEGACY}**: Opens the detailed setup wizard.
- **${SIMPLE_FETCH_STAGE1_DETAILED}**: Opens the detailed setup wizard.
If you want to have more control over the synchronisation process, or want to review the changes before applying, you can choose this option to use the detailed flow.
`;
const stage1 = await host.services.UI.confirm.confirmWithMessage(
@@ -109,7 +109,7 @@ Firstly, how shall we handle the data retrieved from this remote server?
[
SIMPLE_FETCH_STAGE1_NEWER_WINS,
SIMPLE_FETCH_STAGE1_REMOTE_WINS,
SIMPLE_FETCH_STAGE1_LEGACY,
SIMPLE_FETCH_STAGE1_DETAILED,
SIMPLE_FETCH_STAGE1_CANCEL,
],
SIMPLE_FETCH_STAGE1_NEWER_WINS,
@@ -118,7 +118,7 @@ Firstly, how shall we handle the data retrieved from this remote server?
if (!stage1 || stage1 === SIMPLE_FETCH_STAGE1_CANCEL) return "cancelled";
if (stage1 === SIMPLE_FETCH_STAGE1_LEGACY) {
if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) {
return buildSimpleFetchResult(stage1)!;
}
@@ -204,8 +204,8 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
host.services.appLifecycle.performRestart();
return false;
}
if (result.mode === "legacy") {
return undefined; // Let the legacy flow handle it.
if (result.mode === "detailed") {
return undefined; // Let the detailed setup flow handle it.
}
return await processVaultInitialisation(host, log, async () => {
+13 -10
View File
@@ -29,7 +29,7 @@ import {
synchroniseAllFilesBetweenDBandStorage,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import {
SIMPLE_FETCH_STAGE1_LEGACY,
SIMPLE_FETCH_STAGE1_DETAILED,
SIMPLE_FETCH_STAGE1_NEWER_WINS,
SIMPLE_FETCH_STAGE1_REMOTE_WINS,
SIMPLE_FETCH_STAGE2_NEWER_CLEANUP,
@@ -469,16 +469,19 @@ describe("Red Flag Feature", () => {
expect(result).toBe(true);
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalled();
expect(synchroniseAllFilesBetweenDBandStorage).toHaveBeenCalled();
const firstPrompt = host.mocks.ui.confirm.confirmWithMessage.mock.calls[0]?.[1];
expect(firstPrompt).toContain("data retrieved from this remote source");
expect(firstPrompt).not.toContain("remote server");
// We can't easily check performFullScan call here because it's imported,
// but we can verify rebuilder was called.
});
it("should restore legacy fetch flow when requested", async () => {
it("opens the detailed Fetch flow when requested", async () => {
const host = createHostMock();
const log = createLoggerMock();
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
vault: "identical",
backup: "backup_skipped",
@@ -662,11 +665,11 @@ describe("Red Flag Feature", () => {
await expect(askSimpleFetchMode(host as any)).resolves.toBe("cancelled");
});
it("should return legacy mode when selected", async () => {
it("selects the detailed Fetch flow", async () => {
const host = createHostMock();
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
await expect(askSimpleFetchMode(host as any)).resolves.toEqual({ mode: "legacy", options: {} });
await expect(askSimpleFetchMode(host as any)).resolves.toEqual({ mode: "detailed", options: {} });
});
it("should return remote-only with keep-local option", async () => {
@@ -815,12 +818,12 @@ describe("Red Flag Feature", () => {
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalled();
});
it("should return undefined when legacy mode is selected", async () => {
it("leaves the detailed Fetch flow to its existing handler", async () => {
const host = createHostMock();
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
const result = await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
@@ -1474,7 +1477,7 @@ describe("Red Flag Feature", () => {
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({});
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
const handler = createFetchAllFlagHandler(host as any, log);
@@ -1556,7 +1559,7 @@ describe("Red Flag Feature", () => {
} as any);
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ vault: "identical", extra: {} });
host.mocks.rebuilder.$fetchLocal.mockResolvedValueOnce();
const handler = createFetchAllFlagHandler(host as any, log);
+84 -35
View File
@@ -3,7 +3,6 @@ import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView";
import {
P2PServerStatusPaneView,
VIEW_TYPE_P2P_SERVER_STATUS,
@@ -11,6 +10,34 @@ import {
import type { LiveSyncCore } from "@/main";
import type { WorkspaceLeaf } from "@/deps";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
export const LEGACY_VIEW_TYPE_P2P = "p2p-replicator";
class LegacyP2PStatusPaneView extends P2PServerStatusPaneView {
override getViewType() {
return LEGACY_VIEW_TYPE_P2P;
}
}
export function hasP2PConfiguration(settings: Partial<ObsidianLiveSyncSettings>): boolean {
if (
settings.remoteType === REMOTE_P2P ||
settings.P2P_Enabled === true ||
(settings.P2P_roomID ?? "").trim() !== "" ||
(settings.P2P_passphrase ?? "").trim() !== ""
) {
return true;
}
return Object.values(settings.remoteConfigurations ?? {}).some((configuration) => {
try {
return ConnectionStringParser.parse(configuration.uri).type === "p2p";
} catch {
return false;
}
});
}
/**
* Obsidian-specific P2P views, commands, status collection, and ribbon wiring.
@@ -45,8 +72,7 @@ export function useP2PReplicatorUI(
icon: string,
title: string,
callback: () => void
) => { addClass?: (name: string) => unknown } | undefined;
getPlatform: () => string;
) => { addClass?: (name: string) => unknown; remove?: () => void } | undefined;
};
// const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any };
@@ -64,15 +90,12 @@ export function useP2PReplicatorUI(
storeP2PStatusLine,
};
// Register view, commands and ribbon if a view factory is provided
const viewType = VIEW_TYPE_P2P;
const factory = (leaf: WorkspaceLeaf) => {
return new P2PReplicatorPaneView(leaf, core, p2pParams);
};
const statusFactory = (leaf: WorkspaceLeaf) => {
return new P2PServerStatusPaneView(leaf, core, p2pParams);
};
const openPane = () => api.showWindow(viewType);
const legacyStatusFactory = (leaf: WorkspaceLeaf) => {
return new LegacyP2PStatusPaneView(leaf, core, p2pParams);
};
const openStatusPane = () => {
if (api.showWindowOnRight) {
return api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
@@ -88,20 +111,36 @@ export function useP2PReplicatorUI(
{ label: "replication" }
);
};
api.registerWindow(viewType, factory);
// Keep the retired view type registered only long enough to restore an
// existing workspace leaf with the current status UI. Layout-ready
// migration below rewrites it to the current type without opening a leaf.
api.registerWindow(LEGACY_VIEW_TYPE_P2P, legacyStatusFactory);
api.registerWindow(VIEW_TYPE_P2P_SERVER_STATUS, statusFactory);
let ribbonElement: { addClass?: (name: string) => unknown; remove?: () => void } | undefined;
const updateRibbon = (settings: Partial<ObsidianLiveSyncSettings>) => {
if (hasP2PConfiguration(settings)) {
if (ribbonElement) return;
ribbonElement = api.addRibbonIcon("waypoints", "P2P Status", () => {
void openStatusPane();
});
ribbonElement?.addClass?.("livesync-ribbon-p2p-server-status");
return;
}
ribbonElement?.remove?.();
ribbonElement = undefined;
};
// Settings are loaded after onInitialise. Reading them from the earlier
// phase aborts the plug-in lifecycle before the local database can open.
host.services.appLifecycle.onSettingLoaded.addHandler(() => {
updateRibbon(host.services.setting.currentSettings());
return Promise.resolve(true);
});
host.services.appLifecycle.onInitialise.addHandler(() => {
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => {
void openPane();
});
api.addCommand({
id: "open-p2p-replicator",
name: "P2P Sync : Open P2P Replicator (Old UI)",
callback: () => {
void openPane();
},
void openStatusPane();
});
api.addCommand({
@@ -147,27 +186,37 @@ export function useP2PReplicatorUI(
},
});
// api.addRibbonIcon("waypoints", "P2P Replicator", () => {
// void openPane();
// })?.addClass?.("livesync-ribbon-replicate-p2p");
api.addRibbonIcon("waypoints", "P2P Status", () => {
void openStatusPane();
})?.addClass?.("livesync-ribbon-p2p-server-status");
host.services.setting.onSettingSaved?.addHandler((settings) => {
updateRibbon(settings);
return Promise.resolve(true);
});
return Promise.resolve(true);
});
host.services.appLifecycle.onLayoutReady.addHandler(() => {
if (api.getPlatform() !== "obsidian") {
return Promise.resolve(true);
host.services.appLifecycle.onLayoutReady.addHandler(async () => {
const workspace = (
host.services.context as {
app?: {
workspace?: {
getLeavesOfType(type: string): WorkspaceLeaf[];
};
};
}
).app?.workspace;
if (!workspace) {
return 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);
const legacyLeaves = workspace.getLeavesOfType(LEGACY_VIEW_TYPE_P2P);
await Promise.all(
legacyLeaves.map((leaf) =>
leaf.setViewState({
type: VIEW_TYPE_P2P_SERVER_STATUS,
active: false,
})
)
);
return true;
});
return p2pParams;
}

Some files were not shown because too many files have changed in this diff Show More