Compare commits

...
Author SHA1 Message Date
vorotamoroz 9825ad29a8 test(obsidian): integrate representative adaptive journeys
# Conflicts:
#	package.json
#	test/e2e-obsidian/README.md
#	test/e2e-obsidian/runner/setupUri.ts
#	test/e2e-obsidian/scripts/local-suite.ts
#	test/e2e-obsidian/scripts/run-focused.ts
2026-08-02 08:10:48 +00:00
vorotamoroz ef41cc3486 test(obsidian): focus adaptive PostgREST journey 2026-08-02 08:05:11 +00:00
vorotamoroz b90541b687 test(obsidian): verify PostgREST repository identity 2026-08-02 08:03:49 +00:00
vorotamoroz 50ddc94593 docs(test): document the PostgREST E2E fixture 2026-08-02 08:03:49 +00:00
vorotamoroz ce6dc67975 test(obsidian): cover Adaptive PostgREST remotes 2026-08-02 08:02:41 +00:00
vorotamoroz ea0c6dca48 refactor(test): use the shared WebDAV journey 2026-08-02 07:59:14 +00:00
vorotamoroz c76159ea50 test(obsidian): verify WebDAV repository identity 2026-08-02 07:57:39 +00:00
vorotamoroz d6fd2fa4c8 test: cover Adaptive WebDAV in real Obsidian 2026-08-02 07:57:16 +00:00
vorotamoroz 1fb32cb625 test(obsidian): add shared adaptive journal journey 2026-08-02 07:54:29 +00:00
vorotamoroz c7d1f9352d test(obsidian): verify registered remote setup profiles 2026-08-02 07:52:00 +00:00
vorotamoroz de61425864 test: cover registered adaptive remote choices 2026-08-02 07:51:39 +00:00
vorotamoroz 3825a965bc feat: preselect PostgREST repository identity 2026-08-02 07:51:39 +00:00
vorotamoroz b4800d9756 fix: preserve the active journal protocol 2026-08-02 07:51:39 +00:00
vorotamoroz f67d224657 feat: configure Adaptive PostgREST journal remotes 2026-08-02 07:51:39 +00:00
vorotamoroz f93b5ac8e4 test(cli): focus adaptive PostgREST on consumer behaviour 2026-08-02 07:51:15 +00:00
vorotamoroz ae9dd6f79b test(cli): cover Adaptive PostgREST remotes 2026-08-02 07:51:07 +00:00
vorotamoroz 09ee6023ae refactor: use registered remote configuration dispatch 2026-08-02 07:49:41 +00:00
vorotamoroz 9aefdc117f feat: preselect WebDAV repository identity 2026-08-02 07:49:41 +00:00
vorotamoroz 34255e5736 feat: configure WebDAV journal remotes 2026-08-02 07:49:41 +00:00
vorotamoroz 5db64f6faa test(cli): focus adaptive WebDAV on consumer behaviour 2026-08-02 07:49:19 +00:00
vorotamoroz 032ace8f53 feat(cli): support adaptive WebDAV remotes 2026-08-02 07:49:09 +00:00
vorotamoroz 1543a53263 refactor: compose Journal remotes generically 2026-08-02 07:48:52 +00:00
vorotamoroz 9715d44fb6 feat: preselect adaptive repository identity 2026-08-02 07:48:33 +00:00
vorotamoroz dd8c795210 feat: expose adaptive journal options for S3 2026-08-02 07:48:33 +00:00
vorotamoroz fff8103e40 test(cli): focus adaptive S3 on consumer behaviour 2026-08-02 07:48:08 +00:00
vorotamoroz aeb97c2b13 test(cli): pin Adaptive S3 service images 2026-08-02 07:47:59 +00:00
vorotamoroz 44c52aa2dd test(cli): exercise adaptive S3 pack reads 2026-08-02 07:47:59 +00:00
vorotamoroz d74e37559c test(cli): cover adaptive journal S3 end to end 2026-08-02 07:47:59 +00:00
vorotamoroz 5c7af20ad2 fix(cli): verify adaptive journal remotes 2026-08-02 07:47:59 +00:00
vorotamoroz 0f2efbd670 fix(cli): persist adaptive writer state 2026-08-02 07:47:59 +00:00
vorotamoroz a9283be9d2 refactor: compose Journal sync as a service feature 2026-08-02 04:08:52 +00:00
vorotamoroz 4d6a794f9d refactor: register remote setup presentations 2026-08-02 04:08:52 +00:00
vorotamoroz c4419a837c fix: preserve native HTTP body semantics 2026-08-02 04:08:52 +00:00
vorotamoroz 19f0b3d145 fix(cli): replace the headless log handler 2026-08-01 15:30:46 +00:00
vorotamorozandGitHub 3ba9feaac7 Merge pull request #1063 from vrtmrz/feature/p2p-connection-check
Add a browser-assisted P2P connection check
2026-08-01 03:51:06 +09:00
vorotamoroz 42f37753b8 Address WebPeer CSS review warnings 2026-07-31 17:56:26 +00:00
vorotamoroz ec1fc4a97e Fix WebPeer interop test selector 2026-07-31 16:37:09 +00:00
vorotamoroz 146e25907d Link WebPeer from English README 2026-07-31 15:14:00 +00:00
vorotamoroz c97346e262 Unify WebPeer visual design 2026-07-31 15:05:22 +00:00
vorotamoroz 48c398948a Add browser P2P connection check 2026-07-31 14:13:34 +00:00
vorotamorozandGitHub e32e3f545e Merge pull request #1062 from vrtmrz/fix/release-promotion-order
Fix release promotion order and remediation rendering
2026-07-31 19:41:01 +09:00
vorotamoroz 0aed7f8556 Add remediation fix to unreleased notes 2026-07-31 10:35:05 +00:00
vorotamoroz 50a51bf07e Use setting control for remediation status 2026-07-31 10:08:34 +00:00
vorotamoroz 1570136703 Fix remediation status element insertion 2026-07-31 09:13:56 +00:00
vorotamoroz 5b6bbc8118 Remove release instruction text assertions 2026-07-31 09:13:21 +00:00
vorotamoroz 54f0262b3f Fix stable release promotion order 2026-07-31 08:55:26 +00:00
vorotamorozandGitHub 7d33613017 Merge pull request #1061 from vrtmrz/1_0_2
Releasing 1.0.2
2026-07-31 17:39:40 +09:00
91 changed files with 9346 additions and 576 deletions
+3 -2
View File
@@ -137,8 +137,9 @@ jobs:
echo "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action."
elif [[ "${PRERELEASE}" == "true" ]]; then
echo "Publish the draft initially as a pre-release without replacing the latest stable release."
echo "After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
echo "Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release."
echo "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before its manifest reaches the default branch."
echo "Confirm the release is no longer a pre-release, then merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
echo "This order prevents Community Directory review from inspecting a stable manifest version while the matching GitHub Release remains a pre-release."
echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate."
else
echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
+1
View File
@@ -23,6 +23,7 @@ Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling de
- No central data-storage server is required, but a signalling relay is still required for peer discovery.
- At least one device containing the required data must be online while another device synchronises.
- Follow the [Peer-to-Peer Setup](docs/setup_p2p.md) after reviewing the [P2P communication model](docs/p2p.md).
- If you would like to see how P2P setup and connectivity behave on your devices, try the guided connection check in the experimental [WebPeer browser utility](https://vrtmrz.github.io/obsidian-livesync/webpeer/). It uses an empty Vault and does not test note synchronisation. See the [WebPeer documentation](src/apps/webpeer/README.md) for its scope and limitations.
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.
+3 -3
View File
@@ -250,7 +250,7 @@ export class ModuleExample extends AbstractObsidianModule {
- A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag.
- Keep a hyphenated pre-release's release pull request in draft and unmerged after BRAT validation. Reconcile the published version's metadata into its base branch through a reviewed metadata-only commit, then close the release pull request only through a separate maintainer action.
- Stage a stable version for BRAT by publishing its exact `x.y.z` tag initially as a GitHub pre-release with `prerelease=true` and `publish_cli=false`. The stable manifest version would otherwise make the CLI workflow advance `latest` and the major-minor image tag before validation.
- After a staged stable version passes BRAT validation, merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. Promote the GitHub Release only after the default branch contains the exact stable metadata; publish stable CLI tags through a separate maintainer gate.
- After a staged stable version passes BRAT validation, remove the GitHub pre-release designation and make the exact release the latest stable release first. Confirm that promotion, then merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. This order prevents Community Directory review from inspecting a stable manifest version while the matching GitHub Release remains a pre-release. Publish stable CLI tags through a separate maintainer gate.
- If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version.
## Release Notes
@@ -276,7 +276,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
- Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request.
- Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release.
- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action.
- After a stable version passes, mark its release pull request ready and merge the exact release commit into the selected base branch with a merge commit. Integrate that exact commit through the reviewed branch chain into the repository's default branch. Only after the default branch contains the matching stable metadata, remove the GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
- After a stable version passes, remove the GitHub pre-release designation and make that exact release the latest stable release. Confirm that the release is no longer a pre-release, then mark its release pull request ready, merge the exact release commit into the selected base branch with a merge commit, and integrate that exact commit through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
- If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show <tag>:updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history.
- Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action.
- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`.
@@ -307,7 +307,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
8. Update the PR state message to describe the published pre-release and state that merging remains on hold.
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario.
10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action.
11. After a stable version passes, mark the release PR ready and merge the exact release commit into the selected base branch. Integrate that commit through the reviewed branch chain into the repository's default branch. Once the default branch contains the matching stable metadata, remove the pre-release designation, make the exact release the latest stable release, and publish stable CLI tags through a separate maintainer gate if selected.
11. After a stable version passes, remove the pre-release designation and make the exact release the latest stable release. Confirm that promotion, then mark the release PR ready, merge the exact release commit into the selected base branch, and integrate it through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata, then publish stable CLI tags through a separate maintainer gate if selected.
12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
## Contribution Guidelines
+103
View File
@@ -0,0 +1,103 @@
# Architectural Decision Record: Browser-assisted P2P Connection Check
## Status
Accepted — WebPeer provides a disposable, browser-observed connection check with a clean one-device path and an optional same-room second-device attempt.
## Context
P2P availability depends on the browser, device, signalling relay, NAT, firewall, and current network. A Setup URI can make a trial easier, but a connection observed between a browser and one device does not prove that two other devices can connect to each other, and a WebRTC connection does not prove that LiveSync documents can be synchronised correctly.
Showing this experiment inside the ordinary LiveSync interface would mix a temporary connectivity check with the state of a real Vault. It would also make a browser-owned diagnostic result appear to be a plug-in-owned synchronisation result.
The Commonlib diagnostic counters are cumulative within one JavaScript realm. A negotiation may create more than one `RTCPeerConnection`, a failed attempt may later be followed by a successful retry, and closing a successful connection is not a failure. The counters therefore cannot be interpreted as peer counts or as mutually exclusive outcomes.
## Decision
WebPeer provides a separate page labelled 'P2P connection check'. It acts as a temporary reference peer and creates a fresh, random P2P configuration locally in the browser. The page displays an encrypted Setup URI, a QR code containing that URI, and the separate Setup URI passphrase.
The initial implementation is part of the WebPeer production build and is served over HTTPS or from `localhost`. A downloaded copy of `check.html` alone is not a supported standalone application because the build uses separate module assets and origin-scoped browser storage.
Each check has these boundaries:
| Participant | Responsibility |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| Browser page | Joins the generated room as the diagnostic reference peer and displays its own diagnostic counters. |
| Desktop or mobile LiveSync | Opens the generated Setup URI in a dedicated empty Vault and attempts the ordinary P2P connection. |
| User | Uses an empty Vault for every device and chooses either a fresh room or the explicitly less isolated same-room second-device attempt. |
The generated device configuration:
- uses a dedicated P2P application identifier so that test rooms do not overlap ordinary LiveSync rooms;
- uses random room, P2P, Vault-encryption, and Setup URI secrets;
- enables P2P auto-start so that a dedicated empty test Vault attempts the connection after setup;
- does not enable automatic broadcasting, peer acceptance, watching, or replication;
- omits device-local names from the Setup URI; and
- enables diagnostic WebRTC wrapping only on the browser reference peer.
The browser joins the room only after the user explicitly starts monitoring. Preparing the Setup URI and QR code does not contact the signalling relay. The page must remain open while monitoring.
The primary path checks one target device. The user selects either desktop or mobile before generating the configuration. Starting a fresh check reloads the page, creates a new room, and resets the page-scoped diagnostic baseline; this remains the clearest way to compare devices.
After the first connection succeeds, the page can scroll back to the existing QR code without generating new credentials. It also offers an optional same-room second-device attempt. This keeps the browser and first device connected, records the current diagnostic totals and active `RTCPeerConnection` identifiers as a local baseline, and reuses the exact Setup URI for another empty Vault. It reports an additional connection only when both the successful-connection total and the number of simultaneous active connections increase, with an active connection identifier absent from the baseline. A reconnect which replaces the first connection can therefore increase negotiation counters without satisfying the additional-connection result.
The same-room result remains a convenience rather than an isolated comparison. Its totals are still cumulative, a connection is not a device identity, it depends on the first device remaining connected, and unusual reconnect timing can be harder to interpret than a fresh room. The interface keeps the fresh-check action available and explains this limitation beside the additional result.
The page displays the following Commonlib diagnostic totals:
- new connection states;
- successful connection states;
- failed connection states; and
- closed connection states.
A successful total greater than zero proves that this browser and the target device established at least one WebRTC connection in the generated room. That successful result remains valid if a later closed or failed total increases. A failed total without a successful total describes a failed attempt, not a final verdict, because the transport may retry. If no success has been observed after the documented observation period, the result is inconclusive for P2P on the current network. The page recommends CouchDB for predictable synchronisation when repeated checks on the intended networks do not connect.
The page does not claim to verify document transfer, conflict handling, sustained connectivity, background execution, or a direct desktop-to-mobile path. An optional final test can use two disposable empty Vaults and a note round trip directly between the user's devices. That direct test is more representative, but its result cannot be observed reliably by the browser page without a separate reporting protocol.
## Security and privacy
All credentials are generated with Web Crypto in the browser. The Setup URI is encrypted, its passphrase is shown separately, and neither value is uploaded merely by preparing the check. Their read-only fields disable browser autocomplete and spell-check; copying either value to the system clipboard remains an explicit user action. Starting monitoring uses the configured signalling relay, which is the existing public relay by default, and exposes the usual P2P signalling metadata to that service. WebRTC and optional relay or TURN behaviour retain their existing Commonlib security and privacy properties.
The production page does not accept a relay override from a deployed origin. A `relay` query parameter is honoured only when the page itself is served from a loopback host, and only for `ws:` or `wss:` URLs. This narrow boundary allows the real-Obsidian E2E scenario to use the local Compose relay without making an arbitrary relay query part of the hosted user workflow.
The generated configuration is disposable. It must be used only in a dedicated empty Vault, must not replace a production Vault's settings, and must not be retained as the credentials for a later production setup. The browser page stores no ordinary Vault files for this workflow, although browser-local database infrastructure is opened to satisfy the existing WebPeer runtime contract.
## Alternatives rejected
### Display the result in the LiveSync plug-in
The observation belongs to the browser reference peer and does not prove LiveSync document synchronisation. Keeping the result beside the QR code makes the evidence and its owner explicit.
### Use browser, desktop, and mobile simultaneously as the primary comparison
The diagnostic totals are cumulative and do not attribute an `RTCPeerConnection` to a named target device. Separate random rooms provide a clearer result. Same-room reuse is retained only as an explicit convenience, with a counter baseline and another simultaneous active connection required for its result.
### Treat any failure increment as a final failure
Negotiation can fail and then retry successfully. A later successful increment takes precedence over earlier failure increments.
### Treat browser success as proof of device-to-device synchronisation
Browser-to-device success is a useful preflight check, but it does not exercise the exact desktop-to-mobile network path or a LiveSync note round trip.
### Put the Setup URI passphrase in the QR code
Keeping the encrypted URI and passphrase separate preserves the existing Setup URI boundary and avoids turning one captured QR code into the complete configuration secret.
### Distribute one standalone downloaded HTML file
WebPeer already depends on a compiled module graph, origin-scoped storage, and secure browser capabilities. A hosted production build reuses those boundaries and can be updated consistently; a single self-contained file would require a separate bundling and trust model.
## Verification
Unit tests decode each generated Setup URI and verify its isolation identifier, random credentials, remote-profile selection, automatic-start policy, absence of a device-local name, and browser-only diagnostic setting. Pure outcome tests cover waiting, failed attempts, later success, and closure after success.
The WebPeer browser smoke test verifies that the production page can prepare a desktop or mobile check, render the QR code, display a separately formatted passphrase, expose zeroed counters without contacting the signalling relay, and return to the unchanged QR code. Pure tests verify that a reconnect which replaces the first connection does not satisfy the same-room additional-connection rule. The focused real-Obsidian E2E test serves the production page from loopback, starts the local Compose Nostr relay, applies the browser-generated Setup URI through visible onboarding in one empty Vault, records the same-room baseline, reuses the unchanged URI in a second empty Vault, and requires the browser to observe another simultaneous active connection before retaining a result screenshot. Existing Commonlib tests remain authoritative for diagnostic WebRTC wrapping, Trystero negotiation, and P2P lifecycle behaviour.
## Consequences
- Users can assess whether P2P is plausible on each intended device and network before relying on it.
- Results remain visibly scoped to connectivity rather than synchronisation correctness.
- A fresh test requires a fresh page session and disposable empty Vault; same-room reuse requires another empty Vault and retains cumulative counters.
- CouchDB remains the recommended predictable option when representative P2P checks repeatedly fail.
- Direct device-to-device note transfer remains a separate, optional verification step.
+78 -4
View File
@@ -58,7 +58,7 @@ A current Setup URI retains its remote profiles, display names, and separate mai
Step-by-step setup for Self-hosted LiveSync. You can setup Self-hosted LiveSync manually with Minimal setting items.
Completing manual CouchDB, Object Storage, or P2P setup creates the corresponding remote profile without replacing profiles which are already saved. CouchDB and Object Storage setup select the new profile as the main remote. P2P setup selects it for P2P use and, when the wizard is enabling LiveSync, also selects it as the main remote. A descriptive display name is generated and can be changed later.
Completing manual CouchDB, Object Storage, WebDAV, PostgREST, or P2P setup creates the corresponding remote profile without replacing profiles which are already saved. CouchDB, Object Storage, WebDAV, and PostgREST setup select the new profile as the main remote. P2P setup selects it for P2P use and, when the wizard is enabling LiveSync, also selects it as the main remote. A descriptive display name is generated and can be changed later.
#### Enable LiveSync
@@ -171,7 +171,7 @@ Show verbose log. Please enable when you report the logs
### 1. Connection settings
Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings****Saved connections**. Each profile represents a CouchDB database, an Object Storage connection, or a P2P configuration, and several profiles can be kept in one Vault.
Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings****Saved connections**. Each profile represents a CouchDB database, a Journal storage connection, or a P2P configuration, and several profiles can be kept in one Vault.
Each profile has an opaque identifier and a presentation name. The name does not need to be unique and is not used to select the profile. The main remote and the P2P remote are selected independently, so code and settings imports must preserve both selections rather than relying on a special identifier such as `default`.
@@ -301,11 +301,85 @@ Setting key: bucketCustomHeaders
Custom HTTP headers to include in every request sent to the Object Storage bucket. Specify them in the format `Header-Name: Value`, with each header on a new line.
#### Journal data format
Setting key: journalFormat
Existing Object Storage profiles use `opaque-v1` unless Adaptive Journal is selected explicitly. `adaptive-v1` uses an authenticated manifest and immutable Commit Bundles under a separate namespace, with larger Packs stored separately. Changing between formats requires an explicit remote Rebuild; LiveSync does not migrate or read both representations.
#### Pack retrieval
Setting key: packReadPolicy
Adaptive Journal can download a complete immutable Pack (`whole-pack`) or request only the required byte ranges (`range`). Complete Pack retrieval is the portable, throughput-oriented default. Selecting Range requires exact byte-range support from the configured S3-compatible endpoint. The connection test verifies that capability, and synchronisation refuses an unsupported selection before writing.
#### Test Connection
#### Apply Settings
### 6. CouchDB
### 6. WebDAV Journal
WebDAV Journal is an experimental remote provider configured when adding (``) or editing (`🔧`) a saved connection profile. Use a dedicated WebDAV collection or prefix rather than mixing Journal objects with unrelated files.
#### WebDAV connection
Setting key: webDAVactiveConnectionURI
The saved `sls+webdav` value contains the HTTP or HTTPS endpoint, optional username and password, collection prefix, custom headers, and internal-API preference. The setup dialogue parses and serialises this value; do not edit it manually or share it as plain text. Only HTTPS endpoints work on Obsidian Mobile.
**Use internal API** routes requests through Obsidian when browser-compatible requests are blocked by CORS. It is a compatibility option with different behaviour from standard browser fetch.
#### Journal data format
Setting key: journalFormat
`opaque-v1` is the compatible default. `adaptive-v1` uses authenticated immutable Commit Bundles and Packs under a separate remote representation. Existing Opaque Journal data is neither migrated nor read as Adaptive data. Changing formats requires an explicit remote Rebuild or a separate collection prefix.
#### Expected repository ID
Setting key: expectedRepositoryId
This optional base64url identity pins a trusted Adaptive repository. A trusted Setup URI can supply it. Leave it blank only when creating a repository or intentionally trusting the first compatible Adaptive repository reached.
#### Pack retrieval
Setting key: packReadPolicy
Complete Pack retrieval (`whole-pack`) is the portable, throughput-oriented default. Range retrieval (`range`) can reduce transferred bytes, but the endpoint must pass the exact byte-range check. Range support is optional and does not affect the safety of complete Pack retrieval.
#### Endpoint safety check
Adaptive setup writes, reads, lists, and removes disposable objects under a random reserved probe prefix. It checks binary fidelity, read-after-write and delete visibility, complete listing of the probe objects, and conditional creation which does not replace an existing object. Exact HTTP byte-range behaviour is reported separately as optional.
The dialogue reports required operations and Range support independently. Authentication, permission, availability, and invalid-response failures remain explicit failures rather than being reported as unsupported features. A format mismatch requires a remote Rebuild or restoration of the matching profile format.
Onboarding accepts the profile only after the selected policy passes. Adding or editing a profile in Settings also offers **Save without connecting**, which preserves a locally valid but unverified profile.
### 7. PostgREST Journal
PostgREST Journal is an experimental, Adaptive-only provider. It uses the packaged PostgREST RPC schema rather than exposing synchronisation tables or acting as a CouchDB replication endpoint.
#### PostgREST connection
Setting key: postgrestActiveConnectionURI
The saved `sls+postgrest` value contains the HTTP or HTTPS endpoint, exposed schema, Vault ID, Vault credential, optional client-safe API key, and internal-API preference. A trusted database administrator provisions the Vault ID and credential by following the SQL package instructions. Do not enter a database credential, Supabase secret key, or `service_role` JWT in a client profile.
Only HTTPS endpoints work on Obsidian Mobile. **Use internal API** routes requests through Obsidian when browser-compatible requests are blocked by CORS.
#### Adaptive format and repository identity
PostgREST always uses `adaptive-v1` with native Chunk rows and does not expose Opaque Journal, object Pack, Catalogue, Delta, or Range-retrieval options. Existing data in another Journal format is not read or migrated. Changing the wire or SQL format requires detection and a remote Rebuild.
Setting key: expectedRepositoryId
The optional expected repository ID pins a trusted Adaptive repository in the same way as other Adaptive Journal providers.
#### Server capability check
The setup dialogue verifies the installed RPC operations, Vault authentication, and exact binary behaviour through the provider-neutral Journal inspection boundary. Onboarding accepts the profile only after this check succeeds. Settings can preserve a locally valid profile without connecting so that an unavailable endpoint can be corrected later.
### 8. CouchDB
These settings are configured within the CouchDB Setup dialogue when adding (``) or editing (`🔧`) a CouchDB connection profile.
@@ -399,7 +473,7 @@ This optional check reads the CouchDB server configuration through Obsidian's in
#### Apply Settings
### 7. Peer-to-Peer (P2P) Synchronisation
### 8. Peer-to-Peer (P2P) Synchronisation
#### Enable P2P Synchronisation
+15
View File
@@ -44,6 +44,9 @@
"test:contract:context:obsidian": "npm run build && npm run test:e2e:obsidian:smoke",
"test:e2e:cli": "npm run test:e2e:ci --workspace self-hosted-livesync-cli",
"test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli",
"test:e2e:cli:adaptive-s3": "npm run test:e2e:adaptive-s3 --workspace self-hosted-livesync-cli",
"test:e2e:cli:adaptive-webdav": "npm run test:e2e:adaptive-webdav --workspace self-hosted-livesync-cli",
"test:e2e:cli:adaptive-postgrest": "npm run test:e2e:adaptive-postgrest --workspace self-hosted-livesync-cli",
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
@@ -60,6 +63,7 @@
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
"test:e2e:obsidian:remote-setup-providers": "tsx test/e2e-obsidian/scripts/remote-setup-providers.ts",
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
@@ -67,8 +71,15 @@
"test:e2e:obsidian:couchdb-manual-setup-workflow": "tsx test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts",
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
"test:e2e:obsidian:adaptive-webdav": "tsx test/e2e-obsidian/scripts/adaptive-webdav.ts",
"test:e2e:obsidian:adaptive-webdav:services": "tsx test/e2e-obsidian/scripts/adaptive-webdav.ts --manage-webdav",
"test:e2e:obsidian:adaptive-postgrest": "tsx test/e2e-obsidian/scripts/adaptive-postgrest.ts",
"test:e2e:obsidian:adaptive-postgrest:services": "tsx test/e2e-obsidian/scripts/adaptive-postgrest.ts --manage-postgrest",
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
"test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts",
"pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer",
"test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts",
"test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p",
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
"test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts",
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
@@ -91,6 +102,10 @@
"test:docker-s3:start": "npm run test:docker-s3:up && sleep 3 && npm run test:docker-s3:init",
"test:docker-s3:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/minio-stop.sh",
"test:docker-s3:stop": "npm run test:docker-s3:down",
"test:docker-webdav:start": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-webdav-fixture.ts start",
"test:docker-webdav:stop": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-webdav-fixture.ts stop",
"test:docker-postgrest:start": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-postgrest-fixture.ts start",
"test:docker-postgrest:stop": "npx dotenv-cli -e .env -e .test.env -- deno run --env-file=src/apps/cli/testdeno/.test.env -A --no-check src/apps/cli/testdeno/manage-postgrest-fixture.ts stop",
"test:docker-all:up": "npm run test:docker-couchdb:up ; npm run test:docker-s3:up",
"test:docker-all:init": "npm run test:docker-couchdb:init ; npm run test:docker-s3:init",
"test:docker-all:down": "npm run test:docker-couchdb:down ; npm run test:docker-s3:down",
+3 -2
View File
@@ -15,13 +15,13 @@ import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/co
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { useJournalSyncFeature } from "@vrtmrz/livesync-commonlib/journal-sync";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { AbstractModule } from "./modules/AbstractModule";
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
@@ -139,7 +139,6 @@ export class LiveSyncBaseCore<
public registerModules(extraModules: AbstractModule[] = []) {
this._registerModule(new ModuleLiveSyncMain(this));
this._registerModule(new ModuleConflictChecker(this));
this._registerModule(new ModuleReplicatorMinIO(this));
this._registerModule(new ModuleReplicatorCouchDB(this));
this._registerModule(new ModuleReplicator(this));
this._registerModule(new ModuleConflictResolver(this));
@@ -279,6 +278,8 @@ export class LiveSyncBaseCore<
usePrepareDatabaseForUse(this);
// Migration to multiple remote configurations
useRemoteConfigurationMigration(this);
// Journal providers are selected through the composed remote-provider registry.
useJournalSyncFeature(this);
}
}
+17 -3
View File
@@ -6,7 +6,7 @@ import {
type FilePathWithPrefix,
type ObsidianLiveSyncSettings,
REMOTE_COUCHDB,
REMOTE_MINIO,
isJournalRemoteType,
type EntryMilestoneInfo,
type EntryDoc,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
@@ -25,6 +25,8 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import type { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
import { journalProtocolConfigurationForSettings } from "@vrtmrz/livesync-commonlib/journal-storage";
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
function redactConnectionString(uri: string): string {
@@ -59,8 +61,20 @@ async function verifyRemoteState(
return false;
}
milestone = await dbRet.db.get(MILESTONE_DOCID);
} else if (settings.remoteType === REMOTE_MINIO) {
milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json");
} else if (isJournalRemoteType(settings.remoteType)) {
const journalReplicator = replicator as LiveSyncJournalReplicator;
if (journalProtocolConfigurationForSettings(settings).journalFormat === "adaptive-v1") {
try {
await journalReplicator.client.ensureCheckpointCachesAreFresh();
standardIo.writeStderr("[Verification] Adaptive Journal repository is available.\n");
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
standardIo.writeStderr(`[Verification] Failed to verify Adaptive Journal repository: ${message}\n`);
return false;
}
}
milestone = await (journalReplicator.client as JournalSyncCore).downloadJson("_00000000-milestone.json");
}
if (milestone) {
+61 -1
View File
@@ -2,7 +2,13 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
REMOTE_WEBDAV,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types";
@@ -717,6 +723,60 @@ describe("runCommand abnormal cases", () => {
expect(core.services.control.applySettings).not.toHaveBeenCalled();
});
it("verifies an Adaptive Journal repository without reading the legacy milestone", async () => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
settings.remoteType = REMOTE_MINIO;
settings.journalFormat = "adaptive-v1";
settings.packReadPolicy = "whole-pack";
const ensureCheckpointCachesAreFresh = vi.fn(async () => {});
core.services.replicator.getActiveReplicator.mockReturnValue({
nodeid: "test-node-id",
initializeDatabaseForReplication: vi.fn(async () => {}),
client: {
ensureCheckpointCachesAreFresh,
},
});
const result = await runCommand(makeOptions("mark-resolved", []), {
...context,
core,
});
expect(result).toBe(true);
expect(ensureCheckpointCachesAreFresh).toHaveBeenCalledTimes(1);
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
"[Verification] Adaptive Journal repository is available.\n"
);
});
it("uses the Adaptive verification path for a WebDAV remote", async () => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
settings.remoteType = REMOTE_WEBDAV;
settings.webDAVactiveConnectionURI = "sls+webdav://dav.example/dav";
settings.journalFormat = "adaptive-v1";
settings.packReadPolicy = "whole-pack";
const ensureCheckpointCachesAreFresh = vi.fn(async () => {});
core.services.replicator.getActiveReplicator.mockReturnValue({
nodeid: "test-node-id",
initializeDatabaseForReplication: vi.fn(async () => {}),
client: {
ensureCheckpointCachesAreFresh,
},
});
const result = await runCommand(makeOptions("mark-resolved", []), {
...context,
core,
});
expect(result).toBe(true);
expect(ensureCheckpointCachesAreFresh).toHaveBeenCalledTimes(1);
});
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const mocks = vi.hoisted(() => ({
setLogHandler: vi.fn(),
}));
vi.mock("./services/NodeServiceHub", () => ({
NodeServiceContext: class {},
NodeServiceHub: class {
API = {
addLog: {
setHandler: mocks.setLogHandler,
},
};
},
}));
import { main } from "./main";
function createStandardIoMock() {
return {
readStdin: vi.fn(async () => ""),
prompt: vi.fn(async () => ""),
writeStdout: vi.fn(),
writeStderr: vi.fn(),
};
}
describe("CLI log handler", () => {
const originalArgv = process.argv.slice();
let databasePath: string;
beforeEach(async () => {
databasePath = await mkdtemp(join(tmpdir(), "livesync-cli-log-handler-"));
mocks.setLogHandler.mockReset();
mocks.setLogHandler.mockImplementation(() => {
throw new Error("__LOG_HANDLER_CONFIGURED__");
});
});
afterEach(async () => {
process.argv = originalArgv.slice();
await rm(databasePath, { recursive: true, force: true });
});
it("replaces the default Headless API log handler", async () => {
process.argv = ["node", "livesync-cli", databasePath, "remote-ls"];
await expect(main(createStandardIoMock())).rejects.toThrow("__LOG_HANDLER_CONFIGURED__");
expect(mocks.setLogHandler).toHaveBeenCalledWith(expect.any(Function), true);
});
});
+27 -4
View File
@@ -37,6 +37,14 @@ defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
/** Injectable command boundary used by CLI integration probes. */
export type CliCommandRunner = (options: CLIOptions, context: CLICommandContext) => Promise<boolean>;
const SETTINGS_MANAGEMENT_COMMANDS: ReadonlySet<CLICommand> = new Set([
"setup",
"remote-add",
"remote-rm",
"remote-set",
"remote-activate",
]);
function printHelp(standardIo: StandardIo): void {
writeStdoutLine(
standardIo,
@@ -274,7 +282,10 @@ export async function main(
) {
const options = parseArgs(standardIo);
if (options.interval && options.command !== "daemon") {
writeStderrLine(standardIo, `Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
writeStderrLine(
standardIo,
`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`
);
}
const avoidStdoutNoise =
options.command === "cat" ||
@@ -399,12 +410,15 @@ export async function main(
if (!options.verbose) return;
}
writeStderrLine(standardIo, prefix, message);
});
}, true);
// Prevent replication result from being processed automatically in non-daemon commands.
// In daemon mode the default handler must run so changes are applied to the filesystem.
if (options.command !== "daemon") {
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
writeStderrLine(standardIo, `[Info] Replication result received, but not processed automatically in CLI mode.`);
writeStderrLine(
standardIo,
`[Info] Replication result received, but not processed automatically in CLI mode.`
);
return await Promise.resolve(true);
}, -100);
}
@@ -506,7 +520,7 @@ export async function main(
// Save the settings file before any lifecycle events can mutate and persist them.
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
// various code paths persist the clobbered state to disk. We restore on shutdown.
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!);
let settingsBackup: string | null = await fs.readFile(settingsPath, "utf-8").catch(() => null);
// Restore settings file on any exit to undo lifecycle mutations.
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
@@ -576,6 +590,15 @@ export async function main(
settingsPath,
originalSyncSettings,
});
if (result && SETTINGS_MANAGEMENT_COMMANDS.has(options.command)) {
// Settings management is intentional, unlike the temporary changes made by suspendAllSync().
// setup replaces the complete configuration, while remote profile commands must retain the
// synchronisation mode which was active before the command lifecycle suspended it.
if (options.command !== "setup") {
await core.services.setting.applyPartial(originalSyncSettings, true);
}
settingsBackup = await fs.readFile(settingsPath, "utf-8");
}
if (!result) {
writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
process.exitCode = 1;
+6
View File
@@ -22,6 +22,12 @@
"pretest:e2e:ci": "npm run build",
"test:e2e:ci": "deno task --cwd testdeno test:ci",
"test:e2e:p2p": "deno task --cwd testdeno test:p2p:compose",
"pretest:e2e:adaptive-s3": "npm run build",
"test:e2e:adaptive-s3": "deno task --cwd testdeno test:adaptive-journal-s3",
"pretest:e2e:adaptive-webdav": "npm run build",
"test:e2e:adaptive-webdav": "deno task --cwd testdeno test:adaptive-journal-webdav",
"pretest:e2e:adaptive-postgrest": "npm run build",
"test:e2e:adaptive-postgrest": "deno task --cwd testdeno test:adaptive-journal-postgrest",
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
"test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh",
"pretest:e2e:all": "npm run build",
@@ -25,6 +25,10 @@ type SerializableContainer =
| {
[NODE_KV_TYPED_KEY]: "ArrayBuffer";
[NODE_KV_VALUES_KEY]: number[];
}
| {
[NODE_KV_TYPED_KEY]: "BigInt";
[NODE_KV_VALUES_KEY]: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -32,6 +36,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
function serializeForNodeKV(value: unknown): unknown {
if (typeof value === "bigint") {
return {
[NODE_KV_TYPED_KEY]: "BigInt",
[NODE_KV_VALUES_KEY]: value.toString(10),
} satisfies SerializableContainer;
}
if (value instanceof Set) {
return {
[NODE_KV_TYPED_KEY]: "Set",
@@ -78,6 +88,9 @@ function deserializeFromNodeKV(value: unknown): unknown {
if (taggedType === "ArrayBuffer" && Array.isArray(taggedValues)) {
return Uint8Array.from(taggedValues).buffer;
}
if (taggedType === "BigInt" && typeof taggedValues === "string" && /^-?(?:0|[1-9]\d*)$/u.test(taggedValues)) {
return BigInt(taggedValues);
}
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deserializeFromNodeKV(v)]));
}
@@ -1,8 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
function createInitialisableDependencies(): {
dependencies: NodeKeyValueDBDependencies;
initialise: () => Promise<boolean>;
} {
let initialise: (() => Promise<boolean>) | undefined;
const dependencies = {
appLifecycle: {
onSettingLoaded: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
initialise = handler;
}),
},
},
databaseEvents: {
onResetDatabase: { addHandler: vi.fn() },
onDatabaseInitialisation: { addHandler: vi.fn() },
onUnloadDatabase: { addHandler: vi.fn() },
onCloseDatabase: { addHandler: vi.fn() },
},
vault: {},
} as unknown as NodeKeyValueDBDependencies;
return {
dependencies,
initialise: async () => {
if (!initialise) throw new Error("Initialisation handler was not registered");
return await initialise();
},
};
}
describe("NodeKeyValueDBService.openSimpleStore", () => {
it("creates a namespaced store handle before the backing database is initialised", () => {
const dependencies = {
@@ -44,4 +75,29 @@ describe("NodeKeyValueDBService.openSimpleStore", () => {
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
});
it("preserves bigint values used by Adaptive Journal state", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-node-kv-bigint-"));
const filePath = path.join(tempDir, "keyvalue-db.json");
const writerState = {
lastCommittedSequence: 9007199254740993n,
pendingCommit: { sequence: 18446744073709551615n },
writerEpoch: "test-writer-epoch",
};
try {
const firstLifecycle = createInitialisableDependencies();
const first = new NodeKeyValueDBService(createServiceContext(), firstLifecycle.dependencies, filePath);
await expect(firstLifecycle.initialise()).resolves.toBe(true);
await first.openSimpleStore("adaptive").set("writer-state", writerState);
const secondLifecycle = createInitialisableDependencies();
const second = new NodeKeyValueDBService(createServiceContext(), secondLifecycle.dependencies, filePath);
await expect(secondLifecycle.initialise()).resolves.toBe(true);
await expect(second.openSimpleStore("adaptive").get("writer-state")).resolves.toEqual(writerState);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
+4 -1
View File
@@ -34,7 +34,10 @@
"test:e2e-matrix:couchdb-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc0' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:couchdb-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc1' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:minio-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc0' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts"
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts",
"test:adaptive-journal-s3": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-s3.ts",
"test:adaptive-journal-webdav": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-webdav.ts",
"test:adaptive-journal-postgrest": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-postgrest.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.13",
@@ -0,0 +1,12 @@
do $$
begin
if not exists (select 1 from pg_roles where rolname = 'livesync_postgrest_anon') then
create role livesync_postgrest_anon nologin;
end if;
if not exists (select 1 from pg_roles where rolname = 'livesync_postgrest_authenticator') then
create role livesync_postgrest_authenticator noinherit login password 'integration-password';
end if;
end
$$;
grant livesync_postgrest_anon to livesync_postgrest_authenticator;
@@ -0,0 +1,6 @@
insert into livesync_private.adaptive_vaults (vault_id, credential_digest)
values (
'adaptive-cli-vault-01',
livesync_private.sha256(convert_to('adaptive-cli-vault-credential-0000000000001', 'UTF8'))
)
on conflict (vault_id) do update set credential_digest = excluded.credential_digest;
+11 -2
View File
@@ -23,6 +23,7 @@ export interface CliResult {
export const TEE_ENABLED = Deno.env.get("LIVESYNC_TEST_TEE") === "1";
const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1";
const DEBUG_ENABLED = Deno.env.get("LIVESYNC_CLI_DEBUG") === "1";
const SETUP_URI_PREFIX = "obsidian://setuplivesync?settings=";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -39,8 +40,16 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array {
return out;
}
export function redactCliSensitiveText(value: string): string {
return value
.replace(/(obsidian:\/\/setuplivesync\?settings=)[^\s"']+/gu, "$1<redacted>")
.replace(/(sls\+[^:\s]+:\/\/)[^/?#@\s]*@/gu, "$1<redacted>@");
}
export function formatTeeCommand(args: string[]): string {
return ["node", CLI_DIST, ...args].map((part) => JSON.stringify(part)).join(" ");
const redactArgument = (argument: string): string =>
argument.startsWith(SETUP_URI_PREFIX) ? `${SETUP_URI_PREFIX}<redacted>` : redactCliSensitiveText(argument);
return ["node", CLI_DIST, ...args.map(redactArgument)].map((part) => JSON.stringify(part)).join(" ");
}
export function createLineTeeWriter(
@@ -57,7 +66,7 @@ export function createLineTeeWriter(
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}]\n`));
headerWritten = true;
}
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}] ${line}\n`));
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}] ${redactCliSensitiveText(line)}\n`));
};
const flush = (final = false) => {
+487 -12
View File
@@ -6,6 +6,8 @@
* available — including Windows — without needing bash.
*/
import { join } from "@std/path";
type DockerInvoker = {
bin: string;
prefix: string[];
@@ -15,6 +17,7 @@ type DockerInvoker = {
let dockerInvokerPromise: Promise<DockerInvoker> | null = null;
const DOCKER_TEE = Deno.env.get("LIVESYNC_DOCKER_TEE") === "1" || Deno.env.get("LIVESYNC_TEST_TEE") === "1";
const trackedContainers = new Set<string>();
const trackedNetworks = new Set<string>();
const CLEANUP_SIGNALS: Deno.Signal[] = ["SIGINT", "SIGTERM"];
let signalCleanupHandlersInstalled = false;
let signalCleanupInProgress = false;
@@ -62,17 +65,28 @@ async function collectStream(
return out;
}
async function runCommand(bin: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
async function runCommand(
bin: string,
args: string[],
stdinData?: Uint8Array
): Promise<{ code: number; stdout: string; stderr: string }> {
try {
const child = new Deno.Command(bin, {
args,
stdin: "null",
stdin: stdinData ? "piped" : "null",
stdout: "piped",
stderr: "piped",
}).spawn();
const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => Deno.stdout.writeSync(chunk) : null);
const stderrPromise = collectStream(child.stderr, DOCKER_TEE ? (chunk) => Deno.stderr.writeSync(chunk) : null);
const [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise]);
const stdinPromise = stdinData
? (async () => {
const writer = child.stdin.getWriter();
await writer.write(stdinData);
await writer.close();
})()
: Promise.resolve();
const [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise, stdinPromise]);
const dec = new TextDecoder();
const result = {
code: status.code,
@@ -163,6 +177,20 @@ async function getDockerInvoker(): Promise<DockerInvoker> {
}
async function docker(...args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return await dockerCommand(undefined, ...args);
}
async function dockerWithInput(
input: Uint8Array,
...args: string[]
): Promise<{ code: number; stdout: string; stderr: string }> {
return await dockerCommand(input, ...args);
}
async function dockerCommand(
input: Uint8Array | undefined,
...args: string[]
): Promise<{ code: number; stdout: string; stderr: string }> {
const invoker = await getDockerInvoker();
// Either:
@@ -176,7 +204,7 @@ async function docker(...args: string[]): Promise<{ code: number; stdout: string
: args
: [...invoker.prefix, ...args];
const r = await runCommand(invoker.bin, finalArgs);
const r = await runCommand(invoker.bin, finalArgs, input);
return { code: r.code, stdout: r.stdout, stderr: r.stderr };
}
@@ -195,12 +223,21 @@ async function stopAndRemoveContainer(container: string): Promise<void> {
async function cleanupTrackedContainers(reason: string): Promise<void> {
const names = [...trackedContainers];
if (names.length === 0) return;
if (names.length > 0) {
console.warn(`[WARN] cleaning up tracked containers on ${reason}: ${names.join(", ")}`);
for (const container of names.reverse()) {
await stopAndRemoveContainer(container);
trackedContainers.delete(container);
}
}
console.warn(`[WARN] cleaning up tracked containers on ${reason}: ${names.join(", ")}`);
for (const container of names.reverse()) {
await stopAndRemoveContainer(container);
trackedContainers.delete(container);
const networks = [...trackedNetworks];
if (networks.length > 0) {
console.warn(`[WARN] cleaning up tracked networks on ${reason}: ${networks.join(", ")}`);
for (const network of networks.reverse()) {
await docker("network", "rm", network).catch(() => {});
trackedNetworks.delete(network);
}
}
}
@@ -250,7 +287,19 @@ function trackContainer(container: string): void {
function untrackContainer(container: string): void {
trackedContainers.delete(container);
if (trackedContainers.size === 0) {
if (trackedContainers.size === 0 && trackedNetworks.size === 0) {
removeSignalCleanupHandlers();
}
}
function trackNetwork(network: string): void {
ensureSignalCleanupHandlers();
trackedNetworks.add(network);
}
function untrackNetwork(network: string): void {
trackedNetworks.delete(network);
if (trackedContainers.size === 0 && trackedNetworks.size === 0) {
removeSignalCleanupHandlers();
}
}
@@ -327,8 +376,49 @@ const COUCHDB_CONTAINER = "couchdb-test";
const COUCHDB_IMAGE = "couchdb:3.5.0";
const MINIO_CONTAINER = "minio-test";
const MINIO_IMAGE = "minio/minio";
const MINIO_MC_IMAGE = "minio/mc";
const MINIO_IMAGE = "minio/minio:RELEASE.2025-04-22T22-12-26Z";
const MINIO_MC_IMAGE = "minio/mc:RELEASE.2025-04-16T18-13-26Z";
const WEBDAV_CONTAINER = "webdav-test";
const WEBDAV_IMAGE = "httpd:2.4.68";
const POSTGRES_CONTAINER = "postgrest-postgres-test";
const POSTGRES_IMAGE = "postgres:15-alpine";
const POSTGREST_CONTAINER = "postgrest-test";
const POSTGREST_IMAGE = "postgrest/postgrest:v14.16";
const POSTGREST_NETWORK = "postgrest-test";
const POSTGREST_DATABASE_PASSWORD = "integration-password";
const POSTGREST_VAULT_ID = "adaptive-cli-vault-01";
const POSTGREST_VAULT_CREDENTIAL = "adaptive-cli-vault-credential-0000000000001";
const WEBDAV_HTTPD_CONFIG = `ServerRoot "/usr/local/apache2"
Listen 80
LoadModule mpm_event_module modules/mod_mpm_event.so
LoadModule authn_core_module modules/mod_authn_core.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule dav_module modules/mod_dav.so
LoadModule dav_fs_module modules/mod_dav_fs.so
LoadModule unixd_module modules/mod_unixd.so
User www-data
Group www-data
ServerName localhost
DocumentRoot "/usr/local/apache2/htdocs"
PidFile "/tmp/httpd.pid"
ErrorLog "/proc/self/fd/2"
LogLevel warn
DavLockDB "/usr/local/apache2/var/DavLock"
DavLockDiscovery Off
<Directory "/usr/local/apache2/htdocs">
AllowOverride None
Require all granted
</Directory>
<Directory "/usr/local/apache2/htdocs/dav">
Dav On
</Directory>
`;
export async function stopCouchdb(): Promise<void> {
await stopAndRemoveContainer(COUCHDB_CONTAINER);
@@ -466,6 +556,70 @@ export async function stopMinio(): Promise<void> {
untrackContainer(MINIO_CONTAINER);
}
export async function listMinioObjectKeys(
minioEndpoint: string,
accessKey: string,
secretKey: string,
bucket: string
): Promise<string[]> {
const cmd =
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
`mc ls --recursive --json myminio/${shQuote(bucket)}`;
const result = await docker(
"run",
"--rm",
"--network",
"host",
"--entrypoint",
"/bin/sh",
MINIO_MC_IMAGE,
"-c",
cmd
);
if (result.code !== 0) {
throw new Error(`Could not list MinIO objects: ${result.stderr.trim()}`);
}
return result.stdout
.split(/\r?\n/u)
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as { key?: unknown })
.map(({ key }) => {
if (typeof key !== "string") {
throw new Error("MinIO returned an object without a string key");
}
return key;
})
.sort();
}
export async function readMinioObjectText(
minioEndpoint: string,
accessKey: string,
secretKey: string,
bucket: string,
key: string
): Promise<string> {
const cmd =
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
`mc cat myminio/${shQuote(bucket)}/${shQuote(key)}`;
const result = await docker(
"run",
"--rm",
"--network",
"host",
"--entrypoint",
"/bin/sh",
MINIO_MC_IMAGE,
"-c",
cmd
);
if (result.code !== 0) {
throw new Error(`Could not read MinIO object ${key}: ${result.stderr.trim()}`);
}
return result.stdout;
}
async function initMinioBucket(
minioEndpoint: string,
accessKey: string,
@@ -561,6 +715,327 @@ export async function startMinio(
await waitForMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
}
// ---------------------------------------------------------------------------
// WebDAV
// ---------------------------------------------------------------------------
export async function stopWebDAV(): Promise<void> {
await stopAndRemoveContainer(WEBDAV_CONTAINER);
untrackContainer(WEBDAV_CONTAINER);
}
async function waitForWebDAV(endpoint: string): Promise<void> {
for (let attempt = 0; attempt < 30; attempt += 1) {
try {
const response = await fetch(endpoint, {
method: "PROPFIND",
headers: { Depth: "0" },
signal: AbortSignal.timeout(3000),
});
await response.body?.cancel().catch(() => {});
if (response.status === 207) return;
} catch {
// The container is still starting.
}
await sleep(500);
}
throw new Error(`WebDAV collection did not become ready: ${endpoint}`);
}
export async function startWebDAV(endpoint: string): Promise<void> {
const url = new URL(endpoint);
if (url.protocol !== "http:" || (url.hostname !== "127.0.0.1" && url.hostname !== "localhost")) {
throw new Error(`Managed WebDAV requires a local HTTP endpoint, received: ${endpoint}`);
}
if (url.pathname.replace(/\/+$/u, "") !== "/dav") {
throw new Error(`Managed WebDAV requires the /dav collection, received: ${endpoint}`);
}
const hostPort = url.port || "80";
const encodedConfig = btoa(WEBDAV_HTTPD_CONFIG);
const startCommand = `printf '%s' '${encodedConfig}' | base64 -d > /tmp/httpd.conf && exec httpd -DFOREGROUND -f /tmp/httpd.conf`;
console.log("[INFO] stopping leftover WebDAV container if present");
await stopWebDAV().catch(() => {});
console.log("[INFO] starting Apache WebDAV test container");
await dockerOrFail(
"run",
"-d",
"--name",
WEBDAV_CONTAINER,
"-p",
`${hostPort}:80`,
"--tmpfs",
"/usr/local/apache2/htdocs/dav:mode=0777",
"--tmpfs",
"/usr/local/apache2/var:mode=0777",
"--entrypoint",
"/bin/sh",
WEBDAV_IMAGE,
"-c",
startCommand
);
trackContainer(WEBDAV_CONTAINER);
await waitForWebDAV(endpoint);
}
function directWebDAVObjectUrl(collectionEndpoint: string, key: string): string {
return `${collectionEndpoint.replace(/\/+$/u, "")}/${encodeURIComponent(key)}`;
}
function webDAVRequestHeaders(): HeadersInit {
const username = Deno.env.get("WEBDAV_USERNAME") ?? "";
const password = Deno.env.get("WEBDAV_PASSWORD") ?? "";
if (!username && !password) return {};
return { Authorization: `Basic ${btoa(`${username}:${password}`)}` };
}
export async function listWebDAVObjectKeys(collectionEndpoint: string): Promise<string[]> {
const collectionUrl = new URL(`${collectionEndpoint.replace(/\/+$/u, "")}/`);
const response = await fetch(collectionUrl, {
method: "PROPFIND",
headers: { ...webDAVRequestHeaders(), Depth: "1" },
});
if (response.status !== 207) {
throw new Error(`Could not list WebDAV objects: HTTP ${response.status}`);
}
const xml = await response.text();
const hrefs = [
...xml.matchAll(/<(?:[A-Za-z_][\w.-]*:)?href\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?href>/giu),
].map((match) => match[1].trim());
const basePath = decodeURIComponent(collectionUrl.pathname);
const keys = new Set<string>();
for (const href of hrefs) {
const path = decodeURIComponent(new URL(href, collectionUrl).pathname);
if (!path.startsWith(basePath)) continue;
const key = path.slice(basePath.length).replace(/\/$/u, "");
if (key && !key.includes("/")) keys.add(key);
}
return [...keys].sort();
}
export async function readWebDAVObjectText(collectionEndpoint: string, key: string): Promise<string> {
const response = await fetch(directWebDAVObjectUrl(collectionEndpoint, key), { headers: webDAVRequestHeaders() });
if (!response.ok) {
throw new Error(`Could not read WebDAV object ${key}: HTTP ${response.status}`);
}
return await response.text();
}
// ---------------------------------------------------------------------------
// PostgREST
// ---------------------------------------------------------------------------
export interface PostgRESTFixture {
endpoint: string;
vaultCredential: string;
vaultId: string;
}
export interface PostgRESTAdaptiveRowCounts {
chunks: number;
commits: number;
manifests: number;
writers: number;
}
async function waitForPostgres(): Promise<void> {
let consecutiveReadyChecks = 0;
for (let attempt = 0; attempt < 60; attempt += 1) {
const result = await docker("exec", POSTGRES_CONTAINER, "pg_isready", "-U", "postgres");
if (result.code === 0) {
consecutiveReadyChecks += 1;
if (consecutiveReadyChecks >= 3) return;
} else {
consecutiveReadyChecks = 0;
}
await sleep(500);
}
throw new Error("PostgreSQL did not become ready in time");
}
async function applyPostgresSql(sql: string): Promise<void> {
const result = await dockerWithInput(
new TextEncoder().encode(sql),
"exec",
"-i",
POSTGRES_CONTAINER,
"psql",
"-v",
"ON_ERROR_STOP=1",
"-U",
"postgres",
"-d",
"postgres"
);
if (result.code !== 0) {
throw new Error(`Could not initialise PostgreSQL for PostgREST: ${result.stderr.trim()}`);
}
}
async function waitForPostgREST(fixture: PostgRESTFixture): Promise<void> {
const capabilityUrl = `${fixture.endpoint.replace(/\/+$/u, "")}/rpc/livesync_adaptive_capabilities`;
for (let attempt = 0; attempt < 30; attempt += 1) {
try {
const response = await fetch(capabilityUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Accept-Profile": "livesync_api",
"Content-Profile": "livesync_api",
"Content-Type": "application/json",
"X-LiveSync-Vault-Credential": fixture.vaultCredential,
"X-LiveSync-Vault-ID": fixture.vaultId,
},
body: "{}",
signal: AbortSignal.timeout(3000),
});
await response.body?.cancel().catch(() => {});
if (response.ok) return;
} catch {
// PostgREST or its schema cache is still starting.
}
await sleep(500);
}
throw new Error(`PostgREST did not become ready: ${fixture.endpoint}`);
}
export async function stopPostgREST(): Promise<void> {
await stopAndRemoveContainer(POSTGREST_CONTAINER);
untrackContainer(POSTGREST_CONTAINER);
await stopAndRemoveContainer(POSTGRES_CONTAINER);
untrackContainer(POSTGRES_CONTAINER);
await docker("network", "rm", POSTGREST_NETWORK).catch(() => {});
untrackNetwork(POSTGREST_NETWORK);
}
export async function startPostgREST(endpoint: string): Promise<PostgRESTFixture> {
const endpointUrl = new URL(endpoint);
if (
endpointUrl.protocol !== "http:" ||
(endpointUrl.hostname !== "127.0.0.1" && endpointUrl.hostname !== "localhost")
) {
throw new Error(`Managed PostgREST requires a local HTTP endpoint, received: ${endpoint}`);
}
if (endpointUrl.pathname !== "/" || endpointUrl.search || endpointUrl.hash) {
throw new Error(`Managed PostgREST requires a root endpoint without query parameters, received: ${endpoint}`);
}
const fixture: PostgRESTFixture = {
endpoint: endpoint.replace(/\/+$/u, ""),
vaultCredential: POSTGREST_VAULT_CREDENTIAL,
vaultId: POSTGREST_VAULT_ID,
};
const fixtureDirectory = join(import.meta.dirname!, "..", "fixtures", "postgrest");
const commonlibSqlPath = join(
import.meta.dirname!,
"..",
"..",
"..",
"..",
"..",
"node_modules",
"@vrtmrz",
"livesync-commonlib",
"sql",
"postgrest",
"adaptive_journal_v1.sql"
);
console.log("[INFO] stopping leftover PostgREST test services if present");
await stopPostgREST().catch(() => {});
try {
await dockerOrFail("network", "create", POSTGREST_NETWORK);
trackNetwork(POSTGREST_NETWORK);
console.log("[INFO] starting PostgreSQL test container");
await dockerOrFail(
"run",
"-d",
"--name",
POSTGRES_CONTAINER,
"--network",
POSTGREST_NETWORK,
"--network-alias",
"postgres",
"-e",
`POSTGRES_PASSWORD=${POSTGREST_DATABASE_PASSWORD}`,
POSTGRES_IMAGE
);
trackContainer(POSTGRES_CONTAINER);
await waitForPostgres();
for (const sqlPath of [
join(fixtureDirectory, "00_roles.sql"),
commonlibSqlPath,
join(fixtureDirectory, "02_vault.sql"),
]) {
await applyPostgresSql(await Deno.readTextFile(sqlPath));
}
console.log("[INFO] starting PostgREST test container");
await dockerOrFail(
"run",
"-d",
"--name",
POSTGREST_CONTAINER,
"--network",
POSTGREST_NETWORK,
"-p",
`${endpointUrl.port || "80"}:3000`,
"-e",
"PGRST_DB_ANON_ROLE=livesync_postgrest_anon",
"-e",
"PGRST_DB_SCHEMAS=livesync_api",
"-e",
`PGRST_DB_URI=postgres://livesync_postgrest_authenticator:${POSTGREST_DATABASE_PASSWORD}@postgres:5432/postgres`,
"-e",
"PGRST_SERVER_PORT=3000",
POSTGREST_IMAGE
);
trackContainer(POSTGREST_CONTAINER);
await waitForPostgREST(fixture);
return fixture;
} catch (error) {
await stopPostgREST().catch(() => {});
throw error;
}
}
export async function readPostgRESTAdaptiveRowCounts(vaultId: string): Promise<PostgRESTAdaptiveRowCounts> {
if (!/^[A-Za-z0-9_-]{16,128}$/u.test(vaultId)) {
throw new Error(`Invalid PostgREST test Vault ID: ${vaultId}`);
}
const query = `select
(select count(*) from livesync_private.adaptive_v1_manifests where vault_id = '${vaultId}'),
(select count(*) from livesync_private.adaptive_v1_chunks where vault_id = '${vaultId}'),
(select count(*) from livesync_private.adaptive_v1_writers where vault_id = '${vaultId}'),
(select count(*) from livesync_private.adaptive_v1_commits where vault_id = '${vaultId}')`;
const output = await dockerOrFail(
"exec",
POSTGRES_CONTAINER,
"psql",
"-At",
"-F",
",",
"-U",
"postgres",
"-d",
"postgres",
"-c",
query
);
const [manifests, chunks, writers, commits] = output
.trim()
.split(",")
.map((value) => Number(value));
if (![manifests, chunks, writers, commits].every(Number.isSafeInteger)) {
throw new Error(`Could not parse PostgREST row counts: ${output}`);
}
return { chunks, commits, manifests, writers };
}
// ---------------------------------------------------------------------------
// P2P relay (strfry)
// ---------------------------------------------------------------------------
+49 -14
View File
@@ -13,7 +13,12 @@ export async function initSettingsFile(settingsFile: string): Promise<void> {
* Generate a full setup URI from a settings file via the Commonlib package API.
* Mirrors the bash flow in test-setup-put-cat-linux.sh.
*/
export async function generateSetupUriFromSettings(settingsFile: string, setupPassphrase: string): Promise<string> {
export async function generateSetupUriFromSettings(
settingsFile: string,
setupPassphrase: string,
preserveSettings = false,
runtimeVaultPassphrase?: string
): Promise<string> {
const script = [
"import { fs } from '@vrtmrz/livesync-commonlib/node';",
"import { encodeSettingsToSetupURI } from '@vrtmrz/livesync-commonlib/compat/API/processSetting';",
@@ -21,13 +26,18 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
" const settingsPath = process.env.SETTINGS_FILE;",
" const passphrase = process.env.SETUP_PASSPHRASE;",
" const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));",
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
" settings.couchDB_USER = 'dummy';",
" settings.couchDB_PASSWORD = 'dummy';",
" settings.liveSync = false;",
" settings.syncOnStart = false;",
" settings.syncOnSave = false;",
" if (process.env.RUNTIME_VAULT_PASSPHRASE !== undefined) {",
" settings.passphrase = process.env.RUNTIME_VAULT_PASSPHRASE;",
" }",
" if (process.env.PRESERVE_SETTINGS !== 'true') {",
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
" settings.couchDB_USER = 'dummy';",
" settings.couchDB_PASSWORD = 'dummy';",
" settings.liveSync = false;",
" settings.syncOnStart = false;",
" settings.syncOnSave = false;",
" }",
" const uri = await encodeSettingsToSetupURI(settings, passphrase);",
" process.stdout.write(uri.trim());",
"})();",
@@ -41,13 +51,18 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
await Deno.writeTextFile(scriptPath, script);
try {
const env: Record<string, string> = {
SETTINGS_FILE: settingsFile,
SETUP_PASSPHRASE: setupPassphrase,
PRESERVE_SETTINGS: preserveSettings ? "true" : "false",
};
if (runtimeVaultPassphrase !== undefined) {
env.RUNTIME_VAULT_PASSPHRASE = runtimeVaultPassphrase;
}
const cmd = new Deno.Command("npx", {
args: ["tsx", scriptPath],
cwd: CLI_DIR,
env: {
SETTINGS_FILE: settingsFile,
SETUP_PASSPHRASE: setupPassphrase,
},
env,
stdin: "null",
stdout: "piped",
stderr: "piped",
@@ -112,7 +127,7 @@ export async function applyCouchdbSettings(
export async function applyRemoteSyncSettings(
settingsFile: string,
options: {
remoteType: "COUCHDB" | "MINIO";
remoteType: "COUCHDB" | "MINIO" | "POSTGREST" | "WEBDAV";
couchdbUri?: string;
couchdbUser?: string;
couchdbPassword?: string;
@@ -121,10 +136,15 @@ export async function applyRemoteSyncSettings(
minioEndpoint?: string;
minioAccessKey?: string;
minioSecretKey?: string;
postgRESTConnectionURI?: string;
webDAVConnectionURI?: string;
encrypt?: boolean;
passphrase?: string;
enableCompression?: boolean;
usePathObfuscation?: boolean;
journalFormat?: "adaptive-v1" | "opaque-v1";
expectedRepositoryId?: string;
packReadPolicy?: "range" | "whole-pack";
}
): Promise<void> {
const data = JSON.parse(await Deno.readTextFile(settingsFile));
@@ -135,7 +155,7 @@ export async function applyRemoteSyncSettings(
data.couchDB_USER = options.couchdbUser;
data.couchDB_PASSWORD = options.couchdbPassword;
data.couchDB_DBNAME = options.couchdbDbname;
} else {
} else if (options.remoteType === "MINIO") {
data.remoteType = "MINIO";
data.bucket = options.minioBucket;
data.endpoint = options.minioEndpoint;
@@ -143,6 +163,21 @@ export async function applyRemoteSyncSettings(
data.secretKey = options.minioSecretKey;
data.region = "auto";
data.forcePathStyle = true;
} else if (options.remoteType === "POSTGREST") {
data.remoteType = "POSTGREST";
data.postgrestActiveConnectionURI = options.postgRESTConnectionURI;
} else {
data.remoteType = "WEBDAV";
data.webDAVactiveConnectionURI = options.webDAVConnectionURI;
}
if (options.journalFormat !== undefined) {
data.journalFormat = options.journalFormat;
}
if (options.expectedRepositoryId !== undefined) {
data.expectedRepositoryId = options.expectedRepositoryId;
}
if (options.packReadPolicy !== undefined) {
data.packReadPolicy = options.packReadPolicy;
}
data.liveSync = true;
@@ -0,0 +1,26 @@
import { startPostgREST, stopPostgREST } from "./helpers/docker.ts";
const action = Deno.args[0];
const endpoint = (
Deno.env.get("POSTGREST_ENDPOINT") ??
Deno.env.get("postgrestEndpoint") ??
"http://127.0.0.1:3001"
).replace(/\/+$/u, "");
try {
if (action === "start") {
await startPostgREST(endpoint);
} else if (action === "stop") {
await stopPostgREST();
} else {
throw new Error("Usage: manage-postgrest-fixture.ts <start|stop>");
}
} catch (error) {
if (action === "start") await stopPostgREST().catch(() => undefined);
console.error(error instanceof Error ? error.stack : error);
Deno.exit(1);
}
// The one-shot command intentionally leaves successful services running after
// 'start'; the matching 'stop' command owns their removal.
Deno.exit(0);
@@ -0,0 +1,27 @@
import { startWebDAV, stopWebDAV } from "./helpers/docker.ts";
const action = Deno.args[0];
const endpoint = (
Deno.env.get("WEBDAV_ENDPOINT") ??
Deno.env.get("webdavEndpoint") ??
"http://127.0.0.1:8088/dav"
).replace(/\/+$/u, "");
try {
if (action === "start") {
await startWebDAV(endpoint);
} else if (action === "stop") {
await stopWebDAV();
} else {
throw new Error("Usage: manage-webdav-fixture.ts <start|stop>");
}
} catch (error) {
if (action === "start") await stopWebDAV().catch(() => undefined);
console.error(error instanceof Error ? error.stack : error);
Deno.exit(1);
}
// The shared Docker helper installs signal cleanup listeners for long-running
// Deno tests. This one-shot fixture command intentionally leaves the container
// running after 'start'; the matching 'stop' command owns its removal.
Deno.exit(0);
+3
View File
@@ -11,6 +11,9 @@ const TASKS = [
"test:e2e-matrix:couchdb-enc1",
"test:e2e-matrix:minio-enc0",
"test:e2e-matrix:minio-enc1",
"test:adaptive-journal-s3",
"test:adaptive-journal-webdav",
"test:adaptive-journal-postgrest",
] as const;
for (const [index, task] of TASKS.entries()) {
@@ -0,0 +1,190 @@
import { assert, assertEquals } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import {
assertFilesEqual,
redactCliSensitiveText,
runCli,
runCliOrFail,
runCliWithInputOrFail,
sanitiseCatStdout,
} from "./helpers/cli.ts";
import { applyRemoteSyncSettings, generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts";
import { type PostgRESTFixture, startPostgREST, stopPostgREST } from "./helpers/docker.ts";
const BINARY_TEST_BYTES = 2 * 1024 * 1024;
function deterministicBytes(length: number, seed: number): Uint8Array {
const bytes = new Uint8Array(length);
let state = seed;
for (let index = 0; index < bytes.byteLength; index += 1) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
bytes[index] = state & 0xff;
}
return bytes;
}
function postgRESTConnectionURI(fixture: PostgRESTFixture): string {
const endpointUrl = new URL(fixture.endpoint);
const proxyUrl = new URL(`https://${endpointUrl.host}${endpointUrl.pathname}`);
proxyUrl.username = fixture.vaultId;
proxyUrl.password = fixture.vaultCredential;
if (endpointUrl.protocol === "http:") proxyUrl.searchParams.set("insecure", "true");
return `sls+postgrest:${proxyUrl.toString().slice("https:".length)}`;
}
function remoteIdFromListing(listing: string): string {
const line = listing
.split(/\r?\n/u)
.find((candidate) => candidate.includes("\tPostgREST E2E\t") || candidate.includes("\tPostgREST "));
const id = line?.split("\t", 1)[0];
if (!id) throw new Error(`PostgREST remote profile was not listed:\n${listing}`);
return id;
}
function externalFixture(endpoint: string): PostgRESTFixture {
return {
endpoint,
vaultCredential:
Deno.env.get("POSTGREST_VAULT_CREDENTIAL") ??
Deno.env.get("postgrestVaultCredential") ??
"adaptive-cli-vault-credential-0000000000001",
vaultId: Deno.env.get("POSTGREST_VAULT_ID") ?? Deno.env.get("postgrestVaultId") ?? "adaptive-cli-vault-01",
};
}
Deno.test("CLI tee output redacts a PostgREST Vault credential", () => {
assertEquals(
redactCliSensitiveText(
"exported sls+postgrest://adaptive-cli-vault-01:adaptive-cli-vault-credential@example.test/rest/v1"
),
"exported sls+postgrest://<redacted>@example.test/rest/v1"
);
});
Deno.test("e2e: two CLI vaults synchronise through Adaptive Journal PostgREST", async () => {
const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const endpoint = (
Deno.env.get("POSTGREST_ENDPOINT") ??
Deno.env.get("postgrestEndpoint") ??
"http://127.0.0.1:3001"
).replace(/\/+$/u, "");
const vaultPassphrase = "adaptive-journal-postgrest-cli-e2ee";
const setupPassphrase = "adaptive-journal-postgrest-cli-setup";
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
const fixture = shouldStartDocker ? await startPostgREST(endpoint) : externalFixture(endpoint);
const connectionURI = postgRESTConnectionURI(fixture);
await using workDir = await TempDir.create("livesync-cli-adaptive-journal-postgrest");
const vaultA = workDir.join("vault-a");
const vaultB = workDir.join("vault-b");
const settingsA = workDir.join("settings-a.json");
const settingsB = workDir.join("settings-b.json");
const binarySourceA = workDir.join("source-a.bin");
const binarySourceB = workDir.join("source-b.bin");
const binaryDestinationA = workDir.join("destination-a.bin");
const binaryDestinationB = workDir.join("destination-b.bin");
await Deno.mkdir(vaultA, { recursive: true });
await Deno.mkdir(vaultB, { recursive: true });
try {
await initSettingsFile(settingsA);
await applyRemoteSyncSettings(settingsA, {
remoteType: "POSTGREST",
postgRESTConnectionURI: connectionURI,
encrypt: true,
passphrase: vaultPassphrase,
enableCompression: false,
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
});
const addedRemote = await runCliOrFail(
vaultA,
"--settings",
settingsA,
"remote-add",
"PostgREST E2E",
connectionURI
);
const remoteId = addedRemote.trim().split("\t", 1)[0];
assert(remoteId, `remote-add did not return a profile ID:\n${addedRemote}`);
await runCliOrFail(vaultA, "--settings", settingsA, "remote-activate", remoteId);
const textPath = "adaptive/text.md";
const binaryPath = "adaptive/data.bin";
await runCliWithInputOrFail(`created-by-a-${suffix}\n`, vaultA, "--settings", settingsA, "put", textPath);
await Deno.writeFile(binarySourceA, deterministicBytes(BINARY_TEST_BYTES, 0x1a2b3c4d));
await runCliOrFail(vaultA, "--settings", settingsA, "push", binarySourceA, binaryPath);
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
const remoteListing = await runCliOrFail(vaultA, "--settings", settingsA, "remote-ls");
assertEquals(remoteIdFromListing(remoteListing), remoteId);
assert(
remoteListing
.split(/\r?\n/u)
.some((line) => line.startsWith(`${remoteId}\t`) && line.includes("\tactive\t")),
`Activated PostgREST profile was not listed as active:\n${remoteListing}`
);
const exportedConnection = (
await runCliOrFail(vaultA, "--settings", settingsA, "remote-export", remoteId)
).trim();
assert(exportedConnection.startsWith("sls+postgrest://"));
assert(exportedConnection.includes("journalFormat=adaptive-v1"));
assert(!exportedConnection.includes("packReadPolicy="));
const setupURI = await generateSetupUriFromSettings(settingsA, setupPassphrase, true, vaultPassphrase);
await initSettingsFile(settingsB);
await runCliWithInputOrFail(`${setupPassphrase}\n`, vaultB, "--settings", settingsB, "setup", setupURI);
const settingsAfterSetup = JSON.parse(await Deno.readTextFile(settingsB)) as {
encryptedPassphrase?: string;
postgrestActiveConnectionURI?: string;
};
assert(
typeof settingsAfterSetup.encryptedPassphrase === "string" &&
settingsAfterSetup.encryptedPassphrase.length > 0,
"setup did not persist the encrypted Vault passphrase"
);
assertEquals(settingsAfterSetup.postgrestActiveConnectionURI, connectionURI);
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
assertEquals(
sanitiseCatStdout(await runCliOrFail(vaultB, "--settings", settingsB, "cat", textPath)).trimEnd(),
`created-by-a-${suffix}`
);
await runCliOrFail(vaultB, "--settings", settingsB, "pull", binaryPath, binaryDestinationB);
await assertFilesEqual(binarySourceA, binaryDestinationB, "Adaptive Journal PostgREST transfer differs");
await runCliWithInputOrFail(`updated-by-b-${suffix}\n`, vaultB, "--settings", settingsB, "put", textPath);
await Deno.writeFile(binarySourceB, deterministicBytes(BINARY_TEST_BYTES, 0x5e6f7788));
await runCliOrFail(vaultB, "--settings", settingsB, "push", binarySourceB, binaryPath);
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
assertEquals(
sanitiseCatStdout(await runCliOrFail(vaultA, "--settings", settingsA, "cat", textPath)).trimEnd(),
`updated-by-b-${suffix}`
);
await runCliOrFail(vaultA, "--settings", settingsA, "pull", binaryPath, binaryDestinationA);
await assertFilesEqual(binarySourceB, binaryDestinationA, "Adaptive Journal PostgREST return transfer differs");
await runCliOrFail(vaultA, "--settings", settingsA, "rm", binaryPath);
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
const deleted = await runCli(vaultB, "--settings", settingsB, "cat", binaryPath);
assert(deleted.code !== 0, `Deleted binary remained readable:\n${deleted.combined}`);
const statusOutput = await runCliOrFail(vaultA, "--settings", settingsA, "remote-status", remoteId);
const statusJsonStart = statusOutput.indexOf("{");
assert(statusJsonStart >= 0, `PostgREST remote status did not contain JSON:\n${statusOutput}`);
const status = JSON.parse(statusOutput.slice(statusJsonStart)) as { estimatedSize?: unknown };
assert(
typeof status.estimatedSize === "number" && status.estimatedSize > 0,
`PostgREST remote status did not report a positive size: ${JSON.stringify(status)}`
);
} finally {
if (shouldStartDocker && !keepDocker) {
await stopPostgREST().catch(() => {});
}
}
});
@@ -0,0 +1,117 @@
import { assert, assertEquals } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { assertFilesEqual, runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
import { startMinio, stopMinio } from "./helpers/docker.ts";
const EXTERNAL_PACK_TEST_BYTES = 9 * 1024 * 1024;
function deterministicBytes(length: number, seed: number): Uint8Array {
const bytes = new Uint8Array(length);
let state = seed;
for (let index = 0; index < bytes.byteLength; index += 1) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
bytes[index] = state & 0xff;
}
return bytes;
}
function requireEnv(...keys: string[]): string {
for (const key of keys) {
const value = Deno.env.get(key)?.trim();
if (value) return value;
}
throw new Error(`Required environment variable is missing: ${keys.join(" or ")}`);
}
Deno.test("e2e: two CLI vaults synchronise through Adaptive Journal S3", async () => {
const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const endpoint = requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/u, "");
const accessKey = requireEnv("MINIO_ACCESS_KEY", "accessKey");
const secretKey = requireEnv("MINIO_SECRET_KEY", "secretKey");
const bucket = `${requireEnv("MINIO_BUCKET_NAME", "bucketName")}-${suffix}`;
const passphrase = "adaptive-journal-cli-e2e-passphrase";
await using workDir = await TempDir.create("livesync-cli-adaptive-journal-s3");
const vaultA = workDir.join("vault-a");
const vaultB = workDir.join("vault-b");
const settingsA = workDir.join("settings-a.json");
const settingsB = workDir.join("settings-b.json");
const binarySourceA = workDir.join("source-a.bin");
const binarySourceB = workDir.join("source-b.bin");
const binaryDestinationA = workDir.join("destination-a.bin");
const binaryDestinationB = workDir.join("destination-b.bin");
await Deno.mkdir(vaultA, { recursive: true });
await Deno.mkdir(vaultB, { recursive: true });
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
await startMinio(endpoint, accessKey, secretKey, bucket);
try {
await initSettingsFile(settingsA);
await initSettingsFile(settingsB);
await applyRemoteSyncSettings(settingsA, {
remoteType: "MINIO",
minioBucket: bucket,
minioEndpoint: endpoint,
minioAccessKey: accessKey,
minioSecretKey: secretKey,
encrypt: true,
passphrase,
enableCompression: false,
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
});
await applyRemoteSyncSettings(settingsB, {
remoteType: "MINIO",
minioBucket: bucket,
minioEndpoint: endpoint,
minioAccessKey: accessKey,
minioSecretKey: secretKey,
encrypt: true,
passphrase,
enableCompression: false,
journalFormat: "adaptive-v1",
packReadPolicy: "range",
});
const textPath = "adaptive/text.md";
const binaryPath = "adaptive/data.bin";
await runCliWithInputOrFail(`created-by-a-${suffix}\n`, vaultA, "--settings", settingsA, "put", textPath);
await Deno.writeFile(binarySourceA, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x1a2b3c4d));
await runCliOrFail(vaultA, "--settings", settingsA, "push", binarySourceA, binaryPath);
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
assertEquals(
sanitiseCatStdout(await runCliOrFail(vaultB, "--settings", settingsB, "cat", textPath)).trimEnd(),
`created-by-a-${suffix}`
);
await runCliOrFail(vaultB, "--settings", settingsB, "pull", binaryPath, binaryDestinationB);
await assertFilesEqual(binarySourceA, binaryDestinationB, "Adaptive Journal Range transfer differs");
await runCliWithInputOrFail(`updated-by-b-${suffix}\n`, vaultB, "--settings", settingsB, "put", textPath);
await Deno.writeFile(binarySourceB, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x5e6f7788));
await runCliOrFail(vaultB, "--settings", settingsB, "push", binarySourceB, binaryPath);
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
assertEquals(
sanitiseCatStdout(await runCliOrFail(vaultA, "--settings", settingsA, "cat", textPath)).trimEnd(),
`updated-by-b-${suffix}`
);
await runCliOrFail(vaultA, "--settings", settingsA, "pull", binaryPath, binaryDestinationA);
await assertFilesEqual(binarySourceB, binaryDestinationA, "Adaptive Journal whole-Pack transfer differs");
await runCliOrFail(vaultA, "--settings", settingsA, "rm", binaryPath);
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
const deleted = await runCli(vaultB, "--settings", settingsB, "cat", binaryPath);
assert(deleted.code !== 0, `Deleted binary remained readable:\n${deleted.combined}`);
} finally {
if (!keepDocker) {
await stopMinio().catch(() => {});
}
}
});
@@ -0,0 +1,185 @@
import { assert, assertEquals } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { assertFilesEqual, runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
import { applyRemoteSyncSettings, generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts";
import { startWebDAV, stopWebDAV } from "./helpers/docker.ts";
const EXTERNAL_PACK_TEST_BYTES = 9 * 1024 * 1024;
function deterministicBytes(length: number, seed: number): Uint8Array {
const bytes = new Uint8Array(length);
let state = seed;
for (let index = 0; index < bytes.byteLength; index += 1) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
bytes[index] = state & 0xff;
}
return bytes;
}
function webDAVConnectionURI(endpoint: string, prefix: string): string {
const endpointUrl = new URL(endpoint);
const proxyUrl = new URL(`https://${endpointUrl.host}${endpointUrl.pathname}`);
const username = Deno.env.get("WEBDAV_USERNAME") ?? "";
const password = Deno.env.get("WEBDAV_PASSWORD") ?? "";
proxyUrl.username = username;
proxyUrl.password = password;
if (endpointUrl.protocol === "http:") proxyUrl.searchParams.set("insecure", "true");
proxyUrl.searchParams.set("prefix", prefix);
return `sls+webdav:${proxyUrl.toString().slice("https:".length)}`;
}
function setPackReadPolicy(connectionURI: string, policy: "range" | "whole-pack"): string {
const url = new URL(connectionURI);
url.searchParams.set("packReadPolicy", policy);
return url.toString();
}
function selectAdaptiveJournal(connectionURI: string): string {
const url = new URL(connectionURI);
url.searchParams.set("journalFormat", "adaptive-v1");
return url.toString();
}
function remoteIdFromListing(listing: string): string {
const line = listing
.split(/\r?\n/u)
.find((candidate) => candidate.includes("\tWebDAV Remote\t") || candidate.includes("\tWebDAV "));
const id = line?.split("\t", 1)[0];
if (!id) throw new Error(`WebDAV remote profile was not listed:\n${listing}`);
return id;
}
Deno.test("e2e: two CLI vaults synchronise through Adaptive Journal WebDAV", async () => {
const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const endpoint = (Deno.env.get("WEBDAV_ENDPOINT") ?? "http://127.0.0.1:8088/dav").replace(/\/+$/u, "");
const prefix = `adaptive-cli-${suffix}/`;
const connectionURI = webDAVConnectionURI(endpoint, prefix);
const adaptiveConnectionURI = selectAdaptiveJournal(connectionURI);
const vaultPassphrase = "adaptive-journal-webdav-cli-e2ee";
const setupPassphrase = "adaptive-journal-webdav-cli-setup";
await using workDir = await TempDir.create("livesync-cli-adaptive-journal-webdav");
const vaultA = workDir.join("vault-a");
const vaultB = workDir.join("vault-b");
const settingsA = workDir.join("settings-a.json");
const settingsB = workDir.join("settings-b.json");
const binarySourceA = workDir.join("source-a.bin");
const binarySourceB = workDir.join("source-b.bin");
const binaryDestinationA = workDir.join("destination-a.bin");
const binaryDestinationB = workDir.join("destination-b.bin");
await Deno.mkdir(vaultA, { recursive: true });
await Deno.mkdir(vaultB, { recursive: true });
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
if (shouldStartDocker) await startWebDAV(endpoint);
try {
await initSettingsFile(settingsA);
await applyRemoteSyncSettings(settingsA, {
remoteType: "WEBDAV",
webDAVConnectionURI: connectionURI,
encrypt: true,
passphrase: vaultPassphrase,
enableCompression: false,
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
});
const addedRemote = await runCliOrFail(
vaultA,
"--settings",
settingsA,
"remote-add",
"WebDAV E2E",
adaptiveConnectionURI
);
const remoteId = addedRemote.trim().split("\t", 1)[0];
assert(remoteId, `remote-add did not return a profile ID:\n${addedRemote}`);
const settingsAfterRemoteAdd = JSON.parse(await Deno.readTextFile(settingsA)) as {
remoteConfigurations?: Record<string, unknown>;
liveSync?: boolean;
};
assert(
settingsAfterRemoteAdd.remoteConfigurations?.[remoteId],
`remote-add did not persist profile ${remoteId}: ${Object.keys(settingsAfterRemoteAdd.remoteConfigurations ?? {}).join(", ")}`
);
assertEquals(settingsAfterRemoteAdd.liveSync, true, "remote-add changed the persisted synchronisation mode");
await runCliOrFail(vaultA, "--settings", settingsA, "remote-activate", remoteId);
const textPath = "adaptive/text.md";
const binaryPath = "adaptive/data.bin";
await runCliWithInputOrFail(`created-by-a-${suffix}\n`, vaultA, "--settings", settingsA, "put", textPath);
await Deno.writeFile(binarySourceA, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x1a2b3c4d));
await runCliOrFail(vaultA, "--settings", settingsA, "push", binarySourceA, binaryPath);
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
const remoteListing = await runCliOrFail(vaultA, "--settings", settingsA, "remote-ls");
assertEquals(remoteIdFromListing(remoteListing), remoteId);
assert(
remoteListing
.split(/\r?\n/u)
.some((line) => line.startsWith(`${remoteId}\t`) && line.includes("\tactive\t")),
`Activated WebDAV profile was not listed as active:\n${remoteListing}`
);
const exportedConnection = (
await runCliOrFail(vaultA, "--settings", settingsA, "remote-export", remoteId)
).trim();
assert(exportedConnection.startsWith("sls+webdav://"));
assert(exportedConnection.includes("journalFormat=adaptive-v1"));
assert(!exportedConnection.includes("packReadPolicy="));
const setupURI = await generateSetupUriFromSettings(settingsA, setupPassphrase, true, vaultPassphrase);
await initSettingsFile(settingsB);
await runCliWithInputOrFail(`${setupPassphrase}\n`, vaultB, "--settings", settingsB, "setup", setupURI);
const settingsAfterSetup = JSON.parse(await Deno.readTextFile(settingsB)) as {
encryptedPassphrase?: string;
};
assert(
typeof settingsAfterSetup.encryptedPassphrase === "string" &&
settingsAfterSetup.encryptedPassphrase.length > 0,
"setup did not persist the encrypted Vault passphrase"
);
await runCliOrFail(
vaultB,
"--settings",
settingsB,
"remote-set",
remoteId,
setPackReadPolicy(exportedConnection, "range")
);
const rangeConnection = (await runCliOrFail(vaultB, "--settings", settingsB, "remote-export", remoteId)).trim();
assert(rangeConnection.includes("packReadPolicy=range"));
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
assertEquals(
sanitiseCatStdout(await runCliOrFail(vaultB, "--settings", settingsB, "cat", textPath)).trimEnd(),
`created-by-a-${suffix}`
);
await runCliOrFail(vaultB, "--settings", settingsB, "pull", binaryPath, binaryDestinationB);
await assertFilesEqual(binarySourceA, binaryDestinationB, "Adaptive Journal Range transfer differs");
await runCliWithInputOrFail(`updated-by-b-${suffix}\n`, vaultB, "--settings", settingsB, "put", textPath);
await Deno.writeFile(binarySourceB, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x5e6f7788));
await runCliOrFail(vaultB, "--settings", settingsB, "push", binarySourceB, binaryPath);
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
assertEquals(
sanitiseCatStdout(await runCliOrFail(vaultA, "--settings", settingsA, "cat", textPath)).trimEnd(),
`updated-by-b-${suffix}`
);
await runCliOrFail(vaultA, "--settings", settingsA, "pull", binaryPath, binaryDestinationA);
await assertFilesEqual(binarySourceB, binaryDestinationA, "Adaptive Journal whole-Pack transfer differs");
await runCliOrFail(vaultA, "--settings", settingsA, "rm", binaryPath);
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
const deleted = await runCli(vaultB, "--settings", settingsB, "cat", binaryPath);
assert(deleted.code !== 0, `Deleted binary remained readable:\n${deleted.combined}`);
} finally {
if (shouldStartDocker && !keepDocker) {
await stopWebDAV().catch(() => {});
}
}
});
+33 -1
View File
@@ -29,6 +29,28 @@ Serve `src/apps/webpeer/dist/` over HTTPS, or from `localhost`, then open `index
Keep the page open while WebPeer is expected to announce or transfer changes.
## P2P connection check
`check.html` provides a disposable preflight check for P2P connectivity. It creates a random encrypted Setup URI and QR code locally, joins the generated room as a browser reference peer only after explicit confirmation, and displays the browser's WebRTC diagnostic totals beside the QR code.
Use a dedicated empty Vault for every device:
1. Select desktop or mobile, then prepare the check.
2. Start the browser connection monitor.
3. Open or scan the Setup URI in an empty Vault with Self-hosted LiveSync installed and enabled.
4. Enter the separately displayed Setup URI passphrase.
5. Keep both peers open for the observation period and watch the successful-connection total.
6. Use **Show the Setup QR again** to return to the existing configuration without regenerating it.
7. For the clearest device comparison, start a fresh check before testing the other device.
A successful total greater than zero means that the browser and target established at least one WebRTC connection. It does not verify note synchronisation, sustained connectivity, or a direct desktop-to-mobile path. If representative checks repeatedly do not connect, CouchDB is the more predictable synchronisation option. An optional final check can use two disposable empty Vaults to verify a note round trip directly between the user's devices.
After the first device connects, **Try another device without resetting** keeps the browser, room, credentials, first device, and cumulative counters in place. The page records a local baseline, shows the same QR code for another empty Vault, and reports an additional connection only after both a new successful connection state and another simultaneous active connection appear. Keep the first device connected. Connections are not device identities, so this same-room route is convenient but a fresh check remains easier to interpret because reconnect activity is isolated.
Serve the production build over HTTPS or from `localhost`. Downloading `check.html` alone is not supported because the page uses built module assets and origin-scoped browser storage. The generated credentials are temporary and must not replace the settings of a production Vault.
The detailed scope and result semantics are recorded in the [browser-assisted P2P connection-check ADR](../../../docs/adr/2026_07_p2p_connection_check.md).
## Storage and lifecycle
WebPeer stores its settings, metadata, and chunks in storage belonging to the page origin. Consequently:
@@ -79,11 +101,21 @@ npm run test:unit --workspace webpeer
npm run test:browser --workspace webpeer
```
The focused browser-to-Obsidian E2E test builds both production artefacts, manages the local Compose P2P relay, applies the browser-generated Setup URI to two isolated empty Vaults without resetting the browser room, and retains the successful additional-device result under the Obsidian diagnostics directory:
```bash
npm run test:e2e:obsidian:p2p-connection-check:services
```
Configure `OBSIDIAN_BINARY` and `OBSIDIAN_CLI` when the E2E runner cannot discover them automatically.
The unit tests are stored in `test/apps/webpeer/`, outside the Community Review source boundary.
## Composition
`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls.
The WebPeer production bundle has two HTML entry points. `index.html` loads `src/main.ts` and the ordinary browser peer, while `check.html` loads `src/check.ts` and the isolated P2P connection check. The ordinary page links to the check, so it is a WebPeer feature without sharing its saved settings or runtime session.
`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls. The connection-check modules create an isolated runtime with in-memory test settings and derive user-visible results from Commonlib's browser-side diagnostic events. Both entry points use the same WebPeer visual foundation.
## Licence
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Prepare a disposable Setup URI and check a Self-hosted LiveSync P2P connection from the browser."
/>
<title>P2P connection check · Self-hosted LiveSync</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/check.ts"></script>
</body>
</html>
+6 -2
View File
@@ -6,7 +6,11 @@
<link rel="icon" type="image/svg+xml" href="icon.svg" />
<link rel="manifest" href="manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Peer-to-Peer Daemon on Browser</title>
<meta
name="description"
content="Run a temporary Self-hosted LiveSync P2P peer and inspect its activity in the browser."
/>
<title>WebPeer · Self-hosted LiveSync</title>
</head>
<body>
@@ -14,4 +18,4 @@
<script type="module" src="./src/main.ts"></script>
</body>
</html>
</html>
+601
View File
@@ -0,0 +1,601 @@
<script lang="ts">
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
import qrcode from "qrcode-generator";
import { onDestroy, tick } from "svelte";
import {
generateP2PCheckSetup,
resolveLocalP2PCheckRelayOverride,
type GeneratedP2PCheckSetup,
type P2PCheckTarget,
} from "./P2PCheckSetup";
import { P2PCheckSession } from "./P2PCheckSession";
import {
EMPTY_P2P_CHECK_DIAGNOSTICS,
P2P_CHECK_OBSERVATION_MILLISECONDS,
captureP2PAdditionalCheckBaseline,
countActiveP2PConnections,
deriveP2PAdditionalCheckProgress,
deriveP2PCheckOutcome,
type P2PAdditionalCheckBaseline,
type P2PAdditionalCheckOutcome,
type P2PCheckOutcome,
} from "./P2PCheckState";
const OUTCOME_COPY: Record<
P2PCheckOutcome,
{ readonly title: string; readonly body: string; readonly tone: string }
> = {
idle: {
title: "Not monitoring yet",
body: "Prepare the reference peer, then start monitoring before opening the Setup URI on the target device.",
tone: "neutral",
},
waiting: {
title: "Waiting for the device",
body: "The browser reference peer is ready. Keep this page open and complete setup in the empty test Vault.",
tone: "neutral",
},
connecting: {
title: "Negotiating a connection",
body: "A WebRTC connection attempt is in progress. The counters can increase more than once during negotiation.",
tone: "progress",
},
retrying: {
title: "An attempt failed; waiting for a retry",
body: "A failed attempt is not final. Leave both peers open because a later retry can still succeed.",
tone: "warning",
},
connected: {
title: "P2P connection observed",
body: "This browser and the target device established at least one WebRTC connection. This checks connectivity, not note synchronisation.",
tone: "success",
},
inconclusive: {
title: "No P2P connection observed",
body: "No successful connection was seen during the observation period. Repeat the check on the networks you intend to use; if it remains unsuccessful, CouchDB is the more predictable choice.",
tone: "warning",
},
error: {
title: "The browser monitor could not start",
body: "Check browser support, the secure-context requirement, and access to the signalling relay, then start a fresh check.",
tone: "error",
},
};
const ADDITIONAL_OUTCOME_COPY: Record<
P2PAdditionalCheckOutcome,
{ readonly title: string; readonly body: string; readonly tone: string }
> = {
waiting: {
title: "Waiting for another device",
body: "The same Setup URI is ready. Keep the browser and first device open while another empty Vault joins.",
tone: "neutral",
},
negotiating: {
title: "New connection activity observed",
body: "The counters or active connections changed. Waiting for both a new successful state and another simultaneous active connection.",
tone: "progress",
},
connected: {
title: "An additional connection was observed",
body: "The browser gained another simultaneous active WebRTC connection after this attempt began.",
tone: "success",
},
inconclusive: {
title: "Another device was not identified",
body: "The same-room baseline did not produce both signals during this observation period. Use a fresh check for a clean per-device result.",
tone: "warning",
},
};
interface AdditionalDeviceAttempt {
readonly baseline: P2PAdditionalCheckBaseline;
readonly startedAtElapsedMilliseconds: number;
}
let target = $state<P2PCheckTarget>("desktop");
let setup = $state<GeneratedP2PCheckSetup>();
let qrDataURL = $state("");
let preparing = $state(false);
let preparationError = $state("");
let monitorStarting = $state(false);
let monitorActive = $state(false);
let monitorError = $state("");
let status = $state<P2PServerInfo>();
let elapsedMilliseconds = $state(0);
let copied = $state<"uri" | "passphrase">();
let copyError = $state("");
let freshCheckStarting = $state(false);
let additionalDeviceAttempt = $state<AdditionalDeviceAttempt>();
let session: P2PCheckSession | undefined;
let setupCard = $state<HTMLElement>();
let setupHeading = $state<HTMLHeadingElement>();
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
let copyTimer: ReturnType<typeof setTimeout> | undefined;
let monitorStartedAt = 0;
let diagnostics = $derived(status?.diag ?? EMPTY_P2P_CHECK_DIAGNOSTICS);
let outcome = $derived(
deriveP2PCheckOutcome(
diagnostics,
monitorActive,
elapsedMilliseconds,
monitorError !== ""
)
);
let outcomeCopy = $derived(OUTCOME_COPY[outcome]);
let activeConnections = $derived(countActiveP2PConnections(diagnostics));
let observationSeconds = $derived(
Math.floor(P2P_CHECK_OBSERVATION_MILLISECONDS / 1_000)
);
let elapsedSeconds = $derived(Math.floor(elapsedMilliseconds / 1_000));
let targetLabel = $derived(target === "desktop" ? "desktop" : "mobile");
let additionalElapsedMilliseconds = $derived(
additionalDeviceAttempt
? Math.max(
0,
elapsedMilliseconds -
additionalDeviceAttempt.startedAtElapsedMilliseconds
)
: 0
);
let additionalElapsedSeconds = $derived(
Math.floor(additionalElapsedMilliseconds / 1_000)
);
let additionalProgress = $derived(
additionalDeviceAttempt
? deriveP2PAdditionalCheckProgress(
diagnostics,
additionalDeviceAttempt.baseline,
additionalElapsedMilliseconds
)
: undefined
);
let additionalOutcomeCopy = $derived(
additionalProgress
? ADDITIONAL_OUTCOME_COPY[additionalProgress.outcome]
: undefined
);
function createQRCodeDataURL(setupURI: string): string {
const code = qrcode(0, "L");
code.addData(setupURI);
code.make();
return code.createDataURL(4, 4);
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function prepareCheck(): Promise<void> {
preparing = true;
preparationError = "";
try {
const relay = resolveLocalP2PCheckRelayOverride(window.location);
const generated = await generateP2PCheckSetup(target, { relay });
qrDataURL = createQRCodeDataURL(generated.setupURI);
setup = generated;
} catch (error) {
preparationError = formatError(error);
} finally {
preparing = false;
}
}
function updateElapsedTime(): void {
elapsedMilliseconds = Date.now() - monitorStartedAt;
}
async function startMonitor(): Promise<void> {
if (!setup || monitorStarting || monitorActive) {
return;
}
monitorStarting = true;
monitorError = "";
const newSession = new P2PCheckSession();
session = newSession;
try {
await newSession.start(setup.browserSettings, setup.browserDeviceName, (nextStatus) => {
status = nextStatus;
});
monitorActive = true;
monitorStartedAt = Date.now();
elapsedMilliseconds = 0;
elapsedTimer = setInterval(updateElapsedTime, 250);
} catch (error) {
monitorError = formatError(error);
monitorActive = false;
} finally {
monitorStarting = false;
}
}
async function copyText(value: string, kind: "uri" | "passphrase"): Promise<void> {
copyError = "";
try {
await navigator.clipboard.writeText(value);
copied = kind;
if (copyTimer !== undefined) {
clearTimeout(copyTimer);
}
copyTimer = setTimeout(() => {
copied = undefined;
}, 2_000);
} catch (error) {
copyError = `Copying failed: ${formatError(error)}. Select the text and copy it manually.`;
}
}
async function startFreshCheck(): Promise<void> {
freshCheckStarting = true;
if (elapsedTimer !== undefined) {
clearInterval(elapsedTimer);
}
await session?.stop();
window.location.reload();
}
function scrollToSetupQRCode(): void {
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
setupCard?.scrollIntoView({
behavior: reducedMotion ? "auto" : "smooth",
block: "start",
});
setupHeading?.focus({ preventScroll: true });
}
async function showSetupQRCode(): Promise<void> {
await tick();
scrollToSetupQRCode();
}
async function startAdditionalDeviceAttempt(): Promise<void> {
if (
!monitorActive ||
outcome !== "connected" ||
activeConnections === 0 ||
additionalDeviceAttempt
) {
return;
}
additionalDeviceAttempt = {
baseline: captureP2PAdditionalCheckBaseline(diagnostics),
startedAtElapsedMilliseconds: elapsedMilliseconds,
};
await showSetupQRCode();
}
onDestroy(() => {
if (elapsedTimer !== undefined) {
clearInterval(elapsedTimer);
}
if (copyTimer !== undefined) {
clearTimeout(copyTimer);
}
void session?.stop();
});
</script>
<svelte:head>
<meta name="theme-color" content="#12233f" />
</svelte:head>
<main class="check-shell">
<header class="hero">
<a class="eyebrow" href="./index.html">Self-hosted LiveSync · WebPeer</a>
<h1>P2P connection check</h1>
<p class="hero-copy">
See whether one LiveSync device can establish a WebRTC connection to this browser on
the current network.
</p>
<div class="scope-note" role="note">
<span aria-hidden="true"></span>
<strong>Empty test Vaults only.</strong>
This is a disposable connectivity check, not a synchronisation or backup test.
</div>
</header>
<section class="step-card" aria-labelledby="choose-target-heading">
<div class="step-number" aria-hidden="true">1</div>
<div class="step-content">
<p class="section-kicker">Choose one target</p>
<h2 id="choose-target-heading">Which device will connect to this browser?</h2>
<p class="section-copy">
Check desktop and mobile separately. A fresh test gives each device its own random
room and clear diagnostic counters.
</p>
<fieldset class="target-picker" disabled={setup !== undefined}>
<legend class="visually-hidden">Target device</legend>
<label class:chosen={target === "desktop"} class="target-option">
<input type="radio" bind:group={target} value="desktop" />
<span class="target-icon" aria-hidden="true"></span>
<span>
<strong>Desktop LiveSync</strong>
<small>Browser ↔ desktop plug-in</small>
</span>
</label>
<label class:chosen={target === "mobile"} class="target-option">
<input type="radio" bind:group={target} value="mobile" />
<span class="target-icon mobile" aria-hidden="true"></span>
<span>
<strong>Mobile LiveSync</strong>
<small>Browser ↔ mobile plug-in</small>
</span>
</label>
</fieldset>
<button class="primary-action" type="button" onclick={prepareCheck} disabled={preparing || setup !== undefined}>
{preparing ? "Preparing locally…" : `Prepare ${targetLabel} check`}
</button>
<p class="privacy-line">
Preparing generates and encrypts everything in this browser. It does not contact
the signalling relay.
</p>
{#if preparationError}
<p class="inline-error" role="alert">{preparationError}</p>
{/if}
</div>
</section>
{#if setup}
<section bind:this={setupCard} class="step-card setup-card" aria-labelledby="setup-heading">
<div class="step-number" aria-hidden="true">2</div>
<div class="step-content">
<p class="section-kicker">Set up the empty Vault</p>
<h2 bind:this={setupHeading} id="setup-heading" tabindex="-1">
{additionalDeviceAttempt
? "Use this same one-off configuration on another device"
: `Open this one-off configuration on ${targetLabel}`}
</h2>
{#if additionalDeviceAttempt}
<p class="section-copy">
Keep the browser and first device open. In another new empty Vault, scan
this same QR code or open the same Setup URI, then enter the same separate
passphrase.
</p>
<p class="reuse-note" role="note">
The room, credentials, connection, and existing counters have not been
reset. The additional-device result uses the values recorded when you
selected the button.
</p>
{:else}
<p class="section-copy">
In a new empty Vault with Self-hosted LiveSync installed and enabled, scan
the QR code or open the Setup URI. Enter the separate passphrase when
prompted.
</p>
{/if}
<div class="setup-grid">
<div class="qr-panel">
<img
src={qrDataURL}
alt={additionalDeviceAttempt
? "Setup URI QR code for another device"
: `Setup URI QR code for the ${targetLabel} check`}
/>
<p>
{additionalDeviceAttempt
? "This is the original encrypted Setup URI; it was not regenerated."
: "QR contains the encrypted Setup URI only."}
</p>
</div>
<div class="credential-panel">
<label for="setup-passphrase">Setup URI passphrase</label>
<div class="copy-row compact">
<input
id="setup-passphrase"
value={setup.setupPassphrase}
autocomplete="off"
spellcheck="false"
readonly
/>
<button type="button" onclick={() => copyText(setup!.setupPassphrase, "passphrase")}>
{copied === "passphrase" ? "Copied" : "Copy"}
</button>
</div>
<p class="field-help">Type this when LiveSync asks to decrypt the Setup URI.</p>
<label for="setup-uri">Setup URI</label>
<textarea
id="setup-uri"
rows="4"
autocomplete="off"
spellcheck="false"
readonly
value={setup.setupURI}
></textarea>
<div class="button-row">
<button type="button" onclick={() => copyText(setup!.setupURI, "uri")}>
{copied === "uri" ? "Copied URI" : "Copy Setup URI"}
</button>
<a class="button-link" href={setup.setupURI}>Open in Obsidian</a>
</div>
{#if copyError}
<p class="inline-error" role="alert">{copyError}</p>
{/if}
</div>
</div>
<dl class="session-details">
<div>
<dt>Target</dt>
<dd>{targetLabel}</dd>
</div>
<div>
<dt>Test Group ID</dt>
<dd>{setup.groupId}</dd>
</div>
<div>
<dt>Signalling relay</dt>
<dd>{setup.relay}</dd>
</div>
</dl>
</div>
</section>
<section class="step-card results-card" aria-labelledby="monitor-heading">
<div class="step-number" aria-hidden="true">3</div>
<div class="step-content">
<p class="section-kicker">Watch the browser diagnostics</p>
<h2 id="monitor-heading">Start monitoring, then complete setup on {targetLabel}</h2>
<p class="section-copy">
Starting monitoring joins the configured signalling relay. Keep this page and
the target Vault open for at least {observationSeconds} seconds.
</p>
<button
class="primary-action monitor-action"
type="button"
onclick={startMonitor}
disabled={monitorStarting || monitorActive}
>
{monitorStarting ? "Starting browser peer…" : monitorActive ? "Monitoring is active" : "Start connection monitor"}
</button>
<div class="outcome" class:success={outcomeCopy.tone === "success"} class:warning={outcomeCopy.tone === "warning"} class:error={outcomeCopy.tone === "error"} class:progress={outcomeCopy.tone === "progress"} aria-live="polite">
<div class="outcome-mark" aria-hidden="true">
{outcome === "connected" ? "✓" : outcome === "error" ? "!" : outcome === "inconclusive" ? "?" : "•"}
</div>
<div>
<h3>{outcomeCopy.title}</h3>
<p>{outcomeCopy.body}</p>
{#if monitorActive}
<small>{elapsedSeconds}s observed · {activeConnections} currently connected</small>
{/if}
{#if monitorError}
<code>{monitorError}</code>
{/if}
</div>
</div>
<div class="metrics" aria-label="WebRTC diagnostic totals since this page opened">
<article>
<span>New</span>
<strong data-testid="diag-new">{diagnostics.totalNewConnections}</strong>
<small>connection states</small>
</article>
<article class="successful">
<span>Successful</span>
<strong data-testid="diag-successful">{diagnostics.totalSuccessfulConnections}</strong>
<small>connection states</small>
</article>
<article class="failed">
<span>Failed</span>
<strong data-testid="diag-failed">{diagnostics.totalFailedConnections}</strong>
<small>connection states</small>
</article>
<article>
<span>Closed</span>
<strong data-testid="diag-closed">{diagnostics.totalClosedConnections}</strong>
<small>connection states</small>
</article>
</div>
<p class="counter-note">
These are negotiation events, not device counts. <strong>Successful &gt; 0</strong>
is the connection signal; later failures or closures do not erase it.
</p>
{#if additionalProgress && additionalOutcomeCopy}
<div
class="outcome additional-outcome"
class:success={additionalOutcomeCopy.tone === "success"}
class:warning={additionalOutcomeCopy.tone === "warning"}
class:progress={additionalOutcomeCopy.tone === "progress"}
aria-live="polite"
data-testid="additional-device-outcome"
>
<div class="outcome-mark" aria-hidden="true">
{additionalProgress.outcome === "connected"
? "✓"
: additionalProgress.outcome === "inconclusive"
? "?"
: "•"}
</div>
<div>
<p class="section-kicker">Another device in this room</p>
<h3>{additionalOutcomeCopy.title}</h3>
<p>{additionalOutcomeCopy.body}</p>
<small>
{additionalElapsedSeconds}s observed ·
<span data-testid="additional-successful">
+{additionalProgress.successfulConnections} successful
</span>
·
<span data-testid="additional-active-connections">
+{additionalProgress.activeConnections} active
</span>
</small>
</div>
</div>
<p class="counter-note additional-caveat">
Same-room counters remain cumulative and connections are not device counts.
The first device must stay connected; a fresh room remains the clearest
per-device comparison.
</p>
{/if}
<div class="session-actions">
<button class="secondary-action" type="button" onclick={showSetupQRCode}>
Show the Setup QR again
</button>
{#if outcome === "connected" && !additionalDeviceAttempt}
<button
class="primary-action"
type="button"
onclick={startAdditionalDeviceAttempt}
disabled={activeConnections === 0}
>
{activeConnections === 0
? "Waiting for the first device to reconnect…"
: "Try another device without resetting"}
</button>
{/if}
<button
class="secondary-action"
type="button"
onclick={startFreshCheck}
disabled={freshCheckStarting}
>
{freshCheckStarting ? "Closing this check…" : "Start a fresh check for the other device"}
</button>
</div>
</div>
</section>
<aside class="next-step" aria-labelledby="next-step-heading">
<p class="section-kicker">What this tells you</p>
<h2 id="next-step-heading">Use the result as a P2P preflight</h2>
<div class="next-step-grid">
<div>
<h3>If both devices are observed</h3>
<p>
P2P looks plausible on these networks. Separate fresh checks give the
clearest device comparison. For a stronger final check, use two disposable
empty Vaults and verify a note round trip directly between your devices.
</p>
</div>
<div>
<h3>If checks repeatedly do not connect</h3>
<p>
Network policy or NAT may make P2P unreliable. Use CouchDB when you need a
predictable synchronisation path across these networks.
</p>
</div>
</div>
</aside>
{/if}
<footer>
<p>
The generated credentials are disposable. Delete the empty test Vault afterwards and
generate new credentials for any real setup.
</p>
</footer>
</main>
+70
View File
@@ -0,0 +1,70 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
EVENT_SERVER_STATUS,
type P2PServerInfo,
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { WEBPEER_SETTINGS_KEY } from "./WebPeerPersistence";
import { WebPeerRuntime } from "./WebPeerRuntime";
export const P2P_CHECK_SYSTEM_VAULT_NAME = "p2p-livesync-connection-check";
function createMemoryStore(settings: ObsidianLiveSyncSettings): SimpleStore<unknown> {
const values = new Map<string, unknown>([[WEBPEER_SETTINGS_KEY, settings]]);
return {
db: Promise.resolve(undefined),
get: async (key) => values.get(key),
set: async (key, value) => {
values.set(key, value);
},
delete: async (key) => {
values.delete(key);
},
keys: async (from, to, count) => {
const selected = [...values.keys()]
.sort()
.filter((key) => (from === undefined || key >= from) && (to === undefined || key <= to));
return count === undefined ? selected : selected.slice(0, count);
},
};
}
export class P2PCheckSession {
private runtime?: WebPeerRuntime;
private removeStatusListener?: () => void;
async start(
settings: ObsidianLiveSyncSettings,
browserDeviceName: string,
onStatus: (status: P2PServerInfo) => void
): Promise<void> {
if (this.runtime) {
throw new Error("This P2P connection-check session has already started");
}
const runtime = new WebPeerRuntime({
store: createMemoryStore(settings),
deviceName: browserDeviceName,
systemVaultName: P2P_CHECK_SYSTEM_VAULT_NAME,
});
this.runtime = runtime;
this.removeStatusListener = runtime.events.onEvent(EVENT_SERVER_STATUS, onStatus);
try {
await runtime.start();
await runtime.currentReplicator.makeSureOpened();
} catch (error) {
await this.stop();
throw error;
}
}
async stop(): Promise<void> {
this.removeStatusListener?.();
this.removeStatusListener = undefined;
const runtime = this.runtime;
this.runtime = undefined;
await runtime?.shutdown();
}
}
+166
View File
@@ -0,0 +1,166 @@
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import {
P2P_DEFAULT_SETTINGS,
PREFERRED_BASE,
createNewVaultSettings,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { generateP2PRoomId } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
export const P2P_CHECK_APP_ID = "self-hosted-livesync-p2p-check-v1";
export const P2P_CHECK_REMOTE_NAME = "P2P connection check";
export type P2PCheckTarget = "desktop" | "mobile";
export interface GeneratedP2PCheckSetup {
readonly target: P2PCheckTarget;
readonly setupURI: string;
readonly setupPassphrase: string;
readonly groupId: string;
readonly relay: string;
readonly browserDeviceName: string;
readonly browserSettings: ObsidianLiveSyncSettings;
}
export interface P2PCheckSetupOptions {
readonly relay?: string;
}
export interface P2PCheckPageLocation {
readonly hostname: string;
readonly search: string;
}
interface SharedP2PCheckCredentials {
readonly groupId: string;
readonly p2pPassphrase: string;
readonly vaultPassphrase: string;
}
const READABLE_SECRET_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz";
const RANDOM_BYTE_ACCEPTANCE_LIMIT =
Math.floor(256 / READABLE_SECRET_ALPHABET.length) * READABLE_SECRET_ALPHABET.length;
function generateReadableSecret(length: number): string {
const crypto = compatGlobal.crypto;
if (!crypto) {
throw new Error("Web Crypto is required to prepare a P2P connection check");
}
let result = "";
const bytes = new Uint8Array(Math.max(16, length));
while (result.length < length) {
crypto.getRandomValues(bytes);
for (const byte of bytes) {
if (byte >= RANDOM_BYTE_ACCEPTANCE_LIMIT) {
continue;
}
result += READABLE_SECRET_ALPHABET[byte % READABLE_SECRET_ALPHABET.length];
if (result.length === length) {
break;
}
}
}
return result;
}
function generateSetupPassphrase(): string {
return generateReadableSecret(16).match(/.{4}/g)!.join("-");
}
function createSettings(
credentials: SharedP2PCheckCredentials,
options: {
readonly autoStart: boolean;
readonly relay: string;
readonly useDiagnostics: boolean;
}
): ObsidianLiveSyncSettings {
const settings = createNewVaultSettings();
Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, {
isConfigured: true,
encrypt: true,
passphrase: credentials.vaultPassphrase,
usePathObfuscation: true,
P2P_Enabled: true,
P2P_AppID: P2P_CHECK_APP_ID,
P2P_roomID: credentials.groupId,
P2P_passphrase: credentials.p2pPassphrase,
P2P_relays: options.relay,
P2P_AutoStart: options.autoStart,
P2P_AutoBroadcast: false,
P2P_DevicePeerName: "",
P2P_useDiagRTC: options.useDiagnostics,
});
upsertRemoteConfigurationInPlace(settings, "p2p", {
name: P2P_CHECK_REMOTE_NAME,
activate: true,
activateForP2P: true,
});
return settings;
}
export function resolveLocalP2PCheckRelayOverride(location: P2PCheckPageLocation): string | undefined {
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
if (!loopbackHosts.has(location.hostname.toLowerCase())) {
return undefined;
}
const requestedRelay = new URLSearchParams(location.search).get("relay")?.trim();
if (!requestedRelay) {
return undefined;
}
try {
const relay = new URL(requestedRelay);
if (relay.protocol !== "ws:" && relay.protocol !== "wss:") {
return undefined;
}
return relay.href;
} catch {
return undefined;
}
}
export async function generateP2PCheckSetup(
target: P2PCheckTarget,
options: P2PCheckSetupOptions = {}
): Promise<GeneratedP2PCheckSetup> {
const relay = options.relay?.trim() || P2P_DEFAULT_SETTINGS.P2P_relays;
const credentials: SharedP2PCheckCredentials = {
groupId: generateP2PRoomId(),
p2pPassphrase: generateReadableSecret(32),
vaultPassphrase: generateReadableSecret(32),
};
const deviceSettings = createSettings(credentials, {
autoStart: true,
relay,
useDiagnostics: false,
});
const browserSettings = createSettings(credentials, {
autoStart: false,
relay,
useDiagnostics: true,
});
browserSettings.suspendParseReplicationResult = true;
const setupPassphrase = generateSetupPassphrase();
const setupURI = await encodeSettingsToSetupURI(
deviceSettings,
setupPassphrase,
["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks", "P2P_DevicePeerName", "deviceAndVaultName"],
true
);
return {
target,
setupURI: setupURI.trim(),
setupPassphrase,
groupId: credentials.groupId,
relay: deviceSettings.P2P_relays,
browserDeviceName: `p2p-check-browser-${target}-${credentials.groupId.slice(-3)}`,
browserSettings,
};
}
+129
View File
@@ -0,0 +1,129 @@
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
export const P2P_CHECK_OBSERVATION_MILLISECONDS = 60_000;
export type P2PCheckDiagnostics = P2PServerInfo["diag"];
export type P2PCheckOutcome = "idle" | "waiting" | "connecting" | "retrying" | "connected" | "inconclusive" | "error";
export type P2PAdditionalCheckOutcome = "waiting" | "negotiating" | "connected" | "inconclusive";
export interface P2PAdditionalCheckBaseline {
readonly activeConnectionIds: readonly string[];
readonly totalClosedConnections: number;
readonly totalFailedConnections: number;
readonly totalNewConnections: number;
readonly totalSuccessfulConnections: number;
}
export interface P2PAdditionalCheckProgress {
readonly activeConnections: number;
readonly closedConnections: number;
readonly failedConnections: number;
readonly newConnections: number;
readonly newActiveConnectionIds: readonly string[];
readonly outcome: P2PAdditionalCheckOutcome;
readonly successfulConnections: number;
}
export const EMPTY_P2P_CHECK_DIAGNOSTICS: P2PCheckDiagnostics = {
totalNewConnections: 0,
totalFailedConnections: 0,
totalSuccessfulConnections: 0,
totalClosedConnections: 0,
details: {},
};
export function deriveP2PCheckOutcome(
diagnostics: P2PCheckDiagnostics,
monitorActive: boolean,
elapsedMilliseconds: number,
monitorError = false
): P2PCheckOutcome {
if (monitorError) {
return "error";
}
if (diagnostics.totalSuccessfulConnections > 0) {
return "connected";
}
if (!monitorActive) {
return "idle";
}
if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) {
return "inconclusive";
}
if (diagnostics.totalFailedConnections > 0) {
return "retrying";
}
if (diagnostics.totalNewConnections > 0 || Object.keys(diagnostics.details).length > 0) {
return "connecting";
}
return "waiting";
}
export function countActiveP2PConnections(diagnostics: P2PCheckDiagnostics): number {
return Object.values(diagnostics.details).filter(({ connectionState }) => connectionState === "connected").length;
}
function activeConnectionIds(diagnostics: P2PCheckDiagnostics): string[] {
return Object.entries(diagnostics.details)
.filter(([, { connectionState }]) => connectionState === "connected")
.map(([connectionId]) => connectionId);
}
export function captureP2PAdditionalCheckBaseline(diagnostics: P2PCheckDiagnostics): P2PAdditionalCheckBaseline {
return {
activeConnectionIds: activeConnectionIds(diagnostics),
totalClosedConnections: diagnostics.totalClosedConnections,
totalFailedConnections: diagnostics.totalFailedConnections,
totalNewConnections: diagnostics.totalNewConnections,
totalSuccessfulConnections: diagnostics.totalSuccessfulConnections,
};
}
function counterIncrease(current: number, baseline: number): number {
return Math.max(0, current - baseline);
}
export function deriveP2PAdditionalCheckProgress(
diagnostics: P2PCheckDiagnostics,
baseline: P2PAdditionalCheckBaseline,
elapsedMilliseconds: number
): P2PAdditionalCheckProgress {
const baselineConnectionIds = new Set(baseline.activeConnectionIds);
const currentActiveConnectionIds = activeConnectionIds(diagnostics);
const newActiveConnectionIds = currentActiveConnectionIds.filter(
(connectionId) => !baselineConnectionIds.has(connectionId)
);
const activeConnections = counterIncrease(currentActiveConnectionIds.length, baseline.activeConnectionIds.length);
const newConnections = counterIncrease(diagnostics.totalNewConnections, baseline.totalNewConnections);
const successfulConnections = counterIncrease(
diagnostics.totalSuccessfulConnections,
baseline.totalSuccessfulConnections
);
const failedConnections = counterIncrease(diagnostics.totalFailedConnections, baseline.totalFailedConnections);
const closedConnections = counterIncrease(diagnostics.totalClosedConnections, baseline.totalClosedConnections);
let outcome: P2PAdditionalCheckOutcome = "waiting";
if (successfulConnections > 0 && activeConnections > 0 && newActiveConnectionIds.length > 0) {
outcome = "connected";
} else if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) {
outcome = "inconclusive";
} else if (
newConnections > 0 ||
successfulConnections > 0 ||
failedConnections > 0 ||
closedConnections > 0 ||
newActiveConnectionIds.length > 0
) {
outcome = "negotiating";
}
return {
activeConnections,
closedConnections,
failedConnections,
newConnections,
newActiveConnectionIds,
outcome,
successfulConnections,
};
}
+80 -86
View File
@@ -26,90 +26,84 @@
});
</script>
<main>
<div class="control">
{#await synchronised then activeRuntime}
<BrowserP2PTransportSettings host={activeRuntime.paneHost} />
<P2PReplicatorPane host={activeRuntime.paneHost}></P2PReplicatorPane>
{:catch error}
<p>{error instanceof Error ? error.message : String(error)}</p>
{/await}
</div>
<div class="log">
<div class="status">
{statusLine}
</div>
<div class="logslist" bind:this={elP}>
{#each $logs as log}
<p>{log}</p>
{/each}
</div>
</div>
</main>
<svelte:head>
<meta name="theme-color" content="#12233f" />
</svelte:head>
<style>
main {
display: flex;
flex-direction: row;
flex-grow: 1;
max-height: 100vh;
box-sizing: border-box;
}
@media (max-width: 900px) {
main {
flex-direction: column;
}
}
@media (device-orientation: portrait) {
main {
flex-direction: column;
}
}
.log {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
padding: 1em;
min-width: 50%;
}
@media (max-width: 900px) {
.log {
max-height: 50vh;
}
}
@media (device-orientation: portrait) {
.log {
max-height: 50vh;
}
}
.control {
padding: 1em 1em;
overflow-y: scroll;
flex-grow: 1;
}
.status {
flex-grow: 0;
/* max-height: 40px; */
/* height: 40px; */
flex-shrink: 0;
}
.logslist {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
/* padding: 1em; */
width: 100%;
overflow-y: scroll;
flex-grow: 1;
flex-shrink: 1;
/* max-height: calc(100% - 40px); */
}
p {
margin: 0;
white-space: pre-wrap;
text-align: left;
word-break: break-all;
}
</style>
<main class="webpeer-shell">
<header class="webpeer-header">
<div class="brand-lockup">
<span class="brand-mark" aria-hidden="true"><span></span></span>
<span>
<small>Self-hosted LiveSync</small>
<strong>WebPeer</strong>
<span class="brand-description">A browser-hosted peer for temporary P2P transfers.</span>
</span>
</div>
<a
class="connection-check-link"
href="./check.html"
aria-label="Try the P2P connection check"
>
<span class="check-mark" aria-hidden="true"></span>
<span>
<small>Disposable connectivity check</small>
<strong>Try the P2P connection check</strong>
</span>
<span class="check-arrow" aria-hidden="true"></span>
</a>
</header>
<div class="workspace-grid">
<section class="workspace-card control-card" aria-label="WebPeer controls">
<div class="panel-meta">
<p>Browser peer controls</p>
<span>Stored in this browser</span>
</div>
{#await synchronised then activeRuntime}
<P2PReplicatorPane host={activeRuntime.paneHost}></P2PReplicatorPane>
<BrowserP2PTransportSettings host={activeRuntime.paneHost} />
{:catch error}
<div class="runtime-error" role="alert">
<strong>WebPeer could not start</strong>
<p>{error instanceof Error ? error.message : String(error)}</p>
</div>
{/await}
</section>
<aside class="workspace-card log-card" aria-labelledby="activity-heading">
<header class="log-header">
<div>
<p class="section-kicker">Live diagnostics</p>
<h2 id="activity-heading">Activity log</h2>
</div>
<div class="status-pill" aria-live="polite">
<span aria-hidden="true"></span>
{statusLine || "Initialising"}
</div>
</header>
<div
class="logslist"
bind:this={elP}
role="log"
aria-label="WebPeer activity log"
>
{#if $logs.length === 0}
<p class="empty-log">Waiting for peer activity.</p>
{:else}
{#each $logs as log}
<p class="log-entry">{log}</p>
{/each}
{/if}
</div>
</aside>
</div>
<footer class="webpeer-footer">
WebPeer is experimental browser software. Keep this page open while it is connected or
transferring changes.
</footer>
</main>
+8 -2
View File
@@ -22,6 +22,8 @@ import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersisten
export interface WebPeerRuntimeOptions {
context?: ServiceContext;
store?: SimpleStore<unknown>;
deviceName?: string;
systemVaultName?: string;
}
function addToList(item: string, list: string): string {
@@ -55,12 +57,12 @@ export class WebPeerRuntime {
private startPromise?: Promise<this>;
private shutdownPromise?: Promise<void>;
constructor(options: WebPeerRuntimeOptions = {}) {
constructor(private readonly options: WebPeerRuntimeOptions = {}) {
const persistence = createWebPeerPersistence(options.store);
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
context: this.context,
getSystemVaultName: () => WEBPEER_STORE_NAME,
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
settings: persistence.settings,
restart: {
schedule: () => this.scheduleRestart(),
@@ -104,6 +106,10 @@ export class WebPeerRuntime {
private async startRuntime(): Promise<this> {
await this.services.setting.loadSettings();
const deviceName = this.options.deviceName?.trim();
if (deviceName) {
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
}
const opened = await this.services.database.openDatabase({
replicator: this.services.replicator,
databaseEvents: this.services.databaseEvents,
+492 -99
View File
@@ -1,112 +1,505 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--background-primary: #ffffff;
--background-primary-alt: #e9e9e9;
--size-4-1: 0.25em;
--tag-background: #f0f0f0;
--tag-border-width: 1px;
--tag-border-color: #cfffdd;
--background-modifier-success: #d4f3e9;
--background-secondary: #f0f0f0;
--background-modifier-error: #f8d7da;
--background-modifier-error-hover: #f5c6cb;
--interactive-accent: #007bff;
--interactive-accent-hover: #0056b3;
--text-normal: #333;
--text-warning: #f0ad4e;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
padding: 0;
display: flex;
min-width: 320px;
min-height: 100vh;
box-sizing: border-box;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
.card {
padding: 2em;
}
#app {
margin: 0;
padding: 0;
text-align: center;
display: flex;
flex-grow: 1;
min-height: 100vh;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
.webpeer-shell {
width: min(92rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 1.5rem;
}
button:hover {
border-color: #646cff;
.webpeer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.25rem;
margin-bottom: 1.25rem;
padding: 1rem 1.1rem 1rem 1.25rem;
border: 1px solid rgba(204, 214, 227, 0.9);
border-radius: 1.25rem;
background: rgba(255, 255, 255, 0.92);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
.brand-lockup {
display: flex;
align-items: center;
gap: 0.9rem;
min-width: 0;
}
input,
select {
border-radius: 8px;
border: 1px solid #1a1a1a;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
transition: border-color 0.25s;
.brand-lockup > span:last-child,
.connection-check-link > span:nth-child(2) {
display: grid;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
.brand-lockup small,
.connection-check-link small,
.section-kicker,
.panel-meta p {
color: var(--blue);
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.11em;
text-transform: uppercase;
}
a:hover {
color: #747bff;
}
.brand-lockup strong {
color: var(--navy);
font-size: 1.45rem;
line-height: 1.05;
letter-spacing: -0.03em;
}
button {
background-color: #f9f9f9;
}
}
.brand-description {
margin-top: 0.18rem;
overflow: hidden;
color: var(--muted);
font-size: 0.82rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-mark {
position: relative;
display: grid;
place-items: center;
width: 3.2rem;
height: 3.2rem;
flex: 0 0 auto;
border-radius: 1rem;
background: var(--navy);
box-shadow: 0 10px 24px rgba(18, 35, 63, 0.2);
}
.brand-mark::before,
.brand-mark::after,
.brand-mark span {
position: absolute;
border: 2px solid #ffffff;
border-radius: 50%;
content: "";
}
.brand-mark::before {
width: 1.6rem;
height: 1.6rem;
opacity: 0.45;
}
.brand-mark::after {
width: 0.7rem;
height: 0.7rem;
border-color: #8eb1ff;
background: #8eb1ff;
}
.brand-mark span {
width: 2.25rem;
height: 2.25rem;
border-width: 1px;
opacity: 0.28;
}
.connection-check-link {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
max-width: 25rem;
padding: 0.75rem 0.85rem;
color: #ffffff;
border-radius: 0.95rem;
background: var(--navy);
box-shadow: 0 10px 26px rgba(18, 35, 63, 0.16);
text-decoration: none;
transition:
background 140ms ease,
transform 140ms ease,
box-shadow 140ms ease;
}
.connection-check-link:hover {
color: #ffffff;
background: #1d385f;
box-shadow: 0 13px 30px rgba(18, 35, 63, 0.22);
transform: translateY(-1px);
}
.connection-check-link small {
color: #8eb1ff;
}
.connection-check-link strong {
margin-top: 0.12rem;
font-size: 0.9rem;
line-height: 1.2;
}
.check-mark {
display: grid;
place-items: center;
width: 2.15rem;
height: 2.15rem;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 0.7rem;
color: #b8ceff;
background: rgba(255, 255, 255, 0.08);
font-size: 1.15rem;
}
.check-arrow {
color: #b8ceff;
font-size: 1.15rem;
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(22rem, 0.8fr);
align-items: start;
gap: 1.25rem;
}
.workspace-card {
min-width: 0;
border: 1px solid rgba(204, 214, 227, 0.9);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow);
}
.control-card {
padding: clamp(1.35rem, 3vw, 2rem);
}
.panel-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--line);
}
.panel-meta p {
margin: 0;
}
.panel-meta span {
padding: 0.35rem 0.55rem;
color: #48627f;
border: 1px solid #cfdae7;
border-radius: 999px;
background: #f5f8fb;
font-size: 0.72rem;
font-weight: 700;
}
.control-card article {
color: var(--ink);
}
.control-card article > h1 {
margin: 0 0 1.15rem;
color: var(--navy);
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1;
letter-spacing: -0.045em;
}
.control-card h2 {
margin: 1.45rem 0 0.75rem;
padding-bottom: 0.45rem;
color: var(--navy);
border-bottom-color: var(--line);
font-size: 1.15rem;
letter-spacing: -0.015em;
}
.control-card p {
color: var(--muted);
line-height: 1.62;
}
.control-card .panel-meta p {
color: var(--blue);
line-height: 1.2;
}
.control-card details {
margin: 0.8rem 0 1rem;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
background: var(--surface-subtle);
}
.control-card details[open] {
background: #fbfcfe;
}
.control-card summary {
padding: 0.12rem 0;
}
.control-card details[open] > summary {
margin-bottom: 0.7rem;
}
.control-card table {
width: 100%;
border-spacing: 0 0.35rem;
}
.control-card th,
.control-card td {
padding: 0.55rem 0.4rem;
vertical-align: top;
}
.control-card th {
width: 32%;
color: #40536b;
font-size: 0.82rem;
text-align: left;
}
.control-card td {
color: var(--ink);
}
.control-card input:not([type="checkbox"]):not([type="radio"]) {
min-width: min(100%, 18rem);
max-width: 100%;
color: var(--ink);
background: var(--surface);
}
.control-card input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
}
.control-card label.is-dirty {
border-radius: 0.55rem;
background: var(--red-soft);
}
.control-card .important {
color: var(--red);
}
.control-card .important-sub {
color: var(--amber);
}
.control-card .browser-p2p-transport-settings {
margin-top: 1.3rem;
padding-top: 0.3rem;
border-top: 1px solid var(--line);
}
.control-card .browser-p2p-transport-settings label {
padding: 0.25rem;
border-radius: 0.55rem;
}
.control-card .browser-p2p-transport-settings span {
color: #40536b;
font-size: 0.82rem;
font-weight: 700;
}
.runtime-error {
padding: 1rem;
color: var(--red);
border: 1px solid #efb1b1;
border-radius: 0.9rem;
background: var(--red-soft);
}
.runtime-error p {
margin: 0.35rem 0 0;
}
.log-card {
position: sticky;
top: 1.25rem;
overflow: hidden;
background: var(--navy);
}
.log-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.section-kicker {
margin: 0 0 0.25rem;
color: #8eb1ff;
}
.log-header h2 {
margin: 0;
color: #ffffff;
font-size: 1.4rem;
letter-spacing: -0.025em;
}
.status-pill {
display: flex;
align-items: center;
gap: 0.45rem;
max-width: 55%;
padding: 0.42rem 0.62rem;
overflow: hidden;
color: #dbe6f5;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
font-size: 0.72rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-pill > span {
width: 0.48rem;
height: 0.48rem;
flex: 0 0 auto;
border-radius: 50%;
background: #8eb1ff;
box-shadow: 0 0 0 0.2rem rgba(142, 177, 255, 0.13);
}
.logslist {
min-height: 34rem;
max-height: calc(100vh - 10rem);
padding: 1rem 1.1rem 1.2rem;
overflow: auto;
color: #cad7e8;
background:
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
var(--navy);
background-size: 100% 1.8rem;
scrollbar-color: #4e6280 transparent;
}
.log-entry,
.empty-log {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.72rem;
line-height: 1.8rem;
text-align: left;
}
.log-entry {
color: #cad7e8;
}
.empty-log {
color: #8294ac;
}
.webpeer-footer {
max-width: 48rem;
margin: 1.3rem auto 0;
color: var(--muted);
font-size: 0.78rem;
text-align: center;
}
@media (max-width: 1050px) {
.workspace-grid {
grid-template-columns: 1fr;
}
.log-card {
position: static;
}
.logslist {
min-height: 18rem;
max-height: 28rem;
}
}
@media (max-width: 700px) {
.webpeer-shell {
width: min(100% - 1rem, 46rem);
padding-top: 0.5rem;
}
.webpeer-header {
align-items: stretch;
flex-direction: column;
padding: 1rem;
}
.connection-check-link {
width: 100%;
max-width: none;
}
.control-card {
padding: 1rem;
}
.control-card table.settings,
.control-card table.settings tbody,
.control-card table.settings tr,
.control-card table.settings th,
.control-card table.settings td {
display: block;
width: 100%;
}
.control-card table.settings tr {
padding: 0.6rem 0;
border-bottom: 1px solid var(--line);
}
.control-card table.settings th,
.control-card table.settings td {
min-height: 0;
padding: 0.2rem 0;
}
.control-card table.peers {
display: block;
overflow-x: auto;
}
.panel-meta {
align-items: flex-start;
flex-direction: column;
gap: 0.5rem;
}
}
@media (max-width: 430px) {
.brand-description {
white-space: normal;
}
.panel-meta span {
border-radius: 0.55rem;
}
.log-header {
flex-direction: column;
}
.status-pill {
max-width: 100%;
}
}
+631
View File
@@ -0,0 +1,631 @@
.setup-card h2:focus-visible {
outline: 3px solid rgba(42, 102, 220, 0.38);
outline-offset: 2px;
}
.check-shell {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 4.5rem 0 3rem;
}
.hero {
max-width: 51rem;
margin: 0 auto 2.4rem;
text-align: center;
}
.eyebrow,
.section-kicker {
color: var(--blue);
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.eyebrow {
text-decoration: none;
}
.eyebrow:hover {
text-decoration: underline;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin: 0.8rem 0 1rem;
color: var(--navy);
font-size: clamp(2.45rem, 7vw, 4.5rem);
line-height: 0.98;
letter-spacing: -0.045em;
}
.hero-copy {
max-width: 43rem;
margin: 0 auto 1.5rem;
color: var(--muted);
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
}
.scope-note {
display: inline-flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.35rem;
padding: 0.75rem 1rem;
border: 1px solid #e8d8a7;
border-radius: 999px;
color: #765119;
background: #fff9e8;
font-size: 0.92rem;
}
.step-card,
.next-step {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 1.35rem;
margin-top: 1.25rem;
padding: clamp(1.35rem, 4vw, 2.4rem);
border: 1px solid rgba(204, 214, 227, 0.9);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow);
}
.step-number {
display: grid;
place-items: center;
width: 2.35rem;
height: 2.35rem;
border-radius: 50%;
color: #ffffff;
background: var(--navy);
font-weight: 800;
}
.step-content {
min-width: 0;
}
.section-kicker {
margin-bottom: 0.35rem;
}
h2 {
margin-bottom: 0.65rem;
color: var(--navy);
font-size: clamp(1.35rem, 3vw, 1.85rem);
line-height: 1.2;
letter-spacing: -0.02em;
}
h3 {
color: var(--navy);
}
.section-copy,
.field-help,
.privacy-line,
.counter-note,
.next-step p,
footer {
color: var(--muted);
}
.section-copy {
max-width: 51rem;
margin-bottom: 1.4rem;
}
.target-picker {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
margin: 0 0 1.2rem;
padding: 0;
border: 0;
}
.target-option {
display: flex;
align-items: center;
gap: 0.8rem;
min-height: 5.3rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
background: #fbfcfe;
cursor: pointer;
transition:
border-color 140ms ease,
background 140ms ease,
transform 140ms ease;
}
.target-option:hover {
border-color: #9cb7eb;
transform: translateY(-1px);
}
.target-option.chosen {
border-color: var(--blue);
background: var(--blue-soft);
box-shadow: inset 0 0 0 1px var(--blue);
}
.target-option input {
width: 1rem;
height: 1rem;
accent-color: var(--blue);
}
.target-option strong,
.target-option small {
display: block;
}
.target-option small {
margin-top: 0.2rem;
color: var(--muted);
}
.target-icon {
color: var(--blue);
font-size: 1.6rem;
}
.target-icon.mobile {
font-size: 1.9rem;
}
.primary-action,
.secondary-action,
.credential-panel button {
border: 1px solid transparent;
padding: 0.72rem 1rem;
}
.primary-action {
color: #ffffff;
background: var(--blue);
box-shadow: 0 8px 20px rgba(42, 102, 220, 0.18);
}
.primary-action:hover:not(:disabled) {
background: var(--blue-dark);
}
.privacy-line {
margin: 0.8rem 0 0;
font-size: 0.83rem;
}
.inline-error {
margin: 0.8rem 0 0;
color: var(--red);
font-weight: 650;
}
.setup-grid {
display: grid;
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.28fr);
gap: 1.25rem;
}
.qr-panel,
.credential-panel {
border: 1px solid var(--line);
border-radius: 1rem;
background: #fbfcfe;
}
.qr-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem;
}
.qr-panel img {
display: block;
width: min(100%, 31rem);
height: auto;
border: 0.7rem solid #ffffff;
image-rendering: pixelated;
}
.qr-panel p {
margin: 0.6rem 0 0;
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
.credential-panel {
padding: 1.2rem;
}
.credential-panel label {
display: block;
margin: 0 0 0.45rem;
color: var(--navy);
font-size: 0.83rem;
font-weight: 800;
}
.credential-panel label:not(:first-child) {
margin-top: 1.1rem;
}
.credential-panel input,
.credential-panel textarea {
width: 100%;
border: 1px solid #bdc9d8;
border-radius: 0.65rem;
padding: 0.75rem 0.85rem;
color: #213048;
background: #ffffff;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.86rem;
}
.credential-panel input {
font-size: 1.02rem;
font-weight: 750;
letter-spacing: 0.06em;
}
.credential-panel textarea {
min-height: 7rem;
resize: vertical;
overflow-wrap: anywhere;
}
.copy-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.55rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-top: 0.65rem;
}
.credential-panel button,
.secondary-action {
color: #26405e;
border-color: #bdc9d8;
background: #ffffff;
}
.credential-panel button:hover,
.secondary-action:hover:not(:disabled) {
border-color: var(--blue);
color: var(--blue-dark);
}
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.68rem 1rem;
color: #ffffff;
background: var(--navy);
text-decoration: none;
}
.button-link:hover {
background: #1d385f;
}
.field-help {
margin: 0.4rem 0 0;
font-size: 0.8rem;
}
.reuse-note {
margin: -0.35rem 0 1.2rem;
padding: 0.8rem 0.9rem;
border: 1px solid #acc1ec;
border-radius: 0.8rem;
color: #34547c;
background: var(--blue-soft);
font-size: 0.84rem;
}
.session-details {
display: grid;
grid-template-columns: 0.8fr 1fr 1.5fr;
gap: 0.7rem;
margin: 1rem 0 0;
}
.session-details div {
min-width: 0;
padding: 0.7rem 0.8rem;
border-radius: 0.7rem;
background: #f2f5f9;
}
.session-details dt {
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.session-details dd {
margin: 0.25rem 0 0;
overflow-wrap: anywhere;
color: var(--navy);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.8rem;
}
.monitor-action {
margin-bottom: 1rem;
}
.outcome {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.8rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
background: #f5f7fa;
}
.outcome.success {
border-color: #9ad6c2;
background: var(--green-soft);
}
.outcome.warning {
border-color: #e7cd83;
background: var(--amber-soft);
}
.outcome.error {
border-color: #efb1b1;
background: var(--red-soft);
}
.outcome.progress {
border-color: #acc1ec;
background: var(--blue-soft);
}
.outcome-mark {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
color: #ffffff;
background: #7b8796;
font-weight: 900;
}
.outcome.success .outcome-mark {
background: var(--green);
}
.outcome.warning .outcome-mark {
background: var(--amber);
}
.outcome.error .outcome-mark {
background: var(--red);
}
.outcome.progress .outcome-mark {
background: var(--blue);
}
.outcome h3 {
margin: 0 0 0.2rem;
font-size: 1rem;
}
.outcome p,
.outcome small,
.outcome code {
display: block;
margin: 0;
color: #4f6074;
}
.outcome small {
margin-top: 0.45rem;
font-weight: 700;
}
.outcome code {
margin-top: 0.45rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
margin-top: 0.8rem;
}
.metrics article {
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: #fbfcfe;
}
.metrics span,
.metrics small {
display: block;
color: var(--muted);
font-size: 0.72rem;
}
.metrics span {
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.metrics strong {
display: block;
margin: 0.2rem 0;
color: var(--navy);
font-size: 1.8rem;
line-height: 1;
}
.metrics .successful strong {
color: var(--green);
}
.metrics .failed strong {
color: var(--red);
}
.counter-note {
margin: 0.7rem 0 1rem;
font-size: 0.82rem;
}
.additional-outcome {
margin-top: 1rem;
}
.additional-outcome .section-kicker {
margin-bottom: 0.25rem;
}
.additional-caveat {
margin-bottom: 1rem;
}
.session-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.next-step {
display: block;
margin-top: 1.25rem;
color: #ecf2fb;
border-color: transparent;
background: var(--navy);
}
.next-step h2,
.next-step h3 {
color: #ffffff;
}
.next-step .section-kicker {
color: #87adff;
}
.next-step p {
color: #c5d0df;
}
.next-step-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
}
.next-step-grid h3 {
margin-bottom: 0.35rem;
font-size: 1rem;
}
.next-step-grid p {
margin-bottom: 0;
}
footer {
max-width: 48rem;
margin: 1.6rem auto 0;
font-size: 0.8rem;
text-align: center;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 760px) {
.check-shell {
width: min(100% - 1rem, 46rem);
padding-top: 2.5rem;
}
.scope-note {
border-radius: 0.8rem;
}
.step-card {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.step-number {
width: 2rem;
height: 2rem;
}
.target-picker,
.setup-grid,
.next-step-grid {
grid-template-columns: 1fr;
}
.session-details {
grid-template-columns: 1fr;
}
.metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 430px) {
.copy-row {
grid-template-columns: 1fr;
}
.copy-row button,
.button-row > *,
.session-actions > *,
.primary-action,
.secondary-action {
width: 100%;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { mount } from "svelte";
import P2PCheck from "./P2PCheck.svelte";
import "./theme.css";
import "./check.css";
const app = mount(P2PCheck, {
target: _activeDocument.getElementById("app")!,
});
export default app;
+1
View File
@@ -1,4 +1,5 @@
import { mount } from "svelte";
import "./theme.css";
import "./app.css";
import App from "./App.svelte";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
+170
View File
@@ -0,0 +1,170 @@
:root {
font-family:
Inter,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #17253b;
background: #f3f6fa;
line-height: 1.5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--ink: #17253b;
--muted: #5d6c80;
--line: #d8e0ea;
--navy: #12233f;
--blue: #2a66dc;
--blue-dark: #1e4fae;
--blue-soft: #eaf1ff;
--green: #14795c;
--green-soft: #e5f6ef;
--amber: #9c6510;
--amber-soft: #fff4d6;
--red: #a33a3a;
--red-soft: #ffeded;
--surface: #ffffff;
--surface-subtle: #f8fafc;
--shadow: 0 18px 55px rgba(24, 42, 72, 0.08);
/* Browser-hosted aliases required by the shared Obsidian P2P controls. */
--background-primary: var(--surface);
--background-primary-alt: #edf1f6;
--background-secondary: #f2f5f9;
--background-modifier-border: var(--line);
--background-modifier-success: var(--green-soft);
--background-modifier-error: var(--red-soft);
--background-modifier-error-hover: #f8dada;
--interactive-accent: var(--blue);
--interactive-accent-hover: var(--blue-dark);
--text-normal: var(--ink);
--text-muted: var(--muted);
--text-error: var(--red);
--text-warning: var(--amber);
--input-height: 2.75rem;
--size-4-1: 0.25rem;
--tag-background: #f0f3f7;
--tag-border-width: 1px;
--tag-border-color: var(--line);
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
min-height: 100%;
color: var(--ink);
background:
radial-gradient(circle at 12% 8%, rgba(66, 117, 222, 0.11), transparent 26rem),
#f3f6fa;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
.button-link {
min-height: 2.75rem;
border-radius: 0.7rem;
font-weight: 700;
}
button {
border: 1px solid #bdc9d8;
padding: 0.68rem 1rem;
color: #26405e;
background: var(--surface);
cursor: pointer;
transition:
border-color 140ms ease,
color 140ms ease,
background 140ms ease,
transform 140ms ease;
}
button:hover:not(:disabled) {
border-color: var(--blue);
color: var(--blue-dark);
}
button.mod-cta {
color: #ffffff;
border-color: var(--blue);
background: var(--blue);
box-shadow: 0 8px 20px rgba(42, 102, 220, 0.18);
}
button.mod-cta:hover:not(:disabled) {
color: #ffffff;
border-color: var(--blue-dark);
background: var(--blue-dark);
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
summary:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(42, 102, 220, 0.38);
outline-offset: 2px;
}
input:not([type="checkbox"]):not([type="radio"]),
select,
textarea {
border: 1px solid #bdc9d8;
border-radius: 0.65rem;
padding: 0.68rem 0.8rem;
color: var(--ink);
background: var(--surface);
}
input[type="checkbox"],
input[type="radio"] {
accent-color: var(--blue);
}
a {
color: var(--blue);
}
summary {
color: var(--navy);
cursor: pointer;
font-weight: 750;
}
::selection {
color: var(--navy);
background: #cddcff;
}
@media (prefers-reduced-motion: reduce) {
#app *,
#app *::before,
#app *::after {
scroll-behavior: auto;
transition: none;
}
}
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: "index.html",
check: "check.html",
// uitest: "uitest.html",
},
},
@@ -49,6 +49,91 @@ export const liveSyncProvisionalEnglishMessages = {
"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",
"WebDAV Journal Configuration": "WebDAV Journal Configuration",
"Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted.":
"Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted.",
"Collection prefix": "Collection prefix",
"Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work.":
"Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work.",
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch.":
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch.",
"Enter a complete HTTP or HTTPS endpoint without a query string or fragment.":
"Enter a complete HTTP or HTTPS endpoint without a query string or fragment.",
"Use HTTP Range requests": "Use HTTP Range requests",
"Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check.":
"Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check.",
"The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data.":
"The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data.",
"Required Adaptive operations are supported by this WebDAV endpoint.":
"Required Adaptive operations are supported by this WebDAV endpoint.",
"The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.":
"The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.",
"The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).":
"The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).",
"Required Adaptive operations were not checked.": "Required Adaptive operations were not checked.",
"Exact HTTP byte-range retrieval is supported.": "Exact HTTP byte-range retrieval is supported.",
"HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.":
"HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.",
"HTTP byte-range retrieval was not checked because the required safety check did not complete.":
"HTTP byte-range retrieval was not checked because the required safety check did not complete.",
"The selected WebDAV Journal policy is not supported by this endpoint.":
"The selected WebDAV Journal policy is not supported by this endpoint.",
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.":
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.",
"This build cannot inspect WebDAV Journal capabilities.": "This build cannot inspect WebDAV Journal capabilities.",
"Invalid WebDAV settings: ${REASON}": "Invalid WebDAV settings: ${REASON}",
"The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.":
"The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.",
"WebDAV Journal": "WebDAV Journal",
"Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check.":
"Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check.",
"Continue to WebDAV setup": "Continue to WebDAV setup",
"PostgREST Journal Configuration": "PostgREST Journal Configuration",
"Connect to the packaged, Adaptive-only PostgREST RPC contract. This experimental provider is not a CouchDB endpoint and does not expose synchronisation tables directly.":
"Connect to the packaged, Adaptive-only PostgREST RPC contract. This experimental provider is not a CouchDB endpoint and does not expose synchronisation tables directly.",
"Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment.":
"Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment.",
"Vault ID": "Vault ID",
"Vault credential": "Vault credential",
"Enter the provisioned Vault credential": "Enter the provisioned Vault credential",
"A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential.":
"A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential.",
"Exposed schema": "Exposed schema",
"Client API key (optional)": "Client API key (optional)",
"Supabase publishable key, if required": "Supabase publishable key, if required",
"Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential.":
"Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential.",
"Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential.":
"Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential.",
"PostgREST stores only Adaptive Journal records. It cannot read Opaque Journal data, and format changes require a remote Rebuild rather than an in-place migration.":
"PostgREST stores only Adaptive Journal records. It cannot read Opaque Journal data, and format changes require a remote Rebuild rather than an in-place migration.",
"The required PostgREST RPC operations and binary semantics were verified.":
"The required PostgREST RPC operations and binary semantics were verified.",
"The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.":
"The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.",
"The PostgREST SQL contract is unavailable or incompatible with this build.":
"The PostgREST SQL contract is unavailable or incompatible with this build.",
"This build cannot inspect PostgREST Journal capabilities.":
"This build cannot inspect PostgREST Journal capabilities.",
"Invalid PostgREST settings: ${REASON}": "Invalid PostgREST settings: ${REASON}",
"The saved connection contains the Vault credential and optional API key. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.":
"The saved connection contains the Vault credential and optional API key. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string.",
"PostgREST Journal": "PostgREST Journal",
"Store Adaptive Journal records through the packaged PostgREST SQL contract. This experimental provider requires a provisioned Vault credential, and onboarding requires a successful server capability check.":
"Store Adaptive Journal records through the packaged PostgREST SQL contract. This experimental provider requires a provisioned Vault credential, and onboarding requires a successful server capability check.",
"Continue to PostgREST setup": "Continue to PostgREST setup",
"Check PostgREST server": "Check PostgREST server",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected.":
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected.",
"Expected repository ID": "Expected repository ID",
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached.":
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached.",
"Run endpoint safety check": "Run endpoint safety check",
"Test WebDAV connection": "Test WebDAV connection",
"Continue with verified settings": "Continue with verified settings",
"Save verified settings": "Save verified settings",
"WebDAV access and the selected Journal format were verified.":
"WebDAV access and the selected Journal format were verified.",
"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.",
@@ -288,6 +288,13 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "生效中的远程配置",
"zh-tw": "目前啟用的遠端設定",
},
"Adaptive Journal (experimental)": {
def: "Adaptive Journal (experimental)",
},
"Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.":
{
def: "Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.",
},
"Add default patterns": {
def: "Add default patterns",
es: "Añadir patrones predeterminados",
@@ -784,6 +791,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "兼容性(问题修复)",
"zh-tw": "相容性(問題修復)",
},
"Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.":
{
def: "Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.",
},
"Compute revisions for chunks": {
def: "Compute revisions for chunks",
es: "Calcular revisiones para los chunks",
@@ -1585,6 +1596,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
ko: "문서 기록",
"zh-tw": "文件歷程",
},
"Download complete Packs": {
def: "Download complete Packs",
},
Duplicate: {
def: "Duplicate",
es: "Duplicar",
@@ -2605,6 +2619,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
ru: "Интервал (сек)",
zh: "间隔(秒)",
},
"Invalid Object Storage settings: ${reason}": {
def: "Invalid Object Storage settings: ${reason}",
},
INVERTED: {
def: "INVERTED",
es: "INVERTIDO",
@@ -2617,6 +2634,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
def: "It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.",
es: "Se recomienda encarecidamente crear una copia de seguridad antes de continuar. Continuar sin copia de seguridad puede provocar pérdida de datos.",
},
"Journal data format": {
def: "Journal data format",
},
"Just for a minute, please!": {
def: "Just for a minute, please!",
es: "¡Solo un momento, por favor!",
@@ -6187,6 +6207,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
def: "On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.",
es: "En este dispositivo, cambia a la aplicación de cámara o usa un lector de QR para escanear el código mostrado.",
},
"Opaque Journal (current format)": {
def: "Opaque Journal (current format)",
},
Open: {
def: "Open",
es: "Abrir",
@@ -6447,6 +6470,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
ru: "P2P Sync с name начат.",
zh: "P2P Sync with ${name} have been started.",
},
"Pack retrieval": {
def: "Pack retrieval",
},
"paneMaintenance.markDeviceResolvedAfterBackup": {
def: "paneMaintenance.markDeviceResolvedAfterBackup",
es: "Marcar el dispositivo como resuelto después de hacer una copia de seguridad",
@@ -11069,6 +11095,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
def: "Use Random Number",
es: "Usar número aleatorio",
},
"Use S3 Range requests": {
def: "Use S3 Range requests",
},
"Use Segmented-splitter": {
def: "Use Segmented-splitter",
es: "Usar divisor segmentado",
+9
View File
@@ -44,6 +44,8 @@
"Action": "Action",
"Activate": "Activate",
"Active Remote Configuration": "Active Remote Configuration",
"Adaptive Journal (experimental)": "Adaptive Journal (experimental)",
"Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.": "Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.",
"Add default patterns": "Add default patterns",
"Add new connection": "Add new connection",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.",
@@ -112,6 +114,7 @@
"Compatibility (Metadata)": "Compatibility (Metadata)",
"Compatibility (Remote Database)": "Compatibility (Remote Database)",
"Compatibility (Trouble addressed)": "Compatibility (Trouble addressed)",
"Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.": "Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.",
"Compute revisions for chunks": "Compute revisions for chunks",
"Configuration": "Configuration",
"Configuration Encryption": "Configuration Encryption",
@@ -212,6 +215,7 @@
"Doctor.Message.SomeSkipped": "We left some issues as is. Shall I ask you again on next startup?",
"Doctor.RULES.E2EE_V02500.REASON": "The End-to-End Encryption has got now more robust and faster. Also because, the previous E2EE was found to be compromised in a re-conducted code review. It should be applied as soon as possible. Really apologises for your inconvenience. And, this setting is not forward compatible. All synchronised devices must be updated to v0.25.0 or higher. Rebuilds are not required and will be converted from the new transfer to the new format, However, it is recommended to rebuild whenever possible.",
"Document History": "Document History",
"Download complete Packs": "Download complete Packs",
"Duplicate": "Duplicate",
"Duplicate remote": "Duplicate remote",
"E2EE Configuration": "E2EE Configuration",
@@ -355,9 +359,11 @@
"Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.": "Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.",
"Initialise journal sent history. On the next sync, every item except this device received will be sent again.": "Initialise journal sent history. On the next sync, every item except this device received will be sent again.",
"Interval (sec)": "Interval (sec)",
"Invalid Object Storage settings: ${reason}": "Invalid Object Storage settings: ${reason}",
"INVERTED": "INVERTED",
"Issue detection log:": "Issue detection log:",
"It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.": "It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.",
"Journal data format": "Journal data format",
"Just for a minute, please!": "Just for a minute, please!",
"JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the server's configuration. Incidentally, I have not verified it very thoroughly.": "JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the server's configuration. Incidentally, I have not verified it very thoroughly.",
"JWT Algorithm": "JWT Algorithm",
@@ -738,6 +744,7 @@
"On the source device, open Obsidian.": "On the source device, open Obsidian.",
"On this device, please keep this Vault open.": "On this device, please keep this Vault open.",
"On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.": "On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.",
"Opaque Journal (current format)": "Opaque Journal (current format)",
"Open": "Open",
"Open connection": "Open connection",
"Open P2P Setup...": "Open P2P Setup...",
@@ -768,6 +775,7 @@
"P2P.SyncAlreadyRunning": "P2P Sync is already running.",
"P2P.SyncCompleted": "P2P Sync completed.",
"P2P.SyncStartedWith": "P2P Sync with ${name} have been started.",
"Pack retrieval": "Pack retrieval",
"paneMaintenance.markDeviceResolvedAfterBackup": "paneMaintenance.markDeviceResolvedAfterBackup",
"paneMaintenance.remoteLockedAndDeviceNotAccepted": "paneMaintenance.remoteLockedAndDeviceNotAccepted",
"paneMaintenance.remoteLockedResolvedDevice": "paneMaintenance.remoteLockedResolvedDevice",
@@ -1418,6 +1426,7 @@
"Use JWT Authentication": "Use JWT Authentication",
"Use Path-Style Access": "Use Path-Style Access",
"Use Random Number": "Use Random Number",
"Use S3 Range requests": "Use S3 Range requests",
"Use Segmented-splitter": "Use Segmented-splitter",
"Use splitting-limit-capped chunk splitter": "Use splitting-limit-capped chunk splitter",
"Use the trash bin": "Use the trash bin",
+15
View File
@@ -66,6 +66,21 @@ AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situa
situation. Please report this issue.
Advanced: Advanced
Advanced Settings: Advanced Settings
Adaptive Journal (experimental): Adaptive Journal (experimental)
Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.:
Adaptive Journal uses immutable objects and a separate remote format.
Existing Opaque Journal data is not migrated or read. Rebuild the remote when
changing formats.
Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.:
Complete Pack reads favour throughput. Range reads can reduce transferred
bytes. The connection test verifies exact Range support on this endpoint, and
synchronisation refuses an unsupported selection before writing.
Download complete Packs: Download complete Packs
"Invalid Object Storage settings: ${reason}": "Invalid Object Storage settings: ${reason}"
Journal data format: Journal data format
Opaque Journal (current format): Opaque Journal (current format)
Pack retrieval: Pack retrieval
Use S3 Range requests: Use S3 Range requests
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.:
After restarting, the data on this device will be uploaded to the server as
the 'master copy'. Please be aware that any unintended data currently on the
+4 -4
View File
@@ -1,5 +1,6 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import { AbstractModule } from "@/modules/AbstractModule";
@@ -8,8 +9,7 @@ import type { LiveSyncCore } from "@/main";
export class ModuleReplicatorCouchDB extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
if (!defaultRemoteProviderRegistry.isRemoteTypeInFamily(settings.remoteType, "couchdb")) {
return Promise.resolve(false);
}
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
@@ -17,7 +17,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
_everyAfterResumeProcess(): Promise<boolean> {
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
if (defaultRemoteProviderRegistry.isRemoteTypeInFamily(this.settings.remoteType, "couchdb")) {
const LiveSyncEnabled = this.settings.liveSync;
const continuous = LiveSyncEnabled;
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_WEBDAV } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isReplicationReady = true) {
function createModule(
settings: { liveSync: boolean; remoteType?: string; syncOnStart: boolean },
isReplicationReady = true
) {
const openReplication = vi.fn(async () => true);
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
const services = {
@@ -30,7 +34,7 @@ function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isR
_services: services,
services,
settings: {
remoteType: "",
remoteType: REMOTE_COUCHDB,
...settings,
},
replicator: { openReplication },
@@ -86,4 +90,19 @@ describe("ModuleReplicatorCouchDB resume replication activity", () => {
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
expect(openReplication).not.toHaveBeenCalled();
});
it.each([REMOTE_MINIO, REMOTE_WEBDAV])("does not claim or resume registered Journal provider %s", async (remoteType) => {
const { module, openReplication, runFiniteReplicationActivity } = createModule({
liveSync: true,
remoteType,
syncOnStart: true,
});
await expect(module._anyNewReplicator()).resolves.toBe(false);
await module._everyAfterResumeProcess();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
expect(openReplication).not.toHaveBeenCalled();
});
});
-18
View File
@@ -1,18 +0,0 @@
import { REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
import type { LiveSyncCore } from "@/main";
import { AbstractModule } from "@/modules/AbstractModule";
export class ModuleReplicatorMinIO extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
if (settings.remoteType == REMOTE_MINIO) {
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
}
return Promise.resolve(false);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
}
}
@@ -176,8 +176,9 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
new Setting(paneEl).autoWireToggle("disableCheckingConfigMismatch");
});
void addPanel(paneEl, "Remediation").then((paneEl) => {
let dateEl: HTMLSpanElement;
new Setting(paneEl)
const setting = new Setting(paneEl);
const dateEl = setting.controlEl.createSpan();
setting
.addText((text) => {
const updateDateText = () => {
if (this.editingSettings.maxMTimeForReflectEvents == 0) {
@@ -188,7 +189,6 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
}
this.requestUpdate();
};
text.inputEl.before((dateEl = activeDocument.createSpan()));
text.inputEl.type = "datetime-local";
if (this.editingSettings.maxMTimeForReflectEvents > 0) {
const date = new Date(this.editingSettings.maxMTimeForReflectEvents);
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { panePatches } from "./PanePatches.ts";
const remediationHarness = vi.hoisted(() => {
const dateElement = { textContent: "" };
const createSpan = vi.fn(() => dateElement);
const inputEl = {
type: "",
};
const textComponent = {
inputEl,
onChange: vi.fn(),
setValue: vi.fn(),
};
return {
createSpan,
dateElement,
inputEl,
textComponent,
};
});
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class LiveSyncSetting {
controlEl = {
createSpan: remediationHarness.createSpan,
};
addText(callback: (text: typeof remediationHarness.textComponent) => void): this {
callback(remediationHarness.textComponent);
return this;
}
setAuto(): this {
return this;
}
addApplyButton(): this {
return this;
}
autoWireToggle(): this {
return this;
}
},
}));
afterEach(() => {
Reflect.deleteProperty(globalThis, "activeDocument");
vi.clearAllMocks();
remediationHarness.dateElement.textContent = "";
remediationHarness.inputEl.type = "";
});
describe("panePatches remediation setting", () => {
it("creates the status element in the setting control instead of the document", () => {
const hierarchyError = new DOMException(
"Failed to execute 'appendChild' on 'Node': Only one element on document allowed.",
"HierarchyRequestError"
);
const createSpan = vi.fn(() => {
throw hierarchyError;
});
Object.defineProperty(globalThis, "activeDocument", {
configurable: true,
value: { createSpan },
});
const host = {
addOnSaved: vi.fn(),
editingSettings: {
maxMTimeForReflectEvents: 0,
},
requestUpdate: vi.fn(),
};
const addPanel = vi.fn((_paneEl: HTMLElement, title: string) => ({
then(callback: (paneEl: HTMLElement) => void) {
if (title === "Remediation") {
callback({} as HTMLElement);
}
return Promise.resolve();
},
}));
panePatches.call(
host as never,
{} as HTMLElement,
{
addPanel,
} as never
);
expect(createSpan).not.toHaveBeenCalled();
expect(remediationHarness.createSpan).toHaveBeenCalledOnce();
expect(remediationHarness.dateElement.textContent).toBe("No limit configured");
});
});
@@ -1,7 +1,4 @@
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
type ObsidianLiveSyncSettings,
@@ -21,18 +18,12 @@ import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
import {
activateRemoteConfiguration,
createRemoteConfigurationId,
defaultRemoteProviderRegistry,
suggestRemoteConfigurationName,
type BuiltInRemoteConfiguration,
type RemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import type { RemoteConfigurationResult } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import type {
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes.ts";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
@@ -43,10 +34,6 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
}
return workObj;
}
function createRemoteConfigurationId(): string {
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function cloneRemoteConfigurations(
configs: Record<string, RemoteConfiguration> | undefined
): Record<string, RemoteConfiguration> {
@@ -54,13 +41,19 @@ function cloneRemoteConfigurations(
}
function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string {
if (settings.remoteType === REMOTE_MINIO) {
return ConnectionStringParser.serialize({ type: "s3", settings });
const type = defaultRemoteProviderRegistry.typeForRemoteType(settings.remoteType);
if (!type) throw new Error(`Unsupported remote type: ${settings.remoteType}`);
const configuration = defaultRemoteProviderRegistry.configurationFromSettings(type, settings);
return defaultRemoteProviderRegistry.serialise(configuration);
}
function describeRemoteConfiguration(uri: string): string {
try {
const configuration = defaultRemoteProviderRegistry.parse(uri);
return defaultRemoteProviderRegistry.suggestName(configuration);
} catch {
return "";
}
if (settings.remoteType === REMOTE_P2P) {
return ConnectionStringParser.serialize({ type: "p2p", settings });
}
return ConnectionStringParser.serialize({ type: "couchdb", settings });
}
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
@@ -71,21 +64,6 @@ function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string)
return button;
}
function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): string {
if (parsed.type === "couchdb") {
try {
const url = new URL(parsed.settings.couchDB_URI);
return `CouchDB ${url.host}`;
} catch {
return "Imported CouchDB";
}
}
if (parsed.type === "s3") {
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
}
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
}
export function paneRemoteConfig(
this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement,
@@ -189,48 +167,10 @@ export function paneRemoteConfig(
};
const runRemoteSetup = async (
baseSettings: ObsidianLiveSyncSettings,
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
type?: BuiltInRemoteConfiguration["type"]
): Promise<ObsidianLiveSyncSettings | false> => {
const setupManager = this.core.getModule(SetupManager);
const dialogManager = setupManager.dialogManager;
let targetRemoteType = remoteType;
if (targetRemoteType === undefined) {
const method = await dialogManager.openWithExplicitCancel(SetupRemote);
if (method === "cancelled") {
return false;
}
targetRemoteType =
method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB;
}
if (targetRemoteType === REMOTE_MINIO) {
const bucketConf = await dialogManager.openWithExplicitCancel(SetupRemoteBucket, baseSettings);
if (bucketConf === "cancelled" || typeof bucketConf !== "object") {
return false;
}
return { ...baseSettings, ...bucketConf, remoteType: REMOTE_MINIO };
}
if (targetRemoteType === REMOTE_P2P) {
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, baseSettings);
if (p2pConf === "cancelled" || typeof p2pConf !== "object") {
return false;
}
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
}
const couchConf = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: baseSettings,
mode: "settings",
});
if (couchConf === "cancelled" || typeof couchConf !== "object") {
return false;
}
return { ...baseSettings, ...couchConf, remoteType: REMOTE_COUCHDB };
return await setupManager.configureRemoteForSettings(baseSettings, type);
};
const createBaseRemoteSettings = (): ObsidianLiveSyncSettings => ({
...DEFAULT_SETTINGS,
@@ -282,9 +222,9 @@ export function paneRemoteConfig(
return;
}
let parsed: RemoteConfigurationResult;
let parsed: BuiltInRemoteConfiguration;
try {
parsed = ConnectionStringParser.parse(trimmedURI);
parsed = defaultRemoteProviderRegistry.parse(trimmedURI);
} catch (ex) {
this.services.API.addLog(`Failed to import remote configuration!`, LOG_LEVEL_NOTICE);
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
@@ -302,7 +242,7 @@ export function paneRemoteConfig(
configs[id] = {
id,
name: name.trim() || defaultName,
uri: ConnectionStringParser.serialize(parsed),
uri: defaultRemoteProviderRegistry.serialise(parsed),
isEncrypted: false,
};
this.editingSettings.remoteConfigurations = configs;
@@ -328,7 +268,7 @@ export function paneRemoteConfig(
for (const config of Object.values(configs)) {
const row = new Setting(listContainer)
.setName(config.name)
.setDesc(config.uri.split("@").pop() || ""); // Show host part for privacy
.setDesc(describeRemoteConfiguration(config.uri));
if (config.id === this.editingSettings.activeConfigurationId) {
row.nameEl.addClass("sls-active-remote-name");
@@ -337,9 +277,9 @@ export function paneRemoteConfig(
row.addButton((btn) =>
setEmojiButton(btn, "🔧", "Configure").onClick(async () => {
let parsed: RemoteConfigurationResult;
let parsed: BuiltInRemoteConfiguration;
try {
parsed = ConnectionStringParser.parse(config.uri);
parsed = defaultRemoteProviderRegistry.parse(config.uri);
} catch (ex) {
this.services.API.addLog(
`Failed to parse remote configuration '${config.id}' for editing!`,
@@ -349,16 +289,9 @@ export function paneRemoteConfig(
return;
}
const workSettings = createBaseRemoteSettings();
if (parsed.type === "couchdb") {
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else {
workSettings.remoteType = REMOTE_P2P;
}
Object.assign(workSettings, parsed.settings);
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
const nextSettings = await runRemoteSetup(workSettings, workSettings.remoteType);
const nextSettings = await runRemoteSetup(workSettings, parsed.type);
if (!nextSettings) {
return;
}
@@ -447,9 +380,9 @@ export function paneRemoteConfig(
.addSeparator()
.addItem((item) => {
item.setTitle("📡 Fetch remote settings").onClick(async () => {
let parsed: RemoteConfigurationResult;
let parsed: BuiltInRemoteConfiguration;
try {
parsed = ConnectionStringParser.parse(config.uri);
parsed = defaultRemoteProviderRegistry.parse(config.uri);
} catch (ex) {
this.services.API.addLog(
`Failed to parse remote configuration '${config.id}' for fetching settings!`,
@@ -459,14 +392,7 @@ export function paneRemoteConfig(
return;
}
const workSettings = createBaseRemoteSettings();
if (parsed.type === "couchdb") {
workSettings.remoteType = REMOTE_COUCHDB;
} else if (parsed.type === "s3") {
workSettings.remoteType = REMOTE_MINIO;
} else {
workSettings.remoteType = REMOTE_P2P;
}
Object.assign(workSettings, parsed.settings);
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
const newTweaks =
await this.services.tweakValue.checkAndAskUseRemoteConfiguration(
workSettings
@@ -1,4 +1,10 @@
import { pickBucketSyncSettings, pickCouchDBSyncSettings, pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import {
pickBucketSyncSettings,
pickCouchDBSyncSettings,
pickP2PSyncSettings,
pickPostgRESTSyncSettings,
pickWebDAVSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
// Keep the setting dialogue buffer aligned with the current core settings before persisting other dirty keys.
@@ -11,7 +17,15 @@ export function syncActivatedRemoteSettings(
remoteType: source.remoteType,
activeConfigurationId: source.activeConfigurationId,
...pickBucketSyncSettings(source),
...pickWebDAVSyncSettings(source),
...pickPostgRESTSyncSettings(source),
...pickCouchDBSyncSettings(source),
...pickP2PSyncSettings(source),
// Provider pickers share these fields. Keep the active profile's protocol after
// collecting provider-specific connection fields so that one provider cannot
// normalise another provider's Journal policy.
expectedRepositoryId: source.expectedRepositoryId ?? "",
journalFormat: source.journalFormat ?? "opaque-v1",
packReadPolicy: source.packReadPolicy ?? "whole-pack",
});
}
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_POSTGREST,
REMOTE_WEBDAV,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer";
describe("syncActivatedRemoteSettings", () => {
@@ -28,6 +34,9 @@ describe("syncActivatedRemoteSettings", () => {
useCustomRequestHandler: false,
forcePathStyle: true,
bucketCustomHeaders: "",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1" as const,
packReadPolicy: "range" as const,
};
syncActivatedRemoteSettings(target, source);
@@ -40,6 +49,9 @@ describe("syncActivatedRemoteSettings", () => {
expect(target.bucket).toBe("vault");
expect(target.region).toBe("sz-hq");
expect(target.bucketPrefix).toBe("folder/");
expect(target.expectedRepositoryId).toBe("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
expect(target.journalFormat).toBe("adaptive-v1");
expect(target.packReadPolicy).toBe("range");
expect(target.encrypt).toBe(true);
});
@@ -80,4 +92,65 @@ describe("syncActivatedRemoteSettings", () => {
expect(target.couchDB_PASSWORD).toBe("current-pass");
expect(target.couchDB_DBNAME).toBe("current-db");
});
it("should copy the active WebDAV connection and Adaptive protocol into the editing buffer", () => {
const target = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
activeConfigurationId: "old-remote",
webDAVactiveConnectionURI: "sls+webdav://stale.invalid/",
expectedRepositoryId: "",
journalFormat: "opaque-v1" as const,
packReadPolicy: "whole-pack" as const,
};
const source = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_WEBDAV,
activeConfigurationId: "remote-webdav",
webDAVactiveConnectionURI: "sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1" as const,
packReadPolicy: "range" as const,
};
syncActivatedRemoteSettings(target, source);
expect(target.remoteType).toBe(REMOTE_WEBDAV);
expect(target.activeConfigurationId).toBe("remote-webdav");
expect(target.webDAVactiveConnectionURI).toBe(source.webDAVactiveConnectionURI);
expect(target.expectedRepositoryId).toBe(source.expectedRepositoryId);
expect(target.journalFormat).toBe("adaptive-v1");
expect(target.packReadPolicy).toBe("range");
});
it("should copy the active PostgREST connection and fixed Adaptive protocol into the editing buffer", () => {
const target = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
activeConfigurationId: "old-remote",
postgrestActiveConnectionURI: "",
expectedRepositoryId: "",
journalFormat: "opaque-v1" as const,
packReadPolicy: "range" as const,
};
const source = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_POSTGREST,
activeConfigurationId: "remote-postgrest",
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1" as const,
packReadPolicy: "whole-pack" as const,
};
syncActivatedRemoteSettings(target, source);
expect(target.remoteType).toBe(REMOTE_POSTGREST);
expect(target.activeConfigurationId).toBe("remote-postgrest");
expect(target.postgrestActiveConnectionURI).toBe(source.postgrestActiveConnectionURI);
expect(target.expectedRepositoryId).toBe(source.expectedRepositoryId);
expect(target.journalFormat).toBe("adaptive-v1");
expect(target.packReadPolicy).toBe("whole-pack");
});
});
+146 -82
View File
@@ -1,15 +1,16 @@
import {
type BucketSyncSetting,
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
REMOTE_MINIO,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { generateAdaptiveJournalRepositoryIdV1 } from "@vrtmrz/livesync-commonlib/adaptive-journal";
import {
defaultRemoteProviderRegistry,
upsertRemoteConfigurationInPlace,
type BuiltInRemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import Intro from "./SetupWizard/dialogs/Intro.svelte";
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
@@ -20,9 +21,6 @@ import OutroNewUser from "./SetupWizard/dialogs/OutroNewUser.svelte";
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { AbstractModule } from "@/modules/AbstractModule.ts";
@@ -31,11 +29,8 @@ import type {
OutroExistingUserResultType,
OutroNewUserResultType,
ScanQRCodeResultType,
SetupRemoteBucketResultType,
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType,
SetupRemoteP2PResultType,
SetupRemoteInitialData,
SetupRemoteResultType,
UseSetupURIResultType,
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
@@ -44,6 +39,8 @@ import {
applySettingsWithScheduledInitialisation,
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { builtInRemoteSetupRegistry } from "./SetupWizard/builtInRemoteSetupProviders.ts";
import type { RemoteSetupIntent } from "./SetupWizard/RemoteSetupRegistry.ts";
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
return {
@@ -75,10 +72,35 @@ export const enum UserMode {
Update = "unknown", // Alias for Unknown for better readability
}
type AdaptiveJournalIdentitySetting = {
expectedRepositoryId?: string;
journalFormat?: string;
};
async function prepareAdaptiveRepositoryIdentityForSetup<T extends object>(
settings: T,
userMode: UserMode
): Promise<T> {
const adaptiveSettings = settings as T & AdaptiveJournalIdentitySetting;
if (
userMode !== UserMode.NewUser ||
adaptiveSettings.journalFormat !== "adaptive-v1" ||
(adaptiveSettings.expectedRepositoryId ?? "").trim() !== ""
) {
return settings;
}
return {
...settings,
expectedRepositoryId: await generateAdaptiveJournalRepositoryIdV1(),
};
}
/**
* Setup Manager to handle onboarding and configuration setup
*/
export class SetupManager extends AbstractModule {
readonly remoteSetupRegistry = builtInRemoteSetupRegistry;
// /**
// * Dialog manager for handling Svelte dialogs
// */
@@ -157,6 +179,81 @@ export class SetupManager extends AbstractModule {
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
}
private setupIntent(userMode: UserMode): RemoteSetupIntent {
if (userMode === UserMode.NewUser) return "create-or-connect";
if (userMode === UserMode.ExistingUser) return "connect-existing";
return "settings";
}
private async selectRemoteSetupProvider(): Promise<BuiltInRemoteConfiguration["type"] | false> {
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType, SetupRemoteInitialData>(
SetupRemote,
this.remoteSetupRegistry.choices()
);
return method === "cancelled" ? false : method;
}
/**
* Opens a provider-specific dialogue without applying or storing the resulting settings.
*
* The settings pane uses this boundary to prepare a remote profile in its own edit buffer.
*/
async configureRemoteForSettings(
currentSetting: ObsidianLiveSyncSettings,
type?: BuiltInRemoteConfiguration["type"]
): Promise<ObsidianLiveSyncSettings | false> {
const selectedType = type ?? (await this.selectRemoteSetupProvider());
if (selectedType === false) return false;
const configuration = await this.remoteSetupRegistry.open(selectedType, {
dialogManager: this.dialogManager,
intent: "settings",
settings: currentSetting,
});
if (configuration === "cancelled") return false;
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
return newSetting;
}
private async onRemoteManualSetup(
type: BuiltInRemoteConfiguration["type"],
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate: boolean
): Promise<boolean> {
const configuration = await this.remoteSetupRegistry.open(type, {
dialogManager: this.dialogManager,
intent: this.setupIntent(userMode),
settings: currentSetting,
});
if (configuration === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
const preparedSettings = await prepareAdaptiveRepositoryIdentityForSetup(configuration.settings, userMode);
const preparedConfiguration = {
...configuration,
settings: preparedSettings,
} as BuiltInRemoteConfiguration;
if (activate) {
defaultRemoteProviderRegistry.applyConfiguration(newSetting, preparedConfiguration);
} else {
Object.assign(newSetting, preparedSettings);
}
const activateForP2P = defaultRemoteProviderRegistry.supportsActivationRole(type, "p2p");
upsertRemoteConfigurationInPlace(newSetting, type, {
id: activateForP2P ? newSetting.P2P_ActiveRemoteConfigurationId || undefined : undefined,
activate,
activateForP2P,
});
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for CouchDB
* @param userMode
@@ -169,31 +266,7 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const couchConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: currentSetting,
mode:
userMode === UserMode.NewUser
? "create-or-connect"
: userMode === UserMode.ExistingUser
? "connect-existing"
: "settings",
});
if (couchConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...couchConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_COUCHDB;
}
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
return await this.onRemoteManualSetup("couchdb", userMode, currentSetting, activate);
}
/**
@@ -208,23 +281,37 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const bucketConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteBucketResultType,
BucketSyncSetting
>(SetupRemoteBucket, currentSetting);
if (bucketConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...bucketConf,
} as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_MINIO;
}
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
return await this.onRemoteManualSetup("s3", userMode, currentSetting, activate);
}
/**
* Handles manual setup for WebDAV Journal storage.
* @param userMode
* @param currentSetting
* @param activate Whether to activate WebDAV as the main remote type
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onWebDAVManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
return await this.onRemoteManualSetup("webdav", userMode, currentSetting, activate);
}
/**
* Handles manual setup for Adaptive Journal storage through PostgREST.
* @param userMode
* @param currentSetting
* @param activate Whether to activate PostgREST as the main remote type
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onPostgRESTManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
return await this.onRemoteManualSetup("postgrest", userMode, currentSetting, activate);
}
/**
@@ -239,24 +326,7 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const p2pConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
SetupRemoteP2P,
currentSetting
);
if (p2pConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = {
...copySettingsForRemoteProfileUpdate(currentSetting),
...p2pConf,
} as ObsidianLiveSyncSettings;
upsertRemoteConfigurationInPlace(newSetting, "p2p", {
id: newSetting.P2P_ActiveRemoteConfigurationId || undefined,
activate,
activateForP2P: true,
});
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
return await this.onRemoteManualSetup("p2p", userMode, currentSetting, activate);
}
/**
@@ -310,21 +380,15 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType>(SetupRemote);
if (method === "couchdb") {
return await this.onCouchDBManualSetup(userMode, currentSetting, true);
} else if (method === "bucket") {
return await this.onBucketManualSetup(userMode, currentSetting, true);
} else if (method === "p2p") {
return await this.onP2PManualSetup(userMode, currentSetting, true);
} else if (method === "cancelled") {
const method = await this.selectRemoteSetupProvider();
if (method === false) {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
if (userMode !== UserMode.Unknown) {
return await this.onOnboard(userMode);
}
return false;
}
// Should not reach here.
return false;
return await this.onRemoteManualSetup(method, userMode, currentSetting, true);
}
/**
* Confirms and applies settings obtained from the wizard
@@ -2,12 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
REMOTE_POSTGREST,
REMOTE_WEBDAV,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
@@ -20,6 +24,8 @@ vi.mock("./SetupWizard/dialogs/OutroAskUserMode.svelte", () => ({ default: {} })
vi.mock("./SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteWebDAV.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemotePostgREST.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
@@ -305,6 +311,44 @@ describe("SetupManager", () => {
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
});
it("preserves Adaptive WebDAV fields imported through a Setup URI profile", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
const imported = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
remoteConfigurations: {
webdav: {
id: "webdav",
name: "WebDAV notes",
uri:
"sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F" +
`&journalFormat=adaptive-v1&expectedRepositoryId=${repositoryId}&packReadPolicy=range`,
isEncrypted: false,
},
},
activeConfigurationId: "webdav",
} as ObsidianLiveSyncSettings;
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://webdav-settings");
const current = setting.currentSettings();
expect(current.activeConfigurationId).toBe("webdav");
const parsed = ConnectionStringParser.parse(current.remoteConfigurations.webdav.uri);
expect(parsed).toMatchObject({
type: "webdav",
settings: {
webDAVactiveConnectionURI: "sls+webdav://alice:secret@dav.example/dav?prefix=notes%2F",
expectedRepositoryId: repositoryId,
journalFormat: "adaptive-v1",
packReadPolicy: "range",
},
});
});
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
@@ -407,6 +451,9 @@ describe("SetupManager", () => {
useCustomRequestHandler: false,
bucketCustomHeaders: "",
forcePathStyle: true,
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1",
packReadPolicy: "range",
})
.mockResolvedValueOnce(true);
@@ -419,6 +466,316 @@ describe("SetupManager", () => {
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.name).toBe("S3 notes");
expect(activeProfile?.uri).toContain("sls+s3://key:secret@storage.example");
expect(activeProfile?.uri).toContain("journalFormat=adaptive-v1");
expect(activeProfile?.uri).toContain("packReadPolicy=range");
expect(activeProfile?.uri).toContain("expectedRepositoryId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
it("uses the registered provider choices when configuring a Settings edit buffer", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const bucketSettings = {
endpoint: "https://storage.example",
accessKey: "key",
secretKey: "secret",
bucket: "notes",
region: "auto",
bucketPrefix: "",
useCustomRequestHandler: false,
bucketCustomHeaders: "",
forcePathStyle: true,
};
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("s3").mockResolvedValueOnce(bucketSettings);
const nextSettings = await manager.configureRemoteForSettings(setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ type: "couchdb" }),
expect.objectContaining({ type: "s3" }),
expect.objectContaining({ type: "webdav" }),
expect.objectContaining({ type: "postgrest" }),
expect.objectContaining({ type: "p2p" }),
])
);
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
2,
expect.anything(),
setting.currentSettings()
);
expect(nextSettings).toEqual(expect.objectContaining({ ...bucketSettings, remoteType: REMOTE_MINIO }));
});
it("preselects a repository identity during fresh Adaptive Object Storage onboarding", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
endpoint: "https://storage.example",
accessKey: "key",
secretKey: "secret",
bucket: "notes",
region: "auto",
bucketPrefix: "",
useCustomRequestHandler: false,
bucketCustomHeaders: "",
forcePathStyle: true,
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onBucketManualSetup(UserMode.NewUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toMatch(/^[A-Za-z0-9_-]{43}$/u);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).toContain(`expectedRepositoryId=${current.expectedRepositoryId}`);
});
it("leaves an existing-device Adaptive Object Storage attachment on trust on first use", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
endpoint: "https://storage.example",
accessKey: "key",
secretKey: "secret",
bucket: "notes",
region: "auto",
bucketPrefix: "",
useCustomRequestHandler: false,
bucketCustomHeaders: "",
forcePathStyle: true,
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onBucketManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toBe("");
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).not.toContain("expectedRepositoryId=");
});
it("adds and activates a manually configured WebDAV profile without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: true,
remoteConfigurations: {
existing: {
id: "existing",
name: "Existing remote",
uri: "sls+http://old:secret@old.example/?db=old",
isEncrypted: false,
},
},
activeConfigurationId: "existing",
};
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
webDAVactiveConnectionURI:
"sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1",
packReadPolicy: "range",
})
.mockResolvedValueOnce(true);
await manager.onWebDAVManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.remoteType).toBe(REMOTE_WEBDAV);
expect(current.remoteConfigurations.existing).toBeDefined();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.name).toBe("WebDAV dav.example");
expect(activeProfile?.uri).toContain("sls+webdav://alice:secret@dav.example");
expect(activeProfile?.uri).toContain("journalFormat=adaptive-v1");
expect(activeProfile?.uri).toContain("packReadPolicy=range");
expect(activeProfile?.uri).toContain("expectedRepositoryId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
it("preselects a repository identity during fresh Adaptive WebDAV onboarding", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
webDAVactiveConnectionURI:
"sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onWebDAVManualSetup(UserMode.NewUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toMatch(/^[A-Za-z0-9_-]{43}$/u);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).toContain(`expectedRepositoryId=${current.expectedRepositoryId}`);
});
it("leaves an existing-device Adaptive WebDAV attachment on trust on first use", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
webDAVactiveConnectionURI:
"sls+webdav://alice:secret@dav.example/remote.php/dav/files/alice?prefix=notes%2F",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onWebDAVManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toBe("");
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).not.toContain("expectedRepositoryId=");
});
it.each([
[UserMode.NewUser, "onboarding"],
[UserMode.ExistingUser, "onboarding"],
[UserMode.Update, "settings"],
] as const)("passes the %s WebDAV verification policy to the manual setup dialogue", async (userMode, mode) => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
vi.spyOn(manager, "onOnboard").mockResolvedValue(false);
await manager.onWebDAVManualSetup(userMode, setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), {
settings: setting.currentSettings(),
mode,
});
});
it("adds and activates a manually configured PostgREST profile without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
isConfigured: true,
remoteConfigurations: {
existing: {
id: "existing",
name: "Existing remote",
uri: "sls+http://old:secret@old.example/?db=old",
isEncrypted: false,
},
},
activeConfigurationId: "existing",
};
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onPostgRESTManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.remoteType).toBe(REMOTE_POSTGREST);
expect(current.remoteConfigurations.existing).toBeDefined();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.name).toBe("PostgREST project.example");
expect(activeProfile?.uri).toContain("sls+postgrest://vault-id-00000001:vault-credential@project.example");
expect(activeProfile?.uri).toContain("journalFormat=adaptive-v1");
expect(activeProfile?.uri).toContain("expectedRepositoryId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
expect(ConnectionStringParser.parse(activeProfile?.uri ?? "")).toMatchObject({
type: "postgrest",
settings: { packReadPolicy: "whole-pack" },
});
});
it("preselects a repository identity during fresh Adaptive PostgREST onboarding", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onPostgRESTManualSetup(UserMode.NewUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toMatch(/^[A-Za-z0-9_-]{43}$/u);
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).toContain(`expectedRepositoryId=${current.expectedRepositoryId}`);
});
it("leaves an existing-device Adaptive PostgREST attachment on trust on first use", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onPostgRESTManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings();
expect(current.expectedRepositoryId).toBe("");
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
expect(activeProfile?.uri).not.toContain("expectedRepositoryId=");
});
it.each([
[UserMode.NewUser, "onboarding"],
[UserMode.ExistingUser, "onboarding"],
[UserMode.Update, "settings"],
] as const)("passes the %s PostgREST verification policy to the manual setup dialogue", async (userMode, mode) => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
vi.spyOn(manager, "onOnboard").mockResolvedValue(false);
await manager.onPostgRESTManualSetup(userMode, setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), {
settings: setting.currentSettings(),
mode,
});
});
it("routes a manual PostgREST selection through the registered setup provider", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce("postgrest")
.mockResolvedValueOnce({
postgrestActiveConnectionURI:
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
.mockResolvedValueOnce(true);
await manager.onSelectServer(setting.currentSettings(), UserMode.NewUser);
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(2, expect.anything(), {
settings: expect.anything(),
mode: "onboarding",
});
expect(setting.currentSettings().remoteType).toBe(REMOTE_POSTGREST);
});
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
@@ -0,0 +1,84 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type {
BuiltInRemoteConfiguration,
RemoteProviderConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { ComponentHasResult } from "@/modules/services/LiveSyncUI/svelteDialog";
export type RemoteSetupIntent = "create-or-connect" | "connect-existing" | "settings";
export interface RemoteSetupDialogManager {
openWithExplicitCancel<TResult, TInitial = TResult>(
component: ComponentHasResult<TResult, TInitial>,
initialData?: TInitial
): Promise<TResult>;
}
export interface RemoteSetupContext {
readonly dialogManager: RemoteSetupDialogManager;
readonly intent: RemoteSetupIntent;
readonly settings: ObsidianLiveSyncSettings;
}
export interface RemoteSetupChoice<TType extends string = string> {
readonly description: string;
readonly proceedTitle: string;
readonly title: string;
readonly type: TType;
}
export interface RemoteSetupProviderDescriptor<
TConfiguration extends RemoteProviderConfiguration = RemoteProviderConfiguration,
> {
readonly type: TConfiguration["type"];
choice(): Omit<RemoteSetupChoice<TConfiguration["type"]>, "type">;
open(context: RemoteSetupContext): Promise<TConfiguration | "cancelled">;
}
/**
* Host-side presentation for the remote providers compiled into this plug-in.
*
* Connection semantics remain in Commonlib's remote provider registry. This registry only owns
* the Obsidian-specific choice text and setup dialogue for each provider.
*/
export class RemoteSetupRegistry<TConfiguration extends RemoteProviderConfiguration = BuiltInRemoteConfiguration> {
private readonly providersByType = new Map<string, RemoteSetupProviderDescriptor<TConfiguration>>();
private frozen = false;
register<TProviderConfiguration extends TConfiguration>(
descriptor: RemoteSetupProviderDescriptor<TProviderConfiguration>
): this {
if (this.frozen) throw new Error("The remote setup registry is frozen");
if (this.providersByType.has(descriptor.type)) {
throw new Error(`Remote setup provider '${descriptor.type}' is already registered`);
}
this.providersByType.set(descriptor.type, descriptor);
return this;
}
freeze(): this {
this.frozen = true;
return this;
}
isFrozen(): boolean {
return this.frozen;
}
choices(): RemoteSetupChoice<TConfiguration["type"]>[] {
return [...this.providersByType.values()].map((provider) => ({
...provider.choice(),
type: provider.type,
}));
}
has(type: string): type is TConfiguration["type"] {
return this.providersByType.has(type);
}
open(type: TConfiguration["type"], context: RemoteSetupContext): Promise<TConfiguration | "cancelled"> {
const provider = this.providersByType.get(type);
if (!provider) throw new Error(`Unsupported remote setup provider: ${type}`);
return provider.open(context);
}
}
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
type TestConfiguration =
| { type: "alpha"; settings: { value: string } }
| { type: "beta"; settings: { enabled: boolean } };
function alphaProvider(
open: RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>>["open"] = async () => ({
type: "alpha",
settings: { value: "configured" },
})
): RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>> {
return {
type: "alpha",
choice: () => ({
title: "Alpha",
description: "Alpha description",
proceedTitle: "Configure Alpha",
}),
open,
};
}
describe("RemoteSetupRegistry", () => {
it("preserves registration order and dispatches setup through the selected provider", async () => {
const openAlpha = vi.fn(alphaProvider().open);
const registry = new RemoteSetupRegistry<TestConfiguration>()
.register(alphaProvider(openAlpha))
.register({
type: "beta",
choice: () => ({
title: "Beta",
description: "Beta description",
proceedTitle: "Configure Beta",
}),
open: async () => ({ type: "beta", settings: { enabled: true } }),
})
.freeze();
const context = {
dialogManager: { openWithExplicitCancel: vi.fn() },
intent: "settings" as const,
settings: {} as never,
};
expect(registry.choices().map((choice) => choice.type)).toEqual(["alpha", "beta"]);
await expect(registry.open("alpha", context)).resolves.toEqual({
type: "alpha",
settings: { value: "configured" },
});
expect(openAlpha).toHaveBeenCalledWith(context);
});
it("rejects duplicate and late registrations", () => {
const registry = new RemoteSetupRegistry<TestConfiguration>().register(alphaProvider());
expect(() => registry.register(alphaProvider())).toThrow("already registered");
registry.freeze();
expect(registry.isFrozen()).toBe(true);
expect(() =>
registry.register({
type: "beta",
choice: () => ({ title: "Beta", description: "", proceedTitle: "" }),
open: async () => "cancelled",
})
).toThrow("frozen");
});
});
@@ -0,0 +1,164 @@
import type { BucketSyncSetting, P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { $msg as translateMessage } from "@/common/translation";
import SetupRemoteBucket from "./dialogs/SetupRemoteBucket.svelte";
import SetupRemoteCouchDB from "./dialogs/SetupRemoteCouchDB.svelte";
import SetupRemoteP2P from "./dialogs/SetupRemoteP2P.svelte";
import SetupRemotePostgREST from "./dialogs/SetupRemotePostgREST.svelte";
import SetupRemoteWebDAV from "./dialogs/SetupRemoteWebDAV.svelte";
import type {
SetupRemoteBucketResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteCouchDBResultType,
SetupRemoteP2PResultType,
SetupRemotePostgRESTInitialData,
SetupRemotePostgRESTResultType,
SetupRemoteWebDAVInitialData,
SetupRemoteWebDAVResultType,
} from "./dialogs/setupDialogTypes";
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
type ConfigurationOf<TType extends BuiltInRemoteConfiguration["type"]> = Extract<
BuiltInRemoteConfiguration,
{ type: TType }
>;
function assertSemanticProvider(type: BuiltInRemoteConfiguration["type"]): void {
const isRegistered = defaultRemoteProviderRegistry.providerSummaries().some((provider) => provider.type === type);
if (!isRegistered) throw new Error(`Remote setup provider '${type}' has no Commonlib provider`);
}
export function useCouchDBRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"couchdb">> = {
type: "couchdb",
choice: () => ({
title: "CouchDB",
description: translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc"),
proceedTitle: translateMessage("Continue to CouchDB setup"),
}),
open: async ({ dialogManager, intent, settings }) => {
const result = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, { settings, mode: intent });
return result === "cancelled" ? result : { type: "couchdb", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function useS3RemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"s3">> = {
type: "s3",
choice: () => ({
title: translateMessage("Ui.SetupWizard.SetupRemote.BucketOption"),
description: translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc"),
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket"),
}),
open: async ({ dialogManager, settings }) => {
const result = await dialogManager.openWithExplicitCancel<SetupRemoteBucketResultType, BucketSyncSetting>(
SetupRemoteBucket,
settings
);
return result === "cancelled" ? result : { type: "s3", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function useP2PRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"p2p">> = {
type: "p2p",
choice: () => ({
title: translateMessage("Ui.SetupWizard.SetupRemote.P2POption"),
description: translateMessage(
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
),
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P"),
}),
open: async ({ dialogManager, settings }) => {
const result = await dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
SetupRemoteP2P,
settings
);
return result === "cancelled" ? result : { type: "p2p", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function useWebDAVRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"webdav">> = {
type: "webdav",
choice: () => ({
title: translateMessage("WebDAV Journal"),
description: translateMessage(
"Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check."
),
proceedTitle: translateMessage("Continue to WebDAV setup"),
}),
open: async ({ dialogManager, intent, settings }) => {
const result = await dialogManager.openWithExplicitCancel<
SetupRemoteWebDAVResultType,
SetupRemoteWebDAVInitialData
>(SetupRemoteWebDAV, {
settings,
mode: intent === "settings" ? "settings" : "onboarding",
});
return result === "cancelled" ? result : { type: "webdav", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function usePostgRESTRemoteSetup(
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"postgrest">> = {
type: "postgrest",
choice: () => ({
title: translateMessage("PostgREST Journal"),
description: translateMessage(
"Store Adaptive Journal records through the packaged PostgREST SQL contract. This experimental provider requires a provisioned Vault credential, and onboarding requires a successful server capability check."
),
proceedTitle: translateMessage("Continue to PostgREST setup"),
}),
open: async ({ dialogManager, intent, settings }) => {
const result = await dialogManager.openWithExplicitCancel<
SetupRemotePostgRESTResultType,
SetupRemotePostgRESTInitialData
>(SetupRemotePostgREST, {
settings,
mode: intent === "settings" ? "settings" : "onboarding",
});
return result === "cancelled" ? result : { type: "postgrest", settings: result };
},
};
assertSemanticProvider(descriptor.type);
return registry.register(descriptor);
}
export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
useCouchDBRemoteSetup(registry);
useS3RemoteSetup(registry);
useWebDAVRemoteSetup(registry);
usePostgRESTRemoteSetup(registry);
useP2PRemoteSetup(registry);
return registry;
}
export const builtInRemoteSetupRegistry = createBuiltInRemoteSetupRegistry().freeze();
@@ -7,58 +7,33 @@
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import {
TYPE_COUCHDB,
TYPE_BUCKET,
TYPE_P2P,
TYPE_CANCELLED,
type SetupRemoteResultType,
} from "./setupDialogTypes";
import { onMount } from "svelte";
import { type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { TYPE_CANCELLED, type SetupRemoteInitialData, type SetupRemoteResultType } from "./setupDialogTypes";
type Props = {
setResult: (result: SetupRemoteResultType) => void;
};
const { setResult }: Props = $props();
type Props = GuestDialogProps<SetupRemoteResultType, SetupRemoteInitialData>;
const { setResult, getInitialData }: Props = $props();
let choices = $state<SetupRemoteInitialData>([]);
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_COUCHDB) {
return translateMessage("Continue to CouchDB setup");
} else if (userType === TYPE_BUCKET) {
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
} else if (userType === TYPE_P2P) {
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
} else {
return translateMessage("Please select an option to proceed");
}
});
const canProceed = $derived.by(() => {
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
onMount(() => {
choices = getInitialData?.() ?? [];
});
const selectedChoice = $derived(choices.find((choice) => choice.type === userType));
const proceedTitle = $derived(
selectedChoice?.proceedTitle ?? translateMessage("Please select an option to proceed")
);
const canProceed = $derived(selectedChoice !== undefined);
</script>
<DialogHeader title={translateMessage("Ui.SetupWizard.SetupRemote.Title")} />
<Instruction>
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
<Options>
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
{translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc")}
</Option>
<Option
selectedValue={TYPE_BUCKET}
title={translateMessage("Ui.SetupWizard.SetupRemote.BucketOption")}
bind:value={userType}
>
{translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc")}
</Option>
<Option
selectedValue={TYPE_P2P}
title={translateMessage("Ui.SetupWizard.SetupRemote.P2POption")}
bind:value={userType}
>
{translateMessage(
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
)}
</Option>
{#each choices as choice (choice.type)}
<Option selectedValue={choice.type} title={choice.title} bind:value={userType}>
{choice.description}
</Option>
{/each}
</Options>
</Instruction>
<UserDecisions>
@@ -20,10 +20,16 @@
import { copyTo, pickBucketSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { TYPE_CANCELLED, type SetupRemoteBucketResultType } from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
import { normaliseS3JournalSettings } from "./s3JournalSettings";
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<BucketSyncSetting>({ ...default_setting });
let syncSetting = $state<BucketSyncSetting>({
...default_setting,
expectedRepositoryId: default_setting.expectedRepositoryId ?? "",
journalFormat: default_setting.journalFormat ?? "opaque-v1",
packReadPolicy: default_setting.packReadPolicy ?? "whole-pack",
});
type Props = GuestDialogProps<SetupRemoteBucketResultType, BucketSyncSetting>;
@@ -58,11 +64,10 @@
isEndpointSupplied
);
});
const isAdaptive = $derived(syncSetting.journalFormat === "adaptive-v1");
function generateSetting() {
const connSetting: BucketSyncSetting = {
...syncSetting,
};
const connSetting = normaliseS3JournalSettings(syncSetting);
const trialSettings: BucketSyncSetting = {
...connSetting,
};
@@ -115,8 +120,13 @@
}
}
function commit() {
const setting = pickBucketSyncSettings(generateSetting());
setResult(setting);
error = "";
try {
const setting = pickBucketSyncSettings(generateSetting());
setResult(setting);
} catch (e) {
error = translateMessage("Invalid Object Storage settings: ${reason}", { reason: `${e}` });
}
}
function cancel() {
setResult(TYPE_CANCELLED);
@@ -220,6 +230,30 @@
</InfoNote>
<ExtraItems title={translateMessage("Advanced Settings")}>
<InputRow label={translateMessage("Journal data format")}>
<select name="s3-journal-format" bind:value={syncSetting.journalFormat}>
<option value="opaque-v1">{translateMessage("Opaque Journal (current format)")}</option>
<option value="adaptive-v1">{translateMessage("Adaptive Journal (experimental)")}</option>
</select>
</InputRow>
<InfoNote warning visible={isAdaptive}>
{translateMessage(
"Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats."
)}
</InfoNote>
{#if isAdaptive}
<InputRow label={translateMessage("Pack retrieval")}>
<select name="s3-pack-read-policy" bind:value={syncSetting.packReadPolicy}>
<option value="whole-pack">{translateMessage("Download complete Packs")}</option>
<option value="range">{translateMessage("Use S3 Range requests")}</option>
</select>
</InputRow>
<InfoNote>
{translateMessage(
"Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing."
)}
</InfoNote>
{/if}
<InputRow label={translateMessage("Custom Headers")}>
<textarea
name="bucket-custom-headers"
@@ -0,0 +1,380 @@
<script lang="ts">
import { onMount } from "svelte";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import {
DEFAULT_SETTINGS,
PREFERRED_JOURNAL_SYNC,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_POSTGREST,
isJournalStorageConnectionInspector,
type JournalStorageConnectivityResult,
type PostgRESTSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
import {
TYPE_CANCELLED,
type PostgRESTSetupMode,
type SetupRemotePostgRESTInitialData,
type SetupRemotePostgRESTResultType,
} from "./setupDialogTypes";
import {
postgRESTJournalFormFromSettings,
postgRESTSyncSettingsFromForm,
type PostgRESTJournalForm,
} from "./postgRESTJournalSettings";
import { $msg as translateMessage } from "@/common/translation";
let syncSetting = $state<PostgRESTJournalForm>(
postgRESTJournalFormFromSettings({
postgrestActiveConnectionURI: "",
expectedRepositoryId: "",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
})
);
let setupMode = $state<PostgRESTSetupMode>("settings");
let error = $state("");
let processing = $state(false);
let inspection = $state<JournalStorageConnectivityResult | undefined>();
let inspectionFingerprint = $state("");
let inspectedSettings = $state<PostgRESTSyncSetting | undefined>();
type Props = GuestDialogProps<SetupRemotePostgRESTResultType, SetupRemotePostgRESTInitialData>;
const { setResult, getInitialData }: Props = $props();
const context = getDialogContext();
onMount(() => {
const initialData = getInitialData?.();
if (!initialData) return;
setupMode = initialData.mode;
try {
Object.assign(syncSetting, postgRESTJournalFormFromSettings(initialData.settings));
} catch (ex) {
error = translateMessage("Invalid PostgREST settings: ${REASON}", {
REASON: ex instanceof Error ? ex.message : `${ex}`,
});
}
});
const isEndpointInsecure = $derived.by(() => syncSetting.endpoint.trim().toLowerCase().startsWith("http://"));
const isEndpointValid = $derived.by(() => {
try {
const endpoint = new URL(syncSetting.endpoint.trim());
return (
(endpoint.protocol === "http:" || endpoint.protocol === "https:") &&
endpoint.username === "" &&
endpoint.password === "" &&
endpoint.search === "" &&
endpoint.hash === ""
);
} catch {
return false;
}
});
const isSchemaValid = $derived(/^[A-Za-z_][A-Za-z0-9_]*$/u.test(syncSetting.schema.trim()));
const isVaultIdValid = $derived(/^[A-Za-z0-9_-]{16,128}$/u.test(syncSetting.vaultId.trim()));
const isVaultCredentialValid = $derived(
syncSetting.vaultCredential.length > 0 && new TextEncoder().encode(syncSetting.vaultCredential).byteLength <= 512
);
const isConnectionValid = $derived(
isEndpointValid && isSchemaValid && isVaultIdValid && isVaultCredentialValid
);
const hasInvalidInput = $derived.by(
() =>
(syncSetting.endpoint.trim() !== "" ||
syncSetting.vaultId.trim() !== "" ||
syncSetting.vaultCredential !== "") &&
!isConnectionValid
);
const formFingerprint = $derived.by(() => JSON.stringify(syncSetting));
const inspectionIsCurrent = $derived(
inspection !== undefined && inspectionFingerprint !== "" && inspectionFingerprint === formFingerprint
);
const requiredCapability = $derived.by(() => {
if (!inspectionIsCurrent) return undefined;
return inspection?.adaptiveCapabilities?.required;
});
function generateSetting(postgRESTSettings: PostgRESTSyncSetting): ObsidianLiveSyncSettings {
return {
...DEFAULT_SETTINGS,
...PREFERRED_JOURNAL_SYNC,
remoteType: REMOTE_POSTGREST,
...postgRESTSettings,
};
}
function explainUnavailable(result: JournalStorageConnectivityResult): string {
if (result.remoteFormat !== undefined && result.remoteFormat !== "empty" && result.remoteFormat !== "adaptive-v1") {
return translateMessage(
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.",
{ REMOTE_FORMAT: result.remoteFormat, SELECTED_FORMAT: "adaptive-v1" }
);
}
const required = result.adaptiveCapabilities?.required;
if (required?.status === "unsupported") {
return translateMessage("The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.", {
CAPABILITIES: required.missing.join(", "),
});
}
if (required?.status === "failed") {
return translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
CATEGORY: required.failure.category,
RETRY: required.failure.retry,
});
}
return translateMessage("The PostgREST SQL contract is unavailable or incompatible with this build.");
}
async function inspectConnection(trialRemoteSetting: ObsidianLiveSyncSettings, testedFingerprint: string) {
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) {
throw new Error(translateMessage("Failed to create replicator instance."));
}
if (!isJournalStorageConnectionInspector(replicator)) {
throw new Error(translateMessage("This build cannot inspect PostgREST Journal capabilities."));
}
const result = await replicator.inspectJournalStorageConnection(trialRemoteSetting);
inspection = result;
inspectionFingerprint = testedFingerprint;
return result;
}
async function checkConnection() {
error = "";
inspection = undefined;
inspectionFingerprint = "";
inspectedSettings = undefined;
processing = true;
try {
const testedFingerprint = formFingerprint;
const candidate = postgRESTSyncSettingsFromForm(syncSetting);
const trialRemoteSetting = generateSetting(candidate);
const result = await inspectConnection(trialRemoteSetting, testedFingerprint);
if (testedFingerprint !== formFingerprint) return;
if (!result.available) {
error = explainUnavailable(result);
return;
}
inspectedSettings = candidate;
} catch (ex) {
error = translateMessage("Error during connection test: ${reason}", {
reason: ex instanceof Error ? ex.message : `${ex}`,
});
} finally {
processing = false;
}
}
function commitVerified() {
if (!inspectionIsCurrent || !inspection?.available || !inspectedSettings) return;
setResult(inspectedSettings);
}
function commit() {
error = "";
if (!isConnectionValid) return;
try {
setResult(postgRESTSyncSettingsFromForm(syncSetting));
} catch (ex) {
error = translateMessage("Invalid PostgREST settings: ${REASON}", {
REASON: ex instanceof Error ? ex.message : `${ex}`,
});
}
}
</script>
<DialogHeader title={translateMessage("PostgREST Journal Configuration")} />
<Guidance>
{translateMessage(
"Connect to the packaged, Adaptive-only PostgREST RPC contract. This experimental provider is not a CouchDB endpoint and does not expose synchronisation tables directly."
)}
</Guidance>
<InputRow label={translateMessage("Endpoint URL")}>
<input
type="text"
name="postgrest-endpoint"
placeholder="https://project.example/rest/v1"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
pattern="^https?://.+"
bind:value={syncSetting.endpoint}
/>
</InputRow>
<InfoNote warning visible={isEndpointInsecure}>
{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")}
</InfoNote>
<InfoNote error visible={syncSetting.endpoint.trim() !== "" && !isEndpointValid}>
{translateMessage(
"Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment."
)}
</InfoNote>
<InputRow label={translateMessage("Vault ID")}>
<input
type="text"
name="postgrest-vault-id"
placeholder="provisioned-vault-id"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={syncSetting.vaultId}
/>
</InputRow>
<InputRow label={translateMessage("Vault credential")}>
<Password
name="postgrest-vault-credential"
placeholder={translateMessage("Enter the provisioned Vault credential")}
required
bind:value={syncSetting.vaultCredential}
/>
</InputRow>
<InfoNote>
{translateMessage(
"A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential."
)}
</InfoNote>
<InputRow label={translateMessage("Exposed schema")}>
<input
type="text"
name="postgrest-schema"
placeholder="livesync_api"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={syncSetting.schema}
/>
</InputRow>
<InputRow label={translateMessage("Client API key (optional)")}>
<Password
name="postgrest-api-key"
placeholder={translateMessage("Supabase publishable key, if required")}
bind:value={syncSetting.apiKey}
/>
</InputRow>
<InfoNote caution>
{translateMessage(
"Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential."
)}
</InfoNote>
<InfoNote error visible={hasInvalidInput}>
{translateMessage(
"Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential."
)}
</InfoNote>
<InputRow label={translateMessage("Use internal API")}>
<input type="checkbox" name="postgrest-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
</InputRow>
<InfoNote>
{translateMessage(
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch."
)}
</InfoNote>
<ExtraItems title={translateMessage("Advanced Settings")}>
<InputRow label={translateMessage("Expected repository ID")}>
<input
type="text"
name="postgrest-expected-repository-id"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.expectedRepositoryId}
/>
</InputRow>
<InfoNote>
{translateMessage(
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached."
)}
</InfoNote>
</ExtraItems>
<InfoNote warning>
{translateMessage(
"PostgREST stores only Adaptive Journal records. It cannot read Opaque Journal data, and format changes require a remote Rebuild rather than an in-place migration."
)}
</InfoNote>
{#if requiredCapability?.status === "verified"}
<InfoNote notice>
{translateMessage("The required PostgREST RPC operations and binary semantics were verified.")}
</InfoNote>
{:else if requiredCapability?.status === "unsupported"}
<InfoNote error>
{translateMessage("The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.", {
CAPABILITIES: requiredCapability.missing.join(", "),
})}
</InfoNote>
{:else if requiredCapability?.status === "failed"}
<InfoNote error>
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
CATEGORY: requiredCapability.failure.category,
RETRY: requiredCapability.failure.retry,
})}
</InfoNote>
{:else if inspectionIsCurrent}
<InfoNote warning>{translateMessage("Required Adaptive operations were not checked.")}</InfoNote>
{/if}
<InfoNote>
{translateMessage(
"The saved connection contains the Vault credential and optional API key. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string."
)}
</InfoNote>
<InfoNote error visible={error !== ""}>{error}</InfoNote>
{#if processing}
{translateMessage("Checking connection... Please wait.")}
{:else}
<UserDecisions>
{#if inspectionIsCurrent && inspection?.available && inspectedSettings}
<Decision
title={setupMode === "settings"
? translateMessage("Save verified settings")
: translateMessage("Continue with verified settings")}
important
commit={() => commitVerified()}
/>
<Decision
title={translateMessage("Check PostgREST server")}
disabled={!isConnectionValid}
commit={() => checkConnection()}
/>
{:else}
<Decision
title={translateMessage("Check PostgREST server")}
important
disabled={!isConnectionValid}
commit={() => checkConnection()}
/>
{/if}
{#if setupMode === "settings"}
<InfoNote warning>
{translateMessage(
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected."
)}
</InfoNote>
<Decision
title={translateMessage("Save without connecting")}
disabled={!isConnectionValid}
commit={() => commit()}
/>
{/if}
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{/if}
@@ -0,0 +1,403 @@
<script lang="ts">
import { onMount } from "svelte";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import {
DEFAULT_SETTINGS,
PREFERRED_JOURNAL_SYNC,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_WEBDAV,
isJournalStorageConnectionInspector,
type JournalStorageConnectivityResult,
type WebDAVSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
import {
TYPE_CANCELLED,
type SetupRemoteWebDAVInitialData,
type SetupRemoteWebDAVResultType,
type WebDAVSetupMode,
} from "./setupDialogTypes";
import {
summariseAdaptiveCapabilityInspection,
webDAVJournalFormFromSettings,
webDAVSyncSettingsFromForm,
type WebDAVJournalForm,
} from "./webDAVJournalSettings";
import { $msg as translateMessage } from "@/common/translation";
let syncSetting = $state<WebDAVJournalForm>(
webDAVJournalFormFromSettings({
webDAVactiveConnectionURI: "",
expectedRepositoryId: "",
journalFormat: "opaque-v1",
packReadPolicy: "whole-pack",
})
);
let setupMode = $state<WebDAVSetupMode>("settings");
let error = $state("");
let processing = $state(false);
let inspection = $state<JournalStorageConnectivityResult | undefined>();
let inspectionFingerprint = $state("");
let inspectedSettings = $state<WebDAVSyncSetting | undefined>();
type Props = GuestDialogProps<SetupRemoteWebDAVResultType, SetupRemoteWebDAVInitialData>;
const { setResult, getInitialData }: Props = $props();
const context = getDialogContext();
onMount(() => {
const initialData = getInitialData?.();
if (!initialData) return;
setupMode = initialData.mode;
try {
Object.assign(syncSetting, webDAVJournalFormFromSettings(initialData.settings));
} catch (ex) {
error = translateMessage("Invalid WebDAV settings: ${REASON}", {
REASON: ex instanceof Error ? ex.message : `${ex}`,
});
}
});
const isAdaptive = $derived(syncSetting.journalFormat === "adaptive-v1");
const isEndpointInsecure = $derived.by(() => syncSetting.endpoint.trim().toLowerCase().startsWith("http://"));
const isEndpointValid = $derived.by(() => {
try {
const endpoint = new URL(syncSetting.endpoint.trim());
return (
(endpoint.protocol === "http:" || endpoint.protocol === "https:") &&
endpoint.search === "" &&
endpoint.hash === ""
);
} catch {
return false;
}
});
const isEndpointInvalid = $derived(syncSetting.endpoint.trim() !== "" && !isEndpointValid);
const formFingerprint = $derived.by(() => JSON.stringify(syncSetting));
const inspectionIsCurrent = $derived(
inspection !== undefined && inspectionFingerprint !== "" && inspectionFingerprint === formFingerprint
);
const adaptiveSummary = $derived.by(() => {
if (!inspectionIsCurrent || !inspection?.adaptiveCapabilities) return undefined;
return summariseAdaptiveCapabilityInspection(inspection.adaptiveCapabilities);
});
function generateSetting(webDAVSettings: WebDAVSyncSetting): ObsidianLiveSyncSettings {
return {
...DEFAULT_SETTINGS,
...PREFERRED_JOURNAL_SYNC,
remoteType: REMOTE_WEBDAV,
...webDAVSettings,
};
}
function explainUnavailable(result: JournalStorageConnectivityResult): string {
if (
result.remoteFormat !== undefined &&
result.remoteFormat !== "empty" &&
result.remoteFormat !== syncSetting.journalFormat
) {
return translateMessage(
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.",
{
REMOTE_FORMAT: result.remoteFormat,
SELECTED_FORMAT: syncSetting.journalFormat,
}
);
}
return translateMessage("The selected WebDAV Journal policy is not supported by this endpoint.");
}
async function inspectConnection(trialRemoteSetting: ObsidianLiveSyncSettings, testedFingerprint: string) {
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) {
throw new Error(translateMessage("Failed to create replicator instance."));
}
if (!isJournalStorageConnectionInspector(replicator)) {
throw new Error(translateMessage("This build cannot inspect WebDAV Journal capabilities."));
}
const result = await replicator.inspectJournalStorageConnection(trialRemoteSetting);
inspection = result;
inspectionFingerprint = testedFingerprint;
return result;
}
async function checkConnection() {
error = "";
inspection = undefined;
inspectionFingerprint = "";
inspectedSettings = undefined;
processing = true;
try {
const testedFingerprint = formFingerprint;
const candidate = webDAVSyncSettingsFromForm(syncSetting);
const trialRemoteSetting = generateSetting(candidate);
const result = await inspectConnection(trialRemoteSetting, testedFingerprint);
if (testedFingerprint !== formFingerprint) return;
if (!result.available) {
error = explainUnavailable(result);
return;
}
inspectedSettings = candidate;
} catch (ex) {
error = translateMessage("Error during connection test: ${reason}", {
reason: ex instanceof Error ? ex.message : `${ex}`,
});
} finally {
processing = false;
}
}
function commitVerified() {
if (!inspectionIsCurrent || !inspection?.available || !inspectedSettings) return;
setResult(inspectedSettings);
}
function commit() {
error = "";
try {
setResult(webDAVSyncSettingsFromForm(syncSetting));
} catch (ex) {
error = translateMessage("Invalid WebDAV settings: ${REASON}", {
REASON: ex instanceof Error ? ex.message : `${ex}`,
});
}
}
</script>
<DialogHeader title={translateMessage("WebDAV Journal Configuration")} />
<Guidance>
{translateMessage(
"Configure a dedicated WebDAV collection for Journal synchronisation. Opaque Journal needs ordinary WebDAV access. Adaptive Journal additionally runs an endpoint safety check before the profile is accepted."
)}
</Guidance>
<InputRow label={translateMessage("Endpoint URL")}>
<input
type="text"
name="webdav-endpoint"
placeholder="https://dav.example/remote.php/dav/files/alice"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
pattern="^https?://.+"
bind:value={syncSetting.endpoint}
/>
</InputRow>
<InfoNote warning visible={isEndpointInsecure}>
{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")}
</InfoNote>
<InfoNote error visible={isEndpointInvalid}>
{translateMessage("Enter a complete HTTP or HTTPS endpoint without a query string or fragment.")}
</InfoNote>
<InputRow label={translateMessage("Username")}>
<input
type="text"
name="webdav-username"
placeholder={translateMessage("Enter your username")}
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.username}
/>
</InputRow>
<InputRow label={translateMessage("Password")}>
<Password
name="webdav-password"
placeholder={translateMessage("Enter your password")}
bind:value={syncSetting.password}
/>
</InputRow>
<InputRow label={translateMessage("Collection prefix")}>
<input
type="text"
name="webdav-prefix"
placeholder="livesync-journal/"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.prefix}
/>
</InputRow>
<InfoNote>
{translateMessage(
"Use a dedicated prefix. WebDAV listing scans the collection, so unrelated files and a long Journal history increase discovery work."
)}
</InfoNote>
<InputRow label={translateMessage("Use internal API")}>
<input type="checkbox" name="webdav-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
</InputRow>
<InfoNote>
{translateMessage(
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch."
)}
</InfoNote>
<ExtraItems title={translateMessage("Advanced Settings")}>
<InputRow label={translateMessage("Journal data format")}>
<select name="webdav-journal-format" bind:value={syncSetting.journalFormat}>
<option value="opaque-v1">{translateMessage("Opaque Journal (current format)")}</option>
<option value="adaptive-v1">{translateMessage("Adaptive Journal (experimental)")}</option>
</select>
</InputRow>
<InfoNote warning visible={isAdaptive}>
{translateMessage(
"Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats."
)}
</InfoNote>
{#if isAdaptive}
<InputRow label={translateMessage("Expected repository ID")}>
<input
type="text"
name="webdav-expected-repository-id"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.expectedRepositoryId}
/>
</InputRow>
<InfoNote>
{translateMessage(
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached."
)}
</InfoNote>
<InputRow label={translateMessage("Pack retrieval")}>
<select name="webdav-pack-read-policy" bind:value={syncSetting.packReadPolicy}>
<option value="whole-pack">{translateMessage("Download complete Packs")}</option>
<option value="range">{translateMessage("Use HTTP Range requests")}</option>
</select>
</InputRow>
<InfoNote>
{translateMessage(
"Complete Pack reads favour throughput and are the portable default. Range reads can reduce transferred bytes, but this endpoint must pass the exact byte-range check."
)}
</InfoNote>
<InfoNote caution>
{translateMessage(
"The Adaptive safety check writes, reads, lists, and removes disposable objects under a random probe prefix. It does not inspect Vault data."
)}
</InfoNote>
{/if}
<InputRow label={translateMessage("Custom Headers")}>
<textarea
name="webdav-custom-headers"
placeholder="e.g., x-example-header: value"
bind:value={syncSetting.customHeaders}
autocapitalize="off"
spellcheck="false"
rows="4"
></textarea>
</InputRow>
</ExtraItems>
{#if adaptiveSummary}
{#if adaptiveSummary.required.kind === "verified"}
<InfoNote notice>
{translateMessage("Required Adaptive operations are supported by this WebDAV endpoint.")}
</InfoNote>
{:else if adaptiveSummary.required.kind === "unsupported"}
<InfoNote error>
{translateMessage("The WebDAV endpoint is missing required Adaptive operations: ${CAPABILITIES}.", {
CAPABILITIES: adaptiveSummary.required.missing.join(", "),
})}
</InfoNote>
{:else if adaptiveSummary.required.kind === "failed"}
<InfoNote error>
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
CATEGORY: adaptiveSummary.required.category,
RETRY: adaptiveSummary.required.retry,
})}
</InfoNote>
{:else}
<InfoNote warning>{translateMessage("Required Adaptive operations were not checked.")}</InfoNote>
{/if}
{#if adaptiveSummary.byteRange.kind === "verified"}
<InfoNote notice>{translateMessage("Exact HTTP byte-range retrieval is supported.")}</InfoNote>
{:else if adaptiveSummary.byteRange.kind === "unsupported"}
<InfoNote warning>
{translateMessage("HTTP byte-range retrieval is not supported. Complete Pack retrieval remains available.")}
</InfoNote>
{:else if adaptiveSummary.byteRange.kind === "failed"}
<InfoNote warning>
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
CATEGORY: adaptiveSummary.byteRange.category,
RETRY: adaptiveSummary.byteRange.retry,
})}
</InfoNote>
{:else}
<InfoNote warning>
{translateMessage(
"HTTP byte-range retrieval was not checked because the required safety check did not complete."
)}
</InfoNote>
{/if}
{/if}
{#if inspectionIsCurrent && inspection?.available && !isAdaptive}
<InfoNote notice>
{translateMessage("WebDAV access and the selected Journal format were verified.")}
</InfoNote>
{/if}
<InfoNote>
{translateMessage(
"The saved connection contains credentials and custom headers. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string."
)}
</InfoNote>
<InfoNote error visible={error !== ""}>{error}</InfoNote>
{#if processing}
{translateMessage("Checking connection... Please wait.")}
{:else}
<UserDecisions>
{#if inspectionIsCurrent && inspection?.available && inspectedSettings}
<Decision
title={setupMode === "settings"
? translateMessage("Save verified settings")
: translateMessage("Continue with verified settings")}
important
commit={() => commitVerified()}
/>
<Decision
title={isAdaptive
? translateMessage("Run endpoint safety check")
: translateMessage("Test WebDAV connection")}
disabled={!isEndpointValid}
commit={() => checkConnection()}
/>
{:else}
<Decision
title={isAdaptive
? translateMessage("Run endpoint safety check")
: translateMessage("Test WebDAV connection")}
important
disabled={!isEndpointValid}
commit={() => checkConnection()}
/>
{/if}
{#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={!isEndpointValid}
commit={() => commit()}
/>
{/if}
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{/if}
@@ -0,0 +1,65 @@
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_POSTGREST,
journalProtocolConfigurationForSettings,
parsePostgRESTConnectionURI,
serialisePostgRESTConnectionURI,
type PostgRESTConnection,
type PostgRESTSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
export type PostgRESTJournalForm = PostgRESTConnection & {
expectedRepositoryId: string;
};
const emptyPostgRESTConnection: PostgRESTConnection = {
apiKey: "",
endpoint: "",
schema: "livesync_api",
useCustomRequestHandler: false,
vaultCredential: "",
vaultId: "",
};
function resolveProtocol(settings: PostgRESTSyncSetting) {
return journalProtocolConfigurationForSettings({
...DEFAULT_SETTINGS,
remoteType: REMOTE_POSTGREST,
...settings,
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
});
}
export function postgRESTJournalFormFromSettings(settings: PostgRESTSyncSetting): PostgRESTJournalForm {
const activeConnectionURI = settings.postgrestActiveConnectionURI.trim();
const connection = activeConnectionURI
? parsePostgRESTConnectionURI(activeConnectionURI)
: emptyPostgRESTConnection;
const protocol = resolveProtocol({
...settings,
expectedRepositoryId: activeConnectionURI ? settings.expectedRepositoryId : "",
});
return {
...connection,
expectedRepositoryId: protocol.expectedRepositoryId,
};
}
export function postgRESTSyncSettingsFromForm(form: PostgRESTJournalForm): PostgRESTSyncSetting {
const settings: PostgRESTSyncSetting = {
postgrestActiveConnectionURI: serialisePostgRESTConnectionURI({
apiKey: form.apiKey.trim(),
endpoint: form.endpoint.trim(),
schema: form.schema.trim(),
useCustomRequestHandler: form.useCustomRequestHandler,
vaultCredential: form.vaultCredential,
vaultId: form.vaultId.trim(),
}),
expectedRepositoryId: form.expectedRepositoryId.trim(),
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
};
resolveProtocol(settings);
return settings;
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { serialisePostgRESTConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import { postgRESTJournalFormFromSettings, postgRESTSyncSettingsFromForm } from "./postgRESTJournalSettings.ts";
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
describe("PostgREST Journal settings", () => {
it("round-trips client connection fields with the fixed Adaptive protocol", () => {
const settings = {
postgrestActiveConnectionURI: serialisePostgRESTConnectionURI({
apiKey: "publishable-key",
endpoint: "https://project.example/rest/v1",
schema: "private_sync",
useCustomRequestHandler: true,
vaultCredential: "credential with spaces",
vaultId: "vault-id-00000001",
}),
expectedRepositoryId: repositoryId,
journalFormat: "adaptive-v1" as const,
packReadPolicy: "whole-pack" as const,
};
const form = postgRESTJournalFormFromSettings(settings);
expect(form).toEqual({
apiKey: "publishable-key",
endpoint: "https://project.example/rest/v1",
expectedRepositoryId: repositoryId,
schema: "private_sync",
useCustomRequestHandler: true,
vaultCredential: "credential with spaces",
vaultId: "vault-id-00000001",
});
expect(postgRESTSyncSettingsFromForm(form)).toEqual(settings);
});
it("uses the exposed-schema default for a new profile", () => {
expect(
postgRESTJournalFormFromSettings({
postgrestActiveConnectionURI: "",
expectedRepositoryId: repositoryId,
journalFormat: "opaque-v1",
packReadPolicy: "range",
})
).toEqual({
apiKey: "",
endpoint: "",
expectedRepositoryId: "",
schema: "livesync_api",
useCustomRequestHandler: false,
vaultCredential: "",
vaultId: "",
});
});
it("rejects an invalid pinned repository identity", () => {
expect(() =>
postgRESTSyncSettingsFromForm({
apiKey: "publishable-key",
endpoint: "https://project.example/rest/v1",
expectedRepositoryId: "AA",
schema: "livesync_api",
useCustomRequestHandler: false,
vaultCredential: "vault-credential",
vaultId: "vault-id-00000001",
})
).toThrow("expectedRepositoryId must be a canonical base64url-encoded 32-byte value");
});
});
@@ -0,0 +1,25 @@
import { DEFAULT_SETTINGS, REMOTE_MINIO, type BucketSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { journalProtocolConfigurationForSettings } from "@vrtmrz/livesync-commonlib/journal-storage";
export function normaliseS3JournalSettings(settings: BucketSyncSetting): BucketSyncSetting {
const journalFormat = settings.journalFormat ?? "opaque-v1";
const candidate: BucketSyncSetting = {
...settings,
bucket: settings.bucket.trim(),
bucketPrefix: settings.bucketPrefix.trim(),
endpoint: settings.endpoint.trim(),
expectedRepositoryId: journalFormat === "adaptive-v1" ? (settings.expectedRepositoryId ?? "").trim() : "",
journalFormat,
packReadPolicy: journalFormat === "adaptive-v1" ? (settings.packReadPolicy ?? "whole-pack") : "whole-pack",
region: settings.region.trim(),
};
const protocol = journalProtocolConfigurationForSettings({
...DEFAULT_SETTINGS,
...candidate,
remoteType: REMOTE_MINIO,
});
return {
...candidate,
...protocol,
};
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, type BucketSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { pickBucketSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { normaliseS3JournalSettings } from "./s3JournalSettings";
function settings(overrides: Partial<BucketSyncSetting> = {}): BucketSyncSetting {
return {
...pickBucketSyncSettings(DEFAULT_SETTINGS),
...overrides,
};
}
describe("normaliseS3JournalSettings", () => {
it("retains and validates Adaptive repository options", () => {
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
const result = normaliseS3JournalSettings(
settings({
bucket: " vault ",
bucketPrefix: " journals/ ",
endpoint: " https://storage.example ",
expectedRepositoryId: ` ${repositoryId} `,
journalFormat: "adaptive-v1",
packReadPolicy: "range",
region: " auto ",
})
);
expect(result).toMatchObject({
bucket: "vault",
bucketPrefix: "journals/",
endpoint: "https://storage.example",
expectedRepositoryId: repositoryId,
journalFormat: "adaptive-v1",
packReadPolicy: "range",
region: "auto",
});
});
it("uses conservative Opaque defaults for older profiles", () => {
const legacy = settings();
delete legacy.expectedRepositoryId;
delete legacy.journalFormat;
delete legacy.packReadPolicy;
expect(normaliseS3JournalSettings(legacy)).toMatchObject({
expectedRepositoryId: "",
journalFormat: "opaque-v1",
packReadPolicy: "whole-pack",
});
});
it("clears Adaptive-only options when Opaque Journal is selected", () => {
expect(
normaliseS3JournalSettings(
settings({
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
journalFormat: "opaque-v1",
packReadPolicy: "range",
})
)
).toMatchObject({
expectedRepositoryId: "",
journalFormat: "opaque-v1",
packReadPolicy: "whole-pack",
});
});
it("rejects a non-canonical expected repository ID", () => {
expect(() =>
normaliseS3JournalSettings(
settings({
expectedRepositoryId: "not-a-repository-id",
journalFormat: "adaptive-v1",
})
)
).toThrow("expectedRepositoryId must be a canonical base64url-encoded 32-byte value");
});
});
@@ -4,7 +4,11 @@ import type {
EncryptionSettings,
ObsidianLiveSyncSettings,
P2PConnectionInfo,
PostgRESTSyncSetting,
WebDAVSyncSetting,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { RemoteSetupChoice } from "@/modules/features/SetupWizard/RemoteSetupRegistry";
export const TYPE_IDENTICAL = "identical";
export const TYPE_INDEPENDENT = "independent";
@@ -38,7 +42,7 @@ export const TYPE_CLOSE = "close";
// SetupRemote
export const TYPE_COUCHDB = "couchdb";
export const TYPE_BUCKET = "bucket";
export const TYPE_BUCKET = "s3";
export const TYPE_P2P = "p2p";
export type ResultTypeVault =
@@ -93,7 +97,8 @@ export type SelectMethodExistingResultType =
| typeof TYPE_CONFIGURE_MANUALLY
| typeof TYPE_CANCELLED;
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
export type SetupRemoteResultType = BuiltInRemoteConfiguration["type"] | typeof TYPE_CANCELLED;
export type SetupRemoteInitialData = RemoteSetupChoice<BuiltInRemoteConfiguration["type"]>[];
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
@@ -101,6 +106,20 @@ export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettin
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
export type SetupRemoteWebDAVResultType = typeof TYPE_CANCELLED | WebDAVSyncSetting;
export type WebDAVSetupMode = "onboarding" | "settings";
export type SetupRemoteWebDAVInitialData = {
settings: WebDAVSyncSetting;
mode: WebDAVSetupMode;
};
export type SetupRemotePostgRESTResultType = typeof TYPE_CANCELLED | PostgRESTSyncSetting;
export type PostgRESTSetupMode = "onboarding" | "settings";
export type SetupRemotePostgRESTInitialData = {
settings: PostgRESTSyncSetting;
mode: PostgRESTSetupMode;
};
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings";
export type SetupRemoteCouchDBInitialData = {
@@ -0,0 +1,107 @@
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REMOTE_WEBDAV,
journalProtocolConfigurationForSettings,
parseWebDAVConnectionURI,
serialiseWebDAVConnectionURI,
type AdaptiveJournalPackReadPolicyV1,
type JournalFormatV1,
type JournalStorageAdaptiveCapabilityInspection,
type JournalStorageCapabilityInspection,
type WebDAVConnection,
type WebDAVSyncSetting,
} from "@vrtmrz/livesync-commonlib/journal-storage";
export type WebDAVJournalForm = WebDAVConnection & {
expectedRepositoryId: string;
journalFormat: JournalFormatV1;
packReadPolicy: AdaptiveJournalPackReadPolicyV1;
};
export type WebDAVCapabilitySummary =
| { kind: "verified" }
| { kind: "not-checked" }
| { kind: "unsupported"; missing: string[] }
| {
kind: "failed";
category: "authentication" | "invalid-response" | "permission" | "rate-limited" | "unavailable" | "unknown";
retry: "later" | "never" | "verify-first";
};
export type WebDAVAdaptiveCapabilitySummary = {
byteRange: WebDAVCapabilitySummary;
required: WebDAVCapabilitySummary;
};
const emptyWebDAVConnection: WebDAVConnection = {
customHeaders: "",
endpoint: "",
password: "",
prefix: "",
useCustomRequestHandler: false,
username: "",
};
function resolveProtocol(settings: WebDAVSyncSetting) {
return journalProtocolConfigurationForSettings({
...DEFAULT_SETTINGS,
remoteType: REMOTE_WEBDAV,
...settings,
});
}
export function webDAVJournalFormFromSettings(settings: WebDAVSyncSetting): WebDAVJournalForm {
const connection = settings.webDAVactiveConnectionURI.trim()
? parseWebDAVConnectionURI(settings.webDAVactiveConnectionURI.trim())
: emptyWebDAVConnection;
const protocol = resolveProtocol(settings);
return {
...connection,
...protocol,
};
}
export function webDAVSyncSettingsFromForm(form: WebDAVJournalForm): WebDAVSyncSetting {
const journalFormat = form.journalFormat;
const settings: WebDAVSyncSetting = {
webDAVactiveConnectionURI: serialiseWebDAVConnectionURI({
customHeaders: form.customHeaders.trim(),
endpoint: form.endpoint.trim(),
password: form.password,
prefix: form.prefix.trim(),
useCustomRequestHandler: form.useCustomRequestHandler,
username: form.username.trim(),
}),
expectedRepositoryId: journalFormat === "adaptive-v1" ? form.expectedRepositoryId.trim() : "",
journalFormat,
packReadPolicy: journalFormat === "adaptive-v1" ? form.packReadPolicy : "whole-pack",
};
resolveProtocol(settings);
return settings;
}
function summariseCapabilityInspection(inspection: JournalStorageCapabilityInspection): WebDAVCapabilitySummary {
switch (inspection.status) {
case "verified":
return { kind: "verified" };
case "not-checked":
return { kind: "not-checked" };
case "unsupported":
return { kind: "unsupported", missing: [...inspection.missing] };
case "failed":
return {
category: inspection.failure.category,
kind: "failed",
retry: inspection.failure.retry,
};
}
}
export function summariseAdaptiveCapabilityInspection(
inspection: JournalStorageAdaptiveCapabilityInspection
): WebDAVAdaptiveCapabilitySummary {
return {
byteRange: summariseCapabilityInspection(inspection.byteRange),
required: summariseCapabilityInspection(inspection.required),
};
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { serialiseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import {
summariseAdaptiveCapabilityInspection,
webDAVJournalFormFromSettings,
webDAVSyncSettingsFromForm,
} from "./webDAVJournalSettings.ts";
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
describe("WebDAV Journal settings", () => {
it("round-trips connection fields separately from Adaptive protocol fields", () => {
const settings = {
webDAVactiveConnectionURI: serialiseWebDAVConnectionURI({
customHeaders: "X-Vault: notes",
endpoint: "https://dav.example/remote.php/dav/files/alice",
password: "p@ss word",
prefix: "vault/notes/",
useCustomRequestHandler: true,
username: "alice@example.com",
}),
expectedRepositoryId: repositoryId,
journalFormat: "adaptive-v1" as const,
packReadPolicy: "range" as const,
};
const form = webDAVJournalFormFromSettings(settings);
expect(form).toEqual({
customHeaders: "X-Vault: notes",
endpoint: "https://dav.example/remote.php/dav/files/alice",
expectedRepositoryId: repositoryId,
journalFormat: "adaptive-v1",
packReadPolicy: "range",
password: "p@ss word",
prefix: "vault/notes/",
useCustomRequestHandler: true,
username: "alice@example.com",
});
expect(webDAVSyncSettingsFromForm(form)).toEqual(settings);
});
it("normalises editable text and removes Adaptive-only options from Opaque settings", () => {
const settings = webDAVSyncSettingsFromForm({
customHeaders: " X-Vault: notes ",
endpoint: " http://localhost:8080/dav ",
expectedRepositoryId: ` ${repositoryId} `,
journalFormat: "opaque-v1",
packReadPolicy: "range",
password: " password with spaces ",
prefix: " vault/notes/ ",
useCustomRequestHandler: false,
username: " alice ",
});
expect(settings).toEqual({
webDAVactiveConnectionURI:
"sls+webdav://alice:%20password%20with%20spaces%20@localhost:8080/dav?insecure=true&prefix=vault%2Fnotes%2F&headers=X-Vault%3A+notes",
expectedRepositoryId: "",
journalFormat: "opaque-v1",
packReadPolicy: "whole-pack",
});
});
it("rejects an invalid pinned repository identity", () => {
expect(() =>
webDAVSyncSettingsFromForm({
customHeaders: "",
endpoint: "https://dav.example/vault",
expectedRepositoryId: "AA",
journalFormat: "adaptive-v1",
packReadPolicy: "whole-pack",
password: "secret",
prefix: "",
useCustomRequestHandler: false,
username: "alice",
})
).toThrow("expectedRepositoryId must be a canonical base64url-encoded 32-byte value");
});
it("presents required capabilities and optional byte-range support independently", () => {
expect(
summariseAdaptiveCapabilityInspection({
required: { status: "verified" },
byteRange: { missing: ["byte-range"], status: "unsupported" },
})
).toEqual({
required: { kind: "verified" },
byteRange: { kind: "unsupported", missing: ["byte-range"] },
});
expect(
summariseAdaptiveCapabilityInspection({
required: {
failure: { category: "authentication", retry: "never" },
status: "failed",
},
byteRange: { status: "not-checked" },
})
).toEqual({
required: { category: "authentication", kind: "failed", retry: "never" },
byteRange: { kind: "not-checked" },
});
});
});
+6 -9
View File
@@ -147,14 +147,9 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
: req instanceof Request && typeof req.method === "string"
? req.method
: "GET";
if (typeof req !== "string") {
if (opts?.body) {
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
} else if (req.body) {
body = await new Response(req.body).arrayBuffer();
}
} else {
body = opts?.body as string;
const suppliedBody = opts?.body ?? (req instanceof Request ? req.body : undefined);
if (suppliedBody !== undefined && suppliedBody !== null) {
body = typeof suppliedBody === "string" ? suppliedBody : await new Response(suppliedBody).arrayBuffer();
}
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
@@ -192,7 +187,9 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
contentType: contentType,
};
const r = await requestUrl({ ...requestParam, throw: false });
return new Response(r.arrayBuffer, {
const responseHasNoBody =
method.toUpperCase() === "HEAD" || r.status === 204 || r.status === 205 || r.status === 304;
return new Response(responseHasNoBody ? null : r.arrayBuffer, {
headers: r.headers,
status: r.status,
statusText: `${r.status}`,
@@ -4,16 +4,17 @@ const mocks = vi.hoisted(() => ({
platform: {
isMobile: false,
},
requestUrl: vi.fn(),
}));
vi.mock("@/deps.ts", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/deps", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
@@ -65,3 +66,41 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
});
});
describe("ObsidianAPIService.nativeFetch", () => {
it("normalises a typed-array body when the request URL is a string", async () => {
mocks.requestUrl.mockResolvedValue({
arrayBuffer: new Uint8Array([9, 8, 7]).buffer,
headers: { etag: '"created"' },
status: 201,
});
const source = new Uint8Array([0, 1, 2, 3, 4]);
const body = source.subarray(1, 4);
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
body: body as unknown as BodyInit,
headers: { "Content-Type": "application/octet-stream" },
method: "PUT",
});
expect(response.status).toBe(201);
const request = mocks.requestUrl.mock.calls[0][0] as { body?: unknown };
expect(request.body).toBeInstanceOf(ArrayBuffer);
expect([...new Uint8Array(request.body as ArrayBuffer)]).toEqual([1, 2, 3]);
});
it("constructs a bodyless response for a successful DELETE", async () => {
mocks.requestUrl.mockResolvedValue({
arrayBuffer: new ArrayBuffer(0),
headers: {},
status: 204,
});
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
method: "DELETE",
});
expect(response.status).toBe(204);
expect(await response.text()).toBe("");
});
});
@@ -0,0 +1,131 @@
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { describe, expect, it } from "vitest";
import {
P2P_CHECK_APP_ID,
generateP2PCheckSetup,
resolveLocalP2PCheckRelayOverride,
} from "@/apps/webpeer/src/P2PCheckSetup";
describe("P2P connection-check setup", () => {
it.each(["desktop", "mobile"] as const)(
"creates an isolated, disposable %s Setup URI and diagnostic browser peer",
async (target) => {
const generated = await generateP2PCheckSetup(target);
const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase);
expect(decoded).not.toBe(false);
if (decoded === false) {
throw new Error("The generated Setup URI could not be decoded");
}
const effective = { ...DEFAULT_SETTINGS, ...decoded };
expect(generated.target).toBe(target);
expect(generated.setupPassphrase).toMatch(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/);
expect(generated.setupURI).toMatch(/^obsidian:\/\/setuplivesync\?settings=/);
expect(effective).toEqual(
expect.objectContaining({
remoteType: REMOTE_P2P,
isConfigured: true,
encrypt: true,
usePathObfuscation: true,
P2P_Enabled: true,
P2P_AppID: P2P_CHECK_APP_ID,
P2P_roomID: generated.groupId,
P2P_AutoStart: true,
P2P_AutoBroadcast: false,
})
);
expect(decoded.P2P_DevicePeerName).toBeUndefined();
expect(decoded.P2P_useDiagRTC).toBeUndefined();
expect(effective.passphrase).toHaveLength(32);
expect(effective.P2P_passphrase).toHaveLength(32);
expect(effective.passphrase).not.toBe(effective.P2P_passphrase);
expect(effective.P2P_AutoAccepting).toBe(DEFAULT_SETTINGS.P2P_AutoAccepting);
expect(effective.P2P_AutoSyncPeers).toBe("");
expect(effective.P2P_AutoWatchPeers).toBe("");
expect(effective.P2P_SyncOnReplication).toBe("");
const remoteConfigurations = Object.values(decoded.remoteConfigurations ?? {});
expect(remoteConfigurations).toHaveLength(1);
expect(decoded.activeConfigurationId).toBe(remoteConfigurations[0].id);
expect(decoded.P2P_ActiveRemoteConfigurationId).toBe(remoteConfigurations[0].id);
const deviceRemote = ConnectionStringParser.parse(remoteConfigurations[0].uri);
expect(deviceRemote).toEqual(
expect.objectContaining({
type: "p2p",
settings: expect.objectContaining({
P2P_AutoStart: true,
P2P_AutoBroadcast: false,
}),
})
);
expect("P2P_useDiagRTC" in deviceRemote.settings).toBe(false);
expect(generated.browserSettings).toEqual(
expect.objectContaining({
remoteType: REMOTE_P2P,
P2P_Enabled: true,
P2P_AppID: P2P_CHECK_APP_ID,
P2P_roomID: effective.P2P_roomID,
P2P_passphrase: effective.P2P_passphrase,
passphrase: effective.passphrase,
P2P_AutoStart: false,
P2P_AutoBroadcast: false,
P2P_useDiagRTC: true,
})
);
expect(generated.browserDeviceName).toMatch(/^p2p-check-browser-(?:desktop|mobile)-/);
const browserConfigurations = Object.values(generated.browserSettings.remoteConfigurations ?? {});
expect(browserConfigurations).toHaveLength(1);
const browserRemote = ConnectionStringParser.parse(browserConfigurations[0].uri);
expect(browserRemote).toEqual(
expect.objectContaining({
type: "p2p",
settings: expect.objectContaining({
P2P_AutoStart: false,
P2P_AutoBroadcast: false,
}),
})
);
expect("P2P_useDiagRTC" in browserRemote.settings).toBe(false);
}
);
it("creates independent rooms and secrets for separate checks", async () => {
const first = await generateP2PCheckSetup("desktop");
const second = await generateP2PCheckSetup("desktop");
expect(second.groupId).not.toBe(first.groupId);
expect(second.setupPassphrase).not.toBe(first.setupPassphrase);
expect(second.browserSettings.P2P_passphrase).not.toBe(first.browserSettings.P2P_passphrase);
expect(second.browserSettings.passphrase).not.toBe(first.browserSettings.passphrase);
});
it("uses the same explicitly selected relay for the Setup URI and browser peer", async () => {
const relay = "ws://127.0.0.1:4010/";
const generated = await generateP2PCheckSetup("desktop", { relay });
const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase);
expect(decoded).not.toBe(false);
if (decoded === false) {
throw new Error("The generated Setup URI could not be decoded");
}
expect(generated.relay).toBe(relay);
expect(decoded.P2P_relays).toBe(relay);
expect(generated.browserSettings.P2P_relays).toBe(relay);
});
it("accepts a relay query override only from a loopback-served check page", () => {
const search = "?relay=ws%3A%2F%2F127.0.0.1%3A4010%2F";
expect(resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search })).toBe("ws://127.0.0.1:4010/");
expect(resolveLocalP2PCheckRelayOverride({ hostname: "localhost", search })).toBe("ws://127.0.0.1:4010/");
expect(resolveLocalP2PCheckRelayOverride({ hostname: "example.com", search })).toBeUndefined();
expect(
resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search: "?relay=https%3A%2F%2Fexample.com" })
).toBeUndefined();
});
});
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import {
EMPTY_P2P_CHECK_DIAGNOSTICS,
P2P_CHECK_OBSERVATION_MILLISECONDS,
captureP2PAdditionalCheckBaseline,
deriveP2PAdditionalCheckProgress,
deriveP2PCheckOutcome,
} from "@/apps/webpeer/src/P2PCheckState";
describe("P2P connection-check outcome", () => {
it("waits for a target after the browser monitor joins the room", () => {
expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, true, 5_000)).toBe("waiting");
});
it("reports an active negotiation before any connection succeeds", () => {
expect(
deriveP2PCheckOutcome(
{
...EMPTY_P2P_CHECK_DIAGNOSTICS,
totalNewConnections: 1,
details: {
"rtc-1": {
connectionState: "connecting",
iceConnectionState: "checking",
},
},
},
true,
10_000
)
).toBe("connecting");
});
it("keeps a failed attempt retryable until the observation period expires", () => {
const diagnostics = {
...EMPTY_P2P_CHECK_DIAGNOSTICS,
totalNewConnections: 1,
totalFailedConnections: 1,
};
expect(deriveP2PCheckOutcome(diagnostics, true, 15_000)).toBe("retrying");
expect(deriveP2PCheckOutcome(diagnostics, true, P2P_CHECK_OBSERVATION_MILLISECONDS)).toBe("inconclusive");
});
it("lets a later success take precedence over failures and closure", () => {
expect(
deriveP2PCheckOutcome(
{
...EMPTY_P2P_CHECK_DIAGNOSTICS,
totalNewConnections: 2,
totalFailedConnections: 1,
totalSuccessfulConnections: 1,
totalClosedConnections: 1,
},
true,
P2P_CHECK_OBSERVATION_MILLISECONDS * 2
)
).toBe("connected");
});
it("distinguishes an idle page and a monitor-start error", () => {
expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0)).toBe("idle");
expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0, true)).toBe("error");
});
});
describe("same-room additional-device progress", () => {
const firstDeviceDiagnostics = {
...EMPTY_P2P_CHECK_DIAGNOSTICS,
totalNewConnections: 1,
totalSuccessfulConnections: 1,
details: {
"rtc-a": {
connectionState: "connected" as const,
iceConnectionState: "connected" as const,
},
},
};
it("captures the counter and currently active connection baseline", () => {
expect(captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics)).toEqual({
activeConnectionIds: ["rtc-a"],
totalClosedConnections: 0,
totalFailedConnections: 0,
totalNewConnections: 1,
totalSuccessfulConnections: 1,
});
});
it("does not mistake a reconnect from the first peer for an additional device", () => {
const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics);
const progress = deriveP2PAdditionalCheckProgress(
{
...firstDeviceDiagnostics,
totalNewConnections: 2,
totalSuccessfulConnections: 2,
details: {
"rtc-a": {
connectionState: "closed",
iceConnectionState: "closed",
},
"rtc-b": {
connectionState: "connected",
iceConnectionState: "connected",
},
},
},
baseline,
10_000
);
expect(progress).toEqual({
activeConnections: 0,
closedConnections: 0,
failedConnections: 0,
newConnections: 1,
newActiveConnectionIds: ["rtc-b"],
outcome: "negotiating",
successfulConnections: 1,
});
});
it("requires both a new successful state and another simultaneous active connection", () => {
const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics);
expect(
deriveP2PAdditionalCheckProgress(
{
...firstDeviceDiagnostics,
details: {
...firstDeviceDiagnostics.details,
"rtc-b": {
connectionState: "connected",
iceConnectionState: "connected",
},
},
},
baseline,
10_000
).outcome
).toBe("negotiating");
expect(
deriveP2PAdditionalCheckProgress(
{
...firstDeviceDiagnostics,
totalNewConnections: 2,
totalSuccessfulConnections: 2,
details: {
...firstDeviceDiagnostics.details,
"rtc-b": {
connectionState: "connected",
iceConnectionState: "connected",
},
},
},
baseline,
10_000
)
).toEqual({
activeConnections: 1,
closedConnections: 0,
failedConnections: 0,
newConnections: 1,
newActiveConnectionIds: ["rtc-b"],
outcome: "connected",
successfulConnections: 1,
});
});
it("marks an additional-device attempt inconclusive after its own observation period", () => {
const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics);
expect(
deriveP2PAdditionalCheckProgress(firstDeviceDiagnostics, baseline, P2P_CHECK_OBSERVATION_MILLISECONDS)
.outcome
).toBe("inconclusive");
});
});
+25 -1
View File
@@ -1,5 +1,5 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { describe, expect, it, vi } from "vitest";
@@ -73,4 +73,28 @@ describe("WebPeer runtime composition", () => {
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
});
it("isolates a specialised runtime and applies its device name before opening the database", async () => {
const runtime = new WebPeerRuntime({
store: createMemoryStore(),
deviceName: " p2p-check-browser-desktop-abc ",
systemVaultName: "p2p-check-vault",
});
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({
...DEFAULT_SETTINGS,
P2P_AutoStart: false,
P2P_Enabled: false,
});
const setSmallConfig = vi.spyOn(runtime.services.config, "setSmallConfig").mockImplementation(() => undefined);
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => {
expect(setSmallConfig).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
return true;
});
await runtime.start();
expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault");
expect(openDatabase).toHaveBeenCalledOnce();
});
});
+12 -9
View File
@@ -392,15 +392,18 @@ Deno.test({
screenshotDirectory,
"webpeer-initial.png",
);
await configureP2PPane(webPeerPage.locator(".control"), {
deviceName: webPeerDeviceName,
passphrase: p2pPassphrase,
relay,
room,
turnCredential,
turnServers,
turnUsername,
});
await configureP2PPane(
webPeerPage.getByRole("region", { name: "WebPeer controls" }),
{
deviceName: webPeerDeviceName,
passphrase: p2pPassphrase,
relay,
room,
turnCredential,
turnServers,
turnUsername,
},
);
await webPeerPage.getByRole("button", { name: "Connect", exact: true })
.click();
await webPeerPage.getByText("Connected to Signaling Server", {
@@ -21,6 +21,12 @@ Deno.test({
timeout: 30_000,
});
await page.getByText("No Connection", { exact: true }).waitFor();
assertEquals(
await page
.getByRole("link", { name: "Try the P2P connection check", exact: true })
.getAttribute("href"),
"./check.html"
);
await page.getByPlaceholder("anything-you-like").fill("browser-e2e-room");
await page.getByPlaceholder("iphone-16").fill("browser-e2e-peer");
@@ -44,10 +50,7 @@ Deno.test({
await page.getByText("Optional TURN server settings", { exact: true }).click();
assertEquals(await page.getByPlaceholder("turn:turn.example.com:3478").inputValue(), "turn:127.0.0.1:3478");
assertEquals(await page.getByPlaceholder("Enter TURN username").inputValue(), "browser-turn-user");
assertEquals(
await page.getByPlaceholder("Enter TURN credential").inputValue(),
"browser-turn-credential"
);
assertEquals(await page.getByPlaceholder("Enter TURN credential").inputValue(), "browser-turn-credential");
assertEquals(await page.getByRole("button", { name: "Connect", exact: true }).isVisible(), true);
assertNoPageFailures();
} finally {
@@ -56,3 +59,82 @@ Deno.test({
}
},
});
Deno.test({
name: "WebPeer: P2P connection check prepares a local Setup URI and zeroed diagnostics",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(webPeerDist);
const browser = await chromium.launch({ headless: true });
try {
for (const target of ["desktop", "mobile"] as const) {
const page = await browser.newPage();
const assertNoPageFailures = observePageFailures(page);
try {
await page.goto(`${server.baseUrl}check.html`);
await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({
timeout: 30_000,
});
if (target === "mobile") {
await page.locator('input[type="radio"][value="mobile"]').check();
}
await page.getByRole("button", { name: `Prepare ${target} check`, exact: true }).click();
await page.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true }).waitFor({
timeout: 30_000,
});
const setupURI = await page.getByLabel("Setup URI", { exact: true }).inputValue();
const passphrase = await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue();
const qrSource = await page
.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true })
.getAttribute("src");
assertEquals(setupURI.startsWith("obsidian://setuplivesync?settings="), true);
assertEquals(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/.test(passphrase), true);
assertEquals(qrSource?.startsWith("data:image/"), true);
for (const label of ["Setup URI", "Setup URI passphrase"] as const) {
const credentialField = page.getByLabel(label, { exact: true });
assertEquals(await credentialField.getAttribute("autocomplete"), "off");
assertEquals(await credentialField.getAttribute("spellcheck"), "false");
}
assertEquals(await page.getByTestId("diag-new").textContent(), "0");
assertEquals(await page.getByTestId("diag-successful").textContent(), "0");
assertEquals(await page.getByTestId("diag-failed").textContent(), "0");
assertEquals(await page.getByTestId("diag-closed").textContent(), "0");
assertEquals(
await page.getByRole("button", { name: "Start connection monitor", exact: true }).isVisible(),
true
);
assertEquals(
await page
.getByRole("button", { name: "Try another device without resetting", exact: true })
.count(),
0
);
await page.locator(".results-card").scrollIntoViewIfNeeded();
await page.getByRole("button", { name: "Show the Setup QR again", exact: true }).click();
await waitFor(
async () =>
await page
.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true })
.evaluate((element) => {
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= document.documentElement.clientHeight;
}),
`The ${target} Setup QR did not return to the viewport`
);
assertEquals(await page.getByLabel("Setup URI", { exact: true }).inputValue(), setupURI);
assertNoPageFailures();
} finally {
await page.close();
}
}
} finally {
await browser.close();
await server.close();
}
},
});
+17 -3
View File
@@ -76,9 +76,11 @@ After changing plug-in source, use the focused wrapper rather than invoking a sc
npm run test:e2e:obsidian:focused -- settings-ui
npm run test:e2e:obsidian:focused -- two-vault-sync
npm run test:e2e:obsidian:focused -- security-seed-reconnect
npm run test:e2e:obsidian:focused -- adaptive-webdav --manage-webdav
npm run test:e2e:obsidian:focused -- adaptive-postgrest --manage-postgrest
```
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, PostgREST, WebDAV, or the P2P signalling relay unless a focused scenario exposes its own explicit service-management argument. Start the required fixture first, pass the provider's service-management argument to the Adaptive WebDAV or PostgREST scenario, or use the complete service-managed suite.
The principal entry points are:
@@ -114,7 +116,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. 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, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, 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, revision repair, settings UI, the remote-setup provider flow, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload, the Adaptive WebDAV and PostgREST workflows, Object Storage and P2P Setup URI round trips, 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, Apache WebDAV, PostgreSQL/PostgREST, 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:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
@@ -147,10 +149,16 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
`test:e2e:obsidian:adaptive-webdav` uses visible manual onboarding to configure a unique collection on a reachable WebDAV endpoint. It requires the endpoint safety check before onboarding can continue, retains the WebDAV profile across a real Obsidian restart, generates a Setup URI on that working device, and completes Fast Fetch and a text and binary return journey through a second device. This representative object-store journey exercises Obsidian's internal request bridge and verifies user-observable Vault reflection. Commonlib integration tests own the Adaptive remote layout, capability semantics, and failure matrix. Run `test:e2e:obsidian:adaptive-webdav:services`, or pass `--manage-webdav` through the focused wrapper, to start and stop the shared local Apache fixture around this scenario.
`test:e2e:obsidian:adaptive-postgrest` applies the two-device real-Obsidian workflow to the Adaptive-only PostgREST provider. Visible manual onboarding must withhold continuation until the packaged RPC and binary-semantics check succeeds, and it must not expose Opaque or Pack retrieval choices. The first device retains its profile across a real Obsidian restart, generates a Setup URI for the second device, and completes Fast Fetch and a text and binary return journey. This representative native-store journey verifies Host composition and user-observable Vault reflection. Commonlib integration tests own the RPC failure matrix, SQL transaction semantics, repository binding, and remote layout. Rebuild resets all Adaptive data for the supplied Vault ID, so external settings must identify a disposable test Vault. Run `test:e2e:obsidian:adaptive-postgrest:services`, or pass `--manage-postgrest` through the focused wrapper, to start and stop the shared PostgreSQL and PostgREST fixture around this scenario.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, 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 Compose Nostr relay fixture. The first device imports a generated initial Setup 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 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-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay.
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
@@ -196,11 +204,13 @@ Start the local fixtures first when they are not already running:
```bash
npm run test:docker-couchdb:start
npm run test:docker-s3:start
npm run test:docker-webdav:start
npm run test:docker-postgrest:start
npm run test:docker-p2p:start
npm run test:e2e:obsidian:local-suite
```
Or let the wrapper manage both fixtures:
Or let the wrapper manage the required fixtures:
```bash
npm run test:e2e:obsidian:local-suite:services
@@ -223,6 +233,8 @@ Useful environment variables:
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
- `E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS`: timeout for the browser-to-Obsidian successful WebRTC diagnostic; default is 60 seconds.
- `E2E_P2P_CHECK_SCREENSHOT`: explicit path for the successful browser result screenshot; default is `p2p-connection-check-browser-success.png` under `E2E_OBSIDIAN_DIAGNOSTICS_DIR`.
- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`.
- `E2E_P2P_RELAY_PORT`: host port for the local P2P relay fixture; default is `4010`.
- `E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT`: CDP port for the second concurrent real Obsidian session; default is one greater than the primary port.
@@ -242,8 +254,10 @@ Useful environment variables:
- `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds.
- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots, including the Security Seed reconnect stages; default is `/tmp/obsidian-livesync-e2e`.
- `E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS`: timeout for waiting until Object Storage contains uploaded E2E objects.
- `POSTGREST_ENDPOINT`, `POSTGREST_SCHEMA`, `POSTGREST_VAULT_ID`, `POSTGREST_VAULT_CREDENTIAL`, and `POSTGREST_API_KEY`: external PostgREST fixture settings. The API key must be publishable or otherwise client-safe. The managed fixture supplies disposable defaults.
- `E2E_OBSIDIAN_KEEP_COUCHDB=true`: keep the temporary CouchDB database for inspection.
- `E2E_OBSIDIAN_KEEP_OBJECT_STORAGE=true`: keep the temporary Object Storage prefix for inspection.
- `E2E_OBSIDIAN_KEEP_WEBDAV=true`: keep the temporary WebDAV collection for inspection while the endpoint remains available.
- `E2E_OBSIDIAN_STARTUP_GRACE_MS`: early process-exit detection window in milliseconds.
- `E2E_OBSIDIAN_KEEP_VAULT=true`: keep the temporary vault for inspection.
- `E2E_OBSIDIAN_USE_XVFB=false`: disable automatic `xvfb-run` on headless Linux.
+384
View File
@@ -0,0 +1,384 @@
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { evalObsidianJson } from "./cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "./environment.ts";
import {
assertEqual,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "./liveSyncWorkflow.ts";
import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "./remoteActivity.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "./session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
modalByTitle,
readSetupState,
resumeCompatibilityReviewIfShown,
skipMissingRemoteConfiguration,
type SetupArtifact,
type SetupCaptureNames,
type SetupState,
} from "./setupUri.ts";
import { captureObsidianPage, withObsidianPage } from "./ui.ts";
import { createTemporaryVault, type TemporaryVault } from "./vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ??= "180000";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
const binaryLength = 256 * 1024;
const firstBinarySeed = 0x1a2b3c4d;
const secondBinarySeed = 0x5e6f7788;
type RunnerContext = {
binary: string;
cliBinary: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
export type AdaptiveJournalObsidianScenario = {
/** Human-readable provider name, for example, 'Adaptive WebDAV'. */
label: string;
/** Stable lowercase name used for paths, screenshots, and setup captures. */
slug: string;
targetDescription: string;
enterManualSettings(port: number, vaultPassphrase: string): Promise<string[]>;
assertSettings(state: SetupState, label: string): void;
};
function assertRepositoryIdentity(state: SetupState, label: string, expectedRepositoryId?: string): string {
if (!/^[A-Za-z0-9_-]{43}$/u.test(state.expectedRepositoryId)) {
throw new Error(`${label} did not retain a canonical repository ID.`);
}
if (expectedRepositoryId !== undefined) {
assertEqual(state.expectedRepositoryId, expectedRepositoryId, `${label} retained a different repository ID.`);
}
return state.expectedRepositoryId;
}
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
export function runNpmScript(script: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(npmBinary(), ["run", script], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}.`));
});
});
}
async function dismissRedundantExternalOpenPrompt(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const prompt = modalByTitle(page, "Run action from external link?");
if (!(await prompt.isVisible({ timeout: 2000 }).catch(() => false))) return;
await prompt.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await prompt.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function startSession(context: RunnerContext, vault: TemporaryVault): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
context.activeSessions.add(session);
// Obsidian 1.12 can ask whether to repeat the CLI's 'open' action when the
// isolated profile has already restored this exact Vault. The session has
// already verified the active Vault, so dismiss only that redundant host
// prompt before exercising LiveSync UI.
await dismissRedundantExternalOpenPrompt(session.remoteDebuggingPort);
return session;
}
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
if (!context.activeSessions.has(session)) return;
await session.app.stop();
context.activeSessions.delete(session);
}
async function stopSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) await stopSession(context, session);
}
async function captureFailure(
session: ObsidianLiveSyncSession,
scenario: AdaptiveJournalObsidianScenario,
label: string
): Promise<void> {
const screenshot = await captureObsidianPage(
session.remoteDebuggingPort,
`${scenario.slug}-${label}-failure.png`,
async () => undefined
).catch(() => undefined);
if (screenshot) console.error(`${scenario.label} failure screenshot: ${screenshot}`);
}
function deterministicBytes(length: number, seed: number): Uint8Array {
const bytes = new Uint8Array(length);
let state = seed;
for (let index = 0; index < bytes.byteLength; index += 1) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
bytes[index] = state & 0xff;
}
return bytes;
}
async function writePayloadViaObsidian(
cliBinary: string,
environment: NodeJS.ProcessEnv,
textPath: string,
binaryPath: string,
text: string,
binarySeed: number
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const textPath=${JSON.stringify(textPath)};`,
`const binaryPath=${JSON.stringify(binaryPath)};`,
`const text=${JSON.stringify(text)};`,
`const binaryLength=${JSON.stringify(binaryLength)};`,
`let state=${JSON.stringify(binarySeed)};`,
"let folder='';",
"for(const part of textPath.split('/').slice(0,-1)){",
"folder=folder?`${folder}/${part}`:part;",
"if(!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
"}",
"const existingText=app.vault.getAbstractFileByPath(textPath);",
"if(existingText) await app.vault.modify(existingText,text);",
"else await app.vault.create(textPath,text);",
"const bytes=new Uint8Array(binaryLength);",
"for(let i=0;i<bytes.byteLength;i++){",
"state^=state<<13;state^=state>>>17;state^=state<<5;bytes[i]=state&0xff;",
"}",
"const existingBinary=app.vault.getAbstractFileByPath(binaryPath);",
"if(existingBinary) await app.vault.modifyBinary(existingBinary,bytes.buffer);",
"else await app.vault.createBinary(binaryPath,bytes.buffer);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
await waitForLocalDatabaseEntry(cliBinary, environment, textPath);
await waitForLocalDatabaseEntry(cliBinary, environment, binaryPath);
}
async function waitForText(vault: TemporaryVault, textPath: string, expected: string): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000);
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(join(vault.path, textPath), "utf8");
if (lastContent === expected) return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${textPath}. Last content:\n${lastContent}`);
}
async function waitForBinary(vault: TemporaryVault, binaryPath: string, expected: Uint8Array): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000);
let lastLength = -1;
while (Date.now() < deadline) {
try {
const actual = await readFile(join(vault.path, binaryPath));
lastLength = actual.byteLength;
if (actual.byteLength === expected.byteLength && actual.equals(Buffer.from(expected))) return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${binaryPath}; last length was ${lastLength}.`);
}
async function pushAndObserve(
scenario: AdaptiveJournalObsidianScenario,
session: ObsidianLiveSyncSession,
cliBinary: string
): Promise<number> {
const before = await waitForRemoteActivityState(session.remoteDebuggingPort, REMOTE_ACTIVITY_EXPECTED_STATE.idle);
await pushLocalChanges(cliBinary, session.cliEnv);
const after = await waitForRemoteActivityState(session.remoteDebuggingPort, REMOTE_ACTIVITY_EXPECTED_STATE.idle);
if (after.requestCount <= before.requestCount) {
throw new Error(`${scenario.label} synchronisation did not advance the tracked remote-request count.`);
}
assertEqual(
after.responseCount,
after.requestCount,
`${scenario.label} remote-request counters did not rebalance after synchronisation.`
);
return after.requestCount - before.requestCount;
}
export async function runAdaptiveJournalObsidianRoundTrip(scenario: AdaptiveJournalObsidianScenario): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const vaultPassphrase = randomBytes(24).toString("base64url");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
const captures: SetupCaptureNames = { scenario: scenario.slug, guide: scenario.slug };
const secondDeviceCaptures: SetupCaptureNames = {
scenario: `${scenario.slug}-second-device`,
guide: `${scenario.slug}-second-device`,
};
const textPath = `E2E/${scenario.slug}/round-trip.md`;
const binaryPath = `E2E/${scenario.slug}/round-trip.bin`;
const firstText = `# ${scenario.label}\n\nCreated by the first real Obsidian device.\n`;
const secondText = `# ${scenario.label}\n\nUpdated by the second real Obsidian device.\n`;
const screenshots: string[] = [];
let generatedSetup: SetupArtifact | undefined;
let observedRequests = 0;
let repositoryId = "";
try {
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary Vault A: ${vaultA.path}`);
console.log(`Temporary Vault B: ${vaultB.path}`);
console.log(scenario.targetDescription);
let session = await startSession(context, vaultA);
try {
screenshots.push(...(await scenario.enterManualSettings(session.remoteDebuggingPort, vaultPassphrase)));
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures));
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures));
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures));
const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
scenario.assertSettings(state, "The first device");
repositoryId = assertRepositoryIdentity(state, "The first device");
await writePayloadViaObsidian(
context.cliBinary,
session.cliEnv,
textPath,
binaryPath,
firstText,
firstBinarySeed
);
observedRequests += await pushAndObserve(scenario, session, context.cliBinary);
} catch (error) {
await captureFailure(session, scenario, "first-device");
throw error;
} finally {
await stopSession(context, session);
}
session = await startSession(context, vaultA);
try {
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
const state = await readSetupState(context.cliBinary, session.cliEnv);
scenario.assertSettings(state, "The restarted first device");
assertRepositoryIdentity(state, "The restarted first device", repositoryId);
const generated = await generateSetupURIFromDevice(
session.remoteDebuggingPort,
randomBytes(24).toString("base64url"),
captures
);
generatedSetup = generated.artifact;
screenshots.push(...generated.screenshots);
} catch (error) {
await captureFailure(session, scenario, "first-device-restart");
throw error;
} finally {
await stopSession(context, session);
}
session = await startSession(context, vaultB);
try {
if (!generatedSetup) throw new Error("The first device did not generate a Setup URI.");
screenshots.push(
await enterSetupURI(session.remoteDebuggingPort, "existing", generatedSetup, secondDeviceCaptures)
);
screenshots.push(
await captureAndStartInitialisation(session.remoteDebuggingPort, "existing", secondDeviceCaptures)
);
screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort, secondDeviceCaptures)));
// Journal remotes do not expose the legacy remote-configuration
// document. The device-generated Setup URI is the authoritative
// connection input, so acknowledge its expected absence explicitly.
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort, secondDeviceCaptures));
const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
scenario.assertSettings(state, "The second device");
assertRepositoryIdentity(state, "The second device", repositoryId);
observedRequests += await pushAndObserve(scenario, session, context.cliBinary);
await waitForText(vaultB, textPath, firstText);
await waitForBinary(vaultB, binaryPath, deterministicBytes(binaryLength, firstBinarySeed));
await writePayloadViaObsidian(
context.cliBinary,
session.cliEnv,
textPath,
binaryPath,
secondText,
secondBinarySeed
);
observedRequests += await pushAndObserve(scenario, session, context.cliBinary);
} catch (error) {
await captureFailure(session, scenario, "second-device");
throw error;
} finally {
await stopSession(context, session);
}
session = await startSession(context, vaultA);
try {
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
const state = await readSetupState(context.cliBinary, session.cliEnv);
scenario.assertSettings(state, "The final first-device session");
assertRepositoryIdentity(state, "The final first-device session", repositoryId);
observedRequests += await pushAndObserve(scenario, session, context.cliBinary);
await waitForText(vaultA, textPath, secondText);
await waitForBinary(vaultA, binaryPath, deterministicBytes(binaryLength, secondBinarySeed));
} catch (error) {
await captureFailure(session, scenario, "return-journey");
throw error;
} finally {
await stopSession(context, session);
}
console.log(
`${scenario.label} passed visible safety-gated onboarding, restart persistence, a device-generated Setup URI, and a two-device text and binary return journey. Tracked requests across measured synchronisations: ${observedRequests}. Screenshots: ${screenshots.join(", ")}`
);
} finally {
await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await vaultA.dispose();
await vaultB.dispose();
}
}
+330
View File
@@ -0,0 +1,330 @@
import type { Locator, Page } from "playwright";
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
const remoteSetupStateKey = "__livesyncE2ERemoteSetup";
export type RemoteInspectionMode = "failed" | "verified";
export type RemoteSetupCall = {
journalFormat: string;
operation: "create" | "inspect" | "test";
remoteType: string;
};
type RemoteSetupBrowserState = {
calls: RemoteSetupCall[];
inspectionMode: RemoteInspectionMode;
nextProfileName: string;
unregister?: () => void;
};
type RuntimeRemoteConfiguration = {
id: string;
isEncrypted: boolean;
name: string;
uri: string;
};
type RuntimeSettings = {
activeConfigurationId: string;
remoteConfigurations: Record<string, RuntimeRemoteConfiguration>;
};
type RuntimePlugin = {
core: {
services: {
replicator: {
getNewReplicator: {
addHandler: (...args: unknown[]) => () => void;
} & ((settingOverride?: Record<string, unknown>) => Promise<unknown>);
};
setting: {
currentSettings(): RuntimeSettings;
};
UI: {
confirm: {
askString(
title: string,
label: string,
placeholder: string,
isPassword?: boolean
): Promise<string | false>;
};
};
};
};
};
type RuntimeSettingsController = {
close(): void;
open(): void;
openTabById(tabId: string): void;
};
type RuntimeApp = {
plugins?: { plugins: Record<string, RuntimePlugin | undefined> };
setting?: RuntimeSettingsController;
};
type RuntimeGlobal = typeof globalThis & {
app?: RuntimeApp;
[remoteSetupStateKey]?: RemoteSetupBrowserState;
};
export function remoteSelectionModal(page: Page): Locator {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
});
}
export function remoteProviderModal(page: Page, title: string): Locator {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: title }),
});
}
export function remoteConfigurationPanel(page: Page): Locator {
return page
.locator(".sls-setting h4.sls-setting-panel-title")
.filter({ hasText: "Connection settings" })
.locator("..");
}
function remoteProfileRow(page: Page, profileName: string): Locator {
return remoteConfigurationPanel(page).locator(".sls-remote-list .setting-item").filter({ hasText: profileName });
}
async function tooltipButton(container: Locator, label: string, fallbackText: string): Promise<Locator> {
const labelled = container.locator(`button[aria-label="${label}"], button[title="${label}"]`);
if ((await labelled.count()) > 0) return labelled.first();
return container.locator("button").filter({ hasText: fallbackText }).first();
}
export async function installRemoteSetupTestSeam(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const runtime = globalThis as RuntimeGlobal;
const plugin = runtime.app?.plugins?.plugins["obsidian-livesync"];
if (!plugin) throw new Error("Self-hosted LiveSync is not loaded");
const state: RemoteSetupBrowserState = {
calls: [],
inspectionMode: "verified",
nextProfileName: "",
};
runtime[stateKey as typeof remoteSetupStateKey] = state;
const getNewReplicator = plugin.core.services.replicator.getNewReplicator;
const replacements = {
async createReplicator(settingOverride: Record<string, unknown> = {}) {
const remoteType = String(settingOverride.remoteType ?? "");
const journalFormat = String(settingOverride.journalFormat ?? "");
state.calls.push({ journalFormat, operation: "create", remoteType });
return {
async inspectJournalStorageConnection(settings: Record<string, unknown>) {
state.calls.push({
journalFormat: String(settings.journalFormat ?? ""),
operation: "inspect",
remoteType: String(settings.remoteType ?? ""),
});
if (state.inspectionMode === "failed") {
return {
adaptiveCapabilities: {
byteRange: { status: "not-checked" },
required: { missing: ["conditional-create"], status: "unsupported" },
},
available: false,
remoteFormat: "empty",
};
}
return {
adaptiveCapabilities: {
byteRange: { status: "verified" },
required: { status: "verified" },
},
available: true,
remoteFormat: "empty",
};
},
async tryConnectRemote(settings: Record<string, unknown>) {
state.calls.push({
journalFormat: String(settings.journalFormat ?? ""),
operation: "test",
remoteType: String(settings.remoteType ?? ""),
});
return state.inspectionMode === "verified";
},
};
},
async askString(title: string, label: string, placeholder: string, isPassword: boolean = false) {
if (title !== "Remote name") return await originalAskString(title, label, placeholder, isPassword);
const name = state.nextProfileName;
state.nextProfileName = "";
if (!name) throw new Error("The remote setup E2E did not supply a profile name");
return name;
},
};
state.unregister = getNewReplicator.addHandler(replacements.createReplicator, -1000, true);
const confirm = plugin.core.services.UI.confirm;
const originalAskString = confirm.askString.bind(confirm);
confirm.askString = replacements.askString;
}, remoteSetupStateKey);
});
}
export async function setRemoteInspectionMode(port: number, mode: RemoteInspectionMode): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
({ mode, stateKey }) => {
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
if (!state) throw new Error("The remote setup E2E seam is not installed");
state.inspectionMode = mode;
},
{ mode, stateKey: remoteSetupStateKey }
);
});
}
export async function remoteSetupCalls(port: number): Promise<RemoteSetupCall[]> {
return await withObsidianPage(port, async (page) => {
return await page.evaluate((stateKey) => {
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
if (!state) throw new Error("The remote setup E2E seam is not installed");
return state.calls;
}, remoteSetupStateKey);
});
}
export async function openRemoteConfigurationSettings(port: number, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const setting = (globalThis as RuntimeGlobal).app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.close();
});
await page.waitForTimeout(100);
await page.evaluate(() => {
const setting = (globalThis as RuntimeGlobal).app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const settings = page.locator(".sls-setting");
try {
await settings.waitFor({ state: "visible", timeout: timeoutMs });
} catch (error) {
const modalTitles = await page.locator(".modal-title").allTextContents();
const settingTabs = await page.locator(".vertical-tab-nav-item").allTextContents();
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`The LiveSync settings pane did not become visible. Open modal titles: ${JSON.stringify(modalTitles)}. Settings tabs: ${JSON.stringify(settingTabs)}. Cause: ${reason}`
);
}
await settings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click({ timeout: timeoutMs });
await remoteConfigurationPanel(page).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function closeRemoteConfigurationSettings(port: number, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const setting = (globalThis as RuntimeGlobal).app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.close();
});
await page.locator(".sls-setting").waitFor({ state: "hidden", timeout: timeoutMs });
});
}
export async function beginRemoteProfileSetup(port: number, profileName: string, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
({ profileName, stateKey }) => {
const state = (globalThis as RuntimeGlobal)[stateKey as typeof remoteSetupStateKey];
if (!state) throw new Error("The remote setup E2E seam is not installed");
state.nextProfileName = profileName;
},
{ profileName, stateKey: remoteSetupStateKey }
);
const add = await tooltipButton(remoteConfigurationPanel(page), "Add new connection", "");
await add.click({ timeout: timeoutMs });
await remoteSelectionModal(page).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function captureRemoteProviderChoices(
port: number,
filename: string,
labels: readonly string[],
timeoutMs: number
): Promise<string> {
return await captureObsidianDialogue(port, filename, async (page) => {
const modal = remoteSelectionModal(page);
await modal.waitFor({ state: "visible", timeout: timeoutMs });
for (const label of labels) {
await modal.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: timeoutMs });
}
});
}
export async function selectRemoteProvider(
port: number,
choiceLabel: string,
proceedLabel: string,
providerTitle: string,
timeoutMs: number
): Promise<void> {
await withObsidianPage(port, async (page) => {
const selection = remoteSelectionModal(page);
await selection
.locator("label")
.filter({ hasText: choiceLabel })
.locator('input[type="radio"]')
.first()
.check({ timeout: timeoutMs });
await selection.getByRole("button", { name: proceedLabel, exact: true }).click({ timeout: timeoutMs });
await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function captureAndCancelRemoteProvider(
port: number,
filename: string,
providerTitle: string,
timeoutMs: number
): Promise<string> {
const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
await remoteProviderModal(page, providerTitle).waitFor({ state: "visible", timeout: timeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, providerTitle);
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: timeoutMs });
await modal.waitFor({ state: "hidden", timeout: timeoutMs });
});
return screenshot;
}
export async function waitForSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await remoteProfileRow(page, profileName).waitFor({ state: "visible", timeout: timeoutMs });
});
}
export async function openSavedRemoteProfile(port: number, profileName: string, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const row = remoteProfileRow(page, profileName);
await row.waitFor({ state: "visible", timeout: timeoutMs });
const configure = await tooltipButton(row, "Configure", "🔧");
await configure.click({ timeout: timeoutMs });
});
}
export async function runtimeRemoteSettings(port: number): Promise<RuntimeSettings> {
return await withObsidianPage(port, async (page) => {
return await page.evaluate(() => {
const plugin = (globalThis as RuntimeGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (!plugin) throw new Error("Self-hosted LiveSync is not loaded");
return structuredClone(plugin.core.services.setting.currentSettings());
});
});
}
+16 -6
View File
@@ -18,9 +18,14 @@ export type SetupState = {
endpoint: string;
bucket: string;
bucketPrefix: string;
expectedRepositoryId: string;
journalFormat: string;
packReadPolicy: string;
p2pEnabled: boolean;
p2pRelays: string;
p2pRoomId: string;
postgrestActiveConnectionURI: string;
webDAVactiveConnectionURI: string;
};
export type SetupCaptureNames = {
@@ -182,16 +187,16 @@ export async function captureAndStartInitialisation(
? "Setup Complete: Preparing This P2P Device"
: p2pAdditionalDevice
? "Setup Complete: Preparing to Fetch from Another Device"
: mode === "new"
? "Setup Complete: Preparing to Initialise Server"
: "Setup Complete: Preparing to Fetch Synchronisation Data";
: mode === "new"
? "Setup Complete: Preparing to Initialise Server"
: "Setup Complete: Preparing to Fetch Synchronisation Data";
const button = p2pFirstDevice
? "Restart and Prepare This Device"
: p2pAdditionalDevice
? "Restart and Select Source Device"
: mode === "new"
? "Restart and Initialise Server"
: "Restart and Fetch Data";
: mode === "new"
? "Restart and Initialise Server"
: "Restart and Fetch Data";
if (p2pAdditionalDevice) {
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
@@ -342,9 +347,14 @@ export async function readSetupState(cliBinary: string, environment: NodeJS.Proc
"endpoint:settings.endpoint||'',",
"bucket:settings.bucket||'',",
"bucketPrefix:settings.bucketPrefix||'',",
"expectedRepositoryId:settings.expectedRepositoryId||'',",
"journalFormat:settings.journalFormat||'',",
"packReadPolicy:settings.packReadPolicy||'',",
"p2pEnabled:settings.P2P_Enabled===true,",
"p2pRelays:settings.P2P_relays||'',",
"p2pRoomId:settings.P2P_roomID||'',",
"postgrestActiveConnectionURI:settings.postgrestActiveConnectionURI||'',",
"webDAVactiveConnectionURI:settings.webDAVactiveConnectionURI||'',",
"});",
"})()",
].join(""),
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { parseWebDAVObjectKeys, webDAVCollectionUrl } from "./webDAV.ts";
describe("WebDAV E2E helpers", () => {
it("builds an encoded collection URL below the configured endpoint", () => {
expect(
webDAVCollectionUrl({ endpoint: "http://127.0.0.1:8088/dav/" }, "Adaptive Journal/run one/").toString()
).toBe("http://127.0.0.1:8088/dav/Adaptive%20Journal/run%20one/");
});
it("extracts only flat object keys below the exact collection", () => {
const collection = new URL("http://127.0.0.1:8088/dav/run/");
const xml = `<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response><d:href>/dav/run/</d:href></d:response>
<d:response><d:href>/dav/run/a1~manifest.json</d:href></d:response>
<d:response><d:href>/dav/run/a1~commit~writer~1.bin</d:href></d:response>
<d:response><d:href>/dav/run/nested/ignored.bin</d:href></d:response>
<d:response><d:href>/dav/sibling/ignored.bin</d:href></d:response>
</d:multistatus>`;
expect(parseWebDAVObjectKeys(xml, collection)).toEqual(["a1~commit~writer~1.bin", "a1~manifest.json"]);
});
});
+156
View File
@@ -0,0 +1,156 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
export type WebDAVConfig = {
endpoint: string;
username: string;
password: string;
};
function parseEnvFile(content: string): Record<string, string> {
const entries = content
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map((line) => {
const equalsAt = line.indexOf("=");
if (equalsAt < 0) return undefined;
const key = line.slice(0, equalsAt).trim();
const rawValue = line.slice(equalsAt + 1).trim();
return [key, rawValue.replace(/^['"]|['"]$/gu, "")] as const;
})
.filter((entry): entry is readonly [string, string] => entry !== undefined);
return Object.fromEntries(entries);
}
function firstValue(values: Record<string, string | undefined>, keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = values[key]?.trim();
if (value) return value;
}
return undefined;
}
export async function loadWebDAVConfig(envFile = ".test.env"): Promise<WebDAVConfig> {
let fileValues: Record<string, string> = {};
try {
fileValues = parseEnvFile(await readFile(resolve(envFile), "utf8"));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const values = { ...fileValues, ...process.env };
return {
endpoint: (firstValue(values, ["WEBDAV_ENDPOINT", "webdavEndpoint"]) ?? "http://127.0.0.1:8088/dav").replace(
/\/+$/u,
""
),
username: firstValue(values, ["WEBDAV_USERNAME", "webdavUsername"]) ?? "",
password: firstValue(values, ["WEBDAV_PASSWORD", "webdavPassword"]) ?? "",
};
}
function normalisePrefix(prefix: string): string[] {
const parts = prefix
.trim()
.split("/")
.filter((part) => part.length > 0);
if (parts.some((part) => part === "." || part === "..")) {
throw new TypeError("WebDAV E2E prefix must not contain dot path segments.");
}
return parts;
}
export function webDAVCollectionUrl(config: Pick<WebDAVConfig, "endpoint">, prefix: string): URL {
const url = new URL(`${config.endpoint.replace(/\/+$/u, "")}/`);
if (url.search || url.hash) throw new TypeError("WebDAV E2E endpoint must not contain a query or fragment.");
const baseParts = url.pathname.split("/").filter((part) => part.length > 0);
const prefixParts = normalisePrefix(prefix);
url.pathname = `/${[...baseParts, ...prefixParts].map((part) => encodeURIComponent(decodeURIComponent(part))).join("/")}/`;
return url;
}
function requestHeaders(config: WebDAVConfig, additional: HeadersInit = {}): Headers {
const headers = new Headers(additional);
if (config.username || config.password) {
headers.set(
"Authorization",
`Basic ${Buffer.from(`${config.username}:${config.password}`, "utf8").toString("base64")}`
);
}
return headers;
}
export async function assertWebDAVReachable(config: WebDAVConfig): Promise<void> {
const response = await fetch(`${config.endpoint}/`, {
method: "PROPFIND",
headers: requestHeaders(config, { Depth: "0" }),
});
await response.body?.cancel().catch(() => undefined);
if (response.status !== 207) {
throw new Error(`WebDAV fixture is not reachable: HTTP ${response.status}.`);
}
}
function decodeXmlText(value: string): string {
return value
.replace(/&amp;/giu, "&")
.replace(/&lt;/giu, "<")
.replace(/&gt;/giu, ">")
.replace(/&quot;/giu, '"')
.replace(/&apos;/giu, "'");
}
export function parseWebDAVObjectKeys(xml: string, collectionUrl: URL): string[] {
const hrefs = [
...xml.matchAll(/<(?:[A-Za-z_][\w.-]*:)?href\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?href>/giu),
].map((match) => decodeXmlText(match[1].trim()));
const basePath = decodeURIComponent(collectionUrl.pathname);
const keys = new Set<string>();
for (const href of hrefs) {
const path = decodeURIComponent(new URL(href, collectionUrl).pathname);
if (!path.startsWith(basePath)) continue;
const key = path.slice(basePath.length).replace(/\/$/u, "");
if (key && !key.includes("/")) keys.add(key);
}
return [...keys].sort();
}
export async function listWebDAVObjectKeys(config: WebDAVConfig, prefix: string): Promise<string[]> {
const collectionUrl = webDAVCollectionUrl(config, prefix);
const response = await fetch(collectionUrl, {
method: "PROPFIND",
headers: requestHeaders(config, { Depth: "1" }),
});
if (response.status !== 207) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`Could not list WebDAV E2E objects: HTTP ${response.status}.`);
}
return parseWebDAVObjectKeys(await response.text(), collectionUrl);
}
export async function readWebDAVObjectText(config: WebDAVConfig, prefix: string, key: string): Promise<string> {
if (key.includes("/") || key === "." || key === "..") {
throw new TypeError("WebDAV E2E object keys must be flat names.");
}
const response = await fetch(new URL(encodeURIComponent(key), webDAVCollectionUrl(config, prefix)), {
headers: requestHeaders(config),
});
if (!response.ok) throw new Error(`Could not read WebDAV E2E object ${key}: HTTP ${response.status}.`);
return await response.text();
}
export async function deleteWebDAVPrefix(config: WebDAVConfig, prefix: string): Promise<void> {
const response = await fetch(webDAVCollectionUrl(config, prefix), {
method: "DELETE",
headers: requestHeaders(config),
});
await response.body?.cancel().catch(() => undefined);
if (!response.ok && response.status !== 404 && response.status !== 410) {
throw new Error(`Could not remove WebDAV E2E prefix: HTTP ${response.status}.`);
}
}
export function makeUniqueWebDAVPrefix(label: string): string {
const random = Math.random().toString(36).slice(2, 8);
return `${label}-${Date.now()}-${random}/`;
}
@@ -0,0 +1,262 @@
/**
* Exercises the Adaptive-only PostgREST Journal provider through visible,
* safety-gated onboarding. The shared Adaptive Journal runner owns restart
* persistence, device-generated Setup URI transfer, Fast Fetch, and a
* two-device text and binary return journey.
*/
import { parsePostgRESTConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import { runAdaptiveJournalObsidianRoundTrip, runNpmScript } from "../runner/adaptiveJournal.ts";
import { assertEqual } from "../runner/liveSyncWorkflow.ts";
import { captureGuideDialogue, modalByTitle, selectRadioOption, type SetupState } from "../runner/setupUri.ts";
import { withObsidianPage } from "../runner/ui.ts";
type PostgRESTConfig = {
apiKey: string;
endpoint: string;
schema: string;
vaultCredential: string;
vaultId: string;
};
const supportedArguments = new Set(["--keep-postgrest", "--manage-postgrest"]);
const unsupportedArguments = process.argv.slice(2).filter((argument) => !supportedArguments.has(argument));
if (unsupportedArguments.length > 0) {
throw new Error(`Unsupported Adaptive PostgREST argument: ${unsupportedArguments.join(", ")}`);
}
const managePostgREST = process.argv.includes("--manage-postgrest");
const keepPostgRESTFixture = process.argv.includes("--keep-postgrest");
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
function environmentValue(primary: string, legacy: string, fallback: string): string {
return process.env[primary] ?? process.env[legacy] ?? fallback;
}
function loadPostgRESTConfig(): PostgRESTConfig {
const endpoint = environmentValue("POSTGREST_ENDPOINT", "postgrestEndpoint", "http://127.0.0.1:3001").replace(
/\/+$/u,
""
);
const endpointUrl = new URL(endpoint);
if (
(endpointUrl.protocol !== "http:" && endpointUrl.protocol !== "https:") ||
endpointUrl.username !== "" ||
endpointUrl.password !== "" ||
endpointUrl.search !== "" ||
endpointUrl.hash !== ""
) {
throw new Error("Adaptive PostgREST requires a complete HTTP or HTTPS endpoint without embedded credentials.");
}
const config = {
apiKey: environmentValue("POSTGREST_API_KEY", "postgrestApiKey", ""),
endpoint,
schema: environmentValue("POSTGREST_SCHEMA", "postgrestSchema", "livesync_api"),
vaultCredential: environmentValue(
"POSTGREST_VAULT_CREDENTIAL",
"postgrestVaultCredential",
"adaptive-cli-vault-credential-0000000000001"
),
vaultId: environmentValue("POSTGREST_VAULT_ID", "postgrestVaultId", "adaptive-cli-vault-01"),
};
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(config.schema)) {
throw new Error("Adaptive PostgREST requires a valid exposed schema identifier.");
}
if (!/^[A-Za-z0-9_-]{16,128}$/u.test(config.vaultId) || config.vaultCredential.length === 0) {
throw new Error("Adaptive PostgREST requires a provisioned Vault ID and Vault credential.");
}
return config;
}
function postgRESTHeaders(config: PostgRESTConfig): Headers {
const headers = new Headers({
Accept: "application/json",
"Accept-Profile": config.schema,
"Content-Profile": config.schema,
"X-LiveSync-Vault-Credential": config.vaultCredential,
"X-LiveSync-Vault-ID": config.vaultId,
});
if (config.apiKey) headers.set("apikey", config.apiKey);
return headers;
}
async function readEstimatedSize(config: PostgRESTConfig): Promise<number> {
const response = await fetch(`${config.endpoint}/rpc/livesync_adaptive_status`, {
headers: postgRESTHeaders(config),
method: "GET",
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`Adaptive PostgREST status failed with HTTP ${response.status}.`);
}
const value = (await response.json()) as unknown;
const body = Array.isArray(value) && value.length === 1 ? value[0] : value;
const estimatedSize = Number(
body && typeof body === "object" && !Array.isArray(body)
? (body as { estimated_size?: unknown }).estimated_size
: Number.NaN
);
if (!Number.isFinite(estimatedSize) || estimatedSize < 0) {
throw new Error("Adaptive PostgREST status returned an invalid estimated size.");
}
return estimatedSize;
}
async function assertPostgRESTReachable(config: PostgRESTConfig): Promise<void> {
await readEstimatedSize(config);
}
async function enterManualAdaptivePostgRESTSettings(
port: number,
config: PostgRESTConfig,
vaultPassphrase: string
): Promise<string[]> {
const screenshots: string[] = [];
await withObsidianPage(port, async (page) => {
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(intro, "I am setting this up for the first time");
await intro
.getByRole("button", { name: "Yes, I want to set up a new synchronisation" })
.click({ timeout: uiTimeoutMs });
const method = modalByTitle(page, "Connection Method");
await method.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(method, "Configure a remote manually");
await method.getByRole("button", { name: "Proceed with manual configuration" }).click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
await encryption
.locator("label.row")
.filter({ hasText: "End-to-End Encryption" })
.locator('input[type="checkbox"]')
.first()
.check({ timeout: uiTimeoutMs });
await encryption.locator('input[name="e2ee-passphrase"]').fill(vaultPassphrase);
});
screenshots.push(
await captureGuideDialogue(port, "guide-adaptive-postgrest-encryption.png", "End-to-End Encryption")
);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "End-to-End Encryption")
.getByRole("button", { name: "Proceed", exact: true })
.click({ timeout: uiTimeoutMs });
const remoteSelection = modalByTitle(page, "Choose a synchronisation remote");
await remoteSelection.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(remoteSelection, "PostgREST Journal");
await remoteSelection
.getByRole("button", { name: "Continue to PostgREST setup", exact: true })
.click({ timeout: uiTimeoutMs });
const postgRESTModal = modalByTitle(page, "PostgREST Journal Configuration");
await postgRESTModal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await postgRESTModal.locator('input[name="postgrest-endpoint"]').fill(config.endpoint);
await postgRESTModal.locator('input[name="postgrest-vault-id"]').fill(config.vaultId);
await postgRESTModal.locator('input[name="postgrest-vault-credential"]').fill(config.vaultCredential);
await postgRESTModal.locator('input[name="postgrest-schema"]').fill(config.schema);
if (config.apiKey) await postgRESTModal.locator('input[name="postgrest-api-key"]').fill(config.apiKey);
await postgRESTModal.locator('input[name="postgrest-use-internal-api"]').check({ timeout: uiTimeoutMs });
if (
(await postgRESTModal.locator('select[name="postgrest-journal-format"]').count()) !== 0 ||
(await postgRESTModal.locator('select[name="postgrest-pack-read-policy"]').count()) !== 0
) {
throw new Error("Adaptive-only PostgREST onboarding exposed an Opaque or Pack retrieval choice.");
}
if (
(await postgRESTModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.count()) !== 0
) {
throw new Error("Adaptive PostgREST could continue before its server safety check completed.");
}
await postgRESTModal
.getByRole("button", { name: "Check PostgREST server", exact: true })
.click({ timeout: uiTimeoutMs });
await postgRESTModal
.getByText("The required PostgREST RPC operations and binary semantics were verified.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await postgRESTModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (
(await postgRESTModal.getByRole("button", { name: "Save without connecting", exact: true }).count()) !== 0
) {
throw new Error("Adaptive PostgREST onboarding exposed the unverified Settings-only save action.");
}
});
screenshots.push(
await captureGuideDialogue(port, "guide-adaptive-postgrest-safety-check.png", "PostgREST Journal Configuration")
);
await withObsidianPage(port, async (page) => {
const postgRESTModal = modalByTitle(page, "PostgREST Journal Configuration");
await postgRESTModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.click({ timeout: uiTimeoutMs });
await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
return screenshots;
}
function assertAdaptivePostgRESTSettings(state: SetupState, config: PostgRESTConfig, label: string): void {
assertEqual(state.remoteType, "POSTGREST", `${label} did not retain PostgREST as its active remote.`);
assertEqual(state.journalFormat, "adaptive-v1", `${label} did not retain the Adaptive Journal format.`);
assertEqual(state.packReadPolicy, "whole-pack", `${label} did not retain complete Pack retrieval.`);
assertEqual(state.remoteConfigurationCount, 1, `${label} did not retain exactly one remote profile.`);
const connection = parsePostgRESTConnectionURI(state.postgrestActiveConnectionURI);
assertEqual(connection.endpoint, config.endpoint, `${label} retained a different PostgREST endpoint.`);
assertEqual(connection.schema, config.schema, `${label} retained a different PostgREST schema.`);
if (connection.vaultId !== config.vaultId || connection.vaultCredential !== config.vaultCredential) {
throw new Error(`${label} did not retain the provisioned PostgREST Vault ID and Vault credential.`);
}
if (connection.apiKey !== config.apiKey) {
throw new Error(`${label} did not retain the configured PostgREST client API key.`);
}
assertEqual(
connection.useCustomRequestHandler,
true,
`${label} did not retain the Obsidian internal request API selection.`
);
}
async function main(): Promise<void> {
const config = loadPostgRESTConfig();
let shouldStopPostgREST = false;
try {
if (managePostgREST) {
await runNpmScript("test:docker-postgrest:start");
shouldStopPostgREST = !keepPostgRESTFixture;
}
await assertPostgRESTReachable(config);
await runAdaptiveJournalObsidianRoundTrip({
label: "Adaptive PostgREST",
slug: "adaptive-postgrest",
targetDescription: `Temporary PostgREST target: ${config.endpoint}`,
enterManualSettings: async (port, vaultPassphrase) =>
await enterManualAdaptivePostgRESTSettings(port, config, vaultPassphrase),
assertSettings: (state, label) => assertAdaptivePostgRESTSettings(state, config, label),
});
} finally {
if (shouldStopPostgREST) {
await runNpmScript("test:docker-postgrest:stop").catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,186 @@
/**
* Exercises Adaptive Journal WebDAV through visible safety-gated onboarding.
* The shared Adaptive Journal runner owns restart persistence, device-generated
* Setup URI transfer, Fast Fetch, and a two-device text and binary return
* journey. This scenario retains only the WebDAV UI and live endpoint boundary.
*/
import { parseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import { runAdaptiveJournalObsidianRoundTrip, runNpmScript } from "../runner/adaptiveJournal.ts";
import { assertEqual } from "../runner/liveSyncWorkflow.ts";
import { captureGuideDialogue, modalByTitle, selectRadioOption, type SetupState } from "../runner/setupUri.ts";
import { withObsidianPage } from "../runner/ui.ts";
import {
assertWebDAVReachable,
deleteWebDAVPrefix,
loadWebDAVConfig,
makeUniqueWebDAVPrefix,
type WebDAVConfig,
} from "../runner/webDAV.ts";
const supportedArguments = new Set(["--keep-webdav", "--manage-webdav"]);
const unsupportedArguments = process.argv.slice(2).filter((argument) => !supportedArguments.has(argument));
if (unsupportedArguments.length > 0) {
throw new Error(`Unsupported Adaptive WebDAV argument: ${unsupportedArguments.join(", ")}`);
}
const manageWebDAV = process.argv.includes("--manage-webdav");
const keepWebDAVFixture = process.argv.includes("--keep-webdav");
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
async function enterManualAdaptiveWebDAVSettings(
port: number,
webDAV: WebDAVConfig,
prefix: string,
vaultPassphrase: string
): Promise<string[]> {
const screenshots: string[] = [];
await withObsidianPage(port, async (page) => {
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(intro, "I am setting this up for the first time");
await intro
.getByRole("button", { name: "Yes, I want to set up a new synchronisation" })
.click({ timeout: uiTimeoutMs });
const method = modalByTitle(page, "Connection Method");
await method.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(method, "Configure a remote manually");
await method.getByRole("button", { name: "Proceed with manual configuration" }).click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
await encryption
.locator("label.row")
.filter({ hasText: "End-to-End Encryption" })
.locator('input[type="checkbox"]')
.first()
.check({ timeout: uiTimeoutMs });
await encryption.locator('input[name="e2ee-passphrase"]').fill(vaultPassphrase);
});
screenshots.push(await captureGuideDialogue(port, "guide-adaptive-webdav-encryption.png", "End-to-End Encryption"));
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "End-to-End Encryption")
.getByRole("button", { name: "Proceed", exact: true })
.click({ timeout: uiTimeoutMs });
const remoteSelection = modalByTitle(page, "Choose a synchronisation remote");
await remoteSelection.waitFor({ state: "visible", timeout: uiTimeoutMs });
await selectRadioOption(remoteSelection, "WebDAV Journal");
await remoteSelection
.getByRole("button", { name: "Continue to WebDAV setup", exact: true })
.click({ timeout: uiTimeoutMs });
const webDAVModal = modalByTitle(page, "WebDAV Journal Configuration");
await webDAVModal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await webDAVModal.locator('input[name="webdav-endpoint"]').fill(webDAV.endpoint);
await webDAVModal.locator('input[name="webdav-username"]').fill(webDAV.username);
await webDAVModal.locator('input[name="webdav-password"]').fill(webDAV.password);
await webDAVModal.locator('input[name="webdav-prefix"]').fill(prefix);
await webDAVModal.locator('input[name="webdav-use-internal-api"]').check({ timeout: uiTimeoutMs });
await webDAVModal.locator("summary").filter({ hasText: "Advanced Settings" }).click();
await webDAVModal.locator('select[name="webdav-journal-format"]').selectOption("adaptive-v1");
const packPolicy = webDAVModal.locator('select[name="webdav-pack-read-policy"]');
await packPolicy.waitFor({ state: "visible", timeout: uiTimeoutMs });
assertEqual(
await packPolicy.inputValue(),
"whole-pack",
"Adaptive WebDAV did not present complete Pack retrieval as the default."
);
if (
(await webDAVModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.count()) !== 0
) {
throw new Error("Adaptive WebDAV could continue before its endpoint safety check completed.");
}
await webDAVModal
.getByRole("button", { name: "Run endpoint safety check", exact: true })
.click({ timeout: uiTimeoutMs });
await webDAVModal
.getByText("Required Adaptive operations are supported by this WebDAV endpoint.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await webDAVModal
.getByText("Exact HTTP byte-range retrieval is supported.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await webDAVModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await webDAVModal.getByRole("button", { name: "Save without connecting", exact: true }).count()) !== 0) {
throw new Error("Adaptive WebDAV onboarding exposed the unverified Settings-only save action.");
}
});
screenshots.push(
await captureGuideDialogue(port, "guide-adaptive-webdav-safety-check.png", "WebDAV Journal Configuration")
);
await withObsidianPage(port, async (page) => {
const webDAVModal = modalByTitle(page, "WebDAV Journal Configuration");
await webDAVModal
.getByRole("button", { name: "Continue with verified settings", exact: true })
.click({ timeout: uiTimeoutMs });
await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
return screenshots;
}
function assertAdaptiveWebDAVSettings(state: SetupState, webDAV: WebDAVConfig, prefix: string, label: string): void {
assertEqual(state.remoteType, "WEBDAV", `${label} did not retain WebDAV as its active remote.`);
assertEqual(state.journalFormat, "adaptive-v1", `${label} did not retain the Adaptive Journal format.`);
assertEqual(state.packReadPolicy, "whole-pack", `${label} did not retain complete Pack retrieval.`);
assertEqual(state.remoteConfigurationCount, 1, `${label} did not retain exactly one remote profile.`);
const connection = parseWebDAVConnectionURI(state.webDAVactiveConnectionURI);
assertEqual(connection.endpoint, webDAV.endpoint, `${label} retained a different WebDAV endpoint.`);
assertEqual(connection.username, webDAV.username, `${label} retained a different WebDAV username.`);
assertEqual(connection.password, webDAV.password, `${label} retained a different WebDAV password.`);
assertEqual(connection.prefix, prefix, `${label} retained a different WebDAV prefix.`);
assertEqual(
connection.useCustomRequestHandler,
true,
`${label} did not retain the Obsidian internal request API selection.`
);
}
async function main(): Promise<void> {
const webDAV = await loadWebDAVConfig();
const prefix = makeUniqueWebDAVPrefix("adaptive-obsidian");
let shouldStopWebDAV = false;
try {
if (manageWebDAV) {
await runNpmScript("test:docker-webdav:start");
shouldStopWebDAV = !keepWebDAVFixture;
}
await assertWebDAVReachable(webDAV);
await runAdaptiveJournalObsidianRoundTrip({
label: "Adaptive WebDAV",
slug: "adaptive-webdav",
targetDescription: `Temporary WebDAV target: ${webDAV.endpoint}/${prefix}`,
enterManualSettings: async (port, vaultPassphrase) =>
await enterManualAdaptiveWebDAVSettings(port, webDAV, prefix, vaultPassphrase),
assertSettings: (state, label) => assertAdaptiveWebDAVSettings(state, webDAV, prefix, label),
});
} finally {
if (process.env.E2E_OBSIDIAN_KEEP_WEBDAV !== "true") {
await deleteWebDAVPrefix(webDAV, prefix).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
if (shouldStopWebDAV) {
await runNpmScript("test:docker-webdav:stop").catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+41
View File
@@ -17,6 +17,7 @@ const testSteps: Step[] = [
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
{ name: "remote setup providers", args: ["run", "test:e2e:obsidian:remote-setup-providers"] },
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
@@ -30,6 +31,8 @@ const testSteps: Step[] = [
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
},
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
{ name: "Adaptive WebDAV workflow", args: ["run", "test:e2e:obsidian:adaptive-webdav"] },
{ name: "Adaptive PostgREST workflow", args: ["run", "test:e2e:obsidian:adaptive-postgrest"] },
{
name: "Object Storage Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
@@ -46,10 +49,14 @@ const testSteps: Step[] = [
const manageCouchDb = process.argv.includes("--manage-couchdb") || process.argv.includes("--manage-services");
const manageMinio = process.argv.includes("--manage-minio") || process.argv.includes("--manage-services");
const manageP2P = process.argv.includes("--manage-p2p") || process.argv.includes("--manage-services");
const manageWebDAV = process.argv.includes("--manage-webdav") || process.argv.includes("--manage-services");
const managePostgREST = process.argv.includes("--manage-postgrest") || process.argv.includes("--manage-services");
const keepServices = process.argv.includes("--keep-services");
const keepCouchDb = keepServices || process.argv.includes("--keep-couchdb");
const keepMinio = keepServices || process.argv.includes("--keep-minio");
const keepP2P = keepServices || process.argv.includes("--keep-p2p");
const keepWebDAV = keepServices || process.argv.includes("--keep-webdav");
const keepPostgREST = keepServices || process.argv.includes("--keep-postgrest");
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
@@ -104,10 +111,28 @@ async function stopManagedP2P(): Promise<void> {
});
}
async function stopManagedWebDAV(): Promise<void> {
await runStep({
name: "stop WebDAV fixture",
args: ["run", "test:docker-webdav:stop"],
optional: true,
});
}
async function stopManagedPostgREST(): Promise<void> {
await runStep({
name: "stop PostgREST fixture",
args: ["run", "test:docker-postgrest:stop"],
optional: true,
});
}
async function main(): Promise<void> {
let shouldStopCouchDb = false;
let shouldStopMinio = false;
let shouldStopP2P = false;
let shouldStopWebDAV = false;
let shouldStopPostgREST = false;
try {
if (manageCouchDb) {
await stopManagedCouchDb();
@@ -124,11 +149,27 @@ async function main(): Promise<void> {
await runStep({ name: "start P2P relay fixture", args: ["run", "test:docker-p2p:start"] });
shouldStopP2P = !keepP2P;
}
if (manageWebDAV) {
await stopManagedWebDAV();
await runStep({ name: "start WebDAV fixture", args: ["run", "test:docker-webdav:start"] });
shouldStopWebDAV = !keepWebDAV;
}
if (managePostgREST) {
await stopManagedPostgREST();
await runStep({ name: "start PostgREST fixture", args: ["run", "test:docker-postgrest:start"] });
shouldStopPostgREST = !keepPostgREST;
}
for (const step of testSteps) {
await runStep(step);
}
} finally {
if (shouldStopPostgREST) {
await stopManagedPostgREST();
}
if (shouldStopWebDAV) {
await stopManagedWebDAV();
}
if (shouldStopP2P) {
await stopManagedP2P();
}
@@ -0,0 +1,406 @@
import { spawn } from "node:child_process";
import { mkdir, readFile, stat } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { connect } from "node:net";
import { dirname, extname, relative, resolve } from "node:path";
import { chromium, type Browser, type ConsoleMessage, type Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
confirmRebuild,
enterSetupURI,
finishInitialisation,
resumeCompatibilityReviewIfShown,
type SetupArtifact,
type SetupCaptureNames,
} from "../runner/setupUri.ts";
import { obsidianRemoteDebuggingPort } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const captures: SetupCaptureNames = { scenario: "p2p-connection-check", guide: "p2p-setup" };
const connectionTimeoutMs = Number(process.env.E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS ?? 60000);
const webPeerDist = resolve(process.cwd(), "src/apps/webpeer/dist");
type StaticServer = {
readonly baseUrl: string;
close(): Promise<void>;
};
function contentType(path: string): string {
switch (extname(path)) {
case ".css":
return "text/css; charset=utf-8";
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".json":
case ".map":
return "application/json; charset=utf-8";
case ".svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolveClose, reject) => {
server.close((error) => {
if (error) reject(error);
else resolveClose();
});
});
}
async function startStaticServer(): Promise<StaticServer> {
const distribution = await stat(webPeerDist).catch(() => undefined);
if (!distribution?.isDirectory()) {
throw new Error(
`WebPeer production bundle was not found at ${webPeerDist}. Build the webpeer workspace first.`
);
}
const server = createServer((request, response) => {
void (async () => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const requestedPath = decodeURIComponent(url.pathname).replace(/^\/+/, "") || "index.html";
const candidate = resolve(webPeerDist, requestedPath);
const relativePath = relative(webPeerDist, candidate);
if (relativePath.startsWith("..") || relativePath.includes("\0")) {
response.writeHead(404).end("Not found");
return;
}
try {
const candidateStat = await stat(candidate);
const filePath = candidateStat.isDirectory() ? resolve(candidate, "index.html") : candidate;
const body = await readFile(filePath);
response.writeHead(200, {
"cache-control": "no-store",
"content-type": contentType(filePath),
});
response.end(body);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
response.writeHead(404).end("Not found");
return;
}
throw error;
}
})().catch((error: unknown) => {
response.writeHead(500).end("Internal server error");
console.error(error instanceof Error ? error.stack : error);
});
});
await new Promise<void>((resolveListen, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolveListen();
});
});
const address = server.address();
if (!address || typeof address === "string") {
await closeServer(server);
throw new Error("The WebPeer static server did not expose a TCP port.");
}
return {
baseUrl: `http://127.0.0.1:${address.port}/`,
close: async () => await closeServer(server),
};
}
async function waitForRelay(relay: string): Promise<void> {
const endpoint = new URL(relay);
if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") {
throw new Error(`P2P relay must use ws: or wss:, received ${endpoint.protocol}`);
}
const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80));
const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname;
const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000);
let lastError: unknown;
let consecutiveConnections = 0;
while (Date.now() < deadline) {
try {
await new Promise<void>((resolveConnection, reject) => {
const socket = connect({ host, port });
socket.setTimeout(1000);
socket.once("connect", () => {
socket.destroy();
resolveConnection();
});
socket.once("timeout", () => {
socket.destroy();
reject(new Error("connection timed out"));
});
socket.once("error", reject);
});
consecutiveConnections += 1;
if (consecutiveConnections >= 3) return;
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
} catch (error) {
lastError = error;
consecutiveConnections = 0;
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
}
}
throw new Error(
`P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : String(lastError)}`
);
}
async function readDiagnostics(page: Page): Promise<Record<string, string>> {
return {
new: (await page.getByTestId("diag-new").textContent())?.trim() ?? "",
successful: (await page.getByTestId("diag-successful").textContent())?.trim() ?? "",
failed: (await page.getByTestId("diag-failed").textContent())?.trim() ?? "",
closed: (await page.getByTestId("diag-closed").textContent())?.trim() ?? "",
};
}
async function waitForSuccessfulConnection(page: Page, consoleErrors: string[]): Promise<Record<string, string>> {
try {
await page.waitForFunction(
() => Number(document.querySelector('[data-testid="diag-successful"]')?.textContent ?? "0") > 0,
undefined,
{ timeout: connectionTimeoutMs }
);
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n` +
`Browser diagnostics: ${JSON.stringify(await readDiagnostics(page))}\n` +
`Browser console errors: ${JSON.stringify(consoleErrors)}`
);
}
await page.getByRole("heading", { name: "P2P connection observed", exact: true }).waitFor({
timeout: 5000,
});
return await readDiagnostics(page);
}
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort();
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
}
return [first, second];
}
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function runNpmScript(script: string, optional = false): Promise<void> {
return new Promise((resolveRun, reject) => {
const child = spawn(npmBinary(), ["run", script], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0 || optional) {
resolveRun();
return;
}
reject(new Error(`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`));
});
});
}
async function runScenario(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`;
await waitForRelay(relay);
const server = await startStaticServer();
let browser: Browser | undefined;
const vaults: TemporaryVault[] = [];
const sessions: ObsidianLiveSyncSession[] = [];
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
try {
browser = await chromium.launch({ headless: true });
const firstVault = await createTemporaryVault("obsidian-livesync-p2p-check-first-e2e-");
vaults.push(firstVault);
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message));
page.on("console", (message: ConsoleMessage) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
const checkUrl = new URL("check.html", server.baseUrl);
checkUrl.searchParams.set("relay", relay);
await page.goto(checkUrl.href);
await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({ timeout: 30000 });
await page.getByRole("button", { name: "Prepare desktop check", exact: true }).click();
await page.getByAltText("Setup URI QR code for the desktop check", { exact: true }).waitFor({
timeout: 30000,
});
const artifact: SetupArtifact = {
setupURI: await page.getByLabel("Setup URI", { exact: true }).inputValue(),
setupPassphrase: await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue(),
};
if (!artifact.setupURI.startsWith("obsidian://setuplivesync?settings=")) {
throw new Error("The browser did not generate a Setup URI.");
}
await page.getByText(relay, { exact: true }).waitFor({ timeout: 5000 });
await page.getByRole("button", { name: "Start connection monitor", exact: true }).click();
await page.getByRole("button", { name: "Monitoring is active", exact: true }).waitFor({ timeout: 30000 });
const [firstPort, secondPort] = sessionPorts();
const firstSession = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault: firstVault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(firstPort),
});
sessions.push(firstSession);
await enterSetupURI(firstPort, "new", artifact, captures);
await captureAndStartInitialisation(firstPort, "new", captures);
await confirmRebuild(firstPort, captures);
await acknowledgeDisabledOptionalFeatures(firstPort, captures);
const firstSetupState = await finishInitialisation(firstPort, cli.binary, firstSession.cliEnv);
await resumeCompatibilityReviewIfShown(firstPort);
if (!firstSetupState.p2pEnabled || firstSetupState.p2pRelays !== relay) {
throw new Error(
`The first Obsidian device did not apply the browser P2P setup: ${JSON.stringify(firstSetupState)}`
);
}
const firstDiagnostics = await waitForSuccessfulConnection(page, consoleErrors);
const tryAnotherDevice = page.getByRole("button", {
name: "Try another device without resetting",
exact: true,
});
await tryAnotherDevice.click({ timeout: connectionTimeoutMs });
await page
.getByRole("heading", { name: "Use this same one-off configuration on another device", exact: true })
.waitFor({ timeout: 5000 });
await page.getByAltText("Setup URI QR code for another device", { exact: true }).waitFor({ timeout: 5000 });
if ((await page.getByLabel("Setup URI", { exact: true }).inputValue()) !== artifact.setupURI) {
throw new Error("The additional-device action regenerated or replaced the Setup URI.");
}
const secondVault = await createTemporaryVault("obsidian-livesync-p2p-check-second-e2e-");
vaults.push(secondVault);
const secondSession = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault: secondVault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(secondPort),
});
sessions.push(secondSession);
await enterSetupURI(secondPort, "new", artifact, captures);
await captureAndStartInitialisation(secondPort, "new", captures);
await confirmRebuild(secondPort, captures);
await acknowledgeDisabledOptionalFeatures(secondPort, captures);
const secondSetupState = await finishInitialisation(secondPort, cli.binary, secondSession.cliEnv);
await resumeCompatibilityReviewIfShown(secondPort);
if (
!secondSetupState.p2pEnabled ||
secondSetupState.p2pRelays !== relay ||
secondSetupState.p2pRoomId !== firstSetupState.p2pRoomId
) {
throw new Error(
`The second Obsidian device did not reuse the browser P2P setup: ${JSON.stringify(secondSetupState)}`
);
}
await page.getByRole("heading", { name: "An additional connection was observed", exact: true }).waitFor({
timeout: connectionTimeoutMs,
});
const diagnostics = await readDiagnostics(page);
if (pageErrors.length > 0) {
throw new Error(`The browser page reported runtime errors: ${JSON.stringify(pageErrors)}`);
}
const screenshotPath = resolve(
process.env.E2E_P2P_CHECK_SCREENSHOT ??
resolve(
process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e",
"p2p-connection-check-browser-success.png"
)
);
await mkdir(dirname(screenshotPath), { recursive: true });
await page.locator(".results-card").screenshot({
animations: "disabled",
caret: "hide",
path: screenshotPath,
});
console.log(
`Browser-to-two-Obsidian P2P connection check succeeded through ${relay}. ` +
`First diagnostics: ${JSON.stringify(firstDiagnostics)}; final diagnostics: ${JSON.stringify(diagnostics)}`
);
console.log(`Browser result screenshot: ${screenshotPath}`);
if (consoleErrors.length > 0) {
console.warn(`Browser console errors after successful connection: ${JSON.stringify(consoleErrors)}`);
}
} finally {
for (const session of sessions.reverse()) {
await session.app.stop().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
for (const vault of vaults.reverse()) {
await vault.dispose().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
await browser?.close().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await server.close().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
async function main(): Promise<void> {
const manageP2P = process.argv.includes("--manage-p2p");
let shouldStopP2P = false;
try {
if (manageP2P) {
await runNpmScript("test:docker-p2p:stop", true);
await runNpmScript("test:docker-p2p:start");
shouldStopP2P = true;
}
await runScenario();
} finally {
if (shouldStopP2P) {
await runNpmScript("test:docker-p2p:stop", true);
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,524 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import {
defaultRemoteProviderRegistry,
type BuiltInRemoteConfiguration,
type RemoteConfiguration,
} from "@vrtmrz/livesync-commonlib/remote-configurations";
import { parsePostgRESTConnectionURI, parseWebDAVConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
import { enableAndReloadPlugin } from "@vrtmrz/obsidian-test-session";
import type { Locator, Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import {
beginRemoteProfileSetup,
captureAndCancelRemoteProvider,
captureRemoteProviderChoices,
closeRemoteConfigurationSettings,
installRemoteSetupTestSeam,
openRemoteConfigurationSettings,
openSavedRemoteProfile,
remoteProviderModal,
remoteSetupCalls,
runtimeRemoteSettings,
selectRemoteProvider,
setRemoteInspectionMode,
waitForSavedRemoteProfile,
} from "../runner/remoteSetupUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianDialogue,
captureObsidianElement,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_SETUP_TIMEOUT_MS ?? 10000);
const repositoryA = "A".repeat(43);
const repositoryB = `${"A".repeat(42)}Q`;
const profiles = {
postgrest: {
apiKey: "publishable-ui-key",
credential: "remote-setup-ui-credential",
endpoint: "https://postgrest.example.test/rest/v1",
expectedRepositoryId: repositoryB,
name: "PostgREST UI profile",
schema: "livesync_api",
vaultId: "remote-setup-vault-01",
},
s3: {
accessKey: "remote-setup-access-key",
bucket: "remote-setup-bucket",
endpoint: "https://s3.example.test",
name: "S3 UI profile",
prefix: "adaptive-ui/",
region: "us-east-1",
secretKey: "remote-setup-secret-key",
},
webdav: {
endpoint: "https://dav.example.test/remote.php/dav/files/tester",
expectedRepositoryId: repositoryA,
name: "WebDAV UI profile",
password: "remote-setup-password",
prefix: "adaptive-ui/",
username: "remote-setup-user",
},
} as const;
const providerChoices = [
"CouchDB",
"S3-compatible Object Storage",
"WebDAV Journal",
"PostgREST Journal",
"Peer-to-Peer (P2P)",
] as const;
type PersistedSettings = {
activeConfigurationId: string;
remoteConfigurations: Record<string, RemoteConfiguration>;
};
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function profileByName(settings: PersistedSettings, name: string): RemoteConfiguration {
const profile = Object.values(settings.remoteConfigurations).find((candidate) => candidate.name === name);
if (!profile) throw new Error(`Saved remote profile '${name}' was not found`);
return profile;
}
function parseProfile(settings: PersistedSettings, name: string): BuiltInRemoteConfiguration {
return defaultRemoteProviderRegistry.parse(profileByName(settings, name).uri);
}
function assertPersistedProfiles(settings: PersistedSettings): void {
const s3 = parseProfile(settings, profiles.s3.name);
assertEqual(s3.type, "s3", "The S3 dialogue returned the wrong provider type.");
if (s3.type !== "s3") return;
assertEqual(s3.settings.endpoint, profiles.s3.endpoint, "The S3 endpoint was not saved.");
assertEqual(s3.settings.bucket, profiles.s3.bucket, "The S3 bucket was not saved.");
assertEqual(s3.settings.bucketPrefix, profiles.s3.prefix, "The S3 prefix was not saved.");
assertEqual(s3.settings.journalFormat, "adaptive-v1", "The S3 Adaptive format was not saved.");
assertEqual(s3.settings.packReadPolicy, "range", "The S3 Range policy was not saved.");
const webdav = parseProfile(settings, profiles.webdav.name);
assertEqual(webdav.type, "webdav", "The WebDAV dialogue returned the wrong provider type.");
if (webdav.type !== "webdav") return;
const webdavConnection = parseWebDAVConnectionURI(webdav.settings.webDAVactiveConnectionURI);
assertEqual(webdavConnection.endpoint, profiles.webdav.endpoint, "The WebDAV endpoint was not saved.");
assertEqual(webdavConnection.prefix, profiles.webdav.prefix, "The WebDAV prefix was not saved.");
assertEqual(webdavConnection.username, profiles.webdav.username, "The WebDAV username was not saved.");
assertEqual(webdav.settings.expectedRepositoryId, repositoryA, "The WebDAV repository ID was not saved.");
assertEqual(webdav.settings.journalFormat, "adaptive-v1", "The WebDAV Adaptive format was not saved.");
assertEqual(webdav.settings.packReadPolicy, "range", "The WebDAV Range policy was not saved.");
const postgrest = parseProfile(settings, profiles.postgrest.name);
assertEqual(postgrest.type, "postgrest", "The PostgREST dialogue returned the wrong provider type.");
if (postgrest.type !== "postgrest") return;
const postgrestConnection = parsePostgRESTConnectionURI(postgrest.settings.postgrestActiveConnectionURI);
assertEqual(postgrestConnection.endpoint, profiles.postgrest.endpoint, "The PostgREST endpoint was not saved.");
assertEqual(postgrestConnection.schema, profiles.postgrest.schema, "The PostgREST schema was not saved.");
assertEqual(postgrestConnection.vaultId, profiles.postgrest.vaultId, "The PostgREST Vault ID was not saved.");
assertEqual(postgrest.settings.expectedRepositoryId, repositoryB, "The PostgREST repository ID was not saved.");
assertEqual(postgrest.settings.journalFormat, "adaptive-v1", "PostgREST did not retain its fixed format.");
assertEqual(postgrest.settings.packReadPolicy, "whole-pack", "PostgREST did not retain its fixed read policy.");
}
function assertEncryptedProfilesAtRest(settings: PersistedSettings): void {
for (const fixture of [profiles.s3, profiles.webdav, profiles.postgrest]) {
const profile = profileByName(settings, fixture.name);
assertEqual(profile.isEncrypted, true, `Saved remote profile '${fixture.name}' was not encrypted at rest.`);
for (const exposed of [
profiles.s3.endpoint,
profiles.s3.secretKey,
profiles.webdav.endpoint,
profiles.webdav.password,
profiles.postgrest.endpoint,
profiles.postgrest.credential,
]) {
if (profile.uri.includes(exposed)) {
throw new Error(`Saved remote profile '${fixture.name}' exposed a connection secret or endpoint.`);
}
}
}
}
async function fill(locator: Locator, value: string): Promise<void> {
await locator.fill(value, { timeout: uiTimeoutMs });
}
async function openAdvanced(modal: Locator): Promise<void> {
const details = modal.locator("details").filter({ hasText: "Advanced Settings" }).first();
await details.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (!(await details.getAttribute("open"))) {
await details.locator("summary").click({ timeout: uiTimeoutMs });
}
}
async function expectInputValue(modal: Locator, name: string, expected: string): Promise<void> {
const actual = await modal.locator(`[name="${name}"]`).inputValue({ timeout: uiTimeoutMs });
assertEqual(actual, expected, `Reloaded control '${name}' has the wrong value.`);
}
async function mountLegacyProvider(
port: number,
profileName: string,
choice: string,
proceed: string,
title: string,
screenshotName: string
): Promise<string> {
await beginRemoteProfileSetup(port, profileName, uiTimeoutMs);
await selectRemoteProvider(port, choice, proceed, title, uiTimeoutMs);
return await captureAndCancelRemoteProvider(port, screenshotName, title, uiTimeoutMs);
}
async function addS3Profile(port: number): Promise<string> {
await beginRemoteProfileSetup(port, profiles.s3.name, uiTimeoutMs);
await selectRemoteProvider(
port,
"S3-compatible Object Storage",
"Continue to Object Storage setup",
"S3/MinIO/R2 Configuration",
uiTimeoutMs
);
const screenshot = await captureObsidianDialogue(port, "remote-setup-s3-adaptive.png", async (page) => {
const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration");
const testButton = modal.getByRole("button", { name: "Test Settings and Continue", exact: true });
if (!(await testButton.isDisabled()))
throw new Error("Incomplete S3 settings did not disable connection testing.");
await fill(modal.locator('[name="s3-endpoint"]'), profiles.s3.endpoint);
await fill(modal.locator('[name="s3-access-key-id"]'), profiles.s3.accessKey);
await fill(modal.locator('[name="s3-secret-access-key"]'), profiles.s3.secretKey);
await fill(modal.locator('[name="s3-bucket-name"]'), profiles.s3.bucket);
await fill(modal.locator('[name="s3-region"]'), profiles.s3.region);
await fill(modal.locator('[name="s3-folder-prefix"]'), profiles.s3.prefix);
await openAdvanced(modal);
await modal.locator('[name="s3-journal-format"]').selectOption("adaptive-v1");
await modal.locator('[name="s3-pack-read-policy"]').selectOption("range");
if (await testButton.isDisabled()) throw new Error("Complete S3 settings did not enable connection testing.");
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration");
await modal.getByRole("button", { name: "Test Settings and Continue", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForSavedRemoteProfile(port, profiles.s3.name, uiTimeoutMs);
return screenshot;
}
async function addWebDAVProfile(port: number): Promise<string> {
await beginRemoteProfileSetup(port, profiles.webdav.name, uiTimeoutMs);
await selectRemoteProvider(
port,
"WebDAV Journal",
"Continue to WebDAV setup",
"WebDAV Journal Configuration",
uiTimeoutMs
);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await fill(modal.locator('[name="webdav-endpoint"]'), profiles.webdav.endpoint);
await fill(modal.locator('[name="webdav-username"]'), profiles.webdav.username);
await fill(modal.locator('[name="webdav-password"]'), profiles.webdav.password);
await fill(modal.locator('[name="webdav-prefix"]'), profiles.webdav.prefix);
await modal.locator('[name="webdav-use-internal-api"]').check({ timeout: uiTimeoutMs });
await openAdvanced(modal);
await modal.locator('[name="webdav-journal-format"]').selectOption("adaptive-v1");
await fill(modal.locator('[name="webdav-expected-repository-id"]'), repositoryA);
await modal.locator('[name="webdav-pack-read-policy"]').selectOption("range");
});
await setRemoteInspectionMode(port, "failed");
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByText("missing required Adaptive operations", { exact: false }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) {
throw new Error("A failed WebDAV safety check exposed the verified-save action.");
}
});
await setRemoteInspectionMode(port, "verified");
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await fill(modal.locator('[name="webdav-prefix"]'), "stale-inspection/");
if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) {
throw new Error("Editing WebDAV settings did not invalidate the previous safety check.");
}
await fill(modal.locator('[name="webdav-prefix"]'), profiles.webdav.prefix);
await modal.getByRole("button", { name: "Run endpoint safety check", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
const screenshot = await captureObsidianDialogue(port, "remote-setup-webdav-verified.png", async (page) => {
await remoteProviderModal(page, "WebDAV Journal Configuration")
.getByText("Required Adaptive operations are supported", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await modal.getByRole("button", { name: "Save verified settings", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForSavedRemoteProfile(port, profiles.webdav.name, uiTimeoutMs);
return screenshot;
}
async function addPostgRESTProfile(port: number): Promise<string> {
await beginRemoteProfileSetup(port, profiles.postgrest.name, uiTimeoutMs);
await selectRemoteProvider(
port,
"PostgREST Journal",
"Continue to PostgREST setup",
"PostgREST Journal Configuration",
uiTimeoutMs
);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "PostgREST Journal Configuration");
const check = modal.getByRole("button", { name: "Check PostgREST server", exact: true });
if (!(await check.isDisabled())) {
throw new Error("Incomplete PostgREST settings did not disable the server check.");
}
await fill(modal.locator('[name="postgrest-endpoint"]'), profiles.postgrest.endpoint);
await fill(modal.locator('[name="postgrest-vault-id"]'), profiles.postgrest.vaultId);
await fill(modal.locator('[name="postgrest-vault-credential"]'), profiles.postgrest.credential);
await fill(modal.locator('[name="postgrest-schema"]'), profiles.postgrest.schema);
await fill(modal.locator('[name="postgrest-api-key"]'), profiles.postgrest.apiKey);
await modal.locator('[name="postgrest-use-internal-api"]').check({ timeout: uiTimeoutMs });
await openAdvanced(modal);
await fill(modal.locator('[name="postgrest-expected-repository-id"]'), repositoryB);
if (await check.isDisabled()) throw new Error("Complete PostgREST settings did not enable the server check.");
await check.click({ timeout: uiTimeoutMs });
try {
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`PostgREST verification did not enable saving. Dialogue text:\n${await modal.innerText()}\nCause: ${reason}`
);
}
await fill(modal.locator('[name="postgrest-api-key"]'), "stale-publishable-key");
if ((await modal.getByRole("button", { name: "Save verified settings", exact: true }).count()) !== 0) {
throw new Error("Editing PostgREST settings did not invalidate the previous server check.");
}
await fill(modal.locator('[name="postgrest-api-key"]'), profiles.postgrest.apiKey);
await modal.getByRole("button", { name: "Check PostgREST server", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.getByRole("button", { name: "Save verified settings", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
const screenshot = await captureObsidianDialogue(port, "remote-setup-postgrest-verified.png", async (page) => {
await remoteProviderModal(page, "PostgREST Journal Configuration")
.getByText("required PostgREST RPC operations", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "PostgREST Journal Configuration");
await modal.getByRole("button", { name: "Save verified settings", exact: true }).click({
timeout: uiTimeoutMs,
});
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForSavedRemoteProfile(port, profiles.postgrest.name, uiTimeoutMs);
return screenshot;
}
async function assertReloadedS3(port: number): Promise<void> {
await openSavedRemoteProfile(port, profiles.s3.name, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "S3/MinIO/R2 Configuration");
await expectInputValue(modal, "s3-endpoint", profiles.s3.endpoint);
await expectInputValue(modal, "s3-bucket-name", profiles.s3.bucket);
await openAdvanced(modal);
await expectInputValue(modal, "s3-journal-format", "adaptive-v1");
await expectInputValue(modal, "s3-pack-read-policy", "range");
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function assertReloadedWebDAV(port: number): Promise<void> {
await openSavedRemoteProfile(port, profiles.webdav.name, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "WebDAV Journal Configuration");
await expectInputValue(modal, "webdav-endpoint", profiles.webdav.endpoint);
await expectInputValue(modal, "webdav-prefix", profiles.webdav.prefix);
await openAdvanced(modal);
await expectInputValue(modal, "webdav-journal-format", "adaptive-v1");
await expectInputValue(modal, "webdav-expected-repository-id", repositoryA);
await expectInputValue(modal, "webdav-pack-read-policy", "range");
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function assertReloadedPostgREST(port: number): Promise<void> {
await openSavedRemoteProfile(port, profiles.postgrest.name, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const modal = remoteProviderModal(page, "PostgREST Journal Configuration");
await expectInputValue(modal, "postgrest-endpoint", profiles.postgrest.endpoint);
await expectInputValue(modal, "postgrest-vault-id", profiles.postgrest.vaultId);
await expectInputValue(modal, "postgrest-schema", profiles.postgrest.schema);
await openAdvanced(modal);
await expectInputValue(modal, "postgrest-expected-repository-id", repositoryB);
await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function readPersistedSettings(pluginDir: string): Promise<PersistedSettings> {
return JSON.parse(await readFile(join(pluginDir, "data.json"), "utf8")) as PersistedSettings;
}
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(", ")}`);
const vault = await createTemporaryVault("obsidian-livesync-remote-setup-e2e-");
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
pluginData: createE2eCouchDbPluginData(
{
dbName: "remote-setup-ui-only",
password: "",
uri: "http://127.0.0.1:5984",
username: "",
},
{
notifyThresholdOfRemoteStorageSize: 0,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
useAdvancedMode: true,
}
),
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
vault,
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const port = obsidianRemoteDebuggingPort();
await installRemoteSetupTestSeam(port);
await openRemoteConfigurationSettings(port, uiTimeoutMs);
await beginRemoteProfileSetup(port, "Cancelled CouchDB profile", uiTimeoutMs);
const selectionScreenshot = await captureRemoteProviderChoices(
port,
"remote-setup-provider-selection.png",
providerChoices,
uiTimeoutMs
);
await selectRemoteProvider(port, "CouchDB", "Continue to CouchDB setup", "CouchDB Configuration", uiTimeoutMs);
const couchdbScreenshot = await captureAndCancelRemoteProvider(
port,
"remote-setup-couchdb.png",
"CouchDB Configuration",
uiTimeoutMs
);
const p2pScreenshot = await mountLegacyProvider(
port,
"Cancelled P2P profile",
"Peer-to-Peer (P2P)",
"Continue to P2P setup",
"P2P Configuration",
"remote-setup-p2p.png"
);
const s3Screenshot = await addS3Profile(port);
const webdavScreenshot = await addWebDAVProfile(port);
const postgrestScreenshot = await addPostgRESTProfile(port);
const firstRuntimeSettings = (await runtimeRemoteSettings(port)) as PersistedSettings;
assertPersistedProfiles(firstRuntimeSettings);
const calls = await remoteSetupCalls(port);
assertEqual(
calls.filter((call) => call.operation === "test").length,
1,
"The S3 profile did not use the injected connection-test boundary exactly once."
);
if (calls.filter((call) => call.operation === "inspect").length < 5) {
throw new Error("The WebDAV and PostgREST profiles did not exercise failure, stale, and verified checks.");
}
const pluginDir = session.install.pluginDir;
assertEncryptedProfilesAtRest(await readPersistedSettings(pluginDir));
await closeRemoteConfigurationSettings(port, uiTimeoutMs);
await enableAndReloadPlugin(port, "obsidian-livesync");
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await openRemoteConfigurationSettings(port, uiTimeoutMs);
await assertReloadedS3(port);
await assertReloadedWebDAV(port);
await assertReloadedPostgREST(port);
const reloadedSettings = (await runtimeRemoteSettings(port)) as PersistedSettings;
assertPersistedProfiles(reloadedSettings);
assertEqual(
Object.keys(reloadedSettings.remoteConfigurations).length,
Object.keys(firstRuntimeSettings.remoteConfigurations).length,
"Plug-in reload changed the saved remote profile count."
);
const listScreenshot = await captureObsidianElement(port, "remote-setup-reloaded-profiles.png", (page: Page) =>
page.locator(".sls-setting .sls-remote-list")
);
console.log(
`All registered providers mounted through the real Settings flow; S3, WebDAV, and PostgREST passed validation, injected checks, persistence, and plug-in reload. Screenshots: ${[
selectionScreenshot,
couchdbScreenshot,
p2pScreenshot,
s3Screenshot,
webdavScreenshot,
postgrestScreenshot,
listScreenshot,
].join(", ")}`
);
} finally {
if (session) await session.app.stop();
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+4 -1
View File
@@ -11,6 +11,7 @@ const focusedScenarios = new Set([
"revision-repair",
"document-history-nav",
"settings-ui",
"remote-setup-providers",
"review-harness",
"p2p-pane",
"vault-reflection",
@@ -18,6 +19,8 @@ const focusedScenarios = new Set([
"couchdb-manual-setup-workflow",
"cli-to-obsidian-sync",
"minio-upload",
"adaptive-webdav",
"adaptive-postgrest",
"object-storage-setup-uri-workflow",
"p2p-setup-uri-workflow",
"startup-scan",
@@ -38,7 +41,7 @@ real-Obsidian scenario. Supported scenarios:
${[...focusedScenarios].map((scenario) => ` ${scenario}`).join("\n")}
This wrapper does not start CouchDB, Object Storage, or the P2P signalling
This wrapper does not start CouchDB, Object Storage, PostgREST, WebDAV, or the P2P signalling
relay. Use the documented service commands or the complete
local-suite:services wrapper when required.`;
}
+20
View File
@@ -12,6 +12,26 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
## Unreleased
### Synchronisation and storage
#### Improved
- Object Storage setup can select the experimental Adaptive Journal format and choose complete Pack or verified Range retrieval. Existing Opaque Journal repositories remain the default and require an explicit remote Rebuild before changing formats.
- Saved connections and Setup now support experimental WebDAV Journal profiles. Adaptive WebDAV runs an endpoint safety check, reports required immutable-object behaviour separately from optional Range support, and permits an unverified save only from Settings.
- Saved connections and Setup now support experimental, Adaptive-only PostgREST Journal profiles. Setup verifies the packaged RPC contract and binary semantics, the adapter rejects recognised privileged Supabase keys before a request, and Opaque Journal, Pack, and Range options remain outside this provider.
### P2P and experimental browser applications
#### Improved
- WebPeer now includes a separate P2P connection check which prepares a disposable encrypted Setup URI and QR code locally, tests desktop and mobile devices in separate random rooms, can reuse the unchanged QR and a recorded baseline for an optional second device in the same room, and displays browser-side WebRTC diagnostic totals without presenting them as note-synchronisation results.
### Interface
#### Fixed
- The Remediation setting now displays its configured modification-time limit without raising a `HierarchyRequestError`.
## 1.0.2
31st July, 2026
+5 -6
View File
@@ -52,15 +52,14 @@ export function renderReleasePrBody(version, baseBranch) {
: "Confirm the draft GitHub Release assets; keep stable CLI publication deferred until BRAT validation passes";
const holdInstruction = isPrerelease
? `Publishing and validating this pre-release does not unblock this pull request. Keep it in draft and unmerged, and leave ${baseBranchCode} unchanged.`
: `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation. Promotion remains on hold until the exact release commit has been integrated into the repository's default branch.`;
: `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation. Before the exact release commit reaches the repository's default branch, remove the pre-release designation and make this exact release the latest stable release so Community Directory review does not inspect a manifest version whose GitHub Release remains a pre-release.`;
const completionInstructions = isPrerelease
? [
"- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action",
]
? ["- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action"]
: [
`- [ ] After BRAT validation passes, mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`,
"- [ ] After BRAT validation passes, remove the pre-release designation and make this exact release the latest stable release",
`- [ ] Confirm this exact release is no longer a pre-release, then mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`,
"- [ ] Integrate the exact release commit through the reviewed branch chain into the repository's default branch",
"- [ ] Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release",
"- [ ] Confirm the default branch contains the exact release commit and metadata",
"- [ ] Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate",
];
+1 -32
View File
@@ -206,43 +206,12 @@ describe("release workflow", () => {
expect(stable).toContain(
"Publish the GitHub Release initially as a pre-release without replacing the latest stable release"
);
expect(stable).toContain(
"After BRAT validation passes, mark this pull request ready and merge it into `main` with a merge commit"
);
expect(stable).toContain(
"Integrate the exact release commit through the reviewed branch chain into the repository's default branch"
);
expect(stable).toContain(
"Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release"
);
expect(stable).toContain(
"Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate"
);
expect(stable.indexOf("After BRAT validation passes")).toBeLessThan(
stable.indexOf("Integrate the exact release commit")
);
expect(stable.indexOf("Integrate the exact release commit")).toBeLessThan(
stable.indexOf("Confirm the default branch contains the exact release metadata")
);
expect(stable.indexOf("Confirm the default branch contains the exact release metadata")).toBeLessThan(
stable.indexOf("Create the stable CLI tag")
);
expect(stable).not.toContain("prerelease=false");
});
it("summarises immutable pre-releases separately from stable versions awaiting promotion", () => {
it("requires stable pre-release staging to defer CLI publication", () => {
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
expect(workflow).toContain('if [[ "${VERSION}" == *-* ]]; then');
expect(workflow).toContain(
"Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action."
);
expect(workflow).toContain(
"After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
);
expect(workflow).toContain(
"Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release."
);
expect(workflow).toContain(
'if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then'
);