Prepare the 1.0 compatibility review flow

This commit is contained in:
vorotamoroz
2026-07-19 04:34:08 +00:00
parent 52138bf7a5
commit ed3f81e9f9
42 changed files with 5524 additions and 4119 deletions
+9 -3
View File
@@ -2,7 +2,8 @@
# Image tag format: <manifest-version>-<unix-epoch>-cli
# Example: 0.25.56-1743500000-cli
#
# The image is also tagged 'latest' for convenience.
# Stable releases are also tagged with their major-minor version and 'latest'.
# Pre-releases receive immutable version and SHA-qualified tags only.
# Image name: ghcr.io/<owner>/livesync-cli
name: Build and Push CLI Docker Image
@@ -59,8 +60,13 @@ jobs:
# Build tag list based on the event and git ref
TAGS=""
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
# Stable release builds
TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${MAJOR_MINOR}-cli,${IMAGE}:latest,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli"
if [[ "${VERSION}" == *-* ]]; then
# Pre-release builds must not advance stable moving tags.
TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli"
else
# Stable release builds
TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${MAJOR_MINOR}-cli,${IMAGE}:latest,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli"
fi
elif [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Bleeding-edge / nightly builds
TAGS="${IMAGE}:edge"
+38 -10
View File
@@ -15,6 +15,16 @@ on:
description: Full head commit SHA reviewed in the release PR
required: true
type: string
prerelease:
description: Mark the GitHub Release as a pre-release
required: false
type: boolean
default: false
publish_cli:
description: Create the CLI tag and publish its container image
required: false
type: boolean
default: true
jobs:
finalise:
@@ -51,6 +61,7 @@ jobs:
env:
VERSION: ${{ inputs.version }}
EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
set -euo pipefail
ACTUAL_HEAD_SHA="$(git rev-parse HEAD)"
@@ -58,43 +69,60 @@ jobs:
echo "Release branch head is ${ACTUAL_HEAD_SHA}, expected ${EXPECTED_HEAD_SHA}." >&2
exit 1
fi
if [[ "${VERSION}" == *-* && "${PRERELEASE}" != "true" ]]; then
echo "Version ${VERSION} is a pre-release version, but prerelease was not enabled." >&2
exit 1
fi
node utils/release-notes.mjs validate "${VERSION}"
- name: Ensure and push release tags
env:
VERSION: ${{ inputs.version }}
EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }}
PUBLISH_CLI: ${{ inputs.publish_cli }}
run: |
set -euo pipefail
git fetch --tags --force
node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"
git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"
if [[ "${PUBLISH_CLI}" == "true" ]]; then
node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"
git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"
else
node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}" --plugin-only
git push origin "refs/tags/${VERSION}"
fi
- name: Dispatch release workflows
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
set -euo pipefail
gh workflow run release.yml \
--ref "${VERSION}" \
--field tag="${VERSION}" \
--field draft=true \
--field prerelease=false
gh workflow run cli-docker.yml \
--ref "${VERSION}-cli" \
--field dry_run=false \
--field force=false
--field prerelease="${PRERELEASE}"
- name: Summarise next steps
env:
VERSION: ${{ inputs.version }}
PRERELEASE: ${{ inputs.prerelease }}
PUBLISH_CLI: ${{ inputs.publish_cli }}
run: |
{
echo "Ensured tags \`${VERSION}\` and \`${VERSION}-cli\` point to the reviewed release commit."
echo "Ensured the plug-in tag \`${VERSION}\` points to the reviewed release commit."
if [[ "${PUBLISH_CLI}" == "true" ]]; then
echo "The CLI tag \`${VERSION}-cli\` was also created; its tag event starts the container workflow."
else
echo "CLI publication was omitted."
fi
echo ""
echo "Dispatched the plug-in release workflow for \`${VERSION}\`. After approval for the release environment, it creates a draft GitHub Release."
echo "Dispatched the CLI Docker workflow for \`${VERSION}-cli\`. It publishes the version, major-minor, latest, and SHA-qualified image tags."
echo ""
echo "Keep the release pull request in draft after publishing the GitHub Release as the latest stable release. Mark it ready and merge it only after BRAT validation succeeds."
if [[ "${PRERELEASE}" == "true" ]]; then
echo "Publish the draft as a pre-release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
else
echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
fi
} >> "$GITHUB_STEP_SUMMARY"
-7
View File
@@ -60,17 +60,10 @@ jobs:
main.js
manifest.json
styles.css
# Package the required files into a zip
- name: Package
run: |
mkdir ${{ github.event.repository.name }}
cp main.js manifest.json styles.css README.md ${{ github.event.repository.name }}
zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }}
- name: Create Release and Upload Assets
uses: softprops/action-gh-release@v2
with:
files: |
${{ github.event.repository.name }}.zip
main.js
manifest.json
styles.css
+14 -25
View File
@@ -228,24 +228,12 @@ export class ModuleExample extends AbstractObsidianModule {
- [esbuild.config.mjs](esbuild.config.mjs) - Build configuration with platform/dev file replacement
- [package.json](package.json) - Scripts reference and dependencies
## Beta Policy
## Pre-release Policy
- Beta versions are denoted by appending `-patchedN` to the base version number.
- `The base version` mostly corresponds to the stable release version.
- e.g., v0.25.41-patched1 is equivalent to v0.25.42-beta1.
- This notation is due to SemVer incompatibility of Obsidian's plugin system.
- Hence, this release is `0.25.41-patched1`.
- Each beta version may include larger changes, but bug fixes will often not be included.
- I think that in most cases, bug fixes will cause the stable releases.
- They will not be released per branch or backported; they will simply be released.
- Bug fixes for previous versions will be applied to the latest beta version.
This means, if xx.yy.02-patched1 exists and there is a defect in xx.yy.01, a fix is applied to xx.yy.02-patched1 and yields xx.yy.02-patched2.
If the fix is required immediately, it is released as xx.yy.02 (with xx.yy.01-patched1).
- This procedure remains unchanged from the current one.
- At the very least, I am using the latest beta.
- However, I will not be using a beta continuously for a week after it has been released. It is probably closer to an RC in nature.
In short, the situation remains unchanged for me, but it means you all become a little safer. Thank you for your understanding!
- New pre-releases use SemVer identifiers such as `1.0.0-rc.0`. Historical `-patchedN` releases remain unchanged in the release history.
- Publish a pre-release from an immutable reviewed tag, mark its GitHub Release as a pre-release, and do not replace the latest stable release.
- A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag.
- Keep the release pull request in draft until the exact published plug-in has passed BRAT validation. If validation fails, prepare the next pre-release version rather than moving the existing tag.
## Release Notes
@@ -263,14 +251,14 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
- Run the `Prepare Release PR` workflow with the target version. It creates the release branch, updates versions, confirms that Commonlib is locked to an immutable package version, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR.
- Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`.
- Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) point to that commit, and explicitly dispatches the plug-in and CLI publishing workflows. The workflow can be retried when existing tags already point to the reviewed commit, but stops if either tag points elsewhere.
- Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that the plug-in tag points to that commit, optionally creates the corresponding CLI tag, and dispatches the plug-in release workflow. A CLI tag starts its own container workflow. The finalisation workflow can be retried when existing tags already point to the reviewed commit, but stops if a selected tag points elsewhere.
- The plug-in publishing workflow is intentionally dispatch-only. Pushing a plug-in tag directly does not publish a GitHub Release; use `Finalise Release Tags`, or dispatch `Release Obsidian Plugin` explicitly for recovery or a pre-release. The CLI Docker workflow retains its documented branch, tag, and manual triggers.
- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. Confirm that the CLI workflow has published the fixed version tag, the major-minor moving tag (for example, `0.25-cli`), `latest`, and the SHA-qualified tag.
- Publish the draft GitHub Release as the latest stable release while keeping the release PR in draft and leaving `main` unchanged. Record the state in the PR with: 'Release `<version>` has been published as the latest stable release. This pull request intentionally remains in draft, and `main` has not yet been updated. Merge is on hold until BRAT validation is complete.'
- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. For a selected CLI publication, confirm the image tags appropriate to a stable or pre-release version.
- Publish a stable draft as the latest release, or publish a pre-release draft without replacing the latest stable release. In either case, keep the release PR in draft and leave its base branch unchanged until BRAT validation succeeds. Record that state in the PR.
- Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release.
- After BRAT validation succeeds, mark the release PR ready and merge it with a merge commit. This keeps the tagged release commit in the `main` history.
- If BRAT validation fails, keep the release PR in draft. Do not move published tags; prepare and publish a new patch release instead.
- If a pre-release is needed, run the `Release Obsidian Plugin` workflow manually with the target tag, `draft=false`, and `prerelease=true`.
- For a pre-release, set `prerelease=true` in `Finalise Release Tags`. A hyphenated version is rejected unless that input is enabled.
### Release Cheat Sheet
@@ -290,14 +278,15 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
- `version`: the same target version.
- `release_branch`: leave blank unless the release branch used a custom name.
- `expected_head_sha`: the full head commit SHA reviewed in the release PR.
- `prerelease`: enable for a version such as `1.0.0-rc.0`.
- `publish_cli`: disable when the reviewed release is plug-in-only.
5. Approve the `Release Obsidian Plugin` workflow for the `release` environment, then check the generated draft GitHub Release.
6. Confirm that the explicitly dispatched CLI Docker workflow has published the expected image tags.
7. Publish the draft GitHub Release as the latest stable release, but keep the release PR in draft and leave `main` unchanged.
8. Update the PR state message to say that the release is now the latest stable release and that merging is intentionally on hold until BRAT validation is complete.
6. If CLI publication was selected, confirm that the CLI tag event published the expected image tags.
7. Publish the draft as a stable release or pre-release as selected, but keep the release PR in draft and leave its base branch unchanged.
8. Update the PR state message to describe the published release and state that merging remains on hold until BRAT validation is complete.
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario.
10. After BRAT validation succeeds, mark the release PR ready and merge it into `main` with a merge commit.
11. If validation fails, leave the PR in draft and prepare a new patch release without moving the published tags.
12. If the release should be a pre-release instead of a draft release, run `Release Obsidian Plugin` manually with the target `tag`, `draft=false`, and `prerelease=true`.
## Contribution Guidelines
@@ -0,0 +1,54 @@
# Release notes and database compatibility gates
## Status
Accepted for the 1.0 release line.
## Context
Self-hosted LiveSync historically used two unrelated kinds of version state during start-up.
The plug-in SemVer was converted into a numeric major/minor value and stored in `lastReadUpdates`. The settings dialogue used that value to open the change log automatically, and offered a button which marked the release line as read. Patch versions were intentionally ignored. Pre-release identifiers containing an additional dot did not fit this numeric representation and could be interpreted as a much larger release line.
Separately, the internal database compatibility constant `VER` is recorded in device-local storage under a Vault-scoped key. Crossing this internal version used to set `versionUpFlash` and permanently change several automatic synchronisation settings to `false`. Replication services already reject work while `versionUpFlash` is non-empty, so changing the user's saved choices duplicated the runtime safety gate and required manual reconstruction after acknowledgement.
The remote `obsydian_livesync_version` document also carries the internal database version. It is a protocol and data-compatibility mechanism, not a copy of the plug-in SemVer.
## Decision
### Release notes
- Keep the change-log pane and render the current release history whenever it is opened.
- Remove automatic unread-version tracking, the acknowledgement button for ordinary release notes, and automatic navigation to the change-log pane.
- Do not derive data-compatibility behaviour from the plug-in's major, minor, patch, or pre-release identifiers.
- Retain the saved `lastReadUpdates` field in the settings schema for backwards compatibility, but do not use it in the plug-in. It can be removed through a separately reviewed settings-schema migration if retaining it later becomes burdensome.
### Database compatibility
- Continue to use the internal database version `VER` for changes which require explicit compatibility review. Changing the plug-in SemVer alone does not increment `VER`.
- Store the last acknowledged internal database version through Commonlib's device-local small-configuration contract under `database-compatibility-version`. Copy the legacy raw local-storage value into that contract once, then remove the legacy key after the copy has completed.
- Initialise the marker to the current `VER` only when Commonlib identifies a genuinely new Vault with no pending review. An existing Vault with a missing or invalid marker requires review instead of being silently accepted.
- Derive one structured pause from the acknowledged database version, Commonlib's settings-migration state, and any persisted legacy review message. Persist the generic `versionUpFlash` message without changing any automatic synchronisation setting, because Commonlib already treats that field as a replication gate.
- Treat non-empty `versionUpFlash` as a runtime replication gate. Standard and one-shot replication must stop before remote work begins.
- Present the reason in a dedicated dialogue after the Obsidian layout is ready. The details view is explanatory only and returns to the summary before any decision can be made. The safe default and closing either dialogue keep synchronisation paused. A persistent Notice and a command allow the dialogue to be reopened without using the settings pane.
- Let the person read focused compatibility details without presenting the whole release history as a safety instruction. The Change Log remains a manually opened release-history pane and contains no compatibility acknowledgement control.
- Offer an explicit resume action only when every reason is recoverable in the running implementation. An upgrade, a missing or invalid marker on an existing Vault, and a reviewed migration from an older settings schema are resumable after all devices have been updated. A downgrade from a newer acknowledged `VER`, or settings saved by a future schema, cannot be acknowledged by the older installation.
- On resume, clear `versionUpFlash` and persist that fail-closed change before recording the current `VER` as acknowledged. If saving fails, restore the gate. Reapply settings only after the marker has advanced so that the previously configured synchronisation behaviour can resume without reconstruction.
- Preserve the original legacy review message as a structured reason when no more specific database or settings-schema reason is available. Escape it before including it in Markdown UI.
- Continue to reject a remote version document which is newer than the running implementation. That receiver-side check is independent of the local upgrade review.
## Consequences
- Ordinary releases no longer force the settings dialogue to show release notes. Important operational instructions must be clear in the published release notes and any explicit migration notice.
- SemVer pre-releases such as `1.0.0-rc.0` no longer require a special numeric encoding inside plug-in settings.
- An internal compatibility change remains fail-closed for replication, but it no longer destroys the person's synchronisation preferences.
- A new installation has no previous internal-version marker and therefore does not show an upgrade review. Its initial settings and onboarding remain responsible for keeping replication disabled until configuration is complete.
- An older installation cannot dismiss evidence that a newer implementation or settings schema has already been used on the device.
- The Obsidian-specific dialogue depends only on a host-neutral compatibility result and the injected confirmation capability. Commonlib remains responsible for settings migration, device-local storage, and the replication gate.
- A future incompatible database change must increment `VER`, provide an actionable review message, verify the remote version negotiation, and test both the pending and acknowledged states. A major SemVer increase without those changes has no database-compatibility effect.
## Verification
- Unit tests verify new-Vault initialisation, upgrades, missing and invalid markers, downgrades, future settings schemas, legacy marker migration, acknowledgement ordering, and save-failure recovery while retaining automatic synchronisation choices.
- Unit tests verify that a pending review is honoured by the packaged Commonlib replication service before remote activity begins.
- A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control.
+2 -1
View File
@@ -1,4 +1,5 @@
# Data Structures of Self-Hosted LiveSync
## Overview
Self-hosted LiveSync uses the following types of documents:
@@ -30,7 +31,7 @@ export interface DatabaseEntry {
This document stores version information for Self-hosted LiveSync.
The ID is fixed as `obsydian_livesync_version` [VERSIONING_DOCID]. Yes, the typo has become a curse.
When Self-hosted LiveSync detects changes to this document via Replication, it reads the version information and checks compatibility.
In that case, if there are major changes, synchronisation may be stopped.
This internal database version is independent of the plug-in's SemVer version. The last version explicitly acknowledged on a device is stored through Commonlib's device-local configuration contract. When that version differs, or when a settings migration requires review, Self-hosted LiveSync presents a dedicated compatibility dialogue and blocks replication without changing the user's automatic synchronisation choices. A supported upgrade can resume only after explicit review. A downgrade from a newer acknowledged database version, or settings written by a future schema, remains blocked until a compatible plug-in is installed.
Please refer to negotiation.ts.
### Synchronise Information Document
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -21,7 +21,9 @@ There are many settings in Self-hosted LiveSync. This document describes each se
## 0. Change Log
This pane shows version up information. You can check what has been changed in recent versions.
This pane always shows the current release history. It does not track whether a particular plug-in version has been read and does not open automatically after an ordinary update.
Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. Closing it keeps synchronisation paused. When the detected state can be handled by the running version, the explicit resume action records the current internal database version and restores the configured behaviour. A persistent Notice and the `Review why synchronisation is paused` command reopen the review. An older installation cannot dismiss a pause caused by a newer database or settings version.
## 1. Setup
@@ -170,7 +172,7 @@ The active remote server type. This is automatically projected to the legacy con
Setting key: notifyThresholdOfRemoteStorageSize
MB (0 to disable). We can get a notification when the estimated remote storage size exceeds this value.
MB (0 to disable). At startup, Self-hosted LiveSync shows a long-lived, clickable notice when this value has not been configured or the estimated remote storage size exceeds it. Select **Review options** to open the detailed, untimed dialogue. Running the remote-size check explicitly opens that dialogue directly.
### 3. Privacy & Encryption
@@ -197,6 +199,7 @@ In default, the path of the file is not obfuscated to improve the performance. I
Setting key: E2EEAlgorithm
The encryption algorithm version used for end-to-end encryption.
- `v2` (V2: AES-256-GCM With HKDF): Recommended and default version.
- `forceV1` or `""` (V1: Legacy): Older legacy encryption. Only use this if you have an existing vault encrypted in the legacy format.
@@ -447,6 +450,7 @@ Apply preset configuration
Setting key: syncMode
The trigger mechanism for synchronisation.
- **LiveSync** (`LIVESYNC`): Real-time, continuous, bidirectional synchronisation.
Note: This requires a CouchDB or WebRTC P2P remote server. It is not supported for S3-compatible Object Storage.
- **Periodic Sync** (`PERIODIC`): Synchronisation is performed at regular intervals specified by the **Periodic Sync interval** setting.
@@ -748,11 +752,11 @@ Setting key: concurrencyOfReadChunksOnline
Setting key: minimumIntervalOfReadChunksOnline
#### Maximum size of chunks to send in one request
#### Maximum request size for manually resending chunks
Setting key: sendChunksBulkMaxSize
Limit the maximum size of chunks to send in a single bulk request (MB).
Limit the maximum size of chunks sent in one request by the explicit **Resend all chunks** maintenance operation (MB). Ordinary and initial replication do not use this setting.
## 9. Power users (Power User)
@@ -938,7 +942,7 @@ Disables all synchronisation and restart.
#### Resend
Resend all chunks to the remote.
Explicitly resend all locally available chunks to the remote. This is a recovery and maintenance operation; ordinary replication does not pre-send every chunk.
#### Reset journal received history
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "0.25.83",
"version": "1.0.0-rc.0",
"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.",
"author": "vorotamoroz",
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "0.25.83",
"version": "1.0.0-rc.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "0.25.83",
"version": "1.0.0-rc.0",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -16300,7 +16300,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "0.25.83-cli",
"version": "1.0.0-rc.0-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -16325,7 +16325,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "0.25.83-webapp",
"version": "1.0.0-rc.0-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
@@ -16340,7 +16340,7 @@
}
},
"src/apps/webpeer": {
"version": "0.25.83-webpeer",
"version": "1.0.0-rc.0-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "0.25.83",
"version": "1.0.0-rc.0",
"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",
"type": "module",
+35 -7
View File
@@ -1,4 +1,4 @@
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import MessageBox from "./ui/MessageBox.svelte";
import TextInputBox from "./ui/TextInputBox.svelte";
@@ -6,13 +6,14 @@ import TextInputBox from "./ui/TextInputBox.svelte";
import { mount } from "svelte";
import { promiseWithResolvers } from "octagonal-wheels/promises";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { _activeDocument, compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
function displayMessageBox<T, U extends string[]>(
message: string,
buttons: U,
title: string,
commit: (ret: U[number]) => T
commit: (ret: U[number]) => T,
actionLayout?: ConfirmActionLayout
): Promise<T> {
const el = _activeDocument.createElement("div");
const p = promiseWithResolvers<T>();
@@ -22,6 +23,7 @@ function displayMessageBox<T, U extends string[]>(
message,
buttons: buttons as string[],
title: title,
actionLayout,
commit: (action: U[number]) => {
const ret = commit(action);
p.resolve(ret);
@@ -92,16 +94,42 @@ export class BrowserConfirm<T extends ServiceContext> implements Confirm {
): Promise<T[number] | false> {
return displayMessageBox(message, [...buttons] as const, opt.title ?? "Confirm", (action) => action);
}
askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void): void {
throw new Error("Method not implemented.");
askInPopup(
key: string,
dialogText: string,
anchorCallback: (anchor: HTMLAnchorElement) => void,
durationMs: number = 20000
): void {
const existing = _activeDocument.querySelector(`[data-livesync-popup="${CSS.escape(key)}"]`);
existing?.remove();
const notice = _activeDocument.createElement("div");
notice.className = "livesync-browser-notice";
notice.dataset.livesyncPopup = key;
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
notice.append(beforeText);
const anchor = _activeDocument.createElement("a");
anchor.href = "#";
anchorCallback(anchor);
anchor.addEventListener("click", () => notice.remove());
notice.append(anchor, afterText ?? "");
_activeDocument.body.appendChild(notice);
compatGlobal.setTimeout(() => notice.remove(), durationMs);
}
confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number
timeout?: number,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
return displayMessageBox(contentMd, [...buttons] as const, title ?? "Confirm", (action) => action);
return displayMessageBox(
contentMd,
[...buttons] as const,
title ?? "Confirm",
(action) => action,
actionLayout
);
}
}
+12 -2
View File
@@ -5,9 +5,11 @@
title: string;
message: string;
buttons: string[];
actionLayout?: ConfirmActionLayout;
commit: (button: string) => void;
};
let { title, message, buttons, commit }: Props = $props();
type ConfirmActionLayout = "auto" | "vertical";
let { title, message, buttons, actionLayout, commit }: Props = $props();
const renderedMessage = $derived(renderMessageMarkdown(message));
function handleEsc(event: KeyboardEvent) {
@@ -20,7 +22,7 @@
<popup>
<header>{title}</header>
<article><div class="msg">{@html renderedMessage}</div></article>
<div class="buttons">
<div class:vertical={actionLayout === "vertical"} class="buttons">
{#each buttons as button}
<button onclick={() => commit(button)}>{button}</button>
{/each}
@@ -116,6 +118,14 @@
margin: 0 0.5em;
background-color: var(--background-primary-alt);
}
popup .buttons.vertical {
align-items: stretch;
flex-direction: column;
}
popup .buttons.vertical button {
margin: 0.25em 0;
width: 100%;
}
popup ~ .background {
position: fixed;
top: 0;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "0.25.83-cli",
"version": "1.0.0-rc.0-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "0.25.83-webapp",
"version": "1.0.0-rc.0-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "0.25.83-webpeer",
"version": "1.0.0-rc.0-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
+144
View File
@@ -0,0 +1,144 @@
import type { SettingsMigrationState } from "@vrtmrz/livesync-commonlib/compat/common/types";
export const DATABASE_COMPATIBILITY_VERSION_KEY = "database-compatibility-version";
export const DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX = "obsidian-live-sync-ver";
export const COMPATIBILITY_PAUSE_SETTING_MESSAGE =
"Remote synchronisation is paused until this device's compatibility review has been completed.";
export type DatabaseCompatibilityVersionState = "missing" | "invalid" | "upgrade" | "downgrade";
export interface DatabaseCompatibilityReason {
source: "database-version";
state: DatabaseCompatibilityVersionState;
acknowledgedVersion?: number;
currentVersion: number;
resumable: boolean;
}
export interface SettingsCompatibilityReason {
source: "settings-schema";
sourceVersion: number;
currentVersion: number;
isFromFutureSchema: boolean;
resumable: boolean;
}
export interface LegacyCompatibilityReason {
source: "legacy-review";
message: string;
resumable: true;
}
export type CompatibilityPauseReason =
| DatabaseCompatibilityReason
| SettingsCompatibilityReason
| LegacyCompatibilityReason;
export interface CompatibilityPause {
reasons: readonly CompatibilityPauseReason[];
resumable: boolean;
}
export interface CompatibilityEvaluation {
pause?: CompatibilityPause;
initialiseAcknowledgedVersion: boolean;
}
export interface CompatibilityEvaluationInput {
acknowledgedVersion: string | null;
currentVersion: number;
migrationState?: SettingsMigrationState;
legacyReviewMessage: string;
}
function databaseVersionReason(
acknowledgedVersion: string | null,
currentVersion: number,
isNewVault: boolean
): DatabaseCompatibilityReason | undefined {
if (acknowledgedVersion === null || acknowledgedVersion === "") {
if (isNewVault) return undefined;
return {
source: "database-version",
state: "missing",
currentVersion,
resumable: true,
};
}
const parsed = Number(acknowledgedVersion);
if (!Number.isSafeInteger(parsed)) {
return {
source: "database-version",
state: "invalid",
currentVersion,
resumable: true,
};
}
if (parsed === currentVersion) return undefined;
if (parsed < currentVersion) {
return {
source: "database-version",
state: "upgrade",
acknowledgedVersion: parsed,
currentVersion,
resumable: true,
};
}
return {
source: "database-version",
state: "downgrade",
acknowledgedVersion: parsed,
currentVersion,
resumable: false,
};
}
/**
* Derives a host-neutral compatibility pause from device-local and settings-schema state.
*
* The caller owns persistence, user interaction, and the actual replication gate.
*/
export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput): CompatibilityEvaluation {
const isNewVault = input.migrationState?.isNewVault === true;
const reasons: CompatibilityPauseReason[] = [];
const databaseReason = databaseVersionReason(input.acknowledgedVersion, input.currentVersion, isNewVault);
if (databaseReason) reasons.push(databaseReason);
if (input.migrationState?.requiresSyncReview === true) {
reasons.push({
source: "settings-schema",
sourceVersion: input.migrationState.sourceVersion,
currentVersion: input.migrationState.targetVersion,
isFromFutureSchema: input.migrationState.isFromFutureSchema,
resumable: !input.migrationState.isFromFutureSchema,
});
}
if (input.legacyReviewMessage !== "" && reasons.length === 0) {
reasons.push({
source: "legacy-review",
message: input.legacyReviewMessage,
resumable: true,
});
}
if (reasons.length === 0) {
return {
initialiseAcknowledgedVersion:
isNewVault && (input.acknowledgedVersion === null || input.acknowledgedVersion === ""),
};
}
return {
pause: {
reasons,
resumable: reasons.every((reason) => reason.resumable),
},
initialiseAcknowledgedVersion: false,
};
}
export function legacyDatabaseCompatibilityVersionKey(vaultName: string): string {
return `${DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX}${vaultName}`;
}
@@ -0,0 +1,163 @@
import { describe, expect, it, vi } from "vitest";
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { evaluateCompatibilityPause, legacyDatabaseCompatibilityVersionKey } from "./databaseCompatibility.ts";
function migrationState(overrides: Record<string, unknown> = {}) {
return {
sourceVersion: 2,
targetVersion: 2,
isNewVault: false,
isFromFutureSchema: false,
changed: false,
requiresSyncReview: false,
reviewReasons: [],
...overrides,
} as never;
}
describe("database compatibility evaluation", () => {
it("initialises a new Vault without presenting an upgrade review", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: null,
currentVersion: 12,
migrationState: migrationState({ isNewVault: true }),
legacyReviewMessage: "",
});
expect(result).toEqual({ initialiseAcknowledgedVersion: true });
});
it("allows an older acknowledged version to be reviewed and resumed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "11",
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "",
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "database-version",
state: "upgrade",
acknowledgedVersion: 11,
currentVersion: 12,
resumable: true,
},
],
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
});
it("does not permit an older implementation to acknowledge a newer database version", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "13",
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "",
});
expect(result.pause?.resumable).toBe(false);
expect(result.pause?.reasons[0]).toMatchObject({
source: "database-version",
state: "downgrade",
acknowledgedVersion: 13,
currentVersion: 12,
});
});
it("requires review when an existing Vault has no valid acknowledged version", () => {
for (const acknowledgedVersion of [null, "invalid"]) {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "",
});
expect(result.pause?.resumable).toBe(true);
expect(result.pause?.reasons[0]).toMatchObject({ source: "database-version" });
}
});
it("does not permit a future settings schema to be acknowledged by an older implementation", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 3,
targetVersion: 2,
isFromFutureSchema: true,
requiresSyncReview: true,
}),
legacyReviewMessage: "",
});
expect(result.pause).toEqual({
resumable: false,
reasons: [
{
source: "settings-schema",
sourceVersion: 3,
currentVersion: 2,
isFromFutureSchema: true,
resumable: false,
},
],
});
});
it("retains an existing legacy review when no structured reason can be reconstructed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "Review an earlier compatibility change.",
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "legacy-review",
message: "Review an earlier compatibility change.",
resumable: true,
},
],
});
});
it("scopes the legacy marker to the Vault", () => {
expect(legacyDatabaseCompatibilityVersionKey("Example Vault")).toBe("obsidian-live-sync-verExample Vault");
});
});
describe("packaged Commonlib compatibility gate", () => {
it("prevents replication while the compatibility review remains pending", async () => {
const openReplication = vi.fn().mockResolvedValue(true);
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
const dependencies = {
APIService: { isOnline: true, addLog: vi.fn() },
appLifecycleService: {
isReady: () => true,
getUnresolvedMessages: Object.assign(vi.fn().mockResolvedValue([]), { addHandler: vi.fn() }),
},
databaseService: {},
fileProcessingService: { commitPendingFileEvents: vi.fn().mockResolvedValue(true) },
replicatorService: {
getActiveReplicator: () => ({ openReplication }),
runFiniteReplicationActivity,
},
settingService: {
currentSettings: () => ({ versionUpFlash: "Review the database compatibility change." }),
},
};
const service = new InjectableReplicationService(new ServiceContext(), dependencies as never);
await expect(service.replicate(true)).resolves.toBe(false);
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
expect(openReplication).not.toHaveBeenCalled();
});
});
+5 -39
View File
@@ -44,7 +44,11 @@ import {
isObjectDifferent,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { arrayBufferToBase64, decodeBinary, readString } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import {
arrayBufferToBase64,
decodeBinary,
readString,
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { serialized, shareRunningResult } from "octagonal-wheels/concurrency/lock";
import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
@@ -1683,43 +1687,6 @@ export class ConfigSync extends LiveSyncCommands {
return filenames as FilePath[];
}
private async _allAskUsingOptionalSyncFeature(opt: {
enableFetch?: boolean;
enableOverwrite?: boolean;
}): Promise<boolean> {
await this.__askHiddenFileConfiguration(opt);
return true;
}
private async __askHiddenFileConfiguration(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
const message = `Would you like to enable **Customization sync**?
> [!DETAILS]-
> This feature allows you to sync your customisations -- such as configurations, themes, snippets, and plugins -- across your devices in a fully controlled manner, unlike the fully automatic behaviour of hidden file synchronisation.
>
> You may use this feature alongside hidden file synchronisation. When both features are enabled, items configured as \`Automatic\` in this feature will be managed by **hidden file synchronisation**.
> Do not worry, you will be prompted to enable or keep disabled **hidden file synchronisation** after this dialogue.
`;
const CHOICE_CUSTOMIZE = "Yes, Enable it";
const CHOICE_DISABLE = "No, Disable it";
const CHOICE_DISMISS = "Later";
const choices = [];
choices.push(CHOICE_CUSTOMIZE);
choices.push(CHOICE_DISABLE);
choices.push(CHOICE_DISMISS);
const ret = await this.core.confirm.askSelectStringDialogue(message, choices, {
defaultAction: CHOICE_DISMISS,
timeout: 40,
title: "Customisation sync",
});
if (ret == CHOICE_CUSTOMIZE) {
await this.configureHiddenFileSync("CUSTOMIZE");
} else if (ret == CHOICE_DISABLE) {
await this.configureHiddenFileSync("DISABLE_CUSTOM");
}
}
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
if (isPluginMetadata(path)) {
return Promise.resolve("newer");
@@ -1826,7 +1793,6 @@ export class ConfigSync extends LiveSyncCommands {
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this));
services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this));
services.setting.suspendExtraSync.addHandler(this._allSuspendExtraSync.bind(this));
services.setting.suggestOptionalFeatures.addHandler(this._allAskUsingOptionalSyncFeature.bind(this));
services.setting.enableOptionalFeature.addHandler(this._allConfigureOptionalSyncFeature.bind(this));
}
}
@@ -46,7 +46,10 @@ import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.t
import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts";
import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import { hiddenFilesEventCount, hiddenFilesProcessingCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
import {
hiddenFilesEventCount,
hiddenFilesProcessingCount,
} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
@@ -1765,56 +1768,6 @@ Offline Changed files: ${files.length}`;
// <-- Database To Storage Functions
private async _allAskUsingOptionalSyncFeature(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
await this.__askHiddenFileConfiguration(opt);
return true;
}
private async __askHiddenFileConfiguration(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
const messageFetch = `${opt.enableFetch ? `> - Fetch: Use the files stored from other devices. Choose this option if you have already configured hidden file synchronization on those devices and wish to accept their files.\n` : ""}`;
const messageOverwrite = `${opt.enableOverwrite ? `> - Overwrite: Use the files from this device. Select this option if you want to overwrite the files stored on other devices.\n` : ""}`;
const messageMerge = `> - Merge: Merge the files from this device with those on other devices. Choose this option if you wish to combine files from multiple sources.
> However, please be reminded that merging may cause conflicts if the files are not identical. Additionally, this process may occur within the same folder, potentially breaking your plug-in or theme settings that comprise multiple files.\n`;
const message = `Would you like to enable **Hidden File Synchronization**?
> [!DETAILS]-
> This feature allows you to synchronize all hidden files without any user interaction.
> To enable this feature, you should choose one of the following options:
${messageFetch}${messageOverwrite}${messageMerge}
> [!IMPORTANT]
> Please keep in mind that enabling this feature alongside customisation sync may override certain behaviors.`;
const CHOICE_FETCH = "Fetch";
const CHOICE_OVERWRITE = "Overwrite";
const CHOICE_MERGE = "Merge";
const CHOICE_DISABLE = "Disable";
const choices = [];
if (opt?.enableFetch) {
choices.push(CHOICE_FETCH);
}
if (opt?.enableOverwrite) {
choices.push(CHOICE_OVERWRITE);
}
choices.push(CHOICE_MERGE);
choices.push(CHOICE_DISABLE);
const ret = await this.core.confirm.confirmWithMessage(
"Hidden file sync",
message,
choices,
CHOICE_DISABLE,
40
);
if (ret == CHOICE_FETCH) {
await this.configureHiddenFileSync("FETCH");
} else if (ret == CHOICE_OVERWRITE) {
await this.configureHiddenFileSync("OVERWRITE");
} else if (ret == CHOICE_MERGE) {
await this.configureHiddenFileSync("MERGE");
} else if (ret == CHOICE_DISABLE) {
await this.configureHiddenFileSync("DISABLE_HIDDEN");
}
}
private _allSuspendExtraSync(): Promise<boolean> {
if (this.core.settings.syncInternalFiles) {
this._log(
@@ -1988,7 +1941,6 @@ ${messageFetch}${messageOverwrite}${messageMerge}
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this));
services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this));
services.setting.suspendExtraSync.addHandler(this._allSuspendExtraSync.bind(this));
services.setting.suggestOptionalFeatures.addHandler(this._allAskUsingOptionalSyncFeature.bind(this));
services.setting.enableOptionalFeature.addHandler(this._allConfigureOptionalSyncFeature.bind(this));
services.vault.isTargetFileInExtra.addHandler((file) =>
this.isTargetFile((typeof file === "string" ? file : stripAllPrefixes(file.path)) as FilePath)
+3
View File
@@ -43,6 +43,8 @@ import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/repli
import { useP2PReplicatorCommands } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorCommands";
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore;
@@ -192,6 +194,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
useOfflineScanner(core);
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useCompatibilityReview(core, createObsidianCompatibilityReviewUi(core.confirm));
// p2pReplicatorResult = useP2PReplicator(core, [
// VIEW_TYPE_P2P,
// (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!),
@@ -188,6 +188,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
override onOpen() {
this.component.load();
const { contentEl } = this;
contentEl.closest(".modal-container")?.classList.add("livesync-message-box-container");
this.titleEl.setText(this.title);
const div = contentEl.createDiv();
div.setCssStyles({
@@ -14,7 +14,6 @@ import {
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay, isObjectDifferent, sizeToHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { versionNumberString2Number } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { checkSyncInfo } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import { testCrypt } from "octagonal-wheels/encryption/encryption";
@@ -34,7 +33,6 @@ import {
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import { fireAndForget, yieldNextAnimationFrame } from "octagonal-wheels/promises";
import { confirmWithMessage } from "@/modules/coreObsidian/UILib/dialogs.ts";
import { EVENT_REQUEST_RELOAD_SETTING_TAB, eventHub } from "@/common/events.ts";
import { paneChangeLog } from "./PaneChangeLog.ts";
import {
@@ -417,11 +415,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
}
//@ts-ignore
manifestVersion: string = MANIFEST_VERSION || "-";
lastVersion = ~~(versionNumberString2Number(this.manifestVersion) / 1000);
screenElements: { [key: string]: HTMLElement[] } = {};
changeDisplay(screen: string) {
for (const k in this.screenElements) {
@@ -623,7 +616,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
OPTION_ONLY_SETTING,
OPTION_CANCEL,
];
const result = await confirmWithMessage(this.plugin, title, note, buttons, OPTION_CANCEL, 0);
const result = await this.core.confirm.confirmWithMessage(title, note, buttons, OPTION_CANCEL);
if (result == OPTION_CANCEL) return;
if (result == OPTION_FETCH) {
if (!(await this.checkWorkingPassphrase())) {
@@ -829,18 +822,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
void yieldNextAnimationFrame().then(() => {
if (this.selectedScreen == "") {
if (this.lastVersion != this.editingSettings.lastReadUpdates) {
if (this.editingSettings.isConfigured) {
changeDisplay("100");
} else {
changeDisplay("110");
}
if (this.isAnySyncEnabled()) {
changeDisplay("20");
} else {
if (this.isAnySyncEnabled()) {
changeDisplay("20");
} else {
changeDisplay("110");
}
changeDisplay("110");
}
} else {
changeDisplay(this.selectedScreen);
@@ -1,61 +1,11 @@
import { MarkdownRenderer } from "@/deps.ts";
import { versionNumberString2Number } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import { fireAndForget } from "octagonal-wheels/promises";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import { visibleOnly } from "./SettingPane.ts";
//@ts-ignore
const manifestVersion: string = MANIFEST_VERSION || "-";
//@ts-ignore
const updateInformation: string = UPDATE_INFO || "";
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement): void {
const cx = this.createEl(
paneEl,
"div",
{
cls: "op-warn-info",
},
undefined,
visibleOnly(() => !this.isConfiguredAs("versionUpFlash", ""))
);
this.createEl(
cx,
"div",
{
text: this.editingSettings.versionUpFlash,
},
undefined
);
this.createEl(cx, "button", { text: $msg("obsidianLiveSyncSettingTab.btnGotItAndUpdated") }, (e) => {
e.addClass("mod-cta");
e.addEventListener("click", () => {
fireAndForget(async () => {
this.editingSettings.versionUpFlash = "";
await this.saveAllDirtySettings();
});
});
});
const informationDivEl = this.createEl(paneEl, "div", { text: "" });
const tmpDiv = createDiv();
// tmpDiv.addClass("sls-header-button");
tmpDiv.addClass("op-warn-info");
tmpDiv.createEl("p", { text: $msg("obsidianLiveSyncSettingTab.msgNewVersionNote") });
const readEverythingButton = tmpDiv.createEl("button", {
text: $msg("obsidianLiveSyncSettingTab.optionOkReadEverything"),
});
if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) {
const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
readEverythingButton.addEventListener("click", () => {
fireAndForget(async () => {
this.editingSettings.lastReadUpdates = lastVersion;
await this.saveAllDirtySettings();
informationButtonDiv.remove();
});
});
}
fireAndForget(() =>
MarkdownRenderer.render(this.plugin.app, updateInformation, informationDivEl, "/", this.lifetimeComponent)
);
+5 -27
View File
@@ -1,5 +1,9 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, VER, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
EVENT_LAYOUT_READY,
EVENT_PLUGIN_LOADED,
@@ -8,13 +12,11 @@ import {
eventHub,
} from "@/common/events.ts";
import { $msg, setLang } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import { versionNumberString2Number } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker/bgWorker";
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
export class ModuleLiveSyncMain extends AbstractModule {
async _onLiveSyncReady() {
@@ -97,30 +99,6 @@ export class ModuleLiveSyncMain extends AbstractModule {
this._log($msg("moduleLiveSyncMain.logPluginInitCancelled"), LOG_LEVEL_NOTICE);
return false;
}
const lsKey = "obsidian-live-sync-ver" + this.services.vault.getVaultName();
const last_version = compatGlobal.localStorage.getItem(lsKey);
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
if (lastVersion > this.settings.lastReadUpdates && this.settings.isConfigured) {
this._log($msg("moduleLiveSyncMain.logReadChangelog"), LOG_LEVEL_NOTICE);
}
// //@ts-ignore
// if (this.isMobile) {
// this.settings.disableRequestURI = true;
// }
if (last_version && Number(last_version) < VER) {
this.settings.liveSync = false;
this.settings.syncOnSave = false;
this.settings.syncOnEditorSave = false;
this.settings.syncOnStart = false;
this.settings.syncOnFileOpen = false;
this.settings.syncAfterMerge = false;
this.settings.periodicReplication = false;
this.settings.versionUpFlash = $msg("moduleLiveSyncMain.logVersionUpdate");
await this.saveSettings();
}
compatGlobal.localStorage.setItem(lsKey, `${VER}`);
await this.services.database.openDatabase({
databaseEvents: this.services.databaseEvents,
replicator: this.services.replicator,
@@ -54,6 +54,14 @@
max-height: 100%;
}
:global(body.is-mobile .livesync-svelte-dialog-container .dialog-host > .button-group) {
background: var(--modal-background, var(--background-primary));
bottom: 0;
padding-bottom: 1px;
position: sticky;
z-index: 1;
}
.dialog-host {
padding: 20px;
gap: 0.5em;
+143 -25
View File
@@ -1,18 +1,19 @@
import { type App, type Plugin, Notice } from "@/deps";
import { scheduleTask, memoIfNotExist, memoObject, retrieveMemoObject, disposeMemoObject } from "@/common/utils";
import { EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import { confirmAction, pickOne, promptPassword, promptText } from "@vrtmrz/obsidian-plugin-kit";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import {
askYesNo,
askString,
confirmWithMessageWithWideButton,
askSelectString,
confirmWithMessage,
confirmWithMessage as confirmWithLegacyMessage,
} from "@/modules/coreObsidian/UILib/dialogs";
export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceContext> implements Confirm {
private _context: T;
private readonly dialogueController = new AbortController();
private readonly popupKeys = new Set<string>();
get _app(): App {
return this._context.app;
}
@@ -21,12 +22,58 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
}
constructor(context: T) {
this._context = context;
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
this.dialogueController.abort();
for (const popupKey of this.popupKeys) {
this.closePopup(popupKey);
}
});
}
askYesNo(message: string): Promise<"yes" | "no"> {
return askYesNo(this._app, message);
private get dialogueLifecycle() {
return { signal: this.dialogueController.signal };
}
askString(title: string, key: string, placeholder: string, isPassword: boolean = false): Promise<string | false> {
return askString(this._app, title, key, placeholder, isPassword);
private hasCountdown(timeout: number | undefined): timeout is number {
return timeout !== undefined && timeout > 0;
}
async askYesNo(message: string): Promise<"yes" | "no"> {
const result = await confirmAction(
this._app,
{
title: $msg("moduleInputUIObsidian.defaultTitleConfirmation"),
message,
actions: ["yes", "no"] as const,
labels: {
yes: $msg("moduleInputUIObsidian.optionYes"),
no: $msg("moduleInputUIObsidian.optionNo"),
},
defaultAction: "no",
sourcePath: "/",
},
this.dialogueLifecycle
);
return result === "yes" ? "yes" : "no";
}
async askString(
title: string,
key: string,
placeholder: string,
isPassword: boolean = false
): Promise<string | false> {
const prompt = isPassword ? promptPassword : promptText;
const result = await prompt(
this._app,
{
title,
label: key,
placeholder,
},
this.dialogueLifecycle
);
return result ?? false;
}
async askYesNoDialog(
@@ -37,6 +84,21 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
const yesLabel = $msg("moduleInputUIObsidian.optionYes");
const noLabel = $msg("moduleInputUIObsidian.optionNo");
const defaultOption = opt.defaultOption === "Yes" ? yesLabel : noLabel;
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
actions: ["Yes", "No"] as const,
labels: { Yes: yesLabel, No: noLabel },
defaultAction: opt.defaultOption === "Yes" ? "Yes" : "No",
sourcePath: "/",
},
this.dialogueLifecycle
);
return result === "Yes" ? "yes" : "no";
}
const ret = await confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
@@ -48,16 +110,39 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
return ret === yesLabel ? "yes" : "no";
}
askSelectString(message: string, items: string[]): Promise<string> {
return askSelectString(this._app, message, items);
async askSelectString(message: string, items: string[]): Promise<string> {
const result = await pickOne(
this._app,
{
items,
getText: (item) => item,
placeholder: message,
},
this.dialogueLifecycle
);
return result ?? "";
}
askSelectStringDialogue<T extends readonly string[]>(
async askSelectStringDialogue<T extends readonly string[]>(
message: string,
buttons: T,
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect");
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
actions: buttons,
defaultAction: opt.defaultAction,
sourcePath: "/",
},
this.dialogueLifecycle
);
return result ?? false;
}
return confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
@@ -68,7 +153,14 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
);
}
askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void) {
askInPopup(
key: string,
dialogText: string,
anchorCallback: (anchor: HTMLAnchorElement) => void,
durationMs: number = 20000
) {
const popupKey = "popup-" + key;
this.popupKeys.add(popupKey);
const fragment = createFragment((doc) => {
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
doc.createSpan(undefined, (a) => {
@@ -76,36 +168,62 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
a.appendChild(
a.createEl("a", undefined, (anchor) => {
anchorCallback(anchor);
anchor.addEventListener("click", () => this.closePopup(popupKey));
})
);
a.appendText(afterText);
});
});
const popupKey = "popup-" + key;
scheduleTask(popupKey, 1000, async () => {
if (this.dialogueController.signal.aborted) {
this.popupKeys.delete(popupKey);
return;
}
const popup = await memoIfNotExist(popupKey, () => new Notice(fragment, 0));
const isShown = popup?.noticeEl?.isShown();
if (!isShown) {
memoObject(popupKey, new Notice(fragment, 0));
}
scheduleTask(popupKey + "-close", 20000, () => {
const popup = retrieveMemoObject<Notice>(popupKey);
if (!popup) return;
if (popup?.noticeEl?.isShown()) {
popup.hide();
}
disposeMemoObject(popupKey);
});
scheduleTask(popupKey + "-close", durationMs, () => this.closePopup(popupKey));
});
}
confirmWithMessage(
private closePopup(popupKey: string) {
const popup = retrieveMemoObject<Notice>(popupKey);
if (!popup) {
this.popupKeys.delete(popupKey);
return;
}
if (popup.noticeEl?.isShown()) {
popup.hide();
}
disposeMemoObject(popupKey);
this.popupKeys.delete(popupKey);
}
async confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number
timeout?: number,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
return confirmWithMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout);
if (this.hasCountdown(timeout)) {
return confirmWithLegacyMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout);
}
const result = await confirmAction(
this._app,
{
title,
message: contentMd,
actions: buttons,
defaultAction,
sourcePath: "/",
...(actionLayout === undefined ? {} : { actionLayout }),
},
this.dialogueLifecycle
);
return result ?? false;
}
}
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
confirmAction: vi.fn(),
pickOne: vi.fn(),
promptPassword: vi.fn(),
promptText: vi.fn(),
legacyAskSelectString: vi.fn(),
legacyAskString: vi.fn(),
legacyAskYesNo: vi.fn(),
legacyConfirm: vi.fn(),
legacyWideConfirm: vi.fn(),
}));
vi.mock("@vrtmrz/obsidian-plugin-kit", () => ({
confirmAction: mocks.confirmAction,
pickOne: mocks.pickOne,
promptPassword: mocks.promptPassword,
promptText: mocks.promptText,
}));
vi.mock("@/modules/coreObsidian/UILib/dialogs", () => ({
askSelectString: mocks.legacyAskSelectString,
askString: mocks.legacyAskString,
askYesNo: mocks.legacyAskYesNo,
confirmWithMessage: mocks.legacyConfirm,
confirmWithMessageWithWideButton: mocks.legacyWideConfirm,
}));
vi.mock("@/deps", () => ({
Notice: class {},
}));
import { EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { memoObject, retrieveMemoObject } from "@/common/utils";
import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { ObsidianConfirm } from "./ObsidianConfirm";
import type { ObsidianServiceContext } from "./ObsidianServiceContext";
function createConfirm() {
const app = { id: "app" };
const plugin = { app };
const events = createLiveSyncEventHub();
const context = { app, plugin, events } as unknown as ObsidianServiceContext;
return { confirm: new ObsidianConfirm(context), events, app, plugin };
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("ObsidianConfirm Fancy Kit adapter", () => {
it("uses owner-bound Kit prompts and preserves cancellation and empty input", async () => {
const { confirm, app } = createConfirm();
mocks.promptText.mockResolvedValueOnce(null);
mocks.promptPassword.mockResolvedValueOnce("");
await expect(confirm.askString("Name", "Device name", "New Remote")).resolves.toBe(false);
await expect(confirm.askString("Secret", "Passphrase", "Enter it", true)).resolves.toBe("");
expect(mocks.promptText).toHaveBeenCalledWith(
app,
{
title: "Name",
label: "Device name",
placeholder: "New Remote",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.promptPassword).toHaveBeenCalledWith(
app,
{
title: "Secret",
label: "Passphrase",
placeholder: "Enter it",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskString).not.toHaveBeenCalled();
});
it("uses Kit for untimed yes/no and typed selection while preserving dismissed results", async () => {
const { confirm, app } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("yes");
mocks.pickOne.mockResolvedValueOnce("Beta").mockResolvedValueOnce(null);
await expect(confirm.askYesNo("Continue?")).resolves.toBe("yes");
await expect(confirm.askSelectString("Target", ["Alpha", "Beta"])).resolves.toBe("Beta");
await expect(confirm.askSelectString("Target", ["Alpha"])).resolves.toBe("");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
expect.objectContaining({
message: "Continue?",
actions: ["yes", "no"],
defaultAction: "no",
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.pickOne).toHaveBeenCalledWith(
app,
expect.objectContaining({
items: ["Alpha", "Beta"],
getText: expect.any(Function),
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskYesNo).not.toHaveBeenCalled();
expect(mocks.legacyAskSelectString).not.toHaveBeenCalled();
});
it("uses Kit for untimed action dialogues and retains the legacy countdown path", async () => {
const { confirm, app, plugin } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("Apply").mockResolvedValueOnce("Yes");
mocks.legacyConfirm.mockResolvedValueOnce("Cancel");
mocks.legacyWideConfirm.mockResolvedValueOnce("No");
await expect(
confirm.confirmWithMessage("Review", "**Apply?**", ["Apply", "Cancel"], "Cancel", undefined, "vertical")
).resolves.toBe("Apply");
await expect(confirm.askYesNoDialog("Continue?", { title: "Question", defaultOption: "Yes" })).resolves.toBe(
"yes"
);
await expect(confirm.confirmWithMessage("Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30)).resolves.toBe(
"Cancel"
);
await expect(confirm.askYesNoDialog("Timed?", { defaultOption: "No", timeout: 10 })).resolves.toBe("no");
expect(mocks.confirmAction).toHaveBeenNthCalledWith(
1,
app,
{
title: "Review",
message: "**Apply?**",
actions: ["Apply", "Cancel"],
actionLayout: "vertical",
defaultAction: "Cancel",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyConfirm).toHaveBeenCalledWith(plugin, "Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
expect.any(String),
"Timed?",
expect.any(Array),
expect.any(String),
10
);
});
it("uses Kit for untimed multi-action selection and keeps timed wide actions on the countdown dialogue", async () => {
const { confirm, app, plugin } = createConfirm();
const actions = ["Apply now", "Review later"] as const;
mocks.confirmAction.mockResolvedValueOnce("Review later");
mocks.legacyWideConfirm.mockResolvedValueOnce("Apply now");
await expect(
confirm.askSelectStringDialogue("Choose the next step", actions, {
title: "Next step",
defaultAction: "Review later",
})
).resolves.toBe("Review later");
await expect(
confirm.askSelectStringDialogue("Choose before the timer expires", actions, {
title: "Timed step",
defaultAction: "Apply now",
timeout: 15,
})
).resolves.toBe("Apply now");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
{
title: "Next step",
message: "Choose the next step",
actions,
defaultAction: "Review later",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
"Timed step",
"Choose before the timer expires",
actions,
"Apply now",
15
);
});
it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => {
const { confirm, events } = createConfirm();
let observedSignal: AbortSignal | undefined;
mocks.confirmAction.mockImplementation(
(_app, _options, lifecycle: { signal: AbortSignal }) =>
new Promise<null>((resolve) => {
observedSignal = lifecycle.signal;
lifecycle.signal.addEventListener("abort", () => resolve(null), { once: true });
})
);
const result = confirm.confirmWithMessage("Review", "Message", ["OK"], "OK");
expect(observedSignal?.aborted).toBe(false);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
await expect(result).resolves.toBe(false);
expect(observedSignal?.aborted).toBe(true);
});
it("closes an active Notice when the plug-in unload event is emitted", () => {
const { confirm, events } = createConfirm();
const popupKey = "popup-remote-size-exceeded";
const popup = {
hide: vi.fn(),
noticeEl: { isShown: vi.fn(() => true) },
};
memoObject(popupKey, popup);
(confirm as unknown as { popupKeys: Set<string> }).popupKeys.add(popupKey);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(popup.hide).toHaveBeenCalledOnce();
expect(retrieveMemoObject(popupKey)).toBe(false);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncCore } from "@/main.ts";
import {
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
DATABASE_COMPATIBILITY_VERSION_KEY,
evaluateCompatibilityPause,
legacyDatabaseCompatibilityVersionKey,
type CompatibilityPause,
} from "@/common/databaseCompatibility.ts";
export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false;
export type CompatibilityReviewDetailsAction = "back" | false;
export interface CompatibilityReviewUi {
showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction>;
showDetails(pause: CompatibilityPause): Promise<CompatibilityReviewDetailsAction>;
showReminder(openReview: () => void): void;
clearReminder(): void;
}
export class CompatibilityReviewController {
private pause: CompatibilityPause | undefined;
private activeReview: Promise<void> | undefined;
private disposed = false;
constructor(
private readonly core: LiveSyncCore,
private readonly ui: CompatibilityReviewUi,
private readonly currentVersion: number = VER
) {}
get pendingPause(): CompatibilityPause | undefined {
return this.pause;
}
private readAcknowledgedVersion(): string | null {
const setting = this.core.services.setting;
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
if (currentMarker) return currentMarker;
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
const legacyMarker = setting.getDeviceLocalConfig(legacyKey);
if (!legacyMarker) return null;
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
setting.deleteDeviceLocalConfig(legacyKey);
return legacyMarker;
}
async initialise(): Promise<boolean> {
if (this.disposed) return true;
const setting = this.core.services.setting;
const settings = setting.currentSettings();
const acknowledgedVersion = this.readAcknowledgedVersion();
const evaluation = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: this.currentVersion,
migrationState: setting.getSettingsMigrationState(),
legacyReviewMessage: settings.versionUpFlash,
});
this.pause = evaluation.pause;
if (evaluation.initialiseAcknowledgedVersion) {
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
return true;
}
if (!this.pause) return true;
if (settings.versionUpFlash === "") {
settings.versionUpFlash = COMPATIBILITY_PAUSE_SETTING_MESSAGE;
await setting.saveSettingData();
}
return true;
}
private async acknowledge(): Promise<void> {
if (!this.pause?.resumable) return;
const setting = this.core.services.setting;
const settings = setting.currentSettings();
const previousMessage = settings.versionUpFlash;
settings.versionUpFlash = "";
try {
await setting.saveSettingData();
} catch (error) {
settings.versionUpFlash = previousMessage || COMPATIBILITY_PAUSE_SETTING_MESSAGE;
throw error;
}
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
setting.deleteDeviceLocalConfig(legacyKey);
this.pause = undefined;
this.ui.clearReminder();
await this.core.services.control.applySettings();
}
private async runReview(): Promise<void> {
while (this.pause) {
const action = await this.ui.showSummary(this.pause);
if (action === "details") {
const detailsAction = await this.ui.showDetails(this.pause);
if (detailsAction === "back") continue;
break;
}
if (action === "resume" && this.pause.resumable) {
await this.acknowledge();
return;
}
break;
}
if (this.pause && !this.disposed) {
this.ui.showReminder(() => {
fireAndForget(() => this.openReview());
});
}
}
openReview(): Promise<void> {
if (this.disposed || !this.pause) return Promise.resolve();
if (this.activeReview) return this.activeReview;
this.ui.clearReminder();
this.activeReview = this.runReview().finally(() => {
this.activeReview = undefined;
});
return this.activeReview;
}
dispose(): void {
this.disposed = true;
this.pause = undefined;
this.ui.clearReminder();
}
}
export function useCompatibilityReview(core: LiveSyncCore, ui: CompatibilityReviewUi): CompatibilityReviewController {
const controller = new CompatibilityReviewController(core, ui);
core.services.appLifecycle.onSettingLoaded.addHandler(() => controller.initialise());
core.services.appLifecycle.onLayoutReady.addHandler(() => {
fireAndForget(() => controller.openReview());
return Promise.resolve(true);
});
core.services.appLifecycle.onUnload.addHandler(() => {
controller.dispose();
return Promise.resolve(true);
});
core.services.API.addCommand({
id: "livesync-review-compatibility-pause",
name: "Review why synchronisation is paused",
checkCallback: (checking) => {
if (!controller.pendingPause) return false;
if (!checking) fireAndForget(() => controller.openReview());
return true;
},
});
return controller;
}
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
DATABASE_COMPATIBILITY_VERSION_KEY,
legacyDatabaseCompatibilityVersionKey,
} from "@/common/databaseCompatibility.ts";
import { CompatibilityReviewController, type CompatibilityReviewUi } from "./compatibilityReview.ts";
function migrationState(overrides: Record<string, unknown> = {}) {
return {
sourceVersion: 2,
targetVersion: 2,
isNewVault: false,
isFromFutureSchema: false,
changed: false,
requiresSyncReview: false,
reviewReasons: [],
...overrides,
};
}
function createFixture(
options: {
marker?: string | null;
legacyMarker?: string | null;
versionUpFlash?: string;
migration?: Record<string, unknown>;
} = {}
) {
const local = new Map<string, string>();
if (options.marker !== undefined && options.marker !== null) {
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
}
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
local.set(legacyKey, options.legacyMarker);
}
const settings = { versionUpFlash: options.versionUpFlash ?? "" };
const saveSettingData = vi.fn().mockResolvedValue(undefined);
const applySettings = vi.fn().mockResolvedValue(true);
const setting = {
currentSettings: () => settings,
getSettingsMigrationState: () => migrationState(options.migration),
getSmallConfig: (key: string) => local.get(key) ?? "",
setSmallConfig: (key: string, value: string) => local.set(key, value),
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
deleteDeviceLocalConfig: (key: string) => local.delete(key),
saveSettingData,
};
const core = {
services: {
setting,
vault: { getVaultName: () => "Test Vault" },
control: { applySettings },
},
} as never;
const ui: CompatibilityReviewUi = {
showSummary: vi.fn().mockResolvedValue("keep-paused"),
showDetails: vi.fn().mockResolvedValue(false),
showReminder: vi.fn(),
clearReminder: vi.fn(),
};
const controller = new CompatibilityReviewController(core, ui, 12);
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings };
}
describe("compatibility review controller", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("initialises the acknowledged version for a new Vault without showing a pause", async () => {
const fixture = createFixture({ marker: null, migration: { isNewVault: true } });
await expect(fixture.controller.initialise()).resolves.toBe(true);
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.saveSettingData).not.toHaveBeenCalled();
});
it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.saveSettingData).toHaveBeenCalledTimes(1);
await fixture.controller.openReview();
expect(fixture.settings.versionUpFlash).toBe("");
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.saveSettingData).toHaveBeenCalledTimes(2);
expect(fixture.applySettings).toHaveBeenCalledOnce();
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.ui.clearReminder).toHaveBeenCalled();
});
it("does not allow a downgrade pause to be resumed", async () => {
const fixture = createFixture({ marker: "13" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.controller.pendingPause?.resumable).toBe(false);
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("13");
expect(fixture.applySettings).not.toHaveBeenCalled();
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
});
it("returns from details to the reason dialogue and leaves a persistent reminder", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValueOnce("details").mockResolvedValueOnce("keep-paused");
vi.mocked(fixture.ui.showDetails).mockResolvedValue("back");
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.ui.showSummary).toHaveBeenCalledTimes(2);
expect(fixture.ui.showDetails).toHaveBeenCalledOnce();
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
});
it("migrates the old Vault-scoped marker to Commonlib device-local storage", async () => {
const fixture = createFixture({ legacyMarker: "11" });
await fixture.controller.initialise();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.local.has(fixture.legacyKey)).toBe(false);
expect(fixture.controller.pendingPause).toBeDefined();
});
it("restores the runtime gate if persisting an acknowledgement fails", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
fixture.saveSettingData.mockRejectedValueOnce(new Error("save failed"));
await expect(fixture.controller.openReview()).rejects.toThrow("save failed");
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.applySettings).not.toHaveBeenCalled();
});
it("does not open a delayed review after the controller has been disposed", async () => {
const fixture = createFixture({ marker: "11" });
await fixture.controller.initialise();
fixture.controller.dispose();
await fixture.controller.openReview();
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.ui.showSummary).not.toHaveBeenCalled();
expect(fixture.ui.clearReminder).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,131 @@
import { Notice } from "@/deps.ts";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts";
import type {
CompatibilityReviewDetailsAction,
CompatibilityReviewSummaryAction,
CompatibilityReviewUi,
} from "./compatibilityReview.ts";
const REVIEW_DETAILS = "Review compatibility details";
const KEEP_PAUSED = "Keep synchronisation paused";
const RESUME = "Resume synchronisation";
const BACK = "Back to compatibility review";
function summaryMarkdown(pause: CompatibilityPause): string {
const action = pause.resumable
? "Before resuming, review the compatibility details and update Self-hosted LiveSync on every device which uses this remote database."
: "This installation cannot safely acknowledge the detected state. Update Self-hosted LiveSync before attempting to synchronise again.";
return `Remote synchronisation is paused on this device because its compatibility state requires attention.
${action}
Your automatic synchronisation preferences have not been changed. Closing this dialogue keeps synchronisation paused.`;
}
function reasonMarkdown(reason: CompatibilityPauseReason): string {
if (reason.source === "database-version") {
if (reason.state === "upgrade") {
return `- The last acknowledged internal database version was **${reason.acknowledgedVersion}** and this installation uses **${reason.currentVersion}**.`;
}
if (reason.state === "downgrade") {
return `- This installation uses internal database version **${reason.currentVersion}**, but this device previously acknowledged newer version **${reason.acknowledgedVersion}**. An older installation must not resume synchronisation.`;
}
if (reason.state === "missing") {
return `- No previously acknowledged internal database version was found for this existing Vault. This installation uses version **${reason.currentVersion}**.`;
}
return `- The saved internal database version marker is invalid. This installation uses version **${reason.currentVersion}**.`;
}
if (reason.source === "settings-schema") {
if (reason.isFromFutureSchema) {
return `- The saved settings use schema **${reason.sourceVersion}**, which is newer than schema **${reason.currentVersion}** supported by this installation.`;
}
return `- The settings were migrated from schema **${reason.sourceVersion}** to **${reason.currentVersion}** and require review before synchronisation resumes.`;
}
const escapedMessage = reason.message.replace(/[\\`*_{}[\]()<>#+.!|-]/gu, "\\$&");
return `- An earlier compatibility review remains pending: ${escapedMessage}`;
}
function detailsMarkdown(pause: CompatibilityPause): string {
const resolution = pause.resumable
? "After all devices have been updated, return to the compatibility review summary and explicitly resume synchronisation. The current internal version will only then be recorded as acknowledged."
: "Install a compatible current version of Self-hosted LiveSync. This pause cannot be dismissed by the current installation.";
return `## Why synchronisation is paused
${pause.reasons.map(reasonMarkdown).join("\n")}
## What the pause changes
- Remote replication is blocked before work begins.
- Your saved automatic synchronisation preferences remain unchanged.
- Closing either dialogue leaves the safety gate active.
## What to do next
${resolution}`;
}
export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
private reminder: Notice | undefined;
constructor(private readonly confirm: Confirm) {}
async showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction> {
const buttons = pause.resumable
? ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const)
: ([REVIEW_DETAILS, KEEP_PAUSED] as const);
const result = await this.confirm.confirmWithMessage(
"Synchronisation paused for compatibility review",
summaryMarkdown(pause),
[...buttons],
KEEP_PAUSED,
undefined,
"vertical"
);
if (result === REVIEW_DETAILS) return "details";
if (result === RESUME) return "resume";
if (result === KEEP_PAUSED) return "keep-paused";
return false;
}
async showDetails(pause: CompatibilityPause): Promise<CompatibilityReviewDetailsAction> {
const result = await this.confirm.confirmWithMessage(
"Compatibility review details",
detailsMarkdown(pause),
[BACK],
BACK,
undefined,
"vertical"
);
if (result === BACK) return "back";
return false;
}
showReminder(openReview: () => void): void {
this.clearReminder();
let reminderAnchor: HTMLAnchorElement | undefined;
const fragment = createFragment((documentFragment) => {
documentFragment.createSpan({
text: "Self-hosted LiveSync has paused remote synchronisation for compatibility review. ",
});
documentFragment.createEl("a", { text: "Review why" }, (anchor) => {
reminderAnchor = anchor;
anchor.addEventListener("click", (event) => {
event.preventDefault();
openReview();
});
});
});
this.reminder = new Notice(fragment, 0);
reminderAnchor?.closest<HTMLElement>(".notice")?.classList.add("livesync-compatibility-review-notice");
}
clearReminder(): void {
this.reminder?.hide();
this.reminder = undefined;
}
}
export function createObsidianCompatibilityReviewUi(confirm: Confirm): CompatibilityReviewUi {
return new ObsidianCompatibilityReviewUi(confirm);
}
+57 -23
View File
@@ -138,8 +138,8 @@ div.sls-setting-menu-btn {
/* width: 100%; */
}
.sls-setting-tab:hover~div.sls-setting-menu-btn,
.sls-setting-label.selected .sls-setting-tab:checked~div.sls-setting-menu-btn {
.sls-setting-tab:hover ~ div.sls-setting-menu-btn,
.sls-setting-label.selected .sls-setting-tab:checked ~ div.sls-setting-menu-btn {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
@@ -174,9 +174,16 @@ body {
/* padding: 2px; */
margin: 1px;
border-radius: 4px;
background-image: linear-gradient(-45deg,
var(--sls-col-warn-stripe1) 25%, var(--sls-col-warn-stripe2) 25%, var(--sls-col-warn-stripe2) 50%,
var(--sls-col-warn-stripe1) 50%, var(--sls-col-warn-stripe1) 75%, var(--sls-col-warn-stripe2) 75%, var(--sls-col-warn-stripe2));
background-image: linear-gradient(
-45deg,
var(--sls-col-warn-stripe1) 25%,
var(--sls-col-warn-stripe2) 25%,
var(--sls-col-warn-stripe2) 50%,
var(--sls-col-warn-stripe1) 50%,
var(--sls-col-warn-stripe1) 75%,
var(--sls-col-warn-stripe2) 75%,
var(--sls-col-warn-stripe2)
);
background-size: 30px 30px;
display: flex;
flex-direction: row;
@@ -196,7 +203,6 @@ body {
100% {
background-position: 30px 0;
}
}
.sls-setting-menu-buttons label {
@@ -301,30 +307,35 @@ body {
background-color: rgba(var(--background-modifier-error-rgb), 0.3);
}
.sls-setting-disabled input[type=text],
.sls-setting-disabled input[type=number],
.sls-setting-disabled input[type=password] {
.sls-setting-disabled input[type="text"],
.sls-setting-disabled input[type="number"],
.sls-setting-disabled input[type="password"] {
filter: brightness(80%);
color: var(--text-muted);
}
.sls-setting-hidden {
display: none;
}
.sls-setting-obsolete {
/* background-image: linear-gradient(-45deg,
var(--sls-col-warn-stripe1) 25%, var(--sls-col-warn-stripe2) 25%, var(--sls-col-warn-stripe2) 50%,
var(--sls-col-warn-stripe1) 50%, var(--sls-col-warn-stripe1) 75%, var(--sls-col-warn-stripe2) 75%, var(--sls-col-warn-stripe2)); */
background-image: linear-gradient(-45deg,
transparent 25%, rgba(var(--background-secondary), 0.1) 25%, rgba(var(--background-secondary), 0.1) 50%, transparent 50%, transparent 75%, rgba(var(--background-secondary), 0.1) 75%, rgba(var(--background-secondary), 0.1));
background-image: linear-gradient(
-45deg,
transparent 25%,
rgba(var(--background-secondary), 0.1) 25%,
rgba(var(--background-secondary), 0.1) 50%,
transparent 50%,
transparent 75%,
rgba(var(--background-secondary), 0.1) 75%,
rgba(var(--background-secondary), 0.1)
);
background-size: 60px 60px;
}
.password-input>.setting-item-control>input {
.password-input > .setting-item-control > input {
-webkit-text-security: disc;
}
@@ -378,7 +389,6 @@ span.ls-mark-cr::after {
}
}
.livesync-status {
user-select: none;
pointer-events: none;
@@ -401,13 +411,13 @@ span.ls-mark-cr::after {
font-size: 80%;
}
div.workspace-leaf-content[data-type=bases] .livesync-status {
div.workspace-leaf-content[data-type="bases"] .livesync-status {
top: calc(var(--bases-header-height) + var(--header-height));
padding: 5px;
padding-right: 18px;
}
.is-mobile div.workspace-leaf-content[data-type=bases] .livesync-status {
.is-mobile div.workspace-leaf-content[data-type="bases"] .livesync-status {
top: calc(var(--bases-header-height) + var(--view-header-height));
padding: 6px;
padding-right: 18px;
@@ -422,7 +432,6 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
.livesync-status .livesync-status-loghistory {
text-align: left;
opacity: 0.4;
}
.livesync-status div.livesync-status-messagearea:empty {
@@ -440,7 +449,6 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
margin-left: auto;
}
.menu-setting-poweruser-disabled .sls-setting-poweruser {
display: none;
}
@@ -459,7 +467,7 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
top: 2.5em;
background-color: var(--background-secondary-alt);
border-radius: 10px;
padding: 0.5em 1.0em;
padding: 0.5em 1em;
}
.active-pane .sls-setting-panel-title {
@@ -476,6 +484,34 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
font-size: 0.8em;
}
body.is-mobile .livesync-message-box-container {
box-sizing: border-box;
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
}
body.is-mobile .livesync-message-box-container .modal {
max-height: 100%;
}
body.is-mobile .livesync-message-box-container button {
height: auto;
min-height: var(--input-height);
white-space: normal;
}
body.is-mobile .livesync-compatibility-review-notice {
box-sizing: border-box;
margin-right: calc(64px + var(--safe-area-inset-right, env(safe-area-inset-right, 0px)));
min-width: 0;
width: calc(
100vw - 88px - var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) -
var(--safe-area-inset-right, env(safe-area-inset-right, 0px))
);
}
.sls-qr {
display: flex;
justify-content: center;
@@ -488,7 +524,6 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
/* Diff navigation */
@@ -570,4 +605,3 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
outline-offset: 1px;
border-radius: 2px;
}
+1 -1
View File
@@ -76,7 +76,7 @@ npm run test:e2e:obsidian:local-suite:services
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises two representative Svelte dialogue routes: remote server selection through `SetupManager`, and Setup URI entry through the registered command. The session pre-seeds a configured, inactive plug-in state so that onboarding and migration prompts do not interfere with the dialogues under test. It requires each dialogue title and its principal controls to be visible, captures desktop and mobile screenshots, closes them through their normal user controls, and verifies that the remote-selection promise settles without an error. This covers the host, context, component, and result boundaries moved during the Commonlib package extraction without applying settings or contacting a remote service.
`test:e2e:obsidian:settings-ui` opens Self-hosted LiveSync's settings in a temporary real Obsidian session and selects the Synchronisation Settings pane through its user-facing control. It verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay.
+109
View File
@@ -0,0 +1,109 @@
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertLocatorWithinViewport,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import type { Locator, Page } from "playwright";
import { withObsidianPage } from "./ui.ts";
export const mobileViewport = { width: 390, height: 844 } as const;
export const desktopViewport = { width: 1024, height: 768 } as const;
export const iPhoneSafeArea = { top: 47, right: 0, bottom: 34, left: 0 } as const;
type ObsidianTestApp = {
emulateMobile?: (mobile: boolean) => void;
plugins?: { plugins: Record<string, unknown> };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.setViewportSize(enabled ? mobileViewport : desktopViewport);
await page.evaluate((nextEnabled) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
if (typeof obsidianApp?.emulateMobile !== "function") {
throw new Error("app.emulateMobile is unavailable");
}
obsidianApp.emulateMobile(nextEnabled);
}, enabled);
await page.waitForFunction(
(nextEnabled) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
return (
document.body.classList.contains("is-mobile") === nextEnabled &&
obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined
);
},
enabled,
{ timeout: timeoutMs }
);
await page.evaluate(
(safeArea) => {
for (const edge of ["top", "right", "bottom", "left"] as const) {
const property = `--safe-area-inset-${edge}`;
if (safeArea === null) document.body.style.removeProperty(property);
else document.body.style.setProperty(property, `${safeArea[edge]}px`);
}
},
enabled ? iPhoneSafeArea : null
);
});
}
export async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise<void> {
const dialogue = container.locator(".modal").last();
const closeButton = dialogue.locator(".modal-close-button");
await assertLocatorWithinViewport(page, dialogue, { label });
await assertNoHorizontalOverflow(page, dialogue, { label });
await assertLocatorWithinSafeArea(page, dialogue, {
label,
safeAreaInsets: iPhoneSafeArea,
});
await assertLocatorWithinSafeArea(page, closeButton, {
label: `${label} close button`,
safeAreaInsets: iPhoneSafeArea,
});
await assertLocatorHasMinimumTouchTarget(page, closeButton, {
label: `${label} close button`,
});
const visibleButtons = dialogue.locator("button:visible");
for (let index = 0; index < (await visibleButtons.count()); index++) {
const button = visibleButtons.nth(index);
const buttonLabel = (await button.innerText()).trim() || `button ${index + 1}`;
await assertLocatorWithinViewport(page, button, { label: `${label}: ${buttonLabel}` });
await assertLocatorWithinSafeArea(page, button, {
label: `${label}: ${buttonLabel}`,
safeAreaInsets: iPhoneSafeArea,
});
await assertNoHorizontalOverflow(page, button, { label: `${label}: ${buttonLabel}` });
await assertLocatorHasMinimumTouchTarget(page, button, { label: `${label}: ${buttonLabel}` });
}
}
export async function assertMobileNoticeLayout(
page: Page,
notice: Locator,
label: string,
reservedRightPx = 56
): Promise<void> {
await assertLocatorWithinViewport(page, notice, { label });
await assertNoHorizontalOverflow(page, notice, { label });
await assertLocatorWithinSafeArea(page, notice, {
label,
safeAreaInsets: iPhoneSafeArea,
});
const box = await notice.boundingBox();
if (box === null) {
throw new Error(`${label} did not expose a measurable viewport rectangle.`);
}
const viewportWidth = await page.evaluate(() => window.innerWidth);
const rightEdge = box.x + box.width;
if (rightEdge > viewportWidth - reservedRightPx) {
throw new Error(
`${label} overlaps the reserved close-control column: right edge ${rightEdge}, limit ${viewportWidth - reservedRightPx}.`
);
}
}
+164 -68
View File
@@ -1,20 +1,12 @@
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertLocatorWithinViewport,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import type { Locator, Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const dialogRunStateKey = "__livesyncE2EDialogMount";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000);
const mobileViewport = { width: 390, height: 844 } as const;
const iPhoneSafeArea = { top: 47, right: 0, bottom: 34, left: 0 } as const;
type DialogueMode = "desktop" | "mobile";
@@ -39,66 +31,11 @@ type LiveSyncTestPlugin = {
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
emulateMobile?: (mobile: boolean) => void;
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function setMobileDialogueTestMode(enabled: boolean): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
if (enabled) {
await page.setViewportSize(mobileViewport);
}
await page.evaluate((nextEnabled) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
if (typeof obsidianApp?.emulateMobile !== "function") {
throw new Error("app.emulateMobile is unavailable");
}
obsidianApp.emulateMobile(nextEnabled);
}, enabled);
await page.waitForFunction(
(nextEnabled) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
return (
document.body.classList.contains("is-mobile") === nextEnabled &&
obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined
);
},
enabled,
{ timeout: uiTimeoutMs }
);
await page.evaluate(
(safeArea) => {
for (const edge of ["top", "right", "bottom", "left"] as const) {
const property = `--safe-area-inset-${edge}`;
if (safeArea === null) document.body.style.removeProperty(property);
else document.body.style.setProperty(property, `${safeArea[edge]}px`);
}
},
enabled ? iPhoneSafeArea : null
);
});
}
async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise<void> {
const dialogue = container.locator(".modal").last();
const closeButton = dialogue.locator(".modal-close-button");
await assertLocatorWithinViewport(page, dialogue, { label });
await assertNoHorizontalOverflow(page, dialogue, { label });
await assertLocatorWithinSafeArea(page, dialogue, {
label,
safeAreaInsets: iPhoneSafeArea,
});
await assertLocatorWithinSafeArea(page, closeButton, {
label: `${label} close button`,
safeAreaInsets: iPhoneSafeArea,
});
await assertLocatorHasMinimumTouchTarget(page, closeButton, {
label: `${label} close button`,
});
}
async function openRemoteSelectionDialogue(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate((stateKey) => {
@@ -155,6 +92,96 @@ async function assertDialogueRunCompleted(): Promise<void> {
}
}
async function verifyRemoteSizeNoticeAndDialogue(): Promise<{
compatibilityReview: string;
notice: string;
dialogue: string;
}> {
const compatibilityReviewScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"compatibility-review-dialogue.png",
async (page) => {
const compatibilityReview = page.locator(".modal-container").filter({
has: page
.locator(".modal-title")
.filter({ hasText: "Synchronisation paused for compatibility review" }),
});
await compatibilityReview.waitFor({ state: "visible", timeout: uiTimeoutMs });
const actions = compatibilityReview.locator(".vpk-action-dialog__actions--vertical");
await actions.waitFor({ state: "visible", timeout: uiTimeoutMs });
const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection);
if (flexDirection !== "column") {
throw new Error(`Expected vertically stacked compatibility actions, received ${flexDirection}.`);
}
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const compatibilityReview = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }),
});
await compatibilityReview
.getByRole("button", { name: "Keep synchronisation paused" })
.click({ timeout: uiTimeoutMs });
await compatibilityReview.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const noticeScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"remote-size-startup-notice.png",
async (page) => {
const notice = page.locator(".notice").filter({
hasText: "Remote storage size notifications are not configured.",
});
await notice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await notice.getByRole("link", { name: "Review options" }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const notice = page.locator(".notice").filter({
hasText: "Remote storage size notifications are not configured.",
});
await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs });
await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const dialogueScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"remote-size-review-dialogue.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
for (const action of [
"No, never warn please",
"800MB (Cloudant, fly.io)",
"2GB (Standard)",
"Ask me later",
]) {
await modal.getByRole("button", { name: action }).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }),
});
await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return {
compatibilityReview: compatibilityReviewScreenshot,
notice: noticeScreenshot,
dialogue: dialogueScreenshot,
};
}
async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string> {
await openRemoteSelectionDialogue();
const screenshotPath = await captureObsidianDialogue(
@@ -235,6 +262,67 @@ async function verifySetupUriDialogue(mode: DialogueMode): Promise<string> {
return screenshotPath;
}
async function verifyMobileStartupReviews(): Promise<{ compatibilityReview: string; remoteSizeReview: string }> {
const compatibilityReviewScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"compatibility-review-dialogue-mobile.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page
.locator(".modal-title")
.filter({ hasText: "Synchronisation paused for compatibility review" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.locator(".vpk-action-dialog__actions--vertical").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await assertMobileDialogueLayout(page, modal, "compatibility review dialogue");
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }),
});
await modal.getByRole("button", { name: "Keep synchronisation paused" }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
const compatibilityReminder = page.locator(".livesync-compatibility-review-notice");
await compatibilityReminder.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertMobileNoticeLayout(page, compatibilityReminder, "compatibility review reminder");
const notice = page.locator(".notice").filter({
hasText: "Remote storage size notifications are not configured.",
});
await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs });
await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const remoteSizeReviewScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"remote-size-review-dialogue-mobile.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertMobileDialogueLayout(page, modal, "remote size review dialogue");
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }),
});
await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return {
compatibilityReview: compatibilityReviewScreenshot,
remoteSizeReview: remoteSizeReviewScreenshot,
};
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -252,9 +340,8 @@ async function main(): Promise<void> {
pluginData: {
doctorProcessedVersion: "0.25.27",
isConfigured: true,
lastReadUpdates: Number.MAX_SAFE_INTEGER,
liveSync: false,
notifyThresholdOfRemoteStorageSize: 0,
notifyThresholdOfRemoteStorageSize: -1,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
@@ -265,13 +352,22 @@ async function main(): Promise<void> {
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const remoteSizeScreenshots = await verifyRemoteSizeNoticeAndDialogue();
console.log(
`Compatibility review actions were stacked vertically, and the remote-size startup notice opened an untimed review dialogue successfully. Screenshots: ${remoteSizeScreenshots.compatibilityReview}, ${remoteSizeScreenshots.notice}, ${remoteSizeScreenshots.dialogue}`
);
const remoteScreenshot = await verifyRemoteSelectionDialogue("desktop");
console.log(`Remote selection dialogue mounted and closed successfully. Screenshot: ${remoteScreenshot}`);
const setupUriScreenshot = await verifySetupUriDialogue("desktop");
console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`);
await setMobileDialogueTestMode(true);
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
try {
const mobileStartupScreenshots = await verifyMobileStartupReviews();
console.log(
`Mobile compatibility and remote-size reviews passed viewport, safe-area, touch-target, and vertical-action checks. Screenshots: ${mobileStartupScreenshots.compatibilityReview}, ${mobileStartupScreenshots.remoteSizeReview}`
);
const mobileRemoteScreenshot = await verifyRemoteSelectionDialogue("mobile");
console.log(
`Mobile remote selection dialogue passed viewport, safe-area, touch-target, and close-control checks. Screenshot: ${mobileRemoteScreenshot}`
@@ -281,7 +377,7 @@ async function main(): Promise<void> {
`Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}`
);
} finally {
await setMobileDialogueTestMode(false);
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
}
} finally {
if (session) {
+162 -4
View File
@@ -1,22 +1,171 @@
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes.";
type ObsidianSettingsController = {
open(): void;
openTabById(tabId: string): void;
};
type LiveSyncTestPlugin = {
core: {
services: {
setting: {
currentSettings(): { versionUpFlash: string };
getSmallConfig(key: string): string | null;
};
};
};
};
type ObsidianTestApp = {
setting?: ObsidianSettingsController;
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function verifyCompatibilityReview(): Promise<void> {
const port = obsidianRemoteDebuggingPort();
const summaryScreenshot = await captureObsidianDialogue(port, "compatibility-review-summary.png", async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByText("Your automatic synchronisation preferences have not been changed.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", { name: "Review compatibility details" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", {
name: "Resume synchronisation",
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", { name: "Keep synchronisation paused" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const markerBeforeAcknowledgement = await page.evaluate(() => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
return plugin.core.services.setting.getSmallConfig("database-compatibility-version");
});
if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") {
throw new Error(
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
);
}
});
await setObsidianMobileTestMode(port, true, uiTimeoutMs);
const mobileSummaryScreenshot = await captureObsidianDialogue(
port,
"compatibility-review-summary-mobile.png",
async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertMobileDialogueLayout(page, summary, "compatibility review summary");
}
);
await withObsidianPage(port, async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.getByRole("button", { name: "Review compatibility details" }).click();
});
const detailsScreenshot = await captureObsidianDialogue(
port,
"compatibility-review-details-mobile.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByText("Why synchronisation is paused", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText("Remote replication is blocked before work begins.", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal
.getByRole("button", { name: "Back to compatibility review" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await modal.getByRole("button", { name: "Keep synchronisation paused" }).count()) !== 0) {
throw new Error("The explanatory details dialogue must not make the pause decision.");
}
await assertMobileDialogueLayout(page, modal, "compatibility review details");
}
);
await withObsidianPage(port, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.getByRole("button", { name: "Back to compatibility review" }).click();
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await setObsidianMobileTestMode(port, false, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary
.getByRole("button", {
name: "Resume synchronisation",
})
.click();
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page.waitForFunction(
(expectedVersion) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) return false;
const setting = plugin.core.services.setting;
return (
setting.getSmallConfig("database-compatibility-version") === expectedVersion &&
setting.currentSettings().versionUpFlash === ""
);
},
`${VER}`,
{ timeout: uiTimeoutMs }
);
});
console.log(
`Compatibility review screenshots: ${summaryScreenshot}, ${mobileSummaryScreenshot}, ${detailsScreenshot}`
);
}
async function verifyDeletionSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(() => {
@@ -28,8 +177,16 @@ async function verifyDeletionSettings(): Promise<void> {
const liveSyncSettings = page.locator(".sls-setting");
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Change Log"]').click();
const removedAcknowledgements = liveSyncSettings.getByRole("button", {
name: /I got it and updated|OK, I have read everything/u,
});
if ((await removedAcknowledgements.count()) !== 0) {
throw new Error("The Change Log still contains a compatibility or release-note acknowledgement control.");
}
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
const deletionPanel = liveSyncSettings
.locator("h4.sls-setting-panel-title")
.filter({ hasText: "Deletion Propagation" })
@@ -64,8 +221,8 @@ async function main(): Promise<void> {
pluginData: {
doctorProcessedVersion: "0.25.27",
isConfigured: true,
lastReadUpdates: Number.MAX_SAFE_INTEGER,
liveSync: false,
versionUpFlash: compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0,
syncOnStart: false,
syncOnSave: false,
@@ -77,8 +234,9 @@ async function main(): Promise<void> {
},
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await verifyCompatibilityReview();
await verifyDeletionSettings();
console.log("Deletion settings expose only effective user controls.");
console.log("Compatibility review and deletion settings expose only effective user controls.");
} finally {
if (session) {
await session.app.stop();
+16 -181
View File
@@ -14,18 +14,34 @@ Of course, this reorganisation is also being driven by necessity. The time has c
This will call for your help once again. I would be very grateful for your co-operation as we build a sounder foundation for the project and its future development.
Earlier releases remain available in the [0.25 release history](docs/releases/0.25.md) and the [legacy release history](docs/releases/legacy.md).
## Unreleased
### Improved
- Stacked compatibility-review actions vertically and kept persistent review reminders clear of mobile close controls. Long setup dialogues now keep their action area inside the mobile safe area while their content remains scrollable.
- Replaced remote-size decision prompts shown during startup with long-lived, clickable notices. The detailed choices now open only when requested and no longer select an answer automatically after a timeout.
- Removed the obsolete prompt and automatic bulk chunk pre-send from initial and rebuild uploads. These operations retain the standard two-pass replication used to converge follow-up writes and conflict resolution.
## 1.0.0-rc.0
19th July, 2026
### Improved
- Removed the ineffective 'Use the trash bin' toggle from the settings interface. Remote deletions continue to follow Obsidian's deletion preference, while the legacy setting key remains accepted for compatibility.
- Improved P2P restart and settings-reapplication handling by serialising transport start and stop, keeping event handlers bound to the active replicator, and using one package-owned Trystero transport generation.
- Kept the release history available in the settings while removing automatic unread-version tracking and redirection. Release versions are no longer treated as a data-compatibility signal.
- Internal database and settings compatibility reviews now use a dedicated explanation and an explicit, case-specific resume action. They block replication without rewriting the user's automatic synchronisation choices, and older installations cannot dismiss state created by a newer version.
### Miscellaneous
- Replaced the embedded Commonlib source and generated fallback declarations with a locked compiled package, reducing duplicated release and repository-scanner inputs without changing synchronisation behaviour.
- Moved CLI standard input, prompting, and protocol output behind a host-injected Commonlib contract, and routed adapter diagnostics through the service logging API, without changing command output formats.
- Routed Webapp, WebPeer, and plug-in diagnostics through their application-owned log paths instead of writing directly to the browser console.
- Split the release history into the current 1.x line, the 0.25 line, and a legacy archive while preserving the previous `updates_old.md` link as a compatibility index.
- Prepared release automation for immutable pre-release plug-in tags, optional CLI publication, and supported Obsidian release assets only. Pre-release CLI images no longer advance stable moving tags.
### Testing
@@ -33,184 +49,3 @@ This will call for your help once again. I would be very grateful for your co-op
- Added reusable Context result contracts for Obsidian, CLI, and Webapp compositions, including a real-Obsidian smoke assertion that every service retains the host-provided Context.
- Added Commonlib stream-contract tests and downstream CLI unit and Deno E2E coverage for injected text, binary, prompt, and error channels.
- Added a dependency-ownership regression and verified the package-owned P2P transport through Compose CLI synchronisation, real-Obsidian startup and Context checks, representative desktop and mobile dialogues, and the settings pane.
## 0.25.83
16th July, 2026
Our plug-in continues to improve every day thanks to all of your contributions. We have finally resolved an issue first reported in 2023. Thank you for everything you contribute.
### Fixed
- Fixed the 📲 remote-activity indicator remaining visible after CouchDB requests had completed.
- Fixed missing chunks being reported unavailable while an in-progress on-demand fetch or finite replication could still deliver them. Reads now follow the actual delivery lifecycle and recheck the local database when it finishes.
- Fixed an issue where changing only the letter case of a file name within the same directory could delete it on other devices when 'Handle files as Case-Sensitive' was disabled. Directory case changes remain unsupported (#198, PR #1014; [commonlib PR #68](https://github.com/vrtmrz/livesync-commonlib/pull/68)). Thank you to @metrovoc for the fix!
- Fixed **Resolve All conflicted files by the newer one** displaying a separate success notice for every resolved file and updating its progress notice for nine out of every ten checked files. Successful per-file results are now logged, progress updates every ten files, and errors and non-bulk success notices remain visible (#1016, PR #1017). Thank you to @apple-ouyang for the fix!
### Improved
- Split the status-bar remote activity display into `📲` for a finite remote operation and `🌐N` for the approximate number of tracked CouchDB or Object Storage requests currently in progress.
## 0.25.82
15th July, 2026
Recently, I created a repository called Fancy Kit and have been trying to build some proper infrastructure around it. Does any of this look like cumbersome bureaucracy? No need to worry: you can carry on as usual. Codex is simply tidying up my usual rambling prose, terrible pull requests, and disjointed remarks.
### Fixed
- Refreshed the remote Security Seed before each replication, preventing a client that remained open during a remote database rebuild from uploading data encrypted with the previous seed (#1018, PR #1019). Thank you to @apple-ouyang for the fix!
- The P2P **Start Sync & Close** action now waits for synchronisation to settle before closing the dialogue, avoiding premature release of screen-awake protection while work remains in flight.
### Improved
- On supported mobile and desktop devices, one-shot replication, P2P peer discovery and selection, database rebuild and fetch operations, and remote chunk fetching now keep the screen awake for the duration of these finite remote operations. LiveSync also postpones its normal visibility suspension until the operation finishes, although platform restrictions may still pause or terminate background work.
- The 📲 status-bar indicator now covers finite remote work as well as HTTP requests. It reports broad remote activity, such as P2P peer selection and remote chunk fetching, rather than an exact physical connection count.
### Improved (CLI)
- CLI container images are now published for both AMD64 and ARM64 platforms.
### Testing
- Added a local real-Obsidian compatibility test that writes an encrypted, path-obfuscated note through the LiveSync CLI and verifies that the plug-in materialises the same content.
## 0.25.81
14th July, 2026
I am releasing this update to make the chunk-boundary fix available before the next broader release. Thank you to everyone who helped reproduce, trace, and verify the issue.
### Fixed
- Fixed an issue where a U+FEFF character at a RabinKarp chunk boundary could be lost, changing the reconstructed file and causing repeated size-mismatch conflicts (#1000).
### Improved
- Improved vault scanning and CLI file filtering by reusing compiled ignore patterns, reducing processing overhead for vaults with many files or ignore rules (#1006, #1007, and #1008).
### Improved (CLI and Webapp)
- Rooted storage adapters now reject absolute, drive-qualified, backslash-separated, and traversal paths. They also prevent file writes, appends, and removal from targeting the configured root itself.
- File System Access API storage can now create files below previously missing parent directories, matching the existing Node behaviour.
### Testing
- Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling.
- Added a Compose-based P2P end-to-end smoke test and repeatable network benchmark cases for local performance investigations.
### Miscellaneous
- Split the internal storage adapter contract into focused capability views without changing existing runtime behaviour.
## 0.25.80
7th July, 2026
### Fixed
- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993).
- Behaviour change:
- When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result.
- When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side.
- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994).
- Behaviour change:
- Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text.
- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992).
- Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled.
## 0.25.79
29th June, 2026
### Fixed
- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix!
- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix!
- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix!
- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix!
### Improved
- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement!
- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62).
### Documentation
- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement!
### Miscellaneous
- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules.
## 0.25.78
23rd June, 2026
### Fixed
- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix!
### Improved
- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition').
- It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults.
- Decoupled the physical storage logic to make it easier to add new storage backends in the future.
- Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage.
### Testing
- Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation.
- Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables.
## 0.25.77
19th June, 2026
This update is mostly meaningless for users. But for maintainers, not, I hope. I wonder if I were done well in the start, there would be no hassles. It really was a great opportunity.
Also, this update is a very large one, even if we had a lot of time, and we had CI tests, and mostly only fixing the types. Please let me know if you find any issues!
### Improved
- File deletion now respects the user's deletion preferences (by utilising the `FileManager.trashFile` API) on Obsidian v1.7.2 or newer, regardless of the plug-in's internal trashbin setting.
### Miscellaneous
- Typings of the library are now included
- Many typing errors have been improved.
- Import paths have been normalised to be relative to the root and to the `lib/src` directory, to avoid breaking the boundary between the library and the plug-in.
- Subprojects, such as the CLI and the webapp, are now in the workspace.
## 0.25.76
15th June, 2026
### Fixed
- Now the S3 connection with custom headers works properly (#875).
- Previously, custom headers injected for proxy authentication were incorrectly included in the AWS Signature v4 calculation. This led to a '400 Bad Request' error (such as 'signed header is not present') on strict S3 backends (for example, Garage), or when reverse proxies modified, renamed, or stripped these headers before they reached the storage service.
- No longer connection information of the P2P synchronisation is broken on the specific platform (#956).
## 0.25.75
13th June, 2026
### Fixed
- Fixed an issue where using fast synchronisation caused a TypeError in some environments (#953).
### New features
- Now we can configure to keep replication active in the background on desktop platforms (#939, PR #949). Thank you so much for @migsferro!
### Fixed (CLI, automated)
- Fixed an issue where the mirror command could fail to apply updates when conflict preservation checks prevented overwriting unsynchronised local changes, even when the `force` parameter or `writeDocumentsIfConflicted` setting was enabled.
### Improved
- (CLI) Ported the remaining bash regression tests (`test-daemon-linux.sh`, `test-decoupled-vault-linux.sh`, and `test-remote-commands-linux.sh`) to Deno for cross-platform validation.
### Miscellaneous
- Some dependencies have been updated.
- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies.
Full notes are in
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
+6 -3550
View File
File diff suppressed because it is too large Load Diff
+37 -3
View File
@@ -13,6 +13,7 @@ const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs",
const prepareReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/prepare-release.yml", import.meta.url));
const finaliseReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/finalise-release.yml", import.meta.url));
const releaseWorkflow = fileURLToPath(new URL("../.github/workflows/release.yml", import.meta.url));
const cliDockerWorkflow = fileURLToPath(new URL("../.github/workflows/cli-docker.yml", import.meta.url));
const temporaryDirectories: string[] = [];
afterEach(() => {
@@ -150,7 +151,7 @@ describe("release workflow", () => {
expect(workflow).toContain("Mark this pull request ready and merge it with a merge commit");
});
it("explicitly dispatches publishing workflows after creating tags", () => {
it("dispatches the plug-in workflow and lets the CLI tag trigger its own workflow", () => {
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
expect(workflow).toContain("actions: write");
@@ -158,8 +159,8 @@ describe("release workflow", () => {
expect(workflow).toContain('git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"');
expect(workflow).not.toContain("Tag already exists");
expect(workflow).toContain("gh workflow run release.yml");
expect(workflow).toContain("gh workflow run cli-docker.yml");
expect(workflow).toContain("dry_run=false");
expect(workflow).not.toContain("gh workflow run cli-docker.yml");
expect(workflow).toContain("its tag event starts the container workflow");
});
it("publishes only by explicit dispatch and validates the selected release", () => {
@@ -171,6 +172,29 @@ describe("release workflow", () => {
expect(workflow).toContain('TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")"');
expect(workflow).not.toContain("Get Version");
});
it("supports a pre-release plug-in without creating or publishing a CLI release", () => {
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
expect(workflow).toContain("prerelease:");
expect(workflow).toContain("publish_cli:");
expect(workflow).toContain('--field prerelease="${PRERELEASE}"');
expect(workflow).toContain("--plugin-only");
});
it("does not attach an unsupported release archive", () => {
const workflow = readFileSync(releaseWorkflow, "utf8");
expect(workflow).not.toContain("zip -r");
expect(workflow).not.toContain("${{ github.event.repository.name }}.zip");
});
it("does not promote a pre-release CLI image to stable moving tags", () => {
const workflow = readFileSync(cliDockerWorkflow, "utf8");
expect(workflow).toContain('if [[ "${VERSION}" == *-* ]]; then');
expect(workflow).toContain('TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli"');
});
});
describe("release tags", () => {
@@ -198,6 +222,16 @@ describe("release tags", () => {
);
expect(tags.has("0.25.84")).toBe(false);
});
it("can create only the plug-in tag for a review release", () => {
const head = "a".repeat(40);
const { git, tags } = createTagGit(head);
ensureTags("1.0.0-rc.0", head, git, () => undefined, { pluginOnly: true });
expect(tags.get("1.0.0-rc.0")).toBe(head);
expect(tags.has("1.0.0-rc.0-cli")).toBe(false);
});
});
describe("version bump", () => {
+6 -6
View File
@@ -36,10 +36,10 @@ function resolveTag(tag, runGit) {
return runGit(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`], true);
}
export function ensureTags(version, expectedRevision, runGit = git, log = console.log) {
export function ensureTags(version, expectedRevision, runGit = git, log = console.log, options = {}) {
assertVersion(version);
const expectedCommit = resolveCommit(expectedRevision, runGit);
const tags = [version, `${version}-cli`];
const tags = options.pluginOnly ? [version] : [version, `${version}-cli`];
const existing = tags.map((tag) => ({ tag, commit: resolveTag(tag, runGit) }));
for (const { tag, commit } of existing) {
@@ -60,13 +60,13 @@ export function ensureTags(version, expectedRevision, runGit = git, log = consol
const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
if (isMain) {
const [command, version, expectedRevision] = process.argv.slice(2);
if (command !== "ensure" || !version || !expectedRevision) {
fail("Usage: node utils/release-tags.mjs ensure <version> <expected-commit>");
const [command, version, expectedRevision, mode] = process.argv.slice(2);
if (command !== "ensure" || !version || !expectedRevision || (mode && mode !== "--plugin-only")) {
fail("Usage: node utils/release-tags.mjs ensure <version> <expected-commit> [--plugin-only]");
}
try {
ensureTags(version, expectedRevision);
ensureTags(version, expectedRevision, git, console.log, { pluginOnly: mode === "--plugin-only" });
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
+2 -1
View File
@@ -5,5 +5,6 @@
"1.0.0": "0.9.7",
"0.25.81": "1.7.2",
"0.25.82": "1.7.2",
"0.25.83": "1.7.2"
"0.25.83": "1.7.2",
"1.0.0-rc.0": "1.7.2"
}