mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-01 09:21:23 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a722244a92 | |||
| 4f14d7f160 | |||
| 7955ab54d3 | |||
| 133ef34d7b | |||
| 0890a97222 | |||
| b7da78ce45 | |||
| 53d4bd527e | |||
| 64870a32ea | |||
| 5ede11d032 | |||
| bcf123c67a | |||
| f3b0fbb29b | |||
| 73e34aef7c | |||
| 166e431103 | |||
| 3ba9feaac7 | |||
| 42f37753b8 | |||
| ec1fc4a97e | |||
| 146e25907d | |||
| c97346e262 | |||
| 48c398948a | |||
| e32e3f545e | |||
| 0aed7f8556 | |||
| 50a51bf07e | |||
| 1570136703 | |||
| 5b6bbc8118 | |||
| 54f0262b3f | |||
| 7d33613017 |
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Architectural Decision Record: Use Native Batch CAS for Adaptive PostgREST
|
||||
|
||||
## Status
|
||||
|
||||
Proposed as the final experimental provider, after the S3 and WebDAV delivery sequences have established the common
|
||||
protocol, CLI, host, and end-to-end boundaries.
|
||||
|
||||
## Context
|
||||
|
||||
PostgREST can expose PostgreSQL uniqueness, row-level security, bounded set operations, and transactions. Treating it
|
||||
as another opaque object store works, but leaves Metadata and Chunks combined and cannot use a multi-key Chunk query.
|
||||
Using one HTTP request per logical Chunk would make latency dominate synchronisation.
|
||||
|
||||
The common protocol decision is recorded in
|
||||
[Adaptive Journal as an explicit protocol](2026_07_adaptive_journal_protocol.md). The tables, binary RPC framing,
|
||||
transaction rules, and privacy model are specified in the
|
||||
[Adaptive Journal Sync design](../design_docs/adaptive_journal_sync.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Adaptive PostgREST separates Metadata publication from immutable Chunk storage physically as well as logically.
|
||||
|
||||
- Vault- and repository-scoped Chunk rows use the Remote Chunk key as an insert-only unique address.
|
||||
- Bounded binary RPCs implement `hasMany`, `getMany`, and `putMany` while preserving input order and per-entry status.
|
||||
- Writer descriptors, Metadata batches, and commits remain small append-only records.
|
||||
- A transactional commit verifies the sorted required-Chunk-key digest and the existence of every referenced Chunk
|
||||
before making Metadata visible.
|
||||
- Concurrent insertion of a different encrypted frame for the same logical Chunk reads, validates, and accepts the
|
||||
winning plaintext only when it represents the same logical value.
|
||||
- Row-level security scopes every operation to the configured Vault. Server-visible Remote Chunk keys reveal equality
|
||||
within that repository but do not reveal plaintext Chunk IDs.
|
||||
|
||||
PostgREST does not use object packs, catalogue deltas, or Range retrieval for native Chunk rows. The shared binary Chunk
|
||||
record remains independently verifiable, but PostgreSQL supplies the batch index and uniqueness boundary.
|
||||
|
||||
The SQL schema and RPC contract are versioned together with the Adaptive format. A missing or incompatible schema is
|
||||
detected before publication and requires applying the reviewed schema or rebuilding the remote. The client does not
|
||||
perform an implicit data-format migration.
|
||||
|
||||
## Staged acceptance
|
||||
|
||||
### Adapter and Commonlib integration
|
||||
|
||||
Unit tests own binary-envelope limits, ordering, status decoding, insert conflicts, rollback, error classification,
|
||||
Vault isolation, and malformed responses. Disposable PostgreSQL and PostgREST integration tests own the real SQL
|
||||
schema, row-level security, RPC transactions, and two-client Metadata and Chunk synchronisation.
|
||||
|
||||
### CLI end-to-end acceptance
|
||||
|
||||
The built CLI applies an Adaptive PostgREST Setup URI to a second independent database and synchronises text and binary
|
||||
Chunk-backed files through disposable PostgreSQL and PostgREST services. The scenario proves bounded native batching at
|
||||
the headless product boundary without repeating the SQL failure matrix.
|
||||
|
||||
### Host settings and UI
|
||||
|
||||
The PostgREST dialogue persists the endpoint, Vault identifier, authentication configuration, Adaptive format, and
|
||||
expected repository ID. Focused tests own connection-string and Setup URI preservation, validation, and format-mismatch
|
||||
guidance; they do not execute SQL.
|
||||
|
||||
### Real-host end-to-end acceptance
|
||||
|
||||
One real-Obsidian workflow applies the reviewed schema and performs a representative Chunk-backed transfer. The
|
||||
disposable Commonlib integration remains authoritative for RLS, transaction rollback, and the complete RPC matrix.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Reuse immutable object packs in PostgreSQL
|
||||
|
||||
This would preserve portability at the cost of native multi-key lookup and transaction guarantees. The repository
|
||||
contract already permits a different physical representation.
|
||||
|
||||
### Store raw file content with Metadata
|
||||
|
||||
Metadata must remain small and must continue to refer to immutable logical Chunks. Combining content with Metadata
|
||||
would break the maintained PouchDB model and duplicate unchanged content across revisions.
|
||||
|
||||
### Encode Chunk bodies as JSON values
|
||||
|
||||
Base64 and large JSON arrays add framing and memory overhead and make limits harder to enforce. Bounded binary `bytea`
|
||||
RPC envelopes provide deterministic lengths and status ordering.
|
||||
|
||||
## Consequences
|
||||
|
||||
- PostgREST has the largest provider-specific implementation because it includes reviewed SQL, RLS, RPC framing, and
|
||||
transaction behaviour.
|
||||
- Placing it last lets the common protocol, CLI, and host boundaries stabilise before introducing that larger surface.
|
||||
- Native batching can reduce request count substantially relative to object-per-Chunk storage, but actual speed still
|
||||
depends on database, proxy, network, and workload measurements.
|
||||
- PostgREST and object stores share logical records and correctness rules without pretending to share a physical
|
||||
layout.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Architectural Decision Record: Introduce Adaptive Journal as an Explicit Protocol
|
||||
|
||||
## Status
|
||||
|
||||
Proposed for staged implementation. This decision does not change the current default Journal format or promise a
|
||||
compatible in-place migration.
|
||||
|
||||
## Context
|
||||
|
||||
The existing Journal protocol publishes PouchDB Metadata and Chunk documents together in opaque immutable objects.
|
||||
That representation is portable and remains the compatibility baseline, but it prevents a remote from answering a
|
||||
bounded multi-key Chunk query or amortising object-store requests independently from Metadata publication.
|
||||
|
||||
S3-compatible Object Storage, WebDAV, and PostgREST have materially different physical capabilities. Making each one
|
||||
implement a separate replication algorithm would duplicate ordering, encryption, recovery, and Chunk-delivery rules.
|
||||
Making every one use an identical physical layout would discard useful native batching and transactions.
|
||||
|
||||
The detailed binary formats, state machines, privacy properties, and recovery rules are specified in the
|
||||
[Adaptive Journal Sync design](../design_docs/adaptive_journal_sync.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Adaptive Journal is a second, explicit Journal protocol version owned by Commonlib. It keeps one logical repository
|
||||
contract while allowing provider-specific physical storage.
|
||||
|
||||
- Metadata events and raw Chunk records remain separate throughout publication.
|
||||
- Logical Chunks are immutable and content-addressable. A changed value has a new logical Chunk ID.
|
||||
- A repository-scoped Remote Chunk key is derived locally from the exact logical Chunk ID after accepting the
|
||||
repository manifest.
|
||||
- Chunks become durable before a commit makes Metadata references visible.
|
||||
- Ordinary publication creates immutable records. Catalogue snapshots, caches, and indexes are derived and
|
||||
reconstructible.
|
||||
- Each writer publishes a dense sequence identified by a stable host ID and a persisted random writer epoch. A reader
|
||||
tracks one frontier per writer stream; it does not depend on a remote `startAfter` ordering contract.
|
||||
- Remote operations return typed outcomes which distinguish absence, conflict, permanent rejection, retryable failure,
|
||||
and an ambiguous mutation which must be verified before retrying.
|
||||
|
||||
The adaptive repository exposes batched Chunk availability, publication, and retrieval even when its implementation
|
||||
uses immutable packs internally. The caller does not issue one remote request per Chunk.
|
||||
|
||||
### Repository identity and compatibility
|
||||
|
||||
A new repository has one immutable, conditionally created manifest. The first device generates its candidate locally,
|
||||
and the winning manifest fixes the repository ID, Security Seed, protocol parameters, and required capabilities.
|
||||
Every client pins the accepted repository ID in local repository state. A Setup URI exported from an accepted binding
|
||||
should carry that non-secret expected ID.
|
||||
|
||||
`opaque-v1` and `adaptive-v1` are separate remote formats. Format selection is explicit in the remote configuration,
|
||||
and a mismatch fails before publication. The implementation detects incompatible remote data, but it does not migrate
|
||||
it. Changing format requires an explicit remote rebuild or a new remote namespace.
|
||||
|
||||
Deprecated data formats do not enlarge the Adaptive protocol. Compatibility remains at the existing decoding
|
||||
boundaries, and rebuilding the remote is the recovery path for an unsupported Adaptive layout.
|
||||
|
||||
### Provider delivery sequence
|
||||
|
||||
Each provider is delivered and reviewed through four boundaries, in order:
|
||||
|
||||
1. **Adapter and Commonlib integration.** Implement semantic capabilities, typed failure handling, format detection,
|
||||
unit tests, and disposable real-service integration tests.
|
||||
2. **CLI end-to-end acceptance.** Use the built CLI and a real disposable service to apply a Setup URI, synchronise two
|
||||
independent local databases, and restore text and binary Chunk content. This proves the headless product boundary
|
||||
without Obsidian or Svelte.
|
||||
3. **Host settings and UI.** Add only provider-specific controls, validation, profile persistence, Setup URI transport,
|
||||
and focused host tests.
|
||||
4. **Real-host end-to-end acceptance.** Exercise one representative setup and synchronisation path in a real Obsidian
|
||||
instance. This test verifies host composition and does not repeat the adapter capability matrix or the complete CLI
|
||||
conflict suite.
|
||||
|
||||
The initial order is S3-compatible Object Storage, WebDAV, then PostgREST. A provider completes these boundaries before
|
||||
the next provider is presented for integration review. The sequence keeps each review independently attributable and
|
||||
keeps the real-host tests small.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### One cross-provider implementation change
|
||||
|
||||
This hides which provider requires a shared-core change, makes failures difficult to attribute, and forces reviewers to
|
||||
understand SQL RPCs, object packs, WebDAV behaviour, CLI composition, and Obsidian UI in one change.
|
||||
|
||||
### Add the Host UI before headless acceptance
|
||||
|
||||
This makes an application-level failure ambiguous between the protocol, adapter, CLI-independent host composition, and
|
||||
presentation. The CLI provides the smaller executable boundary first.
|
||||
|
||||
### Use one physical representation for every provider
|
||||
|
||||
One-object-per-Chunk storage causes excessive object requests, while forcing packs into PostgreSQL discards bounded
|
||||
multi-key RPCs and transactions. Common semantics do not require common physical storage.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Commonlib owns protocol correctness and provider adapters; Self-hosted LiveSync owns CLI composition, Obsidian host
|
||||
integration, and presentation.
|
||||
- Provider reviews can stop at the first failed boundary without involving later UI or real-host tests.
|
||||
- The final end-to-end suite remains an acceptance layer rather than a duplicate protocol test suite.
|
||||
- Adding another provider requires the same semantic contract and staged evidence, not another replication algorithm.
|
||||
- Remote rebuild remains an explicit operational requirement while Adaptive Journal is evolving.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Architectural Decision Record: Use S3 as the Reference Adaptive Object Store
|
||||
|
||||
## Status
|
||||
|
||||
Proposed as an improvement to the maintained S3-compatible Journal path. Adaptive Journal remains explicit and
|
||||
opt-in; the existing opaque format remains the compatibility default.
|
||||
|
||||
## Context
|
||||
|
||||
S3-compatible Object Storage already supplies the maintained Journal object model and is the smallest provider on
|
||||
which to prove Adaptive immutable packs. It has a standard conditional-create request, paginated listing, binary
|
||||
objects, and optional byte-range retrieval, but compatible endpoints can still differ in consistency, proxy behaviour,
|
||||
and Range handling.
|
||||
|
||||
The common protocol decision is recorded in
|
||||
[Adaptive Journal as an explicit protocol](2026_07_adaptive_journal_protocol.md). The complete object-pack and catalogue
|
||||
formats are specified in the [Adaptive Journal Sync design](../design_docs/adaptive_journal_sync.md).
|
||||
|
||||
## Decision
|
||||
|
||||
S3-compatible storage is the reference implementation of the Adaptive object-store strategy.
|
||||
|
||||
- It stores the manifest, writer control records, Metadata batches, commits, Chunk packs, indexes, and catalogue records
|
||||
as immutable objects under the configured bucket namespace.
|
||||
- Manifest and every immutable publication use conditional create. A successful create response is sufficient; the
|
||||
client does not add a confirmation request solely for caution.
|
||||
- A lost or ambiguous mutation response returns `verify-first`. The caller reads the exact key before retrying.
|
||||
- A conditional-request conflict which does not establish an existing immutable value remains retryable and is not
|
||||
reported as a successful create.
|
||||
- Listing follows every continuation token and proves complete prefix enumeration. Folder marker objects and stale
|
||||
capability-probe objects are not repository data.
|
||||
- Format inspection distinguishes empty, `opaque-v1`, `adaptive-v1`, and mixed repositories. Mixed or mismatched data
|
||||
fails closed, and reset remains an explicit batched operation.
|
||||
|
||||
The portable retrieval policy is `whole-pack`. A user may select `range` when the endpoint capability probe has
|
||||
confirmed exact byte-range behaviour. Range responses must use `206`, carry a matching `Content-Range`, and return the
|
||||
requested number of bytes. Loss of optional Range support falls back to whole-pack retrieval after reporting the
|
||||
capability change; it does not make valid packs unreadable.
|
||||
|
||||
Capability results which prove support or lack of support may be cached for the endpoint identity. A transient probe
|
||||
failure is not cached as a permanent result. Probe objects use a reserved random prefix and are removed when possible;
|
||||
incomplete cleanup is reported and ignored by format detection.
|
||||
|
||||
## Staged acceptance
|
||||
|
||||
### Adapter and Commonlib integration
|
||||
|
||||
Focused tests cover conditional creation, ambiguous responses, binary fidelity, read-after-write visibility, paginated
|
||||
listing, deletion visibility, Range validation, format detection, reset batching, and probe cleanup. A disposable
|
||||
MinIO integration proves two-client Adaptive Metadata and Chunk synchronisation and both retrieval paths.
|
||||
|
||||
### CLI end-to-end acceptance
|
||||
|
||||
One real-MinIO scenario uses two independent CLI databases. Device A uses whole-pack retrieval. Device B receives an
|
||||
Adaptive S3 Setup URI selecting Range retrieval. The scenario verifies that the URI preserves the format and policy,
|
||||
that both devices can publish and receive more than one synchronisation round, and that text and binary content are
|
||||
reconstructed from Chunks.
|
||||
|
||||
This test owns the built CLI, settings persistence, Setup URI decoding, and headless composition. It does not repeat
|
||||
every S3 error classification already owned by Commonlib.
|
||||
|
||||
### Host settings and UI
|
||||
|
||||
The Object Storage dialogue exposes `opaque-v1` and `adaptive-v1`, with whole-pack as the Adaptive default and Range as
|
||||
an explicit preference. It preserves the expected repository ID and read policy through saved profiles and Setup URI
|
||||
handling. Focused host tests own normalisation, validation, persistence, and warnings; they do not contact S3.
|
||||
|
||||
### Real-host end-to-end acceptance
|
||||
|
||||
One real-Obsidian workflow applies an Adaptive S3 configuration and proves a representative Chunk-backed file transfer
|
||||
against disposable MinIO. It verifies the Obsidian composition boundary only. The CLI suite remains the broader
|
||||
headless synchronisation acceptance test.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Re-upload a mutable pack when one Chunk changes
|
||||
|
||||
Packs are immutable publication units. A changed logical Chunk is added to a new pack, and a new catalogue delta makes
|
||||
that location discoverable. Existing packs are replaced only by a separately protected compaction process.
|
||||
|
||||
### Require Range for Adaptive S3
|
||||
|
||||
Whole-pack reads often provide better throughput and work on more endpoints. Range is a deployment preference, not a
|
||||
correctness requirement.
|
||||
|
||||
### Confirm every successful mutation with another request
|
||||
|
||||
This doubles request count in the ordinary success path. Exact-key verification is reserved for ambiguous outcomes and
|
||||
explicit diagnostics.
|
||||
|
||||
## Consequences
|
||||
|
||||
- S3 establishes the object-store contract before the more variable WebDAV implementation.
|
||||
- The two retrieval policies are exercised without duplicating the complete CLI scenario.
|
||||
- Existing opaque S3 repositories remain readable and never become Adaptive implicitly.
|
||||
- Endpoint capability evidence, rather than the S3-compatible label alone, controls optional behaviour.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Architectural Decision Record: Gate Adaptive WebDAV with an Endpoint Safety Check
|
||||
|
||||
## Status
|
||||
|
||||
Proposed as an experimental provider after the S3 Adaptive path has completed adapter, CLI, host, and real-host
|
||||
acceptance.
|
||||
|
||||
## Context
|
||||
|
||||
WebDAV exposes an object-shaped interface suitable for immutable Commit Bundles and Packs, but method support and
|
||||
conditional semantics vary across servers, gateways, reverse proxies, and authentication layers. A server can
|
||||
advertise WebDAV while failing binary fidelity, replacing an object despite `If-None-Match: *`, returning incomplete
|
||||
listings, or ignoring Range.
|
||||
|
||||
The common protocol decision is recorded in
|
||||
[Adaptive Journal as an explicit protocol](2026_07_adaptive_journal_protocol.md). The Pack background, flat object
|
||||
mapping, and detailed safety-check sequence are specified in the
|
||||
[Adaptive Journal Sync design](../design_docs/adaptive_journal_sync.md). Commonlib owns the implemented
|
||||
`commit-bundle-v1` wire contract.
|
||||
|
||||
## Decision
|
||||
|
||||
Adaptive WebDAV uses the same immutable Commit Bundle and external Pack semantics proven by S3, with a WebDAV-specific
|
||||
flat object mapping inside the configured collection. Its Catalogue is derived locally from authenticated routes in
|
||||
received Bundles rather than stored as a separate mutable remote object. Logical writer ordering comes from the host
|
||||
ID, writer epoch, and dense sequence embedded in authenticated records. Correctness does not depend on a
|
||||
server-provided `startAfter` order.
|
||||
|
||||
Before a new Adaptive repository becomes writable, a non-destructive endpoint safety checker exercises random reserved
|
||||
probe keys and reports observed semantic capabilities:
|
||||
|
||||
- binary write and exact read-back;
|
||||
- read-after-write visibility;
|
||||
- complete collection listing for the probe keys;
|
||||
- `If-None-Match: *` preventing replacement;
|
||||
- delete visibility; and
|
||||
- exact byte-range behaviour, reported separately as optional.
|
||||
|
||||
The checker is an implementation acceptance target for compatible servers, not a claim that every WebDAV server must
|
||||
support Adaptive Journal. It touches no repository objects, attempts to remove every probe, reports incomplete cleanup,
|
||||
and never interprets authentication, permission, timeout, malformed response, or server failure as absence.
|
||||
|
||||
Conditional create, binary fidelity, complete listing, read-after-write visibility, and delete visibility are required.
|
||||
Range remains optional. The user selects whole-pack or Range retrieval based on their endpoint and own latency and
|
||||
throughput preference; the checker does not benchmark or recommend a policy.
|
||||
|
||||
Successful mutations do not receive unconditional confirmation requests. An ambiguous response is classified as
|
||||
`verify-first`, and the exact immutable key is read before retrying. The existing opaque WebDAV layout and Adaptive
|
||||
layout are detected separately; a mismatch requires a remote rebuild or another namespace.
|
||||
|
||||
## Working hypotheses and illustrative estimates
|
||||
|
||||
The following values are planning assumptions for the first WebDAV experiment. They are not benchmark results, a
|
||||
provider-pricing statement, or a performance guarantee. The implementation must record the corresponding measurements
|
||||
before this ADR presents either Journal format as generally faster or smaller.
|
||||
|
||||
### Cost and lifecycle assumptions
|
||||
|
||||
The initial deployment model is a self-hosted or quota-priced WebDAV service without a direct per-request tariff.
|
||||
Request count still matters because every request consumes a round trip, server work, connection capacity, and a share
|
||||
of any rate limit. Commercial services, gateways, and managed hosting may use a different charging model, so the
|
||||
adapter must not infer cost policy from the WebDAV label.
|
||||
|
||||
Storage capacity is expected to be the more visible constraint. RFC 4331 defines optional
|
||||
[`DAV:quota-used-bytes` and `DAV:quota-available-bytes`](https://www.rfc-editor.org/rfc/rfc4331.html) properties for
|
||||
collections. When both are available, a connection diagnostic may report them as server-supplied evidence. Their
|
||||
absence means 'unknown', not 'unlimited', and quota reporting is not an Adaptive safety requirement.
|
||||
|
||||
The first experiment has no ordinary remote Garbage Collection or repacking guarantee. Commit Bundles and external
|
||||
Packs remain immutable, unreachable data may remain retained, and an interrupted publication may leave an unreferenced
|
||||
Pack. A remote Rebuild is therefore the only guaranteed way to reclaim all experimental history. This makes retained
|
||||
bytes and object count acceptance measurements rather than later operational details.
|
||||
|
||||
Adaptive and Opaque Journal consume the same local PouchDB Chunk split. A smaller average changed Chunk can reduce
|
||||
retained history in either format. Adaptive adds authenticated routes and independently framed records, while Opaque
|
||||
adds its own container, compression, and encryption overhead. No comparative storage saving is assumed before both
|
||||
representations are measured with the same changes.
|
||||
|
||||
### Ordinary request model
|
||||
|
||||
The object-profile model is shared with S3. Let:
|
||||
|
||||
- `B` be the number of Metadata batches published as distinct Commit Bundles;
|
||||
- `X` be the number of new external Pack objects needed by those batches;
|
||||
- `J` be the number of Opaque Journal objects produced for the same changes;
|
||||
- `W` be the number of visible Writer streams;
|
||||
- `K` be the number of those Writer descriptors not already cached by the opened process;
|
||||
- `P` be the number of additional complete Pack-container reads needed for missing Chunks; and
|
||||
- `M` be the number of missing Chunk frames fetched with Range.
|
||||
|
||||
After the endpoint safety result and repository binding have been cached for the active configuration, ordinary
|
||||
publication performs approximately `J` Opaque PUTs or `B + X` Adaptive PUTs. Every Adaptive batch creates one Bundle;
|
||||
each external Pack adds one preceding PUT. A confirmed conditional create does not add a read-back. An ambiguous PUT
|
||||
adds one exact-key GET before any retry and remains outside the successful-first-attempt estimate.
|
||||
|
||||
The generic object-store receive path performs approximately:
|
||||
|
||||
| Retrieval policy | Requests without WebDAV listing reuse |
|
||||
| --- | ---: |
|
||||
| `whole-pack` | `1 + W + K + B + P` |
|
||||
| `range` | `1 + W + K + B + M` |
|
||||
|
||||
The `1 + W` term represents one Writer listing and one Commit listing per Writer. A flat WebDAV collection normally
|
||||
cannot apply those logical prefixes on the server: each call may require another Depth-one `PROPFIND` over the same
|
||||
collection. The WebDAV target is therefore one complete listing at the start of a receive phase, filtered locally for
|
||||
every Writer and Commit prefix in that phase. This receive-phase listing must expire before the next receive phase; it
|
||||
must never become an unbounded cross-synchronisation cache. A Commit which becomes visible after the listing response
|
||||
is handled by the next receive phase, which is consistent with immutable eventual replication.
|
||||
|
||||
With that explicitly scoped reuse, the request estimates become `1 + K + B + P` and `1 + K + B + M`. For 100 new
|
||||
inline-Pack Bundles which require no additional Pack read, the difference is illustrative rather than a benchmark:
|
||||
|
||||
| Visible Writers | Descriptor state | Repeated-prefix listing | One receive-phase listing |
|
||||
| ---: | --- | ---: | ---: |
|
||||
| 1 | first receive (`K = 1`) | 103 | 102 |
|
||||
| 1 | warm process (`K = 0`) | 102 | 101 |
|
||||
| 10 | first receive (`K = 10`) | 121 | 111 |
|
||||
| 10 | warm process (`K = 0`) | 111 | 101 |
|
||||
|
||||
Opaque catch-up is approximately one listing plus one GET per new Journal object, or `1 + J`. Holding `B = J = 100`
|
||||
would therefore give 101 operations. That comparison does not assume that the two batchers produce equal object counts
|
||||
for a real editing history.
|
||||
|
||||
### Listing-volume hypothesis
|
||||
|
||||
Request count alone understates WebDAV discovery cost. Let `N` be the number of resources in the configured flat
|
||||
collection and `L` the average XML response bytes per resource for a minimal Depth-one `PROPFIND`. Repeating the
|
||||
listing for each logical prefix transfers roughly `(1 + W) * N * L`; one receive-phase listing transfers roughly
|
||||
`N * L`.
|
||||
|
||||
Assuming `L = 1 KiB` only to make the scale visible gives:
|
||||
|
||||
| Collection resources (`N`) | One listing | Repeated listing, `W = 1` | Repeated listing, `W = 10` |
|
||||
| ---: | ---: | ---: | ---: |
|
||||
| 1,000 | about 1 MiB | about 2 MiB | about 11 MiB |
|
||||
| 10,000 | about 10 MiB | about 20 MiB | about 107 MiB |
|
||||
| 100,000 | about 98 MiB | about 195 MiB | about 1.05 GiB |
|
||||
|
||||
Actual XML size, selected properties, URL length, compression, server implementation, and collection scope can change
|
||||
these values materially. The adapter should request only the properties it needs and measure both response bytes and
|
||||
wall time. The table explains the reuse target; it is not an estimate of every WebDAV server.
|
||||
|
||||
Ignoring cleaned probe resources and abandoned Packs, the current Commit Bundle layout contains approximately
|
||||
`1 + W + B + X` remote objects: one manifest, one descriptor per Writer, one object per Bundle, and one per external
|
||||
Pack. Object count therefore grows with synchronisation batches even when the retained payload is small, and it feeds
|
||||
back into subsequent `PROPFIND` cost.
|
||||
|
||||
### Retained-byte hypothesis
|
||||
|
||||
For a localised edit history, let `E` be the number of separately published changes and `D` the average bytes of newly
|
||||
created encoded Chunk frames per change. Before Garbage Collection or Rebuild, retained Chunk bytes grow approximately
|
||||
as `E * D`, plus Metadata, Commit, route, frame, server-metadata, and abandoned-publication overhead.
|
||||
|
||||
For a 10 MiB incompressible binary file whose content-defined split resumes after one changed Chunk:
|
||||
|
||||
| Published changes (`E`) | Average changed Chunk | Approximate new Chunk bytes retained | New Bundle objects when each Pack stays inline |
|
||||
| ---: | ---: | ---: | ---: |
|
||||
| 100 | 1 MiB | 100 MiB | 100 |
|
||||
| 1,000 | 1 MiB | about 0.98 GiB | 1,000 |
|
||||
| 100 | 256 KiB | 25 MiB | 100 |
|
||||
| 1,000 | 256 KiB | 250 MiB | 1,000 |
|
||||
|
||||
These figures exclude Metadata and protocol overhead and do not predict text splitting. They show why a 10 MiB live
|
||||
file can consume much more than 10 MiB remotely after many committed edits. They also show why reducing Chunk size is a
|
||||
storage-versus-record-count decision shared with Opaque Journal, not an Adaptive-only recommendation.
|
||||
|
||||
### Pack-target hypothesis
|
||||
|
||||
Changing the external Pack target does not normally duplicate Chunk frames: a Pack is their concatenation without
|
||||
capacity padding. Smaller targets chiefly increase object count, route data, PUTs, listing work, and later whole-Pack
|
||||
GETs. Larger targets increase peak buffer size, retry cost, transfer duration, and exposure to mobile watchdog or proxy
|
||||
timeouts.
|
||||
|
||||
For about 100 MiB of newly required encoded Chunk frames in one batch, the following serial-transfer model ignores RTT,
|
||||
TLS setup, encryption, server processing, contention, and retries:
|
||||
|
||||
| Pack target | External Packs (`X`) | PUTs including the Bundle | One target-sized upload at 5 / 10 Mbit/s |
|
||||
| ---: | ---: | ---: | ---: |
|
||||
| 8 MiB | 13 | 14 | about 13.4 / 6.7 seconds |
|
||||
| 16 MiB | 7 | 8 | about 26.8 / 13.4 seconds |
|
||||
| 32 MiB | 4 | 5 | about 53.7 / 26.8 seconds |
|
||||
| 64 MiB | 2 | 3 | about 107.4 / 53.7 seconds |
|
||||
|
||||
The total Chunk payload remains about 100 MiB in each row. The initial WebDAV experiment therefore retains the current
|
||||
32 MiB preferred target as a compromise, not as a universal optimum or a user-facing recommendation. Measurements on
|
||||
the disposable server must record peak memory, per-PUT duration, retry behaviour, total object count, and watchdog
|
||||
survival. A later decision may lower the internal target for timeout-constrained endpoints without changing the wire
|
||||
format.
|
||||
|
||||
## Staged acceptance
|
||||
|
||||
### Adapter and Commonlib integration
|
||||
|
||||
Unit tests own HTTP status classification, conditional-create verification, flat-name round trips, complete listing,
|
||||
probe isolation, cleanup reporting, Range validation, and whole-pack fallback. A disposable WebDAV integration runs
|
||||
the safety checker before exercising Adaptive Metadata and Chunk synchronisation. The integration also proves that one
|
||||
receive phase reuses a single collection listing, then invalidates it so the next phase can observe a new Commit.
|
||||
|
||||
### CLI end-to-end acceptance
|
||||
|
||||
The built CLI applies an Adaptive WebDAV Setup URI to a second independent database and synchronises text and binary
|
||||
Chunk-backed files through a real disposable server. One client uses whole-pack retrieval; Range is added to this layer
|
||||
only when the selected test server proves it. The CLI test does not repeat the complete endpoint capability matrix.
|
||||
|
||||
### Host settings and UI
|
||||
|
||||
The WebDAV dialogue exposes Adaptive mode only with clear capability-check results. It persists the expected repository
|
||||
ID and the selected retrieval policy, defaults to whole-pack, and reports that Range is optional. Focused tests own
|
||||
profile and Setup URI preservation without contacting a server.
|
||||
|
||||
### Real-host end-to-end acceptance
|
||||
|
||||
One real-Obsidian workflow uses the same known disposable WebDAV implementation, runs the safety gate, and proves a
|
||||
representative Chunk-backed transfer. Servers outside that fixture are diagnosed by the checker rather than added to a
|
||||
large real-host matrix.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Trust advertised WebDAV methods
|
||||
|
||||
Method advertisement does not prove the conditional, listing, visibility, and byte semantics required for immutable
|
||||
publication.
|
||||
|
||||
### Treat Range as mandatory
|
||||
|
||||
Whole-pack retrieval is correct and often competitive for throughput. Requiring Range would exclude otherwise safe
|
||||
servers for an optional optimisation.
|
||||
|
||||
### Add server-specific compatibility branches
|
||||
|
||||
The remote may be any implementation or proxy composition. Semantic checks produce a maintainable contract, while a
|
||||
growing server-name table would remain incomplete and become stale.
|
||||
|
||||
## Consequences
|
||||
|
||||
- WebDAV remains experimental because suitability is endpoint-specific.
|
||||
- The safety checker gives a concrete reason when a server cannot host Adaptive Journal.
|
||||
- Common object-pack behaviour is inherited from the earlier S3 boundary, keeping WebDAV-specific tests focused on HTTP
|
||||
semantics and name mapping.
|
||||
- Optional Range support can improve request efficiency without becoming a data-availability requirement.
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -301,6 +301,18 @@ 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
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"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: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",
|
||||
@@ -67,8 +69,12 @@
|
||||
"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-s3": "tsx test/e2e-obsidian/scripts/minio-upload.ts --adaptive",
|
||||
"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",
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 { ModuleReplicatorJournal } from "./modules/core/ModuleReplicatorJournal";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
@@ -139,7 +139,7 @@ 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 ModuleReplicatorJournal(this));
|
||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+27
-4
@@ -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;
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"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",
|
||||
"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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"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"
|
||||
},
|
||||
"imports": {
|
||||
"@std/assert": "jsr:@std/assert@^1.0.13",
|
||||
|
||||
@@ -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));
|
||||
@@ -40,7 +41,13 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
||||
}
|
||||
|
||||
export function formatTeeCommand(args: string[]): string {
|
||||
return ["node", CLI_DIST, ...args].map((part) => JSON.stringify(part)).join(" ");
|
||||
const redactArgument = (argument: string): string => {
|
||||
if (argument.startsWith(SETUP_URI_PREFIX)) {
|
||||
return `${SETUP_URI_PREFIX}<redacted>`;
|
||||
}
|
||||
return argument.replace(/^(sls\+[^:]+:\/\/)[^/?#@]*@/u, "$1<redacted>@");
|
||||
};
|
||||
return ["node", CLI_DIST, ...args.map(redactArgument)].map((part) => JSON.stringify(part)).join(" ");
|
||||
}
|
||||
|
||||
export function createLineTeeWriter(
|
||||
|
||||
@@ -330,6 +330,39 @@ const MINIO_CONTAINER = "minio-test";
|
||||
const MINIO_IMAGE = "minio/minio";
|
||||
const MINIO_MC_IMAGE = "minio/mc";
|
||||
|
||||
const WEBDAV_CONTAINER = "webdav-test";
|
||||
const WEBDAV_IMAGE = "httpd:2.4.68";
|
||||
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);
|
||||
untrackContainer(COUCHDB_CONTAINER);
|
||||
@@ -466,6 +499,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 +658,113 @@ 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();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P2P relay (strfry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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" | "WEBDAV";
|
||||
couchdbUri?: string;
|
||||
couchdbUser?: string;
|
||||
couchdbPassword?: string;
|
||||
@@ -121,10 +136,14 @@ export async function applyRemoteSyncSettings(
|
||||
minioEndpoint?: string;
|
||||
minioAccessKey?: string;
|
||||
minioSecretKey?: 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 +154,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 +162,18 @@ export async function applyRemoteSyncSettings(
|
||||
data.secretKey = options.minioSecretKey;
|
||||
data.region = "auto";
|
||||
data.forcePathStyle = true;
|
||||
} 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;
|
||||
|
||||
@@ -11,6 +11,8 @@ 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",
|
||||
] as const;
|
||||
|
||||
for (const [index, task] of TASKS.entries()) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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 { listMinioObjectKeys, readMinioObjectText, 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}`);
|
||||
|
||||
const objectKeys = await listMinioObjectKeys(endpoint, accessKey, secretKey, bucket);
|
||||
assert(objectKeys.includes("a1~manifest.json"), `Adaptive manifest is missing:\n${objectKeys.join("\n")}`);
|
||||
for (const prefix of ["a1~writer~", "a1~pack~", "a1~commit~"]) {
|
||||
assert(
|
||||
objectKeys.some((key) => key.startsWith(prefix)),
|
||||
`Adaptive object with prefix ${prefix} is missing:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
}
|
||||
const packKeys = objectKeys.filter((key) => key.startsWith("a1~pack~"));
|
||||
assert(packKeys.length >= 2, `Expected external Packs from both CLI writers:\n${objectKeys.join("\n")}`);
|
||||
for (const legacyPrefix of ["a1~index~", "a1~delta~", "a1~metadata~"]) {
|
||||
assert(
|
||||
!objectKeys.some((key) => key.startsWith(legacyPrefix)),
|
||||
`Legacy Adaptive object with prefix ${legacyPrefix} was written:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
await readMinioObjectText(endpoint, accessKey, secretKey, bucket, "a1~manifest.json")
|
||||
) as { objectLayout?: unknown };
|
||||
assertEquals(manifest.objectLayout, "commit-bundle-v1");
|
||||
assert(
|
||||
!objectKeys.some((key) => key.startsWith("a1~probe~")),
|
||||
`Adaptive capability probe objects were not removed:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
assert(
|
||||
!objectKeys.includes("_00000000-milestone.json"),
|
||||
`Legacy Journal milestone was written into the Adaptive repository:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
} finally {
|
||||
if (!keepDocker) {
|
||||
await stopMinio().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
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 { listWebDAVObjectKeys, readWebDAVObjectText, 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 collectionEndpoint = `${endpoint}/${prefix}`;
|
||||
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}`);
|
||||
|
||||
const objectKeys = await listWebDAVObjectKeys(collectionEndpoint);
|
||||
assert(objectKeys.includes("a1~manifest.json"), `Adaptive manifest is missing:\n${objectKeys.join("\n")}`);
|
||||
for (const objectPrefix of ["a1~writer~", "a1~pack~", "a1~commit~"]) {
|
||||
assert(
|
||||
objectKeys.some((key) => key.startsWith(objectPrefix)),
|
||||
`Adaptive object with prefix ${objectPrefix} is missing:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
}
|
||||
const packKeys = objectKeys.filter((key) => key.startsWith("a1~pack~"));
|
||||
assert(packKeys.length >= 2, `Expected external Packs from both CLI writers:\n${objectKeys.join("\n")}`);
|
||||
for (const legacyPrefix of ["a1~index~", "a1~delta~", "a1~metadata~"]) {
|
||||
assert(
|
||||
!objectKeys.some((key) => key.startsWith(legacyPrefix)),
|
||||
`Legacy Adaptive object with prefix ${legacyPrefix} was written:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
}
|
||||
const manifest = JSON.parse(await readWebDAVObjectText(collectionEndpoint, "a1~manifest.json")) as {
|
||||
objectLayout?: unknown;
|
||||
};
|
||||
assertEquals(manifest.objectLayout, "commit-bundle-v1");
|
||||
assert(
|
||||
!objectKeys.some((key) => key.startsWith("a1~probe~")),
|
||||
`Adaptive capability probe objects were not removed:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
assert(
|
||||
!objectKeys.includes("_00000000-milestone.json"),
|
||||
`Legacy Journal milestone was written into the Adaptive repository:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
} finally {
|
||||
if (shouldStartDocker && !keepDocker) {
|
||||
await stopWebDAV().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 > 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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: "index.html",
|
||||
check: "check.html",
|
||||
// uitest: "uitest.html",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isJournalRemoteType, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
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";
|
||||
@@ -9,7 +9,7 @@ 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 (isJournalRemoteType(settings.remoteType) || settings.remoteType == REMOTE_P2P) {
|
||||
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 (!isJournalRemoteType(this.settings.remoteType) && this.settings.remoteType != REMOTE_P2P) {
|
||||
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_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 = {
|
||||
@@ -86,4 +90,19 @@ describe("ModuleReplicatorCouchDB resume replication activity", () => {
|
||||
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
|
||||
expect(openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not claim or resume WebDAV as CouchDB", async () => {
|
||||
const { module, openReplication, runFiniteReplicationActivity } = createModule({
|
||||
liveSync: true,
|
||||
remoteType: REMOTE_WEBDAV,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
import { REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isJournalRemoteType, 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 {
|
||||
export class ModuleReplicatorJournal extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_MINIO) {
|
||||
if (isJournalRemoteType(settings.remoteType)) {
|
||||
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_WEBDAV } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const journalReplicatorConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
||||
LiveSyncJournalReplicator: class {
|
||||
constructor(env: unknown) {
|
||||
journalReplicatorConstructor(env);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { ModuleReplicatorJournal } from "./ModuleReplicatorJournal.ts";
|
||||
|
||||
function createModule(remoteType: string) {
|
||||
const services = {
|
||||
API: {
|
||||
addCommand: vi.fn(),
|
||||
addLog: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: { remoteType },
|
||||
} as any;
|
||||
return { core, module: new ModuleReplicatorJournal(core) };
|
||||
}
|
||||
|
||||
describe("ModuleReplicatorJournal", () => {
|
||||
it.each([REMOTE_MINIO, REMOTE_WEBDAV])("creates a Journal replicator for %s", async (remoteType) => {
|
||||
const { core, module } = createModule(remoteType);
|
||||
|
||||
await expect(module._anyNewReplicator()).resolves.not.toBe(false);
|
||||
expect(journalReplicatorConstructor).toHaveBeenLastCalledWith(core);
|
||||
});
|
||||
|
||||
it("does not claim a CouchDB remote", async () => {
|
||||
const { module } = createModule(REMOTE_COUCHDB);
|
||||
|
||||
await expect(module._anyNewReplicator()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,10 @@ function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): stri
|
||||
if (parsed.type === "s3") {
|
||||
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
|
||||
}
|
||||
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
|
||||
if (parsed.type === "p2p") {
|
||||
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
|
||||
}
|
||||
return "WebDAV Remote";
|
||||
}
|
||||
|
||||
export function paneRemoteConfig(
|
||||
|
||||
@@ -28,6 +28,9 @@ describe("syncActivatedRemoteSettings", () => {
|
||||
useCustomRequestHandler: false,
|
||||
forcePathStyle: true,
|
||||
bucketCustomHeaders: "",
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "adaptive-v1" as const,
|
||||
packReadPolicy: "range" as const,
|
||||
};
|
||||
|
||||
syncActivatedRemoteSettings(target, source);
|
||||
@@ -40,6 +43,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);
|
||||
});
|
||||
|
||||
|
||||
@@ -407,6 +407,9 @@ describe("SetupManager", () => {
|
||||
useCustomRequestHandler: false,
|
||||
bucketCustomHeaders: "",
|
||||
forcePathStyle: true,
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
@@ -419,6 +422,9 @@ 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("creates and selects a P2P profile during fresh manual onboarding", async () => {
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -114,7 +114,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 Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Opaque and Adaptive Object Storage uploads, the Object Storage Setup URI round-trip, the P2P Setup URI round-trip, startup scan, the 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: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 +147,14 @@ 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-s3` reuses that one-device upload workflow with the experimental Adaptive Journal format and Range retrieval selected explicitly. It requires real Obsidian to retain those settings, publish a Chunk-backed note through one-shot synchronisation, and produce an authenticated manifest, a writer record, and an immutable Commit Bundle under a disposable MinIO prefix without writing the Opaque Journal milestone. Commonlib tests own the storage protocol and failure classification, while the CLI E2E owns bidirectional synchronisation, external Packs, and both Pack retrieval policies; this scenario verifies only the Obsidian composition boundary.
|
||||
|
||||
`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.
|
||||
@@ -223,6 +227,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.
|
||||
|
||||
@@ -19,6 +19,9 @@ export type ConfiguredSettings = {
|
||||
endpoint?: string;
|
||||
bucket?: string;
|
||||
bucketPrefix?: string;
|
||||
expectedRepositoryId?: string;
|
||||
journalFormat?: string;
|
||||
packReadPolicy?: string;
|
||||
};
|
||||
|
||||
export type CoreReadiness = {
|
||||
@@ -320,6 +323,9 @@ export async function configureObjectStorage(
|
||||
"endpoint:current.endpoint,",
|
||||
"bucket:current.bucket,",
|
||||
"bucketPrefix:current.bucketPrefix,",
|
||||
"expectedRepositoryId:current.expectedRepositoryId,",
|
||||
"journalFormat:current.journalFormat,",
|
||||
"packReadPolicy:current.packReadPolicy,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
|
||||
@@ -30,6 +30,7 @@ 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 S3 upload", args: ["run", "test:e2e:obsidian:adaptive-s3"] },
|
||||
{
|
||||
name: "Object Storage Setup URI workflow",
|
||||
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
listObjectStorageObjects,
|
||||
loadObjectStorageConfig,
|
||||
makeUniqueBucketPrefix,
|
||||
readObjectStorageJson,
|
||||
} from "../runner/objectStorage.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
@@ -40,9 +41,21 @@ import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../r
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
|
||||
const notePath = "E2E/minio-upload.md";
|
||||
const adaptive = process.argv.includes("--adaptive");
|
||||
const unsupportedArguments = process.argv.slice(2).filter((argument) => argument !== "--adaptive");
|
||||
if (unsupportedArguments.length > 0) {
|
||||
throw new Error(`Unsupported Object Storage upload argument: ${unsupportedArguments.join(", ")}`);
|
||||
}
|
||||
const journalSettings = adaptive
|
||||
? {
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
}
|
||||
: {};
|
||||
const scenarioName = adaptive ? "Adaptive S3" : "Object Storage";
|
||||
const notePath = adaptive ? "E2E/adaptive-s3-upload.md" : "E2E/minio-upload.md";
|
||||
const noteContent = [
|
||||
"# Object Storage upload from real Obsidian",
|
||||
`# ${scenarioName} upload from real Obsidian`,
|
||||
"",
|
||||
"This note is created through Obsidian and uploaded by Self-hosted LiveSync to S3-compatible Object Storage.",
|
||||
"The test is intentionally small, but it crosses the real Obsidian, Journal Sync, and AWS SDK boundary.",
|
||||
@@ -78,7 +91,7 @@ async function createNoteAndWaitForLocalDb(cliBinary: string, env: NodeJS.Proces
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForObjectStorageObjects(prefix: string): Promise<string[]> {
|
||||
async function waitForObjectStorageObjects(prefix: string, requiredKeyPrefix?: string): Promise<string[]> {
|
||||
const objectStorage = await loadObjectStorageConfig();
|
||||
const timeoutMs = Number(process.env.E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS ?? 20000);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -86,12 +99,48 @@ async function waitForObjectStorageObjects(prefix: string): Promise<string[]> {
|
||||
while (Date.now() < deadline) {
|
||||
const objects = await listObjectStorageObjects(objectStorage, prefix);
|
||||
keys = objects.flatMap((object) => (object.Key ? [object.Key] : []));
|
||||
if (keys.length > 0) {
|
||||
if (keys.length > 0 && (!requiredKeyPrefix || keys.some((key) => key.startsWith(requiredKeyPrefix)))) {
|
||||
return keys;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Object Storage objects under ${prefix}. Last keys: ${keys.join(", ")}`);
|
||||
throw new Error(
|
||||
`Timed out waiting for Object Storage objects under ${prefix}${requiredKeyPrefix ? ` with prefix ${requiredKeyPrefix}` : ""}. Last keys: ${keys.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
async function assertAdaptiveObjects(prefix: string, keys: string[]): Promise<void> {
|
||||
const objectStorage = await loadObjectStorageConfig();
|
||||
const manifestKey = `${prefix}a1~manifest.json`;
|
||||
const requiredPrefixes = [`${prefix}a1~writer~`, `${prefix}a1~commit~`];
|
||||
if (!keys.includes(manifestKey)) {
|
||||
throw new Error(`Adaptive Journal manifest is missing. Keys: ${keys.join(", ")}`);
|
||||
}
|
||||
for (const requiredPrefix of requiredPrefixes) {
|
||||
if (!keys.some((key) => key.startsWith(requiredPrefix))) {
|
||||
throw new Error(`Adaptive Journal object prefix ${requiredPrefix} is missing. Keys: ${keys.join(", ")}`);
|
||||
}
|
||||
}
|
||||
if (keys.includes(`${prefix}_00000000-milestone.json`)) {
|
||||
throw new Error("Adaptive Journal wrote the legacy Opaque Journal milestone.");
|
||||
}
|
||||
|
||||
const manifest = await readObjectStorageJson<{
|
||||
format?: unknown;
|
||||
formatVersion?: unknown;
|
||||
manifestAuth?: unknown;
|
||||
objectLayout?: unknown;
|
||||
repositoryId?: unknown;
|
||||
}>(objectStorage, manifestKey);
|
||||
assertEqual(manifest.format, "adaptive-journal", "Unexpected Adaptive Journal manifest format.");
|
||||
assertEqual(manifest.formatVersion, 1, "Unexpected Adaptive Journal manifest version.");
|
||||
assertEqual(manifest.objectLayout, "commit-bundle-v1", "Unexpected Adaptive Journal object layout.");
|
||||
if (typeof manifest.repositoryId !== "string" || manifest.repositoryId.length === 0) {
|
||||
throw new Error("Adaptive Journal manifest did not contain a repository ID.");
|
||||
}
|
||||
if (typeof manifest.manifestAuth !== "string" || manifest.manifestAuth.length === 0) {
|
||||
throw new Error("Adaptive Journal manifest did not contain its authentication value.");
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -102,7 +151,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const objectStorage = await loadObjectStorageConfig();
|
||||
const bucketPrefix = makeUniqueBucketPrefix("minio-upload");
|
||||
const bucketPrefix = makeUniqueBucketPrefix(adaptive ? "adaptive-s3-upload" : "minio-upload");
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
@@ -119,18 +168,26 @@ async function main(): Promise<void> {
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: createE2eObjectStoragePluginData({
|
||||
...objectStorage,
|
||||
bucketPrefix,
|
||||
}),
|
||||
pluginData: createE2eObjectStoragePluginData(
|
||||
{
|
||||
...objectStorage,
|
||||
bucketPrefix,
|
||||
},
|
||||
journalSettings
|
||||
),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
|
||||
const configured = await configureObjectStorage(cli.binary, session.cliEnv, {
|
||||
...objectStorage,
|
||||
bucketPrefix,
|
||||
});
|
||||
const configured = await configureObjectStorage(
|
||||
cli.binary,
|
||||
session.cliEnv,
|
||||
{
|
||||
...objectStorage,
|
||||
bucketPrefix,
|
||||
},
|
||||
journalSettings
|
||||
);
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not marked as configured.");
|
||||
assertEqual(configured.remoteType, "MINIO", "Remote type was not Object Storage.");
|
||||
@@ -138,6 +195,11 @@ async function main(): Promise<void> {
|
||||
assertEqual(configured.bucket, objectStorage.bucket, "Configured Object Storage bucket did not match.");
|
||||
assertEqual(configured.bucketPrefix, bucketPrefix, "Configured Object Storage bucket prefix did not match.");
|
||||
assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow.");
|
||||
if (adaptive) {
|
||||
assertEqual(configured.journalFormat, "adaptive-v1", "Adaptive Journal format was not retained.");
|
||||
assertEqual(configured.packReadPolicy, "range", "Adaptive Journal Pack retrieval was not retained.");
|
||||
assertEqual(configured.expectedRepositoryId, "", "A new Adaptive repository should not be pre-bound.");
|
||||
}
|
||||
|
||||
await prepareRemote(cli.binary, session.cliEnv);
|
||||
const activityBeforeUpload = await waitForRemoteActivityState(
|
||||
@@ -159,10 +221,16 @@ async function main(): Promise<void> {
|
||||
"Object Storage remote-request counters did not rebalance after synchronisation."
|
||||
);
|
||||
|
||||
const keys = await waitForObjectStorageObjects(bucketPrefix);
|
||||
const keys = await waitForObjectStorageObjects(
|
||||
bucketPrefix,
|
||||
adaptive ? `${bucketPrefix}a1~commit~` : undefined
|
||||
);
|
||||
if (adaptive) {
|
||||
await assertAdaptiveObjects(bucketPrefix, keys);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}`
|
||||
`Uploaded ${localEntry.path} through ${scenarioName} Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}`
|
||||
);
|
||||
} finally {
|
||||
if (session) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const focusedScenarios = new Set([
|
||||
"couchdb-manual-setup-workflow",
|
||||
"cli-to-obsidian-sync",
|
||||
"minio-upload",
|
||||
"adaptive-s3",
|
||||
"object-storage-setup-uri-workflow",
|
||||
"p2p-setup-uri-workflow",
|
||||
"startup-scan",
|
||||
|
||||
+18
@@ -12,6 +12,24 @@ 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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user