Merge remote-tracking branch 'upstream/main' into feat/syncguard-bulk-sync-edit-guard

# Conflicts:
#	src/common/messages/LiveSyncProvisionalMessages.ts
#	styles.css
#	updates.md
This commit is contained in:
Artem Ovchinnikov committed 2026-10-27 15:28:22 +03:00
commit acd3b0ade6
157 files changed
+15374 -1051

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ on:
- 'src/**' - 'src/**'
- 'test/**' - 'test/**'
- '_tools/**' - '_tools/**'
- 'scripts/**'
- 'package.json' - 'package.json'
- 'package-lock.json' - 'package-lock.json'
- 'tsconfig.json' - 'tsconfig.json'
@@ -51,6 +52,7 @@ on:
- 'src/**' - 'src/**'
- 'test/**' - 'test/**'
- '_tools/**' - '_tools/**'
- 'scripts/**'
- 'package.json' - 'package.json'
- 'package-lock.json' - 'package-lock.json'
- 'tsconfig.json' - 'tsconfig.json'
+20
View File
@@ -84,6 +84,7 @@ Synchronisation status is shown in the status bar with the following icons.
- 🌐N Approximate remote requests currently in progress - 🌐N Approximate remote requests currently in progress
- Status - Status
- ⏹️ Stopped - ⏹️ Stopped
- ⏩ Comparing CouchDB change history, including entries already held locally
- 💤 LiveSync enabled. Waiting for changes - 💤 LiveSync enabled. Waiting for changes
- ⚡️ Synchronisation in progress - ⚡️ Synchronisation in progress
- ⚠ An error occurred - ⚠ An error occurred
@@ -104,6 +105,25 @@ Synchronisation status is shown in the status bar with the following icons.
To prevent file and database corruption, please avoid closing Obsidian until all progress indicators have disappeared as much as possible (although the plug-in will attempt to resume if interrupted). This is especially important if you have deleted or renamed files. To prevent file and database corruption, please avoid closing Obsidian until all progress indicators have disappeared as much as possible (although the plug-in will attempt to resume if interrupted). This is especially important if you have deleted or renamed files.
When CouchDB change history is being compared, `(⏩ 1200) ↑ 0 ↓ 0` can appear before any new documents arrive. The count is the number of candidate changes-feed entries compared during the current replication attempt, including repeated entries on retries. It is separate from downloaded Chunks and Metadata, and is neither a Vault file count nor a count of entries already present locally. It appears before the upload count, disappears after three seconds without a change, and reappears when the count changes. The ⏩ status icon follows comparison activity independently of that timer.
### Customising status items with CSS snippets
The status bar and editor overlay use individual spans with the common `livesync-stat-` class prefix. CSS snippets can hide or style the items we need. For example, this hides the comparison count and P2P summary:
```css
.livesync-stat-ff,
.livesync-stat-p2p {
display: none;
}
```
The item classes include `livesync-stat-network`, `livesync-stat-sync`, `livesync-stat-sent`, `livesync-stat-push-remaining`, `livesync-stat-arrived`, `livesync-stat-pull-remaining`, `livesync-stat-ff`, and `livesync-stat-p2p`. File-processing items use `livesync-stat-file-processing`, `livesync-stat-file-pending`, and `livesync-stat-file-batched`; queue items use `livesync-stat-queue-` followed by `replication`, `database`, `storage`, `chunk-initial`, `chunk-retrying`, `plugin-scan`, `hidden-files`, or `conflicts`. The skipped-file indicator uses `livesync-stat-file-skip`.
All items also have `livesync-stat-item`. Numeric children use `livesync-stat-value`, tabular digits, and right alignment. Each numeric item retains its widest displayed digit count during an active period, reducing movement as counts decrease. The editor overlay uses wider gaps between items, while remaining counts stay beside their transfer totals.
Inactive items receive `livesync-stat-inactive` instead of the HTML `hidden` attribute. The default CSS hides them immediately. They retain their last text, numeric width, and position amongst the other items so that CSS snippets can customise how they disappear. The numeric width starts afresh when an item becomes active again.
## Tips and Troubleshooting ## Tips and Troubleshooting
- If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md). - If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md).
- Configure [Hidden File Sync](docs/tips/hidden-file-sync.md) only after ordinary note synchronisation works. - Configure [Hidden File Sync](docs/tips/hidden-file-sync.md) only after ordinary note synchronisation works.
+18 -3
View File
@@ -166,6 +166,14 @@ Legacy Modules remain grouped by directory:
- `features/` contains optional features; and - `features/` contains optional features; and
- `extras/` contains development and testing tools. - `extras/` contains development and testing tools.
### UI commands and dialogue ownership
Declare shared UI requests with `declareUICommand<Request, Result>` from `@vrtmrz/livesync-commonlib/ui-commands`. The host's `services.UI.commands` registry owns one handler per declaration. A serviceFeature registers the handler, releases it on unload, and owns the associated dialogue lifetime. Callers receive a typed result or an explicit `unavailable` result; handler failures propagate.
`OPEN_JOINED_DEVICES` is shared by Remote Configuration, version Notices, and the command palette. `useJoinedDevicesFeature` registers it once and gives the dialogue narrow refresh, forget, and cancellation callbacks. Each remote operation uses a settings snapshot and releases its finite resource in `finally`. Results from an old remote configuration or a closed dialogue are discarded. A write already sent to the captured remote can still complete after the dialogue closes.
The registry belongs to one UIService instance. Reuse the same declaration object at each call site, and keep active UI cleanup in the feature and dialogue owner. `UIService` disposes the registry when the host's context emits the plug-in-unloaded event.
### Key Architectural Components ### Key Architectural Components
- **LiveSyncLocalDB** (`@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB`): Local PouchDB database wrapper - **LiveSyncLocalDB** (`@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB`): Local PouchDB database wrapper
@@ -204,6 +212,9 @@ The [multiple-device conflict test procedure](test/README.md#multiple-device-con
### File Structure Conventions ### File Structure Conventions
- **Class modules**: Start a new or renamed TypeScript or JavaScript filename with an upper-case letter when the file contains a class with the same name.
- **Function modules**: Start the filename with a lower-case letter when the module primarily exports functions, such as `initialFetchFastForward.ts`. Follow the surrounding filename style for the rest of the basename. Corresponding test files use the same basename.
- **Existing files**: Retain established names for unrelated files and separate conventions for components or other file types.
- **Platform-specific code**: Use `.platform.ts` suffix (replaced with `.obsidian.ts` in production builds via esbuild) - **Platform-specific code**: Use `.platform.ts` suffix (replaced with `.obsidian.ts` in production builds via esbuild)
- **Development code**: Use `.dev.ts` suffix (replaced with `.prod.ts` in production) - **Development code**: Use `.dev.ts` suffix (replaced with `.prod.ts` in production)
- **Path aliases**: `@/*` maps to `src/*`; Commonlib uses package exports rather than a source alias - **Path aliases**: `@/*` maps to `src/*`; Commonlib uses package exports rather than a source alias
@@ -309,6 +320,8 @@ Existing legacy Modules continue to register their handlers in `onBindFunction()
database and Replicator readiness. See database and Replicator readiness. See
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md). [the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly - Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
- Inject `@vrtmrz/value-store` at host composition. `setting.deviceValues` uses exact device-local keys; `setting.config` and the Service Hub `config` share the existing Vault and database-suffix scope. Await reads, writes, and deletions before advancing the workflow. Browser and Obsidian hosts retain their Web Storage keys, and the CLI retains its existing flat JSON string map.
- Seed the settings tab with `await settingTab.loadSettings()` before registration. Its editing buffer remains synchronous for Obsidian rendering; storage failures must not replace a previously loaded value with an empty default.
### Database Operations ### Database Operations
@@ -340,6 +353,7 @@ This policy governs plug-in pre-releases and their accompanying CLI images. For
- Add only user-facing changes that help users understand what they gain, what has changed, or what they may need to do after updating. - Add only user-facing changes that help users understand what they gain, what has changed, or what they may need to do after updating.
- Avoid listing purely internal refactors, maintenance chores, generated-file changes, and dependency updates unless they affect users; group and label them when they are included. - Avoid listing purely internal refactors, maintenance chores, generated-file changes, and dependency updates unless they affect users; group and label them when they are included.
- When preparing a release, replace `## Unreleased` with the target version heading (for example, `## 0.25.81`) and add a fresh empty `## Unreleased` section above it for the next cycle. - When preparing a release, replace `## Unreleased` with the target version heading (for example, `## 0.25.81`) and add a fresh empty `## Unreleased` section above it for the next cycle.
- For a stable release, review the complete user-facing changes since the previous stable release. Classify new features and changed behaviour relative to that stable release. Include changes from intervening pre-releases which never became stable, even when their original notes have already moved into a release history.
- Review and polish the released section in the release PR before tagging, because the content is embedded into the plug-in and may be reused as the GitHub Release notes. - Review and polish the released section in the release PR before tagging, because the content is embedded into the plug-in and may be reused as the GitHub Release notes.
- Keep approximately the five most recent published plug-in versions in the embedded `updates.md`. Move older published sections unchanged into the appropriate release-line archive under `docs/releases/`, and update the history references when rotating them. - Keep approximately the five most recent published plug-in versions in the embedded `updates.md`. Move older published sections unchanged into the appropriate release-line archive under `docs/releases/`, and update the history references when rotating them.
@@ -359,8 +373,9 @@ For a fix needed only by the CLI, use [CLI-only patch releases](#cli-only-patch-
- Validate installation or update through BRAT, and cold-start Obsidian to confirm the plug-in loads. Verify ordinary bidirectional synchronisation and relevant regression scenarios in real Obsidian against the published artefacts, or a build with matching hashes. When automated scenarios establish that coverage, BRAT acceptance need only cover installation or update and start-up; manually check any remaining release-specific behaviour. Record the artefact hashes and validation evidence as described in the [real Obsidian test guide](test/e2e-obsidian/README.md#combined-setup-and-security-regression-checks). - Validate installation or update through BRAT, and cold-start Obsidian to confirm the plug-in loads. Verify ordinary bidirectional synchronisation and relevant regression scenarios in real Obsidian against the published artefacts, or a build with matching hashes. When automated scenarios establish that coverage, BRAT acceptance need only cover installation or update and start-up; manually check any remaining release-specific behaviour. Record the artefact hashes and validation evidence as described in the [real Obsidian test guide](test/e2e-obsidian/README.md#combined-setup-and-security-regression-checks).
- 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 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, 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. - 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. - If BRAT validation fails, keep the release PR in draft and leave its published tag and artefacts unchanged. Prepare the correction on a new branch descended from that exact release commit, retaining its existing changes and `versions.json` entry. Prepare the next patch or pre-release version on that branch, with its own version, tag, and release PR. The selected base branch remains unchanged until a successor stable release passes validation and is promoted.
- 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. - For the successor stable release, consolidate all user-facing changes since the last stable release, including changes from intervening pre-releases which never became stable. Preserve each published pre-release's exact tagged notes in the appropriate history under `docs/releases/`, and compare the archived section with `git show <tag>:updates.md`. The new release notes must describe the whole stable upgrade, including the correction.
- After the successor stable release is validated and promoted, merge its exact release commit and ancestry through the reviewed branch chain. Leave the failed release PR in 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`. - A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`.
### Release Cheat Sheet ### Release Cheat Sheet
@@ -390,7 +405,7 @@ For a fix needed only by the CLI, use [CLI-only patch releases](#cli-only-patch-
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario. 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. 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, 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. 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. 12. If validation fails, leave the PR in draft and keep its published tag and artefacts unchanged. Start a new branch from that release commit, add the correction, and prepare the next patch or pre-release version. Retain the published version's `versions.json` entry and exact archived notes, and consolidate the successor stable release notes from the last stable release. Keep the base branch unchanged until the successor stable release is validated and promoted.
### CLI-only patch releases ### CLI-only patch releases
@@ -0,0 +1,438 @@
---
date: 2026-10-24
commonlib-version: "0.2.0"
self-hosted-livesync-version: "1.0.40"
status: accepted-unreleased
---
# Architectural Decision Record: Local File Save Completion and Batching
## Status
Accepted on 2026-10-25. The package versions above identify the investigated
baseline, rather than a released implementation of this decision.
## Context
[Issue #1207](https://github.com/vrtmrz/obsidian-livesync/issues/1207) includes
conflicts around automated note creation, editing, and renaming. Batching local
file updates can reduce the intermediate revisions which another device receives.
It also extends the period during which the Vault contains an edit which the
local database has not saved. Recovery and incoming-file protection must remain
correct throughout that period.
Two-device Obsidian E2E tests using Self-hosted LiveSync 1.0.40 and Commonlib
0.2.0 found that a test-only removal of the LiveSync mode restriction reduced
six consecutive edits from six additional revisions to one, in both directions.
Incoming identical content produced no extra conflict, while genuinely different
edits or a deletion preserved the necessary branches. A synthetic
create–edit–rename sequence still delivered intermediate content. These tests
used Vault operations, without Templater or Modal Forms themselves.
Restart tests exposed a separate recovery gap:
1. The event manager removes an event from its waiting map when its timer is
released. It also removes a buffered event before dispatching it. Its saved
snapshot covers buffered and waiting events, but omits work in progress.
2. `waitForIdle()` waits for the waiting gates to open, rather than for the file
handlers to finish. `ServiceFileAccessBase` reports that pending events have
been committed after this gate-only wait. Some handler failures also settle
without propagating a failure to that caller.
3. If the process stops before the database write, the file can retain the new
content while the database retains the old content and the saved event
snapshot is empty.
4. At start-up, the Offline Scanner's `EVEN` branch tries to record missing
provenance, then reports completion. It ignores that helper's Boolean result.
Different content can consequently remain unsaved when both modification
times fall in the same two-second comparison bucket.
The last condition was reproduced by pausing the target operation immediately
before database processing, then restarting the same isolated profile. A
different timestamp bucket recovered the edit; the same bucket did not. The
changed-condition success demonstrates the boundary, rather than a fix. This
does not establish the cause of the reporter's historical conflicts.
The runtime evidence covers Linux aarch64, Obsidian 1.12.7, and CouchDB 3.5.0.
Windows, mobile lifecycles, and the reporter's full automation remain outside
that evidence. The inspected Commonlib source is commit
`aa54282937c20d3db7f43778d240feeb1586cced`.
## Decision
Make local file processing recoverable until its result is established, retain
strict content and provenance reconciliation for manual and recovery scans, and
enable batching in LiveSync mode only through an explicit choice after those
contracts pass verification. Ordinary Obsidian start-up may skip a body read
only under the exact-match conditions below. Apply the recovery contract with
batching enabled or disabled.
The content-reconciliation change targets selected ordinary files in the
Offline Scanner's `NEWER_WINS` / `sync-newer` action. Keep the existing action
matrix, explicit `DB_APPLY` policy, selection and size limits, conflict guards,
and recovery suspensions. Hidden and internal files retain their owning
feature's recovery contract; an ordinary file scan cannot recover those paths
on its behalf.
### 1. Distinguish local persistence from replication
The following boundaries have different meanings:
| Boundary | Meaning |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Batch wait released | The file operation may start. Its content can still be absent from the local database. |
| Local processing completed | The intended change is represented durably by the local database and its required content, or has been proved already represented or safely superseded. |
| Replication completed | A finite replication reports its own outcome. A local save alone does not prove remote receipt or reflection into another Vault. |
Ordinary file saving by Obsidian remains independent of database saving. Pending
and processing indicators must include work in progress and unresolved failures,
so an empty timer map cannot imply that local processing is complete.
### Optional file ownership and completion
Hidden File Sync and Customisation Sync register the new
`processOptionalFileEventWithResult` contract together. `firstHandled` tries
providers in order until one returns `completed` or `veto`; only
`not-applicable` advances to the next provider. Exceptions propagate unchanged.
`HandlerOutcome<TValue, TVetoReason>` gives completion data and refusal reasons
independent types. These saves use `void` completion data and string reasons.
A provider returns `completed` after its database operation succeeds or the
current state is already represented. Failed writes and deletions throw, and
the manager retains the event. Hidden File Sync acknowledges its processed
fingerprint after successful processing. Customisation Sync waits for the real
save after its existing 100ms quiet period. All notifications coalesced into a
V1 group receive that save's result; V2 files retain separate save groups.
A finite flush resolves each captured event's `whenFlushRequested` signal. This
releases its customisation delay and freezes that group; later groups do not
extend its completion. During start-up replay or suspension, providers use
physical database readiness rather than waiting for application readiness or
resume. Hidden File Sync loads its existing per-database caches before replay
and reuses them during the following initialisation hook.
The existing boolean optional API remains a legacy notification contract for
other callers and older hosts. Once a host registers typed providers, the
manager dispatches through that chain exclusively. Without a typed provider,
a resolved legacy notification completes that dispatch only; its boolean does
not establish a database save. Snapshot-write failures remain visible under
both contracts. No persistent journal, new snapshot schema, or automatic retry
loop is introduced.
### 2. Keep a recoverable record through processing
The event manager owns each operation from queue admission through completion.
Its existing persistent snapshot must represent buffered, waiting, and
in-progress work in an order which preserves deletion and rename dependencies.
Moving work between those states must not create a persistent gap.
An event can leave the recoverable set only after its effect is durable, current
state proves that the effect is already represented, or a durably recorded
successor safely subsumes it. Finishing an older operation must not remove a
newer operation for the same path. Cancellation of a batch timer alone is not
evidence that its content is safe to forget.
Snapshot writes must preserve mutation order. An older asynchronous write must
not replace a newer snapshot, and a failed snapshot write must remain visible
to the lifecycle caller. Reuse the existing device-local snapshot store; a
second journal and a transaction spanning the file system and database are not
part of this proposal.
The first implementation should retain the existing event snapshot format,
including event keys and rename intent, and add pending ownership within the
event manager. The persistence adapter remains responsible for storing the
ordered snapshots. Handler outcomes must distinguish a failed attempt from a
policy-approved skip before the manager can acknowledge either safely.
A crash after the database write but before snapshot removal can replay an
operation. Replay must check current storage and revision provenance, retain
the existing protection against stale restored events, and never delete a
newer same-path file. Once both the database write and provenance write have
completed, replay must not add another revision. If the database write
succeeded before provenance was recorded, replay may first create an identical
sibling even when no remote descendant has arrived. In the tested case
without a remote edit, replay creates that sibling. Duplicate handling then
tombstones one of the identical branches and converges to one live leaf with
the exact saved content.
Another restart must add no revision. Replay must remain safe to interrupt;
existing saved snapshots remain readable.
The database write and the device-local provenance write are separate,
sequential operations. A stop between them can leave the previous provenance
record in place. This pre-provenance gap is the known exception to the
no-extra-revision result after both writes complete: replay can first create a
duplicate sibling; one of the identical branches is then tombstoned while
one live leaf retains the saved bytes when no remote descendant has arrived. If continuous
replication advances the local database during the gap, replay can instead
expose the local content as a sibling of that descendant. The [conflict
resolution specification](../specs_conflict_resolution.md#known-potentially-problematic-behaviour)
records the required mergeable and manual-resolution outcomes. The per-file
lock does not fence replication writes. The interruption window is expected
to be very rare, but its field frequency is unmeasured; this decision adds no
transaction or second journal to close the interval.
Boolean failure, an exception, and an unavailable comparison are not successful
completion. Keep unresolved work recoverable and report the failure through the
existing service and diagnostic boundaries. Explicit exclusions or a verified
superseding change may finish work without a new revision; a generic handler
failure may not. Recovery may continue with independent paths, while retaining
the failed path and its dependencies. This proposal adds no unbounded retry loop.
The recoverable snapshot is an operation record, not a backup of every transient
editor buffer. File content already saved by the host remains available for
start-up reconciliation if the process stops before the first snapshot write.
Crash guarantees cannot include bytes or events which the host never persisted
or delivered, or repair arbitrary storage failure.
### 3. Give flush a finite, truthful completion boundary
An explicit commit of pending file events captures the work accepted before
that request, releases its batch delays, and waits for the actual processing
results, including buffered and already-running work. Later edits cannot extend
that boundary indefinitely. A newer coalesced operation may satisfy earlier work
only when its successful result covers that work.
The internal ordering barrier before a delete or rename waits only for its
predecessors. It must not wait for itself or later events in the same dispatch
loop. Replacing every gate wait with a wait for global idleness would risk a
dependency cycle or starvation.
Manual synchronisation and replication readiness use the completed local
boundary. Settings application must observe its result before reporting success
or resuming work which depends on it. Persisting a settings file is a separate
operation: `saveSettingData()` returning is not a file-processing completion
signal, and making every settings-saved notification synchronous is unnecessary.
When a host permits orderly shutdown, stop admitting watcher work before
draining accepted operations or preserving their pending records. Keep their
storage available until that work settles. Suspension, forced termination, and
Obsidian's synchronous unload callback cannot guarantee that asynchronous work
finishes; correctness must also follow from the persistent record and start-up
reconciliation. Remote availability must not determine whether local pending
work survives shutdown.
### 4. Preserve strict content reconciliation
Strict content checking retains the timestamp and provenance reconciliation
contract for platforms with coarse modification times. Timestamp equality,
including exactly equal raw timestamps, does not prove content equality. With
strict checking enabled, the Offline Scanner must establish one of the
following outcomes through the ordinary non-forced file handler and its
provenance rules:
| Evidence | Required outcome |
| ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Vault content already matches an eligible current revision | Record or retain valid provenance as appropriate; create no duplicate revision. |
| Vault content matches its recorded revision or saved content evidence, while the current database revision has advanced | Apply the database state through the existing protected reflection path. Do not resurrect the unchanged older content as an edit. |
| Vault content differs from its recorded revision | Preserve the edit on that recorded branch. Keep independent concurrent branches available for conflict resolution. |
| Vault content has no provable origin and matches no current live leaf | Preserve it according to the existing independent-branch rules. Do not attach it to an arbitrary database winner. |
| Required content or provenance cannot be read, or state changes during comparison | Retain unresolved work and report or re-evaluate it. Do not mark it reconciled merely because the timestamp comparison is `EVEN`. |
The `strictContentCheck` scan option is strict when omitted. Ordinary Obsidian
start-up can opt out through **Strict content check on startup**, which is
disabled by default. With the option disabled, a body read may be skipped only
when the file's raw modification time, byte size, and valid device-local
provenance exactly match a conflict-free current revision and no file
processing is pending. All other files retain strict content reconciliation.
An unrecorded edit that preserves its exact size and modification time can
therefore remain undetected by ordinary start-up while the option is disabled.
Manual scans, Fetch, Rebuild, and other explicit recovery operations remain
strict. Pending-event replay, incoming-change protection, and deletion guards
remain active in both modes.
The table describes stable-state strict reconciliation, not atomic database and
provenance persistence. Replay in the pre-provenance gap may create an
identical sibling; with no remote descendant, one of the identical branches
is tombstoned, leaving one live leaf with the exact saved content. If replication has
already added a descendant, preserve its exact content through an existing
safe merge, or retain both branches for manual resolution. See [Known
potentially problematic behaviour](../specs_conflict_resolution.md#known-potentially-problematic-behaviour).
Revalidate the relevant file and database state before acting on a comparison.
Reuse the existing content and provenance checks; avoid a separate conflict
algorithm in the scanner. Metadata continues to reference Chunks containing the
file content.
`tryRecordUntrackedFileRevision()` answers whether it could safely record
provenance. Its `false` also covers an already-populated record, conflicting
history, unavailable content, and a concurrent change. It is not an 'edited
file' predicate. A fallback which treats every `false` as permission for a
forced save would violate the required outcomes.
Propagate the file handler's actual outcome into the pair result, including for
storage-to-database writes. Preserve the scanner's `completed`, `skipped`, and
`failed` distinctions and its existing `continueOnFileFailure` policy. Ordinary
Obsidian start-up can report `completed-with-file-failures` and allow unaffected
files to proceed; an unresolved pair must not become `completed` to achieve
that result. Keep the stricter callers' existing completion requirements.
Keep the original unchanged-file, edited-file, untracked-provenance,
incoming-protection, and concurrent-recheck fixtures under strict content
checking, including identical timestamps and distinct raw timestamps within
one comparison bucket. Do not weaken those strict-mode assertions to describe
the ordinary start-up opt-out. Test the opt-out separately: only the exact
stat, provenance, conflict-free, and no-pending-processing match may skip a
body read. Pending-event replay and incoming or deletion guards remain covered
with the opt-out enabled.
### 5. Make batch timing and activation explicit
Batching is a bounded delay before local database processing. The quiet-period
timer follows the latest eligible content update; the maximum intentional delay
starts with the first uncommitted update and is not reset by later edits or time
spent awaiting a processing slot. Once due, work must become eligible to run.
The maximum delay is not a guarantee of database completion, network delivery,
or another device's reflection time. Waiting timers should not consume the
capacity needed to process unrelated ready files.
For ordinary local content updates, queue the changed path and operation intent
without reading the file body merely to wait. Acquire the current content when
the operation becomes due, after coalescing eligible events. Reuse that content
snapshot within the operation for comparison, provenance evidence, and Chunk
creation. This retains the current ordinary-save boundary; it does not require
reading and hashing the file for every keystroke or queued notification.
Content reuse within one operation is distinct from keeping a body cache across
edits. A cached body would need a proven invalidation and freshness contract;
mtime and size alone cannot establish that it is current. A saved provenance
hash records evidence about earlier content, rather than replacing acquisition
of the current bytes. Restart recovery, an incoming update, or a comparison
which needs revalidation can still require additional reads. Preserve those
checks rather than making a global promise of one read per path.
Batching therefore aims to reduce repeated content acquisition, comparison,
Chunk processing, and local database updates, as well as intermediate revisions
and subsequent replication and reflection work. Event filtering, bookkeeping,
and pending-operation persistence still occur while waiting. The revision
reduction measured by the existing E2E does not quantify file reads, CPU cost,
physical disk I/O, or network traffic; measure those costs separately. Host or
operating-system caching also means that a read API call is not a physical disk
read count.
Manual flush and required deletion or rename ordering can end a batch early.
Ordinary incoming replication continues, using the existing protection for
unsaved local content. It must not require all unrelated local batches to flush
for every incoming document.
Add an independent `batchSaveInLiveSync` Boolean, defaulting to `false`
for both existing settings and new Vaults. In LiveSync mode, this value selects
batching; other modes continue to use `batchSave`. Both choices use the existing
minimum and maximum delay settings. The interface presents the choice for the
current mode. An ordinary mode switch preserves each mode's choice.
This adds one persisted setting to distinguish a new explicit choice from old
`liveSync: true, batchSave: true` configurations, where batching was ignored.
Neither upgrade nor import of that legacy pair may activate batching. An older
client continues to apply its existing LiveSync restriction. Include the new
property in Commonlib's setting policy and import/export tests; do not infer
consent from the presence of the old `batchSave` property.
The setting and scheduling changes follow the recovery work. Recovery in the
currently supported modes must pass verification before the new mode is enabled.
### 6. Keep batching separate from automation completion
The intended benefit is fewer short-lived intermediate revisions. Batching
cannot discover when a third-party template has finished, particularly while a
dialogue is open or another device also runs automation. A maximum delay or a
rename boundary can legitimately publish intermediate content.
Coalescing `CREATE` followed by `CHANGED` for the same still-existing file is a
useful follow-up, but it needs its own ordering and interruption tests. Preserve
the existing cross-path rename rule: store the destination before deleting the
proven source branch. Do not retarget or discard pending source operations
without proving the effect is already covered.
A guarantee that a multi-step template appears remotely only after completion
would need an explicit producer completion contract and its failure behaviour.
That is a separate design. Enabling batching alone must not be described as
eliminating genuine concurrent-edit conflicts or as a confirmed fix for #1207.
## Ownership and implementation order
1. **Commonlib recovery:** event ownership, ordered snapshot persistence,
successful completion and failure propagation, finite flush boundaries, and
same-time content reconciliation. Add focused failing regressions before
changing production code. Reuse the existing event manager, file handler,
scanner, and provenance store.
2. **Host integration:** keep the Obsidian persistence adapter and lifecycle
wiring aligned with those contracts. Verify callers of
`commitPendingFileEvents`, including settings application and database
preparation, and preserve their failure outcomes. Validate the packed
Commonlib artefact and update the consumer's exact dependency.
3. **LiveSync mode batching:** add the explicit setting and its primary-language
presentation only after the recovery regressions pass. Establish the stated
timing semantics and repeat the two-device comparisons with the actual
package, including start-up with batching already enabled.
4. **Further coalescing:** assess same-path creation/editing and any proposed
rename optimisation separately, against the original automation problem.
Keep these changes independently reviewable. Shared behaviour belongs in
Commonlib; the plug-in must not acquire a source mirror or patch installed
package files. Snapshot format changes, a second persistent store, or a new
automation transaction API require a further design decision.
## Verification required
Use controlled promises at the manager and persistence boundaries for focused
regressions, and real PouchDB tests for content and ancestry. The acceptance
matrix is:
| Scenario | Required observation |
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Stop after dequeue, timer release, or during a database write | Pending work remains recoverable; restart eventually represents the saved Vault edit. |
| Database and provenance writes are complete before replay; restart twice | Replay adds no extra revision. |
| Stop after the database write but before provenance; no remote descendant; replay twice | The tested replay creates an identical sibling and tombstones one of the two branches; one live leaf retains exact saved content, and the second restart adds no revision. |
| A remote descendant arrives during the pre-provenance gap | The mergeable case preserves its exact content and ancestry; the overlapping case retains both bodies for manual resolution. |
| Old and new events share a path; snapshot writes overlap | Completing old work cannot remove new work or regress the durable snapshot. |
| Handler returns false, throws, or snapshot persistence fails | Flush reports failure; pending work is retained; unrelated recoverable work can continue. |
| Buffered or in-progress work exists with no waiting timer | Flush still waits for actual completion. |
| A flush overlaps new typing or a rename/delete barrier | The captured work completes without waiting for later work or deadlocking. |
| Strict check: equal timestamps, unchanged or edited files, untracked or pruned provenance, unavailable Chunks, or concurrent recheck changes | Preserve correct ancestry and bytes, avoid duplicate revisions, and distinguish unresolved comparisons from successful reconciliation. |
| Start-up check disabled: exact raw time, size, and provenance match with no conflict or pending processing | The body read may be skipped; an unrecorded edit which preserves exact size and modification time remains an accepted limitation. |
| Pending-event replay and incoming or deletion guards, with either start-up setting | Keep replay and protection active; do not skip work or remove a file based only on matching statistics. |
| Settings save followed by stop; ordinary unload; interruption during replay | No operation is forgotten because a settings callback or shutdown released its timer. |
| Sustained edits and more paths than available processing slots | Observe the intentional-delay deadline and progress of unrelated ready files. |
| Old settings, mode changes, explicit opt-in, and settings import | Existing LiveSync mode users retain immediate saving until they choose batching. |
| Incoming identical content, an edit, a deletion, or delete/recreate while waiting | Preserve genuine branches without extra conflicts or loss of local work. |
Keep the existing E2E comparison between batching disabled, an ignored legacy
setting, and explicitly enabled batching. Repeat both directions, manual
synchronisation, saved-snapshot restart, and controlled in-progress restart with
the fix. Record the original failure separately from repaired results; a pass
with a different timestamp bucket or a test-only bundle is not verification of
the production fix. Run platform-specific lifecycle checks before extending the
evidence to Windows or mobile.
## Alternatives and consequences
- **Remove only the LiveSync guard:** leaves recovery gaps and can activate a
previously ignored setting. The explicit choice follows correctness work.
- **Wait only at unload:** cannot cover abrupt termination and hosts which do
not await asynchronous unload. Continuous recoverability is still required.
- **Repair only the scanner:** improves surviving-file recovery, but does not
establish event ordering, deletion intent, or truthful flush completion.
- **Retain only in-progress events:** closes a persistence gap, but still needs
success/failure handling, replay checks, and recovery before the first snapshot.
- **Remove timestamp rounding or force the Vault copy into the current winner:**
fails for exactly equal timestamps and can destroy branch intent. Content and
provenance determine the safe action.
Keeping work recoverable adds snapshot bookkeeping and can make local flush
wait longer because it now waits for real processing. Same-time reconciliation
can require additional content reads. Batching trades prompt remote visibility
for fewer database updates and may increase the window for genuine concurrent
edits. Measure these costs without weakening the content-preservation contract.
## References
- [Conflict resolution and revision provenance](../specs_conflict_resolution.md)
- [Batch database update settings](../settings.md#batch-database-update)
- [Common library package boundary](2026_07_common_library_package_boundary.md)
- [Fast Fetch persistence and completion](2026_08_fast_fetch_persistence_and_completion.md)
- [Two-device Batch save reproduction](../../test/e2e-obsidian/scripts/issue1207-livesync-batch.ts)
- [Commonlib event manager at the investigated revision](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/src/managers/StorageEventManager.ts)
- [Commonlib Offline Scanner at the investigated revision](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/src/serviceFeatures/offlineScanner.ts)
- [Commonlib file handler at the investigated revision](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/src/serviceModules/ServiceFileHandlerBase.ts)
- [Commonlib scanner policy and stored event interpretation](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/docs/offline-scanner.md)
- [Commonlib event admission and reflection contract](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/docs/storage-events-and-reflection.md)
- [Existing equal-time provenance regression](https://github.com/vrtmrz/livesync-commonlib/blob/aa54282937c20d3db7f43778d240feeb1586cced/src/serviceFeatures/offlineScanner.provenance.unit.spec.ts)
+4
View File
@@ -98,6 +98,10 @@ recovery guidance, or diagnostics intended for users.
- **ID recovery code:** A versioned text form of the saved ID key which can be - **ID recovery code:** A versioned text form of the saved ID key which can be
shown on the current device and imported without deriving a different key. shown on the current device and imported without deriving a different key.
Treat it as a secret. Treat it as a secret.
- **Joined devices:** The dialogue showing device records held by the active
CouchDB or Object Storage remote, with the current device first. Records
describe participation, not current online activity. **Forget device** removes
an obsolete device record; it does not delete Vault files or revoke credentials.
- **JWT Authentication:** An experimental CouchDB authentication option which - **JWT Authentication:** An experimental CouchDB authentication option which
uses a JSON Web Token instead of standard credentials. It requires a private uses a JSON Web Token instead of standard credentials. It requires a private
key or secret, algorithm, expiry duration, subject, and key ID. key or secret, algorithm, expiry duration, subject, and key ID.
+62 -1
View File
@@ -1,9 +1,70 @@
# 1.0 preview release history # 1.0 preview release history
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](1.0.md#100). This document records opt-in pre-release builds in the 1.0 release line. Consolidated notes for stable upgrades are available in the [current change log](../../updates.md) and the [1.0 release history](1.0.md).
The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted. The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted.
Version 1.0.41 was published for validation and remains a GitHub pre-release. Its changes and the subsequent start-up correction are consolidated in the [1.0.42 release notes](../../updates.md#1042).
## 1.0.41
26th October, 2026
### Synchronisation
#### New Feature
- Rapid edits can produce fewer intermediate versions on other devices while LiveSync mode remains active.
- Enable **Batch database updates in LiveSync mode** to group successive local edits.
- Disabled by default; each synchronisation mode keeps its own choice.
- Updates reach other devices later. Batching does not detect template completion or prevent genuine concurrent-edit conflicts.
- CouchDB synchronisation no longer looks idle while it is comparing change history.
- A ⏩ icon and comparison count show activity before documents are transferred.
- This count represents history entries, not files remaining to synchronise.
- Status counts are easier to follow, with less movement as values change.
- CSS snippets can hide unwanted items or style individual `livesync-stat-` items, including inactive ones.
#### Fixed
- Recent Vault edits remain recoverable after an interrupted local database save.
- This includes edits whose modification times compare equal to the database copy, and pending Hidden File Sync and Customisation Sync changes.
- Manual synchronisation no longer starts before already accepted local changes finish saving to the database.
- Settings application also waits for those saves. Failed saves stay pending and report a failure.
- Text from third-party tools is handled more robustly.
- Characters split across Chunks no longer trigger false corruption warnings.
- Start-up integrity checks use less memory for healthy Vault files. (#1283)
- Start-up checks avoid false repair warnings for files that change during inspection. (#1283)
- This applies when the final size matches the database and there are no conflicts.
### Customisation Sync
#### Fixed
- Applying a multi-file customisation no longer skips files after the list is refreshed. (#1271)
- Choosing **Automatic** now explains a missing Hidden File Sync prerequisite instead of proceeding into a failed initialisation. (#1270)
- Enable and initialise Hidden File Sync on this device first. The current mode is kept when the prerequisite is missing.
- Initialising a whole plug-in in **Automatic** mode no longer omits its selected **MAIN** or **DATA** files.
### Setup tools
#### Fixed
- Setup URI generation can avoid shell-variable collisions and unintended use of the operating-system username. (#817, #1274)
- Use `LIVESYNC_HOST`, `LIVESYNC_USER`, `LIVESYNC_PASS`, `LIVESYNC_DB`, and `LIVESYNC_PASSPHRASE` for connection and encryption values.
- Existing scripts remain supported: the previous names are used when the corresponding prefixed value is unset or blank.
### Miscellaneous
- Dependencies have been updated, including security fixes.
### Acknowledgements
Thank you for your contributions!
- @rwbru, for the original Setup URI environment-variable fix ([#817](https://github.com/vrtmrz/obsidian-livesync/pull/817)).
- @junkerderprovinz, for adapting that fix to the current generator while preserving compatibility with existing scripts ([#1274](https://github.com/vrtmrz/obsidian-livesync/pull/1274)).
- @s-qin, for reducing memory use during start-up checks and avoiding repeated content decoding in Commonlib ([#1283](https://github.com/vrtmrz/obsidian-livesync/pull/1283), [Commonlib #184](https://github.com/vrtmrz/livesync-commonlib/pull/184)).
## 1.0.0-rc.1 ## 1.0.0-rc.1
27th July, 2026 27th July, 2026
+24
View File
@@ -2,6 +2,30 @@
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md). This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
## 1.0.35
5th October, 2026
### Synchronisation
#### Fixed
- We can continue synchronising after rebuilding an encrypted CouchDB remote, including when Hidden File Sync and Customisation Sync are enabled. (#1234)
- This plug-in reads the rebuilt data without reusing HTTP responses encrypted with the previous Security Seed.
### Customisation Sync
#### Fixed
- Customisation Sync now waits until this plug-in is ready before handling configuration file events, and applying settings while synchronisation is suspended no longer scans configuration files. (#1220)
- Resuming synchronisation scans once and restarts the periodic scan when enabled.
### Acknowledgements
Thank you for your contributions!
- @kimjansheden (#1220)
## 1.0.34 ## 1.0.34
2nd October, 2026 2nd October, 2026
+60 -8
View File
@@ -248,6 +248,16 @@ Setting key: remoteType
The active connection type. This is automatically projected to the legacy configuration when you activate a connection profile. The active connection type. This is automatically projected to the legacy configuration when you activate a connection profile.
### Joined devices
Choose **Review devices** under **Joined devices** to read the device records on the active CouchDB or Object Storage connection. The same **Review devices** action is available from the command palette and version Notices. **Refresh** reads the remote again. This device appears first, including when its record is absent from the remote. Opening or refreshing this dialogue does not create a missing CouchDB database.
The list shows each device ID, recorded platform, plug-in version, app version, and last recorded connection time. Older clients may have incomplete information. These records do not establish whether a device is online. On a locked remote, a device outside the accepted list is shown as **Locked out**.
Before synchronisation starts, and when this dialogue refreshes, Self-hosted LiveSync checks for recorded plug-in versions newer or older than its own. These Notices recommend reviewing and aligning plug-in versions across devices; they are advisory and do not stop synchronisation. The dialogue explains that synchronisation between different versions is generally safe unless the connection is explicitly blocked, but may be less efficient or use more storage. P2P synchronisation may also be unavailable. Unknown development version labels are left unclassified. Automatic checks run at most once per minute when synchronisation starts; they do not poll a continuously running connection.
**Forget device** removes another device's participation records after confirmation. It preserves the remote lock state and every other device. It does not delete Vault files or revoke credentials. A device can register again on an unlocked remote; on a locked remote, the existing recovery process applies. The result is checked after saving to CouchDB; if it cannot be confirmed, refresh the list and try again. CouchDB stores these records together in a local document which does not reject simultaneous updates, so changes from other devices can be overwritten. Pausing synchronisation on the other devices avoids that overlap. Object Storage uses conditional updates; if the server cannot support them, the record is retained.
### 2. Notification ### 2. Notification
#### Notify when the estimated remote storage size exceeds on start up #### Notify when the estimated remote storage size exceeds on start up
@@ -673,17 +683,28 @@ Setup URIs preserve both sleep preferences. Older Setup URIs which do not contai
#### Batch database update #### Batch database update
Setting key: batchSave Setting key: batchSave
Reducing the frequency with which on-disk changes are reflected into the DB
Combine repeated local file updates before saving them to the local database in modes other than LiveSync mode. This reduces intermediate database revisions and delays their availability to other devices.
#### Batch database updates in LiveSync mode
Setting key: batchSaveInLiveSync
Apply the same bounded delay in LiveSync mode. This separate choice is disabled by default for both existing settings and new Vaults. An older configuration containing `liveSync: true` and `batchSave: true` does not enable it. The settings interface shows the choice for the current mode, and switching modes preserves both choices. Older clients continue to ignore batching in LiveSync mode.
Both choices share the delays below. Manual synchronisation, deletion, or renaming can end a batch early. Batching reduces intermediate updates; it does not identify when a template has finished or prevent genuine concurrent edits on different devices.
#### Minimum delay for batch database updating #### Minimum delay for batch database updating
Setting key: batchSaveMinimumDelay Setting key: batchSaveMinimumDelay
Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.
Seconds of quiet after the latest eligible edit before saving to the local database, subject to the maximum delay below.
#### Maximum delay for batch database updating #### Maximum delay for batch database updating
Setting key: batchSaveMaximumDelay Setting key: batchSaveMaximumDelay
Saving will be performed forcefully after this number of seconds.
Maximum intentional delay in seconds from the first pending update to a file. Later edits do not restart this limit, and waiting for a processing slot does not add another batch delay. This limit makes the work eligible to run; it does not guarantee database completion or receipt by another device within that time.
### 4. Deletion Propagation (Advanced) ### 4. Deletion Propagation (Advanced)
@@ -764,11 +785,13 @@ If enabled, the notification of hidden files change will be suppressed.
#### Synchronising files #### Synchronising files
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files. Setting key: syncOnlyRegEx
(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.
#### Non-Synchronising files #### Non-Synchronising files
(RegExp) If this is set, any changes to local and remote files that match this will be skipped. Setting key: syncIgnoreRegEx
(RegExp) Skip changes to matching normal and hidden files, locally and remotely.
#### Maximum file size #### Maximum file size
@@ -797,6 +820,8 @@ When a saved setting changes whether a normal file can be reflected, LiveSync re
Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for snippets, configuration files, and plug-in files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files. Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for snippets, configuration files, and plug-in files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files.
**Automatic** delegates an item's files to Hidden File Sync. Enable and initialise Hidden File Sync in **Sync Settings** on this device first, after reviewing its [target and ignore patterns](./tips/hidden-file-sync.md#review-the-file-selection). Choosing Automatic while Hidden File Sync is disabled displays a Notice and leaves the current mode unchanged. Existing Automatic assignments, including those imported from another device, remain saved while Hidden File Sync is disabled.
### 1. Customisation Sync ### 1. Customisation Sync
#### Device name #### Device name
@@ -950,6 +975,18 @@ Setting key: hashCacheMaxAmount
Setting key: customChunkSize Setting key: customChunkSize
New setup uses 0 for every remote type. With V3 Rabin–Karp, this gives binary
Chunks a 100 KiB maximum, a 25 KiB minimum, and an approximate 25 KiB candidate
gap. The candidate gap is not the mean Chunk length. Text below 4 MiB retains
adaptive smaller boundaries; large text uses the binary thresholds with
UTF-8-safe cuts.
Larger values can reduce document counts for mostly immutable binary files,
but can increase the new content stored after an edit. Existing explicit and
agreed values are retained. Updating the plug-in does not re-chunk the Vault;
editing, forced writes, Chunk recreation, or a full Rebuild can create new
boundaries while existing content remains readable.
#### Chunk Splitter #### Chunk Splitter
Setting key: chunkSplitterVersion Setting key: chunkSplitterVersion
@@ -966,12 +1003,21 @@ If enabled, chunks will be split into no more than 100 items. However, dedupe is
Setting key: useSegmenter Setting key: useSegmenter
If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature. If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.
#### Strict content check on startup
Setting key: strictContentCheckOnStartup
When enabled, ordinary start-up compares file content with the local database even when the file's exact modification time and size match. When disabled (the default), this comparison can be skipped only when the modification time, size, and recorded device-local provenance match a conflict-free current revision, and no file processing is pending. An edit made outside the normal file-event path which preserves its exact modification time and size can therefore go undetected by ordinary start-up. Manual scans and explicit Fetch or Rebuild operations keep strict content checking.
### 3. Transfer Tweak ### 3. Transfer Tweak
#### Fetch chunks on demand #### Fetch chunks on demand
Setting key: readChunksOnline Setting key: readChunksOnline
(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended. If enabled, this plug-in obtains missing Chunks when they are needed instead
of including all Chunks in ordinary pull replication. New setup retains the
same Chunk-size recommendation of 0. This setting does not re-chunk content
already stored by another device.
#### Batch size of on-demand fetching #### Batch size of on-demand fetching
@@ -1024,12 +1070,18 @@ The setting remains disabled by default because the measured storage and transfe
#### Batch size #### Batch size
Setting key: batch_size Setting key: batch_size
Number of changes to sync at a time. Defaults to 50. Minimum is 2. Number of changes in an ordinary CouchDB replication batch. The schema default
is 25, and the minimum is 2. Changing this value alone preserves the
replication checkpoint. Larger counts can reduce round trips, but do not bound
the final request size or memory use.
#### Batch limit #### Batch limit
Setting key: batches_limit Setting key: batches_limit
Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time. Number of changes batches which PouchDB can read ahead. The schema default is
25, and the minimum is 2. This does not create parallel body transfers;
PouchDB processes one current replication batch at a time. It does not control
the separate P2P or Object Storage transfer units.
#### Use timeouts instead of heartbeats #### Use timeouts instead of heartbeats
+1 -1
View File
@@ -24,7 +24,7 @@ export secret_key=<SECRET KEY>
export bucket=vault-data export bucket=vault-data
export region=auto export region=auto
export bucket_prefix=my-vault export bucket_prefix=my-vault
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE> export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE> export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
``` ```
+10 -7
View File
@@ -128,6 +128,9 @@ export database=obsidiannotes
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash
``` ```
> [!TIP]
> In fish, `hostname` is read-only and cannot be exported. Pass the values through `env` instead: `curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | env hostname=http://localhost:5984 username=<...> password=<...> database=obsidiannotes bash`.
If it results like the following: If it results like the following:
``` ```
CouchDB provisioning completed. CouchDB provisioning completed.
@@ -173,20 +176,20 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv
### 1. Generate the setup URI on a desktop device or server ### 1. Generate the setup URI on a desktop device or server
The `username` and `password` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account. The `LIVESYNC_USER` and `LIVESYNC_PASS` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account.
```bash ```bash
export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com export LIVESYNC_HOST=https://tiles-photograph-routine-groundwater.trycloudflare.com
export database=obsidiannotes export LIVESYNC_DB=obsidiannotes
export username=<INSERT COUCHDB USERNAME FOR LIVESYNC> export LIVESYNC_USER=<INSERT COUCHDB USERNAME FOR LIVESYNC>
export password=<INSERT THE COUCHDB PASSWORD> export LIVESYNC_PASS=<INSERT THE COUCHDB PASSWORD>
export passphrase=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE> export LIVESYNC_PASSPHRASE=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
export uri_passphrase=<INSERT A SEPARATE SETUP URI PASSPHRASE> # Optional export uri_passphrase=<INSERT A SEPARATE SETUP URI PASSPHRASE> # Optional
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
``` ```
> [!TIP] > [!TIP]
> `passphrase` protects the synchronised Vault data with end-to-end encryption. `uri_passphrase` protects only the Setup URI. Use different values, store both securely, and do not send the Setup URI and its passphrase through the same channel. > `LIVESYNC_PASSPHRASE` protects the synchronised Vault data with end-to-end encryption. `uri_passphrase` protects only the Setup URI. Use different values, store both securely, and do not send the Setup URI and its passphrase through the same channel.
> >
> If `uri_passphrase` is omitted, the generator creates a cryptographically random value and prints it once. > If `uri_passphrase` is omitted, the generator creates a cryptographically random value and prints it once.
+1 -1
View File
@@ -130,7 +130,7 @@ export remote_type=p2p
export p2p_relays=wss://relay.example.com export p2p_relays=wss://relay.example.com
export p2p_room_id=<A PRIVATE ROOM ID> # Optional; generated when omitted export p2p_room_id=<A PRIVATE ROOM ID> # Optional; generated when omitted
export p2p_passphrase=<A PRIVATE P2P PASSPHRASE> # Optional; generated when omitted export p2p_passphrase=<A PRIVATE P2P PASSPHRASE> # Optional; generated when omitted
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE> export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE> export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
``` ```
+43
View File
@@ -166,6 +166,49 @@ For ordinary saves and incoming reflection, a missing or unreadable recorded bas
Provenance keys follow the selected [Unicode path identity](design_docs/unicode_path_identity.md#state-which-follows-the-selected-identity). In NFC mode, equivalent path spellings address the same record; the content evidence continues to describe file bytes, independently of that path spelling. Provenance keys follow the selected [Unicode path identity](design_docs/unicode_path_identity.md#state-which-follows-the-selected-identity). In NFC mode, equivalent path spellings address the same record; the content evidence continues to describe file bytes, independently of that path spelling.
## Known potentially problematic behaviour
Saving a local file writes its revision to the local database, then writes
device-local provenance in a separate operation. The writes are not atomic.
If the process stops between them, provenance can still identify the revision
which preceded the local edit. Continuous replication which was already
running can add a remote descendant to the local database during this
interval. The per-file lock covers file handling and Vault reflection; it
does not block raw replication writes to the database.
For example, local content `2-B` may have been saved as a child of
displayed revision `1`, while provenance still identifies `1`.
On restart, replay of the pending save can treat `2-B` as an edit of
`1` and write sibling `2-D`. The tested case demonstrates that this
pre-provenance gap alone can produce the duplicate. Without a remote
descendant, replay creates this sibling; duplicate handling tombstones one
of the identical branches and converges to one live leaf whose exact content
matches the saved local file. A second restart must add no revision.
If replication has already added descendant `3-C` during the gap, replay
can expose an additional branch. When the revisions have a complete shared
base and their text changes can be combined unambiguously, sensible merge can retain the
exact content of the incoming descendant. In the tested fixture, merge
produced revision `4` as a child of `3-C`; its file bytes were exactly
those of `3-C`, `3-C` remained in its ancestry, and sibling
`2-D` was tombstoned. The acceptance condition is retaining
`3-C`'s exact content; it need not remain the current leaf. Both Vaults
converged to those bytes. This observed outcome is limited to that mergeable
fixture and is not a universal no-data-loss guarantee.
When changes cannot be combined safely, as in the tested competing whole-line
replacements, sensible merge does not combine the bodies. Both versions remain available for the existing
manual conflict dialogue, even when sensible merge is enabled.
An interruption in the database/provenance interval is expected to be very
rare, especially when combined with a remote descendant arriving in that
interval; the field frequency is unmeasured. Start-up replays restored work,
scans, and commits pending local file work before marking the plug-in ready;
ordinary replication proceeds after that ready boundary. This does not claim
remote synchronisation before start-up readiness. The accepted design adds no
cross-store transaction, second journal, or new persistent state to close the
interval.
## Operations while a conflict exists ## Operations while a conflict exists
- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision. - Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
+10
View File
@@ -32,6 +32,10 @@ Enabling Hidden File Sync requires an initialisation direction:
Prefer a narrow target list. Device-specific workspace state and another plug-in's credentials are usually poor candidates for cross-device synchronisation. Prefer a narrow target list. Device-specific workspace state and another plug-in's credentials are usually poor candidates for cross-device synchronisation.
`Normal Files` → `Synchronising files` limits ordinary files only. Hidden File Sync uses its own `Target patterns`. The `Non-Synchronising files` patterns under `Normal Files` also exclude matching hidden files, so review them alongside the hidden-file selectors.
If an earlier version skipped hidden files because of the normal-file allow-list, repeat Hidden File Sync initialisation after updating. A routine scan can skip unchanged files which were already checked. Back up the Vault and choose the intended source direction as described above.
Target patterns also control directory traversal. A pattern must therefore match each parent directory as well as the intended files. For example, the following pattern admits the `.obsidian` parent and only its `snippets` subtree: Target patterns also control directory traversal. A pattern must therefore match each parent directory as well as the intended files. For example, the following pattern admits the `.obsidian` parent and only its `snippets` subtree:
```text ```text
@@ -68,6 +72,12 @@ Hidden File Sync must be enabled independently after ordinary setup on each devi
If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately. If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately.
## Automatic in Customisation Sync
**Automatic** delegates an item's files to Hidden File Sync. Enable Hidden File Sync on this device through the steps above before choosing Automatic. If Hidden File Sync is disabled, the choice displays a Notice without changing the item's mode or starting initialisation. Previously saved Automatic assignments remain saved, so this prerequisite also applies after importing settings from another device.
Once Hidden File Sync is enabled, choose the item's initial action in Customisation Sync. Selecting a whole plug-in applies that action to both its **MAIN** and **DATA** selections.
## Related settings ## Related settings
The [settings reference](../settings.md#7-hidden-files-advanced) describes periodic scanning, scanning before replication, notification suppression, selectors, and overwrite patterns. Change those controls only after the basic Hidden File Sync path is working. The [settings reference](../settings.md#7-hidden-files-advanced) describes periodic scanning, scanning before replication, notification suppression, selectors, and overwrite patterns. Change those controls only after the basic Hidden File Sync path is working.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "1.0.39", "version": "1.0.42",
"minAppVersion": "1.7.2", "minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz", "author": "vorotamoroz",
+204 -233
View File
@@ -1,12 +1,12 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.39", "version": "1.0.42",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.39", "version": "1.0.42",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"src/apps/cli", "src/apps/cli",
@@ -23,12 +23,13 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.44", "@vrtmrz/livesync-commonlib": "0.2.2",
"@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"@vrtmrz/value-store": "0.1.0",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"idb": "^8.0.3", "idb": "^8.0.3",
"markdown-it": "^14.3.2", "markdown-it": "^14.3.3",
"minimatch": "^10.2.5", "minimatch": "^10.2.5",
"obsidian": "^1.13.1", "obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.55", "octagonal-wheels": "^0.1.55",
@@ -54,7 +55,7 @@
"@types/pouchdb-replication": "^6.4.7", "@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6", "@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.69.0", "@typescript-eslint/parser": "8.69.0",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.12",
"@vrtmrz/obsidian-test-session": "0.2.6", "@vrtmrz/obsidian-test-session": "0.2.6",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"esbuild": "0.28.1", "esbuild": "0.28.1",
@@ -89,8 +90,8 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.69.0", "typescript-eslint": "8.69.0",
"vite": "^8.0.16", "vite": "^8.1.6",
"vitest": "^4.1.11", "vitest": "^4.1.12",
"yaml": "^2.8.2" "yaml": "^2.8.2"
} }
}, },
@@ -1944,22 +1945,25 @@
} }
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz",
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.2" "@tybys/wasm-util": "^0.10.3"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/Brooooooklyn" "url": "https://github.com/sponsors/Brooooooklyn"
}, },
"peerDependencies": { "peerDependencies": {
"@emnapi/core": "^1.7.1", "@emnapi/core": "^1.7.1 || 2.0.0-beta.2",
"@emnapi/runtime": "^1.7.1" "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2"
} }
}, },
"node_modules/@noble/curves": { "node_modules/@noble/curves": {
@@ -2011,9 +2015,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.133.0", "version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -2221,9 +2225,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2238,9 +2242,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2255,9 +2259,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2272,9 +2276,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2289,9 +2293,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2306,13 +2310,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2323,13 +2330,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2340,13 +2350,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2357,13 +2370,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2374,13 +2390,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2391,13 +2410,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2408,9 +2430,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2425,9 +2447,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@@ -2435,52 +2457,18 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "1.10.0", "@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.10.0", "@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.4" "@napi-rs/wasm-runtime": "^1.1.6"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2495,9 +2483,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3346,9 +3334,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.2", "version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -3962,14 +3950,14 @@
} }
}, },
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bcoe/v8-coverage": "^1.0.2", "@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.11", "@vitest/utils": "4.1.12",
"ast-v8-to-istanbul": "^1.0.0", "ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2", "istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1", "istanbul-lib-report": "^3.0.1",
@@ -3983,8 +3971,8 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"@vitest/browser": "4.1.11", "@vitest/browser": "4.1.12",
"vitest": "4.1.11" "vitest": "4.1.12"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@vitest/browser": { "@vitest/browser": {
@@ -3993,16 +3981,16 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11", "@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.11", "@vitest/utils": "4.1.12",
"chai": "^6.2.2", "chai": "^6.2.2",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@@ -4011,13 +3999,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "4.1.11", "@vitest/spy": "4.1.12",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.21" "magic-string": "^0.30.21"
}, },
@@ -4038,9 +4026,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4051,13 +4039,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "4.1.11", "@vitest/utils": "4.1.12",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@@ -4065,14 +4053,14 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.11", "@vitest/pretty-format": "4.1.12",
"@vitest/utils": "4.1.11", "@vitest/utils": "4.1.12",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -4081,9 +4069,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -4091,13 +4079,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.11", "@vitest/pretty-format": "4.1.12",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@@ -4115,9 +4103,9 @@
} }
}, },
"node_modules/@vrtmrz/livesync-commonlib": { "node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.44", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.44.tgz", "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.2.2.tgz",
"integrity": "sha512-GjXNSLTnWLZzdgrhW3sIOsLqGLP4iMgypPMtcBc2Ju2vMq64TRA1Llfoya1KRIeu6m0G7Xu3ZtkWARTRcHkYYg==", "integrity": "sha512-+nl4ntmRtUmSUuh9mOpicE0U/wpzhSPSQQXy9eg/2xdTBL58jkrQ6bQjbVKEhqUj0bUvg0ESjZm8YX/x+4zXiA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
@@ -4128,20 +4116,23 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@trystero-p2p/nostr": "0.25.3", "@trystero-p2p/nostr": "0.25.3",
"@vrtmrz/value-store": "0.1.0",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"events": "^3.3.0", "events": "^3.3.0",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"idb": "^8.0.3", "idb": "^8.0.3",
"markdown-it": "^14.3.2", "markdown-it": "^14.3.3",
"minimatch": "^10.2.6", "minimatch": "^10.2.6",
"octagonal-wheels": "^0.1.55", "octagonal-wheels": "^0.1.55",
"pouchdb-adapter-http": "^9.0.0", "pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-idb": "^9.0.0", "pouchdb-adapter-idb": "^9.0.0",
"pouchdb-adapter-indexeddb": "^9.0.0", "pouchdb-adapter-indexeddb": "^9.0.0",
"pouchdb-adapter-memory": "^9.0.0", "pouchdb-adapter-memory": "^9.0.0",
"pouchdb-checkpointer": "9.0.0",
"pouchdb-core": "^9.0.0", "pouchdb-core": "^9.0.0",
"pouchdb-errors": "^9.0.0", "pouchdb-errors": "^9.0.0",
"pouchdb-find": "^9.0.0", "pouchdb-find": "^9.0.0",
"pouchdb-generate-replication-id": "9.0.0",
"pouchdb-mapreduce": "^9.0.0", "pouchdb-mapreduce": "^9.0.0",
"pouchdb-merge": "^9.0.0", "pouchdb-merge": "^9.0.0",
"pouchdb-replication": "^9.0.0", "pouchdb-replication": "^9.0.0",
@@ -4210,6 +4201,12 @@
"integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==", "integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vrtmrz/value-store": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@vrtmrz/value-store/-/value-store-0.1.0.tgz",
"integrity": "sha512-M62f1j0bnJkqpgI5sYxnkXUAOSDEwQbYrLf+w58gCCm4sjIXDmFzDJd2duXabRbktBlACt9P99yjN/9gOAPxrA==",
"license": "MIT"
},
"node_modules/abstract-leveldown": { "node_modules/abstract-leveldown": {
"version": "6.2.3", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz", "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
@@ -6367,9 +6364,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.8", "version": "3.1.9",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -8253,9 +8250,9 @@
} }
}, },
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.3.2", "version": "14.3.3",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
"integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -8804,6 +8801,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkg-dir": { "node_modules/pkg-dir": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@@ -9576,13 +9586,13 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.133.0", "@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@@ -9592,21 +9602,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.0.3" "@rolldown/binding-win32-x64-msvc": "1.1.5"
} }
}, },
"node_modules/sade": { "node_modules/sade": {
@@ -10455,19 +10465,6 @@
} }
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyrainbow": { "node_modules/tinyrainbow": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
@@ -10837,16 +10834,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.16", "version": "8.1.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "integrity": "sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.5",
"postcss": "^8.5.15", "postcss": "^8.5.17",
"rolldown": "1.0.3", "rolldown": "~1.1.5",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
@@ -10863,7 +10860,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18", "@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
@@ -10929,19 +10926,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitefu": { "node_modules/vitefu": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz",
@@ -10963,19 +10947,19 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "4.1.11", "@vitest/expect": "4.1.12",
"@vitest/mocker": "4.1.11", "@vitest/mocker": "4.1.12",
"@vitest/pretty-format": "4.1.11", "@vitest/pretty-format": "4.1.12",
"@vitest/runner": "4.1.11", "@vitest/runner": "4.1.12",
"@vitest/snapshot": "4.1.11", "@vitest/snapshot": "4.1.12",
"@vitest/spy": "4.1.11", "@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.11", "@vitest/utils": "4.1.12",
"es-module-lexer": "^2.0.0", "es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0", "expect-type": "^1.3.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -11003,12 +10987,12 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11", "@vitest/browser-playwright": "4.1.12",
"@vitest/browser-preview": "4.1.11", "@vitest/browser-preview": "4.1.12",
"@vitest/browser-webdriverio": "4.1.11", "@vitest/browser-webdriverio": "^4.1.11",
"@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-istanbul": "4.1.12",
"@vitest/coverage-v8": "4.1.11", "@vitest/coverage-v8": "4.1.12",
"@vitest/ui": "4.1.11", "@vitest/ui": "4.1.12",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*", "jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -11059,19 +11043,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vuvuzela": { "node_modules/vuvuzela": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/vuvuzela/-/vuvuzela-1.0.3.tgz", "resolved": "https://registry.npmjs.org/vuvuzela/-/vuvuzela-1.0.3.tgz",
@@ -11398,7 +11369,7 @@
}, },
"src/apps/cli": { "src/apps/cli": {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"version": "1.0.39-cli", "version": "1.0.42-cli",
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"minimatch": "^10.2.5", "minimatch": "^10.2.5",
@@ -11417,13 +11388,13 @@
}, },
"devDependencies": { "devDependencies": {
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "^8.0.16", "vite": "^8.1.6",
"vitest": "^4.1.11" "vitest": "^4.1.12"
} }
}, },
"src/apps/webapp": { "src/apps/webapp": {
"name": "livesync-webapp", "name": "livesync-webapp",
"version": "1.0.39-webapp", "version": "1.0.42-webapp",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.55" "octagonal-wheels": "^0.1.55"
}, },
@@ -11431,11 +11402,11 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "5.56.3", "svelte": "5.56.3",
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "^8.0.16" "vite": "^8.1.6"
} }
}, },
"src/apps/webpeer": { "src/apps/webpeer": {
"version": "1.0.39-webpeer", "version": "1.0.42-webpeer",
"dependencies": { "dependencies": {
"octagonal-wheels": "^0.1.55" "octagonal-wheels": "^0.1.55"
}, },
@@ -11446,7 +11417,7 @@
"svelte": "5.56.3", "svelte": "5.56.3",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "^8.0.16" "vite": "^8.1.6"
} }
} }
} }
+19 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "1.0.39", "version": "1.0.42",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js", "main": "main.js",
"type": "module", "type": "module",
@@ -57,6 +57,8 @@
"test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts", "test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts",
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts", "test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts", "test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
"test:e2e:obsidian:joined-devices": "tsx test/e2e-obsidian/scripts/joined-devices.ts",
"test:e2e:obsidian:joined-devices-object-storage": "tsx test/e2e-obsidian/scripts/joined-devices.ts --object-storage",
"test:e2e:obsidian:database-diagnostics": "tsx test/e2e-obsidian/scripts/database-diagnostics.ts", "test:e2e:obsidian:database-diagnostics": "tsx test/e2e-obsidian/scripts/database-diagnostics.ts",
"test:e2e:obsidian:database-diagnostics-couchdb": "tsx test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts", "test:e2e:obsidian:database-diagnostics-couchdb": "tsx test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts",
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts", "test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
@@ -68,6 +70,7 @@
"test:e2e:obsidian:configuration-encryption-sync": "tsx test/e2e-obsidian/scripts/configuration-encryption-sync.ts", "test:e2e:obsidian:configuration-encryption-sync": "tsx test/e2e-obsidian/scripts/configuration-encryption-sync.ts",
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
"test:e2e:obsidian:replication-status": "tsx test/e2e-obsidian/scripts/replication-status.ts",
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts", "test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts", "test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
"test:e2e:obsidian:couchdb-http-cache": "tsx test/e2e-obsidian/scripts/couchdb-http-cache.ts", "test:e2e:obsidian:couchdb-http-cache": "tsx test/e2e-obsidian/scripts/couchdb-http-cache.ts",
@@ -85,6 +88,7 @@
"test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts", "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:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p",
"test:e2e:obsidian:partial-startup-file-failure": "tsx test/e2e-obsidian/scripts/partial-startup-file-failure.ts", "test:e2e:obsidian:partial-startup-file-failure": "tsx test/e2e-obsidian/scripts/partial-startup-file-failure.ts",
"test:e2e:obsidian:startup-content-check": "tsx test/e2e-obsidian/scripts/startup-content-check.ts",
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts", "test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
"test:e2e:obsidian:stale-file-restart": "tsx test/e2e-obsidian/scripts/stale-file-restart.ts", "test:e2e:obsidian:stale-file-restart": "tsx test/e2e-obsidian/scripts/stale-file-restart.ts",
"test:e2e:obsidian:folder-batch": "tsx test/e2e-obsidian/scripts/folder-batch.ts", "test:e2e:obsidian:folder-batch": "tsx test/e2e-obsidian/scripts/folder-batch.ts",
@@ -92,7 +96,11 @@
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts", "test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
"test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts", "test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts",
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts", "test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
"test:e2e:obsidian:hidden-file-selectors": "tsx test/e2e-obsidian/scripts/hidden-file-selectors.ts",
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts", "test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
"test:e2e:obsidian:customisation-catalogue-reload": "tsx test/e2e-obsidian/scripts/customisation-catalogue-reload.ts",
"test:e2e:obsidian:optional-file-save-completion": "tsx test/e2e-obsidian/scripts/optional-file-save-completion.ts",
"test:e2e:obsidian:livesync-batch": "tsx test/e2e-obsidian/scripts/issue1207-livesync-batch.ts",
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts", "test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
"test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts", "test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts",
"test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts", "test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts",
@@ -120,7 +128,10 @@
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init", "test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
"test:docker-all:stop": "npm run test:docker-all:down", "test:docker-all:stop": "npm run test:docker-all:down",
"update-workspaces": "node update-workspaces.mjs", "update-workspaces": "node update-workspaces.mjs",
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json" "version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json",
"test:e2e:obsidian:startup-integrity": "tsx test/e2e-obsidian/scripts/startup-integrity.ts",
"test:e2e:obsidian:third-party-chunks": "tsx test/e2e-obsidian/scripts/third-party-chunks.ts",
"test:e2e:obsidian:upgrade-livesync-batching": "tsx test/e2e-obsidian/scripts/upgrade-livesync-batching.ts"
}, },
"keywords": [], "keywords": [],
"author": "vorotamoroz", "author": "vorotamoroz",
@@ -144,7 +155,7 @@
"@types/pouchdb-replication": "^6.4.7", "@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6", "@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.69.0", "@typescript-eslint/parser": "8.69.0",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.12",
"@vrtmrz/obsidian-test-session": "0.2.6", "@vrtmrz/obsidian-test-session": "0.2.6",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"esbuild": "0.28.1", "esbuild": "0.28.1",
@@ -179,8 +190,8 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.69.0", "typescript-eslint": "8.69.0",
"vite": "^8.0.16", "vite": "^8.1.6",
"vitest": "^4.1.11", "vitest": "^4.1.12",
"yaml": "^2.8.2" "yaml": "^2.8.2"
}, },
"dependencies": { "dependencies": {
@@ -193,12 +204,13 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.44", "@vrtmrz/livesync-commonlib": "0.2.2",
"@vrtmrz/obsidian-plugin-kit": "0.1.4", "@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"@vrtmrz/value-store": "0.1.0",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"idb": "^8.0.3", "idb": "^8.0.3",
"markdown-it": "^14.3.2", "markdown-it": "^14.3.3",
"minimatch": "^10.2.5", "minimatch": "^10.2.5",
"obsidian": "^1.13.1", "obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.55", "octagonal-wheels": "^0.1.55",
File renamed without changes.
File renamed without changes.
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService"; import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService"; import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService"; import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService"; import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
@@ -11,7 +11,7 @@ import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> { export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
API: InjectableAPIService<T>; API: InjectableAPIService<T>;
appLifecycle: AppLifecycleService<T>; appLifecycle: AppLifecycleService<T>;
config: ConfigService<T>; config: IConfigService;
control: ControlService<T>; control: ControlService<T>;
replicator: ReplicatorService<T>; replicator: ReplicatorService<T>;
} }
@@ -1,11 +1,10 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase"; import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService"; import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService"; import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService"; import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService"; import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
@@ -24,10 +23,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
import { setLang, translateLiveSyncMessage } from "@/common/translation"; import { setLang, translateLiveSyncMessage } from "@/common/translation";
import { BrowserConfirm } from "./BrowserConfirm"; import { BrowserConfirm } from "./BrowserConfirm";
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase"; import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
import { import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
LiveSyncBrowserAPIService,
type LiveSyncBrowserAPIServiceOptions,
} from "./LiveSyncBrowserAPIService";
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService"; import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
export interface LiveSyncBrowserSettingsPersistence { export interface LiveSyncBrowserSettingsPersistence {
@@ -43,6 +39,7 @@ export interface LiveSyncBrowserRestartPolicy {
} }
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> { export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
deviceValues: ValueStore & ValueDeleter;
context?: T; context?: T;
getSystemVaultName?: () => string; getSystemVaultName?: () => string;
settings?: LiveSyncBrowserSettingsPersistence; settings?: LiveSyncBrowserSettingsPersistence;
@@ -51,35 +48,12 @@ export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">; API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
} }
class LiveSyncBrowserAppLifecycleService< class LiveSyncBrowserAppLifecycleService<T extends ServiceContext> extends InjectableAppLifecycleService<T> {}
T extends ServiceContext,
> extends InjectableAppLifecycleService<T> {}
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {} class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {} class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
class LiveSyncBrowserConfigService<T extends ServiceContext> extends ConfigService<T> {
constructor(
context: T,
private readonly setting: ISettingService
) {
super(context);
}
getSmallConfig(key: string): string | null {
return this.setting.getSmallConfig(key);
}
setSmallConfig(key: string, value: string): void {
this.setting.setSmallConfig(key, value);
}
deleteSmallConfig(key: string): void {
this.setting.deleteSmallConfig(key);
}
}
/** LiveSync-owned service composition shared by WebApp and WebPeer. */ /** LiveSync-owned service composition shared by WebApp and WebPeer. */
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> { export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
constructor(options: LiveSyncBrowserServiceHubOptions<T>) { constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
@@ -98,17 +72,14 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
const setting = new InjectableSettingService(context, { const setting = new InjectableSettingService(context, {
APIService: API, APIService: API,
onDisplayLanguageChanged: setLang, onDisplayLanguageChanged: setLang,
deviceValues: options.deviceValues,
}); });
const settingsPersistence = options.settings; const settingsPersistence = options.settings;
setting.loadData.setHandler( setting.loadData.setHandler(
settingsPersistence settingsPersistence ? () => settingsPersistence.load() : () => Promise.resolve(undefined)
? () => settingsPersistence.load()
: () => Promise.resolve(undefined)
); );
setting.saveData.setHandler( setting.saveData.setHandler(
settingsPersistence settingsPersistence ? (settings) => settingsPersistence.save(settings) : () => Promise.resolve()
? (settings) => settingsPersistence.save(settings)
: () => Promise.resolve()
); );
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, { const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
@@ -142,7 +113,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
setting, setting,
API, API,
}); });
const config = new LiveSyncBrowserConfigService(context, setting); const config = setting.config;
const replicator = new InjectableReplicatorService(context, { const replicator = new InjectableReplicatorService(context, {
settingService: setting, settingService: setting,
appLifecycleService: appLifecycle, appLifecycleService: appLifecycle,
@@ -163,8 +134,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
databaseService: database, databaseService: database,
}); });
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, { const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
openKeyValueDatabase: openKeyValueDatabase: options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
appLifecycle, appLifecycle,
databaseEvents, databaseEvents,
vault, vault,
@@ -209,7 +179,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
} }
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>( export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
options: LiveSyncBrowserServiceHubOptions<T> = {} options: LiveSyncBrowserServiceHubOptions<T>
): LiveSyncBrowserServiceHub<T> { ): LiveSyncBrowserServiceHub<T> {
return new LiveSyncBrowserServiceHub(options); return new LiveSyncBrowserServiceHub(options);
} }
@@ -1,5 +1,6 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { MemoryValueStore } from "@vrtmrz/value-store";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
@@ -21,7 +22,8 @@ describe("LiveSync browser service context contract", () => {
const context = createServiceContext({ const context = createServiceContext({
translate: (key) => `webapp:${key}`, translate: (key) => `webapp:${key}`,
}); });
const hub = createLiveSyncBrowserServiceHub({ context }); const deviceValues = new MemoryValueStore();
const hub = createLiveSyncBrowserServiceHub({ context, deviceValues });
expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({ expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({
translation: "webapp:moduleLocalDatabase.logWaitingForReady", translation: "webapp:moduleLocalDatabase.logWaitingForReady",
@@ -32,20 +34,17 @@ describe("LiveSync browser service context contract", () => {
expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual( expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual(
[] []
); );
expect(hub.setting.deviceValues).toBe(deviceValues);
expect(hub.config).toBe(hub.setting.config);
}); });
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => { it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
const deviceLocalSettings = new Map<string, string>(); const deviceValues = new MemoryValueStore();
vi.stubGlobal("localStorage", {
getItem: (key: string) => deviceLocalSettings.get(key) ?? null,
setItem: (key: string, value: string) => deviceLocalSettings.set(key, value),
removeItem: (key: string) => deviceLocalSettings.delete(key),
clear: () => deviceLocalSettings.clear(),
});
const savedSettings: (typeof DEFAULT_SETTINGS)[] = []; const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
const restartCalls: string[] = []; const restartCalls: string[] = [];
const nativeFetch = vi.fn(async () => new Response("ok")); const nativeFetch = vi.fn(async () => new Response("ok"));
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = { const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
deviceValues,
getSystemVaultName: () => "browser-vault", getSystemVaultName: () => "browser-vault",
settings: { settings: {
load: async () => ({ load: async () => ({
@@ -69,6 +68,8 @@ describe("LiveSync browser service context contract", () => {
const hub = createLiveSyncBrowserServiceHub(options); const hub = createLiveSyncBrowserServiceHub(options);
expect(hub.setting.deviceValues).toBe(deviceValues);
expect(hub.config).toBe(hub.setting.config);
expect(hub.API.getSystemVaultName()).toBe("browser-vault"); expect(hub.API.getSystemVaultName()).toBe("browser-vault");
expect(hub.API.getPlatform()).toBe("browser"); expect(hub.API.getPlatform()).toBe("browser");
const response = await hub.API.nativeFetch("https://example.invalid/"); const response = await hub.API.nativeFetch("https://example.invalid/");
@@ -86,4 +87,30 @@ describe("LiveSync browser service context contract", () => {
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true); expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]); expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
}); });
it("keeps each browser composition bound to its own device value store", async () => {
const firstValues = new MemoryValueStore();
const secondValues = new MemoryValueStore();
const firstHub = createLiveSyncBrowserServiceHub({
deviceValues: firstValues,
getSystemVaultName: () => "first-vault",
});
const secondHub = createLiveSyncBrowserServiceHub({
deviceValues: secondValues,
getSystemVaultName: () => "second-vault",
});
await firstHub.config.setValue("probe", "first");
await secondHub.config.setValue("probe", "second");
expect(firstHub.setting.deviceValues).toBe(firstValues);
expect(secondHub.setting.deviceValues).toBe(secondValues);
expect(firstHub.config).toBe(firstHub.setting.config);
expect(secondHub.config).toBe(secondHub.setting.config);
expect(firstHub.config).not.toBe(secondHub.config);
await expect(firstHub.config.getValue("probe")).resolves.toBe("first");
await expect(secondHub.config.getValue("probe")).resolves.toBe("second");
await expect(firstValues.getValue("first-vault-probe")).resolves.toBe("first");
await expect(secondValues.getValue("second-vault-probe")).resolves.toBe("second");
});
}); });
+2 -2
View File
@@ -719,8 +719,8 @@ src/apps/cli/
│ └── ServiceFileAccessImpl.ts │ └── ServiceFileAccessImpl.ts
├── services/ ├── services/
│ ├── NodeKeyValueDBService.ts │ ├── NodeKeyValueDBService.ts
│ ├── NodeServiceHub.ts │ ├── NodeLocalStorage.ts
│ └── NodeSettingService.ts │ └── NodeServiceHub.ts
├── test/ ├── test/
│ ├── test-e2e-two-vaults-common.sh │ ├── test-e2e-two-vaults-common.sh
│ ├── test-e2e-two-vaults-matrix.sh │ ├── test-e2e-two-vaults-matrix.sh
+21
View File
@@ -88,6 +88,27 @@ describe("CLI database preparation", () => {
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`); await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
} }
it("loads legacy device values and persists configuration through the service-owned store", async () => {
const localStoragePath = join(directory, ".livesync", "runtime", "local-storage.json");
await mkdir(join(directory, ".livesync", "runtime"), { recursive: true });
await writeFile(localStoragePath, JSON.stringify({ ["__proto__"]: "legacy value" }));
let legacyValue: string | null | undefined;
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
legacyValue = await core.services.setting.deviceValues.getValue("__proto__");
expect(core.services.config).toBe(core.services.setting.config);
await core.services.config.setValue("bootstrap-probe", "config value");
return false;
});
await start("ls", runner);
expect(legacyValue).toBe("legacy value");
const stored = JSON.parse(await readFile(localStoragePath, "utf-8")) as Record<string, string>;
expect(Object.prototype.hasOwnProperty.call(stored, "__proto__")).toBe(true);
expect(stored["__proto__"]).toBe("legacy value");
expect(Object.values(stored)).toContain("config value");
});
it.each([REMOTE_P2P, REMOTE_COUCHDB])( it.each([REMOTE_P2P, REMOTE_COUCHDB])(
"restores configured P2P automation before the host connects (%s)", "restores configured P2P automation before the host connects (%s)",
async (remoteType) => { async (remoteType) => {
-4
View File
@@ -1,6 +1,5 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation"; import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub"; import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore"; import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules"; import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
import { import {
@@ -78,7 +77,6 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
}, },
}; };
ensureGlobalNodeLocalStorage();
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG; defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
/** Injectable command boundary used by CLI integration probes. */ /** Injectable command boundary used by CLI integration probes. */
@@ -400,8 +398,6 @@ export async function main(
const settingsPath = options.settingsPath const settingsPath = options.settingsPath
? path.resolve(options.settingsPath) ? path.resolve(options.settingsPath)
: path.join(databasePath, SETTINGS_FILE); : path.join(databasePath, SETTINGS_FILE);
configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json"));
// Resolve vault path: mirror positional argument takes priority, // Resolve vault path: mirror positional argument takes priority,
// then --vault flag, otherwise fall back to databasePath. // then --vault flag, otherwise fall back to databasePath.
const vaultPath = const vaultPath =
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "self-hosted-livesync-cli", "name": "self-hosted-livesync-cli",
"private": true, "private": true,
"version": "1.0.39-cli", "version": "1.0.42-cli",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --", "buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..", "build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json", "check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts", "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh", "test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh", "test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh", "test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
@@ -52,7 +52,7 @@
}, },
"devDependencies": { "devDependencies": {
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "^8.0.16", "vite": "^8.1.6",
"vitest": "^4.1.11" "vitest": "^4.1.12"
} }
} }
+72 -101
View File
@@ -1,115 +1,86 @@
import { createDeletableValueStore, sanitiseValueStoreError, ValueStoreError } from "@vrtmrz/value-store";
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node"; import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
type LocalStorageShape = { type NodeLocalStorage = ValueStore & ValueDeleter;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
clear(): void;
};
class PersistentNodeLocalStorage { class JsonFileValueBackend {
private storagePath: string | undefined; private values: Map<string, string>;
private localStore: Record<string, string> = {};
configure(storagePath: string) { constructor(private readonly storagePath: string) {
if (this.storagePath === storagePath) { this.values = this.loadValues();
return;
}
this.storagePath = storagePath;
this.loadFromFile();
} }
private loadFromFile() { get(key: string): string | null {
if (!this.storagePath) { return this.values.get(key) ?? null;
this.localStore = {}; }
set(key: string, value: string): void {
const nextValues = new Map(this.values);
nextValues.set(key, value);
this.writeValues(nextValues);
this.values = nextValues;
}
delete(key: string): void {
if (!this.values.has(key)) {
return; return;
} }
const nextValues = new Map(this.values);
nextValues.delete(key);
this.writeValues(nextValues);
this.values = nextValues;
}
private loadValues(): Map<string, string> {
let contents: string;
try {
contents = nodeFs.readFileSync(this.storagePath, "utf-8");
} catch (error) {
if (isMissingFileError(error)) {
return new Map();
}
throw sanitiseValueStoreError(error);
}
let document: unknown;
try {
document = JSON.parse(contents);
} catch {
throw new ValueStoreError("invalid-data");
}
if (document === null || typeof document !== "object" || Array.isArray(document)) {
throw new ValueStoreError("invalid-data");
}
const values = new Map<string, string>();
for (const [key, value] of Object.entries(document)) {
if (typeof value !== "string") {
throw new ValueStoreError("invalid-data");
}
values.set(key, value);
}
return values;
}
private writeValues(values: ReadonlyMap<string, string>): void {
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
nodeFs.writeFileSync(this.storagePath, JSON.stringify(Object.fromEntries(values), null, 2), "utf-8");
}
}
function isMissingFileError(error: unknown): boolean {
if (typeof error !== "object" || error === null) {
return false;
}
try { try {
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>; const code = Object.getOwnPropertyDescriptor(error, "code");
this.localStore = { ...loaded }; return code !== undefined && Object.prototype.hasOwnProperty.call(code, "value") && code.value === "ENOENT";
} catch { } catch {
this.localStore = {}; return false;
} }
} }
private flushToFile() { export function createNodeLocalStorage(storagePath: string): NodeLocalStorage {
if (!this.storagePath) { return createDeletableValueStore(new JsonFileValueBackend(storagePath));
return;
}
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8");
}
getItem(key: string): string | null {
return this.localStore[key] ?? null;
}
setItem(key: string, value: string) {
this.localStore[key] = value;
this.flushToFile();
}
removeItem(key: string) {
if (!(key in this.localStore)) {
return;
}
delete this.localStore[key];
this.flushToFile();
}
clear() {
this.localStore = {};
this.flushToFile();
}
}
const persistentNodeLocalStorage = new PersistentNodeLocalStorage();
function createNodeLocalStorageShim(): LocalStorageShape {
return {
getItem(key: string) {
return persistentNodeLocalStorage.getItem(key);
},
setItem(key: string, value: string) {
persistentNodeLocalStorage.setItem(key, value);
},
removeItem(key: string) {
persistentNodeLocalStorage.removeItem(key);
},
clear() {
persistentNodeLocalStorage.clear();
},
};
}
export function ensureGlobalNodeLocalStorage() {
const g = compatGlobal as unknown as Record<string, unknown>;
if (
!("localStorage" in g) ||
typeof (g.localStorage as Record<string, unknown> | undefined)?.getItem !== "function"
) {
g.localStorage = createNodeLocalStorageShim();
}
}
export function configureNodeLocalStorage(storagePath: string) {
persistentNodeLocalStorage.configure(storagePath);
ensureGlobalNodeLocalStorage();
}
export function getNodeLocalStorageItem(key: string): string {
return persistentNodeLocalStorage.getItem(key) ?? "";
}
export function setNodeLocalStorageItem(key: string, value: string) {
persistentNodeLocalStorage.setItem(key, value);
}
export function deleteNodeLocalStorageItem(key: string) {
persistentNodeLocalStorage.removeItem(key);
}
export function clearNodeLocalStorage() {
persistentNodeLocalStorage.clear();
} }
@@ -1,58 +1,127 @@
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node"; import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import { ValueStoreError } from "@vrtmrz/value-store";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import { createNodeLocalStorage } from "./NodeLocalStorage";
clearNodeLocalStorage,
configureNodeLocalStorage,
ensureGlobalNodeLocalStorage,
getNodeLocalStorageItem,
setNodeLocalStorageItem,
} from "./NodeLocalStorage";
describe("NodeLocalStorage", () => { describe("NodeLocalStorage", () => {
const tempDirs: string[] = []; const tempDirs: string[] = [];
afterEach(() => { afterEach(() => {
clearNodeLocalStorage();
for (const tempDir of tempDirs.splice(0)) { for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
} }
}); });
it("persists values to the configured file", () => { it("loads and preserves legacy flat string data, including empty values and exact keys", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
configureNodeLocalStorage(storagePath);
setNodeLocalStorageItem("checkpoint", "42");
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
expect(saved.checkpoint).toBe("42");
});
it("reloads persisted values when configured again", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-")); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir); tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json"); const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const specialKey = "__proto__";
const specialValue = "line one\n雪";
fs.mkdirSync(path.dirname(storagePath), { recursive: true }); fs.mkdirSync(path.dirname(storagePath), { recursive: true });
fs.writeFileSync(storagePath, JSON.stringify({ persisted: "value" }, null, 2), "utf-8"); fs.writeFileSync(storagePath, JSON.stringify({ [specialKey]: specialValue, empty: "" }), "utf-8");
configureNodeLocalStorage(storagePath); const store = createNodeLocalStorage(storagePath);
expect(getNodeLocalStorageItem("persisted")).toBe("value"); await expect(store.getValue(specialKey)).resolves.toBe(specialValue);
await expect(store.getValue("empty", "default")).resolves.toBe("");
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
expect(saved[specialKey]).toBe(specialValue);
expect(saved.empty).toBe("");
}); });
it("installs a global localStorage shim backed by the same store", () => { it("persists strings and objects, reloads them, and deletes keys", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-")); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir); tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json"); const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
const object = { enabled: false, entries: ["value", null, 0] };
configureNodeLocalStorage(storagePath); await store.setValue("empty", "");
ensureGlobalNodeLocalStorage(); await store.setValue("text", "stored value");
await store.setObject("object", object);
globalThis.localStorage.setItem("shared", "state"); const reloaded = createNodeLocalStorage(storagePath);
await expect(reloaded.getValue("empty", "default")).resolves.toBe("");
await expect(reloaded.getValue("text")).resolves.toBe("stored value");
await expect(reloaded.getObject("object")).resolves.toEqual(object);
expect(getNodeLocalStorageItem("shared")).toBe("state"); await reloaded.delete("text");
await expect(reloaded.getValue("text")).resolves.toBeNull();
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
expect(saved.text).toBeUndefined();
expect(saved.object).toBe(JSON.stringify(object));
});
it("treats a missing file as empty and creates its parent on the first write", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
await expect(store.getValue("absent")).resolves.toBeNull();
expect(fs.existsSync(storagePath)).toBe(false);
await store.setValue("created", "value");
expect(fs.existsSync(storagePath)).toBe(true);
});
it("keeps separate store instances isolated by their file paths", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const first = createNodeLocalStorage(path.join(tempDir, "first", "local-storage.json"));
const second = createNodeLocalStorage(path.join(tempDir, "second", "local-storage.json"));
await first.setValue("shared-key", "first");
await second.setValue("shared-key", "second");
await expect(first.getValue("shared-key")).resolves.toBe("first");
await expect(second.getValue("shared-key")).resolves.toBe("second");
});
it("surfaces malformed and unreadable files as sanitised value-store errors", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const malformedPath = path.join(tempDir, "malformed.json");
fs.writeFileSync(malformedPath, "{ malformed", "utf-8");
let malformedError: unknown;
try {
createNodeLocalStorage(malformedPath);
} catch (error) {
malformedError = error;
}
expect(malformedError).toBeInstanceOf(ValueStoreError);
expect(malformedError).toMatchObject({ code: "invalid-data" });
expect(fs.readFileSync(malformedPath, "utf-8")).toBe("{ malformed");
const unreadablePath = path.join(tempDir, "unreadable.json");
fs.mkdirSync(unreadablePath);
expect(() => createNodeLocalStorage(unreadablePath)).toThrowError(ValueStoreError);
});
it("does not change its visible values when a write fails", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
const store = createNodeLocalStorage(storagePath);
await store.setValue("retained", "before failure");
fs.rmSync(storagePath);
fs.mkdirSync(storagePath);
await expect(store.setValue("new", "write fails")).rejects.toBeInstanceOf(ValueStoreError);
await expect(store.getValue("retained")).resolves.toBe("before failure");
await expect(store.getValue("new")).resolves.toBeNull();
});
it("does not install or modify a global localStorage value", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
tempDirs.push(tempDir);
const before = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
createNodeLocalStorage(path.join(tempDir, "runtime", "local-storage.json"));
expect(Object.getOwnPropertyDescriptor(globalThis, "localStorage")).toEqual(before);
}); });
}); });
+8 -11
View File
@@ -1,6 +1,5 @@
import type { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService"; import type { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
import type { import type {
ComponentHasResult, ComponentHasResult,
SvelteDialogManager, SvelteDialogManager,
@@ -11,6 +10,7 @@ import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService"; import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService"; import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService"; import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService"; import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService"; import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService"; import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
@@ -21,7 +21,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService"; import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
import { NodeKeyValueDBService } from "./NodeKeyValueDBService"; import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
import { NodeSettingService } from "./NodeSettingService"; import { createNodeLocalStorage } from "./NodeLocalStorage";
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService"; import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node"; import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
@@ -96,11 +96,11 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
const conflict = new InjectableConflictService(context); const conflict = new InjectableConflictService(context);
const fileProcessing = new InjectableFileProcessingService(context); const fileProcessing = new InjectableFileProcessingService(context);
const setting = new NodeSettingService( const setting = new InjectableSettingService<T>(context, {
context, APIService: API,
{ APIService: API, onDisplayLanguageChanged: setLang }, onDisplayLanguageChanged: setLang,
localStoragePath deviceValues: createNodeLocalStorage(localStoragePath),
); });
const appLifecycle = new NodeAppLifecycleService<T>(context, { const appLifecycle = new NodeAppLifecycleService<T>(context, {
settingService: setting, settingService: setting,
@@ -132,10 +132,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
setting, setting,
}); });
const config = new ConfigServiceBrowserCompat<T>(context, { const config = setting.config;
settingService: setting,
APIService: API,
});
const replicator = new InjectableReplicatorService(context, { const replicator = new InjectableReplicatorService(context, {
settingService: setting, settingService: setting,
@@ -1,42 +0,0 @@
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { EVENT_REQUEST_RELOAD_SETTING_TAB } from "@/common/events";
import { handlers } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import {
configureNodeLocalStorage,
deleteNodeLocalStorageItem,
getNodeLocalStorageItem,
setNodeLocalStorageItem,
} from "./NodeLocalStorage";
export class NodeSettingService<T extends ServiceContext> extends SettingService<T> {
constructor(context: T, dependencies: SettingServiceDependencies, storagePath: string) {
super(context, dependencies);
configureNodeLocalStorage(storagePath);
this.onSettingSaved.addHandler((settings) => {
this.context.events.emitEvent(EVENT_SETTING_SAVED, settings);
return Promise.resolve(true);
});
this.onSettingLoaded.addHandler((settings) => {
this.context.events.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
return Promise.resolve(true);
});
}
protected setItem(key: string, value: string) {
setNodeLocalStorageItem(key, value);
}
protected getItem(key: string): string {
return getNodeLocalStorageItem(key);
}
protected deleteItem(key: string): void {
deleteNodeLocalStorageItem(key);
}
public saveData = handlers<{ saveData: (data: ObsidianLiveSyncSettings) => Promise<void> }>().binder("saveData");
public loadData = handlers<{ loadData: () => Promise<ObsidianLiveSyncSettings | undefined> }>().binder("loadData");
}
+12 -2
View File
@@ -21,6 +21,8 @@ import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFea
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig"; import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { import {
createLiveSyncBrowserServiceHub, createLiveSyncBrowserServiceHub,
@@ -56,6 +58,7 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success"; export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
export interface WebAppRuntimeOptions { export interface WebAppRuntimeOptions {
deviceValues?: ValueStore & ValueDeleter;
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void; reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
scheduleReload?: (delayMilliseconds: number) => void; scheduleReload?: (delayMilliseconds: number) => void;
} }
@@ -71,7 +74,10 @@ export class WebAppRuntime {
private paneHost: P2PReplicatorPaneHost | null = null; private paneHost: P2PReplicatorPaneHost | null = null;
private restartScheduled = false; private restartScheduled = false;
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) { constructor(
rootHandle: FileSystemDirectoryHandle,
private readonly options: WebAppRuntimeOptions = {}
) {
this.rootHandle = rootHandle; this.rootHandle = rootHandle;
this.reportStatus = options.reportStatus ?? (() => {}); this.reportStatus = options.reportStatus ?? (() => {});
this.scheduleReload = this.scheduleReload =
@@ -89,6 +95,8 @@ export class WebAppRuntime {
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> { private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
return { return {
deviceValues:
this.options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp", getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
settings: { settings: {
save: async (data) => { save: async (data) => {
@@ -219,7 +227,9 @@ export class WebAppRuntime {
useCheckRemoteSize(core); useCheckRemoteSize(core);
useRemoteConfiguration(core); useRemoteConfiguration(core);
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, { this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)), prepareP2PSettings: useP2PSettingsPreparation(
core.services.API.webCompatFetch.bind(core.services.API)
),
}); });
this.paneHost = { this.paneHost = {
services: core.services, services: core.services,
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "livesync-webapp", "name": "livesync-webapp",
"private": true, "private": true,
"version": "1.0.39-webapp", "version": "1.0.42-webapp",
"type": "module", "type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API", "description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": { "scripts": {
@@ -21,6 +21,6 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "5.56.3", "svelte": "5.56.3",
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "^8.0.16" "vite": "^8.1.6"
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "webpeer", "name": "webpeer",
"private": true, "private": true,
"version": "1.0.39-webpeer", "version": "1.0.42-webpeer",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -24,6 +24,6 @@
"svelte": "5.56.3", "svelte": "5.56.3",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"vite": "^8.0.16" "vite": "^8.1.6"
} }
} }
+7 -3
View File
@@ -7,6 +7,8 @@ import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/t
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p"; import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
import { unique } from "octagonal-wheels/collection"; import { unique } from "octagonal-wheels/collection";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
@@ -20,6 +22,7 @@ import { translateLiveSyncMessage } from "@/common/translation";
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence"; import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
export interface WebPeerRuntimeOptions { export interface WebPeerRuntimeOptions {
deviceValues?: ValueStore & ValueDeleter;
context?: ServiceContext; context?: ServiceContext;
store?: SimpleStore<unknown>; store?: SimpleStore<unknown>;
deviceName?: string; deviceName?: string;
@@ -62,6 +65,7 @@ export class WebPeerRuntime {
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage }); this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({ this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
context: this.context, context: this.context,
deviceValues: options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME, getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
settings: persistence.settings, settings: persistence.settings,
restart: { restart: {
@@ -103,7 +107,7 @@ export class WebPeerRuntime {
await this.services.setting.loadSettings(); await this.services.setting.loadSettings();
const deviceName = this.options.deviceName?.trim(); const deviceName = this.options.deviceName?.trim();
if (deviceName) { if (deviceName) {
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName); await this.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
} }
const opened = await this.services.database.openDatabase({ const opened = await this.services.database.openDatabase({
replicator: this.services.replicator, replicator: this.services.replicator,
@@ -192,7 +196,7 @@ export class WebPeerRuntime {
); );
} }
getDeviceName(): string { async getDeviceName(): Promise<string> {
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName(); return (await this.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME)) || this.services.vault.getVaultName();
} }
} }
@@ -15,6 +15,74 @@ export const liveSyncProvisionalEnglishMessages = {
"Synchronisation was interrupted. You can continue locally, but some notes may be out of date.", "Synchronisation was interrupted. You can continue locally, but some notes may be out of date.",
"At least ${COUNT} changed files received": "At least ${COUNT} changed files received", "At least ${COUNT} changed files received": "At least ${COUNT} changed files received",
"Continue without waiting": "Continue without waiting", "Continue without waiting": "Continue without waiting",
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.",
"Unknown platform": "Unknown platform",
"This removes only this device's record from the remote.":
"This removes only this device's record from the remote.",
"This remote is unlocked. A device that returns can register again.":
"This remote is unlocked. A device that returns can register again.",
"This remote is locked. Use the existing recovery process for devices that need to join again.":
"This remote is locked. Use the existing recovery process for devices that need to join again.",
"This remote is locked. If the device returns, use the existing recovery process to join it again.":
"This remote is locked. If the device returns, use the existing recovery process to join it again.",
"This remote does not support conditional updates, so the device record could not be removed.":
"This remote does not support conditional updates, so the device record could not be removed.",
"This remote does not currently have a record of joined devices.":
"This remote does not currently have a record of joined devices.",
"This list shows device information recorded by the remote. It does not show whether a device is online now.":
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
"This device has been locked out. Use the existing recovery process to join again.":
"This device has been locked out. Use the existing recovery process to join again.",
"This device": "This device",
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.":
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.",
"The device record was forgotten.": "The device record was forgotten.",
"The device record was already absent.": "The device record was already absent.",
"The device list changed while you were trying to forget this device. Refresh the list before trying again.":
"The device list changed while you were trying to forget this device. Refresh the list before trying again.",
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.":
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.",
"Review device records for the active CouchDB or Object Storage connection.":
"Review device records for the active CouchDB or Object Storage connection.",
Refresh: "Refresh",
"Plug-in version": "Plug-in version",
Platform: "Platform",
"Older Self-hosted LiveSync versions detected: ${versions}.":
"Older Self-hosted LiveSync versions detected: ${versions}.",
"Newer Self-hosted LiveSync versions detected: ${versions}.":
"Newer Self-hosted LiveSync versions detected: ${versions}.",
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.":
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.",
Open: "Open",
"Not recorded": "Not recorded",
"Not accepted": "Not accepted",
"No joined devices are recorded on this remote.": "No joined devices are recorded on this remote.",
"Locked out": "Locked out",
"Loading joined devices…": "Loading joined devices…",
"Last recorded": "Last recorded",
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.":
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.",
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.":
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.",
"Joined devices": "Joined devices",
"Review devices": "Review devices",
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.":
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.",
"Forgetting…": "Forgetting…",
"Forgetting the record does not delete Vault files or revoke credentials.":
"Forgetting the record does not delete Vault files or revoke credentials.",
"Forget this device record?": "Forget this device record?",
"Forget device": "Forget device",
Close: "Close",
Cancel: "Cancel",
"App version": "App version",
Accepted: "Accepted",
"Preparing follow-up report…": "Preparing follow-up report…", "Preparing follow-up report…": "Preparing follow-up report…",
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.", "Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.": "Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
+13
View File
@@ -28,7 +28,10 @@ import {
getObjectStorageReplicatorConfigurationIdentity, getObjectStorageReplicatorConfigurationIdentity,
} from "./replicatorConfigurationIdentity"; } from "./replicatorConfigurationIdentity";
import { import {
createCouchDBJoinedDevicesResourceFactory,
createObjectStorageJoinedDevicesResourceFactory,
createCouchDBConnectionProbeFactory, createCouchDBConnectionProbeFactory,
createCouchDBInitialFetchFastForwardResourceFactory,
createCouchDBPreferredTweakProbeFactory, createCouchDBPreferredTweakProbeFactory,
createCouchDBSecuritySeedResourceFactory, createCouchDBSecuritySeedResourceFactory,
createCouchDBSynchronisationInformationResourceFactory, createCouchDBSynchronisationInformationResourceFactory,
@@ -115,6 +118,9 @@ export function createCentralReplicatorProviderDefinitions(
configurationIdentity: getCouchDBReplicatorConfigurationIdentity, configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)), create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
remoteResources: { remoteResources: {
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
createCouchDBJoinedDevicesResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)), [REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability( [REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
createCouchDBPreferredTweakProbeFactory(host) createCouchDBPreferredTweakProbeFactory(host)
@@ -125,6 +131,9 @@ export function createCentralReplicatorProviderDefinitions(
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability( [REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability(
createCouchDBSynchronisationInformationResourceFactory(host) createCouchDBSynchronisationInformationResourceFactory(host)
), ),
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: supportedCapability(
createCouchDBInitialFetchFastForwardResourceFactory(host)
),
}, },
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY, centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot), userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
@@ -141,6 +150,9 @@ export function createCentralReplicatorProviderDefinitions(
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity, configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)), create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
remoteResources: { remoteResources: {
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
createObjectStorageJoinedDevicesResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability( [REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
createObjectStorageConnectionProbeFactory(host) createObjectStorageConnectionProbeFactory(host)
), ),
@@ -151,6 +163,7 @@ export function createCentralReplicatorProviderDefinitions(
createObjectStorageSecuritySeedResourceFactory(host) createObjectStorageSecuritySeedResourceFactory(host)
), ),
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE, [REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
}, },
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY, centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot), userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
+21 -3
View File
@@ -44,7 +44,16 @@ describe("central Replicator provider definitions", () => {
it("keeps the retained remote-resource catalogue bounded", () => { it("keeps the retained remote-resource catalogue bounded", () => {
expect expect
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort()) .soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort()); .toEqual(
[
"connection",
"initial-fetch-fast-forward",
"joined-devices",
"preferred-tweak",
"security-seed",
"synchronisation-information",
].sort()
);
}); });
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => { it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
@@ -106,18 +115,27 @@ describe("central Replicator provider definitions", () => {
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration; const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration; const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort()); const resources = Object.values(REMOTE_RESOURCE_KINDS).sort();
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort()); expect(Object.keys(couchResources ?? {}).sort()).toEqual(resources);
expect(Object.keys(objectResources ?? {}).sort()).toEqual(resources);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe( expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
CAPABILITY_SUPPORT_KINDS.SUPPORTED CAPABILITY_SUPPORT_KINDS.SUPPORTED
); );
expect(couchResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
CAPABILITY_SUPPORT_KINDS.SUPPORTED
);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe( expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
); );
expect(objectResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
);
expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED); expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false); expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
@@ -5,6 +5,7 @@ import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
logger: vi.fn(), logger: vi.fn(),
initialFetchFastForwardResource: undefined as unknown,
couchDB: [] as Array<{ couchDB: [] as Array<{
host: unknown; host: unknown;
isMobile: ReturnType<typeof vi.fn>; isMobile: ReturnType<typeof vi.fn>;
@@ -12,6 +13,9 @@ const mocks = vi.hoisted(() => ({
getRemoteStatus: ReturnType<typeof vi.fn>; getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>; getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>; getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
getJoinedDevices: ReturnType<typeof vi.fn>;
forgetJoinedDevice: ReturnType<typeof vi.fn>;
createInitialFetchFastForwardResource: ReturnType<typeof vi.fn>;
closeReplication: ReturnType<typeof vi.fn>; closeReplication: ReturnType<typeof vi.fn>;
}>, }>,
objectStorage: [] as Array<{ objectStorage: [] as Array<{
@@ -20,6 +24,8 @@ const mocks = vi.hoisted(() => ({
getRemoteStatus: ReturnType<typeof vi.fn>; getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>; getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>; getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
getJoinedDevices: ReturnType<typeof vi.fn>;
forgetJoinedDevice: ReturnType<typeof vi.fn>;
closeReplication: ReturnType<typeof vi.fn>; closeReplication: ReturnType<typeof vi.fn>;
}>, }>,
checkSyncInfo: vi.fn(async () => true), checkSyncInfo: vi.fn(async () => true),
@@ -42,6 +48,9 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato
getRemoteStatus = vi.fn(); getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn(); getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn(); getReplicationPBKDF2Salt = vi.fn();
getJoinedDevices = vi.fn();
forgetJoinedDevice = vi.fn();
createInitialFetchFastForwardResource = vi.fn(() => mocks.initialFetchFastForwardResource);
closeReplication = vi.fn(); closeReplication = vi.fn();
constructor(host: unknown) { constructor(host: unknown) {
@@ -58,6 +67,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
getRemoteStatus = vi.fn(); getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn(); getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn(); getReplicationPBKDF2Salt = vi.fn();
getJoinedDevices = vi.fn();
forgetJoinedDevice = vi.fn();
closeReplication = vi.fn(); closeReplication = vi.fn();
constructor(host: unknown) { constructor(host: unknown) {
@@ -69,10 +80,13 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
import { import {
createCouchDBConnectionProbeFactory, createCouchDBConnectionProbeFactory,
createCouchDBJoinedDevicesResourceFactory,
createCouchDBInitialFetchFastForwardResourceFactory,
createCouchDBPreferredTweakProbeFactory, createCouchDBPreferredTweakProbeFactory,
createCouchDBSecuritySeedResourceFactory, createCouchDBSecuritySeedResourceFactory,
createCouchDBSynchronisationInformationResourceFactory, createCouchDBSynchronisationInformationResourceFactory,
createObjectStorageConnectionProbeFactory, createObjectStorageConnectionProbeFactory,
createObjectStorageJoinedDevicesResourceFactory,
createObjectStoragePreferredTweakProbeFactory, createObjectStoragePreferredTweakProbeFactory,
createObjectStorageSecuritySeedResourceFactory, createObjectStorageSecuritySeedResourceFactory,
} from "./replicatorResources"; } from "./replicatorResources";
@@ -92,6 +106,7 @@ describe("replicator probe factories", () => {
beforeEach(() => { beforeEach(() => {
mocks.couchDB.length = 0; mocks.couchDB.length = 0;
mocks.objectStorage.length = 0; mocks.objectStorage.length = 0;
mocks.initialFetchFastForwardResource = undefined;
mocks.checkSyncInfo.mockReset().mockResolvedValue(true); mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
mocks.logger.mockClear(); mocks.logger.mockClear();
}); });
@@ -226,6 +241,65 @@ describe("replicator probe factories", () => {
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot); expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
}); });
it.each([
{
kind: "CouchDB",
remoteType: REMOTE_COUCHDB,
createFactory: createCouchDBJoinedDevicesResourceFactory,
instances: () => mocks.couchDB,
},
{
kind: "Object Storage",
remoteType: REMOTE_MINIO,
createFactory: createObjectStorageJoinedDevicesResourceFactory,
instances: () => mocks.objectStorage,
},
])(
"keeps $kind joined-device operations on an owned settings snapshot",
async ({ remoteType, createFactory, instances }) => {
const host = { name: "host" };
const source = createSettings({ remoteType });
const snapshot = { ...source };
const factory = createFactory(host as never);
const resource = await factory(source);
const nextResource = await factory(source);
const [replicator, nextReplicator] = instances();
expect(instances()).toHaveLength(2);
expect(replicator.host).toBe(host);
expect(replicator.getJoinedDevices).not.toHaveBeenCalled();
expect(replicator.forgetJoinedDevice).not.toHaveBeenCalled();
const devices = {
milestonePresent: true,
locked: false,
cleaned: false,
devices: [{ id: "other", accepted: true }],
};
replicator.getJoinedDevices.mockResolvedValue(devices);
replicator.forgetJoinedDevice.mockResolvedValue("conflict");
source.couchDB_DBNAME = "changed-vault";
source.bucket = "changed-bucket";
await expect(resource.read()).resolves.toBe(devices);
await expect(resource.forget("other")).resolves.toBe("conflict");
expect(replicator.getJoinedDevices).toHaveBeenCalledWith(snapshot);
expect(replicator.getJoinedDevices.mock.calls[0][0]).not.toBe(source);
expect(replicator.forgetJoinedDevice).toHaveBeenCalledWith(snapshot, "other");
const failure = new Error("remote unavailable");
replicator.getJoinedDevices.mockRejectedValue(failure);
await expect(resource.read()).rejects.toBe(failure);
replicator.forgetJoinedDevice.mockRejectedValue(failure);
await expect(resource.forget("other")).rejects.toBe(failure);
const disposal = resource.dispose();
expect(resource.dispose()).toBe(disposal);
await disposal;
expect(replicator.closeReplication).toHaveBeenCalledOnce();
expect(nextReplicator.closeReplication).not.toHaveBeenCalled();
await nextResource.dispose();
expect(nextReplicator.closeReplication).toHaveBeenCalledOnce();
}
);
it("shares one successful asynchronous disposal promise for every probe kind", async () => { it("shares one successful asynchronous disposal promise for every probe kind", async () => {
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings()); const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)( const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
@@ -370,4 +444,29 @@ describe("replicator probe factories", () => {
await resource.dispose(); await resource.dispose();
expect(replicator.closeReplication).toHaveBeenCalledOnce(); expect(replicator.closeReplication).toHaveBeenCalledOnce();
}); });
it("creates an Initial Fetch Fast Forward resource from a settings snapshot and leaves ownership to the resource", async () => {
const host = { name: "host" };
const settings = createSettings();
const snapshot = { ...settings };
const prepare = vi.fn(async () => undefined);
const dispose = vi.fn(async () => undefined);
const resource = { prepare, dispose };
const factory = createCouchDBInitialFetchFastForwardResourceFactory(host as never);
mocks.initialFetchFastForwardResource = resource;
const result = await factory(settings);
const replicator = mocks.couchDB[0];
settings.couchDB_URI = "https://changed.example.test";
expect(replicator.createInitialFetchFastForwardResource).toHaveBeenCalledWith(snapshot);
expect(replicator.createInitialFetchFastForwardResource.mock.calls[0][0]).not.toBe(settings);
expect(replicator.host).toBe(host);
expect(result).toBe(resource);
await expect(result.prepare()).resolves.toBeUndefined();
await result.dispose();
expect(prepare).toHaveBeenCalledOnce();
expect(dispose).toHaveBeenCalledOnce();
});
}); });
+6
View File
@@ -14,3 +14,9 @@ export {
type SecuritySeedResourceHost, type SecuritySeedResourceHost,
} from "./securitySeed"; } from "./securitySeed";
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation"; export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
export {
createCouchDBJoinedDevicesResourceFactory,
createObjectStorageJoinedDevicesResourceFactory,
} from "./joinedDevices";
export { createCouchDBInitialFetchFastForwardResourceFactory } from "./initialFetchFastForward";
@@ -0,0 +1,17 @@
import type { InitialFetchFastForwardResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { snapshotRemoteSettings } from "./shared";
/** Create a private CouchDB Replicator for a fresh Fast Fetch checkpoint handoff. */
export function createCouchDBInitialFetchFastForwardResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): InitialFetchFastForwardResourceFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
const replicator = new LiveSyncCouchDBReplicator(host);
return Promise.resolve(replicator.createInitialFetchFastForwardResource(snapshot));
};
}
@@ -0,0 +1,40 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type {
JoinedDevicesResource,
JoinedDevicesResourceFactory,
JoinedDevicesSnapshot,
ForgetJoinedDeviceResult,
} from "@vrtmrz/livesync-commonlib/replication";
import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
interface JoinedDevicesReplicator extends ResourceReplicator {
getJoinedDevices(setting: RemoteDBSettings): Promise<JoinedDevicesSnapshot>;
forgetJoinedDevice(setting: RemoteDBSettings, deviceId: string): Promise<ForgetJoinedDeviceResult>;
}
function createResource(replicator: JoinedDevicesReplicator, snapshot: RemoteDBSettings): JoinedDevicesResource {
return {
read: () => replicator.getJoinedDevices(snapshot),
forget: (deviceId) => replicator.forgetJoinedDevice(snapshot, deviceId),
dispose: createReplicatorDisposer(replicator),
};
}
export function createCouchDBJoinedDevicesResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): JoinedDevicesResourceFactory {
return (setting) =>
Promise.resolve(createResource(new LiveSyncCouchDBReplicator(host), snapshotRemoteSettings(setting)));
}
export function createObjectStorageJoinedDevicesResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): JoinedDevicesResourceFactory {
return (setting) =>
Promise.resolve(createResource(new LiveSyncJournalReplicator(host), snapshotRemoteSettings(setting)));
}
+2 -10
View File
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
@@ -18,17 +19,7 @@ import {
} from "./turnSettingsPrivacy"; } from "./turnSettingsPrivacy";
class MemorySettingService extends SettingService { class MemorySettingService extends SettingService {
readonly items = new Map<string, string>();
saved?: ObsidianLiveSyncSettings; saved?: ObsidianLiveSyncSettings;
protected setItem(key: string, value: string) {
this.items.set(key, value);
}
protected getItem(key: string) {
return this.items.get(key) ?? "";
}
protected deleteItem(key: string) {
this.items.delete(key);
}
protected saveData(settings: ObsidianLiveSyncSettings) { protected saveData(settings: ObsidianLiveSyncSettings) {
this.saved = structuredClone(settings); this.saved = structuredClone(settings);
return Promise.resolve(); return Promise.resolve();
@@ -70,6 +61,7 @@ describe("managed TURN settings privacy", () => {
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current }); const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
current.remoteConfigurations.managed.uri = originalURI; current.remoteConfigurations.managed.uri = originalURI;
const service = new MemorySettingService(new ServiceContext(), { const service = new MemorySettingService(new ServiceContext(), {
deviceValues: new MemoryValueStore(),
APIService: { APIService: {
getSystemVaultName: () => "test-vault", getSystemVaultName: () => "test-vault",
getAppID: () => "test-app", getAppID: () => "test-app",
@@ -0,0 +1,298 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: (path: string) => path,
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} }));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({ PeriodicProcessor: class PeriodicProcessor {} }));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: { onEvent: vi.fn() },
}));
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() }));
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
import { ConfigSync, PluginDataExDisplayV2, pluginManifests } from "./CmdConfigSync";
const filenames = ["main.js", "manifest.json", "styles.css"];
const content: Record<string, string> = {
"main.js": "module.exports = class ExamplePlugin {};",
"manifest.json": JSON.stringify({ id: "example", name: "Example", version: "1.0.0" }),
"styles.css": ".example { color: red; }",
};
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
async function catalogue() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.syncOnlyRegEx = "\\.(?:md|png|pdf)$" as typeof fixture.settings.syncOnlyRegEx;
const paths = filenames.map((name) => `ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix);
for (const [index, path] of paths.entries()) {
const wireContent = `fixture-header\u200b\n\u200c${btoa(content[filenames[index]])}`;
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, wireContent))).resolves.toMatchObject({
ok: true,
});
}
const gates = new Map(paths.map((path) => [path, deferred()]));
const readEntry = fixture.localDatabase.getDBEntry.bind(fixture.localDatabase);
vi.spyOn(fixture.localDatabase, "getDBEntry").mockImplementation(async (path, ...args) => {
await gates.get(path)?.promise;
return readEntry(path, ...args);
});
const written = new Map<string, string>();
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(configSync, {
core: {
localDatabase: fixture.localDatabase,
settings: fixture.settings,
services: fixture.services,
storageAccess: {
ensureDir: vi.fn(async () => true),
readHiddenFileText: vi.fn(async (path: string) => written.get(path) ?? ""),
writeHiddenFileAuto: vi.fn(async (path: string, data: string) => {
written.set(path, data);
}),
},
},
pluginList: [],
pluginListGeneration: 0,
loadedManifest_mTime: new Map(),
updatingV2Count: 0,
pluginScanProcessor: { clearQueue: vi.fn(), enqueue: vi.fn() },
_log: vi.fn(),
storeCustomisationFileV2: vi.fn(async () => true),
});
const updates: Promise<void>[] = [];
const update = configSync.updatePluginListV2.bind(configSync);
vi.spyOn(configSync, "updatePluginListV2").mockImplementation((...args) => {
const promise = update(...args);
updates.push(promise);
return promise;
});
const release = (order = filenames) => {
for (const name of order) gates.get(`ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix)!.resolve();
};
return { ...fixture, configSync, updates, paths, release, written };
}
afterEach(async () => {
vi.restoreAllMocks();
pluginManifests.clear();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("issue #1271: rebuilding a multi-file Customisation Sync item", () => {
it.each([
{ order: filenames },
{ order: [...filenames].reverse() },
{ order: ["manifest.json", "main.js", "styles.css"] },
])("retains all per-file documents when reads are released as $order", async ({ order }) => {
const fixture = await catalogue();
await fixture.configSync.reloadPluginList(false);
fixture.release(order);
await Promise.all(fixture.updates);
const documents = await fixture.database.allDocs({ keys: fixture.paths });
expect(documents.rows).toHaveLength(3);
expect(fixture.configSync.pluginList).toHaveLength(1);
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
filenames.map((name) => `plugins/example/${name}`).sort()
);
});
it("applies all three files after the first reload", async () => {
const fixture = await catalogue();
await fixture.configSync.reloadPluginList(false);
fixture.release();
await Promise.all(fixture.updates);
await expect(
fixture.configSync.applyDataV2(fixture.configSync.pluginList[0] as PluginDataExDisplayV2)
).resolves.toBe(true);
expect([...fixture.written.keys()].sort()).toEqual(
filenames.map((name) => `.obsidian/plugins/example/${name}`).sort()
);
for (const name of filenames)
expect(fixture.written.get(`.obsidian/plugins/example/${name}`)).toBe(content[name]);
});
it("fills an existing row during a second enumeration", async () => {
const fixture = await catalogue();
await fixture.configSync.reloadPluginList(false);
fixture.release();
await Promise.all(fixture.updates);
await fixture.configSync.updatePluginList(false);
await Promise.all(fixture.updates);
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
filenames.map((name) => `plugins/example/${name}`).sort()
);
});
it("retains all files when item updates run sequentially", async () => {
const fixture = await catalogue();
fixture.release();
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
filenames.map((name) => `plugins/example/${name}`).sort()
);
});
it("updates a different item while another item's reads are pending", async () => {
const fixture = await catalogue();
const otherPath = "ix:device/PLUGIN_MAIN/other%main.js" as FilePathWithPrefix;
await fixture.localDatabase.putDBEntry(
optionalFileEntry(otherPath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
);
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
try {
await fixture.configSync.updatePluginListV2(false, otherPath);
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["other"]);
expect(fixture.configSync.updatingV2Count).toBe(3);
} finally {
fixture.release();
await Promise.all(pending);
}
expect(fixture.configSync.updatingV2Count).toBe(0);
});
it("continues updating an item after an earlier read fails", async () => {
const fixture = await catalogue();
fixture.release();
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockRejectedValueOnce(new Error("Read failed"));
const failed = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
const next = fixture.configSync.updatePluginListV2(false, fixture.paths[1]);
await expect(failed).rejects.toThrow("Read failed");
await next;
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
"plugins/example/manifest.json",
]);
expect(fixture.configSync.updatingV2Count).toBe(0);
});
it.each(["read", "file update"])("reload discards cached files while an earlier %s is pending", async (phase) => {
const fixture = await catalogue();
fixture.release();
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
const earlierRead = deferred();
const freshRead = deferred();
const entered = deferred();
const createFile = fixture.configSync.createPluginDataExFileV2.bind(fixture.configSync);
let reads = 0;
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockImplementation(async (path) => {
if (reads++ === 0) {
if (phase === "read") {
entered.resolve();
await earlierRead.promise;
}
} else {
await freshRead.promise;
}
return createFile(path);
});
if (phase === "file update") {
const entry = fixture.configSync.pluginList[0] as PluginDataExDisplayV2;
const setFile = entry.setFile.bind(entry);
vi.spyOn(entry, "setFile").mockImplementationOnce(async (file) => {
entered.resolve();
await earlierRead.promise;
await setFile(file);
});
}
const pending = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
try {
await entered.promise;
for (const path of fixture.paths.slice(1)) await fixture.database.remove(await fixture.database.get(path));
await fixture.configSync.reloadPluginList(false);
earlierRead.resolve();
await pending;
} finally {
earlierRead.resolve();
freshRead.resolve();
await Promise.all(fixture.updates);
}
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
"plugins/example/main.js",
]);
});
it("clearing the disabled feature prevents pending updates from repopulating the list", async () => {
const fixture = await catalogue();
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
fixture.settings.usePluginSync = false;
await fixture.configSync.updatePluginList(false);
fixture.release();
await Promise.all(pending);
expect(fixture.configSync.pluginList).toEqual([]);
expect(fixture.configSync.updatingV2Count).toBe(0);
});
it("a superseded enumeration cannot reintroduce a deleted item", async () => {
const fixture = await catalogue();
const obsoletePath = "ix:device/PLUGIN_MAIN/obsolete%main.js" as FilePathWithPrefix;
await fixture.localDatabase.putDBEntry(
optionalFileEntry(obsoletePath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
);
const enumerate = fixture.localDatabase.findEntries.bind(fixture.localDatabase);
type EnumeratedEntry = ReturnType<typeof enumerate> extends AsyncGenerator<infer Entry> ? Entry : never;
const obsoleteDocs: EnumeratedEntry[] = [];
for await (const doc of enumerate(obsoletePath, `${obsoletePath}\u{10ffff}`, { include_docs: true })) {
obsoleteDocs.push(doc);
}
const entered = deferred();
const release = deferred();
let scans = 0;
vi.spyOn(fixture.localDatabase, "findEntries").mockImplementation((...args) => {
if (scans++ !== 0) return enumerate(...args);
return (async function* () {
entered.resolve();
await release.promise;
yield* obsoleteDocs;
})();
});
const pending = fixture.configSync.updatePluginList(false);
try {
await entered.promise;
await fixture.database.remove(await fixture.database.get(obsoletePath));
await fixture.configSync.reloadPluginList(false);
} finally {
release.resolve();
fixture.release();
await pending;
await Promise.all(fixture.updates);
}
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["example"]);
expect(fixture.configSync.pluginList[0].files).toHaveLength(3);
expect(fixture.configSync.updatingV2Count).toBe(0);
});
});
@@ -0,0 +1,220 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
vi.mock("@/modules/features/ModuleLog.ts", () => ({
MARK_DONE: "",
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { ConfigSync } from "./CmdConfigSync";
import { createOptionalFileSyncDatabase } from "../../../test/fixtures/optionalFileSyncDatabase";
const path = ".obsidian/snippets/test.css" as FilePath;
const fixtures: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function createConfigSync(useV2 = false) {
const fixture = await createOptionalFileSyncDatabase();
fixtures.push(fixture);
fixture.settings.usePluginSyncV2 = useV2;
const files = new Map<string, { content: string; mtime: number }>([
[path, { content: "body { color: red; }", mtime: 2000 }],
]);
const storageAccess = {
statHidden: vi.fn(async (file: string) => {
const entry = files.get(file);
return entry
? { type: "file" as const, ctime: 1000, mtime: entry.mtime, size: entry.content.length }
: null;
}),
readHiddenFileBinary: vi.fn(async (file: string) => {
const entry = files.get(file);
if (!entry) throw new Error(`Missing fixture: ${file}`);
return new TextEncoder().encode(entry.content).buffer;
}),
};
const services = {
...fixture.services,
appLifecycle: { isReady: () => false, isSuspended: () => true },
database: { ...fixture.services.database, isDatabaseReady: vi.fn(() => true) },
path: {
...fixture.services.path,
isMarkedAsSameChanges: vi.fn(),
markChangesAreSame: vi.fn(),
},
};
const config = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(config, {
core: { settings: fixture.settings, localDatabase: fixture.localDatabase, storageAccess, services },
_log: vi.fn(),
updatePluginList: vi.fn(async () => undefined),
updatePluginListV2: vi.fn(async () => undefined),
recentProcessedInternalFiles: [],
});
const databasePath = useV2 ? config.filenameWithUnifiedKey(path) : config.filenameToUnifiedKey(path);
return { fixture, config, files, services, storageAccess, databasePath };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtures.splice(0)) await fixture.dispose();
});
describe("Customisation Sync optional save completion", () => {
const flush = { whenFlushRequested: Promise.resolve() };
it("waits for the database write while the application is suspended or still starting", async () => {
const { fixture, config } = await createConfigSync();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
const spy = vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
await gate;
return await put(...args);
});
let completed = false;
const result = config._processOptionalFileEventWithResult(path, flush).then((outcome) => {
completed = true;
return outcome;
});
await vi.waitFor(() => expect(spy).toHaveBeenCalledOnce());
expect(completed).toBe(false);
release();
await expect(result).resolves.toEqual({ kind: "completed" });
});
it.each([false, true])("confirms identical contents and stores a same-time edit (V2: %s)", async (useV2) => {
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
const first = await fixture.database.get(databasePath);
await config._processOptionalFileEventWithResult(path, flush);
expect((await fixture.database.get(databasePath))._rev).toBe(first._rev);
files.get(path)!.content = "body { color: tan; }";
await config._processOptionalFileEventWithResult(path, flush);
expect((await fixture.database.get(databasePath))._rev).not.toBe(first._rev);
});
it.each([
{ useV2: false, throws: false },
{ useV2: true, throws: false },
{ useV2: false, throws: true },
{ useV2: true, throws: true },
])("rejects a failed write and permits the unchanged notification to retry: %j", async ({ useV2, throws }) => {
const { fixture, config, databasePath } = await createConfigSync(useV2);
const failure = new Error("temporary write failure");
const put = vi.spyOn(fixture.localDatabase, "putDBEntry");
if (throws) put.mockRejectedValueOnce(failure);
else put.mockResolvedValueOnce(false);
const failed = config._processOptionalFileEventWithResult(path, flush);
if (throws) await expect(failed).rejects.toBe(failure);
else await expect(failed).rejects.toThrow("Could not save customisation");
await expect(fixture.localDatabase.getDBEntry(databasePath)).resolves.toBe(false);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
expect(put).toHaveBeenCalledTimes(2);
expect(await fixture.localDatabase.getDBEntry(databasePath)).not.toBe(false);
});
it.each([false, true])("confirms deletion and treats a repeated deletion as complete (V2: %s)", async (useV2) => {
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
await config._processOptionalFileEventWithResult(path, flush);
files.delete(path);
const put = vi.spyOn(fixture.localDatabase, "putRaw");
put.mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
await expect(config._processOptionalFileEventWithResult(path, flush)).rejects.toThrow(
"Could not delete customisation"
);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
const deleted = await fixture.database.get(databasePath);
expect("deleted" in deleted && deleted.deleted).toBe(true);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
expect((await fixture.database.get(databasePath))._rev).toBe(deleted._rev);
});
it("distinguishes a different owner and unmet prerequisites", async () => {
const { config, services, storageAccess } = await createConfigSync();
await expect(config._processOptionalFileEventWithResult("notes.md" as FilePath, flush)).resolves.toEqual({
kind: "not-applicable",
});
services.database.isDatabaseReady.mockReturnValue(false);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
kind: "veto",
value: "database-not-ready",
});
services.database.isDatabaseReady.mockReturnValue(true);
vi.spyOn(services.setting, "getDeviceAndVaultName").mockReturnValue("");
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
kind: "veto",
value: "device-name-not-configured",
});
expect(storageAccess.readHiddenFileBinary).not.toHaveBeenCalled();
});
it("preserves every V2 file when neighbouring plug-in files arrive together", async () => {
const { fixture, config, files } = await createConfigSync(true);
const main = ".obsidian/plugins/example/main.js" as FilePath;
const style = ".obsidian/plugins/example/styles.css" as FilePath;
files.set(main, { content: "module.exports = {};", mtime: 3000 });
files.set(style, { content: "body {}", mtime: 3000 });
await Promise.all([
config._processOptionalFileEventWithResult(main, flush),
config._processOptionalFileEventWithResult(style, flush),
]);
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(main))).not.toBe(false);
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(style))).not.toBe(false);
});
it("does not acknowledge a database deletion which was not accepted", async () => {
const { fixture, config, databasePath } = await createConfigSync();
await config.storeCustomizationFiles(path);
const original = await fixture.localDatabase.getDBEntry(databasePath);
expect(original).not.toBe(false);
vi.spyOn(fixture.localDatabase, "putRaw").mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
await expect(config.deleteConfigOnDatabase(databasePath)).resolves.toBe(false);
const unchanged = await fixture.localDatabase.getDBEntry(databasePath);
expect(unchanged && getDocData(unchanged.data)).toBe(original && getDocData(original.data));
});
});
+101 -11
View File
@@ -69,6 +69,10 @@ import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts"; import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts"; import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { PendingConfigSaves } from "./PendingConfigSaves";
const d = "\u200b"; const d = "\u200b";
const d2 = "\n"; const d2 = "\n";
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration"; const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
@@ -412,6 +416,7 @@ export class ConfigSync extends LiveSyncCommands {
periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false)); periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false));
pluginList: IPluginDataExDisplay[] = []; pluginList: IPluginDataExDisplay[] = [];
private pluginListGeneration = 0;
showPluginSyncModal() { showPluginSyncModal() {
if (!this.isThisModuleEnabled()) { if (!this.isThisModuleEnabled()) {
return; return;
@@ -532,6 +537,7 @@ export class ConfigSync extends LiveSyncCommands {
return Promise.resolve(true); return Promise.resolve(true);
} }
async reloadPluginList(showMessage: boolean) { async reloadPluginList(showMessage: boolean) {
this.pluginListGeneration++;
this.pluginList = []; this.pluginList = [];
this.loadedManifest_mTime.clear(); this.loadedManifest_mTime.clear();
pluginList.set(this.pluginList); pluginList.set(this.pluginList);
@@ -761,12 +767,16 @@ export class ConfigSync extends LiveSyncCommands {
updatingV2Count = 0; updatingV2Count = 0;
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> { async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
const generation = this.pluginListGeneration;
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
try { try {
this.updatingV2Count++; this.updatingV2Count++;
pluginV2Progress.set(this.updatingV2Count); pluginV2Progress.set(this.updatingV2Count);
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath); // const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey); const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
await serialized(`config:catalogue:${pathV1}`, async () => {
if (!isCurrent()) return;
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1); const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
let entry: PluginDataExDisplayV2 | undefined = undefined; let entry: PluginDataExDisplayV2 | undefined = undefined;
@@ -780,6 +790,7 @@ export class ConfigSync extends LiveSyncCommands {
} }
if (!entry) return; if (!entry) return;
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey); const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
if (!isCurrent()) return;
if (file) { if (file) {
await entry.setFile(file); await entry.setFile(file);
} else { } else {
@@ -788,6 +799,7 @@ export class ConfigSync extends LiveSyncCommands {
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1); this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
} }
} }
if (!isCurrent()) return;
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath); const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
newList.push(entry); newList.push(entry);
this.pluginList = newList; this.pluginList = newList;
@@ -795,6 +807,7 @@ export class ConfigSync extends LiveSyncCommands {
scheduleTask("updatePluginListV2", 100, () => { scheduleTask("updatePluginListV2", 100, () => {
pluginList.set(this.pluginList); pluginList.set(this.pluginList);
}); });
});
} finally { } finally {
this.updatingV2Count--; this.updatingV2Count--;
pluginV2Progress.set(this.updatingV2Count); pluginV2Progress.set(this.updatingV2Count);
@@ -871,11 +884,13 @@ export class ConfigSync extends LiveSyncCommands {
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> { async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
if (!this.isThisModuleEnabled()) { if (!this.isThisModuleEnabled()) {
this.pluginListGeneration++;
this.pluginScanProcessor.clearQueue(); this.pluginScanProcessor.clearQueue();
this.pluginList = []; this.pluginList = [];
pluginList.set(this.pluginList); pluginList.set(this.pluginList);
return; return;
} }
const generation = this.pluginListGeneration;
try { try {
this.updatingV2Count++; this.updatingV2Count++;
pluginV2Progress.set(this.updatingV2Count); pluginV2Progress.set(this.updatingV2Count);
@@ -888,6 +903,7 @@ export class ConfigSync extends LiveSyncCommands {
}) })
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true }); : this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
for await (const v of plugins) { for await (const v of plugins) {
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
if (v.deleted || v._deleted) continue; if (v.deleted || v._deleted) continue;
if (v.path.indexOf("%") !== -1) { if (v.path.indexOf("%") !== -1) {
fireAndForget(() => this.updatePluginListV2(showMessage, v.path)); fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
@@ -1206,7 +1222,7 @@ export class ConfigSync extends LiveSyncCommands {
} }
recentProcessedInternalFiles = [] as string[]; recentProcessedInternalFiles = [] as string[];
async makeEntryFromFile(path: FilePath): Promise<false | PluginDataExFile> { async makeEntryFromFile(path: FilePath, requireCompletion = false): Promise<false | PluginDataExFile> {
const stat = await this.core.storageAccess.statHidden(path); const stat = await this.core.storageAccess.statHidden(path);
let version: string | undefined; let version: string | undefined;
let displayName: string | undefined; let displayName: string | undefined;
@@ -1240,6 +1256,7 @@ export class ConfigSync extends LiveSyncCommands {
} catch (ex) { } catch (ex) {
this._log(`The file ${path} could not be encoded`); this._log(`The file ${path} could not be encoded`);
this._log(ex, LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false; return false;
} }
const mtime = stat.mtime; const mtime = stat.mtime;
@@ -1254,6 +1271,15 @@ export class ConfigSync extends LiveSyncCommands {
} }
async storeCustomisationFileV2(path: FilePath, term: string, force = false) { async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
return await this.storeCustomisationFileV2Core(path, term, force, false);
}
private async storeCustomisationFileV2Core(
path: FilePath,
term: string,
force: boolean,
requireCompletion: boolean
) {
const vf = this.filenameWithUnifiedKey(path, term); const vf = this.filenameWithUnifiedKey(path, term);
return await serialized(`plugin-${vf}`, async () => { return await serialized(`plugin-${vf}`, async () => {
const prefixedFileName = vf; const prefixedFileName = vf;
@@ -1261,7 +1287,8 @@ export class ConfigSync extends LiveSyncCommands {
const id = await this.path2id(prefixedFileName); const id = await this.path2id(prefixedFileName);
const stat = await this.core.storageAccess.statHidden(path); const stat = await this.core.storageAccess.statHidden(path);
if (!stat) { if (!stat) {
return false; if (!requireCompletion) return false;
return await this.deleteConfigOnDatabaseCore(vf, force, true);
} }
const mtime = stat.mtime; const mtime = stat.mtime;
const content = await this.core.storageAccess.readHiddenFileBinary(path); const content = await this.core.storageAccess.readHiddenFileBinary(path);
@@ -1285,7 +1312,10 @@ export class ConfigSync extends LiveSyncCommands {
eden: {}, eden: {},
}; };
} else { } else {
if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) { if (
!requireCompletion &&
this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
) {
this._log( this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`, `STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
LOG_LEVEL_DEBUG LOG_LEVEL_DEBUG
@@ -1300,7 +1330,7 @@ export class ConfigSync extends LiveSyncCommands {
const dataStart = dataSrc.indexOf(DUMMY_END); const dataStart = dataSrc.indexOf(DUMMY_END);
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length); const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
const oldContentArray = base64ToArrayBuffer(oldContent); const oldContentArray = base64ToArrayBuffer(oldContent);
if (await isDocContentSame(oldContentArray, content)) { if (!old.deleted && (await isDocContentSame(oldContentArray, content))) {
this._log( this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`, `STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
LOG_LEVEL_VERBOSE LOG_LEVEL_VERBOSE
@@ -1320,24 +1350,36 @@ export class ConfigSync extends LiveSyncCommands {
}; };
} }
const ret = await this.localDatabase.putDBEntry(saveData); const ret = await this.localDatabase.putDBEntry(saveData);
if (requireCompletion && (!ret || !ret.ok))
throw new LiveSyncError(`Could not save customisation: ${path}`);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`); this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path))); fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
return ret; return ret;
} catch (ex) { } catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`); this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false; return false;
} }
}); });
} }
async storeCustomizationFiles(path: FilePath, termOverRide?: string) { async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
return await this.storeCustomizationFilesCore(path, termOverRide, false);
}
private async storeCustomizationFilesCore(
path: FilePath,
termOverRide: string | undefined,
requireCompletion: boolean
) {
const term = termOverRide || this.services.setting.getDeviceAndVaultName(); const term = termOverRide || this.services.setting.getDeviceAndVaultName();
if (term == "") { if (term == "") {
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE); this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
if (requireCompletion) throw new LiveSyncError("The device name is not configured");
return; return;
} }
if (this.useV2) { if (this.useV2) {
return await this.storeCustomisationFileV2(path, term); return await this.storeCustomisationFileV2Core(path, term, false, requireCompletion);
} }
const vf = this.filenameToUnifiedKey(path, term); const vf = this.filenameToUnifiedKey(path, term);
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`); // console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
@@ -1378,7 +1420,7 @@ export class ConfigSync extends LiveSyncCommands {
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath); fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
} }
for (const target of fileTargets) { for (const target of fileTargets) {
const data = await this.makeEntryFromFile(target); const data = await this.makeEntryFromFile(target, requireCompletion);
if (data == false) { if (data == false) {
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE); this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
continue; continue;
@@ -1398,9 +1440,9 @@ export class ConfigSync extends LiveSyncCommands {
// this._log(`Configuration saving: ${prefixedFileName}`); // this._log(`Configuration saving: ${prefixedFileName}`);
if (dt.files.length == 0) { if (dt.files.length == 0) {
this._log(`Nothing left: deleting.. ${path}`); this._log(`Nothing left: deleting.. ${path}`);
await this.deleteConfigOnDatabase(prefixedFileName); const deleted = await this.deleteConfigOnDatabaseCore(prefixedFileName, false, requireCompletion);
await this.updatePluginList(false, prefixedFileName); await this.updatePluginList(false, prefixedFileName);
return; return requireCompletion ? deleted : undefined;
} }
const content = createTextBlob(serialize(dt)); const content = createTextBlob(serialize(dt));
@@ -1422,7 +1464,7 @@ export class ConfigSync extends LiveSyncCommands {
eden: {}, eden: {},
}; };
} else { } else {
if (old.mtime == mtime) { if (!requireCompletion && old.mtime == mtime) {
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE); // this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
return true; return true;
} }
@@ -1443,7 +1485,7 @@ export class ConfigSync extends LiveSyncCommands {
} }
}); });
const isSame = (await Promise.all(diffs)).every((e) => e == true); const isSame = (await Promise.all(diffs)).every((e) => e == true);
if (isSame) { if (isSame && !old.deleted) {
this._log( this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`, `STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
LOG_LEVEL_VERBOSE LOG_LEVEL_VERBOSE
@@ -1464,16 +1506,51 @@ export class ConfigSync extends LiveSyncCommands {
}; };
} }
const ret = await this.localDatabase.putDBEntry(saveData); const ret = await this.localDatabase.putDBEntry(saveData);
if (requireCompletion && (!ret || !ret.ok))
throw new LiveSyncError(`Could not save customisation: ${path}`);
await this.updatePluginList(false, saveData.path); await this.updatePluginList(false, saveData.path);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`); this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
return ret; return ret;
} catch (ex) { } catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`); this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false; return false;
} }
}); });
} }
private pendingOptionalSaves?: PendingConfigSaves;
async _processOptionalFileEventWithResult(
path: FilePath,
options?: OptionalFileEventOptions
): Promise<HandlerOutcome<void, string>> {
if (!this.isThisModuleEnabled() || !this.isTargetPath(path)) return { kind: "not-applicable" };
const configDir = normalizePath(this.configDir);
const handledElsewhere = Object.values(this.settings.pluginSyncExtendedSetting)
.filter((entry) => entry.mode !== MODE_SELECTIVE && entry.mode !== MODE_SHINY)
.flatMap((entry) => entry.files)
.some((file) => `${configDir}/${file}`.toLowerCase().startsWith(path.toLowerCase()));
if (handledElsewhere) return { kind: "not-applicable" };
if (!this._isDatabaseReady()) return { kind: "veto", value: "database-not-ready" };
const term = this.services.setting.getDeviceAndVaultName();
if (!term) return { kind: "veto", value: "device-name-not-configured" };
const stat = await this.core.storageAccess.statHidden(path);
if (stat && stat.type !== "file") return { kind: "not-applicable" };
// V1 saves a complete file group; V2 persists each file separately.
const key = this.useV2 ? this.filenameWithUnifiedKey(path, term) : this.filenameToUnifiedKey(path, term);
this.pendingOptionalSaves ??= new PendingConfigSaves();
await this.pendingOptionalSaves.enqueue(
key,
async () => {
await this.storeCustomizationFilesCore(path, term, true);
},
options?.whenFlushRequested
);
return { kind: "completed" };
}
async _anyProcessOptionalFileEvent(path: FilePath): Promise<boolean> { async _anyProcessOptionalFileEvent(path: FilePath): Promise<boolean> {
return await this.watchVaultRawEventsAsync(path); return await this.watchVaultRawEventsAsync(path);
} }
@@ -1615,6 +1692,14 @@ export class ConfigSync extends LiveSyncCommands {
} }
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) { async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
return await this.deleteConfigOnDatabaseCore(prefixedFileName, forceWrite, false);
}
private async deleteConfigOnDatabaseCore(
prefixedFileName: FilePathWithPrefix,
forceWrite: boolean,
requireCompletion: boolean
) {
// const id = await this.path2id(prefixedFileName); // const id = await this.path2id(prefixedFileName);
const mtime = new Date().getTime(); const mtime = new Date().getTime();
return await serialized("file-x-" + prefixedFileName, async () => { return await serialized("file-x-" + prefixedFileName, async () => {
@@ -1640,13 +1725,15 @@ export class ConfigSync extends LiveSyncCommands {
type: "newnote", type: "newnote",
}; };
} }
await this.localDatabase.putRaw(saveData); const result = await this.localDatabase.putRaw(saveData);
if (!result.ok) throw new LiveSyncError(`Could not delete customisation: ${prefixedFileName}`);
await this.updatePluginList(false, prefixedFileName); await this.updatePluginList(false, prefixedFileName);
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`); this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
return true; return true;
} catch (ex) { } catch (ex) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`); this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false; return false;
} }
}); });
@@ -1756,6 +1843,9 @@ export class ConfigSync extends LiveSyncCommands {
} }
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void { override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this)); services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
this._processOptionalFileEventWithResult.bind(this)
);
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this)); services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this)); services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this));
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this)); services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
@@ -0,0 +1,49 @@
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
type PendingSave = {
save: () => Promise<void>;
completion: Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
timer?: CompatTimeoutHandle;
started: boolean;
};
/** Owns the quiet-period delay and completion of coalesced customisation saves. */
export class PendingConfigSaves {
private readonly pending = new Map<string, PendingSave>();
enqueue(key: string, save: () => Promise<void>, whenFlushRequested?: Promise<void>): Promise<void> {
let group = this.pending.get(key);
if (!group) {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const completion = new Promise<void>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
group = { save, completion, resolve, reject, started: false };
this.pending.set(key, group);
} else {
group.save = save;
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
}
const captured = group;
group.timer = compatGlobal.setTimeout(() => this.run(key, captured), 100);
// Capture this group: a late flush of an earlier event must not release
// a newer group which happens to use the same customisation key.
void whenFlushRequested?.then(
() => this.run(key, captured),
() => {}
);
return group.completion;
}
private run(key: string, group: PendingSave): void {
if (group.started) return;
group.started = true;
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
this.pending.delete(key);
void Promise.resolve().then(group.save).then(group.resolve, group.reject);
}
}
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PendingConfigSaves } from "./PendingConfigSaves";
function deferred() {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
afterEach(() => vi.useRealTimers());
describe("pending customisation saves", () => {
it("retains the quiet period and completes every coalesced notification after the actual save", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const save = deferred();
const firstSave = vi.fn(async () => {});
const lastSave = vi.fn(() => save.promise);
const first = pending.enqueue("plugin", firstSave);
await vi.advanceTimersByTimeAsync(80);
const second = pending.enqueue("plugin", lastSave);
let completed = false;
const all = Promise.all([first, second]).then(() => {
completed = true;
});
await vi.advanceTimersByTimeAsync(99);
expect(lastSave).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(firstSave).not.toHaveBeenCalled();
expect(lastSave).toHaveBeenCalledOnce();
expect(completed).toBe(false);
save.resolve();
await all;
expect(completed).toBe(true);
});
it("flushes the captured group without waiting for or releasing later notifications", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const flush = deferred();
const lateFlush = deferred();
const firstSave = deferred();
const work = vi.fn(() => firstSave.promise);
const first = pending.enqueue("plugin", work, flush.promise);
const coalesced = pending.enqueue("plugin", work, lateFlush.promise);
flush.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(work).toHaveBeenCalledOnce();
const nextWork = vi.fn(async () => {});
const later = pending.enqueue("plugin", nextWork);
lateFlush.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(nextWork).not.toHaveBeenCalled();
firstSave.resolve();
await Promise.all([first, coalesced]);
expect(nextWork).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
await later;
expect(nextWork).toHaveBeenCalledOnce();
});
it("rejects every coalesced notification with the original failure and permits a later retry", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const failure = new Error("database write failed");
const save = async () => {
throw failure;
};
const first = pending.enqueue("plugin", save);
const second = pending.enqueue("plugin", save);
const outcomes = Promise.allSettled([first, second]);
await vi.advanceTimersByTimeAsync(100);
expect(await outcomes).toEqual([
{ status: "rejected", reason: failure },
{ status: "rejected", reason: failure },
]);
const retry = pending.enqueue("plugin", async () => {}, Promise.resolve());
await retry;
});
});
+14 -3
View File
@@ -20,10 +20,14 @@
MODE_SHINY, MODE_SHINY,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { normalizePath } from "@/deps"; import { normalizePath } from "@/deps";
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts"; import {
HiddenFileSync,
HIDDEN_FILE_SYNC_DISABLED_NOTICE,
} from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger"; import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import { $msg as translateMessage } from "@/common/translation"; import { $msg as translateMessage } from "@/common/translation";
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
import { import {
REPLICATION_PROGRESS_PRESENTATIONS, REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY, USER_INITIATED_REPLICATION_AUTHORITY,
@@ -139,6 +143,11 @@
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> { async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.deleteData(data); return await addOn.deleteData(data);
} }
function checkAutomaticSyncAvailable() {
if (addOnHiddenFileSync.isThisModuleEnabled()) return true;
Logger(translateMessage(HIDDEN_FILE_SYNC_DISABLED_NOTICE), LOG_LEVEL_NOTICE);
return false;
}
function askMode(evt: MouseEvent, title: string, key: string) { function askMode(evt: MouseEvent, title: string, key: string) {
const menu = new Menu(); const menu = new Menu();
menu.addItem((item) => item.setTitle(title).setIsLabel(true)); menu.addItem((item) => item.setTitle(title).setIsLabel(true));
@@ -149,6 +158,7 @@
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`) item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
.onClick((e) => { .onClick((e) => {
if (mode === MODE_AUTOMATIC) { if (mode === MODE_AUTOMATIC) {
if (!checkAutomaticSyncAvailable()) return;
askOverwriteModeForAutomatic(evt, key); askOverwriteModeForAutomatic(evt, key);
} else { } else {
setMode(key, mode as SYNC_MODE); setMode(key, mode as SYNC_MODE);
@@ -161,10 +171,11 @@
menu.showAtMouseEvent(evt); menu.showAtMouseEvent(evt);
} }
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") { function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
if (!checkAutomaticSyncAvailable()) return;
setMode(key, MODE_AUTOMATIC); setMode(key, MODE_AUTOMATIC);
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir()); const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`); const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files); void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
} }
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) { function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
const menu = new Menu(); const menu = new Menu();
@@ -0,0 +1,13 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
export function getAutomaticSyncTargetFiles(
key: string,
configDir: string,
entries: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]
): string[] {
const keys = key.startsWith("PLUGIN_ALL/")
? [`PLUGIN_DATA/${key.substring("PLUGIN_ALL/".length)}`, `PLUGIN_MAIN/${key.substring("PLUGIN_ALL/".length)}`]
: [key];
const files = new Set(keys.flatMap((entryKey) => entries[entryKey]?.files ?? []));
return [...files].map((file) => `${configDir}/${file}`);
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { MODE_AUTOMATIC, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
function createEntries(): ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] {
return {
"CONFIG/app.json": { key: "CONFIG/app.json", mode: MODE_AUTOMATIC, files: ["app.json"] },
"PLUGIN_MAIN/example": {
key: "PLUGIN_MAIN/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/main.js", "plugins/example/manifest.json"],
},
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/data.json"],
},
"PLUGIN_ETC/example": {
key: "PLUGIN_ETC/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/extra.json"],
},
"PLUGIN_MAIN/other": {
key: "PLUGIN_MAIN/other",
mode: MODE_AUTOMATIC,
files: ["plugins/other/main.js"],
},
};
}
describe("getAutomaticSyncTargetFiles", () => {
it("initialises a single selected item in the current configuration directory", () => {
expect(getAutomaticSyncTargetFiles("CONFIG/app.json", ".custom-config", createEntries())).toEqual([
".custom-config/app.json",
]);
});
it("initialises both saved child selections when the whole plug-in is selected", () => {
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", createEntries())).toEqual([
".obsidian/plugins/example/data.json",
".obsidian/plugins/example/main.js",
".obsidian/plugins/example/manifest.json",
]);
});
it("retains the available child files when the other child has no saved entry", () => {
const entries = createEntries();
delete entries["PLUGIN_DATA/example"];
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", entries)).toEqual([
".obsidian/plugins/example/main.js",
".obsidian/plugins/example/manifest.json",
]);
});
it("keeps an empty selection empty rather than including unrelated plug-ins", () => {
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/absent", ".obsidian", createEntries())).toEqual([]);
});
});
@@ -0,0 +1,260 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { HiddenFileSync } from "./CmdHiddenFileSync";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
const path = ".obsidian/plugins/example/main.js" as FilePath;
const content = "module.exports = class ExamplePlugin {};";
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function getText(entry: Parameters<typeof readContent>[0]) {
const value = await readContent(entry);
return typeof value === "string" ? value : new TextDecoder().decode(value);
}
async function createHiddenFileSync() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.usePluginSync = false;
fixture.settings.syncInternalFilesTargetPatterns = "" as typeof fixture.settings.syncInternalFilesTargetPatterns;
fixture.settings.syncInternalFilesIgnorePatterns = "" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
let fileContent = content;
let stat: UXStat | null = {
ctime: 1_000,
mtime: 2_000,
size: fileContent.length,
type: "file",
};
const values = new Map<string, unknown>();
const kvDB = {
get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
set: vi.fn(async <T>(key: string, value: T) => {
values.set(key, value);
}),
};
const storageAccess = {
statHidden: vi.fn(async () => stat),
readHiddenFileAuto: vi.fn(async () => fileContent),
writeHiddenFileAuto: vi.fn(async () => undefined),
removeHidden: vi.fn(async () => true),
isExistsIncludeHidden: vi.fn(async () => true),
ensureDir: vi.fn(async () => undefined),
triggerHiddenFile: vi.fn(async () => undefined),
setFile(nextContent: string | null, mtime = 2_000) {
fileContent = nextContent ?? "";
stat = nextContent === null ? null : { ctime: 1_000, mtime, size: fileContent.length, type: "file" };
},
};
const databaseService = { isDatabaseReady: vi.fn(() => true) };
const appLifecycle = { isReady: vi.fn(() => false), isSuspended: vi.fn(() => true) };
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: fixture.settings,
localDatabase: fixture.localDatabase,
kvDB,
storageAccess,
services: {
...fixture.services,
database: databaseService,
appLifecycle,
path: {
...fixture.services.path,
markChangesAreSame: vi.fn(),
unmarkChanges: vi.fn(),
},
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
},
},
cacheFileRegExps: new Map(),
cacheCustomisationSyncIgnoredFiles: new Map(),
_fileInfoLastProcessed: new Map(),
_databaseInfoLastProcessed: new Map(),
_fileInfoLastKnown: new Map(),
semaphore: Semaphore(10),
_log: vi.fn(),
});
return { fixture, hiddenFileSync, storageAccess, kvDB, databaseService, appLifecycle };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("Hidden File Sync optional save outcomes", () => {
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
const { fixture, hiddenFileSync } = await createHiddenFileSync();
await expect(hiddenFileSync.isTargetFile(path)).resolves.toBe(true);
vi.spyOn(fixture.localDatabase, "putDBEntry").mockRejectedValueOnce(new Error("temporary database failure"));
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(false);
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(true);
expect(fixture.localDatabase.putDBEntry).toHaveBeenCalledTimes(2);
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(saved).not.toBe(false);
});
it("rejects a failed strict write and retries the same fingerprint without delaying readiness", async () => {
const { fixture, hiddenFileSync, appLifecycle } = await createHiddenFileSync();
const putDBEntry = vi
.spyOn(fixture.localDatabase, "putDBEntry")
.mockRejectedValueOnce(new Error("temporary database failure"));
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
"temporary database failure"
);
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBeUndefined();
await expect(
hiddenFileSync._processOptionalFileEventWithResult(path, {
whenFlushRequested: new Promise<void>(() => undefined),
})
).resolves.toMatchObject({
kind: "completed",
});
expect(putDBEntry).toHaveBeenCalledTimes(2);
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(saved).not.toBe(false);
if (saved) expect(await getText(saved)).toBe(content);
expect(appLifecycle.isReady()).toBe(false);
expect(appLifecycle.isSuspended()).toBe(true);
});
it("rejects a false database-write result and can store a later edit after a same-content event", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
const putDBEntry = vi
.spyOn(fixture.localDatabase, "putDBEntry")
.mockImplementationOnce(async () => false as never);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
`Failed to store hidden file in the database: ${path}`
);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const first = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(first).not.toBe(false);
if (first) {
const firstRevision = first._rev;
hiddenFileSync._fileInfoLastProcessed.delete(path);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const unchanged = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(unchanged).not.toBe(false);
if (unchanged) expect(unchanged._rev).toBe(firstRevision);
}
storageAccess.setFile("module.exports = class UpdatedExamplePlugin {};", 3_000);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const updated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(updated).not.toBe(false);
if (updated) expect(await getText(updated)).toBe("module.exports = class UpdatedExamplePlugin {};");
expect(putDBEntry).toHaveBeenCalledTimes(3);
});
it("vetoes when the physical database is unavailable and ignores disabled or filtered events", async () => {
const { fixture, hiddenFileSync, databaseService } = await createHiddenFileSync();
databaseService.isDatabaseReady.mockReturnValue(false);
await expect(
hiddenFileSync._processOptionalFileEventWithResult(path, {
whenFlushRequested: new Promise<void>(() => undefined),
})
).resolves.toMatchObject({ kind: "veto", value: expect.any(String) });
databaseService.isDatabaseReady.mockReturnValue(true);
fixture.settings.syncInternalFiles = false;
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "not-applicable",
});
fixture.settings.syncInternalFiles = true;
fixture.settings.syncInternalFilesTargetPatterns =
"^\\.obsidian/snippets/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "not-applicable",
});
});
it("treats an absent replayed deletion as complete and leaves cache initialisation intact", async () => {
const { hiddenFileSync, storageAccess, kvDB } = await createHiddenFileSync();
storageAccess.setFile(null);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
const lifecycle = hiddenFileSync as unknown as {
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean>;
};
hiddenFileSync.core.settings.syncInternalFiles = false;
await lifecycle._everyOnDatabaseInitialized(false);
expect(kvDB.get).toHaveBeenCalledTimes(3);
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
});
it("rejects actual deletion failures and completes duplicate deletions without another write", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, content));
storageAccess.setFile(null);
const putRaw = vi
.spyOn(fixture.localDatabase, "putRaw")
.mockRejectedValueOnce(new Error("temporary deletion failure"));
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
"temporary deletion failure"
);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
hiddenFileSync.resetLastProcessedFile([path]);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(putRaw).toHaveBeenCalledTimes(2);
});
it("recreates an empty hidden file when its database entry is logically deleted", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, ""));
await expect(hiddenFileSync.deleteInternalFileOnDatabase(path)).resolves.toBe(true);
const deletedBase = await fixture.localDatabase.getDBEntry(
`i:${path}` as FilePath,
undefined,
false,
true,
true
);
expect(deletedBase).not.toBe(false);
if (deletedBase) expect(deletedBase.deleted).toBe(true);
if (!deletedBase) throw new Error("The logically deleted test entry could not be loaded");
vi.spyOn(fixture.localDatabase, "getDBEntry").mockResolvedValueOnce(deletedBase);
storageAccess.setFile("", 4_000);
const putDBEntry = vi.spyOn(fixture.localDatabase, "putDBEntry");
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(putDBEntry).toHaveBeenCalledTimes(1);
const recreated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(recreated).not.toBe(false);
if (recreated) {
expect(recreated.deleted).not.toBe(true);
expect(await getText(recreated)).toBe("");
}
});
});
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
import { HiddenFileSync } from "./CmdHiddenFileSync";
const hiddenPath = ".obsidian/plugins/example/main.js" as FilePath;
const prefixedPath = `i:${hiddenPath}`;
const body = "module.exports = class ExamplePlugin {};";
const allowList = "\\.(?:md|png|pdf)$";
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function hiddenFiles() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.usePluginSync = false;
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: fixture.settings,
services: {
...fixture.services,
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
},
},
cacheFileRegExps: new Map(),
cacheCustomisationSyncIgnoredFiles: new Map(),
});
return { ...fixture, hiddenFileSync };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("issue #1272: Hidden File Sync with normal-file selectors", () => {
it("stores a selected hidden file while the normal-file allow-list is set", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
ok: true,
});
const stored = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
expect(stored).not.toBe(false);
if (stored) expect(await readContent(stored)).toBe(body);
});
it("reads existing hidden-file content while the normal-file allow-list is set", async () => {
const fixture = await hiddenFiles();
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
ok: true,
});
const meta = await fixture.localDatabase.getDBEntryMeta(prefixedPath as FilePath);
if (!meta) throw new Error("The seeded hidden-file Metadata is unavailable");
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
const entry = await fixture.localDatabase.getDBEntryFromMeta(meta);
expect(entry).not.toBe(false);
if (entry) expect(await readContent(entry)).toBe(body);
});
it("stores and reads the same hidden file when the normal-file allow-list is empty", async () => {
const fixture = await hiddenFiles();
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
ok: true,
});
const entry = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
expect(entry).not.toBe(false);
if (entry) expect(await readContent(entry)).toBe(body);
});
it("continues to apply the normal-file allow-list to ordinary files", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
expect(fixture.localDatabase.isTargetFile("notes/example.md" as FilePath)).toBe(true);
expect(fixture.localDatabase.isTargetFile("notes/example.js" as FilePath)).toBe(false);
expect(fixture.localDatabase.isTargetFile("i:.obsidian/example.md" as FilePath)).toBe(true);
});
it("keeps Customisation Sync documents usable with the same normal-file allow-list", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
const path = "ix:device/PLUGIN_MAIN/example%main.js" as FilePath;
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, body))).resolves.toMatchObject({
ok: true,
});
const entry = await fixture.localDatabase.getDBEntry(path);
expect(entry).not.toBe(false);
if (entry) expect(await readContent(entry)).toBe(body);
});
it("keeps Hidden File Sync target and ignore patterns effective", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncInternalFilesTargetPatterns =
"^\\.obsidian/plugins/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
await expect(fixture.hiddenFileSync.isTargetFile(".obsidian/snippets/example.css" as FilePath)).resolves.toBe(
false
);
fixture.settings.syncInternalFilesIgnorePatterns =
"main\\.js$" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(false);
});
it("compatibility: applies the normal-file ignore-list to hidden documents", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
fixture.settings.syncIgnoreRegEx = "^\\.obsidian/" as typeof fixture.settings.syncIgnoreRegEx;
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
});
it("rejects hidden documents when Hidden File Sync is disabled", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncInternalFiles = false;
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
});
});
+158 -11
View File
@@ -54,6 +54,8 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts"; import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts"; import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts"; import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts"; import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
@@ -69,6 +71,9 @@ type HiddenFileInitialisationProgress = {
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes"; const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000; const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
export const HIDDEN_FILE_SYNC_DISABLED_NOTICE =
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
function getComparingMTime( function getComparingMTime(
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined, doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
includeDeleted = false includeDeleted = false
@@ -161,9 +166,7 @@ export class HiddenFileSync extends LiveSyncCommands {
// return true; // return true;
// } // }
private async _everyOnDatabaseInitialized(showNotice: boolean) { private async _everyOnDatabaseInitialized(showNotice: boolean) {
this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed"); await this.ensureProcessedCaches();
this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database");
this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown");
if (this.isThisModuleEnabled()) { if (this.isThisModuleEnabled()) {
if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) { if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) {
this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE); this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE);
@@ -249,6 +252,26 @@ export class HiddenFileSync extends LiveSyncCommands {
return false; return false;
} }
async _processOptionalFileEventWithResult(
path: FilePath,
_options?: OptionalFileEventOptions
): Promise<HandlerOutcome<void, string>> {
if (!this.isThisModuleEnabled()) return { kind: "not-applicable" };
if (!(await this.isTargetFile(path))) return { kind: "not-applicable" };
if (!this._isDatabaseReady()) {
return { kind: "veto", value: "The local database is not ready" };
}
await this.ensureProcessedCaches();
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) {
return { kind: "not-applicable" };
}
if (!(await this.trackStorageFileModificationStrict(path))) {
return { kind: "not-applicable" };
}
return { kind: "completed" };
}
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> { _anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
if (isInternalMetadata(path)) { if (isInternalMetadata(path)) {
this.queueConflictCheck(path); this.queueConflictCheck(path);
@@ -317,6 +340,51 @@ export class HiddenFileSync extends LiveSyncCommands {
_fileInfoLastKnown!: MapLike<string, number>; _fileInfoLastKnown!: MapLike<string, number>;
_databaseInfoLastProcessed!: MapLike<string, string>; _databaseInfoLastProcessed!: MapLike<string, string>;
private processedCacheStore: unknown;
private processedCacheStoreReady = false;
private processedCacheLoadStore: unknown;
private processedCacheLoad?: Promise<void>;
private async ensureProcessedCaches() {
const store = this.kvDB;
if (!store) throw new Error("The Hidden File Sync cache store is unavailable");
if (this.processedCacheStoreReady && this.processedCacheStore === store) return;
if (this.processedCacheLoad && this.processedCacheLoadStore === store) {
await this.processedCacheLoad;
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
}
return;
}
let load!: Promise<void>;
load = Promise.all([
autosaveCache<string, string>(store, "hidden-file-lastProcessed"),
autosaveCache<string, string>(store, "hidden-file-lastProcessed-database"),
autosaveCache<string, number>(store, "hidden-file-lastKnown"),
])
.then(([fileInfoLastProcessed, databaseInfoLastProcessed, fileInfoLastKnown]) => {
if (this.processedCacheLoad !== load || this.kvDB !== store) return;
this._fileInfoLastProcessed = fileInfoLastProcessed;
this._databaseInfoLastProcessed = databaseInfoLastProcessed;
this._fileInfoLastKnown = fileInfoLastKnown;
this.processedCacheStore = store;
this.processedCacheStoreReady = true;
})
.finally(() => {
if (this.processedCacheLoad === load) {
this.processedCacheLoad = undefined;
this.processedCacheLoadStore = undefined;
}
});
this.processedCacheLoadStore = store;
this.processedCacheLoad = load;
await load;
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
}
}
statToKey(stat: UXStat | null) { statToKey(stat: UXStat | null) {
return `${stat?.mtime ?? 0}-${stat?.size ?? 0}`; return `${stat?.mtime ?? 0}-${stat?.size ?? 0}`;
} }
@@ -600,7 +668,6 @@ Offline Changed files: ${processFiles.length}`;
this._log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE); this._log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE);
stat = cache.stat; stat = cache.stat;
} }
this.updateLastProcessedFile(path, stat!);
const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0"); const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0");
const nowIsNotFound = cache.deleted; const nowIsNotFound = cache.deleted;
const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified"; const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified";
@@ -651,6 +718,27 @@ Offline Changed files: ${processFiles.length}`;
return true; return true;
} }
private async trackStorageFileModificationStrict(path: FilePath): Promise<boolean> {
return await this.serializedForEvent(path, async () => {
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) return false;
const stat = await this.core.storageAccess.statHidden(path);
if (stat != null && stat.type != "file") return true;
const key = await this.fileToStatKey(path, stat);
if (this.getLastProcessedFileKey(path) === key) {
this._log(`${path} Already processed.`, LOG_LEVEL_DEBUG);
return true;
}
const fileInfo = await this.loadFileWithInfo(path);
if (fileInfo.deleted) {
await this.deleteInternalFileOnDatabaseStrict(path);
return true;
}
await this.storeInternalFileToDatabaseStrict(fileInfo);
return true;
});
}
// --> Conflict processing // --> Conflict processing
// Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue. // Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue.
@@ -1444,6 +1532,11 @@ Offline Changed files: ${files.length}`;
initialisationProgress?: HiddenFileInitialisationProgress initialisationProgress?: HiddenFileInitialisationProgress
) { ) {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
if (!this.isThisModuleEnabled()) {
this._log($msg(HIDDEN_FILE_SYNC_DISABLED_NOTICE), logLevel);
initialisationProgress?.done($msg("Hidden File Sync initialisation was not started."));
return false;
}
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel); const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
// p.log("Resolving conflicts before starting..."); // p.log("Resolving conflicts before starting...");
// await this.resolveConflictOnInternalFiles(); // await this.resolveConflictOnInternalFiles();
@@ -1494,12 +1587,25 @@ Offline Changed files: ${files.length}`;
await this.scanAllDatabaseChanges(showChildNotices, true, false); await this.scanAllDatabaseChanges(showChildNotices, true, false);
} }
p.done(); p.done();
return true;
} }
// <-- Initialization functions // <-- Initialization functions
// --> Storage To Database Functions // --> Storage To Database Functions
async __loadBaseSaveData(file: FilePath, includeContent = true): Promise<LoadedEntry | false> { async __loadBaseSaveData(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
return await this.loadBaseSaveData(file, includeContent, false);
}
private async loadBaseSaveDataStrict(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
return await this.loadBaseSaveData(file, includeContent, true);
}
private async loadBaseSaveData(
file: FilePath,
includeContent: boolean,
strict: boolean
): Promise<LoadedEntry | false> {
const prefixedFileName = addPrefix(file, ICHeader); const prefixedFileName = addPrefix(file, ICHeader);
const id = await this.path2id(prefixedFileName, ICHeader); const id = await this.path2id(prefixedFileName, ICHeader);
try { try {
@@ -1525,6 +1631,7 @@ Offline Changed files: ${files.length}`;
return old; return old;
} }
} catch (ex) { } catch (ex) {
if (strict) throw ex;
this._log(`Getting base save data failed`); this._log(`Getting base save data failed`);
this._log(ex, LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE);
return false; return false;
@@ -1552,6 +1659,18 @@ Offline Changed files: ${files.length}`;
} }
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) { async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
return await this.storeInternalFileToDatabaseCore(file, forceWrite, false);
}
private async storeInternalFileToDatabaseStrict(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
return await this.storeInternalFileToDatabaseCore(file, forceWrite, true);
}
private async storeInternalFileToDatabaseCore(
file: InternalFileInfo | UXFileInfo,
forceWrite: boolean,
strict: boolean
) {
const storeFilePath = stripAllPrefixes(file.path); const storeFilePath = stripAllPrefixes(file.path);
const storageFilePath = file.path; const storageFilePath = file.path;
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) { if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
@@ -1565,9 +1684,11 @@ Offline Changed files: ${files.length}`;
if (fileInfo.deleted) { if (fileInfo.deleted) {
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`); throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
} }
const baseData = await this.__loadBaseSaveData(storeFilePath, true); const baseData = strict
? await this.loadBaseSaveDataStrict(storeFilePath, true)
: await this.__loadBaseSaveData(storeFilePath, true);
if (baseData === false) throw new Error("Failed to load base data"); if (baseData === false) throw new Error("Failed to load base data");
if (baseData._rev && !forceWrite) { if (baseData._rev && !baseData.deleted && !baseData._deleted && !forceWrite) {
// Not newly created, we should check the content is actually modified. // Not newly created, we should check the content is actually modified.
const isSame = await isDocContentSame(readAsBlob(baseData), fileInfo.body); const isSame = await isDocContentSame(readAsBlob(baseData), fileInfo.body);
if (isSame) { if (isSame) {
@@ -1592,11 +1713,15 @@ Offline Changed files: ${files.length}`;
this.updateLastProcessed(storeFilePath, saveData, fileInfo.stat); this.updateLastProcessed(storeFilePath, saveData, fileInfo.stat);
} }
const success = ret && ret.ok; const success = ret && ret.ok;
if (!success && strict) {
throw new Error(`Failed to store hidden file in the database: ${storeFilePath}`);
}
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) ${success ? "Done" : "Failed"}`); this._log(`STORAGE --> DB:${storageFilePath}: (hidden) ${success ? "Done" : "Failed"}`);
return success; return success;
} catch (ex) { } catch (ex) {
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) Failed`); this._log(`STORAGE --> DB:${storageFilePath}: (hidden) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE);
if (strict) throw ex;
return false; return false;
} }
}); });
@@ -1676,6 +1801,14 @@ Offline Changed files: ${files.length}`;
} }
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) { async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, false);
}
private async deleteInternalFileOnDatabaseStrict(filenameSrc: FilePath, forceWrite = false) {
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, true);
}
private async deleteInternalFileOnDatabaseCore(filenameSrc: FilePath, _forceWrite: boolean, strict: boolean) {
const storeFilePath = filenameSrc; const storeFilePath = filenameSrc;
const storageFilePath = filenameSrc; const storageFilePath = filenameSrc;
const displayFileName = filenameSrc; const displayFileName = filenameSrc;
@@ -1686,18 +1819,26 @@ Offline Changed files: ${files.length}`;
} }
return await serialized("file-" + prefixedFileName, async () => { return await serialized("file-" + prefixedFileName, async () => {
try { try {
const baseData = await this.__loadBaseSaveData(storeFilePath, false); const baseData = strict
? await this.loadBaseSaveDataStrict(storeFilePath, false)
: await this.__loadBaseSaveData(storeFilePath, false);
if (baseData === false) throw new Error("Failed to load base data during deleting"); if (baseData === false) throw new Error("Failed to load base data during deleting");
if (baseData._conflicts !== undefined) { if (baseData._conflicts !== undefined) {
for (const conflictRev of baseData._conflicts) { for (const conflictRev of baseData._conflicts) {
await this.localDatabase.removeRevision(baseData._id, conflictRev); const removed = await this.localDatabase.removeRevision(baseData._id, conflictRev);
if (strict && removed === false) {
throw new Error(`Failed to remove conflicted hidden-file revision ${conflictRev}`);
}
this._log( this._log(
`STORAGE -x> DB: ${displayFileName}: (hidden) conflict removed ${baseData._rev} => ${conflictRev}`, `STORAGE -x> DB: ${displayFileName}: (hidden) conflict removed ${baseData._rev} => ${conflictRev}`,
LOG_LEVEL_VERBOSE LOG_LEVEL_VERBOSE
); );
} }
} }
if (baseData.deleted) { const alreadyDeleted = strict
? !baseData._rev || baseData.deleted || baseData._deleted
: baseData.deleted;
if (alreadyDeleted) {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) already deleted`, LOG_LEVEL_VERBOSE); this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) already deleted`, LOG_LEVEL_VERBOSE);
this.updateLastProcessedDeletion(storeFilePath, baseData); this.updateLastProcessedDeletion(storeFilePath, baseData);
return true; return true;
@@ -1718,11 +1859,13 @@ Offline Changed files: ${files.length}`;
return true; return true;
} else { } else {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`); this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
if (strict) throw new Error(`Failed to delete hidden file from the database: ${storeFilePath}`);
return false; return false;
} }
} catch (ex) { } catch (ex) {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`); this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE);
if (strict) throw ex;
return false; return false;
} }
}); });
@@ -1925,6 +2068,7 @@ Offline Changed files: ${files.length}`;
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) { async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
let initialisationProgress: HiddenFileInitialisationProgress | undefined; let initialisationProgress: HiddenFileInitialisationProgress | undefined;
let initialised = false;
let result: ConfigureHiddenFileSyncResult; let result: ConfigureHiddenFileSyncResult;
try { try {
result = await configureHiddenFileSyncMode(mode, { result = await configureHiddenFileSyncMode(mode, {
@@ -1954,7 +2098,7 @@ Offline Changed files: ${files.length}`;
); );
}, },
initialise: async (direction) => { initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress); initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
initialisationProgress = undefined; initialisationProgress = undefined;
}, },
}); });
@@ -1962,7 +2106,7 @@ Offline Changed files: ${files.length}`;
initialisationProgress?.done("Failed"); initialisationProgress?.done("Failed");
throw error; throw error;
} }
if (result == "ignored" || result == "disabled") { if (result == "ignored" || result == "disabled" || !initialised) {
return; return;
} }
// this.plugin.settings.useAdvancedMode = true; // this.plugin.settings.useAdvancedMode = true;
@@ -2084,6 +2228,9 @@ Offline Changed files: ${files.length}`;
// services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this)); // services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this)); services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this)); services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
this._processOptionalFileEventWithResult.bind(this)
);
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this)); services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this)); services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this));
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this)); services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
} }
describe("HiddenFileSync configuration-change notices", () => { describe("HiddenFileSync configuration-change notices", () => {
it.each(["pushForce", "pullForce", "safe"] as const)(
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
async (direction) => {
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const path = ".obsidian/example.json" as FilePath;
const operations = {
rebuildFromStorage: vi.fn(async () => [path]),
rebuildFromDatabase: vi.fn(async () => []),
rebuildMerging: vi.fn(async () => [path]),
adoptCurrentStorageFilesAsProcessed: vi.fn(async () => undefined),
adoptCurrentDatabaseFilesAsProcessed: vi.fn(async () => undefined),
scanAllStorageChanges: vi.fn(async () => undefined),
scanAllDatabaseChanges: vi.fn(async () => undefined),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
const settings = { syncInternalFiles: false };
const createProgress = vi.fn(() => progress);
const log = vi.fn();
Object.assign(hiddenFileSync, {
core: { settings },
_progress: createProgress,
_log: log,
...operations,
});
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, [path]);
for (const operation of Object.values(operations)) {
expect(operation).not.toHaveBeenCalled();
}
expect(result).toBe(false);
expect(settings.syncInternalFiles).toBe(false);
expect(createProgress).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Hidden File Sync is disabled"), LOG_LEVEL_NOTICE);
expect(progress.done).not.toHaveBeenCalled();
}
);
it("finishes an existing preparation Notice as not started when initialisation is disabled", async () => {
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const createProgress = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: false } },
_progress: createProgress,
_log: vi.fn(),
});
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true, false, progress);
expect(result).toBe(false);
expect(createProgress).not.toHaveBeenCalled();
expect(progress.done).toHaveBeenCalledExactlyOnceWith("Hidden File Sync initialisation was not started.");
});
it("does not apply a stored deletion when initialisation is requested while disabled", async () => {
const path = ".obsidian/example.json" as FilePath;
const deleted: MetaEntry = {
_id: "i:example" as DocumentID,
_rev: "2-deleted",
path: `i:${path}` as FilePathWithPrefix,
type: "plain",
ctime: 1,
mtime: 10,
size: 0,
deleted: true,
children: [],
eden: {},
};
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const deleteFile = vi.fn(async () => "OK" as const);
const updateDeletion = vi.fn();
const getFiles = vi.fn(async () => [deleted]);
const processedStorage = new Map([[path, "storage-processed"]]);
const processedDatabase = new Map([[path, "database-processed"]]);
const resetProcessed = vi.fn(() => processedDatabase.clear());
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: { syncInternalFiles: false },
services: { vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) } },
},
_progress: vi.fn(() => progress),
_log: vi.fn(),
_verbose: vi.fn(),
_fileInfoLastProcessed: processedStorage,
_databaseInfoLastProcessed: processedDatabase,
getAllDatabaseFiles: getFiles,
getPath: (doc: MetaEntry) => doc.path,
resetLastProcessedDatabase: resetProcessed,
serializedForEvent: async (_path: unknown, operation: () => Promise<unknown>) => await operation(),
__deleteFile: deleteFile,
updateLastProcessedDeletion: updateDeletion,
adoptCurrentStorageFilesAsProcessed: vi.fn(),
adoptCurrentDatabaseFilesAsProcessed: vi.fn(),
scanAllStorageChanges: vi.fn(),
scanAllDatabaseChanges: vi.fn(),
});
await hiddenFileSync.initialiseInternalFileSync("pullForce", true);
expect(deleteFile).not.toHaveBeenCalled();
expect(updateDeletion).not.toHaveBeenCalled();
expect(getFiles).not.toHaveBeenCalled();
expect(resetProcessed).not.toHaveBeenCalled();
expect(processedStorage).toEqual(new Map([[path, "storage-processed"]]));
expect(processedDatabase).toEqual(new Map([[path, "database-processed"]]));
});
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => { it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
const commands: Array<{ const commands: Array<{
id: string; id: string;
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
expect(command?.checkCallback?.(true)).toBe(false); expect(command?.checkCallback?.(true)).toBe(false);
} }
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(false);
expect(command?.checkCallback?.(false)).toBe(false);
}
settings.syncInternalFiles = true; settings.syncInternalFiles = true;
settings.useAdvancedMode = true; settings.useAdvancedMode = true;
for (const commandId of commandIds) { for (const commandId of commandIds) {
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
const scanAllDatabaseChanges = vi.fn(async () => undefined); const scanAllDatabaseChanges = vi.fn(async () => undefined);
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, { Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: true } },
_progress: vi.fn(() => progress), _progress: vi.fn(() => progress),
rebuildMerging, rebuildMerging,
adoptCurrentStorageFilesAsProcessed, adoptCurrentStorageFilesAsProcessed,
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
scanAllDatabaseChanges, scanAllDatabaseChanges,
}); });
await hiddenFileSync.initialiseInternalFileSync("safe", true); const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
expect(result).toBe(true);
expect(rebuildMerging).toHaveBeenCalledWith(false, false); expect(rebuildMerging).toHaveBeenCalledWith(false, false);
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false); expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false); expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
expect(progress.done).toHaveBeenCalledOnce(); expect(progress.done).toHaveBeenCalledOnce();
}); });
it.each(["pushForce", "pullForce", "safe"] as const)(
"retains the selected files and completes %s initialisation when enabled",
async (direction) => {
const files = [".obsidian/plugins/example/main.js", ".obsidian/plugins/example/data.json"] as FilePath[];
const entries = files.map((path) => ({ path: `i:${path}` as FilePathWithPrefix }));
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const rebuildFromStorage = vi.fn(async () => files);
const rebuildFromDatabase = vi.fn(async () => entries);
const rebuildMerging = vi.fn(async () => files);
const adoptStorage = vi.fn();
const adoptDatabase = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: true } },
_progress: vi.fn(() => progress),
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
rebuildFromStorage,
rebuildFromDatabase,
rebuildMerging,
adoptCurrentStorageFilesAsProcessed: adoptStorage,
adoptCurrentDatabaseFilesAsProcessed: adoptDatabase,
scanAllStorageChanges: vi.fn(),
scanAllDatabaseChanges: vi.fn(),
});
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, files);
expect(result).toBe(true);
if (direction === "pushForce") {
expect(rebuildFromStorage).toHaveBeenCalledExactlyOnceWith(false, files, false);
expect(rebuildFromDatabase).not.toHaveBeenCalled();
expect(rebuildMerging).not.toHaveBeenCalled();
} else if (direction === "pullForce") {
expect(rebuildFromDatabase).toHaveBeenCalledExactlyOnceWith(false, files, false);
expect(rebuildFromStorage).not.toHaveBeenCalled();
expect(rebuildMerging).not.toHaveBeenCalled();
} else {
expect(rebuildMerging).toHaveBeenCalledExactlyOnceWith(false, files);
expect(rebuildFromStorage).not.toHaveBeenCalled();
expect(rebuildFromDatabase).not.toHaveBeenCalled();
}
expect(adoptStorage).toHaveBeenCalledExactlyOnceWith(files);
expect(adoptDatabase).toHaveBeenCalledExactlyOnceWith(files);
expect(progress.done).toHaveBeenCalledOnce();
}
);
it("does not report completion when the initialisation entry point refuses to start", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
await handlers.initialise("safe");
return "enabled";
});
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { services: { setting: { applyPartial: vi.fn() } } },
_progress: vi.fn(() => progress),
_log: log,
initialiseInternalFileSync: vi.fn(async () => false),
});
await hiddenFileSync.configureHiddenFileSync("MERGE");
expect(log).not.toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
});
it("retirement guard: does not restore separate gathering and restart Notices", async () => { it("retirement guard: does not restore separate gathering and restart Notices", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => { vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable(); await handlers.enable();
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
const applyPartial = vi.fn(async () => { const applyPartial = vi.fn(async () => {
events.push("apply-settings"); events.push("apply-settings");
}); });
const initialiseInternalFileSync = vi.fn(async () => undefined); const initialiseInternalFileSync = vi.fn(async () => true);
const log = vi.fn(); const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, { Object.assign(hiddenFileSync, {
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { ValueStoreError } from "@vrtmrz/value-store";
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { import {
AcceptedStatus, AcceptedStatus,
@@ -33,6 +34,14 @@
let settings = $state<P2PSyncSetting>(initialSettings); let settings = $state<P2PSyncSetting>(initialSettings);
let deviceName = $state<string>(""); let deviceName = $state<string>("");
let deviceNameReady = $state(false);
let dialogStatusReady = $state(false);
type StorageErrorKey = "deviceNameLoad" | "dialogStatusLoad" | "deviceNameWrite" | "dialogStatusWrite";
let storageErrors = $state<Partial<Record<StorageErrorKey, string>>>({});
let mounted = false;
let deviceNameLoadSequence = 0;
let deviceNameWriteQueue: Promise<void> = Promise.resolve();
let dialogStatusWriteQueue: Promise<void> = Promise.resolve();
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled); let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
let eRelay = $state<string>(initialSettings.P2P_relays); let eRelay = $state<string>(initialSettings.P2P_relays);
@@ -68,6 +77,7 @@
); );
async function saveAndApply() { async function saveAndApply() {
const savedDeviceName = eDeviceName;
// const newSettings = { // const newSettings = {
// ...currentSettings(), // ...currentSettings(),
// P2P_Enabled: eP2PEnabled, // P2P_Enabled: eP2PEnabled,
@@ -92,8 +102,39 @@
}, },
true true
); );
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName); try {
deviceName = eDeviceName; await queueDeviceNameWrite(savedDeviceName);
if (!mounted) return;
deviceName = savedDeviceName;
clearStorageError("deviceNameWrite");
} catch (error) {
setStorageError("deviceNameWrite", error);
}
}
function setStorageError(key: StorageErrorKey, error: unknown) {
if (mounted) {
storageErrors = {
...storageErrors,
[key]: error instanceof Error ? error.message : String(error),
};
}
}
function clearStorageError(key: StorageErrorKey) {
if (!mounted) return;
const nextErrors = { ...storageErrors };
delete nextErrors[key];
storageErrors = nextErrors;
}
function queueDeviceNameWrite(name: string): Promise<void> {
const write = deviceNameWriteQueue.catch(() => undefined).then(async () => {
if (!mounted) return;
await services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, name);
});
deviceNameWriteQueue = write;
return write;
} }
async function revert() { async function revert() {
eP2PEnabled = settings.P2P_Enabled; eP2PEnabled = settings.P2P_Enabled;
@@ -110,10 +151,7 @@
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined); let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => { const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
if (force) { if (force) {
const initDeviceName = void loadDeviceName(true).catch(() => undefined);
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
deviceName = initDeviceName;
eDeviceName = initDeviceName;
} }
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d; const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled; if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
@@ -129,6 +167,7 @@
settings = d; settings = d;
}; };
onMount(() => { onMount(() => {
mounted = true;
const r = events.onEvent("setting-saved", async (d) => { const r = events.onEvent("setting-saved", async (d) => {
applyLoadSettings(d, false); applyLoadSettings(d, false);
closeServer(); closeServer();
@@ -143,15 +182,52 @@
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => { const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorInfo = status; replicatorInfo = status;
}); });
applyLoadSettings(currentSettings(), true); applyLoadSettings(currentSettings(), false);
void loadInitialPreferences().catch(() => undefined);
host.p2p.diagnostics.requestStatus(); host.p2p.diagnostics.requestStatus();
return () => { return () => {
mounted = false;
r(); r();
rx(); rx();
r2(); r2();
r3(); r3();
}; };
}); });
async function loadInitialPreferences() {
const deviceNameLoad = loadDeviceName(true).catch(() => undefined);
const dialogStatusLoad = readDialogStatus().then(
(dialogStatus) => {
if (!mounted) return;
isNoticeOpened = dialogStatus.notice ?? true;
isSettingOpened = dialogStatus.setting ?? true;
dialogStatusReady = true;
clearStorageError("dialogStatusLoad");
},
(error: unknown) => {
setStorageError("dialogStatusLoad", error);
}
);
await Promise.all([deviceNameLoad, dialogStatusLoad]);
}
async function loadDeviceName(force: boolean) {
const request = ++deviceNameLoadSequence;
try {
const savedDeviceName = await services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME, "");
if (!mounted || request !== deviceNameLoadSequence) return;
const initialDeviceName = savedDeviceName;
if (force || !isDeviceNameModified) {
deviceName = initialDeviceName;
eDeviceName = initialDeviceName;
}
deviceNameReady = true;
clearStorageError("deviceNameLoad");
} catch (error) {
if (mounted && request === deviceNameLoadSequence) setStorageError("deviceNameLoad", error);
throw error;
}
}
let isConnected = $derived.by(() => { let isConnected = $derived.by(() => {
return serverInfo?.isConnected ?? false; return serverInfo?.isConnected ?? false;
}); });
@@ -234,26 +310,42 @@
} }
const initialDialogStatusKey = `p2p-dialog-status`; const initialDialogStatusKey = `p2p-dialog-status`;
const getDialogStatus = () => { const readDialogStatus = async (): Promise<{ notice?: boolean; setting?: boolean }> => {
try { try {
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as { const initialDialogStatus = await services.config.getObject(initialDialogStatusKey);
notice?: boolean; if (typeof initialDialogStatus !== "object" || initialDialogStatus === null || Array.isArray(initialDialogStatus)) {
setting?: boolean;
};
return initialDialogStatus;
} catch {
return {}; return {};
} }
const status = initialDialogStatus as Record<string, unknown>;
return {
...(typeof status.notice === "boolean" ? { notice: status.notice } : {}),
...(typeof status.setting === "boolean" ? { setting: status.setting } : {}),
}; };
const initialDialogStatus = getDialogStatus(); } catch (error) {
let isNoticeOpened = $state<boolean>(initialDialogStatus.notice ?? true); if (error instanceof ValueStoreError && error.code === "invalid-data") {
let isSettingOpened = $state<boolean>(initialDialogStatus.setting ?? true); // Invalid optional UI state is recovered with the historical defaults.
return {};
}
throw error;
}
};
let isNoticeOpened = $state<boolean>(true);
let isSettingOpened = $state<boolean>(true);
$effect(() => { $effect(() => {
if (!mounted || !dialogStatusReady) return;
const dialogStatus = { const dialogStatus = {
notice: isNoticeOpened, notice: isNoticeOpened,
setting: isSettingOpened, setting: isSettingOpened,
}; };
services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus)); const write = dialogStatusWriteQueue.catch(() => undefined).then(async () => {
if (!mounted) return;
await services.config.setObject(initialDialogStatusKey, dialogStatus);
});
dialogStatusWriteQueue = write;
void write.then(
() => clearStorageError("dialogStatusWrite"),
(error: unknown) => setStorageError("dialogStatusWrite", error)
);
}); });
let isObsidian = $derived.by(() => { let isObsidian = $derived.by(() => {
return services.API.getPlatform() === "obsidian"; return services.API.getPlatform() === "obsidian";
@@ -261,6 +353,9 @@
</script> </script>
<article> <article>
{#each Object.values(storageErrors) as storageError}
<p role="alert">{storageError}</p>
{/each}
<h1>{_msg("Peer to Peer Replicator")}</h1> <h1>{_msg("Peer to Peer Replicator")}</h1>
<details bind:open={isNoticeOpened}> <details bind:open={isNoticeOpened}>
<summary>{_msg("P2P.Note.Summary")}</summary> <summary>{_msg("P2P.Note.Summary")}</summary>
@@ -345,6 +440,7 @@
type="text" type="text"
placeholder="iphone-16" placeholder="iphone-16"
bind:value={eDeviceName} bind:value={eDeviceName}
disabled={!deviceNameReady}
autocomplete="off" autocomplete="off"
/> />
</label> </label>
@@ -383,7 +479,7 @@
</tr> --> </tr> -->
</tbody> </tbody>
</table> </table>
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply} <button disabled={!isAnyModified || !deviceNameReady} class="button mod-cta" onclick={saveAndApply}
>{_msg("Save and Apply")}</button >{_msg("Save and Apply")}</button
> >
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button> <button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
@@ -1,4 +1,5 @@
import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps"; import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import { import {
REVIEW_HARNESS_SCENARIOS, REVIEW_HARNESS_SCENARIOS,
type ReviewHarnessScenarioId, type ReviewHarnessScenarioId,
@@ -101,7 +102,7 @@ export class ReviewHarnessView extends ItemView {
true true
); );
this.addActionButton(actions, "Restart and return", "review-harness-restart", () => this.addActionButton(actions, "Restart and return", "review-harness-restart", () =>
this.controller.prepareCompatibilityReviewRestart() this.controller.prepareCompatibilityReviewRestart().catch((error) => Logger(error, LOG_LEVEL_NOTICE))
); );
} }
} }
@@ -23,9 +23,9 @@ export interface ReviewHarnessRuntime {
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>; runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>; runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>; runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>;
readContinuation(): string | null; readContinuation(): Promise<string | null>;
writeContinuation(value: string): void; writeContinuation(value: string): Promise<void>;
deleteContinuation(): void; deleteContinuation(): Promise<void>;
restart(): void; restart(): void;
reportError(error: unknown): void; reportError(error: unknown): void;
copyText(value: string): Promise<void>; copyText(value: string): Promise<void>;
@@ -68,6 +68,7 @@ export class ReviewHarnessController {
private current: ReviewHarnessScenarioId | null = null; private current: ReviewHarnessScenarioId | null = null;
private resumedRequestId: string | null = null; private resumedRequestId: string | null = null;
private continuationError: string | null = null; private continuationError: string | null = null;
private continuationConsumption: Promise<void> | undefined;
constructor(private readonly runtime: ReviewHarnessRuntime) {} constructor(private readonly runtime: ReviewHarnessRuntime) {}
@@ -102,10 +103,20 @@ export class ReviewHarnessController {
}; };
} }
consumeContinuation(): void { consumeContinuation(): Promise<void> {
const serialised = this.runtime.readContinuation(); if (this.continuationConsumption) return this.continuationConsumption;
const consumption = this.consumeContinuationFromStore();
const trackedConsumption = consumption.finally(() => {
if (this.continuationConsumption === trackedConsumption) this.continuationConsumption = undefined;
});
this.continuationConsumption = trackedConsumption;
return trackedConsumption;
}
private async consumeContinuationFromStore(): Promise<void> {
const serialised = await this.runtime.readContinuation();
if (!serialised) return; if (!serialised) return;
this.runtime.deleteContinuation(); await this.runtime.deleteContinuation();
const parsed = parsePendingReviewRun(serialised); const parsed = parsePendingReviewRun(serialised);
if (!parsed.pendingRun) { if (!parsed.pendingRun) {
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid."; this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
@@ -232,7 +243,7 @@ export class ReviewHarnessController {
this.record("scenario-failed", id); this.record("scenario-failed", id);
} }
prepareCompatibilityReviewRestart(): void { async prepareCompatibilityReviewRestart(): Promise<void> {
const requestedAt = this.runtime.now().toISOString(); const requestedAt = this.runtime.now().toISOString();
const pending = { const pending = {
formatVersion: 1, formatVersion: 1,
@@ -241,7 +252,7 @@ export class ReviewHarnessController {
stage: "awaiting-restart", stage: "awaiting-restart",
requestedAt, requestedAt,
} as const; } as const;
this.runtime.writeContinuation(JSON.stringify(pending)); await this.runtime.writeContinuation(JSON.stringify(pending));
this.results["compatibility-review"] = { this.results["compatibility-review"] = {
status: "waiting-for-user", status: "waiting-for-user",
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.", detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
@@ -90,14 +90,14 @@ function createRuntime(): ReviewHarnessRuntime & {
detail: "File content hash measurements completed.", detail: "File content hash measurements completed.",
observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"], observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"],
})), })),
readContinuation() { async readContinuation() {
return this.continuation; return this.continuation;
}, },
writeContinuation(value) { async writeContinuation(value) {
this.events.push("write"); this.events.push("write");
this.continuation = value; this.continuation = value;
}, },
deleteContinuation() { async deleteContinuation() {
this.events.push("delete"); this.events.push("delete");
this.continuation = null; this.continuation = null;
}, },
@@ -251,7 +251,7 @@ describe("ReviewHarnessController", () => {
expect(controller.snapshot().running).toBe(false); expect(controller.snapshot().running).toBe(false);
}); });
it("deletes a one-shot continuation before exposing the resumed guided step", () => { it("deletes a one-shot continuation before exposing the resumed guided step", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
runtime.continuation = JSON.stringify({ runtime.continuation = JSON.stringify({
formatVersion: 1, formatVersion: 1,
@@ -262,7 +262,7 @@ describe("ReviewHarnessController", () => {
}); });
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation(); await controller.consumeContinuation();
expect(runtime.events).toEqual(["delete"]); expect(runtime.events).toEqual(["delete"]);
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({ expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
@@ -271,7 +271,7 @@ describe("ReviewHarnessController", () => {
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z"); expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
}); });
it("does not copy rejected continuation values into the report", () => { it("does not copy rejected continuation values into the report", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
runtime.continuation = JSON.stringify({ runtime.continuation = JSON.stringify({
formatVersion: 1, formatVersion: 1,
@@ -282,7 +282,7 @@ describe("ReviewHarnessController", () => {
}); });
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation(); await controller.consumeContinuation();
expect(controller.snapshot().continuationError).toContain("private-vault"); expect(controller.snapshot().continuationError).toContain("private-vault");
expect(controller.snapshot().results["compatibility-review"].detail).toBe( expect(controller.snapshot().results["compatibility-review"].detail).toBe(
@@ -292,7 +292,7 @@ describe("ReviewHarnessController", () => {
expect(controller.createReport()).not.toContain("secret-note.md"); expect(controller.createReport()).not.toContain("secret-note.md");
}); });
it("does not accept an injected request ID which could enter the report transcript", () => { it("does not accept an injected request ID which could enter the report transcript", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
runtime.continuation = JSON.stringify({ runtime.continuation = JSON.stringify({
formatVersion: 1, formatVersion: 1,
@@ -303,18 +303,18 @@ describe("ReviewHarnessController", () => {
}); });
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation(); await controller.consumeContinuation();
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed"); expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
expect(controller.createReport()).not.toContain("private-vault"); expect(controller.createReport()).not.toContain("private-vault");
expect(controller.createReport()).not.toContain("secret-note.md"); expect(controller.createReport()).not.toContain("secret-note.md");
}); });
it("persists the fixed continuation before requesting restart", () => { it("persists the fixed continuation before requesting restart", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
controller.prepareCompatibilityReviewRestart(); await controller.prepareCompatibilityReviewRestart();
expect(runtime.events).toEqual(["write", "restart"]); expect(runtime.events).toEqual(["write", "restart"]);
expect(JSON.parse(runtime.continuation!)).toMatchObject({ expect(JSON.parse(runtime.continuation!)).toMatchObject({
@@ -324,6 +324,135 @@ describe("ReviewHarnessController", () => {
}); });
}); });
it("waits for the continuation save before requesting restart", async () => {
const runtime = createRuntime();
let markSaveStarted!: () => void;
const saveStarted = new Promise<void>((resolve) => {
markSaveStarted = resolve;
});
let finishSave!: () => void;
const saving = new Promise<void>((resolve) => {
finishSave = resolve;
});
runtime.writeContinuation = vi.fn(async (value) => {
runtime.events.push("write-started");
markSaveStarted();
await saving;
runtime.continuation = value;
runtime.events.push("write-saved");
});
const controller = new ReviewHarnessController(runtime);
const preparing = controller.prepareCompatibilityReviewRestart();
await saveStarted;
expect(runtime.events).toEqual(["write-started"]);
expect(runtime.restart).not.toHaveBeenCalled();
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
finishSave();
await preparing;
expect(runtime.events).toEqual(["write-started", "write-saved", "restart"]);
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("keeps the guided review retryable when saving its continuation fails", async () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);
await controller.runScenario("compatibility-review");
runtime.writeContinuation = vi.fn(async () => {
runtime.events.push("write-failed");
throw new Error("continuation save failed");
});
await expect(controller.prepareCompatibilityReviewRestart()).rejects.toThrow("continuation save failed");
expect(runtime.events).toEqual(["write-failed"]);
expect(runtime.restart).not.toHaveBeenCalled();
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("waits for one continuation deletion before exposing it and coalesces concurrent consumption", async () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
formatVersion: 1,
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
scenarioId: "compatibility-review",
stage: "awaiting-restart",
requestedAt: "2026-07-18T11:59:00.000Z",
});
let markDeleteStarted!: () => void;
const deleteStarted = new Promise<void>((resolve) => {
markDeleteStarted = resolve;
});
let finishDelete!: () => void;
const deleting = new Promise<void>((resolve) => {
finishDelete = resolve;
});
runtime.readContinuation = vi.fn(async () => {
runtime.events.push("read");
return runtime.continuation;
});
runtime.deleteContinuation = vi.fn(async () => {
runtime.events.push("delete-started");
markDeleteStarted();
await deleting;
runtime.continuation = null;
runtime.events.push("delete-finished");
});
const controller = new ReviewHarnessController(runtime);
const firstConsumption = controller.consumeContinuation();
const duplicateConsumption = controller.consumeContinuation();
await deleteStarted;
expect(duplicateConsumption).toBe(firstConsumption);
expect(runtime.events).toEqual(["read", "delete-started"]);
expect(controller.snapshot().resumedRequestId).toBeNull();
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
finishDelete();
await firstConsumption;
expect(runtime.events).toEqual(["read", "delete-started", "delete-finished"]);
expect(runtime.readContinuation).toHaveBeenCalledOnce();
expect(runtime.deleteContinuation).toHaveBeenCalledOnce();
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("keeps a failed continuation deletion available for retry", async () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
formatVersion: 1,
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
scenarioId: "compatibility-review",
stage: "awaiting-restart",
requestedAt: "2026-07-18T11:59:00.000Z",
});
runtime.deleteContinuation = vi
.fn<ReviewHarnessRuntime["deleteContinuation"]>()
.mockRejectedValueOnce(new Error("continuation delete failed"))
.mockImplementation(async () => {
runtime.events.push("delete");
runtime.continuation = null;
});
const controller = new ReviewHarnessController(runtime);
await expect(controller.consumeContinuation()).rejects.toThrow("continuation delete failed");
expect(runtime.continuation).not.toBeNull();
expect(controller.snapshot().resumedRequestId).toBeNull();
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
await controller.consumeContinuation();
expect(runtime.continuation).toBeNull();
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("passes only after the actual compatibility review clears its pause", async () => { it("passes only after the actual compatibility review clears its pause", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime); const controller = new ReviewHarnessController(runtime);
+10 -1
View File
@@ -1,4 +1,5 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation"; import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps"; import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
setGetLanguage(getLanguage); setGetLanguage(getLanguage);
@@ -53,6 +54,7 @@ import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianR
import { useSyncGuardFeature } from "./serviceFeatures/syncGuard"; import { useSyncGuardFeature } from "./serviceFeatures/syncGuard";
import { SyncGuardOverlay } from "./serviceFeatures/syncGuard/SyncGuardOverlay"; import { SyncGuardOverlay } from "./serviceFeatures/syncGuard/SyncGuardOverlay";
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle"; import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
import { useJoinedDevicesFeature } from "./serviceFeatures/useJoinedDevices";
import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics"; import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>; export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin { export default class ObsidianLiveSyncPlugin extends Plugin {
@@ -200,6 +202,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
useObsidianReplicationRibbonFeature(core); useObsidianReplicationRibbonFeature(core);
useSyncGuardFeature(core, (onBypass) => new SyncGuardOverlay(this.app.workspace.containerEl, onBypass)); useSyncGuardFeature(core, (onBypass) => new SyncGuardOverlay(this.app.workspace.containerEl, onBypass));
useDatabaseDiagnosticsFeature(core); useDatabaseDiagnosticsFeature(core);
useJoinedDevicesFeature(core, serviceHub.context.noticeGroups);
useRemoteConfiguration(core); useRemoteConfiguration(core);
useSetupProtocolFeature(core, setupManager); useSetupProtocolFeature(core, setupManager);
@@ -234,6 +237,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
void this._startUp(); void this._startUp();
} }
override onunload(): void { override onunload(): void {
return void this.core.services.control.onUnload(); void this.core.services.control.onUnload().catch((error) => {
this.core.services.API.addLog(
"Failed to unload Self-hosted LiveSync: " + String(error),
LOG_LEVEL_URGENT,
"shutdown"
);
});
} }
} }
@@ -230,7 +230,13 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
} }
this.isLastHidden = isHidden; this.isLastHidden = isHidden;
await this.services.fileProcessing.commitPendingFileEvents(); const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
if (!pendingFileEventsCommitted) {
this._log(
"Pending file events remain because their commit did not complete before the window visibility changed.",
LOG_LEVEL_NOTICE
);
}
// Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the // Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the
// window is hidden, instead of suspending on hide. On hide we skip the suspend for both // window is hidden, instead of suspending on hide. On hide we skip the suspend for both
@@ -287,7 +293,10 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
if (this.settings.suspendFileWatching) return; if (this.settings.suspendFileWatching) return;
if (!this.settings.isConfigured) return; if (!this.settings.isConfigured) return;
if (!this.services.appLifecycle.isReady()) return; if (!this.services.appLifecycle.isReady()) return;
await this.services.fileProcessing.commitPendingFileEvents(); const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
if (!pendingFileEventsCommitted) {
return;
}
if (file == null) { if (file == null) {
return; return;
} }
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { ModuleObsidianEvents } from "./ModuleObsidianEvents"; import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive"; import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
type SetupOptions = { type SetupOptions = {
settings?: Partial<typeof DEFAULT_SETTINGS>; settings?: Partial<typeof DEFAULT_SETTINGS>;
@@ -23,6 +24,8 @@ function setup(opts: SetupOptions) {
onResumed: vi.fn(async () => true), onResumed: vi.fn(async () => true),
}; };
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) }; const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const replication = { replicateUnattendedByEvent: vi.fn(async () => undefined) };
const conflict = { queueCheckForIfOpen: vi.fn(async () => undefined) };
const boundedRemoteActivityCount = reactiveSource(0); const boundedRemoteActivityCount = reactiveSource(0);
const boundedLocalApplicationActivityCount = reactiveSource(0); const boundedLocalApplicationActivityCount = reactiveSource(0);
@@ -39,6 +42,8 @@ function setup(opts: SetupOptions) {
setting: { saveSettingData: vi.fn(async () => undefined) }, setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle, appLifecycle,
fileProcessing, fileProcessing,
replication,
conflict,
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount }, replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
}, },
settings: { settings: {
@@ -61,6 +66,9 @@ function setup(opts: SetupOptions) {
module, module,
appLifecycle, appLifecycle,
fileProcessing, fileProcessing,
replication,
conflict,
api: core._services.API,
boundedRemoteActivityCount, boundedRemoteActivityCount,
boundedLocalApplicationActivityCount, boundedLocalApplicationActivityCount,
}; };
@@ -303,6 +311,23 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1); expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
}); });
it("logs a failed pending-file commit and still suspends on hide", async () => {
const { module, appLifecycle, fileProcessing, api } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: true },
hidden: true,
});
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
expect(api.addLog).toHaveBeenCalledWith(
expect.stringContaining("Pending file events remain"),
LOG_LEVEL_NOTICE,
expect.any(String)
);
});
it("does not apply on mobile even if the flag is set", async () => { it("does not apply on mobile even if the flag is set", async () => {
const { module, appLifecycle } = setup({ const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true }, settings: { keepReplicationActiveInBackground: true, liveSync: true },
@@ -313,3 +338,20 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1); expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
}); });
}); });
describe("watchWorkspaceOpenAsync — pending file event commit", () => {
it("skips replication and conflict checks when pending file events fail to commit", async () => {
const { module, fileProcessing, replication, conflict } = setup({
settings: { syncOnFileOpen: true },
hidden: false,
});
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
await module.watchWorkspaceOpenAsync({ path: "note.md" } as Parameters<
typeof module.watchWorkspaceOpenAsync
>[0]);
expect(replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(conflict.queueCheckForIfOpen).not.toHaveBeenCalled();
});
});
+61 -68
View File
@@ -4,7 +4,6 @@ import {
LOG_LEVEL_INFO, LOG_LEVEL_INFO,
LOG_LEVEL_VERBOSE, LOG_LEVEL_VERBOSE,
PREFIXMD_LOGFILE, PREFIXMD_LOGFILE,
type DatabaseConnectingStatus,
type LOG_LEVEL, type LOG_LEVEL,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { scheduleTask } from "octagonal-wheels/concurrency/task"; import { scheduleTask } from "octagonal-wheels/concurrency/task";
@@ -31,16 +30,14 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
import { serialized } from "octagonal-wheels/concurrency/lock"; import { serialized } from "octagonal-wheels/concurrency/lock";
import { $msg } from "@/common/translation"; import { $msg } from "@/common/translation";
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector"; import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
import { import { REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS, getTrackedRequestCount } from "./RemoteActivityStatus.ts";
REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS,
formatRemoteActivityStatusLabel,
getTrackedRequestCount,
} from "./RemoteActivityStatus.ts";
import { import {
createChunkFetchCounterLabel, createChunkFetchCounterLabel,
createMinimumVisibleActivityCount, createMinimumVisibleActivityCount,
createPaddedCounterLabel, createPaddedCounterLabel,
} from "./StatusBarDisplay.ts"; } from "./StatusBarDisplay.ts";
import { createFastForwardCheckedChangesDisplay, projectReplicationStatusLabel } from "./ReplicationStatusLabel.ts";
import { createStatusBarRenderer, type StatusBarPart, type StatusBarRenderer } from "./StatusBarRenderer.ts";
import type { LiveSyncCore } from "@/main.ts"; import type { LiveSyncCore } from "@/main.ts";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"; import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import { isValidPath } from "@/common/utils.ts"; import { isValidPath } from "@/common/utils.ts";
@@ -124,7 +121,14 @@ export class ModuleLog extends AbstractObsidianModule {
logHistory?: HTMLDivElement; logHistory?: HTMLDivElement;
messageArea?: HTMLDivElement; messageArea?: HTMLDivElement;
statusBarLabels!: ReactiveValue<{ message: string; status: string }>; statusBarRenderer?: StatusBarRenderer;
statusLineRenderer?: StatusBarRenderer;
statusBarLabels!: ReactiveValue<{
message: string;
status: string;
parts: readonly StatusBarPart[];
statusBarParts: readonly StatusBarPart[];
}>;
statusLog = reactiveSource(""); statusLog = reactiveSource("");
activeFileStatus = reactiveSource(""); activeFileStatus = reactiveSource("");
notifies: { [key: string]: { count: number } } = {}; notifies: { [key: string]: { count: number } } = {};
@@ -164,79 +168,54 @@ export class ModuleLog extends AbstractObsidianModule {
); );
const requestingStatLabel = computed(() => { const requestingStatLabel = computed(() => {
return formatRemoteActivityStatusLabel({ return {
remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value), remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value),
trackedRequestCount: displayedTrackedRequestCount.value, trackedRequestCount: displayedTrackedRequestCount.value,
}); };
}); });
const replicationStatLabel = computed(() => { const fastForwardCount = registerDisplay(
const e = this.services.replicator.replicationStatics.value; createFastForwardCheckedChangesDisplay(this.services.replicator.replicationStatics)
const sent = e.sent; );
const arrived = e.arrived; const replicationStatLabel = computed(() =>
const maxPullSeq = e.maxPullSeq; projectReplicationStatusLabel(
const maxPushSeq = e.maxPushSeq; this.services.replicator.replicationStatics.value,
const lastSyncPullSeq = e.lastSyncPullSeq; requestingStatLabel(),
const lastSyncPushSeq = e.lastSyncPushSeq; fastForwardCount.value
let pushLast = ""; )
let pullLast = ""; );
let w = "";
const labels: Partial<Record<DatabaseConnectingStatus, string>> = {
CONNECTED: "⚡",
JOURNAL_SEND: "📦↑",
JOURNAL_RECEIVE: "📦↓",
};
switch (e.syncStatus) {
case "CLOSED":
case "COMPLETED":
case "NOT_CONNECTED":
w = "⏹";
break;
case "STARTED":
w = "🌀";
break;
case "PAUSED":
w = "💤";
break;
case "CONNECTED":
case "JOURNAL_SEND":
case "JOURNAL_RECEIVE":
w = labels[e.syncStatus] || "⚡";
pushLast =
lastSyncPushSeq == 0
? ""
: lastSyncPushSeq >= maxPushSeq
? " (LIVE)"
: ` (${maxPushSeq - lastSyncPushSeq})`;
pullLast =
lastSyncPullSeq == 0
? ""
: lastSyncPullSeq >= maxPullSeq
? " (LIVE)"
: ` (${maxPullSeq - lastSyncPullSeq})`;
break;
case "ERRORED":
w = "⚠";
break;
default:
w = "?";
}
return { w, sent, pushLast, arrived, pullLast };
});
const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, `⏳`)); const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, `⏳`));
const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `🛫`)); const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `🛫`));
const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `📬`)); const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `📬`));
const waitingLabel = computed(() => { const waitingLabel = computed(() => {
return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`; return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`;
}); });
const counterPart = (key: string, mark: string, value: number | undefined): StatusBarPart[] =>
value === undefined ? [] : [{ key, text: " " + mark, value }];
const statusLineLabel = computed(() => { const statusLineLabel = computed(() => {
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel(); const replication = replicationStatLabel();
const queued = queueCountLabel(); const queued = queueCountLabel();
const waiting = waitingLabel(); const waiting = waitingLabel();
const networkActivity = requestingStatLabel();
const p2p = this.p2pLogCollector.p2pReplicationLine.value; const p2p = this.p2pLogCollector.p2pReplicationLine.value;
const firstLineParts = [
...replication.parts,
...counterPart("file-processing", "⏳", labelProc.count.value),
...counterPart("file-pending", "🛫", labelPend.count.value),
...counterPart("file-batched", "📬", labelInBatchDelay.count.value),
...counterPart("queue-replication", "📥", labelReplication.count.value),
...counterPart("queue-database", "📄", labelDBCount.count.value),
...counterPart("queue-storage", "💾", labelStorageCount.count.value),
...counterPart("queue-chunk-initial", "🛄", labelChunkCount.initialCount.value),
...counterPart("queue-chunk-retrying", "🔁", labelChunkCount.retryingCount.value),
...counterPart("queue-plugin-scan", "🔌", labelPluginScanCount.count.value),
...counterPart("queue-hidden-files", "⚙️", labelHiddenFilesCount.count.value),
...counterPart("queue-conflicts", "🔩", labelConflictProcessCount.count.value),
];
return { return {
message: `${networkActivity}Sync: ${w} ↑ ${sent}${pushLast} ↓ ${arrived}${pullLast}${waiting}${queued}${p2p == "" ? "" : "\n" + p2p}`, message: replication.message + waiting + queued + (p2p === "" ? "" : "\n" + p2p),
parts: p2p === "" ? firstLineParts : [...firstLineParts, { key: "p2p", text: "\n" + p2p }],
statusBarParts: firstLineParts,
hasP2P: p2p !== "",
}; };
}); });
@@ -244,13 +223,16 @@ export class ModuleLog extends AbstractObsidianModule {
const scheduleMessage = this.services.appLifecycle.isReloadingScheduled() const scheduleMessage = this.services.appLifecycle.isReloadingScheduled()
? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n` ? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n`
: ""; : "";
const { message } = statusLineLabel(); const { message, parts, statusBarParts, hasP2P } = statusLineLabel();
const fileStatus = this.activeFileStatus.value; const fileStatus = this.activeFileStatus.value;
const status = scheduleMessage + this.statusLog.value; const status = scheduleMessage + this.statusLog.value;
const fileStatusIcon = `${fileStatus && this.settings.hideFileWarningNotice ? " ⛔ SKIP" : ""}`; const fileStatusIcon = `${fileStatus && this.settings.hideFileWarningNotice ? " ⛔ SKIP" : ""}`;
const skipPart = fileStatusIcon === "" ? [] : [{ key: "file-skip", text: fileStatusIcon }];
return { return {
message: `${message}${fileStatusIcon}`, message: `${message}${fileStatusIcon}`,
status, status,
parts: [...parts, ...skipPart],
statusBarParts: hasP2P ? statusBarParts : [...statusBarParts, ...skipPart],
}; };
}); });
this.statusBarLabels = statusBarLabels; this.statusBarLabels = statusBarLabels;
@@ -391,7 +373,8 @@ export class ModuleLog extends AbstractObsidianModule {
newLog = newLog.substring(moduleTagEnd + MARK_LOG_SEPARATOR.length + 1); newLog = newLog.substring(moduleTagEnd + MARK_LOG_SEPARATOR.length + 1);
} }
this.statusBar?.setText(newMsg.split("\n")[0]); const { parts, statusBarParts } = this.statusBarLabels.value;
this.statusBarRenderer?.render(statusBarParts);
if (this.statusDiv) { if (this.statusDiv) {
this.statusDiv.setCssStyles({ display: this.settings?.showStatusOnEditor ? "" : "none" }); this.statusDiv.setCssStyles({ display: this.settings?.showStatusOnEditor ? "" : "none" });
} }
@@ -409,7 +392,7 @@ export class ModuleLog extends AbstractObsidianModule {
const recentLogs = recent.reverse().join("\n"); const recentLogs = recent.reverse().join("\n");
if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs; if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs;
} }
if (isDirty("newMsg", newMsg)) this.statusLine!.innerText = newMsg; if (isDirty("newMsg", newMsg)) this.statusLineRenderer?.render(parts);
if (isDirty("newLog", newLog)) this.logMessage!.innerText = newLog; if (isDirty("newLog", newLog)) this.logMessage!.innerText = newLog;
} else { } else {
// const root = activeDocument.documentElement; // const root = activeDocument.documentElement;
@@ -505,6 +488,16 @@ ${stringifyYaml(info)}
this.statusBar = this.services.API.addStatusBarItem(); this.statusBar = this.services.API.addStatusBarItem();
this.statusBar?.addClass("syncstatusbar"); this.statusBar?.addClass("syncstatusbar");
} }
if (this.statusLine) {
const renderer = createStatusBarRenderer(this.statusLine);
this.statusLineRenderer = renderer;
this.plugin.register(() => renderer.dispose());
}
if (this.statusBar) {
const renderer = createStatusBarRenderer(this.statusBar);
this.statusBarRenderer = renderer;
this.plugin.register(() => renderer.dispose());
}
this.adjustStatusDivPosition(); this.adjustStatusDivPosition();
this._log("Log module loaded", LOG_LEVEL_INFO); this._log("Log module loaded", LOG_LEVEL_INFO);
this._log("Verbose log", LOG_LEVEL_VERBOSE); this._log("Verbose log", LOG_LEVEL_VERBOSE);
@@ -8,13 +8,13 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule { export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
settingTab!: ObsidianLiveSyncSettingTab; settingTab!: ObsidianLiveSyncSettingTab;
_everyOnloadAfterLoadSettings(): Promise<boolean> { async _everyOnloadAfterLoadSettings(): Promise<boolean> {
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin); this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
this.settingTab.reloadAllSettings(true); await this.settingTab.loadSettings();
this.plugin.addSettingTab(this.settingTab); this.plugin.addSettingTab(this.settingTab);
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting()); eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
return Promise.resolve(true); return true;
} }
openSetting() { openSetting() {
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const settingTabState = vi.hoisted(() => ({ const settingTabState = vi.hoisted(() => ({
callOrder: [] as string[], callOrder: [] as string[],
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(), loadSettings: vi.fn<() => Promise<void>>(),
})); }));
const eventHubState = vi.hoisted(() => ({ const eventHubState = vi.hoisted(() => ({
@@ -11,9 +11,9 @@ const eventHubState = vi.hoisted(() => ({
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({ vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab { ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
reloadAllSettings(skipUpdate?: boolean) { async loadSettings() {
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`); await settingTabState.loadSettings();
settingTabState.reloadAllSettings(skipUpdate); settingTabState.callOrder.push("settings-loaded");
} }
}, },
})); }));
@@ -65,7 +65,7 @@ function createModuleHarness() {
describe("ModuleObsidianSettingDialogue startup lifecycle", () => { describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
beforeEach(() => { beforeEach(() => {
settingTabState.callOrder.length = 0; settingTabState.callOrder.length = 0;
settingTabState.reloadAllSettings.mockClear(); settingTabState.loadSettings.mockReset().mockResolvedValue(undefined);
eventHubState.onEvent.mockClear(); eventHubState.onEvent.mockClear();
}); });
@@ -79,13 +79,29 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
}); });
it("seeds the setting editor without requesting a render before registration", async () => { it("seeds the setting editor without requesting a render before registration", async () => {
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness(); const { initialisationHandler, settingsLoadedHandler, plugin } = createModuleHarness();
let finishRead!: () => void;
settingTabState.loadSettings.mockReturnValueOnce(
new Promise<void>((resolve) => {
finishRead = resolve;
})
);
const handler = settingsLoadedHandler() ?? initialisationHandler(); const handler = settingsLoadedHandler() ?? initialisationHandler();
expect(handler).toBeTypeOf("function"); expect(handler).toBeTypeOf("function");
await handler!(); const loading = handler!();
expect(plugin.addSettingTab).not.toHaveBeenCalled();
finishRead();
await loading;
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true); expect(settingTabState.loadSettings).toHaveBeenCalledOnce();
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]); expect(settingTabState.callOrder).toEqual(["settings-loaded", "add-setting-tab"]);
});
it("does not register an editor when reading device values fails", async () => {
const { settingsLoadedHandler, plugin } = createModuleHarness();
const failure = new Error("device storage unavailable");
settingTabState.loadSettings.mockRejectedValueOnce(failure);
await expect(settingsLoadedHandler()!()).rejects.toBe(failure);
expect(plugin.addSettingTab).not.toHaveBeenCalled();
}); });
}); });
@@ -0,0 +1,209 @@
import { reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import type { DisposableReactiveValue } from "./StatusBarDisplay.ts";
import type { StatusBarPart } from "./StatusBarRenderer.ts";
import {
formatRemoteActivityStatusLabel,
REMOTE_OPERATION_ACTIVITY_ICON,
REMOTE_REQUEST_ACTIVITY_ICON,
type RemoteActivityStatus,
} from "./RemoteActivityStatus.ts";
export const FAST_FORWARD_COUNT_LINGER_MS = 3_000;
function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>): number | undefined {
const count = source.value.couchDB?.pull?.checkedChanges;
return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined;
}
/**
* Briefly displays changing CouchDB pull-feed comparison counts.
*
* Unrelated replication-stat changes do not extend the linger or redisplay an
* expired count. A positive initial snapshot is displayed to late subscribers.
*/
export function createFastForwardCheckedChangesDisplay(
source: ReactiveValue<ReplicationStatics>,
lingerMs = FAST_FORWARD_COUNT_LINGER_MS
): DisposableReactiveValue<number | undefined> {
const displayed = reactiveSource<number | undefined>(undefined);
const linger = Math.max(0, lingerMs);
let lastObserved = readPositiveCheckedChanges(source);
let clearTimer: number | undefined;
let disposed = false;
const cancelClear = () => {
if (clearTimer === undefined) return;
compatGlobal.clearTimeout(clearTimer);
clearTimer = undefined;
};
const scheduleClear = () => {
clearTimer = compatGlobal.setTimeout(() => {
clearTimer = undefined;
if (disposed) return;
if (readPositiveCheckedChanges(source) !== lastObserved) {
update();
return;
}
displayed.value = undefined;
}, linger);
};
const update = () => {
if (disposed) return;
const nextCount = readPositiveCheckedChanges(source);
if (nextCount === lastObserved) return;
lastObserved = nextCount;
cancelClear();
if (nextCount === undefined) {
displayed.value = undefined;
return;
}
displayed.value = nextCount;
scheduleClear();
};
if (lastObserved !== undefined) {
displayed.value = lastObserved;
scheduleClear();
}
source.onChanged(update);
return {
get value() {
return displayed.value;
},
onChanged(handler) {
displayed.onChanged(handler);
},
offChanged(handler) {
displayed.offChanged(handler);
},
dispose() {
if (disposed) return;
disposed = true;
cancelClear();
source.offChanged(update);
},
};
}
export type ReplicationStatusProjection = {
icon: string;
message: string;
parts: StatusBarPart[];
};
function remainingSequenceLabel(
key: string,
lastSyncSeq: number,
maxSeq: number
): { text: string; part?: StatusBarPart } {
if (lastSyncSeq === 0) return { text: "" };
if (lastSyncSeq >= maxSeq) return { text: " (LIVE)", part: { key, text: " (LIVE)" } };
const remaining = maxSeq - lastSyncSeq;
return {
text: " (" + remaining + ")",
part: { key, text: " (", value: remaining, suffix: ")" },
};
}
/** Projects the existing replication labels into reusable numeric status items. */
export function projectReplicationStatusLabel(
stats: ReplicationStatics,
networkStatus: RemoteActivityStatus,
fastForwardCount?: number
): ReplicationStatusProjection {
const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats;
const networkActivity = formatRemoteActivityStatusLabel(networkStatus);
let pushRemaining: { text: string; part?: StatusBarPart } = { text: "" };
let pullRemaining: { text: string; part?: StatusBarPart } = { text: "" };
let icon: string;
switch (stats.syncStatus) {
case "CLOSED":
case "COMPLETED":
case "NOT_CONNECTED":
icon = "⏹";
break;
case "STARTED":
icon = "🌀";
break;
case "PAUSED":
icon = "💤";
break;
case "CONNECTED":
case "JOURNAL_SEND":
case "JOURNAL_RECEIVE":
icon = stats.syncStatus === "JOURNAL_SEND" ? "📦↑" : stats.syncStatus === "JOURNAL_RECEIVE" ? "📦↓" : "⚡";
pushRemaining = remainingSequenceLabel("push-remaining", lastSyncPushSeq, maxPushSeq);
pullRemaining = remainingSequenceLabel("pull-remaining", lastSyncPullSeq, maxPullSeq);
break;
case "ERRORED":
icon = "⚠";
break;
default:
icon = "?";
}
const couchDB = stats.couchDB;
const isFastForwardChecking =
stats.syncStatus !== "ERRORED" &&
couchDB !== undefined &&
couchDB.phase !== "idle" &&
(couchDB.pull?.checking === true || couchDB.push?.checking === true);
if (isFastForwardChecking) icon = "⏩";
const visibleFastForwardCount =
couchDB !== undefined &&
typeof fastForwardCount === "number" &&
Number.isFinite(fastForwardCount) &&
fastForwardCount > 0
? fastForwardCount
: undefined;
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
const parts: StatusBarPart[] = [];
if (networkStatus.trackedRequestCount > 0) {
parts.push({
key: "network",
text:
(networkStatus.remoteOperationCount > 0 ? `${REMOTE_OPERATION_ACTIVITY_ICON} ` : "") +
REMOTE_REQUEST_ACTIVITY_ICON,
value: networkStatus.trackedRequestCount,
suffix: " ",
});
} else if (networkActivity !== "") {
parts.push({ key: "network", text: networkActivity });
}
parts.push({ key: "sync", text: "Sync: " + icon });
if (visibleFastForwardCount !== undefined) {
parts.push({
key: "ff",
text: " (⏩ ",
value: visibleFastForwardCount,
suffix: ")",
title: "Compared CouchDB feed entries",
});
}
parts.push({ key: "sent", text: " ↑ ", value: sent });
if (pushRemaining.part !== undefined) parts.push(pushRemaining.part);
parts.push({ key: "arrived", text: " ↓ ", value: arrived });
if (pullRemaining.part !== undefined) parts.push(pullRemaining.part);
return {
icon,
message:
networkActivity +
"Sync: " +
icon +
fastForwardLabel +
" ↑ " +
sent +
pushRemaining.text +
" ↓ " +
arrived +
pullRemaining.text,
parts,
};
}
@@ -0,0 +1,242 @@
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
import {
FAST_FORWARD_COUNT_LINGER_MS,
createFastForwardCheckedChangesDisplay,
projectReplicationStatusLabel,
} from "./ReplicationStatusLabel.ts";
function couchDBProgress(
phase: "idle" | "one-shot" | "catch-up" | "continuous",
checkedChanges = 0,
checking = false
): NonNullable<ReplicationStatics["couchDB"]> {
return {
phase,
pull: { checkedChanges, completedBatches: 0, checking },
push: { checkedChanges: 0, completedBatches: 0, checking: false },
};
}
const noNetworkActivity = { remoteOperationCount: 0, trackedRequestCount: 0 };
function stats(overrides: Partial<ReplicationStatics> = {}): ReplicationStatics {
return {
sent: 0,
arrived: 0,
maxPullSeq: 0,
maxPushSeq: 0,
lastSyncPullSeq: 0,
lastSyncPushSeq: 0,
syncStatus: "CONNECTED",
...overrides,
};
}
describe("projectReplicationStatusLabel", () => {
it("exposes the remote-request count as a numeric status item", () => {
const projection = projectReplicationStatusLabel(stats(), { remoteOperationCount: 1, trackedRequestCount: 12 });
expect(projection.parts.find((part) => part.key === "network")).toMatchObject({
text: "📲 🌐",
value: 12,
suffix: " ",
});
});
it("preserves the connected label, directions, and remaining-sequence text", () => {
const projection = projectReplicationStatusLabel(
stats({
sent: 4,
arrived: 2,
maxPullSeq: 20,
maxPushSeq: 30,
lastSyncPullSeq: 17,
lastSyncPushSeq: 22,
}),
{ remoteOperationCount: 0, trackedRequestCount: 1 }
);
expect(projection.message).toBe("🌐1 Sync: ⚡ ↑ 4 (8) ↓ 2 (3)");
expect(projection.parts.map((part) => part.key)).toEqual([
"network",
"sync",
"sent",
"push-remaining",
"arrived",
"pull-remaining",
]);
expect(projection.parts[2]).toMatchObject({ key: "sent", text: " ↑ ", value: 4 });
expect(projection.parts[3]).toMatchObject({ key: "push-remaining", text: " (", value: 8, suffix: ")" });
expect(projection.parts[4]).toMatchObject({ key: "arrived", text: " ↓ ", value: 2 });
expect(projection.parts[5]).toMatchObject({ key: "pull-remaining", text: " (", value: 3, suffix: ")" });
});
it("keeps the existing status icons when CouchDB is idle or not present", () => {
const statuses = [
["CLOSED", "⏹"],
["COMPLETED", "⏹"],
["NOT_CONNECTED", "⏹"],
["STARTED", "🌀"],
["PAUSED", "💤"],
["CONNECTED", "⚡"],
["JOURNAL_SEND", "📦↑"],
["JOURNAL_RECEIVE", "📦↓"],
["ERRORED", "⚠"],
] as const;
for (const [syncStatus, icon] of statuses) {
const idleWithCount = projectReplicationStatusLabel(
stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }),
noNetworkActivity,
19
);
expect(idleWithCount.icon).toBe(icon);
expect(idleWithCount.parts.some((part) => part.key === "ff")).toBe(true);
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, 19);
expect(withoutCouchDB.icon).toBe(icon);
expect(withoutCouchDB.parts.some((part) => part.key === "ff")).toBe(false);
}
const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity);
expect(noFastForward.parts.some((part) => part.key === "ff")).toBe(false);
expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0");
});
it("shows fast-forward checking with zero arrivals, transferred data, and the final idle count", () => {
const checking = projectReplicationStatusLabel(
stats({
syncStatus: "PAUSED",
arrived: 0,
couchDB: couchDBProgress("catch-up", 0, true),
}),
noNetworkActivity
);
expect(checking.icon).toBe("⏩");
expect(checking.parts[2]).toMatchObject({ key: "arrived", value: 0 });
const transferred = projectReplicationStatusLabel(
stats({
arrived: 7,
couchDB: couchDBProgress("continuous", 1200, false),
}),
noNetworkActivity,
1200
);
expect(transferred.icon).toBe("⚡");
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]);
expect(transferred.parts[1]).toMatchObject({
key: "ff",
text: " (⏩ ",
value: 1200,
suffix: ")",
});
const idle = projectReplicationStatusLabel(
stats({
syncStatus: "CONNECTED",
arrived: 7,
couchDB: couchDBProgress("idle", 1200, false),
}),
noNetworkActivity,
1200
);
expect(idle.icon).toBe("⚡");
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
});
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
const projection = projectReplicationStatusLabel(
stats({
syncStatus: "ERRORED",
couchDB: {
...couchDBProgress("one-shot", 2, true),
push: { checkedChanges: 0, completedBatches: 0, checking: true },
},
}),
noNetworkActivity
);
expect(projection.icon).toBe("⚠");
});
});
describe("createFastForwardCheckedChangesDisplay", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("uses the initial snapshot, expires unchanged counts, and only reappears for new progress", () => {
const source = reactiveSource(
stats({
couchDB: couchDBProgress("catch-up", 1200, true),
})
);
const display = createFastForwardCheckedChangesDisplay(source);
expect(display.value).toBe(1200);
vi.advanceTimersByTime(1000);
source.value = stats({
sent: 3,
couchDB: couchDBProgress("idle", 1200, false),
});
expect(display.value).toBe(1200);
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001);
expect(display.value).toBe(1200);
source.value = stats({
sent: 4,
couchDB: couchDBProgress("idle", 1200, false),
});
vi.advanceTimersByTime(1);
expect(display.value).toBeUndefined();
source.value = stats({
syncStatus: "PAUSED",
couchDB: couchDBProgress("continuous", 1200, true),
});
expect(display.value).toBeUndefined();
source.value = stats({
couchDB: couchDBProgress("continuous", 1201, true),
});
expect(display.value).toBe(1201);
source.value = stats({
couchDB: couchDBProgress("one-shot", 0, true),
});
expect(display.value).toBeUndefined();
source.value = stats();
expect(display.value).toBeUndefined();
display.dispose();
});
it("clears its timer and subscription when disposed", () => {
const source = reactiveSource(stats({ couchDB: couchDBProgress("catch-up", 5, true) }));
const display = createFastForwardCheckedChangesDisplay(source);
display.dispose();
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS);
source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) });
expect(display.value).toBe(5);
});
it("does not display progress for providers without CouchDB replication state", () => {
const source = reactiveSource(stats());
const display = createFastForwardCheckedChangesDisplay(source);
expect(display.value).toBeUndefined();
source.value = stats({ couchDB: { phase: "continuous" } });
expect(display.value).toBeUndefined();
display.dispose();
});
});
@@ -23,6 +23,7 @@ export function createAdvancedSettingSpecGroups({
control: { type: "dropdown", options: () => ChunkAlgorithmNames }, control: { type: "dropdown", options: () => ChunkAlgorithmNames },
}, },
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } }, { key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
{ {
key: "pathNormalizationVersion", key: "pathNormalizationVersion",
holdValue: true, holdValue: true,
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian"; import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
@@ -158,6 +159,7 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
}, },
services: { services: {
setting: { setting: {
deviceValues: new MemoryValueStore(),
getDeviceAndVaultName: vi.fn(() => ""), getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined), saveSettingData: vi.fn(async () => undefined),
}, },
@@ -459,7 +461,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
expect(runtime.pageCleanup).toHaveBeenCalledOnce(); expect(runtime.pageCleanup).toHaveBeenCalledOnce();
}); });
it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => { it("rebuilds the catalogue when an externally loaded setting changes page visibility", async () => {
const tab = createSettingsTab(); const tab = createSettingsTab();
const changeLog = findPage(tab, "Change Log"); const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) { if (!changeLog.page) {
@@ -468,7 +470,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
changeLog.page().display(); changeLog.page().display();
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode; tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
tab.requestReload(); await tab.requestReload();
expect(tab.update).toHaveBeenCalledOnce(); expect(tab.update).toHaveBeenCalledOnce();
}); });
@@ -482,7 +484,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
expect(tab.update).toHaveBeenCalledOnce(); expect(tab.update).toHaveBeenCalledOnce();
}); });
it("rebuilds translated catalogue names when the display language changes externally", () => { it("rebuilds translated catalogue names when the display language changes externally", async () => {
const tab = createSettingsTab(); const tab = createSettingsTab();
const changeLog = findPage(tab, "Change Log"); const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) { if (!changeLog.page) {
@@ -491,12 +493,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
changeLog.page().display(); changeLog.page().display();
tab.core.settings.displayLanguage = "ja"; tab.core.settings.displayLanguage = "ja";
tab.requestReload(); await tab.requestReload();
expect(tab.update).toHaveBeenCalledOnce(); expect(tab.update).toHaveBeenCalledOnce();
}); });
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => { it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", async () => {
const tab = createSettingsTab(); const tab = createSettingsTab();
const changeLog = findPage(tab, "Change Log"); const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) { if (!changeLog.page) {
@@ -507,7 +509,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
tab.editingSettings.usePowerUserMode = true; tab.editingSettings.usePowerUserMode = true;
tab.core.settings.usePowerUserMode = true; tab.core.settings.usePowerUserMode = true;
tab.requestReload(); await tab.requestReload();
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2]; const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
expect(configureAnchor).toBeTypeOf("function"); expect(configureAnchor).toBeTypeOf("function");
let acceptExternalSetting: (() => void) | undefined; let acceptExternalSetting: (() => void) | undefined;
@@ -52,7 +52,6 @@ import {
type PageFunctions, type PageFunctions,
type UpdateFunction, type UpdateFunction,
} from "./SettingPane.ts"; } from "./SettingPane.ts";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore"; import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter"; import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
import { closeObsidianSettings } from "@/common/obsidianSettings.ts"; import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
@@ -100,6 +99,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
} }
selectedScreen = ""; selectedScreen = "";
private localConfigurationPassphrase = "";
private localSettingsReadVersion = 0;
_editingSettings?: AllSettings; _editingSettings?: AllSettings;
// Buffered Settings for editing // Buffered Settings for editing
get editingSettings(): AllSettings { get editingSettings(): AllSettings {
@@ -162,8 +164,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) { async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
if (key == "deviceAndVaultName") { if (key == "deviceAndVaultName") {
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? ""); this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
this.services.setting.saveDeviceAndVaultName(); await this.services.setting.saveDeviceAndVaultName();
return await Promise.resolve();
} }
} }
/** /**
@@ -225,6 +226,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
appliedConfigurationEncryption.configPassphrase appliedConfigurationEncryption.configPassphrase
); );
if (appliedConfigurationEncryption.configPassphraseStore === "LOCALSTORAGE") {
this.localSettingsReadVersion++;
this.localConfigurationPassphrase = appliedConfigurationEncryption.configPassphrase;
}
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore"); this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase"); this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
} }
@@ -285,9 +290,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
} }
} }
private async readLocalSettings(): Promise<boolean> {
const version = ++this.localSettingsReadVersion;
const passphrase = await this.services.setting.deviceValues.getValue("ls-setting-passphrase", "");
if (version !== this.localSettingsReadVersion) return false;
this.localConfigurationPassphrase = passphrase;
return true;
}
async loadSettings(): Promise<void> {
if (await this.readLocalSettings()) this.reloadAllSettings(true);
}
reloadAllLocalSettings() { reloadAllLocalSettings() {
const ret = { ...OnDialogSettingsDefault }; const ret = { ...OnDialogSettingsDefault };
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || ""; ret.configPassphrase = this.localConfigurationPassphrase;
ret.preset = ""; ret.preset = "";
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName(); ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
return ret; return ret;
@@ -402,7 +419,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.plugin = plugin; this.plugin = plugin;
Setting.env = this; Setting.env = this;
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => { eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
this.requestReload(); void this.requestReload().catch((error: unknown) => Logger(error, LOG_LEVEL_NOTICE));
}); });
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh()); this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR)); this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
@@ -545,7 +562,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
); );
} }
requestReload() { async requestReload(): Promise<void> {
if (!(await this.readLocalSettings())) return;
const nativeTabIsShown = const nativeTabIsShown =
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown(); this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
if (this.isShown || nativeTabIsShown) { if (this.isShown || nativeTabIsShown) {
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication"; import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { MemoryValueStore } from "@vrtmrz/value-store";
const settingsInitialisationMocks = vi.hoisted(() => ({ const settingsInitialisationMocks = vi.hoisted(() => ({
applySettingsWithInitialisationChoice: vi.fn(), applySettingsWithInitialisationChoice: vi.fn(),
@@ -76,8 +76,6 @@ beforeEach(() => {
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset(); settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
loggerMocks.Logger.mockClear(); loggerMocks.Logger.mockClear();
vi.mocked($msg).mockClear(); vi.mocked($msg).mockClear();
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
}); });
describe("ObsidianLiveSyncSettingTab passphrase verification", () => { describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
@@ -280,6 +278,7 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
}, },
services: { services: {
setting: { setting: {
deviceValues: new MemoryValueStore(),
saveSettingData, saveSettingData,
getDeviceAndVaultName: vi.fn(() => ""), getDeviceAndVaultName: vi.fn(() => ""),
suspendAllSync, suspendAllSync,
@@ -665,6 +664,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
}, },
services: { services: {
setting: { setting: {
deviceValues: new MemoryValueStore(),
saveSettingData, saveSettingData,
getDeviceAndVaultName: vi.fn(() => ""), getDeviceAndVaultName: vi.fn(() => ""),
}, },
@@ -692,6 +692,54 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
return { tab, saveSettingData }; return { tab, saveSettingData };
} }
it("awaits the stored passphrase before initialising the editing buffer", async () => {
const { tab } = createSettingsTab();
tab.resetEditingSettings();
let resolveRead!: (value: string) => void;
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveRead = resolve;
})
);
const loading = tab.loadSettings();
expect(tab.initialSettings).toBeUndefined();
resolveRead("stored passphrase");
await loading;
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
});
it("preserves the loaded settings when device storage cannot be read", async () => {
const { tab } = createSettingsTab();
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "stored passphrase");
await tab.loadSettings();
const failure = new Error("device storage unavailable");
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockRejectedValueOnce(failure);
await expect(tab.requestReload()).rejects.toBe(failure);
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
});
it("does not let a delayed read overwrite a successfully saved passphrase", async () => {
const { tab } = createSettingsTab();
await tab.loadSettings();
let resolveRead!: (value: string) => void;
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveRead = resolve;
})
);
const loading = tab.loadSettings();
Object.assign(tab.services.setting, { changeConfigurationEncryption: vi.fn(async () => undefined) });
tab.editingSettings.configPassphraseStore = "LOCALSTORAGE";
tab.editingSettings.configPassphrase = "new passphrase";
await tab.saveSettings(["configPassphraseStore", "configPassphrase"]);
resolveRead("old passphrase");
await loading;
tab.reloadAllSettings();
expect(tab.initialSettings!.configPassphrase).toBe("new passphrase");
});
it("loads the imperative fallback without a SettingPage runtime export", () => { it("loads the imperative fallback without a SettingPage runtime export", () => {
const { tab } = createSettingsTab(); const { tab } = createSettingsTab();
@@ -779,7 +827,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
it("saves a custom configuration passphrase edit through the setting service", async () => { it("saves a custom configuration passphrase edit through the setting service", async () => {
const { tab } = createSettingsTab(); const { tab } = createSettingsTab();
const changeConfigurationEncryption = vi.fn(async () => { const changeConfigurationEncryption = vi.fn(async () => {
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase"); await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "new custom passphrase");
}); });
Object.assign(tab.core.services.setting, { changeConfigurationEncryption }); Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
const store = "LOCALSTORAGE"; const store = "LOCALSTORAGE";
@@ -787,13 +835,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.initialSettings!.configPassphraseStore = store; tab.initialSettings!.configPassphraseStore = store;
tab.editingSettings.configPassphrase = "new custom passphrase"; tab.editingSettings.configPassphrase = "new custom passphrase";
tab.initialSettings!.configPassphrase = "old custom passphrase"; tab.initialSettings!.configPassphrase = "old custom passphrase";
vi.mocked(compatGlobal.localStorage.setItem).mockClear(); const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
await tab.saveSettings(["configPassphrase"]); await tab.saveSettings(["configPassphrase"]);
expect(changeConfigurationEncryption).toHaveBeenCalledOnce(); expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase"); expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled(); expect(setValue).toHaveBeenCalledOnce();
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase"); expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
}); });
@@ -811,7 +859,6 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE"; tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
tab.initialSettings!.configPassphrase = "previous passphrase"; tab.initialSettings!.configPassphrase = "previous passphrase";
tab.editingSettings.configPassphrase = "passphrase A"; tab.editingSettings.configPassphrase = "passphrase A";
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
const apply = tab.saveSettings(["configPassphrase"]); const apply = tab.saveSettings(["configPassphrase"]);
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A"); expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
@@ -840,13 +887,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
tab.editingSettings.configPassphraseStore = editedStore; tab.editingSettings.configPassphraseStore = editedStore;
tab.initialSettings!.configPassphrase = "old custom passphrase"; tab.initialSettings!.configPassphrase = "old custom passphrase";
tab.editingSettings.configPassphrase = "new custom passphrase"; tab.editingSettings.configPassphrase = "new custom passphrase";
vi.mocked(compatGlobal.localStorage.setItem).mockClear(); const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure); await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
expect(changeConfigurationEncryption).toHaveBeenCalledOnce(); expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase"); expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled(); expect(setValue).not.toHaveBeenCalled();
expect(tab.core.settings.configPassphraseStore).toBe(savedStore); expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore); expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase"); expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
}; };
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never); paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10)); await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([ expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"Memory cache", "Memory cache",
@@ -56,6 +56,7 @@ describe("paneAdvanced", () => {
panelElements.get("Local Database Tweak"), panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"), panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"), panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Transfer Tweak"), panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"), panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"), panelElements.get("Transfer Tweak"),
@@ -67,6 +68,7 @@ describe("paneAdvanced", () => {
"hashCacheMaxCount", "hashCacheMaxCount",
"chunkSplitterVersion", "chunkSplitterVersion",
"customChunkSize", "customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion", "pathNormalizationVersion",
"readChunksOnline", "readChunksOnline",
"useOnlyLocalChunk", "useOnlyLocalChunk",
@@ -7,6 +7,7 @@ import {
type ObsidianLiveSyncSettings, type ObsidianLiveSyncSettings,
LOG_LEVEL_VERBOSE, LOG_LEVEL_VERBOSE,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
import { Menu, type ButtonComponent } from "@/deps.ts"; import { Menu, type ButtonComponent } from "@/deps.ts";
import { $msg } from "@/common/translation"; import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
@@ -101,6 +102,22 @@ export function paneRemoteConfig(
paneEl: HTMLElement, paneEl: HTMLElement,
{ addPanel }: PageFunctions { addPanel }: PageFunctions
): void { ): void {
new Setting(paneEl)
.setName($msg("Joined devices"))
.setDesc($msg("Review device records for the active CouchDB or Object Storage connection."))
.addButton((button) =>
button.setButtonText($msg("Review devices")).onClick(async () => {
const result = await this.services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
if (result.status === "unavailable" || result.value === "unavailable") {
this.services.API.addLog(
$msg(
"Joined devices are available after database startup with an active CouchDB or Object Storage connection."
),
LOG_LEVEL_NOTICE
);
}
})
);
{ {
/* E2EE */ /* E2EE */
const E2EEInitialProps = { const E2EEInitialProps = {
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({ const runtime = vi.hoisted(() => ({
buttonClasses: [] as string[], buttonClasses: [] as string[],
clickHandlers: [] as Array<() => Promise<void> | void>, clickHandlers: [] as Array<{ setting: string; run: () => Promise<void> | void }>,
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>, panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
settingClasses: [] as string[], settingClasses: [] as string[],
})); }));
@@ -32,7 +32,10 @@ vi.mock("./LiveSyncSetting.ts", () => ({
}, },
}; };
setName() { settingName = "";
setName(name: string) {
this.settingName = name;
return this; return this;
} }
@@ -41,6 +44,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
} }
addButton(callback: (button: unknown) => void) { addButton(callback: (button: unknown) => void) {
const setting = this.settingName;
const button = { const button = {
buttonEl: { buttonEl: {
classList: { classList: {
@@ -53,7 +57,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this; return this;
}, },
onClick(callback: () => Promise<void> | void) { onClick(callback: () => Promise<void> | void) {
runtime.clickHandlers.push(callback); runtime.clickHandlers.push({ setting, run: callback });
return this; return this;
}, },
setButtonText() { setButtonText() {
@@ -107,6 +111,7 @@ vi.mock("./remoteConfigBuffer.ts", () => ({
syncActivatedRemoteSettings: vi.fn(), syncActivatedRemoteSettings: vi.fn(),
})); }));
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
import { paneRemoteConfig } from "./PaneRemoteConfig.ts"; import { paneRemoteConfig } from "./PaneRemoteConfig.ts";
function createPanelElement(): HTMLElement { function createPanelElement(): HTMLElement {
@@ -124,6 +129,18 @@ afterEach(() => {
}); });
describe("paneRemoteConfig", () => { describe("paneRemoteConfig", () => {
it("opens joined devices through the shared UI command", async () => {
const invoke = vi.fn(async () => ({ status: "handled", value: "closed" }));
const host = {
editingSettings: { remoteConfigurations: {} },
services: { UI: { commands: { invoke } } },
};
const addPanel = vi.fn(() => ({ then: vi.fn() }));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers.find((handler) => handler.setting === "Joined devices")!.run();
expect(invoke).toHaveBeenCalledWith(OPEN_JOINED_DEVICES, undefined);
});
it("destroys the E2EE info panel when the settings page lifetime unloads", async () => { it("destroys the E2EE info panel when the settings page lifetime unloads", async () => {
const callbacks: Array<() => unknown> = []; const callbacks: Array<() => unknown> = [];
const lifetimeComponent = { const lifetimeComponent = {
@@ -187,7 +204,7 @@ describe("paneRemoteConfig", () => {
})); }));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never); paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0](); await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce(); expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
expect(host.editingSettings.encryptInternalMetadata).toBe(true); expect(host.editingSettings.encryptInternalMetadata).toBe(true);
@@ -233,7 +250,7 @@ describe("paneRemoteConfig", () => {
})); }));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never); paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0](); await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
expect(host.editingSettings.idDerivationVersion).toBe(1); expect(host.editingSettings.idDerivationVersion).toBe(1);
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey); expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
@@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
.setName($msg("Synchronising files")) .setName($msg("Synchronising files"))
.setDesc( .setDesc(
$msg( $msg(
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files." "(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
) )
); );
const syncFilesControl = mount(MultipleRegExpControl, { const syncFilesControl = mount(MultipleRegExpControl, {
@@ -32,9 +32,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
const nonSyncFilesSetting = new Setting(paneEl) const nonSyncFilesSetting = new Setting(paneEl)
.setName($msg("Non-Synchronising files")) .setName($msg("Non-Synchronising files"))
.setDesc( .setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
$msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.")
);
const nonSyncFilesControl = mount(MultipleRegExpControl, { const nonSyncFilesControl = mount(MultipleRegExpControl, {
target: nonSyncFilesSetting.controlEl, target: nonSyncFilesSetting.controlEl,
@@ -171,20 +171,18 @@ export function paneSyncSettings(
} }
}); });
void addPanel( void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleUpdateThinning")).then((paneEl) => {
paneEl, const inLiveSyncMode = () => this.isConfiguredAs("syncMode", "LIVESYNC");
$msg("obsidianLiveSyncSettingTab.titleUpdateThinning"), const batchEnabled = () => this.isConfiguredAs(inLiveSyncMode() ? "batchSaveInLiveSync" : "batchSave", true);
undefined, new Setting(paneEl).autoWireToggle("batchSave", { onUpdate: visibleOnly(() => !inLiveSyncMode()) });
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC")) new Setting(paneEl).autoWireToggle("batchSaveInLiveSync", { onUpdate: visibleOnly(inLiveSyncMode) });
).then((paneEl) => {
new Setting(paneEl).autoWireToggle("batchSave");
new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", { new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", {
acceptZero: true, acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)), onUpdate: visibleOnly(batchEnabled),
}); });
new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", { new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", {
acceptZero: true, acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)), onUpdate: visibleOnly(batchEnabled),
}); });
}); });
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import type { OnUpdateFunc, PageFunctions } from "./SettingPane";
const controls = vi.hoisted(() => new Map<string, { onUpdate?: () => { visibility?: boolean } }>());
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ Logger: vi.fn() }));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class {
autoWireDropDown(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireToggle(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireNumeric(key: string, options = {}) {
controls.set(key, options);
return this;
}
},
}));
import { paneSyncSettings } from "./PaneSyncSettings";
function setup(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
const callbacks = new Map<string, (value: string) => Promise<void>>();
const panels = new Map<string, OnUpdateFunc | undefined>();
const tab = {
editingSettings: { ...DEFAULT_SETTINGS, liveSync, batchSave, batchSaveInLiveSync },
services: { API: { isMobile: () => false }, control: { applySettings: vi.fn(async () => {}) } },
addOnSaved: (key: string, callback: (value: string) => Promise<void>) => callbacks.set(key, callback),
isConfiguredAs(key: string, value: unknown) {
if (key === "syncMode") {
return (
(this.editingSettings.liveSync
? "LIVESYNC"
: this.editingSettings.periodicReplication
? "PERIODIC"
: "ONEVENTS") === value
);
}
return this.editingSettings[key as keyof typeof this.editingSettings] === value;
},
saveSettings: vi.fn(async () => {}),
requestCatalogueRefresh: vi.fn(),
};
const functions = {
addPanel: (_element: HTMLElement, title: string, _callback: unknown, onUpdate?: OnUpdateFunc) => ({
then(callback: (element: HTMLElement) => void) {
if (!title.endsWith("titleSynchronizationMethod") && !title.endsWith("titleUpdateThinning")) return;
panels.set(title, onUpdate);
callback({} as HTMLElement);
},
}),
} as unknown as PageFunctions;
paneSyncSettings.call(tab as unknown as ObsidianLiveSyncSettingTab, {} as HTMLElement, functions);
return { tab, callbacks, panels };
}
function visible(key: string): boolean {
const control = controls.get(key);
if (!control) throw new Error(`Missing setting control: ${key}`);
return control.onUpdate?.().visibility !== false;
}
beforeEach(() => controls.clear());
describe("mode-specific database batching settings", () => {
it.each([
{ liveSync: true, batchSave: true, batchSaveInLiveSync: false, delays: false },
{ liveSync: true, batchSave: false, batchSaveInLiveSync: true, delays: true },
{ liveSync: false, batchSave: true, batchSaveInLiveSync: false, delays: true },
{ liveSync: false, batchSave: false, batchSaveInLiveSync: true, delays: false },
])("shows the current mode choice and its shared delays: %j", ({ delays, ...settings }) => {
const { panels } = setup(settings.liveSync, settings.batchSave, settings.batchSaveInLiveSync);
expect(panels.get("obsidianLiveSyncSettingTab.titleUpdateThinning")?.().visibility).not.toBe(false);
expect(visible("batchSave")).toBe(!settings.liveSync);
expect(visible("batchSaveInLiveSync")).toBe(settings.liveSync);
expect(visible("batchSaveMinimumDelay")).toBe(delays);
expect(visible("batchSaveMaximumDelay")).toBe(delays);
});
it.each([
[true, false],
[false, true],
])("retains both batching choices across mode changes: %j", async (batchSave, batchSaveInLiveSync) => {
const { tab, callbacks } = setup(false, batchSave, batchSaveInLiveSync);
const switchMode = callbacks.get("syncMode")!;
for (const mode of ["LIVESYNC", "PERIODIC", "ONEVENTS", "LIVESYNC"]) {
await switchMode(mode);
expect(tab.editingSettings).toMatchObject({ batchSave, batchSaveInLiveSync });
}
expect(tab.services.control.applySettings).toHaveBeenCalledTimes(4);
});
});
@@ -17,7 +17,7 @@ const rangeMessages = {
}; };
describe("Advanced setting specifications", () => { describe("Advanced setting specifications", () => {
it("lists only the ten controls currently exposed by the Advanced page", () => { it("lists only the eleven controls currently exposed by the Advanced page", () => {
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true }); const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
expect(groups.map(({ heading }) => heading)).toEqual([ expect(groups.map(({ heading }) => heading)).toEqual([
@@ -30,6 +30,7 @@ describe("Advanced setting specifications", () => {
"hashCacheMaxCount", "hashCacheMaxCount",
"chunkSplitterVersion", "chunkSplitterVersion",
"customChunkSize", "customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion", "pathNormalizationVersion",
"readChunksOnline", "readChunksOnline",
"useOnlyLocalChunk", "useOnlyLocalChunk",
@@ -40,6 +41,17 @@ describe("Advanced setting specifications", () => {
]); ]);
}); });
it("exposes strict content checking as an ungated Advanced toggle", () => {
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => false })
.find(({ heading }) => heading === "Local Database Tweak")
?.items.find(({ key }) => key === "strictContentCheckOnStartup");
if (!spec) throw new Error("Strict content checking is not exposed on the Advanced page");
expect(spec.control).toEqual({ type: "toggle" });
expect(spec.visible).toBeUndefined();
expect(spec.disabled).toBeUndefined();
});
it("keeps path identity and the four remote chunk controls CouchDB-only", () => { it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
let couchDB = false; let couchDB = false;
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB }); const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
@@ -86,6 +86,7 @@ describe("settings page catalogue", () => {
"hashCacheMaxCount", "hashCacheMaxCount",
"chunkSplitterVersion", "chunkSplitterVersion",
"customChunkSize", "customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion", "pathNormalizationVersion",
"readChunksOnline", "readChunksOnline",
"useOnlyLocalChunk", "useOnlyLocalChunk",
+19 -7
View File
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
@@ -37,11 +38,6 @@ import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/
import { SetupManager, UserMode } from "./SetupManager"; import { SetupManager, UserMode } from "./SetupManager";
class TestSettingService extends SettingService<ServiceContext> { class TestSettingService extends SettingService<ServiceContext> {
protected setItem(_key: string, _value: string): void {}
protected getItem(_key: string): string {
return "";
}
protected deleteItem(_key: string): void {}
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> { protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
return Promise.resolve(); return Promise.resolve();
} }
@@ -65,6 +61,7 @@ function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
function createSetupManager() { function createSetupManager() {
const setting = new TestSettingService(new ServiceContext(), { const setting = new TestSettingService(new ServiceContext(), {
deviceValues: new MemoryValueStore(),
APIService: { APIService: {
getSystemVaultName: vi.fn(() => "vault"), getSystemVaultName: vi.fn(() => "vault"),
getAppID: vi.fn(() => "app"), getAppID: vi.fn(() => "app"),
@@ -172,10 +169,12 @@ describe("SetupManager", () => {
const { manager, dialogManager } = createSetupManager(); const { manager, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually"); dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true); const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true);
const newVaultSettings = createNewVaultSettings();
await manager.onOnboard(UserMode.NewUser); await manager.onOnboard(UserMode.NewUser);
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser); expect(newVaultSettings.customChunkSize).toBe(0);
expect(configureManually).toHaveBeenCalledWith(newVaultSettings, UserMode.NewUser);
}); });
it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => { it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => {
@@ -487,8 +486,15 @@ describe("SetupManager", () => {
expect(applySettings).not.toHaveBeenCalled(); expect(applySettings).not.toHaveBeenCalled();
}); });
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => { it.each([
{ importedSize: 0, expectedSize: 0 },
{ importedSize: undefined, expectedSize: 60 },
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
const { manager, setting, dialogManager } = createSetupManager(); const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
customChunkSize: 60,
};
const imported = { const imported = {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
remoteConfigurations: { remoteConfigurations: {
@@ -507,6 +513,8 @@ describe("SetupManager", () => {
}, },
activeConfigurationId: "archive", activeConfigurationId: "archive",
} as ObsidianLiveSyncSettings; } as ObsidianLiveSyncSettings;
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
else imported.customChunkSize = importedSize;
dialogManager.openWithExplicitCancel dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported) .mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user"); .mockResolvedValueOnce("compatible-existing-user");
@@ -514,6 +522,7 @@ describe("SetupManager", () => {
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings"); await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
const current = setting.currentSettings(); const current = setting.currentSettings();
expect(current.customChunkSize).toBe(expectedSize);
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations); expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
expect(current.activeConfigurationId).toBe("archive"); expect(current.activeConfigurationId).toBe("archive");
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false); expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
@@ -523,6 +532,7 @@ describe("SetupManager", () => {
const { manager, setting, dialogManager } = createSetupManager(); const { manager, setting, dialogManager } = createSetupManager();
setting.settings = { setting.settings = {
...setting.currentSettings(), ...setting.currentSettings(),
customChunkSize: 60,
isConfigured: true, isConfigured: true,
remoteConfigurations: { remoteConfigurations: {
existing: { existing: {
@@ -554,6 +564,7 @@ describe("SetupManager", () => {
await manager.onCouchDBManualSetup(UserMode.ExistingUser, setting.currentSettings()); await manager.onCouchDBManualSetup(UserMode.ExistingUser, setting.currentSettings());
const current = setting.currentSettings(); const current = setting.currentSettings();
expect(current.customChunkSize).toBe(60);
expect(current.remoteConfigurations.existing).toBeDefined(); expect(current.remoteConfigurations.existing).toBeDefined();
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2); expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
expect(current.activeConfigurationId).not.toBe("existing"); expect(current.activeConfigurationId).not.toBe("existing");
@@ -662,6 +673,7 @@ describe("SetupManager", () => {
await manager.onP2PManualSetup(UserMode.NewUser, setting.currentSettings()); await manager.onP2PManualSetup(UserMode.NewUser, setting.currentSettings());
const current = setting.currentSettings(); const current = setting.currentSettings();
expect(current.customChunkSize).toBe(0);
expect(Object.keys(current.remoteConfigurations)).toHaveLength(1); expect(Object.keys(current.remoteConfigurations)).toHaveLength(1);
expect(current.activeConfigurationId).not.toBe(""); expect(current.activeConfigurationId).not.toBe("");
expect(current.P2P_ActiveRemoteConfigurationId).toBe(current.activeConfigurationId); expect(current.P2P_ActiveRemoteConfigurationId).toBe(current.activeConfigurationId);
@@ -52,26 +52,48 @@
const context = getDialogContext(); const context = getDialogContext();
let error = $state(""); let error = $state("");
let connectionPathResetNotice = $state(false); let connectionPathResetNotice = $state(false);
let peerNameReady = $state(false);
let mounted = false;
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting)); const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>; type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
const { setResult, getInitialData }: Props = $props(); const { setResult, getInitialData }: Props = $props();
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined; let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
onMount(() => { onMount(() => {
mounted = true;
const initialData = getInitialData?.(); const initialData = getInitialData?.();
connectionProbe = initialData?.connectionProbe; connectionProbe = initialData?.connectionProbe;
const initialSettings = initialData?.settings; const initialSettings = initialData?.settings;
if (initialSettings) { if (initialSettings) {
syncSetting = pickP2PSyncSettings(initialSettings); syncSetting = pickP2PSyncSettings(initialSettings);
} }
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim(); const savedPeerName = initialSettings?.P2P_DevicePeerName;
const initialPeerName = typeof savedPeerName === "string" ? savedPeerName.trim() : "";
if (initialPeerName !== "") { if (initialPeerName !== "") {
return; peerNameReady = true;
return () => {
mounted = false;
};
} }
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME); void context.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME).then(
if (cachedPeerName) { (cachedPeerName) => {
syncSetting.P2P_DevicePeerName = cachedPeerName as string; if (mounted) {
if (typeof cachedPeerName === "string" && cachedPeerName) {
syncSetting.P2P_DevicePeerName = cachedPeerName;
} }
peerNameReady = true;
}
},
(loadError: unknown) => {
if (mounted) {
error = loadError instanceof Error ? loadError.message : String(loadError);
peerNameReady = true;
}
}
);
return () => {
mounted = false;
};
}); });
function generateSetting() { function generateSetting() {
const connSetting: P2PSyncSetting = { const connSetting: P2PSyncSetting = {
@@ -193,6 +215,7 @@
} }
async function checkAndCommit() { async function checkAndCommit() {
if (!peerNameReady) return;
error = ""; error = "";
try { try {
error = (await checkConnection()) || ""; error = (await checkConnection()) || "";
@@ -207,6 +230,7 @@
} }
} }
function commit() { function commit() {
if (!peerNameReady) return;
error = validateManagedTurnSettings(syncSetting) ?? ""; error = validateManagedTurnSettings(syncSetting) ?? "";
if (error) return; if (error) return;
const setting = pickP2PSyncSettings(generateSetting()); const setting = pickP2PSyncSettings(generateSetting());
@@ -217,6 +241,7 @@
} }
const canProceed = $derived.by(() => { const canProceed = $derived.by(() => {
return ( return (
peerNameReady &&
syncSetting.P2P_relays.trim() !== "" && syncSetting.P2P_relays.trim() !== "" &&
syncSetting.P2P_roomID.trim() !== "" && syncSetting.P2P_roomID.trim() !== "" &&
syncSetting.P2P_passphrase.trim() !== "" && syncSetting.P2P_passphrase.trim() !== "" &&
@@ -291,6 +316,7 @@
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
spellcheck="false" spellcheck="false"
disabled={!peerNameReady}
bind:value={syncSetting.P2P_DevicePeerName} bind:value={syncSetting.P2P_DevicePeerName}
/> />
</InputRow> </InputRow>
@@ -385,7 +411,11 @@
disabled={!canProceed} disabled={!canProceed}
commit={() => checkAndCommit()} commit={() => checkAndCommit()}
/> />
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} /> <Decision
title={translateMessage("Continue anyway")}
disabled={!peerNameReady}
commit={() => commit()}
/>
<Decision title={translateMessage("Cancel")} commit={() => cancel()} /> <Decision title={translateMessage("Cancel")} commit={() => cancel()} />
</UserDecisions> </UserDecisions>
{/if} {/if}
+47 -4
View File
@@ -10,6 +10,15 @@ export type DisposableReactiveValue<T> = ReactiveValue<T> & {
dispose(): void; dispose(): void;
}; };
export type PaddedCounterLabel = DisposableReactiveValue<string> & {
readonly count: ReactiveValue<number | undefined>;
};
export type ChunkFetchCounterLabel = DisposableReactiveValue<string> & {
readonly initialCount: ReactiveValue<number | undefined>;
readonly retryingCount: ReactiveValue<number | undefined>;
};
function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> { function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> {
return { return {
get value() { get value() {
@@ -92,9 +101,10 @@ export function createPaddedCounterLabel(
source: ReactiveValue<number>, source: ReactiveValue<number>,
mark: string, mark: string,
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
): DisposableReactiveValue<string> { ): PaddedCounterLabel {
const linger = Math.max(0, inactiveLingerMs); const linger = Math.max(0, inactiveLingerMs);
const formatted = reactiveSource(""); const formatted = reactiveSource("");
const displayedCount = reactiveSource<number | undefined>(undefined);
let maximumLength = 1; let maximumLength = 1;
let clearTimer: number | undefined; let clearTimer: number | undefined;
let disposed = false; let disposed = false;
@@ -114,22 +124,39 @@ export function createPaddedCounterLabel(
cancelClear(); cancelClear();
const count = source.value; const count = source.value;
formatted.value = format(count); formatted.value = format(count);
displayedCount.value = count;
if (count !== 0) return; if (count !== 0) return;
clearTimer = compatGlobal.setTimeout(() => { clearTimer = compatGlobal.setTimeout(() => {
clearTimer = undefined; clearTimer = undefined;
if (disposed) return; if (disposed) return;
formatted.value = ""; formatted.value = "";
displayedCount.value = undefined;
maximumLength = 1; maximumLength = 1;
}, linger); }, linger);
}; };
source.onChanged(update); source.onChanged(update);
return asDisposableReactiveValue(formatted, () => { const display = asDisposableReactiveValue(formatted, () => {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
cancelClear(); cancelClear();
source.offChanged(update); source.offChanged(update);
}); });
return {
get value() {
return display.value;
},
onChanged(handler) {
display.onChanged(handler);
},
offChanged(handler) {
display.offChanged(handler);
},
count: displayedCount,
dispose() {
display.dispose();
},
};
} }
/** /**
@@ -138,7 +165,7 @@ export function createPaddedCounterLabel(
*/ */
export function createChunkFetchCounterLabel( export function createChunkFetchCounterLabel(
source: ReactiveValue<{ initial: number; retrying: number }> source: ReactiveValue<{ initial: number; retrying: number }>
): DisposableReactiveValue<string> { ): ChunkFetchCounterLabel {
const initialCount = reactiveSource(0); const initialCount = reactiveSource(0);
const retryingCount = reactiveSource(0); const retryingCount = reactiveSource(0);
const initialLabel = createPaddedCounterLabel(initialCount, "🛄"); const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
@@ -168,7 +195,7 @@ export function createChunkFetchCounterLabel(
source.onChanged(updateCounts); source.onChanged(updateCounts);
updateCounts(); updateCounts();
return asDisposableReactiveValue(formatted, () => { const display = asDisposableReactiveValue(formatted, () => {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
source.offChanged(updateCounts); source.offChanged(updateCounts);
@@ -177,4 +204,20 @@ export function createChunkFetchCounterLabel(
initialLabel.dispose(); initialLabel.dispose();
retryingLabel.dispose(); retryingLabel.dispose();
}); });
return {
get value() {
return display.value;
},
onChanged(handler) {
display.onChanged(handler);
},
offChanged(handler) {
display.offChanged(handler);
},
initialCount: initialLabel.count,
retryingCount: retryingLabel.count,
dispose() {
display.dispose();
},
};
} }
@@ -111,6 +111,24 @@ describe("createPaddedCounterLabel", () => {
display.dispose(); display.dispose();
}); });
it("exposes the numeric value while retaining zero through the existing linger", () => {
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📥");
expect(display.count.value).toBeUndefined();
source.value = 9;
expect(display.count.value).toBe(9);
source.value = 0;
expect(display.count.value).toBe(0);
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
expect(display.count.value).toBe(0);
vi.advanceTimersByTime(1);
expect(display.count.value).toBeUndefined();
display.dispose();
});
it("cancels the pending clear when counter activity resumes", () => { it("cancels the pending clear when counter activity resumes", () => {
const source = reactiveSource(0); const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "📄"); const display = createPaddedCounterLabel(source, "📄");
+152
View File
@@ -0,0 +1,152 @@
const INACTIVE_CLASS = "livesync-stat-inactive";
export type StatusBarPart = {
key: string;
text: string;
value?: number;
suffix?: string;
title?: string;
};
export type StatusBarRenderer = {
render(parts: readonly StatusBarPart[]): void;
dispose(): void;
};
type RenderedItem = {
element: HTMLSpanElement;
prefix: Text;
value: HTMLSpanElement;
valueText: Text;
suffix: Text;
widestValueLength: number;
};
export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer {
const items = new Map<string, RenderedItem>();
let disposed = false;
const setText = (node: Text, text: string) => {
if (node.data !== text) node.data = text;
};
const setHidden = (element: HTMLElement, hidden: boolean) => {
if (element.hidden !== hidden) element.hidden = hidden;
};
const setInactive = (element: HTMLElement, inactive: boolean) => {
if (element.classList.contains(INACTIVE_CLASS) === inactive) return;
element.classList.toggle(INACTIVE_CLASS, inactive);
if (inactive) element.setAttribute("aria-hidden", "true");
else element.removeAttribute("aria-hidden");
};
const setWidth = (element: HTMLElement, width: string) => {
if (element.style.getPropertyValue("--livesync-stat-width") !== width) {
element.setCssProps({ "--livesync-stat-width": width });
}
};
const createItem = (key: string): RenderedItem => {
const document = parent.ownerDocument;
const element = parent.createSpan();
element.className = "livesync-stat-item livesync-stat-" + key;
const prefix = document.createTextNode("");
const value = element.createSpan();
value.className = "livesync-stat-value";
const valueText = document.createTextNode("");
value.appendChild(valueText);
value.hidden = true;
const suffix = document.createTextNode("");
element.appendChild(prefix);
element.appendChild(value);
element.appendChild(suffix);
const item = { element, prefix, value, valueText, suffix, widestValueLength: 0 };
items.set(key, item);
return item;
};
const updateItem = (item: RenderedItem, part: StatusBarPart) => {
setText(item.prefix, part.text);
const title = part.title ?? "";
if (item.element.title !== title) item.element.title = title;
if (part.value === undefined) {
setText(item.valueText, "");
setText(item.suffix, "");
setHidden(item.value, true);
setWidth(item.value, "");
item.widestValueLength = 0;
return;
}
const formattedValue = String(part.value);
setText(item.valueText, formattedValue);
setText(item.suffix, part.suffix ?? "");
setHidden(item.value, false);
if (formattedValue.length > item.widestValueLength) {
item.widestValueLength = formattedValue.length;
const width = item.widestValueLength + "ch";
setWidth(item.value, width);
}
};
const reorderIfNeeded = (visibleItems: readonly RenderedItem[]) => {
const desiredOrder = visibleItems.map((item) => item.element);
const visibleElements = new Set(desiredOrder);
const currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement =>
visibleElements.has(node as HTMLSpanElement)
);
if (
currentOrder.length === desiredOrder.length &&
currentOrder.every((node, index) => node === desiredOrder[index])
)
return;
let reference: ChildNode | null = currentOrder[0] ?? null;
for (const element of desiredOrder) {
if (element !== reference) parent.insertBefore(element, reference);
const childNodes = Array.from(parent.childNodes);
const elementIndex = childNodes.indexOf(element);
reference =
childNodes.slice(elementIndex + 1).find((node) => visibleElements.has(node as HTMLSpanElement)) ?? null;
}
};
return {
render(parts) {
if (disposed) return;
const visibleItems: RenderedItem[] = [];
const visibleKeys = new Set<string>();
for (const part of parts) {
let item = items.get(part.key);
if (!item) item = createItem(part.key);
if (item.element.classList.contains(INACTIVE_CLASS)) item.widestValueLength = 0;
updateItem(item, part);
setInactive(item.element, false);
visibleItems.push(item);
visibleKeys.add(part.key);
}
for (const [key, item] of items) {
if (visibleKeys.has(key)) continue;
setInactive(item.element, true);
}
reorderIfNeeded(visibleItems);
},
dispose() {
if (disposed) return;
disposed = true;
for (const item of items.values()) {
if (item.element.parentNode === parent) parent.removeChild(item.element);
}
items.clear();
},
};
}
@@ -0,0 +1,364 @@
import { describe, expect, it } from "vitest";
import { createStatusBarRenderer, type StatusBarPart } from "./StatusBarRenderer.ts";
type TestNode = TestElement | TestTextNode;
class TestDocument {
mutations = 0;
createElement(tagName: string): TestElement {
return new TestElement(this, tagName);
}
createTextNode(data: string): TestTextNode {
return new TestTextNode(this, data);
}
}
class TestTextNode {
readonly nodeType = 3;
parentNode: TestElement | undefined;
private currentData: string;
constructor(
private readonly document: TestDocument,
data: string
) {
this.currentData = data;
}
get data(): string {
return this.currentData;
}
set data(value: string) {
this.document.mutations++;
this.currentData = value;
}
get textContent(): string {
return this.currentData;
}
}
class TestStyle {
private properties = new Map<string, string>();
constructor(private readonly document: TestDocument) {}
getPropertyValue(name: string): string {
return this.properties.get(name) ?? "";
}
setProperty(name: string, value: string) {
this.document.mutations++;
this.properties.set(name, value);
}
}
class TestElement {
readonly nodeType = 1;
readonly ownerDocument: Document;
readonly childNodes: TestNode[] = [];
parentNode: TestElement | undefined;
readonly style: TestStyle;
private currentClassName = "";
private currentHidden = false;
private currentTitle = "";
private attributes = new Map<string, string>();
readonly classList = {
contains: (token: string): boolean => this.currentClassName.split(" ").includes(token),
toggle: (token: string, force: boolean): boolean => {
const tokens = this.currentClassName.split(" ").filter(Boolean);
const present = tokens.includes(token);
if (present !== force) {
this.className = force
? [...tokens, token].join(" ")
: tokens.filter((item) => item !== token).join(" ");
}
return force;
},
};
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
setAttribute(name: string, value: string) {
this.document.mutations++;
this.attributes.set(name, value);
}
removeAttribute(name: string) {
if (this.attributes.delete(name)) this.document.mutations++;
}
constructor(
private readonly document: TestDocument,
readonly tagName: string
) {
this.ownerDocument = document as unknown as Document;
this.style = new TestStyle(document);
}
get className(): string {
return this.currentClassName;
}
set className(value: string) {
this.document.mutations++;
this.currentClassName = value;
}
get hidden(): boolean {
return this.currentHidden;
}
set hidden(value: boolean) {
this.document.mutations++;
this.currentHidden = value;
}
get title(): string {
return this.currentTitle;
}
set title(value: string) {
this.document.mutations++;
this.currentTitle = value;
}
get children(): TestElement[] {
return this.childNodes.filter((node): node is TestElement => node instanceof TestElement);
}
get textContent(): string {
return this.childNodes.map((node) => node.textContent ?? "").join("");
}
createSpan(): TestElement {
return this.appendChild(this.document.createElement("span"));
}
setCssProps(properties: Record<string, string>) {
for (const [name, value] of Object.entries(properties)) this.style.setProperty(name, value);
}
appendChild<T extends TestNode>(node: T): T {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
this.childNodes.push(node);
node.parentNode = this;
this.document.mutations++;
return node;
}
insertBefore<T extends TestNode>(node: T, reference: TestNode | null): T {
if (node === reference) return node;
if (node.parentNode) {
node.parentNode.removeChild(node);
}
const index = reference ? this.childNodes.indexOf(reference) : -1;
if (index < 0) {
this.childNodes.push(node);
} else {
this.childNodes.splice(index, 0, node);
}
node.parentNode = this;
this.document.mutations++;
return node;
}
removeChild<T extends TestNode>(node: T): T {
const index = this.childNodes.indexOf(node);
if (index < 0) throw new Error("Node is not a child");
this.childNodes.splice(index, 1);
node.parentNode = undefined;
this.document.mutations++;
return node;
}
}
function createFixture() {
const document = new TestDocument();
const root = new TestElement(document, "div");
return { document, root, parent: root as unknown as HTMLElement };
}
function findItem(root: TestElement, key: string): TestElement {
const item = root.children.find((child) => child.className.split(" ").includes("livesync-stat-" + key));
if (!item) throw new Error("Missing item: " + key);
return item;
}
function findValue(item: TestElement): TestElement {
const value = item.children.find((child) => child.className.split(" ").includes("livesync-stat-value"));
if (!value) throw new Error("Missing numeric value");
return value;
}
function visibleItems(root: TestElement): TestElement[] {
return root.children.filter(
(child) => !child.classList.contains("livesync-stat-inactive") && child.classList.contains("livesync-stat-item")
);
}
describe("createStatusBarRenderer", () => {
it("renders complete text safely and gives each item an independent class hook", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
renderer.render([
{ key: "sync", text: "<sync>" },
{ key: "queue", text: " (queued: ", value: 2, suffix: ")" },
]);
const sync = findItem(root, "sync");
const queue = findItem(root, "queue");
expect(sync.className.split(" ")).toEqual(["livesync-stat-item", "livesync-stat-sync"]);
expect(sync.textContent).toBe("<sync>");
expect(queue.className.split(" ")).toContain("livesync-stat-queue");
expect(queue.textContent).toBe(" (queued: 2)");
expect(findValue(queue).className).toBe("livesync-stat-value");
renderer.render([{ key: "queue", text: " (queued: ", value: 3, suffix: ")" }]);
expect(sync.classList.contains("livesync-stat-inactive")).toBe(true);
expect(sync.hidden).toBe(false);
expect(sync.getAttribute("aria-hidden")).toBe("true");
expect(queue.classList.contains("livesync-stat-inactive")).toBe(false);
expect(queue.textContent).toBe(" (queued: 3)");
renderer.render([{ key: "queue", text: "Queue idle" }]);
expect(queue.textContent).toBe("Queue idle");
expect(findValue(queue).hidden).toBe(true);
renderer.dispose();
});
it("does not mutate the DOM when the same parts are rendered again", () => {
const { document, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
const parts: StatusBarPart[] = [
{ key: "pending", text: " pending ", value: 7, suffix: " files", title: "Pending" },
];
renderer.render(parts);
const mutations = document.mutations;
renderer.render(parts);
expect(document.mutations).toBe(mutations);
renderer.dispose();
});
it("updates values while retaining the widest visible numeric width", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
renderer.render([{ key: "count", text: "(", value: 9, suffix: ")" }]);
const item = findItem(root, "count");
const value = findValue(item);
expect(item.textContent).toBe("(9)");
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
renderer.render([{ key: "count", text: "(", value: 123, suffix: ")" }]);
expect(value.textContent).toBe("123");
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
renderer.render([{ key: "count", text: "(", value: 4, suffix: ")" }]);
expect(value.textContent).toBe("4");
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
renderer.dispose();
});
it("retains omitted content and width, then resets width when the same item returns", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
renderer.render([{ key: "count", text: " ", value: 1234 }]);
const item = findItem(root, "count");
const value = findValue(item);
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
renderer.render([]);
expect(item.classList.contains("livesync-stat-inactive")).toBe(true);
expect(item.hidden).toBe(false);
expect(item.getAttribute("aria-hidden")).toBe("true");
expect(item.textContent).toBe(" 1234");
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
renderer.render([{ key: "count", text: " ", value: 5 }]);
expect(findItem(root, "count")).toBe(item);
expect(item.classList.contains("livesync-stat-inactive")).toBe(false);
expect(item.getAttribute("aria-hidden")).toBeNull();
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
renderer.dispose();
});
it("keeps inactive items in place when other items appear and does not repeat inactive mutations", () => {
const { document, root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
const first = { key: "first", text: "first " };
const count = { key: "count", text: "count ", value: 1234 };
const last = { key: "last", text: "last" };
renderer.render([first, count, last]);
const initialOrder = [...root.children];
const omitted = findItem(root, "count");
renderer.render([first, last]);
expect(root.children.map((item) => initialOrder.indexOf(item))).toEqual([0, 1, 2]);
expect(visibleItems(root)).toEqual([initialOrder[0], initialOrder[2]]);
const mutations = document.mutations;
renderer.render([first, last]);
expect(document.mutations).toBe(mutations);
const added = { key: "added", text: "added " };
renderer.render([first, added, last]);
expect(root.children.map((item) => item.textContent)).toEqual(["first ", "count 1234", "added ", "last"]);
expect(omitted.textContent).toBe("count 1234");
expect(findValue(omitted).style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
renderer.render([first, count, added, last]);
expect(findItem(root, "count")).toBe(omitted);
expect(visibleItems(root)).toEqual(root.children);
renderer.dispose();
});
it("reorders existing nodes without replacing them", () => {
const { root, parent } = createFixture();
const renderer = createStatusBarRenderer(parent);
renderer.render([
{ key: "first", text: "first " },
{ key: "second", text: "second " },
]);
const first = findItem(root, "first");
const second = findItem(root, "second");
renderer.render([
{ key: "second", text: "second " },
{ key: "first", text: "first " },
]);
expect(visibleItems(root)).toEqual([second, first]);
expect(findItem(root, "first")).toBe(first);
expect(findItem(root, "second")).toBe(second);
renderer.dispose();
});
it("removes its nodes on dispose and ignores later renders", () => {
const { root, parent } = createFixture();
const externalNode = new TestElement(new TestDocument(), "strong");
root.appendChild(externalNode);
const renderer = createStatusBarRenderer(parent);
renderer.render([{ key: "sync", text: "sync" }]);
const item = findItem(root, "sync");
renderer.dispose();
const childCountAfterDispose = root.childNodes.length;
renderer.dispose();
renderer.render([{ key: "later", text: "later" }]);
expect(item.parentNode).toBeUndefined();
expect(root.childNodes).toEqual([externalNode]);
expect(root.childNodes).toHaveLength(childCountAfterDispose);
});
});
+25 -2
View File
@@ -19,6 +19,22 @@ import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars"; import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
const OBSIDIAN_PLATFORMS = new Set([
"android-app",
"ios",
"macos",
"mobile-app",
"mobile",
"safari",
"desktop",
"desktop-app",
"unknown-obsidian",
]);
function isObsidianPlatform(platform: string): boolean {
return OBSIDIAN_PLATFORMS.has(platform);
}
export class ModuleLiveSyncMain extends AbstractModule { export class ModuleLiveSyncMain extends AbstractModule {
async _onLiveSyncReady() { async _onLiveSyncReady() {
if (!(await this.core.services.appLifecycle.onLayoutReady())) return false; if (!(await this.core.services.appLifecycle.onLayoutReady())) return false;
@@ -44,13 +60,20 @@ export class ModuleLiveSyncMain extends AbstractModule {
} }
} }
// Ordinary start-up may continue when individual files could not be // Ordinary start-up may continue when individual files could not be
// processed. Explicit Fetch and Rebuild flows retain the strict default. // processed. Explicit Fetch and Rebuild flows remain strict.
const { ignoreSuspending = false, continueOnFileFailure = true } = this.core.startupDatabaseOptions; const { ignoreSuspending = false, continueOnFileFailure = true } = this.core.startupDatabaseOptions;
// Only ordinary Obsidian start-up honours the opt-out. Keep other hosts
// and explicit operation modes strict.
const strictContentCheck =
!isObsidianPlatform(this.services.API.getPlatform()) ||
this.settings.strictContentCheckOnStartup === true ||
this.core.startupDatabaseOptions.continueOnFileFailure === false;
const initialisationResult = await this.services.databaseEvents.initialiseDatabase( const initialisationResult = await this.services.databaseEvents.initialiseDatabase(
false, false,
false, false,
ignoreSuspending, ignoreSuspending,
continueOnFileFailure continueOnFileFailure,
{ strictContentCheck }
); );
if (initialisationResult === VaultScanResults.FAILED) { if (initialisationResult === VaultScanResults.FAILED) {
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE); this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
@@ -90,9 +90,11 @@ describe("ModuleLiveSyncMain", () => {
}, },
}, },
services: { services: {
API: { getPlatform: () => "desktop" },
databaseEvents: { initialiseDatabase }, databaseEvents: { initialiseDatabase },
}, },
settings: { settings: {
strictContentCheckOnStartup: false,
suspendFileWatching: false, suspendFileWatching: false,
suspendParseReplicationResult: false, suspendParseReplicationResult: false,
}, },
@@ -102,7 +104,7 @@ describe("ModuleLiveSyncMain", () => {
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never); const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
expect(result).toBe(false); expect(result).toBe(false);
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true); expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: false });
expect(log).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", LOG_LEVEL_NOTICE); expect(log).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", LOG_LEVEL_NOTICE);
}); });
@@ -120,6 +122,7 @@ describe("ModuleLiveSyncMain", () => {
services: { appLifecycle }, services: { appLifecycle },
}, },
services: { services: {
API: { getPlatform: () => "desktop" },
appLifecycle, appLifecycle,
control: { applySettings: vi.fn(async () => undefined) }, control: { applySettings: vi.fn(async () => undefined) },
databaseEvents: { initialiseDatabase }, databaseEvents: { initialiseDatabase },
@@ -152,6 +155,7 @@ describe("ModuleLiveSyncMain", () => {
}, },
}, },
services: { services: {
API: { getPlatform: () => "desktop" },
databaseEvents: { initialiseDatabase }, databaseEvents: { initialiseDatabase },
}, },
settings: { settings: {
@@ -163,6 +167,96 @@ describe("ModuleLiveSyncMain", () => {
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never); await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, true, false); expect(initialiseDatabase).toHaveBeenCalledWith(false, false, true, false, { strictContentCheck: true });
});
it("passes the strict content check setting for ordinary Obsidian start-up", async () => {
const initialiseDatabase = vi.fn(async () => false);
const host = {
core: {
startupDatabaseOptions: {},
services: {
appLifecycle: {
onLayoutReady: vi.fn(async () => true),
},
},
},
services: {
API: { getPlatform: () => "desktop" },
databaseEvents: { initialiseDatabase },
},
settings: {
strictContentCheckOnStartup: true,
suspendFileWatching: false,
suspendParseReplicationResult: false,
},
_log: vi.fn(),
};
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: true });
});
it.each(["server", "browser"])(
"keeps the strict content check on non-Obsidian host start-up (%s)",
async (platform) => {
const initialiseDatabase = vi.fn(async () => false);
const host = {
core: {
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: true },
services: {
appLifecycle: {
onLayoutReady: vi.fn(async () => true),
},
},
},
services: {
API: { getPlatform: () => platform },
databaseEvents: { initialiseDatabase },
},
settings: {
strictContentCheckOnStartup: false,
suspendFileWatching: false,
suspendParseReplicationResult: false,
},
_log: vi.fn(),
};
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: true });
}
);
it("keeps explicit recovery startup options strict when the setting is off", async () => {
const initialiseDatabase = vi.fn(async () => false);
const host = {
core: {
startupDatabaseOptions: {
ignoreSuspending: false,
continueOnFileFailure: false,
},
services: {
appLifecycle: {
onLayoutReady: vi.fn(async () => true),
},
},
},
services: {
API: { getPlatform: () => "desktop" },
databaseEvents: { initialiseDatabase },
},
settings: {
strictContentCheckOnStartup: false,
suspendFileWatching: false,
suspendParseReplicationResult: false,
},
_log: vi.fn(),
};
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, false, { strictContentCheck: true });
}); });
}); });
@@ -0,0 +1,396 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
import { $msg as translate } from "@/common/translation";
import type {
JoinedDeviceRow,
JoinedDevicesDialogOptions,
JoinedDevicesView,
} from "@/serviceFeatures/joinedDevicesDialogTypes";
const { setResult, getInitialData }: GuestDialogProps<"close", JoinedDevicesDialogOptions> = $props();
let options = $state<JoinedDevicesDialogOptions>();
let view = $state<JoinedDevicesView>();
let isRefreshing = $state(false);
let confirmingId = $state<string>();
let forgettingId = $state<string>();
let statusMessage = $state("");
let errorMessage = $state("");
let disposed = false;
const busy = $derived(isRefreshing || forgettingId !== undefined);
const title = translate("Joined devices");
onMount(() => {
options = getInitialData?.();
if (!options) {
errorMessage = translate(
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
);
return;
}
void refreshView();
});
onDestroy(() => {
disposed = true;
options?.cancel();
});
async function refreshView(afterForget = false) {
const dialogOptions = options;
if (!dialogOptions || disposed || isRefreshing || (forgettingId !== undefined && !afterForget)) return;
isRefreshing = true;
errorMessage = "";
if (!afterForget) {
statusMessage = "";
confirmingId = undefined;
}
try {
const refreshedView = await dialogOptions.refresh();
if (disposed) return;
view = refreshedView;
} catch {
if (!disposed) {
errorMessage = translate(
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
);
}
} finally {
if (!disposed) isRefreshing = false;
}
}
function toggleConfirmation(device: JoinedDeviceRow) {
if (busy || device.self) return;
confirmingId = confirmingId === device.id ? undefined : device.id;
errorMessage = "";
statusMessage = "";
}
async function forgetDevice(device: JoinedDeviceRow) {
const dialogOptions = options;
if (!dialogOptions || disposed || busy || device.self || confirmingId !== device.id) return;
forgettingId = device.id;
confirmingId = undefined;
errorMessage = "";
statusMessage = "";
try {
const result = await dialogOptions.forget(device.id);
if (disposed) return;
if (result === "forgotten") {
statusMessage = translate("The device record was forgotten.");
await refreshView(true);
} else if (result === "not-found") {
statusMessage = translate("The device record was already absent.");
await refreshView(true);
} else if (result === "conflict") {
errorMessage = translate(
"The device list changed while you were trying to forget this device. Refresh the list before trying again."
);
} else {
errorMessage = translate(
"This remote does not support conditional updates, so the device record could not be removed."
);
}
} catch {
if (!disposed) {
errorMessage = translate(
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again."
);
}
} finally {
if (!disposed) forgettingId = undefined;
}
}
function formatLastConnected(timestamp: number | undefined) {
if (timestamp === undefined || !Number.isFinite(timestamp) || timestamp <= 0) return translate("Not recorded");
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return translate("Not recorded");
return date.toLocaleString();
}
</script>
<DialogHeader {title} />
<div class="joined-devices-dialog">
<p>
{translate("This list shows device information recorded by the remote. It does not show whether a device is online now.")}
</p>
<InfoNote
message="Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable."
/>
{#if view}
{#if !view.milestonePresent}
<p class="state-message">{translate("This remote does not currently have a record of joined devices.")}</p>
{/if}
{#if view.cleaned}
<p class="state-message">
{translate(
"This remote has been cleaned. Use the existing recovery process to join devices again as needed."
)}
</p>
{/if}
{#if view.locked}
<p class="state-message">
{translate(
"This remote is locked. Use the existing recovery process for devices that need to join again."
)}
</p>
{/if}
{/if}
{#if errorMessage}
<p class="error-message" role="alert">{errorMessage}</p>
{/if}
{#if statusMessage}
<p class="status-message" role="status">{statusMessage}</p>
{/if}
{#if isRefreshing && !view}
<p role="status">{translate("Loading joined devices…")}</p>
{/if}
{#if view}
{#if view.devices.length > 0}
<ul class="device-list">
{#each view.devices as device (device.id)}
<li class="device-card">
<div class="device-heading">
<div class="device-identity">
{#if device.self}
<span class="device-badge">{translate("This device")}</span>
{/if}
<code class="device-id">{device.id}</code>
</div>
<div class="device-badges">
<span class="device-badge">
{device.accepted ? translate("Accepted") : translate("Not accepted")}
</span>
{#if device.lockedOut}
<span class="device-badge device-badge-warning">{translate("Locked out")}</span>
{/if}
</div>
</div>
{#if device.self && device.lockedOut}
<p class="state-message">
{translate(
"This device has been locked out. Use the existing recovery process to join again."
)}
</p>
{/if}
<dl class="device-details">
<div>
<dt>{translate("Platform")}</dt>
<dd>{device.platform || translate("Unknown platform")}</dd>
</div>
<div>
<dt>{translate("Plug-in version")}</dt>
<dd>{device.pluginVersion || translate("Not recorded")}</dd>
</div>
<div>
<dt>{translate("App version")}</dt>
<dd>{device.appVersion || translate("Not recorded")}</dd>
</div>
<div>
<dt>{translate("Last recorded")}</dt>
<dd>{formatLastConnected(device.lastConnected)}</dd>
</div>
</dl>
{#if !device.self}
{#if confirmingId === device.id}
<div class="forget-confirmation">
<p><strong>{translate("Forget this device record?")}</strong></p>
<p>
{translate("This removes only this device's record from the remote.")}
</p>
<p>
{translate(
"Forgetting the record does not delete Vault files or revoke credentials."
)}
</p>
<p>
{view.locked
? translate(
"This remote is locked. If the device returns, use the existing recovery process to join it again."
)
: translate("This remote is unlocked. A device that returns can register again.")}
</p>
<div class="device-actions">
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
{translate("Cancel")}
</button>
<button
class="mod-warning"
disabled={busy}
onclick={() => void forgetDevice(device)}
>
{translate("Forget device")}
</button>
</div>
</div>
{:else}
<div class="device-actions">
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
{forgettingId === device.id
? translate("Forgetting…")
: translate("Forget device")}
</button>
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{:else if !isRefreshing}
<p>{translate("No joined devices are recorded on this remote.")}</p>
{/if}
{/if}
<div class="dialog-controls">
<button disabled={busy} onclick={() => void refreshView()}>{translate("Refresh")}</button>
<button onclick={() => setResult("close")}>{translate("Close")}</button>
</div>
</div>
<style>
.joined-devices-dialog {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
min-width: 0;
max-width: 100%;
}
p {
margin: 0;
overflow-wrap: anywhere;
}
.state-message,
.forget-confirmation {
border-left: 3px solid var(--interactive-accent);
padding: var(--size-4-2);
background: var(--background-secondary);
}
.error-message {
color: var(--text-error);
}
.device-list {
display: grid;
gap: var(--size-4-2);
list-style: none;
margin: 0;
min-width: 0;
padding: 0;
}
.device-card {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
min-width: 0;
overflow-wrap: anywhere;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-3);
}
.device-heading {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--size-4-2);
min-width: 0;
}
.device-identity,
.device-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--size-4-2);
min-width: 0;
}
.device-id {
display: block;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
white-space: normal;
}
.device-badge {
border-radius: var(--radius-s);
background: var(--background-secondary);
padding: 0 var(--size-4-2);
font-size: var(--font-ui-smaller);
}
.device-badge-warning {
color: var(--text-warning);
}
.device-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: var(--size-4-2) var(--size-4-4);
margin: 0;
min-width: 0;
}
.device-details > div {
min-width: 0;
}
dt {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.forget-confirmation {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
min-width: 0;
}
.device-actions,
.dialog-controls {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--size-4-2);
}
button {
min-height: 44px;
}
@media (max-width: 480px) {
.device-card {
padding: var(--size-4-2);
}
.device-details {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+4 -5
View File
@@ -1,4 +1,6 @@
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub"; import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub";
import type ObsidianLiveSyncPlugin from "@/main"; import type ObsidianLiveSyncPlugin from "@/main";
@@ -11,7 +13,6 @@ import {
ObsidianTweakValueService, ObsidianTweakValueService,
ObsidianTestService, ObsidianTestService,
ObsidianDatabaseEventService, ObsidianDatabaseEventService,
ObsidianConfigService,
ObsidianKeyValueDBService, ObsidianKeyValueDBService,
ObsidianControlService, ObsidianControlService,
} from "./ObsidianServices"; } from "./ObsidianServices";
@@ -46,6 +47,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
const setting = new ObsidianSettingService(context, { const setting = new ObsidianSettingService(context, {
APIService: API, APIService: API,
onDisplayLanguageChanged: setLang, onDisplayLanguageChanged: setLang,
deviceValues: createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
}); });
const appLifecycle = new ObsidianAppLifecycleService(context, { const appLifecycle = new ObsidianAppLifecycleService(context, {
settingService: setting, settingService: setting,
@@ -85,10 +87,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
databaseEvents: databaseEvents, databaseEvents: databaseEvents,
vault: vault, vault: vault,
}); });
const config = new ObsidianConfigService(context, { const config = setting.config;
settingService: setting,
APIService: API,
});
const replicator = new ObsidianReplicatorService(context, { const replicator = new ObsidianReplicatorService(context, {
settingService: setting, settingService: setting,
appLifecycleService: appLifecycle, appLifecycleService: appLifecycle,
+1 -6
View File
@@ -6,7 +6,6 @@ import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService"; import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService"; import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService"; import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService"; import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
@@ -58,10 +57,7 @@ export class ObsidianReplicatorService extends InjectableReplicatorService<Obsid
super(context, withSleepPreference(dependencies)); super(context, withSleepPreference(dependencies));
} }
async runBoundedLocalApplicationActivity<T>( async runBoundedLocalApplicationActivity<T>(task: () => T | PromiseLike<T>, options?: ActivityOptions): Promise<T> {
task: () => T | PromiseLike<T>,
options?: ActivityOptions
): Promise<T> {
this.boundedLocalApplicationActivityCount.value++; this.boundedLocalApplicationActivityCount.value++;
try { try {
return this.dependencies.activityRunner return this.dependencies.activityRunner
@@ -84,7 +80,6 @@ export class ObsidianConflictService extends InjectableConflictService<ObsidianS
export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {} export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {}
// InjectableTestService // InjectableTestService
export class ObsidianTestService extends InjectableTestService<ObsidianServiceContext> {} export class ObsidianTestService extends InjectableTestService<ObsidianServiceContext> {}
export class ObsidianConfigService extends ConfigServiceBrowserCompat<ObsidianServiceContext> {}
export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {} export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {}
@@ -1,4 +1,3 @@
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { import {
EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_REQUEST_RELOAD_SETTING_TAB,
@@ -27,19 +26,6 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
return Promise.resolve(true); return Promise.resolve(true);
}); });
} }
protected setItem(key: string, value: string) {
// TODO: Implement nativeLocalStorage.
return compatGlobal.localStorage.setItem(key, value);
}
protected getItem(key: string): string {
// TODO: Implement nativeLocalStorage.
return compatGlobal.localStorage.getItem(key) ?? "";
}
protected deleteItem(key: string): void {
// TODO: Implement nativeLocalStorage.
compatGlobal.localStorage.removeItem(key);
}
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> { protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
return await this.context.liveSyncPlugin.saveData(data); return await this.context.liveSyncPlugin.saveData(data);
} }
@@ -1,6 +1,23 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { MemoryValueStore } from "@vrtmrz/value-store";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts"; import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
import { ObsidianSettingService } from "./ObsidianSettingService.ts";
function createSettingService(
vaultName: string,
deviceValues: MemoryValueStore
): ObsidianSettingService<ObsidianServiceContext> {
const context = createServiceContext() as unknown as ObsidianServiceContext;
const APIService = {
addLog: () => undefined,
getSystemVaultName: () => vaultName,
} as unknown as IAPIService;
return new ObsidianSettingService(context, { APIService, deviceValues });
}
describe("normaliseObsidianSettingsData", () => { describe("normaliseObsidianSettingsData", () => {
it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => { it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => {
@@ -12,4 +29,26 @@ describe("normaliseObsidianSettingsData", () => {
expect(normaliseObsidianSettingsData(settings)).toBe(settings); expect(normaliseObsidianSettingsData(settings)).toBe(settings);
}); });
it("keeps injected device values independent from each Vault-scoped configuration store", async () => {
const firstValues = new MemoryValueStore();
const secondValues = new MemoryValueStore();
const firstSetting = createSettingService("first-vault", firstValues);
const secondSetting = createSettingService("second-vault", secondValues);
await firstSetting.deviceValues.setValue("shared-key", "device value");
await firstSetting.config.setValue("shared-key", "first config");
await secondSetting.config.setValue("shared-key", "second config");
expect(firstSetting.deviceValues).toBe(firstValues);
expect(secondSetting.deviceValues).toBe(secondValues);
expect(firstSetting.deviceValues).not.toBe(secondSetting.deviceValues);
expect(firstSetting.config).not.toBe(firstValues);
expect(firstSetting.config).not.toBe(secondSetting.config);
await expect(firstSetting.deviceValues.getValue("shared-key")).resolves.toBe("device value");
await expect(firstSetting.config.getValue("shared-key")).resolves.toBe("first config");
await expect(secondSetting.config.getValue("shared-key")).resolves.toBe("second config");
await expect(firstValues.getValue("first-vault-shared-key")).resolves.toBe("first config");
await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config");
});
}); });
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService"; import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService"; import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService"; import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService"; import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
@@ -8,7 +8,7 @@ import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy
import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = { export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = {
appLifecycle: AppLifecycleService<T>; appLifecycle: AppLifecycleService<T>;
config: ConfigService<T>; config: IConfigService;
replicator: ReplicatorService<T>; replicator: ReplicatorService<T>;
APIService: IAPIService; APIService: IAPIService;
control: IControlService; control: IControlService;
+9 -9
View File
@@ -44,17 +44,17 @@ export class CompatibilityReviewController {
return this._initialised; return this._initialised;
} }
private readAcknowledgedVersion(): string | null { private async readAcknowledgedVersion(): Promise<string | null> {
const setting = this.core.services.setting; const setting = this.core.services.setting;
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY); const currentMarker = await this.core.services.config.getValue(DATABASE_COMPATIBILITY_VERSION_KEY, "");
if (currentMarker) return currentMarker; if (currentMarker) return currentMarker;
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
const legacyMarker = setting.getDeviceLocalConfig(legacyKey); const legacyMarker = await setting.deviceValues.getValue(legacyKey, "");
if (!legacyMarker) return null; if (!legacyMarker) return null;
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker); await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
setting.deleteDeviceLocalConfig(legacyKey); await setting.deviceValues.delete(legacyKey);
return legacyMarker; return legacyMarker;
} }
@@ -75,7 +75,7 @@ export class CompatibilityReviewController {
return true; return true;
} }
const acknowledgedVersion = this.readAcknowledgedVersion(); const acknowledgedVersion = await this.readAcknowledgedVersion();
const evaluation = evaluateCompatibilityPause({ const evaluation = evaluateCompatibilityPause({
acknowledgedVersion, acknowledgedVersion,
currentVersion: this.currentVersion, currentVersion: this.currentVersion,
@@ -85,7 +85,7 @@ export class CompatibilityReviewController {
this.pause = evaluation.pause; this.pause = evaluation.pause;
if (evaluation.initialiseAcknowledgedVersion) { if (evaluation.initialiseAcknowledgedVersion) {
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
this._initialised = true; this._initialised = true;
return true; return true;
} }
@@ -115,9 +115,9 @@ export class CompatibilityReviewController {
throw error; throw error;
} }
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
setting.deleteDeviceLocalConfig(legacyKey); await setting.deviceValues.delete(legacyKey);
this.pause = undefined; this.pause = undefined;
this.ui.clearReminder(); this.ui.clearReminder();
await this.core.services.control.applySettings(); await this.core.services.control.applySettings();
@@ -33,13 +33,32 @@ function createFixture(
} = {} } = {}
) { ) {
const local = new Map<string, string>(); const local = new Map<string, string>();
const deviceValues = new Map<string, string>();
if (options.marker !== undefined && options.marker !== null) { if (options.marker !== undefined && options.marker !== null) {
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker); local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
} }
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault"); const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
if (options.legacyMarker !== undefined && options.legacyMarker !== null) { if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
local.set(legacyKey, options.legacyMarker); deviceValues.set(legacyKey, options.legacyMarker);
} }
const config = {
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
setValue: vi.fn(async (key: string, value: string) => {
local.set(key, value);
}),
delete: vi.fn(async (key: string) => {
local.delete(key);
}),
};
const deviceValueStore = {
getValue: vi.fn(async (key: string, defaultValue = "") => deviceValues.get(key) ?? defaultValue),
setValue: vi.fn(async (key: string, value: string) => {
deviceValues.set(key, value);
}),
delete: vi.fn(async (key: string) => {
deviceValues.delete(key);
}),
};
const settings = { const settings = {
versionUpFlash: options.versionUpFlash ?? "", versionUpFlash: options.versionUpFlash ?? "",
isConfigured: options.isConfigured ?? true, isConfigured: options.isConfigured ?? true,
@@ -49,15 +68,13 @@ function createFixture(
const setting = { const setting = {
currentSettings: () => settings, currentSettings: () => settings,
getSettingsMigrationState: () => migrationState(options.migration), getSettingsMigrationState: () => migrationState(options.migration),
getSmallConfig: (key: string) => local.get(key) ?? "", deviceValues: deviceValueStore,
setSmallConfig: (key: string, value: string) => local.set(key, value),
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
deleteDeviceLocalConfig: (key: string) => local.delete(key),
saveSettingData, saveSettingData,
}; };
const core = { const core = {
services: { services: {
setting, setting,
config,
vault: { getVaultName: () => "Test Vault" }, vault: { getVaultName: () => "Test Vault" },
control: { applySettings }, control: { applySettings },
}, },
@@ -69,7 +86,18 @@ function createFixture(
clearReminder: vi.fn(), clearReminder: vi.fn(),
}; };
const controller = new CompatibilityReviewController(core, ui, 12); const controller = new CompatibilityReviewController(core, ui, 12);
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings }; return {
controller,
ui,
local,
deviceValues,
config,
deviceValueStore,
legacyKey,
settings,
saveSettingData,
applySettings,
};
} }
describe("compatibility review controller", () => { describe("compatibility review controller", () => {
@@ -196,10 +224,96 @@ describe("compatibility review controller", () => {
await fixture.controller.initialise(); await fixture.controller.initialise();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.local.has(fixture.legacyKey)).toBe(false); expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
expect(fixture.controller.pendingPause).toBeDefined(); expect(fixture.controller.pendingPause).toBeDefined();
}); });
it("saves the scoped marker before deleting the legacy device value", async () => {
const fixture = createFixture({ legacyMarker: "11" });
let finishSave!: () => void;
const saving = new Promise<void>((resolve) => {
finishSave = resolve;
});
fixture.config.setValue.mockImplementation(async (key, value) => {
await saving;
fixture.local.set(key, value);
});
const initialising = fixture.controller.initialise();
await Promise.resolve();
await Promise.resolve();
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
expect(fixture.deviceValueStore.delete).not.toHaveBeenCalled();
expect(fixture.controller.initialised).toBe(false);
finishSave();
await initialising;
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
expect(fixture.controller.initialised).toBe(true);
});
it("does not treat a failed marker read as an absent value", async () => {
const fixture = createFixture({ legacyMarker: "11" });
fixture.config.getValue.mockRejectedValueOnce(new Error("marker read failed"));
await expect(fixture.controller.initialise()).rejects.toThrow("marker read failed");
expect(fixture.deviceValueStore.getValue).not.toHaveBeenCalled();
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
expect(fixture.controller.initialised).toBe(false);
});
it("keeps the compatibility pause blocked until its async marker write succeeds", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
let finishSave!: () => void;
const saving = new Promise<void>((resolve) => {
finishSave = resolve;
});
fixture.config.setValue.mockImplementation(async (key, value) => {
await saving;
fixture.local.set(key, value);
});
const reviewing = fixture.controller.openReview();
await Promise.resolve();
await Promise.resolve();
expect(fixture.controller.pendingPause).toBeDefined();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.applySettings).not.toHaveBeenCalled();
finishSave();
await reviewing;
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.applySettings).toHaveBeenCalledOnce();
});
it("keeps a storage-failed acknowledgement blocked and retryable", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
fixture.config.setValue.mockRejectedValueOnce(new Error("marker write failed"));
await expect(fixture.controller.openReview()).rejects.toThrow("marker write failed");
expect(fixture.controller.pendingPause).toBeDefined();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.applySettings).not.toHaveBeenCalled();
await fixture.controller.openReview();
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.applySettings).toHaveBeenCalledOnce();
});
it("restores the runtime gate if persisting an acknowledgement fails", async () => { it("restores the runtime gate if persisting an acknowledgement fails", async () => {
const fixture = createFixture({ marker: "11" }); const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume"); vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
+64
View File
@@ -0,0 +1,64 @@
import type { JoinedDevice, JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
import type { JoinedDevicesView } from "./joinedDevicesDialogTypes";
/** Compare recognised SemVer values; unknown development labels have no ordering. */
export function comparePluginVersions(left: string, right: string): number | undefined {
const parse = (value: string) => {
const match =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
value
);
if (!match) return undefined;
const prerelease = match[4]?.split(".");
if (prerelease?.some((part) => /^0\d+$/.test(part))) return undefined;
return { core: match.slice(1, 4).map(BigInt), prerelease };
};
const a = parse(left);
const b = parse(right);
if (!a || !b) return undefined;
for (let i = 0; i < 3; i++) {
if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i] ? 1 : -1;
}
if (!a.prerelease || !b.prerelease) return a.prerelease ? -1 : b.prerelease ? 1 : 0;
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
const x = a.prerelease[i];
const y = b.prerelease[i];
if (x === undefined) return -1;
if (y === undefined) return 1;
if (x === y) continue;
const xn = /^\d+$/.test(x);
const yn = /^\d+$/.test(y);
if (xn && yn) return BigInt(x) > BigInt(y) ? 1 : -1;
if (xn !== yn) return xn ? -1 : 1;
return x > y ? 1 : -1;
}
return 0;
}
export function joinedDevicesView(
snapshot: JoinedDevicesSnapshot,
self: Omit<JoinedDevice, "accepted">
): JoinedDevicesView {
const ownRecord = snapshot.devices.find((device) => device.id === self.id);
const ownDevice: JoinedDevice = { ...ownRecord, ...self, accepted: ownRecord?.accepted ?? false };
return {
...snapshot,
devices: [ownDevice, ...snapshot.devices.filter((device) => device.id !== self.id)].map((device) => ({
...device,
self: device.id === self.id,
lockedOut: snapshot.locked && !device.accepted,
})),
};
}
export function differentDeviceVersions(snapshot: JoinedDevicesSnapshot, ownId: string, ownVersion: string) {
const newer = new Set<string>();
const older = new Set<string>();
for (const device of snapshot.devices) {
if (device.id === ownId || !device.pluginVersion) continue;
const comparison = comparePluginVersions(device.pluginVersion, ownVersion);
if (comparison !== undefined && comparison > 0) newer.add(device.pluginVersion);
if (comparison !== undefined && comparison < 0) older.add(device.pluginVersion);
}
return { newer: [...newer].sort(), older: [...older].sort() };
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { comparePluginVersions, differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
const empty: JoinedDevicesSnapshot = { milestonePresent: false, locked: false, cleaned: false, devices: [] };
describe("joined device presentation", () => {
it("always shows this device first, including before its remote record exists", () => {
const view = joinedDevicesView(
{ ...empty, devices: [{ id: "another", accepted: true }] },
{ id: "self", platform: "ios", pluginVersion: "1.0.39" }
);
expect(view.devices.map((device) => device.id)).toEqual(["self", "another"]);
expect(view.devices[0]).toMatchObject({ self: true, accepted: false, lockedOut: false, platform: "ios" });
});
it("keeps the remote last-recorded date and acceptance while showing current local versions", () => {
const view = joinedDevicesView(
{
...empty,
milestonePresent: true,
locked: true,
devices: [
{ id: "other", accepted: true },
{ id: "self", accepted: false, lastConnected: 123, pluginVersion: "1.0.0" },
],
},
{ id: "self", pluginVersion: "1.1.0" }
);
expect(view.devices[0]).toMatchObject({
self: true,
lockedOut: true,
lastConnected: 123,
pluginVersion: "1.1.0",
});
expect(view.devices[1].lockedOut).toBe(false);
});
});
describe("version advisories", () => {
it.each([
["1.10.0", "1.9.9", 1],
["1.0.0", "1.0.0-rc.1", 1],
["1.0.0-beta.2", "1.0.0-beta.10", -1],
["1.0.0-1", "1.0.0-alpha", -1],
["1.0.0-alpha", "1.0.0-alpha.1", -1],
["1.0.0+first", "1.0.0+second", 0],
["99999999999999999.0.0", "99999999999999998.0.0", 1],
])("compares %s with %s", (a, b, result) => expect(comparePluginVersions(a, b)).toBe(result));
it.each(["", "development", "1.0", "01.0.0", "1.0.0-01", "v1.0.0"])(
"does not order unknown version %s",
(version) => {
expect(comparePluginVersions(version, "1.0.0")).toBeUndefined();
expect(comparePluginVersions("1.0.0", version)).toBeUndefined();
}
);
it("excludes this device and unknown versions, and deduplicates versions", () => {
const snapshot = {
...empty,
devices: [
{ id: "self", accepted: false, pluginVersion: "9.0.0" },
{ id: "a", accepted: true, pluginVersion: "2.0.0" },
{ id: "b", accepted: false, pluginVersion: "2.0.0" },
{ id: "c", accepted: false, pluginVersion: "0.9.0" },
{ id: "d", accepted: true, pluginVersion: "custom" },
],
};
expect(differentDeviceVersions(snapshot, "self", "1.0.0")).toEqual({ newer: ["2.0.0"], older: ["0.9.0"] });
});
});
@@ -0,0 +1,5 @@
import { declareUICommand } from "@vrtmrz/livesync-commonlib/ui-commands";
export const OPEN_JOINED_DEVICES = declareUICommand<void, "closed" | "already-open" | "unavailable">(
"livesync:joined-devices"
);
@@ -0,0 +1,19 @@
import type { ForgetJoinedDeviceResult, JoinedDevice } from "@vrtmrz/livesync-commonlib/replication";
export interface JoinedDeviceRow extends JoinedDevice {
readonly self: boolean;
readonly lockedOut: boolean;
}
export interface JoinedDevicesView {
readonly milestonePresent: boolean;
readonly locked: boolean;
readonly cleaned: boolean;
readonly devices: readonly JoinedDeviceRow[];
}
export interface JoinedDevicesDialogOptions {
refresh(): Promise<JoinedDevicesView>;
forget(id: string): Promise<ForgetJoinedDeviceResult>;
cancel(): void;
}
+18 -19
View File
@@ -63,28 +63,27 @@ function buildSimpleFetchResult(stage1: string, stage2?: string) {
return undefined; return undefined;
} }
function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) { async function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
host.services.setting.setSmallConfig(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 })); await host.services.setting.config.setValue(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
} }
function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) { async function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
const saved = host.services.setting.getSmallConfig(SIMPLE_FETCH_MODE_KEY); const saved = await host.services.setting.config.getValue(SIMPLE_FETCH_MODE_KEY, "");
if (!saved) return undefined; if (!saved) return undefined;
let remembered: ReturnType<typeof buildSimpleFetchResult> = undefined;
try { try {
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string }; const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
if (stage1) { if (stage1) remembered = buildSimpleFetchResult(stage1, stage2);
const remembered = buildSimpleFetchResult(stage1, stage2);
if (remembered) return remembered;
}
} catch { } catch {
// Clear below; the saved choice is optional and can be rebuilt by asking again. // Clear below; the saved choice is optional and can be rebuilt by asking again.
} }
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY); if (remembered) return remembered;
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
return undefined; return undefined;
} }
function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) { async function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY); await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
} }
async function confirmTranslatedChoice( async function confirmTranslatedChoice(
@@ -104,7 +103,7 @@ async function confirmTranslatedChoice(
export async function askSimpleFetchMode( export async function askSimpleFetchMode(
host: NecessaryServices<"UI" | "setting", never> host: NecessaryServices<"UI" | "setting", never>
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> { ): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
const remembered = getRememberedSimpleFetchMode(host); const remembered = await getRememberedSimpleFetchMode(host);
if (remembered) return remembered; if (remembered) return remembered;
const msg = $msg( const msg = $msg(
@@ -169,7 +168,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
if (stage2 === STAGE2_ABORT) { if (stage2 === STAGE2_ABORT) {
return "aborted"; return "aborted";
} }
rememberSimpleFetchMode(host, stage1, stage2); await rememberSimpleFetchMode(host, stage1, stage2);
return buildSimpleFetchResult(stage1, stage2)!; return buildSimpleFetchResult(stage1, stage2)!;
} }
@@ -198,7 +197,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
if (stage2 === STAGE2_ABORT) { if (stage2 === STAGE2_ABORT) {
return "aborted"; return "aborted";
} }
rememberSimpleFetchMode(host, stage1, stage2); await rememberSimpleFetchMode(host, stage1, stage2);
return buildSimpleFetchResult(stage1, stage2)!; return buildSimpleFetchResult(stage1, stage2)!;
} }
@@ -234,18 +233,18 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.", "Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
LOG_LEVEL_NOTICE LOG_LEVEL_NOTICE
); );
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
return undefined; return undefined;
} }
const result = await askSimpleFetchMode(host); const result = await askSimpleFetchMode(host);
if (result === "cancelled") { if (result === "cancelled") {
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE); log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
return await cancelScheduledInitialisation(host, cleanupFlag); return await cancelScheduledInitialisation(host, cleanupFlag);
} }
if (result === "aborted") { if (result === "aborted") {
log("Fetch exited by user.", LOG_LEVEL_NOTICE); log("Fetch exited by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
host.services.appLifecycle.performRestart(); host.services.appLifecycle.performRestart();
return false; return false;
} }
@@ -256,7 +255,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
const settings = host.services.setting.currentSettings(); const settings = host.services.setting.currentSettings();
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) { if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE); log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
return await cancelScheduledInitialisation(host, cleanupFlag); return await cancelScheduledInitialisation(host, cleanupFlag);
} }
@@ -295,7 +294,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
} }
await host.serviceModules.rebuilder.finishRebuild(); await host.serviceModules.rebuilder.finishRebuild();
await cleanupFlag(); await cleanupFlag();
clearRememberedSimpleFetchMode(host); await clearRememberedSimpleFetchMode(host);
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE); log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
return true; return true;
}; };
@@ -10,19 +10,26 @@ const KEEP_LOCAL = "Keep local files even if not on remote";
function createHost(savedChoice?: string) { function createHost(savedChoice?: string) {
const smallConfig = new Map<string, string>(); const smallConfig = new Map<string, string>();
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice); if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
const config = {
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
setValue: vi.fn(async (key: string, value: string) => {
smallConfig.set(key, value);
}),
delete: vi.fn(async (key: string) => {
smallConfig.delete(key);
}),
};
const confirmWithMessage = const confirmWithMessage =
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>(); vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
const host = { const host = {
services: { services: {
UI: { confirm: { confirmWithMessage } }, UI: { confirm: { confirmWithMessage } },
setting: { setting: {
getSmallConfig: (key: string) => smallConfig.get(key) ?? "", config,
setSmallConfig: (key: string, value: string) => smallConfig.set(key, value),
deleteSmallConfig: (key: string) => smallConfig.delete(key),
}, },
}, },
} as unknown as NecessaryServices<"UI" | "setting", never>; } as unknown as NecessaryServices<"UI" | "setting", never>;
return { host, confirmWithMessage, smallConfig }; return { host, confirmWithMessage, smallConfig, config };
} }
afterEach(() => setLang("def")); afterEach(() => setLang("def"));
@@ -50,6 +57,75 @@ describe("Fast Setup translated choices", () => {
}); });
}); });
it("waits for the saved choice before returning it to the fetch workflow", async () => {
const { host, confirmWithMessage, smallConfig, config } = createHost();
let markSaveStarted!: () => void;
const saveStarted = new Promise<void>((resolve) => {
markSaveStarted = resolve;
});
let finishSave!: () => void;
const save = new Promise<void>((resolve) => {
finishSave = resolve;
});
config.setValue.mockImplementation(async (key, value) => {
markSaveStarted();
await save;
smallConfig.set(key, value);
});
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
const asking = askSimpleFetchMode(host);
await saveStarted;
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
finishSave();
await expect(asking).resolves.toMatchObject({ mode: "remote-only" });
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
});
it("propagates a stored-choice read failure instead of treating it as missing", async () => {
const { host, confirmWithMessage, config } = createHost();
config.getValue.mockRejectedValueOnce(new Error("choice read failed"));
await expect(askSimpleFetchMode(host)).rejects.toThrow("choice read failed");
expect(confirmWithMessage).not.toHaveBeenCalled();
});
it("waits for an invalid optional choice to be deleted before asking again", async () => {
const { host, confirmWithMessage, smallConfig, config } = createHost("not-json");
let markDeleteStarted!: () => void;
const deleteStarted = new Promise<void>((resolve) => {
markDeleteStarted = resolve;
});
let finishDelete!: () => void;
const deletion = new Promise<void>((resolve) => {
finishDelete = resolve;
});
config.delete.mockImplementation(async (key) => {
markDeleteStarted();
await deletion;
smallConfig.delete(key);
});
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
const asking = askSimpleFetchMode(host);
await deleteStarted;
expect(confirmWithMessage).not.toHaveBeenCalled();
expect(smallConfig.has("simple-fetch-mode")).toBe(true);
finishDelete();
await expect(asking).resolves.toBe("cancelled");
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
expect(confirmWithMessage).toHaveBeenCalledOnce();
});
it("restores choices saved by the previous English implementation while displaying Spanish", async () => { it("restores choices saved by the previous English implementation while displaying Spanish", async () => {
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL }); const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
const { host, confirmWithMessage, smallConfig } = createHost(savedChoice); const { host, confirmWithMessage, smallConfig } = createHost(savedChoice);
+8 -6
View File
@@ -107,13 +107,15 @@ const createSettingServiceMock = () => {
}), }),
suspendAllSync: vi.fn(() => Promise.resolve()), suspendAllSync: vi.fn(() => Promise.resolve()),
suspendExtraSync: vi.fn(() => Promise.resolve()), suspendExtraSync: vi.fn(() => Promise.resolve()),
getSmallConfig: vi.fn((key: string) => smallConfig.get(key) ?? ""), config: {
setSmallConfig: vi.fn((key: string, value: string) => { getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
setValue: vi.fn(async (key: string, value: string) => {
smallConfig.set(key, value); smallConfig.set(key, value);
}), }),
deleteSmallConfig: vi.fn((key: string) => { delete: vi.fn(async (key: string) => {
smallConfig.delete(key); smallConfig.delete(key);
}), }),
},
}; };
}; };
@@ -785,7 +787,7 @@ describe("Red Flag Feature", () => {
); );
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled(); expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode"); expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
expect(cleanupFlag).not.toHaveBeenCalled(); expect(cleanupFlag).not.toHaveBeenCalled();
}); });
@@ -830,7 +832,7 @@ describe("Red Flag Feature", () => {
}); });
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled(); expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
expect(cleanupFlag).not.toHaveBeenCalled(); expect(cleanupFlag).not.toHaveBeenCalled();
expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode"); expect(host.mocks.setting.config.delete).not.toHaveBeenCalledWith("simple-fetch-mode");
}); });
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => { it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
@@ -892,7 +894,7 @@ describe("Red Flag Feature", () => {
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag); await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode"); expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
}); });
it("should return false and cleanup when quick flow is cancelled", async () => { it("should return false and cleanup when quick flow is cancelled", async () => {
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more