Refine P2P and manual setup workflows

This commit is contained in:
vorotamoroz
2026-07-23 15:23:47 +00:00
parent cd35858e01
commit 1df034f12a
60 changed files with 1884 additions and 770 deletions
+8 -11
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. 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) ![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. - Compatible solutions are supported.
- Support end-to-end encryption. - 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). - 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). - Enable supported, opt-in WebRTC peer-to-peer synchronisation.
- This feature is still in the experimental stage. Please exercise caution when using it. - No central data-storage server is required, but a signalling relay is still required for peer discovery.
- WebRTC is a peer-to-peer synchronisation method, so **at least one device must be online to synchronise**. - At least one device containing the required data must be online while another device synchronises.
- Instead of keeping your device online as a stable peer, you can use two pseudo-peers: - Follow the [Peer-to-Peer Setup](docs/setup_p2p.md) after reviewing the [P2P communication model](docs/p2p.md).
- [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).
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. 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. 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). 2. Configure the clients by following [Object Storage Setup](docs/setup_object_storage.md).
3. Peer-to-Peer 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). 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 the additional-device Setup URI from that working device, and verifies synchronisation in both directions.
> [!TIP] > [!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). > 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. > 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). > 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 ## 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). - 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. - 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. - 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). - If you are having problems getting the plug-in working, see [Tips and Troubleshooting](docs/troubleshooting.md).
+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 as an additional transport without replacing their main remote.
![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 contains persistent conveniences:
- **Synchronise when this device connects** runs a finite 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 the additional-device URI from a working first device.
## 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 finite 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 - [Set up peer-to-peer synchronisation](setup_p2p.md) for the first device, additional-device Setup URI, approval, and two-way verification.
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." - [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.
## 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.
+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 is not a damaged-database recovery operation.
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. The corresponding first-device preparation rebuilds this device's 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.
+19 -13
View File
@@ -314,7 +314,7 @@ These settings are configured within the CouchDB Setup dialogue when adding (`
Setting key: couchDB_URI Setting key: couchDB_URI
The URI of the CouchDB server. 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 #### Username
@@ -332,14 +332,13 @@ The password used to authenticate with CouchDB.
Setting key: couchDB_DBNAME Setting key: couchDB_DBNAME
The name of the database. 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.
Note: The database name cannot contain capital letters, spaces, or special characters other than `_$()+/-`, and cannot start with an underscore (`_`).
#### Use Request API to avoid inevitable CORS problem #### Use Request API to avoid inevitable CORS problem
Setting key: useRequestAPI 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 #### Custom Headers
@@ -383,13 +382,20 @@ Setting key: jwtSub
The subject (`sub`) claim of the JWT, which should match your CouchDB username. 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 - First-device onboarding uses **Create or connect to database and continue**. It may create the database when it does not exist and the supplied account has permission.
- Additional-device onboarding 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 #### Apply Settings
@@ -401,11 +407,11 @@ Setting key: P2P_Enabled
Enable direct peer-to-peer synchronisation via WebRTC. Enable direct peer-to-peer synchronisation via WebRTC.
#### Relay URL #### Signalling relay URLs
Setting key: P2P_relays 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 #### 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. 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 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) #### TURN Server URLs (comma-separated)
Setting key: P2P_turnServers 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 #### TURN Username
+50 -33
View File
@@ -1,54 +1,44 @@
# Set up peer-to-peer synchronisation # 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: Before starting:
- back up both Vaults; - 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); - 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 - disable every other synchronisation service for these Vaults; and
- keep both devices awake and Obsidian open during the initial transfer. - 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 ## Set up the first device
1. Install and enable Self-hosted LiveSync in the intended Vault. 1. Install and enable Self-hosted LiveSync in the intended Vault.
2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice. 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. 3. Select `I am setting this up for the first time`, choose manual configuration, then select `Peer-to-Peer only`.
4. Paste the bootstrap Setup URI, enter its passphrase, and select `Test Settings and Continue`. 4. In `P2P Configuration`:
- enable P2P;
![P2P Setup URI on the first device](../images/p2p-setup/guide-p2p-setup-first-setup-uri.png) - select `Use the project's public signalling relay`, or enter your own signalling relay URLs;
- generate or enter a private Group ID;
5. Complete the first-device initialisation and final confirmation. This initialises the local LiveSync database; P2P has no central remote database to erase. - 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 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) ![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. 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. 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) ![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 ## Generate the second-device Setup URI
@@ -97,10 +87,10 @@ Keep the first device online. Store the new URI and its passphrase separately.
## Verify the return journey ## 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 and prove the next finite synchronisation explicitly:
1. Open the P2P Replicator pane on both devices. 1. Open `P2P Status` 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. 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`. 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 requesting first-device name and select `Accept` or `Accept Temporarily`.
@@ -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) ![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 ## 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. - Select `Refresh` after the other device joins.
- Reconnect the device which should be discovered last. - 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 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 bootstrap
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. - A privacy option that encrypts file paths and folder names on the remote server.
- plug-in - 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. - 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) - Signalling relay (P2P)
- A WebSocket-based coordination server used to establish direct WebRTC peer-to-peer connections. The default relay is provided by the plug-in author. - 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) - 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. - 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 - Reset Synchronisation on This Device
@@ -95,7 +95,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
- Sync Mode - 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). - 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) - 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) - 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. - 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) - WebRTC P2P (Peer-to-Peer)
+44 -11
View File
@@ -10,22 +10,55 @@ authors:
# Peer-to-Peer Synchronisation Tips # 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] > [!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. Check discovery before changing any Vault settings:
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.
## 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. WebRTC may fail when UDP hole punching is blocked by carrier-grade NAT, a firewall, a VPN policy, or an intermediary gateway.
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.
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 finite 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.
+126 -424
View File
@@ -1,454 +1,156 @@
# Tips and Troubleshooting # 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)
<!-- - --> 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.
## 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)
> [!IMPORTANT] > [!IMPORTANT]
> > 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.
> - Writing logs into the file will impact the performance.
> - Please make sure that you have erased all your confidential information
> before reporting issue.
### 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 ## CouchDB does not connect
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).
### 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 1. Confirm that the URL is complete and points to the intended server.
LiveSync splits the files into multiple chunks and transfers only newly created. 2. On mobile, use HTTPS with a certificate trusted by the operating system. Plain HTTP and self-signed certificates are not supported.
This behaviour enables us to less traffic. And, the chunks will be shared 3. Confirm the username, password, database name, and any custom headers.
between the files to reduce the total usage of the database. 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 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.
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.
To shrink the database size, `Overwrite Server Data with This Device's Files` is the only reliable and effective way. `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.
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.
### 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/). 1. the active remote profile and connection state;
Once the DevTools have been launched, everything operates the same as on a PC. 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.
- Choose `Update with mine` only when this device's setting is the intended shared value.
- Choose `Use configured` to accept the value already stored for the synchronisation group.
- `Dismiss` postpones the decision, but synchronisation remains paused until it 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 files` 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. 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) ![Errored](../images/devtools1.png)
3. Capture the `Headers`, `Payload`, and, `Response`. **Please be sure to keep 3. Record the status, timing, and a sanitised version of the headers, payload, and response.
important information confidential**. If the `Response` contains secrets, you 4. Remove the request path, remote address, authority, authorisation, cookies, credentials, and response secrets before sharing.
can omitted that. Note: Headers contains a some credentials. **The path of
the request URL, Remote Address, authority, and authorization must be
concealed.**\
![Concealed sample](../images/devtools2.png) ![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 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.
`specified time`. This is a timeout error from Cloudflare. From the reported
issue, it seems to be 100 seconds. (#627).
Therefore, this error returns from Cloudflare, not from the server. Hence, the ## Further technical context
result contains no CORS field. It means that this response makes the Obsidian
API fallback.
However, even if the Obsidian API fallback occurs, the request is still not See [Technical Information](tech_info.md) for database and synchronisation internals. Current behaviour belongs in this guide; older defect-specific instructions remain in the release histories.
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.)
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

@@ -7,6 +7,60 @@
* remove it from this map in the same change. * remove it from this map in the same change.
*/ */
export const liveSyncProvisionalEnglishMessages = { 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 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.":
"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.",
+17
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { englishMessageTranslator } from "@vrtmrz/livesync-commonlib/context"; import { englishMessageTranslator } from "@vrtmrz/livesync-commonlib/context";
import { $msg, setLang, translateLiveSyncMessage } from "@/common/translation"; import { $msg, setLang, translateLiveSyncMessage } from "@/common/translation";
import { SUPPORTED_I18N_LANGS } from "@/common/rosetta"; import { SUPPORTED_I18N_LANGS } from "@/common/rosetta";
import { liveSyncProvisionalEnglishMessages } from "@/common/messages/LiveSyncProvisionalMessages";
describe("LiveSync-owned translation catalogue", () => { describe("LiveSync-owned translation catalogue", () => {
afterEach(() => setLang("def")); afterEach(() => setLang("def"));
@@ -28,6 +29,7 @@ describe("LiveSync-owned translation catalogue", () => {
it("uses LiveSync-owned provisional English without extending Commonlib's message contract", () => { 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("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( expect(
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", { $msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: "3", COUNT: "3",
@@ -37,4 +39,19 @@ describe("LiveSync-owned translation catalogue", () => {
"This file has unresolved conflicts." "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",
});
});
}); });
@@ -163,4 +163,16 @@ describe("createOpenRebuildUI", () => {
expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true); expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true);
expect(replicator.clearOnSetup).toHaveBeenCalledOnce(); 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 type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { $msg as translateMessage } from "@/common/translation";
interface Props { interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator; getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
@@ -134,15 +135,16 @@
{#if showBroadcastToggle} {#if showBroadcastToggle}
<div class="status-item status-action broadcast-row"> <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"> <label class="broadcast-label" for="broadcast-toggle">
Live-push to peers {translateMessage("Announce changes")}
</label> </label>
<button <button
id="broadcast-toggle" id="broadcast-toggle"
class="broadcast-button {isBroadcasting ? 'is-on' : 'is-off'}" class="broadcast-button {isBroadcasting ? 'is-on' : 'is-off'}"
onclick={toggleBroadcast} 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'} {isBroadcasting ? '📡 On' : '📡 Off'}
</button> </button>
@@ -24,6 +24,13 @@
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { SetupManager } from "@/modules/features/SetupManager"; import { SetupManager } from "@/modules/features/SetupManager";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte"; 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 { interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator; getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
@@ -40,7 +47,6 @@
// Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time. // Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time.
const readCurrentSettings = () => core.services.setting.currentSettings(); const readCurrentSettings = () => core.services.setting.currentSettings();
const initialSettings = readCurrentSettings(); const initialSettings = readCurrentSettings();
let syncOnReplicationSetting = $state(initialSettings?.P2P_SyncOnReplication ?? "");
type P2PRemoteOption = { type P2PRemoteOption = {
id: string; id: string;
name: string; name: string;
@@ -50,21 +56,7 @@
let selectedP2PRemoteConfigurationId = $state(initialSettings?.P2P_ActiveRemoteConfigurationId ?? ""); let selectedP2PRemoteConfigurationId = $state(initialSettings?.P2P_ActiveRemoteConfigurationId ?? "");
let selectingP2PRemote = $state(false); let selectingP2PRemote = $state(false);
function addToList(item: string, list: string): string { let peerMenu: Menu | undefined;
const items = list
.split(",")
.map((e) => e.trim())
.filter((e) => e);
if (!items.includes(item)) items.push(item);
return items.join(",");
}
function removeFromList(item: string, list: string): string {
return list
.split(",")
.map((e) => e.trim())
.filter((e) => e && e !== item)
.join(",");
}
function markCommunicating(peerId: string) { function markCommunicating(peerId: string) {
const expiry = Date.now() + COMMUNICATION_HOLD_MS; const expiry = Date.now() + COMMUNICATION_HOLD_MS;
@@ -154,7 +146,6 @@
}); });
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => { const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions(); refreshP2PRemoteOptions();
}); });
const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => { const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
@@ -174,6 +165,7 @@
unsubscribeReplicatorProgress(); unsubscribeReplicatorProgress();
unsubscribeSettings(); unsubscribeSettings();
unsubscribeLayoutReady(); unsubscribeLayoutReady();
peerMenu?.hide();
}; };
}); });
@@ -206,8 +198,6 @@
const activated = activateP2PRemoteConfiguration(settings, id); const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings; return activated || settings;
}, true); }, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions(); refreshP2PRemoteOptions();
} finally { } finally {
selectingP2PRemote = false; selectingP2PRemote = false;
@@ -252,8 +242,6 @@
const activated = activateP2PRemoteConfiguration(settings, id); const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings; return activated || settings;
}, true); }, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions(); refreshP2PRemoteOptions();
} }
@@ -299,7 +287,6 @@
const activated = activateP2PRemoteConfiguration(settings, selectedId); const activated = activateP2PRemoteConfiguration(settings, selectedId);
return activated || settings; return activated || settings;
}, true); }, true);
syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
} }
async function makeDecision( async function makeDecision(
@@ -374,23 +361,49 @@
return isLiveCommunicating || isHeldCommunicating; return isLiveCommunicating || isHeldCommunicating;
} }
function isSyncTarget(peerName: string) { function isPersistedPeerSettingEnabled(setting: PersistedP2PPeerSetting, peerName: string) {
return syncOnReplicationSetting const settings = core.services.setting.currentSettings();
.split(",") return hasExactP2PPeer(settings[setting] ?? "", peerName);
.map((e) => e.trim())
.filter((e) => e)
.includes(peerName);
} }
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) { async function togglePersistedPeerSetting(
peer: P2PServerInfo["knownAdvertisements"][number],
setting: PersistedP2PPeerSetting
) {
if (!canEditP2PSettings()) { if (!canEditP2PSettings()) {
return; return;
} }
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? ""; const currentSettings = core.services.setting.currentSettings();
const newValue = isSyncTarget(peer.name) await updateSelectedP2PRemote(togglePersistedP2PPeer(currentSettings, setting, peer.name));
? removeFromList(peer.name, currentValue) }
: addToList(peer.name, currentValue);
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue }); 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> </script>
@@ -486,35 +499,34 @@
> >
Revoke Revoke
</button> </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>
<div class="decision-row watch-row"> <div class="decision-row watch-row">
<span class="decision-label">WATCH</span> <span class="decision-label">{translateMessage("Follow changes")}</span>
<button <button
class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}" class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}"
title={isWatching(peer.peerId) title={isWatching(peer.peerId)
? "Watching this peer \u2014 click to stop" ? translateMessage("Stop following changes from this device")
: "Watch this peer's changes"} : translateMessage("Follow changes from this device")}
aria-label={isWatching(peer.peerId) ? "Stop watching" : "Watch peer"} aria-label={isWatching(peer.peerId)
? translateMessage("Stop following changes from this device")
: translateMessage("Follow changes from this device")}
disabled={!canEditP2PSettings()} disabled={!canEditP2PSettings()}
onclick={() => toggleWatch(peer.peerId)} onclick={() => toggleWatch(peer.peerId)}
> >
{isWatching(peer.peerId) ? "🔔" : "🔕"} {isWatching(peer.peerId) ? "🔔" : "🔕"}
</button> </button>
</div> </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} {:else}
<div class="decision-status"> <div class="decision-status">
<span class="badge status-chip {getAcceptanceStatusClass(peer)}"> <span class="badge status-chip {getAcceptanceStatusClass(peer)}">
@@ -805,7 +817,7 @@
} }
.accepted-row { .accepted-row {
grid-template-columns: 1fr auto auto; grid-template-columns: 1fr auto auto auto;
} }
.decision-label { .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",
});
});
});
@@ -29,6 +29,10 @@ import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svel
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte"; import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte"; import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.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"; import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings { function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
@@ -216,7 +220,13 @@ export function paneRemoteConfig(
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P }; 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") { if (couchConf === "cancelled" || typeof couchConf !== "object") {
return false; return false;
} }
+11 -3
View File
@@ -1,6 +1,5 @@
import { import {
type BucketSyncSetting, type BucketSyncSetting,
type CouchDBConnection,
type EncryptionSettings, type EncryptionSettings,
type ObsidianLiveSyncSettings, type ObsidianLiveSyncSettings,
type P2PSyncSetting, type P2PSyncSetting,
@@ -34,6 +33,7 @@ import type {
ScanQRCodeResultType, ScanQRCodeResultType,
SetupRemoteBucketResultType, SetupRemoteBucketResultType,
SetupRemoteCouchDBResultType, SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType, SetupRemoteE2EEResultType,
SetupRemoteP2PResultType, SetupRemoteP2PResultType,
SetupRemoteResultType, SetupRemoteResultType,
@@ -171,8 +171,16 @@ export class SetupManager extends AbstractModule {
): Promise<boolean> { ): Promise<boolean> {
const couchConf = await this.dialogManager.openWithExplicitCancel< const couchConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType, SetupRemoteCouchDBResultType,
CouchDBConnection SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, currentSetting); >(SetupRemoteCouchDB, {
settings: currentSetting,
mode:
userMode === UserMode.NewUser
? "create-or-connect"
: userMode === UserMode.ExistingUser
? "connect-existing"
: "settings",
});
if (couchConf === "cancelled") { if (couchConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE); this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode); return await this.onOnboard(userMode);
@@ -348,6 +348,39 @@ describe("SetupManager", () => {
expect(activeProfile?.uri).toContain("sls+https://alice:secret@couch.example"); 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 () => { it("adds and activates a manually configured Object Storage profile without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager(); const { manager, setting, dialogManager } = createSetupManager();
setting.settings = { setting.settings = {
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte"; import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.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 Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte"; import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte"; import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
@@ -8,6 +9,7 @@
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte"; import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte"; import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { TYPE_NEW_USER, TYPE_EXISTING_USER, TYPE_CANCELLED, type IntroResultType } from "./setupDialogTypes"; import { TYPE_NEW_USER, TYPE_EXISTING_USER, TYPE_CANCELLED, type IntroResultType } from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
type Props = { type Props = {
setResult: (result: IntroResultType) => void; setResult: (result: IntroResultType) => void;
@@ -30,6 +32,11 @@
<DialogHeader title="Welcome to Self-hosted LiveSync" /> <DialogHeader title="Welcome to Self-hosted LiveSync" />
<Guidance>We will now guide you through a few questions to simplify the synchronisation setup.</Guidance> <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> <Instruction>
<Question>First, please select the option that best describes your current situation.</Question> <Question>First, please select the option that best describes your current situation.</Question>
<Options> <Options>
@@ -5,32 +5,66 @@
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte"; import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte"; import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.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"; import { TYPE_CANCELLED, TYPE_APPLY, type OutroExistingUserResultType } from "./setupDialogTypes";
type Props = { type Props = {
setResult: (result: OutroExistingUserResultType) => void; setResult: (result: OutroExistingUserResultType) => void;
getInitialData?: () => { isP2P?: boolean } | undefined;
}; };
const { setResult }: Props = $props(); const { setResult, getInitialData }: Props = $props();
const isP2P = $derived(getInitialData?.()?.isP2P === true);
</script> </script>
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" /> {#if isP2P}
<Guidance> <DialogHeader title={translateMessage("Setup Complete: Preparing to Fetch from Another Device")} />
<p> <Guidance>
The connection to the server has been configured successfully. As the next step, <strong <p>
>the latest synchronisation data will be downloaded from the server to this device.</strong {translateMessage(
> "The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device."
</p> )}
<p> </p>
<strong>PLEASE NOTE</strong> <p>
<br /> <strong>PLEASE NOTE</strong>
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised <br />
files in this vault, conflicts may occur with the server data. {translateMessage(
</p> "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."
</Guidance> )}
<Instruction> </p>
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question> </Guidance>
</Instruction> <Instruction>
<UserDecisions> <Question>
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} /> {translateMessage("Restart this device, then choose the source device when P2P Rebuild opens.")}
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} /> </Question>
</UserDecisions> </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 UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB"; import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB";
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger"; 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 = { type Props = {
trialRemoteSetting: ObsidianLiveSyncSettings; trialRemoteSetting: ObsidianLiveSyncSettings;
}; };
const { trialRemoteSetting }: Props = $props(); const { trialRemoteSetting }: Props = $props();
const context = getDialogContext();
let detectedIssues = $state<ConfigCheckResult[]>([]); let detectedIssues = $state<ConfigCheckResult[]>([]);
async function testAndFixSettings() { async function testAndFixSettings() {
detectedIssues = []; detectedIssues = [];
@@ -33,14 +37,23 @@
} }
let processing = $state(false); let processing = $state(false);
async function fixIssue(issue: ResultError<unknown>) { 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 { try {
processing = true; processing = true;
await issue.fix(); await issue.fix();
} catch (e) { } catch (e) {
Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-fix"); Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-fix");
} finally {
await testAndFixSettings();
processing = false;
} }
await testAndFixSettings();
processing = false;
} }
const errorIssueCount = $derived.by(() => { const errorIssueCount = $derived.by(() => {
return detectedIssues.filter((issue) => isErrorResult(issue)).length; return detectedIssues.filter((issue) => isErrorResult(issue)).length;
@@ -64,7 +77,7 @@
</div> </div>
{/snippet} {/snippet}
<UserDecisions> <UserDecisions>
<Decision title="Detect and Fix CouchDB Issues" important={true} commit={testAndFixSettings} /> <Decision title={translateMessage("Check server requirements")} important={true} commit={testAndFixSettings} />
</UserDecisions> </UserDecisions>
<div class="check-results"> <div class="check-results">
<details open={!isAllSuccess}> <details open={!isAllSuccess}>
@@ -7,6 +7,7 @@
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte"; import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte"; import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte"; import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import { import {
TYPE_USE_SETUP_URI, TYPE_USE_SETUP_URI,
TYPE_CONFIGURE_MANUALLY, TYPE_CONFIGURE_MANUALLY,
@@ -49,7 +50,10 @@
> >
This is an advanced option for users who do not have a URI or who wish to configure detailed settings. This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
You can also select this option if you intend to use <strong>P2P (Peer-to-Peer) synchronisation</strong> 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. instead of a CouchDB/S3 server.
{translateMessage(
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery."
)}
</Option> </Option>
</Options> </Options>
</Instruction> </Instruction>
@@ -6,6 +6,7 @@
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte"; import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte"; import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte"; import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import { import {
TYPE_COUCHDB, TYPE_COUCHDB,
TYPE_BUCKET, TYPE_BUCKET,
@@ -47,9 +48,9 @@
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage. Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.
</Option> </Option>
<Option selectedValue={TYPE_P2P} title="Peer-to-Peer only" bind:value={userType}> <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 {translateMessage(
online at the same time for synchronisation to occur, and some features may be limited. Internet connection "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."
is only required to signalling (detecting peers) and not for data transfer. )}
</Option> </Option>
</Options> </Options>
</Instruction> </Instruction>
@@ -21,18 +21,27 @@
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog"; import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { copyTo, pickCouchDBSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { copyTo, pickCouchDBSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import PanelCouchDBCheck from "./PanelCouchDBCheck.svelte"; 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); const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<CouchDBConnection>({ ...default_setting }); 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(); const { setResult, getInitialData }: Props = $props();
onMount(() => { onMount(() => {
if (getInitialData) { if (getInitialData) {
const initialData = getInitialData(); const initialData = getInitialData();
if (initialData) { if (initialData) {
copyTo(initialData, syncSetting); setupMode = initialData.mode;
copyTo(initialData.settings, syncSetting);
} }
} }
}); });
@@ -69,11 +78,15 @@
return "Failed to create replicator instance."; return "Failed to create replicator instance.";
} }
try { try {
const result = await replicator.tryConnectRemote(trialRemoteSetting, false); const result = await probeCouchDBConnection(
if (result) { replicator,
trialRemoteSetting,
setupMode === "create-or-connect"
);
if (result.ok) {
return ""; return "";
} else { } else {
return "Failed to connect to the server. Please check your settings."; return `Failed to connect to the server: ${result.reason}`;
} }
} catch (e) { } catch (e) {
return `Failed to connect to the server: ${e}`; return `Failed to connect to the server: ${e}`;
@@ -122,7 +135,7 @@
}); });
const canProceed = $derived.by(() => { const canProceed = $derived.by(() => {
return ( return (
syncSetting.couchDB_URI.trim().length > 0 && isValidCouchDBServerURL(syncSetting.couchDB_URI.trim()) &&
syncSetting.couchDB_USER.trim().length > 0 && syncSetting.couchDB_USER.trim().length > 0 &&
syncSetting.couchDB_PASSWORD.trim().length > 0 && syncSetting.couchDB_PASSWORD.trim().length > 0 &&
syncSetting.couchDB_DBNAME.trim().length > 0 && syncSetting.couchDB_DBNAME.trim().length > 0 &&
@@ -132,6 +145,18 @@
const testSettings = $derived.by(() => { const testSettings = $derived.by(() => {
return generateSetting(); 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> </script>
<DialogHeader title="CouchDB Configuration" /> <DialogHeader title="CouchDB Configuration" />
@@ -150,6 +175,7 @@
/> />
</InputRow> </InputRow>
<InfoNote warning visible={isURIInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote> <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"> <InputRow label="Username">
<input <input
type="text" type="text"
@@ -180,13 +206,11 @@
autocapitalize="off" autocapitalize="off"
spellcheck="false" spellcheck="false"
required required
pattern="^[a-z][a-z0-9_$()+/-]*$"
bind:value={syncSetting.couchDB_DBNAME} bind:value={syncSetting.couchDB_DBNAME}
/> />
</InputRow> </InputRow>
<InfoNote> <InfoNote>
You cannot use capital letters, spaces, or special characters in the database name. And not allowed to start with an {translateMessage("CouchDB validates the database name when you connect. The name must not be empty.")}
underscore (_).
</InfoNote> </InfoNote>
<InputRow label="Use Internal API"> <InputRow label="Use Internal API">
<input type="checkbox" name="couchdb-use-internal-api" bind:checked={syncSetting.useRequestAPI} /> <input type="checkbox" name="couchdb-use-internal-api" bind:checked={syncSetting.useRequestAPI} />
@@ -270,6 +294,11 @@
</InfoNote> </InfoNote>
</ExtraItems> </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> <PanelCouchDBCheck trialRemoteSetting={testSettings}></PanelCouchDBCheck>
<hr /> <hr />
@@ -281,8 +310,19 @@
Checking connection... Please wait. Checking connection... Please wait.
{:else} {:else}
<UserDecisions> <UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} /> <Decision title={primaryActionTitle} important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" commit={() => commit()} /> {#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()} /> <Decision title="Cancel" commit={() => cancel()} />
</UserDecisions> </UserDecisions>
{/if} {/if}
@@ -33,6 +33,8 @@
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte"; import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes"; import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes";
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger"; 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); const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting }); let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
@@ -119,29 +121,15 @@
}; };
const replicator = new TrysteroReplicator(env); const replicator = new TrysteroReplicator(env);
try { try {
await replicator.setOnSetup(); const result = await probeP2PSetupConnection(replicator);
await replicator.allowReconnection(); if (!result.ok) {
await replicator.open(); return `Failed to connect to the signalling relay: ${result.reason}`;
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.";
} }
return ""; return "";
} catch (e) {
return `Failed to connect to other peers: ${e}`;
} finally { } finally {
try { try {
replicator.close(); await replicator.close();
dummyPouch.destroy(); await dummyPouch.destroy();
} catch (e) { } catch (e) {
Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-cleanup"); Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-cleanup");
} }
@@ -195,18 +183,31 @@
<InputRow label="Enabled"> <InputRow label="Enabled">
<input type="checkbox" name="p2p-enabled" bind:checked={syncSetting.P2P_Enabled} /> <input type="checkbox" name="p2p-enabled" bind:checked={syncSetting.P2P_Enabled} />
</InputRow> </InputRow>
<InputRow label="Relay URL"> <InputRow label={translateMessage("Signalling relay URLs")}>
<input <input
type="text" type="text"
name="p2p-relay-url" name="p2p-relay-url"
placeholder="Enter the Relay URL)" placeholder="wss://relay.example.com"
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
spellcheck="false" spellcheck="false"
bind:value={syncSetting.P2P_relays} 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> </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"> <InputRow label="Group ID">
<input <input
type="text" type="text"
@@ -245,12 +246,13 @@
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in
launches. launches.
</InfoNote> </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} /> <input type="checkbox" name="p2p-auto-broadcast" bind:checked={syncSetting.P2P_AutoBroadcast} />
</InputRow> </InputRow>
<InfoNote> <InfoNote>
If "Auto Broadcast Changes" is enabled, changes will be automatically broadcasted to connected peers without {translateMessage(
requiring manual intervention. This requests peers to fetch this device's changes. "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> </InfoNote>
<ExtraItems title="Advanced Settings"> <ExtraItems title="Advanced Settings">
<InfoNote> <InfoNote>
@@ -258,10 +260,14 @@
connections. In most cases, you can leave these fields blank. connections. In most cases, you can leave these fields blank.
</InfoNote> </InfoNote>
<InfoNote warning> <InfoNote warning>
Using public TURN servers may have privacy implications, as your data will be relayed through third-party {translateMessage(
servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the 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."
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. <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> </InfoNote>
<InputRow label="TURN Server URLs (comma-separated)"> <InputRow label="TURN Server URLs (comma-separated)">
<textarea <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 SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection; 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; 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 ResultMessage = { message: string; classes: string[] };
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] }; export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
export type ResultOk<T> = { message: string; result: "ok"; value?: T }; 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> = export type ConfigCheckResult<T = unknown, U = unknown> =
| ResultOk<T> | ResultOk<T>
| ResultError<U> | ResultError<U>
@@ -79,8 +87,15 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
const addSuccess = <T>(msg: string, value?: T) => { const addSuccess = <T>(msg: string, value?: T) => {
result.push({ message: msg, result: "ok", value }); result.push({ message: msg, result: "ok", value });
}; };
const _addError = <T>(message: string, fixMessage: string, fix: () => Promise<void>, value?: T) => { const _addError = <T>(
result.push({ message, result: "error", fixMessage, fix, value }); 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[] = []) => { const addErrorMessage = (msg: string, classes: string[] = []) => {
result.push({ message: msg, result: "error", classes }); result.push({ message: msg, result: "error", classes });
@@ -90,6 +105,8 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
_addError( _addError(
message, message,
fixMessage, fixMessage,
key,
expected,
async () => { async () => {
await updateRemoteSetting(editingSettings, key, expected); 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 } opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> { ): Promise<T[number] | false> {
const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect"); 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)) { if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction( const result = await confirmAction(
this._app, this._app,
{ {
title: opt.title || defaultTitle, title: opt.title || defaultTitle,
message, message: presentedMessage,
actions: buttons, actions: buttons,
actionLayout: "vertical", actionLayout: "vertical",
defaultAction: opt.defaultAction, defaultAction: opt.defaultAction,
@@ -146,7 +152,7 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
return confirmWithMessageWithWideButton( return confirmWithMessageWithWideButton(
this._plugin, this._plugin,
opt.title || defaultTitle, opt.title || defaultTitle,
message, presentedMessage,
buttons, buttons,
opt.defaultAction, opt.defaultAction,
opt.timeout 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 () => { it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => {
const { confirm, events } = createConfirm(); const { confirm, events } = createConfirm();
let observedSignal: AbortSignal | undefined; let observedSignal: AbortSignal | undefined;
+1 -1
View File
@@ -93,7 +93,7 @@ export async function askSimpleFetchMode(
const msg = `We are about to retrieve the remote data. 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. - **${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. 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.
+3
View File
@@ -469,6 +469,9 @@ describe("Red Flag Feature", () => {
expect(result).toBe(true); expect(result).toBe(true);
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalled(); expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalled();
expect(synchroniseAllFilesBetweenDBandStorage).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, // We can't easily check performFullScan call here because it's imported,
// but we can verify rebuilder was called. // but we can verify rebuilder was called.
}); });
+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 { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; import { type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector"; import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView";
import { import {
P2PServerStatusPaneView, P2PServerStatusPaneView,
VIEW_TYPE_P2P_SERVER_STATUS, VIEW_TYPE_P2P_SERVER_STATUS,
@@ -11,6 +10,34 @@ import {
import type { LiveSyncCore } from "@/main"; import type { LiveSyncCore } from "@/main";
import type { WorkspaceLeaf } from "@/deps"; import type { WorkspaceLeaf } from "@/deps";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; 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. * Obsidian-specific P2P views, commands, status collection, and ribbon wiring.
@@ -45,8 +72,7 @@ export function useP2PReplicatorUI(
icon: string, icon: string,
title: string, title: string,
callback: () => void callback: () => void
) => { addClass?: (name: string) => unknown } | undefined; ) => { addClass?: (name: string) => unknown; remove?: () => void } | undefined;
getPlatform: () => string;
}; };
// const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any }; // const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any };
@@ -64,15 +90,12 @@ export function useP2PReplicatorUI(
storeP2PStatusLine, 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) => { const statusFactory = (leaf: WorkspaceLeaf) => {
return new P2PServerStatusPaneView(leaf, core, p2pParams); return new P2PServerStatusPaneView(leaf, core, p2pParams);
}; };
const openPane = () => api.showWindow(viewType); const legacyStatusFactory = (leaf: WorkspaceLeaf) => {
return new LegacyP2PStatusPaneView(leaf, core, p2pParams);
};
const openStatusPane = () => { const openStatusPane = () => {
if (api.showWindowOnRight) { if (api.showWindowOnRight) {
return api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS); return api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
@@ -88,20 +111,36 @@ export function useP2PReplicatorUI(
{ label: "replication" } { 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); 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(() => { host.services.appLifecycle.onInitialise.addHandler(() => {
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => { eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => {
void openPane(); void openStatusPane();
});
api.addCommand({
id: "open-p2p-replicator",
name: "P2P Sync : Open P2P Replicator (Old UI)",
callback: () => {
void openPane();
},
}); });
api.addCommand({ api.addCommand({
@@ -147,27 +186,37 @@ export function useP2PReplicatorUI(
}, },
}); });
// api.addRibbonIcon("waypoints", "P2P Replicator", () => { host.services.setting.onSettingSaved?.addHandler((settings) => {
// void openPane(); updateRibbon(settings);
// })?.addClass?.("livesync-ribbon-replicate-p2p"); return Promise.resolve(true);
});
api.addRibbonIcon("waypoints", "P2P Status", () => {
void openStatusPane();
})?.addClass?.("livesync-ribbon-p2p-server-status");
return Promise.resolve(true); return Promise.resolve(true);
}); });
host.services.appLifecycle.onLayoutReady.addHandler(() => { host.services.appLifecycle.onLayoutReady.addHandler(async () => {
if (api.getPlatform() !== "obsidian") { const workspace = (
return Promise.resolve(true); host.services.context as {
app?: {
workspace?: {
getLeavesOfType(type: string): WorkspaceLeaf[];
};
};
}
).app?.workspace;
if (!workspace) {
return true;
} }
if (api.showWindowOnRight) { const legacyLeaves = workspace.getLeavesOfType(LEGACY_VIEW_TYPE_P2P);
void api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS); await Promise.all(
} else { legacyLeaves.map((leaf) =>
void api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS); leaf.setViewState({
} type: VIEW_TYPE_P2P_SERVER_STATUS,
return Promise.resolve(true); active: false,
})
)
);
return true;
}); });
return p2pParams; return p2pParams;
} }
@@ -1,18 +1,66 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { eventHub, EVENT_REQUEST_OPEN_P2P } from "@/common/events";
vi.mock("@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView", () => ({
P2PReplicatorPaneView: class {},
VIEW_TYPE_P2P: "p2p",
}));
vi.mock("@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView", () => ({ vi.mock("@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView", () => ({
P2PServerStatusPaneView: class {}, P2PServerStatusPaneView: class {
getViewType() {
return "p2p-status";
}
},
VIEW_TYPE_P2P_SERVER_STATUS: "p2p-status", VIEW_TYPE_P2P_SERVER_STATUS: "p2p-status",
})); }));
import { useP2PReplicatorUI } from "./useP2PReplicatorUI"; import { useP2PReplicatorUI } from "./useP2PReplicatorUI";
describe("useP2PReplicatorUI commands", () => { describe("useP2PReplicatorUI commands", () => {
it("waits for settings to load before deciding whether to show the P2P ribbon", async () => {
let initialise: (() => Promise<unknown>) | undefined;
let settingLoaded: (() => Promise<unknown>) | undefined;
let settings: Record<string, unknown> | undefined;
const currentSettings = vi.fn(() => settings);
const host = {
services: {
context: createServiceContext(),
API: {
showWindow: vi.fn(async () => undefined),
registerWindow: vi.fn(),
addCommand: vi.fn(),
addRibbonIcon: vi.fn(),
},
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler) => {
initialise = handler;
}),
},
onSettingLoaded: {
addHandler: vi.fn((handler) => {
settingLoaded = handler;
}),
},
onLayoutReady: { addHandler: vi.fn() },
},
setting: {
currentSettings,
onSettingSaved: { addHandler: vi.fn() },
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any);
await expect(initialise?.()).resolves.toBe(true);
expect(currentSettings).not.toHaveBeenCalled();
settings = {
remoteType: "COUCHDB",
remoteConfigurations: {},
};
await expect(settingLoaded?.()).resolves.toBe(true);
expect(currentSettings).toHaveBeenCalledOnce();
});
it("exposes a direct modal P2P replication command as finite replication activity", async () => { it("exposes a direct modal P2P replication command as finite replication activity", async () => {
const commands: Array<{ id: string; checkCallback?: (isChecking: boolean) => unknown }> = []; const commands: Array<{ id: string; checkCallback?: (isChecking: boolean) => unknown }> = [];
let initialise: (() => Promise<unknown>) | undefined; let initialise: (() => Promise<unknown>) | undefined;
@@ -34,6 +82,7 @@ describe("useP2PReplicatorUI commands", () => {
initialise = handler; initialise = handler;
}), }),
}, },
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: { addHandler: vi.fn() }, onLayoutReady: { addHandler: vi.fn() },
}, },
setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) }, setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) },
@@ -79,6 +128,7 @@ describe("useP2PReplicatorUI commands", () => {
}, },
appLifecycle: { appLifecycle: {
onInitialise: { addHandler: vi.fn() }, onInitialise: { addHandler: vi.fn() },
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: { addHandler: vi.fn() }, onLayoutReady: { addHandler: vi.fn() },
}, },
setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) }, setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) },
@@ -91,4 +141,226 @@ describe("useP2PReplicatorUI commands", () => {
expect(paneParams.replicator).toBe(second); expect(paneParams.replicator).toBe(second);
}); });
it("retains only the current P2P status command and routes existing open requests to it", async () => {
const commands: Array<{ id: string; callback?: () => void }> = [];
let initialise: (() => Promise<unknown>) | undefined;
const showWindow = vi.fn(async () => undefined);
const showWindowOnRight = vi.fn(async () => undefined);
const host = {
services: {
context: createServiceContext(),
API: {
showWindow,
showWindowOnRight,
registerWindow: vi.fn(),
addCommand: vi.fn((command) => commands.push(command)),
addRibbonIcon: vi.fn(),
getPlatform: vi.fn(() => "desktop"),
},
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler) => {
initialise = handler;
}),
},
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: { addHandler: vi.fn() },
},
setting: {
currentSettings: vi.fn(() => ({
remoteType: "COUCHDB",
remoteConfigurations: {},
})),
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
const p2p = { replicator: undefined } as any;
useP2PReplicatorUI(host, {} as any, p2p);
await initialise?.();
expect(commands.map((command) => command.id)).not.toContain("open-p2p-replicator");
expect(commands.map((command) => command.id)).toContain("open-p2p-server-status");
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P);
await vi.waitFor(() => expect(showWindowOnRight).toHaveBeenCalledWith("p2p-status"));
expect(showWindow).not.toHaveBeenCalledWith("p2p");
});
it("does not open the P2P status pane automatically when the workspace becomes ready", async () => {
let layoutReady: (() => Promise<unknown>) | undefined;
const showWindow = vi.fn(async () => undefined);
const showWindowOnRight = vi.fn(async () => undefined);
const host = {
services: {
context: createServiceContext(),
API: {
showWindow,
showWindowOnRight,
registerWindow: vi.fn(),
addCommand: vi.fn(),
addRibbonIcon: vi.fn(),
getPlatform: vi.fn(() => "obsidian"),
},
appLifecycle: {
onInitialise: { addHandler: vi.fn() },
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: {
addHandler: vi.fn((handler) => {
layoutReady = handler;
}),
},
},
setting: {
currentSettings: vi.fn(() => ({
remoteType: "COUCHDB",
remoteConfigurations: {},
})),
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any);
await layoutReady?.();
expect(showWindow).not.toHaveBeenCalled();
expect(showWindowOnRight).not.toHaveBeenCalled();
});
it("shows the ribbon only whilst a P2P configuration exists", async () => {
let initialise: (() => Promise<unknown>) | undefined;
let settingLoaded: (() => Promise<unknown>) | undefined;
let onSettingSaved: ((settings: unknown) => Promise<unknown>) | undefined;
let currentSettings: any = {
remoteType: "COUCHDB",
remoteConfigurations: {},
P2P_Enabled: false,
P2P_roomID: "",
P2P_passphrase: "",
};
const ribbon = { addClass: vi.fn(), remove: vi.fn() };
const addRibbonIcon = vi.fn(() => ribbon);
const host = {
services: {
context: createServiceContext(),
API: {
showWindow: vi.fn(async () => undefined),
showWindowOnRight: vi.fn(async () => undefined),
registerWindow: vi.fn(),
addCommand: vi.fn(),
addRibbonIcon,
getPlatform: vi.fn(() => "desktop"),
},
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler) => {
initialise = handler;
}),
},
onSettingLoaded: {
addHandler: vi.fn((handler) => {
settingLoaded = handler;
}),
},
onLayoutReady: { addHandler: vi.fn() },
},
setting: {
currentSettings: vi.fn(() => currentSettings),
onSettingSaved: {
addHandler: vi.fn((handler) => {
onSettingSaved = handler;
}),
},
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any);
await initialise?.();
await settingLoaded?.();
expect(addRibbonIcon).not.toHaveBeenCalled();
currentSettings = {
...currentSettings,
remoteConfigurations: {
peer: {
id: "peer",
name: "Peer",
uri: "sls+p2p://room?passphrase=secret",
isEncrypted: false,
},
},
};
await onSettingSaved?.(currentSettings);
expect(addRibbonIcon).toHaveBeenCalledOnce();
await onSettingSaved?.(currentSettings);
expect(addRibbonIcon).toHaveBeenCalledOnce();
currentSettings = {
...currentSettings,
remoteConfigurations: {},
};
await onSettingSaved?.(currentSettings);
expect(ribbon.remove).toHaveBeenCalledOnce();
});
it("replaces a restored legacy P2P leaf with the current status view without opening another leaf", async () => {
let layoutReady: (() => Promise<unknown>) | undefined;
const legacyLeaf = {
setViewState: vi.fn(async () => undefined),
};
const workspace = {
getLeavesOfType: vi.fn((type: string) => (type === "p2p-replicator" ? [legacyLeaf] : [])),
};
const context = createServiceContext() as ReturnType<typeof createServiceContext> & {
app: { workspace: typeof workspace };
};
context.app = { workspace };
const showWindow = vi.fn(async () => undefined);
const showWindowOnRight = vi.fn(async () => undefined);
const host = {
services: {
context,
API: {
showWindow,
showWindowOnRight,
registerWindow: vi.fn(),
addCommand: vi.fn(),
addRibbonIcon: vi.fn(),
getPlatform: vi.fn(() => "desktop"),
},
appLifecycle: {
onInitialise: { addHandler: vi.fn() },
onSettingLoaded: { addHandler: vi.fn() },
onLayoutReady: {
addHandler: vi.fn((handler) => {
layoutReady = handler;
}),
},
},
setting: {
currentSettings: vi.fn(() => ({
remoteType: "COUCHDB",
remoteConfigurations: {},
})),
},
replicator: { runFiniteReplicationActivity: vi.fn() },
},
} as any;
useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any);
await layoutReady?.();
expect(legacyLeaf.setViewState).toHaveBeenCalledWith({
type: "p2p-status",
active: false,
});
expect(showWindow).not.toHaveBeenCalled();
expect(showWindowOnRight).not.toHaveBeenCalled();
});
}); });
+3 -3
View File
@@ -83,7 +83,7 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. `test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios.
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises two representative Svelte dialogue routes: remote server selection through `SetupManager`, and Setup URI entry through the registered command. The session pre-seeds a configured, inactive plug-in state so that onboarding and migration prompts do not interfere with the dialogues under test. It requires each dialogue title and its principal controls to be visible, captures desktop and mobile screenshots, closes them through their normal user controls, and verifies that the remote-selection promise settles without an error. This covers the host, context, component, and result boundaries moved during the Commonlib package extraction without applying settings or contacting a remote service. `test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, and the Setup URI controls. It captures the representative dialogues on desktop and mobile, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that the remote-selection promise settles without an error. These UI-only checks do not apply a remote configuration or contact a remote service.
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour. `test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
@@ -91,7 +91,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows. `test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
`test:e2e:obsidian:p2p-pane` opens the P2P status view in a temporary real Obsidian session, verifies its principal connection control and horizontal layout, and captures a screenshot. It deliberately uses no relay or peer: current-replicator replacement is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible two-device real-Obsidian path. `test:e2e:obsidian:p2p-pane` starts one unconfigured CouchDB-only session and one configured P2P session. It proves that the command remains registered while the retired command, automatic pane, and unconfigured ribbon entry are absent. For the configured profile, it verifies that the ribbon and current status command reach the pane without opening it at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. `test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
@@ -124,7 +124,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:object-storage-setup-uri-workflow` generates a public Commonlib-backed bootstrap URI for a unique MinIO prefix, completes visible first-device initialisation, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. `test:e2e:obsidian:object-storage-setup-uri-workflow` generates a public Commonlib-backed bootstrap URI for a unique MinIO prefix, completes visible first-device initialisation, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Nostr relay fixture. The first device imports a generated bootstrap URI, creates the additional-device URI through the registered command, and remains online while the second device imports it. The workflow selects the expected peer, accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, reconnects both finite P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes. `test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated bootstrap URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both finite P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file. `test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file.
@@ -23,6 +23,10 @@ export type ConfiguredSettings = {
export type CoreReadiness = { export type CoreReadiness = {
databaseReady: boolean; databaseReady: boolean;
appReady: boolean; appReady: boolean;
configured?: boolean;
remoteType?: string;
settingVersion?: number;
suspended?: boolean;
}; };
export type ReplicationAttempt = CoreReadiness & { export type ReplicationAttempt = CoreReadiness & {
@@ -348,9 +352,14 @@ export async function waitForLiveSyncCoreReady(
[ [
"(async()=>{", "(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;", "const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({", "return JSON.stringify({",
"databaseReady:core.services.database.isDatabaseReady(),", "databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),", "appReady:core.services.appLifecycle.isReady(),",
"configured:settings?.isConfigured===true,",
"remoteType:settings?.remoteType??'',",
"settingVersion:settings?.settingVersion,",
"suspended:core.services.appLifecycle.isSuspended(),",
"});", "});",
"})()", "})()",
].join(""), ].join(""),
+18
View File
@@ -177,16 +177,34 @@ export async function captureAndStartInitialisation(
captures: SetupCaptureNames captures: SetupCaptureNames
): Promise<string> { ): Promise<string> {
const p2pFirstDevice = mode === "new" && captures.guide === "p2p-setup"; const p2pFirstDevice = mode === "new" && captures.guide === "p2p-setup";
const p2pAdditionalDevice = mode === "existing" && captures.guide === "p2p-setup";
const title = p2pFirstDevice const title = p2pFirstDevice
? "Setup Complete: Preparing This P2P Device" ? "Setup Complete: Preparing This P2P Device"
: p2pAdditionalDevice
? "Setup Complete: Preparing to Fetch from Another Device"
: mode === "new" : mode === "new"
? "Setup Complete: Preparing to Initialise Server" ? "Setup Complete: Preparing to Initialise Server"
: "Setup Complete: Preparing to Fetch Synchronisation Data"; : "Setup Complete: Preparing to Fetch Synchronisation Data";
const button = p2pFirstDevice const button = p2pFirstDevice
? "Restart and Prepare This Device" ? "Restart and Prepare This Device"
: p2pAdditionalDevice
? "Restart and Select Source Device"
: mode === "new" : mode === "new"
? "Restart and Initialise Server" ? "Restart and Initialise Server"
: "Restart and Fetch Data"; : "Restart and Fetch Data";
if (p2pAdditionalDevice) {
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
await modal
.getByText("After restarting, select an online source device for the initial Fetch.", {
exact: false,
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await modal.getByText("downloaded from the server", { exact: false }).count()) !== 0) {
throw new Error("P2P additional-device setup still describes the initial Fetch as a server download.");
}
});
}
const screenshot = await captureGuideDialogue( const screenshot = await captureGuideDialogue(
port, port,
`guide-${captures.guide}-${mode === "new" ? "first-initialise" : "second-fetch"}.png`, `guide-${captures.guide}-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
+131 -8
View File
@@ -1,8 +1,15 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; import {
captureObsidianDialogue,
captureObsidianPage,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts"; import { createTemporaryVault } from "../runner/vault.ts";
const dialogRunStateKey = "__livesyncE2EDialogMount"; const dialogRunStateKey = "__livesyncE2EDialogMount";
@@ -199,6 +206,12 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
for (const label of ["CouchDB", "S3/MinIO/R2 Object Storage", "Peer-to-Peer only"]) { for (const label of ["CouchDB", "S3/MinIO/R2 Object Storage", "Peer-to-Peer only"]) {
await dialogue.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); await dialogue.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
} }
await dialogue
.getByText(
"No central data-storage server is required, but a signalling relay is required for peer discovery.",
{ exact: false }
)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal await modal
.getByRole("button", { name: "No, please take me back" }) .getByRole("button", { name: "No, please take me back" })
.waitFor({ state: "visible", timeout: uiTimeoutMs }); .waitFor({ state: "visible", timeout: uiTimeoutMs });
@@ -226,6 +239,78 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
return screenshotPath; return screenshotPath;
} }
async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string> {
await openRemoteSelectionDialogue();
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const remoteSelection = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Enter Server Information" }),
});
await remoteSelection
.locator("label")
.filter({ hasText: "CouchDB" })
.locator('input[type="radio"]')
.first()
.check({ timeout: uiTimeoutMs });
await remoteSelection
.getByRole("button", { name: "Continue to CouchDB setup", exact: true })
.click({ timeout: uiTimeoutMs });
});
const screenshotPath = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
`setup-couchdb-dialogue${mode === "mobile" ? "-mobile" : ""}.png`,
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "CouchDB Configuration" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
for (const label of [
"Check server requirements",
"Test connection and save",
"Save without connecting",
"Cancel",
]) {
await modal.getByRole("button", { name: label, exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
await modal
.getByText(
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server.",
{ exact: false }
)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByText("CouchDB validates the database name when you connect.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await modal.getByRole("button", { name: "Continue anyway", exact: true }).count()) !== 0) {
throw new Error("CouchDB onboarding still exposes the ambiguous Continue anyway action.");
}
const buttonGroups = modal.locator(".button-group");
for (let index = 0; index < (await buttonGroups.count()); index++) {
const flexDirection = await buttonGroups.nth(index).evaluate((element) => {
return getComputedStyle(element).flexDirection;
});
if (flexDirection !== "column") {
throw new Error(`Expected vertical CouchDB actions, received ${flexDirection}.`);
}
}
if (mode === "mobile") {
await assertMobileDialogueLayout(page, modal, "CouchDB settings dialogue");
}
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "CouchDB Configuration" }),
});
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await assertDialogueRunCompleted();
return screenshotPath;
}
async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> { async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> {
await openSetupUriDialogue(); await openSetupUriDialogue();
const screenshotPath = await captureObsidianDialogue( const screenshotPath = await captureObsidianDialogue(
@@ -337,10 +422,14 @@ async function main(): Promise<void> {
cliBinary: cli.binary, cliBinary: cli.binary,
vault, vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: { pluginData: createE2eCouchDbPluginData(
doctorProcessedVersion: "0.25.27", {
isConfigured: true, uri: "http://127.0.0.1:5984",
liveSync: false, username: "",
password: "",
dbName: "dialog-mounts-ui-only",
},
{
notifyThresholdOfRemoteStorageSize: -1, notifyThresholdOfRemoteStorageSize: -1,
syncOnStart: false, syncOnStart: false,
syncOnSave: false, syncOnSave: false,
@@ -348,9 +437,35 @@ async function main(): Promise<void> {
syncOnFileOpen: false, syncOnFileOpen: false,
syncAfterMerge: false, syncAfterMerge: false,
periodicReplication: false, periodicReplication: false,
}, }
),
}); });
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); try {
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
} catch (error) {
const screenshot = await captureObsidianPage(
obsidianRemoteDebuggingPort(),
"dialog-mounts-core-not-ready.png",
async () => undefined
);
console.error(`Core readiness diagnostic screenshot: ${screenshot}`);
const persistedSettings = JSON.parse(
await readFile(join(session.install.pluginDir, "data.json"), "utf8")
) as Record<string, unknown>;
console.error(
`Persisted readiness settings: ${JSON.stringify({
isConfigured: persistedSettings.isConfigured,
remoteType: persistedSettings.remoteType,
settingVersion: persistedSettings.settingVersion,
couchDB_URI: persistedSettings.couchDB_URI,
couchDB_DBNAME: persistedSettings.couchDB_DBNAME,
remoteConfigurationCount: Object.keys(
(persistedSettings.remoteConfigurations as Record<string, unknown> | undefined) ?? {}
).length,
})}`
);
throw error;
}
const remoteSizeScreenshots = await verifyRemoteSizeNoticeAndDialogue(); const remoteSizeScreenshots = await verifyRemoteSizeNoticeAndDialogue();
console.log( console.log(
@@ -359,6 +474,10 @@ async function main(): Promise<void> {
const remoteScreenshot = await verifyRemoteSelectionDialogue("desktop"); const remoteScreenshot = await verifyRemoteSelectionDialogue("desktop");
console.log(`Remote selection dialogue mounted and closed successfully. Screenshot: ${remoteScreenshot}`); console.log(`Remote selection dialogue mounted and closed successfully. Screenshot: ${remoteScreenshot}`);
const couchDBScreenshot = await verifyCouchDBSettingsDialogue("desktop");
console.log(
`CouchDB settings mode exposed explicit connection, unverified-save, and server-check actions. Screenshot: ${couchDBScreenshot}`
);
const setupUriScreenshot = await verifySetupUriDialogue("desktop"); const setupUriScreenshot = await verifySetupUriDialogue("desktop");
console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`); console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`);
@@ -372,6 +491,10 @@ async function main(): Promise<void> {
console.log( console.log(
`Mobile remote selection dialogue passed viewport, safe-area, touch-target, and close-control checks. Screenshot: ${mobileRemoteScreenshot}` `Mobile remote selection dialogue passed viewport, safe-area, touch-target, and close-control checks. Screenshot: ${mobileRemoteScreenshot}`
); );
const mobileCouchDBScreenshot = await verifyCouchDBSettingsDialogue("mobile");
console.log(
`Mobile CouchDB settings dialogue passed viewport, touch-target, and vertical-action checks. Screenshot: ${mobileCouchDBScreenshot}`
);
const mobileSetupUriScreenshot = await verifySetupUriDialogue("mobile"); const mobileSetupUriScreenshot = await verifySetupUriDialogue("mobile");
console.log( console.log(
`Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}` `Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}`
+143 -28
View File
@@ -1,6 +1,12 @@
import { assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session"; import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
import type { Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts"; import { createTemporaryVault } from "../runner/vault.ts";
@@ -8,7 +14,10 @@ import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000); const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000);
type ObsidianTestApp = { type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean }; commands?: {
commands?: Record<string, unknown>;
executeCommandById(commandId: string): boolean;
};
}; };
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
@@ -25,9 +34,9 @@ async function openP2PStatusPane(): Promise<void> {
} }
} }
async function verifyP2PStatusPane(): Promise<string> { async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<string> {
await openP2PStatusPane(); await openP2PStatusPane();
return await captureObsidianPage(obsidianRemoteDebuggingPort(), "p2p-status-pane.png", async (page) => { return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator( const pane = heading.locator(
@@ -39,42 +48,113 @@ async function verifyP2PStatusPane(): Promise<string> {
timeout: uiTimeoutMs, timeout: uiTimeoutMs,
}); });
await assertNoHorizontalOverflow(page, pane, { label: "P2P status pane" }); await assertNoHorizontalOverflow(page, pane, { label: "P2P status pane" });
if (mobile) {
await assertLocatorWithinViewport(page, pane, { label: "mobile P2P status pane" });
}
await dismissOpenNotices(page);
}); });
} }
async function main(): Promise<void> { async function assertP2PUIIsOptIn(): Promise<void> {
const binary = requireObsidianBinary(); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const cli = discoverObsidianCli(); const state = await page.evaluate(() => {
if (!cli.binary) { const commands = (globalThis as ObsidianTestGlobal).app?.commands?.commands ?? {};
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); return {
currentCommand: commands["obsidian-livesync:open-p2p-server-status"] !== undefined,
legacyCommand: commands["obsidian-livesync:open-p2p-replicator"] !== undefined,
};
});
if (!state.currentCommand) {
throw new Error("The current P2P status command was not registered.");
}
if (state.legacyCommand) {
throw new Error("The retired P2P pane command is still exposed.");
}
if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) {
throw new Error("The P2P status pane opened automatically for an unconfigured CouchDB user.");
}
if ((await page.locator(".livesync-ribbon-p2p-server-status").count()) !== 0) {
throw new Error("The P2P ribbon icon was shown without a P2P configuration.");
}
});
}
async function assertConfiguredP2PUIIsAvailable(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.locator(".livesync-ribbon-p2p-server-status").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) {
throw new Error("The configured P2P status pane opened before the user requested it.");
}
});
}
async function dismissOpenNotices(page: Page): Promise<void> {
const deadline = Date.now() + uiTimeoutMs;
let quietSince = Date.now();
while (Date.now() < deadline) {
const notices = page.locator(".notice:visible");
if ((await notices.count()) === 0) {
if (Date.now() - quietSince >= 500) {
return;
}
await page.waitForTimeout(100);
continue;
}
quietSince = Date.now();
const closeButton = notices.first().locator(".notice-close-button");
if ((await closeButton.count()) > 0) {
await closeButton.click({ force: true, timeout: uiTimeoutMs });
} else {
// Obsidian 1.12 does not render a separate close control for every
// Notice; clicking the Notice itself is its standard dismiss action.
await notices.first().click({ force: true, position: { x: 2, y: 2 }, timeout: uiTimeoutMs });
}
} }
throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot.");
}
const basePluginData = createE2eCouchDbPluginData(
{
uri: "http://127.0.0.1:5984",
username: "",
password: "",
dbName: "p2p-pane-ui-only",
},
{
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
P2P_Enabled: false,
P2P_AutoStart: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
}
);
async function withP2PSession(
binary: string,
cliBinary: string,
pluginData: Record<string, unknown>,
verify: () => Promise<void>
): Promise<void> {
const vault = await createTemporaryVault(); const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined; let session: ObsidianLiveSyncSession | undefined;
try { try {
session = await startObsidianLiveSyncSession({ session = await startObsidianLiveSyncSession({
binary, binary,
cliBinary: cli.binary, cliBinary,
vault, vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: { pluginData,
doctorProcessedVersion: "0.25.27",
isConfigured: true,
liveSync: false,
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
P2P_Enabled: false,
P2P_AutoStart: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
}); });
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
const screenshot = await verifyP2PStatusPane(); await verify();
console.log(`P2P status pane mounted without network fixtures. Screenshot: ${screenshot}`);
} finally { } finally {
if (session) { if (session) {
await session.app.stop(); await session.app.stop();
@@ -83,6 +163,41 @@ async function main(): Promise<void> {
} }
} }
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
await withP2PSession(binary, cli.binary, basePluginData, async () => {
await assertP2PUIIsOptIn();
});
await withP2PSession(
binary,
cli.binary,
{
...basePluginData,
P2P_roomID: "configured-p2p-room",
P2P_passphrase: "configured-p2p-passphrase",
},
async () => {
await assertConfiguredP2PUIIsAvailable();
const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false);
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
try {
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
console.log(
`Configured P2P status UI remained opt-in and was reachable on desktop and mobile. Screenshots: ${desktopScreenshot}, ${mobileScreenshot}`
);
} finally {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
}
}
);
}
main().catch((error: unknown) => { main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error); console.error(error instanceof Error ? error.stack : error);
process.exit(1); process.exit(1);
@@ -419,6 +419,47 @@ async function waitForDetectedPeer(port: number): Promise<void> {
}); });
} }
async function capturePeerActionsMenu(port: number): Promise<string> {
await withObsidianPage(port, async (page) => {
const moreActions = page.getByRole("button", { name: /^More actions for /u }).first();
await moreActions.waitFor({ state: "visible", timeout: uiTimeoutMs });
await moreActions.click({ timeout: uiTimeoutMs });
const menu = page.locator(".menu:visible").last();
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
for (const label of [
"Synchronise when this device connects",
"Follow whenever this device connects",
"Include in the P2P synchronisation command",
]) {
await menu.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
const layout = await menu.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
insideViewport:
rect.left >= 0 &&
rect.top >= 0 &&
rect.right <= document.documentElement.clientWidth &&
rect.bottom <= document.documentElement.clientHeight,
hasHorizontalOverflow: element.scrollWidth > element.clientWidth,
};
});
if (!layout.insideViewport || layout.hasHorizontalOverflow) {
throw new Error(`P2P peer actions menu did not fit the viewport: ${JSON.stringify(layout)}`);
}
});
const screenshot = await captureObsidianElement(
port,
"guide-p2p-setup-peer-actions-menu.png",
(page) => page.locator(".menu:visible").last()
);
await withObsidianPage(port, async (page) => {
await page.keyboard.press("Escape");
await page.locator(".menu:visible").waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return screenshot;
}
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> { async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
await withObsidianPage(port, async (page) => { await withObsidianPage(port, async (page) => {
await page.evaluate((notePath) => { await page.evaluate((notePath) => {
@@ -496,12 +537,15 @@ async function main(): Promise<void> {
screenshots.push( screenshots.push(
await captureNote(portB, noteFromFirst, "P2P from the first device", "guide-p2p-setup-first-to-second.png") await captureNote(portB, noteFromFirst, "P2P from the first device", "guide-p2p-setup-first-to-second.png")
); );
console.log("P2P workflow: initial Fetch from the first device completed.");
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent); await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
await reconnectP2PStatus(portA); await reconnectP2PStatus(portA);
await reconnectP2PStatus(portB); await reconnectP2PStatus(portB);
await waitForDetectedPeer(portA); await waitForDetectedPeer(portA);
screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-devices-connected.png")); screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-devices-connected.png"));
screenshots.push(await capturePeerActionsMenu(portA));
console.log("P2P workflow: peer actions menu verified; starting the return journey.");
let returnJourneyFinished = false; let returnJourneyFinished = false;
const returnJourneyAcceptor = acceptConnectionRequests( const returnJourneyAcceptor = acceptConnectionRequests(
[portA, portB], [portA, portB],
@@ -510,10 +554,13 @@ async function main(): Promise<void> {
); );
try { try {
await replicateFromStatusPane(portA); await replicateFromStatusPane(portA);
console.log("P2P workflow: return replication requested; waiting for the second device's note.");
await waitForPathContent(vaultA, noteFromSecond, secondContent); await waitForPathContent(vaultA, noteFromSecond, secondContent);
console.log("P2P workflow: return note reached the first device.");
} finally { } finally {
returnJourneyFinished = true; returnJourneyFinished = true;
await returnJourneyAcceptor; await returnJourneyAcceptor;
console.log("P2P workflow: return connection approval loop stopped.");
} }
screenshots.push( screenshots.push(
await captureNote( await captureNote(
@@ -526,9 +573,11 @@ async function main(): Promise<void> {
console.log(`P2P Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`); console.log(`P2P Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`);
} finally { } finally {
console.log("P2P workflow: stopping tracked Obsidian sessions.");
await stopSessions(context).catch((error: unknown) => { await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error); console.warn(error instanceof Error ? error.message : error);
}); });
console.log("P2P workflow: disposing temporary Vaults.");
await vaultA.dispose(); await vaultA.dispose();
await vaultB.dispose(); await vaultB.dispose();
} }
+4
View File
@@ -15,6 +15,9 @@ Earlier releases remain available in the [0.25 release history](https://github.c
### Improved ### Improved
- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved. - Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved.
- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits.
- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`.
- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation.
### Fixed ### Fixed
@@ -23,6 +26,7 @@ Earlier releases remain available in the [0.25 release history](https://github.c
### Testing ### Testing
- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages. - Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages.
- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues.
## 1.0.0-beta.1 ## 1.0.0-beta.1