mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-22 11:27:05 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e20386b8e2 | ||
|
|
f09b4c779e | ||
|
|
552d286392 | ||
|
|
22ae1b4a6e | ||
|
|
d1ae42a134 | ||
|
|
c7443ee728 | ||
|
|
f8ee3c8662 | ||
|
|
fbe868092a |
@@ -3,6 +3,8 @@
|
||||
A fully self-hosted CouchDB stack for the [obsidian-livesync](https://github.com/vrtmrz/obsidian-livesync) plugin.
|
||||
**No fly.io. No IBM Cloudant. No cloud accounts required for basic use.**
|
||||
|
||||
The optional [Coturn Compose starter](coturn/README.md) is a separate Linux-only service for P2P connectivity. It is not part of the CouchDB stack below.
|
||||
|
||||
> ✅ **Tested on Docker Desktop for Windows (Docker 29.2, Compose v5, WSL2 backend)** — full init, CORS, auth, and idempotent restart verified.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Public DNS name used as the TURN authentication realm.
|
||||
TURN_REALM=
|
||||
|
||||
# Public IPv4 address advertised by Coturn. If Coturn is behind NAT, forward
|
||||
# port 3478 (TCP and UDP) and the UDP relay range to this host.
|
||||
TURN_EXTERNAL_IP=
|
||||
|
||||
# Static long-term credential used by the LiveSync P2P profile. Use a simple
|
||||
# username without a colon and a high-entropy password, such as hexadecimal.
|
||||
TURN_USERNAME=
|
||||
TURN_PASSWORD=
|
||||
@@ -0,0 +1 @@
|
||||
/.env
|
||||
@@ -0,0 +1,81 @@
|
||||
# Coturn starter for LiveSync P2P
|
||||
|
||||
This optional Compose project runs a small, static-credential TURN service for LiveSync P2P. It uses the upstream `coturn/coturn` image directly; the repository does not maintain a separate Coturn Dockerfile.
|
||||
|
||||
The starter is deliberately limited to a Linux server with a public IPv4 address, TURN over UDP and TCP on port 3478, and UDP relay ports 49160–49200. It does not configure TLS, automatic certificate renewal, monitoring, quotas, or a managed credential endpoint.
|
||||
|
||||
## Before starting
|
||||
|
||||
Prepare:
|
||||
|
||||
- a Linux host with Docker Engine and the Compose plug-in;
|
||||
- a public IPv4 address, either on the host or forwarded to it;
|
||||
- a DNS name such as `turn.example.com`;
|
||||
- firewall and NAT rules for TCP and UDP port 3478, and UDP ports 49160–49200; and
|
||||
- enough bandwidth for every relayed P2P transfer.
|
||||
|
||||
Docker host networking is intentional. Coturn's upstream image recommends it because forwarding a large relay port range through Docker performs poorly. This starter therefore does not support Docker Desktop.
|
||||
|
||||
## Configure and start
|
||||
|
||||
From this directory:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
Set every value in `.env`. Generate a high-entropy password, for example:
|
||||
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Use a simple username without a colon. The static username and password are passed to Coturn as process arguments. They are visible to a local Docker administrator, who already controls the host. The `.env` file is excluded from Git and should remain private. The resolved output of `docker compose config` also contains the credential, so do not publish it.
|
||||
|
||||
Validate the resolved configuration, then start it:
|
||||
|
||||
```sh
|
||||
docker compose config
|
||||
docker compose up -d
|
||||
docker compose logs -f coturn
|
||||
```
|
||||
|
||||
The pinned image version is deliberate. Review the upstream Coturn release notes and update the pin explicitly rather than following `latest` automatically.
|
||||
|
||||
## Configure LiveSync
|
||||
|
||||
Enter both client paths in the P2P profile's TURN server list:
|
||||
|
||||
```text
|
||||
turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp
|
||||
```
|
||||
|
||||
Use `TURN_USERNAME` and `TURN_PASSWORD` as the TURN username and credential. Keep the normal `Automatic` ICE policy unless a future LiveSync release offers `TURN relay only` and the direct path needs to be excluded deliberately.
|
||||
|
||||
Both synchronising devices must be able to reach the server. Prove an explicit two-way `Replicate now` round trip on the intended networks before relying on the configuration.
|
||||
|
||||
The repository check can validate Compose expansion and local TURN allocations. It cannot prove the public firewall, NAT, carrier, or client path for a particular deployment. Validate both UDP and TCP from outside the server network.
|
||||
|
||||
## TLS and port 443 are advanced extensions
|
||||
|
||||
This starter does not recommend putting Coturn on port 443. Coturn cannot bind to the same IP address and TCP port as Caddy or another HTTPS entry point. In particular, it conflicts with the bundled CouchDB Caddy profile when both use the same host address.
|
||||
|
||||
If a restrictive network requires TURN over TLS on port 443, prefer a separate TURN host or a separate public IP address. An outbound tunnel used for CouchDB may also leave the host's public port 443 available for Coturn, provided that TURN uses a separate DNS record which resolves directly to that host. The tunnel itself does not carry TURN traffic.
|
||||
|
||||
A single public IP can technically be shared when one layer-4 TLS router owns port 443 and routes separate CouchDB and TURN hostnames by Server Name Indication (SNI). This adds another certificate and connection-routing boundary, depends on every intended TURN client supplying usable SNI, and is outside this starter. The standard Caddy image used by the bundled CouchDB profile does not provide that layer-4 routing.
|
||||
|
||||
TURN over TLS is not HTTP. An ordinary HTTP reverse proxy or Cloudflare Tunnel route is not a substitute for a TURN listener. Follow Coturn's upstream configuration guidance for `tls-listening-port`, `cert`, and `pkey`, arrange renewal and restart behaviour, and test the resulting `turns:` URL from outside the server network.
|
||||
|
||||
This starter disables Coturn's TLS listener and does not add a TURN-over-DTLS path, so it cannot appear to provide a secure TURN port without those operator-owned prerequisites. This does not disable the end-to-end DTLS encryption used by the WebRTC peer connection carried through TURN.
|
||||
|
||||
## Security and operations
|
||||
|
||||
- Rotate the static credential if the Setup URI, `.env` file, or credential is exposed.
|
||||
- Treat TURN as an internet-facing bandwidth service and monitor traffic and logs.
|
||||
- Add appropriate allocation and bandwidth quotas for a shared or public deployment.
|
||||
- Keep the private-address restrictions unless the TURN server is intentionally permitted to relay to those networks.
|
||||
- Keep independent Vault backups. TURN improves connection reachability; it does not store a backup of Vault data.
|
||||
- A TURN operator can observe endpoint addresses, timing, and traffic volume even though LiveSync content remains end-to-end encrypted.
|
||||
|
||||
The authoritative image and configuration references are the [Coturn Docker image guide](https://github.com/coturn/coturn/blob/master/docker/coturn/README.md) and [Coturn server documentation](https://github.com/coturn/coturn/blob/master/README.turnserver).
|
||||
@@ -0,0 +1,33 @@
|
||||
name: livesync-coturn
|
||||
|
||||
services:
|
||||
coturn:
|
||||
image: coturn/coturn:4.17.2
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# Compose has already interpolated the environment values. Invoke Coturn
|
||||
# directly so that the image's shell entrypoint does not expand them again.
|
||||
entrypoint:
|
||||
- turnserver
|
||||
command:
|
||||
- "-n"
|
||||
- "--log-file=stdout"
|
||||
- "--pidfile=/tmp/turnserver.pid"
|
||||
- "--listening-ip=0.0.0.0"
|
||||
- "--listening-port=3478"
|
||||
- "--min-port=49160"
|
||||
- "--max-port=49200"
|
||||
- "--external-ip=${TURN_EXTERNAL_IP:?Set TURN_EXTERNAL_IP in docker/coturn/.env}"
|
||||
- "--realm=${TURN_REALM:?Set TURN_REALM in docker/coturn/.env}"
|
||||
- "--user=${TURN_USERNAME:?Set TURN_USERNAME in docker/coturn/.env}:${TURN_PASSWORD:?Set TURN_PASSWORD in docker/coturn/.env}"
|
||||
- "--fingerprint"
|
||||
- "--lt-cred-mech"
|
||||
- "--stale-nonce=600"
|
||||
- "--unauthorized-ratelimit"
|
||||
- "--no-multicast-peers"
|
||||
- "--denied-peer-ip=10.0.0.0-10.255.255.255"
|
||||
- "--denied-peer-ip=100.64.0.0-100.127.255.255"
|
||||
- "--denied-peer-ip=169.254.0.0-169.254.255.255"
|
||||
- "--denied-peer-ip=172.16.0.0-172.31.255.255"
|
||||
- "--denied-peer-ip=192.168.0.0-192.168.255.255"
|
||||
- "--no-tls"
|
||||
@@ -0,0 +1,179 @@
|
||||
# Architectural Decision Record: P2P Transport Compatibility Controls
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — the user-facing controls will be introduced in stages. This record defines their boundaries before Commonlib settings and LiveSync interfaces are changed.
|
||||
|
||||
## Context
|
||||
|
||||
WebRTC connectivity depends on both devices, their browsers or embedded WebViews, NAT behaviour, carrier networks, VPNs, firewalls, and the path between them. A configuration which works on desktop Wi-Fi may fail on a mobile carrier, and moving the same devices through a mesh VPN may change the result without changing LiveSync.
|
||||
|
||||
The current P2P transport has several relevant properties:
|
||||
|
||||
- Trystero supplies the Nostr signalling strategy and the browser-owned WebRTC connection.
|
||||
- Commonlib limits one RPC wire payload to 15,360 bytes so it remains below Trystero's own action-chunk boundary.
|
||||
- Trystero supplies ordinary STUN servers and accepts an optional TURN server list with one username and credential.
|
||||
- ICE chooses a direct, server-reflexive, or TURN-relayed path automatically.
|
||||
- Commonlib can collect raw WebRTC statistics, but LiveSync does not yet present the selected candidate route in a concise diagnostic result.
|
||||
|
||||
Issue reports suggest that reducing the application payload may improve some mobile and constrained-network paths. A VPN such as Tailscale may also turn an unreliable route into a reliable one. These observations are consistent with NAT, path-MTU, fragmentation, or intermediary behaviour, but they do not prove one universal cause. Browser WebRTC implementations retain responsibility for SCTP, DTLS, ICE, packetisation, congestion control, and retransmission.
|
||||
|
||||
One low-level number cannot represent all of these concerns. Users need a small set of meaningful compatibility choices, while transport-internal controls which cannot be selected safely should remain implementation details.
|
||||
|
||||
## Decision
|
||||
|
||||
### Message-size presets
|
||||
|
||||
LiveSync will expose a `P2P message size` choice with four presets:
|
||||
|
||||
| Label | Maximum RPC wire payload | Intended use |
|
||||
| ----------------------- | -----------------------: | ------------------------------------------------------------------------------- |
|
||||
| `Standard` | 15,360 bytes | Existing default and best throughput. |
|
||||
| `Reduced` | 2,048 bytes | First compatibility step for an unreliable path. |
|
||||
| `Conservative` | 1,024 bytes | Stronger compatibility at greater framing and processing cost. |
|
||||
| `Maximum compatibility` | 800 bytes | Most conservative offered value for paths suspected of dropping larger packets. |
|
||||
|
||||
This value limits Commonlib RPC wire payloads before Trystero applies its own framing. It is not a LiveSync file Chunk size, an IP MTU, an SCTP fragment size, or a guarantee that lower layers will avoid fragmentation. The smaller presets reduce the amount presented to the transport at once and trade throughput for compatibility.
|
||||
|
||||
The bound applies to outgoing messages. A device which only lowers its own value still receives messages produced under the sender's value. The selected preset therefore belongs to the P2P profile and is included in an encrypted Setup URI for additional devices. A device which was configured earlier must be changed separately; the interface and troubleshooting guidance must state that the same conservative preset should be selected on every participating device. An absent key preserves the current 15,360-byte default.
|
||||
|
||||
Automatic negotiation or fallback between presets is deferred. A failed ordered data channel may require connection replacement before a smaller retry can prove anything, and changing transport parameters during a replication session would broaden the lifecycle contract considerably. The first implementation remains explicit, stable for one room lifetime, and inspectable.
|
||||
|
||||
### Connection path
|
||||
|
||||
LiveSync will expose a separate `Connection path` choice:
|
||||
|
||||
- `Automatic` retains normal ICE selection and is the default.
|
||||
- `TURN relay only` supplies `iceTransportPolicy: 'relay'` and prevents direct or server-reflexive candidates from being selected.
|
||||
|
||||
`TURN relay only` is enabled only when at least one syntactically valid `turn:` or `turns:` URL is configured. If the last valid TURN URL is removed while relay-only mode is selected, the dialogue restores `Automatic` and displays a concise explanation.
|
||||
|
||||
The route policy is an ordinary P2P profile property. It is retained in P2P connection strings and encrypted Setup URIs so that an imported compatibility profile has reproducible transport behaviour.
|
||||
|
||||
Multiple P2P profiles may intentionally use the same Group ID, passphrase, and relay list while selecting different compatibility settings. For example, one profile may use `Standard` and `Automatic`, while another uses `Maximum compatibility` and `TURN relay only`. Only the selected P2P profile joins the group, so each device can select the profile appropriate to its current network without a separate device-local override system.
|
||||
|
||||
No `Direct only` choice will be added. `Automatic` already prefers viable non-relayed candidates, and preventing TURN fallback would mainly create another failure mode.
|
||||
|
||||
### TURN server presentation
|
||||
|
||||
The first settings revision retains the existing storage and dialogue contract of one comma-separated TURN URL list, one username, and one credential. The connection-path choice is presented separately under `Connection compatibility`, while the TURN values remain under `Advanced Settings`.
|
||||
|
||||
A future interface may present the existing comma-separated value as ordered `turn:` and `turns:` URL rows without changing its serialised representation. A structured list of multiple credential profiles is deferred until a provider or self-hosted use case requires different credentials in the same P2P profile.
|
||||
|
||||
Static long-term credentials are the supported first stage. Managed providers may return short-lived credentials, but LiveSync must not store a provider API token or a Coturn shared authentication secret. A future managed-credential design needs a separately trusted HTTPS endpoint, expiry handling, refresh behaviour, failure reporting, and a clear Setup URI policy. It is not represented as another static password field.
|
||||
|
||||
### TURN allocation check and route diagnostics
|
||||
|
||||
A future `Test TURN server` action should create a disposable WebRTC check with `iceTransportPolicy: 'relay'`, request candidate gathering, and require at least one relay candidate. It must not read a Vault, join a LiveSync P2P room, or claim that document synchronisation has succeeded.
|
||||
|
||||
Where the browser exposes the evidence, the result should report:
|
||||
|
||||
- whether a relay candidate was gathered;
|
||||
- the TURN URL used for that candidate;
|
||||
- UDP, TCP, or TLS transport; and
|
||||
- a bounded failure or inconclusive result.
|
||||
|
||||
Ordinary P2P diagnostics should later summarise the selected candidate pair as direct, server-reflexive, or relayed, with its transport. Raw `getStats()` output remains supporting evidence rather than the primary interface.
|
||||
|
||||
### Placement and defaults
|
||||
|
||||
These controls belong inside `P2P Configuration` under a `Connection compatibility` section. They do not require the repository-wide Advanced, Power User, or Edge Case modes. P2P itself remains a supported opt-in feature.
|
||||
|
||||
Existing profiles retain the following defaults:
|
||||
|
||||
- `P2P message size`: `Standard`;
|
||||
- `Connection path`: `Automatic`; and
|
||||
- TURN credentials and URLs: unchanged.
|
||||
|
||||
Settings which replace a room continue to use the established P2P room and transport lifecycle. No new reconnect interval, handshake timeout, keepalive interval, trickle-ICE, candidate-pool, data-channel reliability, or backpressure setting is exposed.
|
||||
|
||||
## Self-hosted TURN example
|
||||
|
||||
The repository supplies an optional Coturn Compose example under `docker/coturn/`. It uses a versioned upstream `coturn/coturn` image rather than maintaining another LiveSync Dockerfile.
|
||||
|
||||
The example deliberately covers one small static-credential deployment:
|
||||
|
||||
- Linux host networking, which avoids Docker's large port-range forwarding cost;
|
||||
- TURN over UDP and TCP on port 3478;
|
||||
- a bounded UDP relay port range;
|
||||
- explicit long-term credentials;
|
||||
- an explicit public IPv4 address;
|
||||
- no TLS or DTLS in the starter configuration; and
|
||||
- restrictions which prevent relaying to common private IPv4 ranges.
|
||||
|
||||
The starter does not recommend `turns:` on port 443. It conflicts with an HTTPS entry point which already owns the same IP address and TCP port, including the bundled CouchDB Caddy profile. When a restrictive network requires this path, the preferred deployment uses a separate TURN host or public IP address.
|
||||
|
||||
An outbound tunnel used for CouchDB may leave the host's public port 443 available when TURN uses a separate DNS record which resolves directly to that host, but the tunnel itself cannot carry TURN traffic. A layer-4 TLS router can also own the shared port and select separate CouchDB and TURN backends by SNI. That alternative adds certificate and routing responsibilities, depends on the intended TURN clients supplying usable SNI, and is outside the supplied Compose example. The standard Caddy image used by the CouchDB profile does not provide that layer-4 routing.
|
||||
|
||||
TURN over TLS is not HTTP and must reach Coturn directly or through a compatible layer-4 proxy. Supporting it also adds private-key, renewal, privileged-port, and real-network verification responsibilities.
|
||||
|
||||
The Compose example is not a hosted service supplied by the project, an availability guarantee, or a substitute for firewall and abuse controls. Operators remain responsible for DNS, certificates when enabled, port forwarding, bandwidth, quotas, monitoring, software updates, credential rotation, and legal or provider constraints.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
TURN relays the already encrypted WebRTC connection. A TURN operator cannot read LiveSync's end-to-end encrypted Vault contents, but can observe endpoint addresses, timing, traffic volume, and service credentials.
|
||||
|
||||
Static credentials allow use of the operator's bandwidth until they are changed. They should be unique, high entropy, and limited to the intended deployment. Setup URIs are encrypted but still contain the P2P connection profile; they and their separate passphrases must be protected.
|
||||
|
||||
The Coturn Docker example uses environment interpolation for its static credential. A local Docker administrator can inspect the resulting container arguments and already has equivalent control of that host. The `.env` file remains untracked and should be readable only by the operator.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Expose a free-form byte field
|
||||
|
||||
Most users cannot infer a safe application payload from a network MTU, and an arbitrary value makes reports difficult to compare. Four named presets provide a bounded troubleshooting ladder.
|
||||
|
||||
### Apply the smaller payload only on the affected mobile device
|
||||
|
||||
The bound controls outgoing messages. This would leave larger messages from another sender unchanged and could fail during the direction which matters most for an initial fetch.
|
||||
|
||||
### Force TURN whenever a TURN server is configured
|
||||
|
||||
TURN is normally a fallback. Forcing it by default adds latency and bandwidth cost, and exposes more connection metadata even when a direct path works.
|
||||
|
||||
### Store the connection path in a device-local overlay
|
||||
|
||||
A second layer of device-specific profile overrides would make imported profile behaviour less reproducible and add another identity, mapping, and lifecycle contract. Separate named P2P profiles already let each device select an explicit transport policy, including when those profiles share the same Group ID and credentials.
|
||||
|
||||
### Automatically decrease the payload after a transfer failure
|
||||
|
||||
A transfer failure does not identify message size as the cause. Reusing a possibly wedged ordered channel would also make the retry inconclusive, while rebuilding the connection expands the lifecycle and user-notification design.
|
||||
|
||||
### Add browser-specific defaults
|
||||
|
||||
Safari, mobile Safari, Chrome, and Chrome on Android use different platform WebRTC implementations and lifecycle policies, but the failing route also depends on both networks and the remote peer. There is not enough stable evidence for a browser-name heuristic. Explicit cross-platform presets are more predictable.
|
||||
|
||||
### Build and maintain a LiveSync Coturn image
|
||||
|
||||
The upstream project already publishes a multi-platform image and documents its configuration contract. A local Dockerfile would duplicate security updates and release work without adding a LiveSync-specific server component.
|
||||
|
||||
### Bundle a shared port-443 router
|
||||
|
||||
A layer-4 TLS router could share one public address between distinct CouchDB and TURN hostnames by inspecting SNI. Bundling that topology would replace the current Caddy ownership of port 443, add another certificate and routing lifecycle, and rely on the intended TURN clients presenting usable SNI. A separate TURN host or public IP address keeps those failure and ownership boundaries explicit.
|
||||
|
||||
## Verification
|
||||
|
||||
The first implementation stage must add focused tests before production changes:
|
||||
|
||||
- settings-schema defaults for absent keys;
|
||||
- P2P connection-string and Setup URI round trips which retain both transport compatibility settings;
|
||||
- compatibility parsing and serialisation of the existing TURN URL string;
|
||||
- mapping each message-size preset to the exact Commonlib wire bound;
|
||||
- mapping relay-only mode to `iceTransportPolicy: 'relay'`;
|
||||
- rejection or automatic reset of relay-only mode without a valid TURN URL;
|
||||
- room replacement after either effective transport setting changes; and
|
||||
- the real Obsidian dialogue, profile, and connection-string round trip.
|
||||
|
||||
The future TURN allocation action requires its own focused tests using injected WebRTC boundaries, followed by a real transport test only for the device- or network-owned behaviour which deterministic injection cannot prove.
|
||||
|
||||
The Coturn example is checked independently with `docker compose config`. Runtime verification uses a real Coturn allocation from outside the server network and confirms both UDP and TCP client paths before it is presented as a known-working deployment.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users gain a small compatibility ladder without learning WebRTC internals.
|
||||
- A conservative message size affects throughput wherever it is selected or imported, and must be applied to every participating device to protect all transfer directions.
|
||||
- Profiles may intentionally share the same P2P group identity while offering different transport compatibility choices; the selected profile determines the active connection behaviour.
|
||||
- TURN can be forced for diagnosis or hostile networks without making relay use the global default.
|
||||
- Static and managed TURN credentials have separate, explicit responsibility boundaries.
|
||||
- Browser-specific heuristics, automatic payload fallback, and low-level transport knobs remain out of scope.
|
||||
- A reproducible self-hosted starter is available without making LiveSync responsible for a separate TURN image.
|
||||
+11
@@ -44,6 +44,17 @@ Both settings contain server addresses, but they are not interchangeable.
|
||||
|
||||
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.
|
||||
|
||||
## Connection compatibility profiles
|
||||
|
||||
`P2P Configuration` includes a separate `Connection compatibility` section. Its defaults preserve the existing transport behaviour:
|
||||
|
||||
- **P2P message size** defaults to **Standard**. **Reduced**, **Conservative**, and **Maximum compatibility** progressively limit outgoing P2P messages when a network path appears to drop larger WebRTC messages. This is not a Vault Chunk size or an IP MTU. Smaller values add framing and processing overhead.
|
||||
- **Connection path** defaults to **Automatic**, which lets WebRTC select a viable direct or TURN-relayed path. **TURN relay only** forces the encrypted connection through TURN and is available only when the profile contains at least one valid `turn:` or `turns:` URL.
|
||||
|
||||
The sending device controls its outgoing message size. Select the same conservative preset on every device which may send across the constrained path. Existing devices do not receive the choice retrospectively merely because another device changed it.
|
||||
|
||||
Both compatibility choices belong to the saved P2P profile and are retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID, passphrase, and relay list while selecting different compatibility choices. Only the selected P2P profile joins the group.
|
||||
|
||||
## P2P Status
|
||||
|
||||
The **P2P Status** pane is the current Obsidian interface for P2P connections.
|
||||
|
||||
@@ -465,6 +465,22 @@ Setting key: P2P_turnCredential
|
||||
|
||||
The password or credential for authentication with the TURN server.
|
||||
|
||||
#### P2P message size
|
||||
|
||||
Setting key: P2P_maxWirePayloadBytes
|
||||
|
||||
This profile setting limits each outgoing Commonlib RPC message before Trystero applies its own framing. It is not a Vault Chunk size, an IP MTU, or an SCTP fragment size. The available presets are **Standard** (15,360 bytes), **Reduced** (2,048 bytes), **Conservative** (1,024 bytes), and **Maximum compatibility** (800 bytes). Smaller values trade throughput for compatibility on paths which appear to drop larger WebRTC messages.
|
||||
|
||||
The sender controls the size of its outgoing messages. Select the same conservative preset on every device which may send across the constrained path. Existing profiles without this key use **Standard**. P2P connection strings and encrypted Setup URIs retain the selected preset.
|
||||
|
||||
#### Connection path
|
||||
|
||||
Setting key: P2P_connectionPath
|
||||
|
||||
**Automatic** lets WebRTC select a viable direct or TURN-relayed path and is the default. **TURN relay only** forces `iceTransportPolicy: 'relay'` and is available only when the profile contains at least one valid `turn:` or `turns:` URL. Removing the last valid TURN URL while relay-only mode is selected restores **Automatic** and displays a Notice.
|
||||
|
||||
This choice belongs to the P2P profile and is retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID and credentials with different compatibility choices; only the selected P2P profile is active.
|
||||
|
||||
## 4. Sync Settings
|
||||
|
||||
### 1. Synchronisation Preset
|
||||
|
||||
@@ -36,9 +36,20 @@ 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.
|
||||
4. Under `Connection compatibility`, select `TURN relay only` to test the configured TURN path without direct ICE candidates.
|
||||
|
||||
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.
|
||||
|
||||
For a small self-hosted deployment, the repository includes an optional [Coturn Compose starter](../../docker/coturn/README.md). It uses static credentials and does not include TLS or a managed credential service; review its network and security boundaries before exposing it.
|
||||
|
||||
## A connection opens but a transfer stalls
|
||||
|
||||
If peers can connect but a transfer repeatedly stalls on one network path, try the `P2P message size` presets under `Connection compatibility`. Start with `Reduced`, then try `Conservative` and `Maximum compatibility` only if needed.
|
||||
|
||||
The preset limits outgoing messages, so select the same value on every device which may send across the affected path. Smaller values add overhead and do not prove that packet fragmentation was the cause. Return to `Standard` when the path works reliably without the compatibility setting.
|
||||
|
||||
Compatibility choices are saved with the P2P profile. You may keep separate standard and compatibility profiles with the same Group ID and credentials, then select the profile appropriate to the current network.
|
||||
|
||||
## A connected peer does not receive later edits
|
||||
|
||||
An open signalling connection does not automatically move every change.
|
||||
|
||||
Generated
+4
-4
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.17",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.18",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -4771,9 +4771,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.17.tgz",
|
||||
"integrity": "sha512-5EGZOKOfoe8jUKwr7Yecyxi6SuHYtMPpWVygKTtkuC8Nf9ZayvYqd6R3VpPqtRoD/nD1KqxrbwJzUbS+qAJgHw==",
|
||||
"version": "0.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.18.tgz",
|
||||
"integrity": "sha512-CgqYGRFrbTFT/bhI/x2Tqws4V3Py94al/UCAx8J5CLhNHuQGQmuGtRAXmbZbo/3JyfVEuo7euITEm58nEWLoYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.17",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.18",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -32,6 +32,20 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"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.",
|
||||
"Connection compatibility": "Connection compatibility",
|
||||
"P2P message size": "P2P message size",
|
||||
Standard: "Standard",
|
||||
Reduced: "Reduced",
|
||||
Conservative: "Conservative",
|
||||
"Maximum compatibility": "Maximum compatibility",
|
||||
"Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required.":
|
||||
"Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required.",
|
||||
"Connection path": "Connection path",
|
||||
"TURN relay only": "TURN relay only",
|
||||
"TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings.":
|
||||
"TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings.",
|
||||
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.":
|
||||
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.",
|
||||
"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.":
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
TweakValuesTemplate,
|
||||
IncompatibleChanges,
|
||||
confName,
|
||||
configurationNames,
|
||||
statusDisplay,
|
||||
type TweakValues,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type RemoteDBSettings,
|
||||
@@ -15,11 +16,21 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { escapeMarkdownValue } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { $msg, translateIfAvailable } from "@/common/translation";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
|
||||
/**
|
||||
* Localised counterpart of Commonlib's `confName()`, which takes no translator.
|
||||
* Same shape: label plus status suffix, and an empty string for an unknown key.
|
||||
*/
|
||||
function localisedConfName(key: keyof ObsidianLiveSyncSettings): string {
|
||||
const info = configurationNames[key];
|
||||
if (!info) return "";
|
||||
return `${translateIfAvailable(info.name)}${statusDisplay(info.status)}`;
|
||||
}
|
||||
|
||||
function valueToString(value: string | number | boolean | object | undefined): string {
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false";
|
||||
@@ -158,7 +169,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
// table += `| ${confName(key)} | ${valueMine} | ${valuePreferred} | \n`;
|
||||
tableRows.push(
|
||||
$msg("TweakMismatchResolve.Table.Row", {
|
||||
name: confName(key),
|
||||
name: localisedConfName(key),
|
||||
self: valueToString(valueMine),
|
||||
remote: valueToString(valuePreferred),
|
||||
})
|
||||
@@ -342,7 +353,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
}
|
||||
tableRows.push(
|
||||
$msg("TweakMismatchResolve.Table.Row", {
|
||||
name: confName(key),
|
||||
name: localisedConfName(key),
|
||||
self: currentValueForDisplay,
|
||||
remote: remoteValueForDisplay,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type TweakValues,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
|
||||
import { setLang } from "@/common/translation";
|
||||
|
||||
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise<string | undefined> => undefined);
|
||||
@@ -255,3 +256,43 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
expect(calls).toEqual(["save", "reinitialise", "set-preferred"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks setting labels", () => {
|
||||
afterEach(() => setLang("def"));
|
||||
|
||||
async function renderMismatchTable() {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
encrypt: false,
|
||||
tweakModified: 100,
|
||||
});
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
encrypt: true,
|
||||
tweakModified: 200,
|
||||
} as Partial<TweakValues>;
|
||||
|
||||
await module._checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
|
||||
return String(askSelectStringDialogue.mock.calls[0]?.[0] ?? "");
|
||||
}
|
||||
|
||||
it("localises the setting names and keeps the status suffix", async () => {
|
||||
setLang("zh-tw");
|
||||
|
||||
const message = await renderMismatchTable();
|
||||
|
||||
expect(message).toContain("chunk ID 的雜湊演算法 (Experimental)");
|
||||
expect(message).toContain("端對端加密");
|
||||
expect(message).not.toContain("The Hash algorithm for chunk IDs");
|
||||
});
|
||||
|
||||
it("leaves English unchanged", async () => {
|
||||
const message = await renderMismatchTable();
|
||||
|
||||
expect(message).toContain("The Hash algorithm for chunk IDs (Experimental)");
|
||||
expect(message).toContain("End-to-End Encryption");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1,37 @@
|
||||
export * from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
export {
|
||||
AllSettingDefault,
|
||||
OnDialogSettingsDefault,
|
||||
SettingInformation,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
export type {
|
||||
AllSettings,
|
||||
AllSettingItemKey,
|
||||
AllStringItemKey,
|
||||
AllNumericItemKey,
|
||||
AllBooleanItemKey,
|
||||
OnDialogSettings,
|
||||
ValueOf,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
|
||||
import {
|
||||
getConfig as getCommonlibConfig,
|
||||
getConfName as getCommonlibConfName,
|
||||
type AllSettingItemKey,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { translateLiveSyncMessage } from "@/common/translation";
|
||||
|
||||
// Commonlib defaults `translate` to its English-only translator, so every caller which omits
|
||||
// it silently renders English regardless of `displayLanguage`. Default it to the LiveSync
|
||||
// catalogue instead, and re-export these wrappers under the original names so that no call
|
||||
// site has to remember the second argument.
|
||||
|
||||
/** `getConfig` with the LiveSync catalogue applied by default. */
|
||||
export function getConfig(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
|
||||
return getCommonlibConfig(key, translate);
|
||||
}
|
||||
|
||||
/** `getConfName` with the LiveSync catalogue applied by default. See `getConfig`. */
|
||||
export function getConfName(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
|
||||
return getCommonlibConfName(key, translate);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { setLang } from "@/common/translation";
|
||||
import { getConfig, getConfName } from "./settingConstants";
|
||||
|
||||
describe("setting manifest labels", () => {
|
||||
afterEach(() => setLang("def"));
|
||||
|
||||
it("renders names and descriptions in the selected display language", () => {
|
||||
setLang("zh-tw");
|
||||
|
||||
expect(getConfName("liveSync")).toBe("同步模式");
|
||||
expect(getConfig("couchDB_URI")).toMatchObject({ name: "伺服器 URI" });
|
||||
expect(getConfig("encrypt")).toMatchObject({
|
||||
name: "端對端加密",
|
||||
desc: "加密遠端資料庫中的內容。如果你使用外掛的同步功能,建議啟用此選項。",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves English untouched, so that the catalogue key and its English value stay interchangeable", () => {
|
||||
expect(getConfName("liveSync")).toBe("Sync Mode");
|
||||
expect(getConfig("encrypt")).toMatchObject({
|
||||
name: "End-to-End Encryption",
|
||||
desc: "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through labels which Commonlib owns but the catalogue does not carry", () => {
|
||||
setLang("zh-tw");
|
||||
|
||||
expect(getConfName("chunkSplitterVersion")).toBe("Chunk Splitter");
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,13 @@
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
P2PConnectionPaths,
|
||||
P2PMessageSizePresets,
|
||||
PREFERRED_BASE,
|
||||
RemoteTypes,
|
||||
hasValidP2PTurnServerUrl,
|
||||
normaliseP2PConnectionPath,
|
||||
normaliseP2PMaxWirePayloadBytes,
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PConnectionInfo,
|
||||
@@ -41,6 +46,8 @@
|
||||
|
||||
const context = getDialogContext();
|
||||
let error = $state("");
|
||||
let connectionPathResetNotice = $state(false);
|
||||
const hasValidTurnServer = $derived(hasValidP2PTurnServerUrl(syncSetting.P2P_turnServers ?? ""));
|
||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, P2PSyncSetting>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
@@ -67,6 +74,12 @@
|
||||
...P2P_DEFAULT_SETTINGS,
|
||||
...syncSetting,
|
||||
P2P_Enabled: true,
|
||||
P2P_maxWirePayloadBytes: normaliseP2PMaxWirePayloadBytes(syncSetting.P2P_maxWirePayloadBytes),
|
||||
P2P_connectionPath:
|
||||
normaliseP2PConnectionPath(syncSetting.P2P_connectionPath) === P2PConnectionPaths.Relay &&
|
||||
hasValidTurnServer
|
||||
? P2PConnectionPaths.Relay
|
||||
: P2PConnectionPaths.Automatic,
|
||||
};
|
||||
const trialSettings: P2PSyncSetting = {
|
||||
...connSetting,
|
||||
@@ -144,6 +157,13 @@
|
||||
syncSetting.P2P_relays = P2P_DEFAULT_SETTINGS.P2P_relays;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!hasValidTurnServer && syncSetting.P2P_connectionPath === P2PConnectionPaths.Relay) {
|
||||
syncSetting.P2P_connectionPath = P2PConnectionPaths.Automatic;
|
||||
connectionPathResetNotice = true;
|
||||
}
|
||||
});
|
||||
|
||||
let processing = $state(false);
|
||||
function generateDefaultGroupId() {
|
||||
syncSetting.P2P_roomID = generateP2PRoomId();
|
||||
@@ -264,6 +284,50 @@
|
||||
"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={translateMessage("Connection compatibility")}>
|
||||
<InputRow label={translateMessage("P2P message size")}>
|
||||
<select
|
||||
name="p2p-message-size"
|
||||
aria-label={translateMessage("P2P message size")}
|
||||
bind:value={syncSetting.P2P_maxWirePayloadBytes}
|
||||
>
|
||||
<option value={P2PMessageSizePresets.Standard}>{translateMessage("Standard")}</option>
|
||||
<option value={P2PMessageSizePresets.Reduced}>{translateMessage("Reduced")}</option>
|
||||
<option value={P2PMessageSizePresets.Conservative}>{translateMessage("Conservative")}</option>
|
||||
<option value={P2PMessageSizePresets.MaximumCompatibility}
|
||||
>{translateMessage("Maximum compatibility")}</option
|
||||
>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InputRow label={translateMessage("Connection path")}>
|
||||
<select
|
||||
name="p2p-connection-path"
|
||||
aria-label={translateMessage("Connection path")}
|
||||
bind:value={syncSetting.P2P_connectionPath}
|
||||
onchange={() => (connectionPathResetNotice = false)}
|
||||
>
|
||||
<option value={P2PConnectionPaths.Automatic}>{translateMessage("Automatic")}</option>
|
||||
<option value={P2PConnectionPaths.Relay} disabled={!hasValidTurnServer}
|
||||
>{translateMessage("TURN relay only")}</option
|
||||
>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote notice visible={connectionPathResetNotice}>
|
||||
{translateMessage(
|
||||
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic."
|
||||
)}
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
<ExtraItems title={translateMessage("Advanced Settings")}>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
|
||||
@@ -30,6 +30,7 @@ type DialogueRunState = {
|
||||
type SetupManagerHandle = {
|
||||
constructor: { name: string };
|
||||
onSelectServer?: (settings: unknown, remoteType: string) => Promise<unknown>;
|
||||
onConfirmApplySettingsFromWizard?: (settings: unknown, userMode: string, activate?: boolean) => Promise<boolean>;
|
||||
_askUseRemoteConfiguration?: (settings: unknown, preferred: unknown) => Promise<unknown>;
|
||||
_checkAndAskResolvingMismatchedTweaks?: (preferred: unknown) => Promise<unknown>;
|
||||
__addLog?: (message: string) => void;
|
||||
@@ -90,6 +91,44 @@ async function openRemoteSelectionDialogue(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
|
||||
if (
|
||||
typeof manager?.onSelectServer !== "function" ||
|
||||
typeof manager.onConfirmApplySettingsFromWizard !== "function"
|
||||
) {
|
||||
throw new Error("Could not find the P2P setup workflow");
|
||||
}
|
||||
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
|
||||
const state: DialogueRunState = { kind: "p2p-compatibility-settings", done: false };
|
||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
|
||||
state.expected = settings;
|
||||
return true;
|
||||
};
|
||||
void manager
|
||||
.onSelectServer(plugin.core.settings, "unknown")
|
||||
.then(
|
||||
(result) => {
|
||||
state.result = result;
|
||||
state.done = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
state.done = true;
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
manager.onConfirmApplySettingsFromWizard = originalConfirm;
|
||||
});
|
||||
}, dialogRunStateKey);
|
||||
});
|
||||
}
|
||||
|
||||
async function openSetupUriDialogue(): Promise<void> {
|
||||
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
return await page.evaluate(
|
||||
@@ -433,6 +472,158 @@ async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string
|
||||
return screenshotPath;
|
||||
}
|
||||
|
||||
async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
await openP2PRemoteSelectionDialogueForInspection();
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const remoteSelection = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
|
||||
});
|
||||
await remoteSelection
|
||||
.locator("label")
|
||||
.filter({ hasText: "Peer-to-Peer (P2P)" })
|
||||
.locator('input[type="radio"]')
|
||||
.first()
|
||||
.check({ timeout: uiTimeoutMs });
|
||||
await remoteSelection
|
||||
.getByRole("button", { name: "Continue to P2P setup", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const screenshotPath = await captureObsidianDialogue(
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"setup-p2p-compatibility-dialogue.png",
|
||||
async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
||||
});
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.locator('input[name="p2p-room-id"]').fill("e2e-p2p-compatibility");
|
||||
await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase");
|
||||
await modal.locator('input[name="p2p-device-peer-id"]').fill("e2e-device");
|
||||
|
||||
const compatibility = modal.locator("details").filter({
|
||||
has: page.locator("summary").filter({ hasText: "Connection compatibility" }),
|
||||
});
|
||||
await compatibility.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (!(await compatibility.evaluate((element) => (element as HTMLDetailsElement).open))) {
|
||||
await compatibility.locator("summary").click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
const parentSection = await compatibility.evaluate(
|
||||
(element) =>
|
||||
element.parentElement?.closest("details")?.querySelector(":scope > summary")?.textContent?.trim() ??
|
||||
""
|
||||
);
|
||||
if (parentSection === "Advanced Settings") {
|
||||
throw new Error("P2P connection compatibility controls were placed under Advanced Settings.");
|
||||
}
|
||||
|
||||
const messageSize = compatibility.getByLabel("P2P message size", { exact: true });
|
||||
await messageSize.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const messageSizeOptions = await messageSize.locator("option").evaluateAll((options) =>
|
||||
options.map((option) => ({
|
||||
label: option.textContent?.trim() ?? "",
|
||||
value: (option as HTMLOptionElement).value,
|
||||
}))
|
||||
);
|
||||
const expectedMessageSizeOptions = [
|
||||
{ label: "Standard", value: "15360" },
|
||||
{ label: "Reduced", value: "2048" },
|
||||
{ label: "Conservative", value: "1024" },
|
||||
{ label: "Maximum compatibility", value: "800" },
|
||||
];
|
||||
if (JSON.stringify(messageSizeOptions) !== JSON.stringify(expectedMessageSizeOptions)) {
|
||||
throw new Error(`Unexpected P2P message-size presets: ${JSON.stringify(messageSizeOptions)}`);
|
||||
}
|
||||
|
||||
const connectionPath = compatibility.getByLabel("Connection path", { exact: true });
|
||||
await connectionPath.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const relayOnly = connectionPath.locator('option[value="relay"]');
|
||||
if (!(await relayOnly.evaluate((option) => (option as HTMLOptionElement).disabled))) {
|
||||
throw new Error("TURN relay only was enabled without a valid TURN server URL.");
|
||||
}
|
||||
|
||||
const advanced = modal.locator("details").filter({
|
||||
has: page.locator("summary").filter({ hasText: "Advanced Settings" }),
|
||||
});
|
||||
if (!(await advanced.evaluate((element) => (element as HTMLDetailsElement).open))) {
|
||||
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
const turnServers = advanced.getByLabel("TURN Server URLs (comma-separated)", { exact: true });
|
||||
await turnServers.fill("turn:turn.example.com:3478");
|
||||
const relayOnlyElement = await relayOnly.elementHandle();
|
||||
const connectionPathElement = await connectionPath.elementHandle();
|
||||
if (relayOnlyElement === null || connectionPathElement === null) {
|
||||
throw new Error("P2P connection-path controls were removed whilst validating TURN settings.");
|
||||
}
|
||||
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await connectionPath.selectOption("relay");
|
||||
|
||||
await turnServers.fill("");
|
||||
await page.waitForFunction(
|
||||
(element) => (element as HTMLSelectElement).value === "automatic",
|
||||
connectionPathElement,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
const resetNotice = compatibility.locator(".sls-info-note-notice").filter({
|
||||
hasText:
|
||||
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.",
|
||||
});
|
||||
await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await resetNotice
|
||||
.locator(".sls-signal-word-notice")
|
||||
.filter({ hasText: "NOTICE" })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
|
||||
await turnServers.fill("turn:turn.example.com:3478");
|
||||
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await connectionPath.selectOption("relay");
|
||||
await messageSize.selectOption("800");
|
||||
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
|
||||
await compatibility.scrollIntoViewIfNeeded();
|
||||
}
|
||||
);
|
||||
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
||||
});
|
||||
await modal.getByRole("button", { name: "Continue anyway", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const state = await assertDialogueRunCompleted();
|
||||
if (state.result !== true || typeof state.expected !== "object" || state.expected === null) {
|
||||
throw new Error("The P2P compatibility settings were not passed to the setup workflow.");
|
||||
}
|
||||
const settings = state.expected as Record<string, unknown>;
|
||||
if (settings.P2P_maxWirePayloadBytes !== 800 || settings.P2P_connectionPath !== "relay") {
|
||||
throw new Error(
|
||||
`The P2P compatibility choices did not round-trip through the setup dialogue: ${JSON.stringify({
|
||||
P2P_maxWirePayloadBytes: settings.P2P_maxWirePayloadBytes,
|
||||
P2P_connectionPath: settings.P2P_connectionPath,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
const activeId = settings.P2P_ActiveRemoteConfigurationId;
|
||||
const configurations = settings.remoteConfigurations;
|
||||
if (typeof activeId !== "string" || typeof configurations !== "object" || configurations === null) {
|
||||
throw new Error("The P2P setup workflow did not create an active remote configuration.");
|
||||
}
|
||||
const active = (configurations as Record<string, { uri?: unknown }>)[activeId];
|
||||
if (typeof active?.uri !== "string") {
|
||||
throw new Error("The active P2P remote configuration did not contain a connection string.");
|
||||
}
|
||||
const query = new URL(active.uri).searchParams;
|
||||
if (query.get("maxWirePayloadBytes") !== "800" || query.get("connectionPath") !== "relay") {
|
||||
throw new Error(`The P2P connection string omitted compatibility settings: ${active.uri}`);
|
||||
}
|
||||
return screenshotPath;
|
||||
}
|
||||
|
||||
async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> {
|
||||
await openSetupUriDialogue();
|
||||
const screenshotPath = await captureObsidianDialogue(
|
||||
@@ -1066,6 +1257,10 @@ async function main(): Promise<void> {
|
||||
console.log(
|
||||
`CouchDB settings mode exposed explicit connection, unverified-save, and server-check actions. Screenshot: ${couchDBScreenshot}`
|
||||
);
|
||||
const p2pCompatibilityScreenshot = await verifyP2PCompatibilitySettingsDialogue();
|
||||
console.log(
|
||||
`P2P compatibility presets, TURN validation, and profile round-trip passed. Screenshot: ${p2pCompatibilityScreenshot}`
|
||||
);
|
||||
const setupUriScreenshot = await verifySetupUriDialogue("desktop");
|
||||
console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`);
|
||||
await verifyCompatibleAlignmentSettingDefault();
|
||||
|
||||
@@ -12,6 +12,14 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Peer-to-peer synchronisation
|
||||
|
||||
#### Improved
|
||||
|
||||
- P2P connection profiles now provide four **P2P message size** presets and a **Connection path** choice between **Automatic** and **TURN relay only**. Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages, while relay-only routing requires a configured TURN server. P2P connection strings and encrypted Setup URIs preserve both choices.
|
||||
- Thank you to @andrewschreiber for the detailed fragmentation diagnosis and working 800-byte threshold in vrtmrz/livesync-commonlib#97, which informed this compatibility design.
|
||||
- An optional self-hosted Coturn Compose starter is now available for P2P deployments that need a TURN relay. It uses a pinned upstream image and documents its network, credential, security, and verification boundaries.
|
||||
|
||||
## 1.0.16
|
||||
|
||||
19th August, 2026
|
||||
|
||||
Reference in New Issue
Block a user