Compare commits

..

13 Commits

Author SHA1 Message Date
vorotamoroz b21c3114e5 fix: clarify browser application guidance 2026-07-30 05:53:42 +00:00
vorotamoroz d4994fdd05 Releasing 1.0.1 2026-07-29 15:57:33 +00:00
vorotamoroz 68b6358345 build: use stable browser UI kit 0.1.0 2026-07-29 15:16:19 +00:00
vorotamoroz 29c26c43b7 test: validate packaged Pages application subpaths 2026-07-29 11:13:10 +00:00
vorotamoroz 72197a5df0 fix: use standard onboarding notice placement 2026-07-29 09:12:27 +00:00
vorotamoroz 4723771ee0 refactor: compose browser application runtimes 2026-07-29 09:11:57 +00:00
vorotamoroz b22c1bd777 Merge pull request #1052 from vrtmrz/v1_0_0
Integrate Self-hosted LiveSync 1.0.0 into main
2026-07-27 20:14:33 +09:00
vorotamoroz 8876213127 Merge pull request #1033 from vrtmrz/common-library-package-boundary
Consume Commonlib through its published package boundary
2026-07-27 19:48:56 +09:00
vorotamoroz 61ffdde9b5 Merge pull request #1051 from vrtmrz/1_0_0
Releasing 1.0.0
2026-07-27 19:33:37 +09:00
vorotamoroz f1e382c6ed Check repository tools with community lint 2026-07-27 09:07:20 +00:00
vorotamoroz 2b95766d4f Correct stable release promotion order 2026-07-27 08:28:16 +00:00
vorotamoroz c3f2163204 Release 1.0.0 2026-07-27 08:12:33 +00:00
vorotamoroz d5a40a7e3d release: prepare 1.0.0-rc.1 2026-07-27 03:37:46 +00:00
91 changed files with 4661 additions and 3052 deletions
+40 -1
View File
@@ -7,10 +7,18 @@ on:
- main
paths:
- 'aggregator.html'
- 'src/**'
- 'test/browser-apps/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/deploy-pages.yml'
pull_request:
paths:
- 'aggregator.html'
- 'src/**'
- 'test/browser-apps/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/deploy-pages.yml'
permissions:
@@ -30,6 +38,29 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24.x'
cache: 'npm'
- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.9.2
- name: Install dependencies
run: npm ci
- name: Install Chromium
run: npx playwright@1.62.0 install --with-deps chromium
- name: Validate browser applications
run: npm run test:browser-apps
- name: Validate browser application interoperability
run: npm run test:e2e:browser-apps:interop
- name: Validate aggregator
run: |
test -s aggregator.html
@@ -41,10 +72,18 @@ jobs:
- name: Prepare Pages site
run: |
mkdir -p _site
mkdir -p _site/webapp _site/webpeer
cp aggregator.html _site/aggregator.html
cp -R src/apps/webapp/dist/. _site/webapp/
cp -R src/apps/webpeer/dist/. _site/webpeer/
test -s _site/webapp/index.html
test -s _site/webapp/webapp.html
test -s _site/webpeer/index.html
touch _site/.nojekyll
- name: Validate packaged Pages site
run: npm run test:browser-apps:pages
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
+10 -2
View File
@@ -101,8 +101,15 @@ jobs:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
PRERELEASE: ${{ inputs.prerelease }}
PUBLISH_CLI: ${{ inputs.publish_cli }}
run: |
set -euo pipefail
if [[ "${PUBLISH_CLI}" == "true" ]]; then
gh workflow run cli-docker.yml \
--ref "${VERSION}-cli" \
--field dry_run=false \
--field force=false
fi
gh workflow run release.yml \
--ref "${VERSION}" \
--field tag="${VERSION}" \
@@ -118,7 +125,7 @@ jobs:
{
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."
echo "The CLI tag \`${VERSION}-cli\` was also created, and finalisation explicitly dispatched the CLI container workflow."
else
echo "CLI publication was omitted."
fi
@@ -130,7 +137,8 @@ jobs:
echo "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action."
elif [[ "${PRERELEASE}" == "true" ]]; then
echo "Publish the draft initially as a pre-release without replacing the latest stable release."
echo "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before merging the release pull request."
echo "After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
echo "Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release."
echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate."
else
echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
+4 -3
View File
@@ -1,11 +1,12 @@
import { writeFileSync } from "fs";
import { allMessages } from "../src/common/messages/combinedMessages.dev.ts";
import path from "path";
const { writeFileSync } = process.getBuiltinModule("node:fs");
const path = process.getBuiltinModule("node:path");
const __dirname = import.meta.dirname;
const currentPath = __dirname;
const outDir = path.resolve(currentPath, "../src/common/messages/combinedMessages.prod.ts");
console.log(`Writing to ${outDir}`);
process.stdout.write(`Writing to ${outDir}\n`);
writeFileSync(
outDir,
`export const allMessages: Readonly<Record<string, Readonly<Record<string, string>>>> = ${JSON.stringify(allMessages, null, 4)};`
+13 -8
View File
@@ -1,14 +1,14 @@
import { readFile } from "fs/promises";
import { join, resolve } from "path";
import { glob } from "tinyglobby";
import { parse } from "yaml";
import { objectToDotted } from "./messagelib.ts";
const fsPromises = process.getBuiltinModule("node:fs/promises");
const path = process.getBuiltinModule("node:path");
const __dirname = import.meta.dirname;
const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/"));
const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/"));
const files = (await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir })).sort();
function flattenMessages(src: Record<string, unknown>) {
function flattenMessages(src: unknown) {
return Object.fromEntries(
Object.entries(objectToDotted(src))
.map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value] as const)
@@ -20,9 +20,14 @@ function flattenMessages(src: Record<string, unknown>) {
const localeData = new Map<string, Record<string, string>>();
for (const file of files) {
const segments = file.split(/[/\\]/);
const locale = segments[segments.length - 1]!.replace(/\.yaml$/, "");
const content = await readFile(file, "utf-8");
localeData.set(locale, flattenMessages(parse(content) ?? {}));
const localeFilename = segments[segments.length - 1];
if (localeFilename === undefined) {
throw new Error(`Could not determine the locale name for ${file}`);
}
const locale = localeFilename.replace(/\.yaml$/, "");
const content = await fsPromises.readFile(file, "utf-8");
const parsed: unknown = parse(content);
localeData.set(locale, flattenMessages(parsed ?? {}));
}
const baseLocale = "en";
@@ -55,4 +60,4 @@ const report = Object.fromEntries(
})
);
console.log(JSON.stringify(report, null, 2));
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+2 -3
View File
@@ -1,9 +1,8 @@
import { writeFileSync } from "fs";
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "../src/common/rosetta";
import { allMessages } from "../src/common/messages/combinedMessages.dev.ts";
import path from "path";
const { writeFileSync } = process.getBuiltinModule("node:fs");
const path = process.getBuiltinModule("node:path");
const thisFileDir = __dirname;
const outDir = path.join(thisFileDir, "i18n");
+5 -14
View File
@@ -1,26 +1,17 @@
import { writeFileSync } from "fs";
import { allMessages } from "../src/common/messages/combinedMessages.prod.ts";
const __dirname = import.meta.dirname;
import path from "path";
const { writeFileSync } = process.getBuiltinModule("node:fs");
const path = process.getBuiltinModule("node:path");
const thisFileDir = __dirname;
const outDir = path.resolve(thisFileDir, "../src/common/messagesJson");
const out = {} as Record<string, { [key: string]: string | undefined }>;
for (const [key, value] of Object.entries(allMessages)) {
//@ts-ignore
for (const [lang, langValue] of Object.entries(allMessages[key])) {
for (const [lang, langValue] of Object.entries(value)) {
if (!out[lang]) out[lang] = {};
if (lang in value) {
out[lang][key] = langValue as string;
} else {
if (lang === "def") {
out[lang][key] = key;
} else {
out[lang][key] = undefined;
}
}
out[lang][key] = langValue;
}
}
+12 -13
View File
@@ -1,6 +1,6 @@
import { access, readFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const fsPromises = process.getBuiltinModule("node:fs/promises");
const path = process.getBuiltinModule("node:path");
const url = process.getBuiltinModule("node:url");
type InspectionError = {
check: "current-label" | "local-reference" | "retired-label";
@@ -20,7 +20,7 @@ const messageCataloguePath = "src/common/messagesJson/en.json";
const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu;
function repositoryRootFromThisFile(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
return path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
}
function normaliseReferenceTarget(rawTarget: string): string {
@@ -48,14 +48,14 @@ async function inspectLocalReferences(
const [pathPart] = target.split("#", 1);
if (!pathPart) continue;
checked++;
const referencedPath = resolve(repositoryRoot, dirname(documentPath), pathPart);
const referencedPath = path.resolve(repositoryRoot, path.dirname(documentPath), pathPart);
try {
await access(referencedPath);
await fsPromises.access(referencedPath);
} catch {
errors.push({
check: "local-reference",
file: documentPath,
detail: `Missing local reference: ${relative(repositoryRoot, referencedPath)}`,
detail: `Missing local reference: ${path.relative(repositoryRoot, referencedPath)}`,
});
}
}
@@ -68,14 +68,13 @@ export async function inspectTroubleshootingDocs(
const errors: InspectionError[] = [];
const documents = new Map<string, string>();
for (const guidePath of guidePaths) {
documents.set(guidePath, await readFile(resolve(repositoryRoot, guidePath), "utf8"));
documents.set(guidePath, await fsPromises.readFile(path.resolve(repositoryRoot, guidePath), "utf8"));
}
const troubleshooting = documents.get("docs/troubleshooting.md")!;
const catalogue = JSON.parse(await readFile(resolve(repositoryRoot, messageCataloguePath), "utf8")) as Record<
string,
string
>;
const catalogue = JSON.parse(
await fsPromises.readFile(path.resolve(repositoryRoot, messageCataloguePath), "utf8")
) as Record<string, string>;
const requiredMessageKeys = [
"TweakMismatchResolve.Action.UseConfigured",
"TweakMismatchResolve.Action.UseMine",
@@ -132,7 +131,7 @@ async function runCli(): Promise<void> {
if (!result.ok) process.exitCode = 1;
}
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined;
const invokedPath = process.argv[1] ? url.pathToFileURL(path.resolve(process.argv[1])).href : undefined;
if (invokedPath === import.meta.url) {
await runCli();
}
+14 -10
View File
@@ -1,27 +1,31 @@
// Convert Application convenient Message Resources (JSON) to Human-Editable format (YAML)
import { readFile, writeFile } from "fs/promises";
import { join, resolve } from "path";
import { stringify } from "yaml";
import { glob } from "tinyglobby";
import { dottedToObject } from "./messagelib";
const fsPromises = process.getBuiltinModule("node:fs/promises");
const path = process.getBuiltinModule("node:path");
const __dirname = import.meta.dirname;
const targetDir = resolve(join(__dirname, "../src/common/messagesJson/"));
console.log(`Target directory: ${targetDir}`);
const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesJson/"));
process.stdout.write(`Target directory: ${targetDir}\n`);
const files = await glob(`*.json`, { expandDirectories: false, absolute: true, cwd: targetDir });
for (const file of files) {
const filePath = resolve(file);
console.log(`Processing file: ${filePath}`);
const content = await readFile(filePath, "utf-8");
const jsonDataSrc = JSON.parse(content);
const filePath = path.resolve(file);
process.stdout.write(`Processing file: ${filePath}\n`);
const content = await fsPromises.readFile(filePath, "utf-8");
const jsonDataSrc: unknown = JSON.parse(content);
if (typeof jsonDataSrc !== "object" || jsonDataSrc === null || Array.isArray(jsonDataSrc)) {
throw new TypeError(`Expected ${filePath} to contain a JSON object`);
}
const jsonDataD2 = Object.fromEntries(
Object.entries(jsonDataSrc).sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
);
const jsonData = dottedToObject(jsonDataD2);
const yamlData = stringify(jsonData, { indent: 2 });
const yamlFilePath = filePath.replace(/\.json$/, ".yaml").replace("Json", "YAML");
await writeFile(yamlFilePath, yamlData, "utf-8");
console.log(`Converted ${filePath} to ${yamlFilePath}`);
await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8");
process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`);
}
// console.dir(files, { depth: 0 });
+47 -36
View File
@@ -1,38 +1,49 @@
export function objectToDotted(obj: any, prefix = ""): Record<string, any> {
return Object.entries(obj).reduce(
(acc, [key, value]) => {
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
Object.assign(acc, objectToDotted(value, newKey));
} else {
acc[newKey] = value;
}
return acc;
},
{} as Record<string, any>
);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function dottedToObject(obj: Record<string, any>): Record<string, any> {
return Object.entries(obj).reduce(
(acc, [key, value]) => {
if (key.includes(" ")) {
// Return as is.
return { ...acc, [key]: value }; // Skip keys with spaces
}
const keys = key.split(".");
keys.reduce((nestedAcc, currKey, index) => {
if (currKey in nestedAcc && typeof nestedAcc[currKey] !== "object") {
nestedAcc[currKey] = { _value: nestedAcc[currKey] }; // Convert to object if not already
}
if (index === keys.length - 1) {
nestedAcc[currKey] = value;
} else {
nestedAcc[currKey] = nestedAcc[currKey] || {};
}
return nestedAcc[currKey];
}, acc);
return acc;
},
{} as Record<string, any>
);
export function objectToDotted(obj: unknown, prefix = ""): Record<string, unknown> {
if (!isRecord(obj)) {
throw new TypeError("Expected a message catalogue object");
}
const flattened: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (isRecord(value)) {
Object.assign(flattened, objectToDotted(value, newKey));
} else {
flattened[newKey] = value;
}
}
return flattened;
}
export function dottedToObject(obj: unknown): Record<string, unknown> {
if (!isRecord(obj)) {
throw new TypeError("Expected a dotted message catalogue object");
}
const nestedResult: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (key.includes(" ")) {
nestedResult[key] = value;
continue;
}
const keys = key.split(".");
let nested = nestedResult;
for (const [index, currentKey] of keys.entries()) {
if (index === keys.length - 1) {
nested[currentKey] = value;
continue;
}
const currentValue = nested[currentKey];
if (isRecord(currentValue)) {
nested = currentValue;
continue;
}
const replacement = currentValue === undefined || currentValue === null ? {} : { _value: currentValue };
nested[currentKey] = replacement;
nested = replacement;
}
}
return nestedResult;
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { dottedToObject, objectToDotted } from "./messagelib";
describe("message catalogue conversion", () => {
it("flattens nested objects while preserving leaf values", () => {
expect(
objectToDotted({
dialogue: {
title: "Title",
options: ["first", "second"],
},
"literal key": "Literal",
})
).toEqual({
"dialogue.title": "Title",
"dialogue.options": ["first", "second"],
"literal key": "Literal",
});
});
it("preserves an existing leaf under _value when a dotted child follows it", () => {
expect(
dottedToObject({
section: "Base value",
"section.child": "Child value",
"literal key": "Literal",
})
).toEqual({
section: {
_value: "Base value",
child: "Child value",
},
"literal key": "Literal",
});
});
it("rejects non-object catalogue roots", () => {
expect(() => objectToDotted("not an object")).toThrow("Expected a message catalogue object");
expect(() => dottedToObject(["not", "an", "object"])).toThrow("Expected a dotted message catalogue object");
});
});
+11 -10
View File
@@ -1,29 +1,30 @@
// Convert Human-Editable format (YAML) to Application convenient Message Resources (JSON)
import { readFile, writeFile } from "fs/promises";
import { join, resolve } from "path";
import { parse } from "yaml";
import { glob } from "tinyglobby";
import { objectToDotted } from "./messagelib";
const fsPromises = process.getBuiltinModule("node:fs/promises");
const path = process.getBuiltinModule("node:path");
const __dirname = import.meta.dirname;
const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/"));
console.log(`Target directory: ${targetDir}`);
const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/"));
process.stdout.write(`Target directory: ${targetDir}\n`);
const files = await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir });
for (const file of files) {
const filePath = resolve(file);
const content = await readFile(filePath, "utf-8");
const jsonDataSrc = parse(content);
const filePath = path.resolve(file);
const content = await fsPromises.readFile(filePath, "utf-8");
const jsonDataSrc: unknown = parse(content);
const jsonDataD2 = objectToDotted(jsonDataSrc);
const jsonData = Object.fromEntries(
Object.entries(jsonDataD2)
.map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value])
.map(([key, value]): [string, unknown] => [key.endsWith("._value") ? key.slice(0, -7) : key, value])
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
);
const yamlData = JSON.stringify(jsonData, null, 4) + "\n";
const yamlFilePath = filePath.replace(/\.yaml$/, ".json").replace("YAML", "Json");
await writeFile(yamlFilePath, yamlData, "utf-8");
console.log(`Converted ${filePath} to ${yamlFilePath}`);
await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8");
process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`);
}
// console.dir(files, { depth: 0 });
+13 -8
View File
@@ -38,6 +38,8 @@ npm run buildDev # Development build (one-time)
npm run test:integration # Run CouchDB-backed integration tests
npm run test:setup-tools # Check provisioning and Setup URI package contracts
npm run test:e2e:cli:p2p # Run canonical P2P validation in Compose
npm run test:browser-apps # Build and run the WebApp and WebPeer app-owned Chromium tests
npm run test:e2e:browser-apps:interop # Run WebApp → WebPeer → CLI in Compose
npm run test:e2e:obsidian:local-suite # Run the real Obsidian local suite
```
@@ -66,7 +68,7 @@ To facilitate development and testing, the build process can automatically copy
- If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you are strongly expected to write an integration test to verify the behaviour against a real CouchDB server.
- **Commonlib Tests**: Commonlib owns unit and package tests for shared RPC, storage, replication, and platform contracts. LiveSync CI verifies the exact packed dependency as a downstream consumer.
Regression tests remain beside the implementation which owns their contract. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure.
Regression tests remain in the suite owned by the implementation under test. Plug-in tests may be co-located with their source, while independent application tests remain under `test/apps/` or `test/browser-apps/` so that they stay outside the Community Review source boundary. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate.
@@ -85,9 +87,11 @@ Regression tests remain beside the implementation which owns their contract. Pre
- **Test Structure**:
- `test/e2e-obsidian/` - Real Obsidian E2E scripts for local verification
- `test/apps/webapp/` and `test/apps/webpeer/` - app-owned unit tests outside the Community Review source boundary
- `test/browser-apps/webapp/` and `test/browser-apps/webpeer/` - app-owned Deno and Chromium tests outside the Community Review source boundary
- `test/browser-apps/` - Compose-owned WebApp → WebPeer → CLI P2P interoperability and shared browser-test support
- co-located `*.unit.spec.ts` files - Node-based unit tests
- co-located `*.integration.spec.ts` files - service-backed integration tests
- `src/apps/webapp/obsidianMock.ts` - Webapp-only Obsidian compatibility adapter; it is not an E2E Harness
### Import Path Normalisation
@@ -144,7 +148,7 @@ Hence, the new feature should be implemented as follows:
- **LiveSyncLocalDB** (`@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB`): Local PouchDB database wrapper
- **Replicators** (`@vrtmrz/livesync-commonlib/compat/replication/*`): CouchDB, Journal, and P2P synchronisation engines
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
- **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, Webapp, WebPeer, and external tools
- **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, WebApp, WebPeer, and external tools
Commonlib owns the P2P replicator and Trystero transport lifecycle. Host commands, event handlers, and views must retain the Commonlib service-feature result and resolve its current `replicator` at the point of use. They must not snapshot an instance which can be replaced when settings or the local database change, close Trystero-owned raw peers, or install another Trystero transport generation at the application root.
@@ -246,6 +250,7 @@ export class ModuleExample extends AbstractObsidianModule {
- A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag.
- Keep a hyphenated pre-release's release pull request in draft and unmerged after BRAT validation. Reconcile the published version's metadata into its base branch through a reviewed metadata-only commit, then close the release pull request only through a separate maintainer action.
- Stage a stable version for BRAT by publishing its exact `x.y.z` tag initially as a GitHub pre-release with `prerelease=true` and `publish_cli=false`. The stable manifest version would otherwise make the CLI workflow advance `latest` and the major-minor image tag before validation.
- After a staged stable version passes BRAT validation, merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. Promote the GitHub Release only after the default branch contains the exact stable metadata; publish stable CLI tags through a separate maintainer gate.
- If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version.
## Release Notes
@@ -264,14 +269,14 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
- Run the `Prepare Release PR` workflow with the target version and selected base branch. 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. The base branch may already select the target development version; the workflow still runs the version lifecycle so that release-only metadata such as `versions.json` is recorded in the release commit.
- 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 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.
- 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 explicitly dispatches the selected plug-in and CLI release workflows. 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. When CLI publication is selected, finalisation dispatches the CLI Docker workflow against the reviewed CLI tag instead of relying on a tag created by `GITHUB_TOKEN` to start another workflow.
- For a hyphenated pre-release, run finalisation with `prerelease=true`; CLI publication remains optional. For a stable version awaiting BRAT validation, use `prerelease=true` and `publish_cli=false`.
- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. When a hyphenated pre-release includes the CLI, confirm that it received only its immutable version and SHA-qualified image tags.
- Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request.
- Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release.
- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action.
- After a stable version passes, remove its GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate. Only then mark the stable release pull request ready and merge it into the selected base branch with a merge commit.
- After a stable version passes, mark its release pull request ready and merge the exact release commit into the selected base branch with a merge commit. Integrate that exact commit through the reviewed branch chain into the repository's default branch. Only after the default branch contains the matching stable metadata, remove the GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
- If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show <tag>:updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history.
- Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action.
- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`.
@@ -297,12 +302,12 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
- `prerelease`: enable for a version such as `1.0.0-rc.0`, and also when staging a stable version for BRAT.
- `publish_cli`: optional for a hyphenated pre-release, but disable it when staging a stable version.
5. Approve the `Release Obsidian Plugin` workflow for the `release` environment, then check the generated draft GitHub Release.
6. If a hyphenated pre-release includes the CLI, confirm that the CLI tag event published only immutable version and SHA-qualified image tags.
6. If a hyphenated pre-release includes the CLI, confirm that the explicitly dispatched CLI workflow published only immutable version and SHA-qualified image tags.
7. Publish the draft as a GitHub pre-release without replacing the latest stable release, but keep the release PR in draft and leave its base branch unchanged.
8. Update the PR state message to describe the published pre-release and state that merging remains on hold.
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario.
10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action.
11. After a stable version passes, remove its pre-release designation, make the exact release the latest stable release, publish the stable CLI tags through a separate maintainer gate if selected, then mark the release PR ready and merge it into the selected base branch.
11. After a stable version passes, mark the release PR ready and merge the exact release commit into the selected base branch. Integrate that commit through the reviewed branch chain into the repository's default branch. Once the default branch contains the matching stable metadata, remove the pre-release designation, make the exact release the latest stable release, and publish stable CLI tags through a separate maintainer gate if selected.
12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
## Contribution Guidelines
+161
View File
@@ -0,0 +1,161 @@
# 1.0 preview release history
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](../../updates.md).
The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted.
## 1.0.0-rc.1
27th July, 2026
The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here).
### Important
- This candidate retains the plug-in behaviour prepared for rc.0. The version was advanced because release tags are immutable; rc.0 was stopped during CLI validation before a plug-in release was published.
- This remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. The exact rc.1 plug-in and CLI artefacts will be validated separately after publication.
### CLI and release validation
- CLI release validation now generates Setup URIs through the supported ESM package interface, allowing the Docker test to reach the CLI container instead of stopping during test preparation.
- The CLI Docker image now assigns its entrypoint permissions explicitly, so non-root execution does not depend on permissions inherited from the source checkout.
- Release finalisation now explicitly dispatches the CLI container workflow when CLI publication is selected, rather than relying on a workflow-created tag to start another workflow.
- Focused regression tests guard the ESM execution mode and deterministic container entrypoint permissions, while the existing release-workflow tests now require explicit CLI dispatch with non-dry-run, immutable-tag inputs.
### Testing
- The native CLI setup, put, cat, list, information, deletion, conflict-resolution, and revision-retrieval scenario completed with the packaged Commonlib dependency.
- The same scenario completed through the rebuilt non-root Docker image.
- The focused CLI and release-workflow unit tests passed after first demonstrating all three regressions against the unmodified implementation.
## 1.0.0-beta.5
26th July, 2026
### Improved
- **Inspect conflicts and file/database differences** now compares the current Vault file with the database winner and every live conflict revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain.
- Each reported file and live revision now has a compact wrench menu. Its available actions can compare readable text, apply the selected revision to the Vault, record an exact byte match, store the Vault content as a child of the selected branch, retry retrieving missing chunks without changing the revision tree, or discard only the selected live branch after confirmation.
### Fixed
- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent.
### Testing
- Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions.
## 1.0.0-beta.4
25th July, 2026
### Improved
- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation.
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings****Setup**.
- Text in setup and review dialogues can now be selected for copying or translation.
- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue.
### Fixed
- An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device.
- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message.
### Testing
- Added regressions for revision repair, command availability, selectable dialogues, conflict-aware chunk reachability, device-progress safeguards, and compaction timeouts.
- Added a real CouchDB integration test for logical chunk deletion, shared and conflict chunk retention, compaction completion, downstream replication, and content-addressed chunk recreation.
- Added a real Obsidian encrypted reconnect scenario which replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation refreshes it without restoring the old value, and proves a bidirectional encrypted round-trip.
## 1.0.0-beta.3
24th July, 2026
### Improved
- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices.
- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits.
- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`.
- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation.
- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review.
### Fixed
- Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings.
- Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart.
### Testing
- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues.
## 1.0.0-beta.2
23rd July, 2026
### Improved
- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved.
### Fixed
- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred.
### Testing
- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages.
## 1.0.0-beta.1
22nd July, 2026
### Important
- This corrected opt-in integration preview follows `1.0.0-beta.0` and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault.
### Fixed
- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers.
- Edits, deletions, and renames made while a file is conflicted now extend the exact revision displayed on that device. If LiveSync cannot prove the displayed branch, it preserves the affected branches for review instead of silently applying the operation to the database winner.
### Testing
- Added revision-tree regressions and focused real-Obsidian scenarios for propagated resolutions and file operations performed while a conflict remains active.
## 1.0.0-beta.0
22nd July, 2026
### Important
- This is an opt-in 1.0 integration preview for BRAT and testing with existing Vaults. It does not replace the latest stable release. Use it with a current backup, and update every participating device before resuming synchronisation.
- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. The review preserves the existing automatic synchronisation choices and resumes them only after the decision has been saved successfully.
### Improved
- An unconfigured installation now waits for you to start setup. A long-lived Notice offers the setup action, and **Open onboarding wizard** remains available from the command palette instead of the dialogue opening automatically.
- The setup wizard now creates named remote profiles for CouchDB, Object Storage, and P2P. Current Setup URIs preserve their profile names and selections, and the wizard reserves Rebuild or Fetch before the ordinary start-up scan begins.
- Peer-to-Peer Synchronisation (P2P) and Hidden File Sync are supported opt-in features. JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews. Customisation Sync remains a supported advanced workflow.
- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the Data Compression specification.
- Compatibility review now runs before Config Doctor without overlapping it. Existing Vaults retain their automatic synchronisation choices and explicit file-name case setting. For installations created by earlier releases, LiveSync preserves whether setup had been completed and saves a missing legacy case setting as case-insensitive.
- P2P connections now restart reliably after settings are reapplied or the local database is reset. Setup on an additional device asks you to select the source device once. Disconnecting leaves the LiveSync room and closes its signalling relay connections so that reconnecting can establish a new room.
- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in issue #555.
- Warnings about estimated remote storage size now appear as long-lived clickable Notices instead of timed dialogues. Initial uploads and Rebuild operations no longer prompt to send every chunk in advance; ordinary replication completes the transfer.
- Removed the obsolete **Use the trash bin** control and the setting for fixed chunk revisions. Remote deletion still follows Obsidian's preference, and chunk revisions remain content-derived. The Change Log remains available but no longer opens automatically or tracks unread versions.
### Fixed
- The optional Custom HTTP Handler used by Object Storage now sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
- When selectors, ignore files, size limits, modification-time limits, or file-name case settings are broadened, LiveSync now rechecks previously received files without requiring another remote update.
- P2P setup on the first device no longer displays reset or upload steps for a central database, and Config Doctor now offers its chunk size recommendation for CouchDB only when a CouchDB remote profile is selected.
### Security
- Fly.io setup now generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates prevent excessive CPU use from specially crafted path patterns and `mailto:` links. The CLI rejects path traversal and symbolic-link components detected before Vault operations.
### Miscellaneous
- Self-hosted LiveSync now owns its translation catalogue. Commonlib provides English messages to other applications, and translation contributions can be made directly to the Self-hosted LiveSync repository.
### Testing
- Expanded automated testing in Obsidian for upgrades, synchronisation between two devices, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, and clean-up after failures.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.0-rc.0",
"version": "1.0.1",
"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",
+24 -13
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.0-rc.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.0-rc.0",
"version": "1.0.1",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -22,8 +22,10 @@
"@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.0",
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
"idb": "^8.0.3",
@@ -4763,6 +4765,15 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vrtmrz/browser-ui-kit": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@vrtmrz/browser-ui-kit/-/browser-ui-kit-0.1.0.tgz",
"integrity": "sha512-UlMPRZS3lLFcuEKBaAPcSkIY0we3+kj32GHLcOAfxmooy8XaJWh9/VP5MCC+lo06GrZ6graE3vwmAiFsAHwgng==",
"license": "MIT",
"dependencies": {
"@vrtmrz/ui-interactions": "0.1.2"
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0.tgz",
@@ -4827,12 +4838,12 @@
}
},
"node_modules/@vrtmrz/obsidian-plugin-kit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.2.tgz",
"integrity": "sha512-LNNV8QCaN6FvRA+of96GiqI8atAbnMKQvLbqpi3nsepEe6tN8SmXO3P7pqBl3axNXEHhQfU5ggj5HAq9GJPgwQ==",
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.3.tgz",
"integrity": "sha512-6fsKdhFZtBv6FXlZHtSmpqwROohFzDmres6q08nr2xYGVeh2ooBGU3zJS94WN/tOjDT+wa/Vr3yE42wmI0pIZA==",
"license": "MIT",
"dependencies": {
"@vrtmrz/ui-interactions": "0.1.1"
"@vrtmrz/ui-interactions": "0.1.2"
},
"peerDependencies": {
"obsidian": ">=1.8.7"
@@ -4853,9 +4864,9 @@
}
},
"node_modules/@vrtmrz/ui-interactions": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@vrtmrz/ui-interactions/-/ui-interactions-0.1.1.tgz",
"integrity": "sha512-XpO5fQzC7jyOW3xVF7YtFHI7X1BPQ7hJW56uw8atx8mDR7C9ejG2YSn0XcZ1H5FOCPgJbmkh/FLQRKLqSK8jkw==",
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@vrtmrz/ui-interactions/-/ui-interactions-0.1.2.tgz",
"integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==",
"license": "MIT"
},
"node_modules/@wdio/config": {
@@ -15913,7 +15924,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.0-rc.0-cli",
"version": "1.0.1-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -15938,7 +15949,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.0-rc.0-webapp",
"version": "1.0.1-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
@@ -15950,7 +15961,7 @@
}
},
"src/apps/webpeer": {
"version": "1.0.0-rc.0-webpeer",
"version": "1.0.1-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
+12 -3
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.0-rc.0",
"version": "1.0.1",
"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",
@@ -12,6 +12,7 @@
"buildDev": "node esbuild.config.mjs dev",
"lint": "eslint --cache --cache-strategy content --concurrency off src",
"lint:community": "eslint --config eslint.community.config.mjs --concurrency off src",
"lint:community:tools": "eslint --config eslint.community.config.mjs --concurrency off --max-warnings 0 _tools",
"svelte-check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
"tsc-check": "tsc --noEmit",
"tsc-check:apps": "tsc --noEmit -p src/apps/browser/tsconfig.json && tsc --noEmit -p src/apps/cli/tsconfig.json && tsc --noEmit -p src/apps/webapp/tsconfig.json && tsc --noEmit -p src/apps/webpeer/tsconfig.app.json && tsc --noEmit -p src/apps/webpeer/tsconfig.node.json",
@@ -22,13 +23,19 @@
"prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
"precheck:compatibility": "npm run build",
"check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15",
"check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run svelte-check && npm run check:compatibility",
"check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run lint:community:tools && npm run svelte-check && npm run check:compatibility",
"i18n:bake": "npm run i18n:yaml2json && npm run i18n:bakejson && npm run i18n:format",
"i18n:bakejson": "tsx _tools/bakei18n.ts",
"i18n:format": "prettier --config .prettierrc.mjs --write --log-level error 'src/common/messagesJson/*.json' 'src/common/messages/*.ts'",
"i18n:json2yaml": "tsx _tools/json2yaml.ts",
"i18n:yaml2json": "tsx _tools/yaml2json.ts",
"test:unit": "vitest run --config vitest.config.unit.ts",
"build:browser-apps": "npm run build --workspace livesync-webapp --workspace webpeer",
"test:browser-apps": "npm run test:browser --workspace livesync-webapp && npm run test:browser --workspace webpeer",
"test:browser-apps:pages": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/pages/browser-smoke.test.ts",
"test:e2e:browser-apps": "npm run test:browser-apps",
"pretest:e2e:browser-apps:interop": "npm run build:browser-apps && npm run build --workspace self-hosted-livesync-cli",
"test:e2e:browser-apps:interop": "deno run -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/run-compose-interop.ts",
"inspect:troubleshooting": "tsx _tools/inspect-troubleshooting-docs.ts",
"test:setup-tools": "bash utils/flyio/setenv.test.sh && deno test -A --config=utils/flyio/deno.jsonc --frozen --lock=utils/flyio/deno.lock utils/livesync-commonlib-version.test.ts utils/couchdb/provision.test.ts utils/setup/generate_setup_uri.test.ts utils/flyio/generate_setupuri.test.ts",
"test:contract:contexts": "vitest run --config vitest.config.unit.ts src/modules/services/ObsidianServiceContext.unit.spec.ts src/apps/cli/services/NodeServiceContext.unit.spec.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts",
@@ -165,8 +172,10 @@
"@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.0",
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
"idb": "^8.0.3",
+33 -130
View File
@@ -1,136 +1,39 @@
import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import { createNativeElement } from "@/apps/browserDom";
import MessageBox from "./ui/MessageBox.svelte";
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, compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
function displayMessageBox<T, U extends string[]>(
message: string,
buttons: U,
title: string,
commit: (ret: U[number]) => T,
actionLayout: ConfirmActionLayout = "vertical"
): Promise<T> {
const el = createNativeElement(_activeDocument, "div");
const p = promiseWithResolvers<T>();
mount(MessageBox, {
target: el,
props: {
message,
buttons: buttons as string[],
title: title,
actionLayout,
commit: (action: U[number]) => {
const ret = commit(action);
p.resolve(ret);
},
},
});
_activeDocument.body.appendChild(el);
void p.promise.finally(() => {
el.remove();
});
return p.promise;
}
function promptForInput(
title: string,
key: string,
placeholder: string,
isPassword?: boolean
): Promise<string | false> {
const el = createNativeElement(_activeDocument, "div");
const p = promiseWithResolvers<string | false>();
mount(TextInputBox, {
target: el,
props: {
title,
message: key,
placeholder,
isPassword,
commit: (text: string | false) => {
p.resolve(text);
},
},
});
_activeDocument.body.appendChild(el);
void p.promise.finally(() => {
el.remove();
});
return p.promise;
}
import { createNativeElement } from "@/apps/browserDom";
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
/**
* Compatibility facade consumed by Commonlib while browser presentation is
* implemented through Fancy Kit's neutral `UiInteractions` contract.
*/
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
readonly context: T;
export class BrowserConfirm<T extends ServiceContext> implements Confirm {
_context: T;
constructor(context: T) {
this._context = context;
}
askYesNo(message: string): Promise<"yes" | "no"> {
return displayMessageBox(message, ["Yes", "No"] as const, "Confirm", (action) =>
action == "Yes" ? "yes" : "no"
);
}
askString(title: string, key: string, placeholder: string, isPassword?: boolean): Promise<string | false> {
return promptForInput(title, key, placeholder, isPassword);
}
askYesNoDialog(
message: string,
opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number }
): Promise<"yes" | "no"> {
return displayMessageBox(message, ["Yes", "No"] as const, opt.title ?? "Confirm", (action) =>
action == "Yes" ? "yes" : "no"
);
}
askSelectString(message: string, items: string[]): Promise<string> {
return displayMessageBox(message, [...items] as const, "Confirm", (action) => action);
}
askSelectStringDialogue<T extends readonly string[]>(
message: string,
buttons: T,
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
return displayMessageBox(message, [...buttons] as const, opt.title ?? "Confirm", (action) => action);
}
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 = createNativeElement(_activeDocument, "div");
notice.className = "livesync-browser-notice";
notice.dataset.livesyncPopup = key;
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
notice.append(beforeText);
const anchor = createNativeElement(_activeDocument, "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,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
return displayMessageBox(
contentMd,
[...buttons] as const,
title ?? "Confirm",
(action) => action,
actionLayout
);
const dialogueController = new AbortController();
const notifications = new BrowserUiNotifications({
document: _activeDocument,
});
super({
ui: createBrowserUi({
document: _activeDocument,
signal: dialogueController.signal,
renderMarkdown: ({ container, markdown }) => {
renderMessageMarkdownInto(container, markdown);
},
}),
notifications,
createActionAnchor: () => createNativeElement(_activeDocument, "a"),
});
this.context = context;
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
dialogueController.abort();
notifications.dispose();
});
}
}
+122
View File
@@ -0,0 +1,122 @@
import type {
KeyValueDatabase,
KeyValueDatabaseFactory,
} from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
import { deleteDB, openDB, type IDBPDatabase } from "idb";
import { serialized } from "octagonal-wheels/concurrency/lock";
/** Creates an application-owned IndexedDB key-value factory for browser runtimes. */
export function createBrowserKeyValueDatabaseFactory(): KeyValueDatabaseFactory {
const cache = new Map<string, BrowserKeyValueDatabase>();
return async (databaseKey) =>
await serialized(`OpenBrowserKeyValueDatabase-${databaseKey}`, async () => {
const cached = cache.get(databaseKey);
if (cached && !cached.isDestroyed) {
return cached;
}
if (cached) {
await cached.ensuredDestroyed;
cache.delete(databaseKey);
}
const database = new BrowserKeyValueDatabase(databaseKey);
await database.getIsReady();
cache.set(databaseKey, database);
return database;
});
}
class BrowserKeyValueDatabase implements KeyValueDatabase {
private databasePromise?: Promise<IDBPDatabase<unknown>>;
private destroyed = false;
private destroyedPromise?: Promise<void>;
constructor(private readonly databaseKey: string) {}
get isDestroyed(): boolean {
return this.destroyed;
}
get ensuredDestroyed(): Promise<void> {
return this.destroyedPromise ?? Promise.resolve();
}
async getIsReady(): Promise<boolean> {
await this.ensureDatabase();
return !this.destroyed;
}
private ensureDatabase(): Promise<IDBPDatabase<unknown>> {
if (this.destroyed) {
throw new Error("Database is destroyed");
}
this.databasePromise ??= openDB(this.databaseKey, undefined, {
upgrade: (database) => {
if (!database.objectStoreNames.contains(this.databaseKey)) {
database.createObjectStore(this.databaseKey);
}
},
blocking: () => {
void this.closeDatabase();
},
terminated: () => {
this.databasePromise = undefined;
},
}).catch((error: unknown) => {
this.databasePromise = undefined;
throw error;
});
return this.databasePromise;
}
private get database(): Promise<IDBPDatabase<unknown>> {
return this.ensureDatabase();
}
private async closeDatabase(): Promise<void> {
const databasePromise = this.databasePromise;
this.databasePromise = undefined;
if (databasePromise) {
(await databasePromise).close();
}
}
async get<T>(key: IDBValidKey): Promise<T> {
return (await (await this.database).get(this.databaseKey, key)) as T;
}
async set<T>(key: IDBValidKey, value: T): Promise<IDBValidKey> {
await (await this.database).put(this.databaseKey, value, key);
return key;
}
async del(key: IDBValidKey): Promise<void> {
await (await this.database).delete(this.databaseKey, key);
}
async clear(): Promise<void> {
await (await this.database).clear(this.databaseKey);
}
async keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise<IDBValidKey[]> {
return await (await this.database).getAllKeys(this.databaseKey, query, count);
}
async close(): Promise<void> {
await this.closeDatabase();
}
async destroy(): Promise<void> {
if (this.destroyedPromise) {
await this.destroyedPromise;
return;
}
this.destroyed = true;
this.destroyedPromise = (async () => {
await this.closeDatabase();
await deleteDB(this.databaseKey);
})();
await this.destroyedPromise;
}
}
@@ -0,0 +1,133 @@
<script lang="ts">
import { onMount } from "svelte";
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
interface Props {
host: P2PReplicatorPaneHost;
}
let { host }: Props = $props();
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
const initialSettings = currentSettings();
let savedTurnServers = $state(initialSettings.P2P_turnServers);
let savedTurnUsername = $state(initialSettings.P2P_turnUsername);
let savedTurnCredential = $state(initialSettings.P2P_turnCredential);
let turnServers = $state(initialSettings.P2P_turnServers);
let turnUsername = $state(initialSettings.P2P_turnUsername);
let turnCredential = $state(initialSettings.P2P_turnCredential);
const isTurnServersModified = $derived(turnServers !== savedTurnServers);
const isTurnUsernameModified = $derived(turnUsername !== savedTurnUsername);
const isTurnCredentialModified = $derived(turnCredential !== savedTurnCredential);
const isModified = $derived(
isTurnServersModified || isTurnUsernameModified || isTurnCredentialModified
);
function loadSettings(settings: P2PSyncSetting): void {
savedTurnServers = settings.P2P_turnServers;
savedTurnUsername = settings.P2P_turnUsername;
savedTurnCredential = settings.P2P_turnCredential;
turnServers = savedTurnServers;
turnUsername = savedTurnUsername;
turnCredential = savedTurnCredential;
}
onMount(() =>
host.services.context.events.onEvent("setting-saved", (settings) => {
loadSettings(settings as P2PSyncSetting);
})
);
async function save(): Promise<void> {
await host.services.setting.applyPartial(
{
P2P_turnServers: turnServers,
P2P_turnUsername: turnUsername,
P2P_turnCredential: turnCredential,
},
true
);
loadSettings(currentSettings());
}
function revert(): void {
turnServers = savedTurnServers;
turnUsername = savedTurnUsername;
turnCredential = savedTurnCredential;
}
</script>
<section class="browser-p2p-transport-settings">
<details>
<summary>Optional TURN server settings</summary>
<p>
Configure TURN only when a direct peer-to-peer connection cannot be established.
</p>
<label class:is-dirty={isTurnServersModified}>
<span>TURN Server URLs (comma-separated)</span>
<input
type="text"
placeholder="turn:turn.example.com:3478"
bind:value={turnServers}
autocomplete="off"
spellcheck="false"
autocorrect="off"
/>
</label>
<label class:is-dirty={isTurnUsernameModified}>
<span>TURN Username</span>
<input
type="text"
placeholder="Enter TURN username"
bind:value={turnUsername}
autocomplete="off"
/>
</label>
<label class:is-dirty={isTurnCredentialModified}>
<span>TURN Credential</span>
<input
type="password"
placeholder="Enter TURN credential"
bind:value={turnCredential}
autocomplete="new-password"
/>
</label>
<div class="actions">
<button type="button" class="button mod-cta" disabled={!isModified} onclick={save}>
Save TURN settings
</button>
<button type="button" class="button" disabled={!isModified} onclick={revert}>
Revert TURN settings
</button>
</div>
</details>
</section>
<style>
.browser-p2p-transport-settings {
margin-bottom: 1rem;
}
p {
margin: 0.75rem 0;
}
label {
display: grid;
gap: 0.25rem;
margin: 0.75rem 0;
}
label.is-dirty {
background-color: var(--background-modifier-error);
}
input {
box-sizing: border-box;
width: 100%;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>
+24 -10
View File
@@ -1,15 +1,15 @@
import {
type ComponentHasResult,
SvelteDialogManagerBase,
SvelteDialogMixIn,
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import { createNativeElement } from "@/apps/browserDom";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
export class ShimModal {
export class BrowserModal {
contentEl: HTMLElement;
titleEl: HTMLElement;
modalEl: HTMLElement;
@@ -45,19 +45,14 @@ export class ShimModal {
}
onOpen() {}
onClose() {}
setPlaceholder(p: string) {}
setTitle(t: string) {
this.titleEl.textContent = t;
}
}
const BrowserSvelteDialogBase = SvelteDialogMixIn(ShimModal, DialogHost);
export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceContext> extends BrowserModal {
private readonly session: SvelteDialogSession<T, U, C>;
export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceContext> extends BrowserSvelteDialogBase<
T,
U,
C
> {
constructor(
context: C,
dependents: SvelteDialogManagerDependencies<C>,
@@ -65,7 +60,26 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
initialData?: U
) {
super();
this.initDialog(context, dependents, component, initialData);
this.session = new SvelteDialogSession({
surface: this,
context,
dependencies: dependents,
dialogHost: DialogHost,
component,
initialData,
});
}
override onOpen(): void {
this.session.onOpen();
}
override onClose(): void {
this.session.onClose();
}
waitForClose(): Promise<T | undefined> {
return this.session.waitForClose();
}
}
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
@@ -0,0 +1,110 @@
import type { LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { ICommandCompat } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
import { FetchHttpHandler } from "@smithy/fetch-http-handler";
import { _fetch } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
declare const MANIFEST_VERSION: string | undefined;
declare const PACKAGE_VERSION: string | undefined;
export interface LiveSyncBrowserAPIServiceOptions {
confirm: Confirm;
getSystemVaultName(): string;
appId?: string;
isMobile?: () => boolean;
fetch?: typeof _fetch;
addLog?: (message: unknown, level: LOG_LEVEL, key?: string) => void;
addCommand?: <TCommand extends ICommandCompat>(command: TCommand) => TCommand;
showWindow?: (type: string) => Promise<void>;
registerWindow?: <T>(type: string, factory: (leaf: T) => unknown) => void;
addRibbonIcon?: (icon: string, title: string, callback: (event: MouseEvent) => unknown) => HTMLElement;
registerProtocolHandler?: (action: string, handler: (params: Record<string, string>) => unknown) => void;
addStatusBarItem?: () => HTMLElement | undefined;
}
/** Browser application implementation of Commonlib's injected host API contract. */
export class LiveSyncBrowserAPIService<T extends ServiceContext> extends InjectableAPIService<T> {
private readonly options: LiveSyncBrowserAPIServiceOptions;
constructor(context: T, options: LiveSyncBrowserAPIServiceOptions) {
super(context);
this.options = options;
this.addLog.setHandler((message, level, key) => {
options.addLog?.(message, level, key);
});
}
get confirm(): Confirm {
return this.options.confirm;
}
getCustomFetchHandler(): FetchHttpHandler {
return new FetchHttpHandler();
}
isMobile(): boolean {
return this.options.isMobile?.() ?? false;
}
showWindow(type: string): Promise<void> {
return this.options.showWindow?.(type) ?? Promise.resolve();
}
getAppID(): string {
return this.options.appId ?? this.options.getSystemVaultName();
}
getSystemVaultName(): string {
return this.options.getSystemVaultName();
}
override getPlatform(): string {
return "browser";
}
getAppVersion(): string {
return MANIFEST_VERSION ?? "0.0.0";
}
getPluginVersion(): string {
return PACKAGE_VERSION ?? "0.0.0";
}
addCommand<TCommand extends ICommandCompat>(command: TCommand): TCommand {
return this.options.addCommand?.(command) ?? command;
}
registerWindow<T>(type: string, factory: (leaf: T) => unknown): void {
this.options.registerWindow?.(type, factory);
}
addRibbonIcon(
icon: string,
title: string,
callback: (event: MouseEvent) => unknown
): HTMLElement {
const element = this.options.addRibbonIcon?.(icon, title, callback);
if (!element) {
throw new Error("Ribbon icons are not supported by this browser application");
}
return element;
}
registerProtocolHandler(
action: string,
handler: (params: Record<string, string>) => unknown
): void {
this.options.registerProtocolHandler?.(action, handler);
}
override nativeFetch(request: string | Request, options?: RequestInit): Promise<Response> {
const fetchImplementation = this.options.fetch ?? _fetch;
return fetchImplementation(request, options);
}
addStatusBarItem(): HTMLElement | undefined {
return this.options.addStatusBarItem?.();
}
}
+14 -2
View File
@@ -1,14 +1,26 @@
import type { BrowserServiceHostDependencies } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
API: InjectableAPIService<T>;
appLifecycle: AppLifecycleService<T>;
config: ConfigService<T>;
control: ControlService<T>;
replicator: ReplicatorService<T>;
}
export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIService<T> {
override get dialogToCopy() {
return DialogToCopy;
}
constructor(context: T, dependents: BrowserServiceHostDependencies<T>) {
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>) {
const browserConfirm = dependents.API.confirm;
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(context, {
appLifecycle: dependents.appLifecycle,
+158
View File
@@ -0,0 +1,158 @@
import type {
Confirm,
ConfirmActionLayout,
} from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { UiInteractions, UiNotifications } from "@vrtmrz/ui-interactions";
const DEFAULT_LABELS = {
confirmationTitle: "Confirmation",
selectionTitle: "Select",
yes: "Yes",
no: "No",
} as const;
export interface UiInteractionsConfirmOptions {
ui: UiInteractions;
notifications: UiNotifications;
createActionAnchor: () => HTMLAnchorElement;
}
function timeoutOption(timeoutSeconds?: number): { timeoutMs?: number } {
return timeoutSeconds !== undefined && timeoutSeconds > 0
? { timeoutMs: timeoutSeconds * 1_000 }
: {};
}
/** Adapts Commonlib's legacy confirmation contract to Fancy Kit capabilities. */
export class UiInteractionsConfirm implements Confirm {
readonly notifications: UiNotifications;
constructor(private readonly options: UiInteractionsConfirmOptions) {
this.notifications = options.notifications;
}
async askYesNo(message: string): Promise<"yes" | "no"> {
const result = await this.options.ui.confirmAction(
{
title: DEFAULT_LABELS.confirmationTitle,
message,
actions: ["yes", "no"],
labels: { yes: DEFAULT_LABELS.yes, no: DEFAULT_LABELS.no },
defaultAction: "no",
actionLayout: "vertical",
},
"legacy-confirm.ask-yes-no"
);
return result === "yes" ? "yes" : "no";
}
async askString(
title: string,
key: string,
placeholder: string,
isPassword = false
): Promise<string | false> {
const prompt = isPassword
? this.options.ui.promptPassword.bind(this.options.ui)
: this.options.ui.promptText.bind(this.options.ui);
const result = await prompt(
{
title,
label: key,
placeholder,
},
"legacy-confirm.ask-string"
);
return result ?? false;
}
async askYesNoDialog(
message: string,
opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number } = {}
): Promise<"yes" | "no"> {
const result = await this.options.ui.confirmAction(
{
title: opt.title ?? DEFAULT_LABELS.confirmationTitle,
message,
actions: ["Yes", "No"],
labels: { Yes: DEFAULT_LABELS.yes, No: DEFAULT_LABELS.no },
defaultAction: opt.defaultOption ?? "No",
actionLayout: "vertical",
...timeoutOption(opt.timeout),
},
"legacy-confirm.ask-yes-no-dialog"
);
return result === "Yes" ? "yes" : "no";
}
async askSelectString(message: string, items: string[]): Promise<string> {
const result = await this.options.ui.pickOne(
{
items,
getText: (item) => item,
placeholder: message,
},
"legacy-confirm.ask-select-string"
);
return result ?? "";
}
async askSelectStringDialogue<T extends readonly string[]>(
message: string,
buttons: T,
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
const result = await this.options.ui.confirmAction(
{
title: opt.title ?? DEFAULT_LABELS.selectionTitle,
message,
actions: buttons,
defaultAction: opt.defaultAction,
actionLayout: "vertical",
...timeoutOption(opt.timeout),
},
"legacy-confirm.ask-select-string-dialogue"
);
return result ?? false;
}
askInPopup(
key: string,
dialogText: string,
anchorCallback: (anchor: HTMLAnchorElement) => void,
durationMs?: number
): void {
const anchor = this.options.createActionAnchor();
anchorCallback(anchor);
this.options.notifications.show(key, {
message: dialogText.replace("{HERE}", "").trim(),
action: {
label: anchor.textContent?.trim() || "Open",
onSelect: () => anchor.click(),
},
durationMs,
});
}
async confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
const result = await this.options.ui.confirmAction(
{
title,
message: contentMd,
actions: buttons,
defaultAction,
actionLayout: actionLayout ?? "vertical",
...timeoutOption(timeout),
},
"legacy-confirm.confirm-with-message"
);
return result ?? false;
}
}
@@ -1,37 +1,215 @@
import { BrowserServiceHub, type BrowserServiceHost } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
import { BrowserConfirm } from "./BrowserConfirm";
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableVaultService";
import { setLang, translateLiveSyncMessage } from "@/common/translation";
import { BrowserConfirm } from "./BrowserConfirm";
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
import {
LiveSyncBrowserAPIService,
type LiveSyncBrowserAPIServiceOptions,
} from "./LiveSyncBrowserAPIService";
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
export type LiveSyncBrowserServiceHubOptions<T extends ServiceContext> = {
export interface LiveSyncBrowserSettingsPersistence {
load(): Promise<ObsidianLiveSyncSettings | undefined>;
save(settings: ObsidianLiveSyncSettings): Promise<void>;
}
export interface LiveSyncBrowserRestartPolicy {
schedule(): void;
perform?: () => void;
ask?: (message?: string) => void;
isScheduled?: () => boolean;
}
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
context?: T;
getSystemVaultName?: () => string;
settings?: LiveSyncBrowserSettingsPersistence;
restart?: LiveSyncBrowserRestartPolicy;
openKeyValueDatabase?: KeyValueDatabaseFactory;
};
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
}
function createLiveSyncBrowserHost<T extends ServiceContext>(): BrowserServiceHost<T> {
return {
createAPI(context) {
return new BrowserAPIService(context, {
confirm: new BrowserConfirm(context),
});
},
createUI(context, dependencies) {
return new LiveSyncBrowserUIService(context, dependencies);
},
};
class LiveSyncBrowserAppLifecycleService<
T extends ServiceContext,
> extends InjectableAppLifecycleService<T> {}
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
class LiveSyncBrowserConfigService<T extends ServiceContext> extends ConfigService<T> {
constructor(
context: T,
private readonly setting: ISettingService
) {
super(context);
}
getSmallConfig(key: string): string | null {
return this.setting.getSmallConfig(key);
}
setSmallConfig(key: string, value: string): void {
this.setting.setSmallConfig(key, value);
}
deleteSmallConfig(key: string): void {
this.setting.deleteSmallConfig(key);
}
}
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
const context =
options.context ??
(new ServiceContext({
translate: translateLiveSyncMessage,
}) as T);
const API = new LiveSyncBrowserAPIService(context, {
...options.API,
confirm: new BrowserConfirm(context),
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
});
const conflict = new InjectableConflictService(context);
const fileProcessing = new InjectableFileProcessingService(context);
const setting = new InjectableSettingService(context, {
APIService: API,
onDisplayLanguageChanged: setLang,
});
const settingsPersistence = options.settings;
setting.loadData.setHandler(
settingsPersistence
? () => settingsPersistence.load()
: () => Promise.resolve(undefined)
);
setting.saveData.setHandler(
settingsPersistence
? (settings) => settingsPersistence.save(settings)
: () => Promise.resolve()
);
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
settingService: setting,
});
const restartPolicy = options.restart;
const scheduleRestart = restartPolicy ? () => restartPolicy.schedule() : () => {};
appLifecycle.scheduleRestart.setHandler(scheduleRestart);
appLifecycle.performRestart.setHandler(
restartPolicy?.perform ? () => restartPolicy.perform?.() : scheduleRestart
);
appLifecycle.askRestart.setHandler(
restartPolicy?.ask ? (message) => restartPolicy.ask?.(message) : scheduleRestart
);
appLifecycle.isReloadingScheduled.setHandler(
restartPolicy?.isScheduled ? () => restartPolicy.isScheduled?.() ?? false : () => false
);
const databaseEvents = new InjectableDatabaseEventService(context);
const path = new PathServiceCompat(context, {
settingService: setting,
});
const vault = new InjectableVaultServiceCompat(context, {
settingService: setting,
APIService: API,
});
const database = new LiveSyncBrowserDatabaseService(context, {
pouchDB: PouchDB,
path,
vault,
setting,
API,
});
const config = new LiveSyncBrowserConfigService(context, setting);
const replicator = new InjectableReplicatorService(context, {
settingService: setting,
appLifecycleService: appLifecycle,
databaseEventService: databaseEvents,
});
const remote = new InjectableRemoteService(context, {
pouchDB: PouchDB,
APIService: API,
appLifecycle,
setting,
});
const replication = new InjectableReplicationService(context, {
APIService: API,
appLifecycleService: appLifecycle,
replicatorService: replicator,
settingService: setting,
fileProcessingService: fileProcessing,
databaseService: database,
});
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
openKeyValueDatabase:
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
appLifecycle,
databaseEvents,
vault,
});
const control = new ControlService(context, {
appLifecycleService: appLifecycle,
databaseService: database,
fileProcessingService: fileProcessing,
settingService: setting,
APIService: API,
replicatorService: replicator,
});
const ui = new LiveSyncBrowserUIService(context, {
API,
appLifecycle,
config,
control,
replicator,
});
super(context, {
API,
appLifecycle,
conflict,
config,
control,
database,
databaseEvents,
fileProcessing,
keyValueDB,
path,
remote,
replication,
replicator,
setting,
test: new InjectableTestService(context),
tweakValue: new InjectableTweakValueService(context),
ui,
vault,
});
}
}
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
options: LiveSyncBrowserServiceHubOptions<T> = {}
): BrowserServiceHub<T> {
const context = options.context ?? (new ServiceContext({ translate: translateLiveSyncMessage }) as T);
return new BrowserServiceHub<T>({
...options,
context,
onDisplayLanguageChanged: setLang,
host: createLiveSyncBrowserHost<T>(),
});
): LiveSyncBrowserServiceHub<T> {
return new LiveSyncBrowserServiceHub(options);
}
@@ -1,12 +1,20 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
observeServiceComposition,
observeServiceContext,
SERVICE_CONTEXT_MEMBERS,
} from "../../../test/contracts/serviceContext";
import { createLiveSyncBrowserServiceHub } from "./createLiveSyncBrowserServiceHub";
import {
createLiveSyncBrowserServiceHub,
type LiveSyncBrowserServiceHubOptions,
} from "./createLiveSyncBrowserServiceHub";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("LiveSync browser service context contract", () => {
it("preserves one injected context and its API results throughout the Webapp composition", () => {
@@ -25,4 +33,57 @@ describe("LiveSync browser service context contract", () => {
[]
);
});
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
const deviceLocalSettings = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => deviceLocalSettings.get(key) ?? null,
setItem: (key: string, value: string) => deviceLocalSettings.set(key, value),
removeItem: (key: string) => deviceLocalSettings.delete(key),
clear: () => deviceLocalSettings.clear(),
});
const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
const restartCalls: string[] = [];
const nativeFetch = vi.fn(async () => new Response("ok"));
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
getSystemVaultName: () => "browser-vault",
settings: {
load: async () => ({
...DEFAULT_SETTINGS,
couchDB_DBNAME: "browser-database",
}),
save: async (settings) => {
savedSettings.push(settings);
},
},
restart: {
schedule: () => restartCalls.push("schedule"),
perform: () => restartCalls.push("perform"),
ask: (message) => restartCalls.push(`ask:${message ?? ""}`),
isScheduled: () => true,
},
API: {
fetch: nativeFetch as typeof fetch,
},
};
const hub = createLiveSyncBrowserServiceHub(options);
expect(hub.API.getSystemVaultName()).toBe("browser-vault");
expect(hub.API.getPlatform()).toBe("browser");
const response = await hub.API.nativeFetch("https://example.invalid/");
expect(await response.text()).toBe("ok");
expect(nativeFetch).toHaveBeenCalledWith("https://example.invalid/", undefined);
await hub.setting.loadSettings();
expect(hub.setting.currentSettings().couchDB_DBNAME).toBe("browser-database");
savedSettings.length = 0;
await hub.setting.saveSettingData();
expect(savedSettings).toHaveLength(1);
hub.appLifecycle.scheduleRestart();
hub.appLifecycle.performRestart();
hub.appLifecycle.askRestart("restart now");
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
});
});
-137
View File
@@ -1,137 +0,0 @@
<script lang="ts">
import { renderMessageMarkdown } from "./renderMessageMarkdown";
type Props = {
title: string;
message: string;
buttons: string[];
actionLayout?: ConfirmActionLayout;
commit: (button: string) => void;
};
type ConfirmActionLayout = "auto" | "vertical";
let { title, message, buttons, actionLayout, commit }: Props = $props();
const renderedMessage = $derived(renderMessageMarkdown(message));
function handleEsc(event: KeyboardEvent) {
if (event.key === "Escape") {
commit("");
}
}
</script>
<popup>
<header>{title}</header>
<article><div class="msg">{@html renderedMessage}</div></article>
<div class:vertical={actionLayout === "vertical"} class="buttons">
{#each buttons as button}
<button onclick={() => commit(button)}>{button}</button>
{/each}
</div>
</popup>
<div class="background" onclick={() => commit("")} onkeydown={handleEsc} role="none"></div>
<style>
popup {
z-index: 1000;
position: fixed;
background: rgba(255, 255, 255, 0.8);
max-width: 70vw;
max-height: 80vh;
margin: auto;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-width: 50vw;
min-height: 50vh;
backdrop-filter: blur(5px);
display: flex;
flex-direction: column;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
border: 1px solid var(--background-primary-alt);
justify-content: space-between;
}
popup header {
background: var(--background-primary);
color: var(--text-normal);
padding: 1em;
border-bottom: 1px solid var(--background-primary-alt);
font: size 1.4em;
}
popup article {
padding: 1em;
display: flex;
justify-content: center;
overflow-y: auto;
}
popup article .msg {
width: 100%;
line-height: 1.5;
overflow-wrap: anywhere;
}
popup article .msg :global(:first-child) {
margin-top: 0;
}
popup article .msg :global(:last-child) {
margin-bottom: 0;
}
popup article .msg :global(pre) {
overflow-x: auto;
padding: 0.75em;
border-radius: 4px;
background: var(--background-secondary);
}
popup article .msg :global(code) {
font-family: var(--font-monospace);
}
popup article .msg :global(blockquote) {
margin: 0;
padding-left: 1em;
border-left: 3px solid var(--background-modifier-border);
color: var(--text-muted);
}
popup article .msg :global(ul),
popup article .msg :global(ol) {
padding-left: 1.5em;
}
popup article .msg :global(table) {
width: 100%;
border-collapse: collapse;
}
popup article .msg :global(th),
popup article .msg :global(td) {
padding: 0.4em 0.6em;
border: 1px solid var(--background-modifier-border);
}
popup article .msg :global(a) {
color: var(--text-accent);
}
popup .buttons {
border-top: 1px solid var(--background-primary-alt);
display: flex;
justify-content: center;
align-items: center;
padding: 1em;
}
popup .buttons button {
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;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.125);
}
</style>
-126
View File
@@ -1,126 +0,0 @@
<script lang="ts">
type Props = {
title: string;
message: string;
initialText?: string;
placeholder?: string;
isPassword?: boolean;
commit: (text: string | false) => void;
};
const { title, message, commit, initialText, placeholder, isPassword }: Props = $props();
function initialTextSeed(): string {
return initialText ?? "";
}
let text = $state(initialTextSeed());
const type = $derived(isPassword ? "password" : "text");
function cancel() {
commit(false);
}
function handleKey(event: KeyboardEvent) {
if (event.key === "Escape") {
handleCancel(event);
} else if (event.key === "Enter") {
handleCommit(event);
}
}
function handleCancel(event: KeyboardEvent | MouseEvent) {
cancel();
event.preventDefault();
}
function handleCommit(event: KeyboardEvent | MouseEvent) {
commit(text);
event.preventDefault();
}
let textEl: HTMLInputElement;
$effect(() => {
textEl.focus();
});
</script>
<popup>
<header>{title}</header>
<article>
<div class="msg">{message}</div>
<div class="input">
<input
bind:this={textEl}
{type}
bind:value={text}
{placeholder}
onkeydown={handleKey}
onkeyup={handleKey}
/>
</div>
</article>
<div class="buttons">
<button onclick={handleCommit}>OK</button>
<button onclick={handleCancel}>Cancel</button>
</div>
</popup>
<div class="background" onclick={handleCancel} onkeydown={handleKey} role="none"></div>
<style>
popup {
z-index: 1000;
position: fixed;
background: rgba(255, 255, 255, 0.8);
max-width: 70vw;
max-height: 80vh;
margin: auto;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-width: 50vw;
min-height: 50vh;
backdrop-filter: blur(5px);
display: flex;
flex-direction: column;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
border: 1px solid var(--background-primary-alt);
justify-content: space-between;
}
popup header {
background: var(--background-primary);
color: var(--text-normal);
padding: 1em;
border-bottom: 1px solid var(--background-primary-alt);
font: size 1.4em;
}
popup article {
align-items: center;
padding: 1em;
display: flex;
justify-content: center;
flex-direction: column;
}
popup article .msg {
overflow-y: auto;
white-space: pre-wrap;
}
popup .buttons {
border-top: 1px solid var(--background-primary-alt);
display: flex;
justify-content: center;
align-items: center;
padding: 1em;
}
popup .buttons button {
margin: 0 0.5em;
background-color: var(--background-primary-alt);
}
popup ~ .background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.125);
}
</style>
@@ -19,3 +19,13 @@ markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) =>
export function renderMessageMarkdown(message: string): string {
return markdownRenderer.render(message);
}
export function renderMessageMarkdownInto(container: HTMLElement, message: string): void {
const DOMParserConstructor = container.ownerDocument.defaultView?.DOMParser;
if (!DOMParserConstructor) {
container.textContent = message;
return;
}
const parsed = new DOMParserConstructor().parseFromString(renderMessageMarkdown(message), "text/html");
container.replaceChildren(...Array.from(parsed.body.childNodes));
}
+2 -2
View File
@@ -2,8 +2,8 @@
* Native DOM creation for browser applications which run outside Obsidian.
*
* Obsidian adds creation helpers to its own DOM environment. The standalone
* Webapp and WebPeer hosts do not own those prototype extensions, and the
* Webapp compatibility layer implements them on top of this native boundary.
* WebApp and WebPeer hosts use this native boundary instead of installing
* Obsidian-shaped prototype extensions.
*/
type NativeDocumentCreation = Pick<Document, "createElement" | "createDocumentFragment">;
+3 -3
View File
@@ -101,9 +101,9 @@ COPY --from=runtime-deps /deps/node_modules ./node_modules
# Copy the built CLI bundle from builder stage
COPY --from=builder /build/src/apps/cli/dist ./dist
# Install entrypoint wrapper
COPY src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli
RUN chmod +x /usr/local/bin/livesync-cli
# Install the entrypoint wrapper with a deterministic mode, regardless of
# source checkout permissions.
COPY --chmod=755 src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli
# Mount your vault / local database directory here
VOLUME ["/data"]
+11
View File
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dockerfile = readFileSync(new URL("./Dockerfile", import.meta.url), "utf8");
describe("CLI Docker image", () => {
it("sets a deterministic readable and executable entrypoint mode", () => {
expect(dockerfile).toContain("COPY --chmod=755 src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli");
expect(dockerfile).not.toContain("RUN chmod +x /usr/local/bin/livesync-cli");
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.0-rc.0-cli",
"version": "1.0.1-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const setupPutCatHelper = readFileSync(new URL("./test/test-setup-put-cat-linux.sh", import.meta.url), "utf8");
describe("CLI setup URI E2E helper", () => {
it("evaluates Commonlib package imports as ESM", () => {
expect(setupPutCatHelper).toContain("node --input-type=module -e");
expect(setupPutCatHelper).not.toContain("npx tsx -e");
});
});
@@ -27,7 +27,7 @@ cli_test_init_settings_file "$SETTINGS_FILE"
echo "[INFO] creating setup URI from settings"
SETUP_URI="$(
SETTINGS_FILE="$SETTINGS_FILE" SETUP_PASSPHRASE="$SETUP_PASSPHRASE" npx tsx -e '
SETTINGS_FILE="$SETTINGS_FILE" SETUP_PASSPHRASE="$SETUP_PASSPHRASE" node --input-type=module -e '
import { fs } from "@vrtmrz/livesync-commonlib/node";
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
(async () => {
+75 -161
View File
@@ -1,198 +1,112 @@
# LiveSync WebApp
Browser-based implementation of Self-hosted LiveSync using the FileSystem API.
Note: (I vrtmrz have not tested this so much yet).
# Self-hosted LiveSync WebApp
## Features
WebApp is an experimental proof of concept for running Self-hosted LiveSync against a browser-authorised local Vault. It is not a replacement for the Obsidian plug-in, and it does not provide the plug-in's settings screen, command palette, or setup wizard.
- 🌐 Runs entirely in the browser
- 📁 Uses FileSystem API to access your local vault
- 🔄 Syncs with CouchDB, Object Storage server (compatible with Self-hosted LiveSync plug-in)
- 🚫 No server-side code required!!
- 💾 Settings stored in `.livesync/settings.json` within your vault
- 👁️ Real-time file watching (Chrome 124+ with FileSystemObserver)
## Capabilities
- Runs as a static application in the browser.
- Reads and writes a user-selected Vault through the File System Access API.
- Keeps previously selected directory handles in origin-scoped IndexedDB.
- Loads and saves LiveSync settings in `.livesync/settings.json` inside the selected Vault.
- Uses CouchDB as its primary continuous remote.
- Provides optional P2P controls as a secondary connection.
- Scans the Vault on start-up and when **Scan local files** is selected.
- Watches external file changes automatically when `FileSystemObserver` is available.
The WebApp itself does not require an application server, but synchronisation still requires a compatible remote or P2P peer.
## Requirements
- **FileSystem API support**:
- Chrome/Edge 86+ (required)
- Opera 72+ (required)
- Safari 15.2+ (experimental, limited support)
- Firefox: Not supported yet
WebApp requires:
- **FileSystemObserver support** (optional, for real-time file watching):
- Chrome 124+ (recommended)
- Without this, files are only scanned on startup
- a secure context, such as HTTPS or `localhost`;
- `showDirectoryPicker()` and read-write access to the selected directory;
- IndexedDB for saved directory handles and the local database; and
- a CouchDB server which permits requests from the WebApp origin when CouchDB is used.
## Getting Started
Automated browser coverage uses Chromium. Other browsers, background execution, and remote types other than CouchDB remain experimental. Use feature detection rather than relying on a fixed browser-version list.
### Installation
## Use
```bash
# Install dependencies (ensure you are in repository root directory, not src/apps/cli)
# due to shared dependencies with webapp and main library
npm install
```
Serve the built files over HTTPS, or from `localhost`, then open `webapp.html`.
### Development
1. Select the local Vault root.
2. Create or edit `.livesync/settings.json` in that Vault.
3. Reload WebApp to apply the settings.
From the repository root:
```bash
npm run dev -w livesync-webapp
```
Or from the package directory:
```bash
cd src/apps/webapp
npm run dev
```
This will start a development server at `http://localhost:3000`.
### Build
From the repository root:
```bash
npm run build -w livesync-webapp
```
Or from the package directory:
```bash
cd src/apps/webapp
npm run build
```
The built files will be in the `dist` directory.
### Usage
1. Open the webapp in your browser (`webapp.html`)
2. Select a vault from history or grant access to a new directory
3. Configure CouchDB connection by editing `.livesync/settings.json` in your vault
- You can also copy data.json from Obsidian's plug-in folder.
Example `.livesync/settings.json`:
For example, a minimal manually configured CouchDB connection includes:
```json
{
"couchDB_URI": "https://your-couchdb-server.com",
"couchDB_USER": "your-username",
"couchDB_PASSWORD": "your-password",
"couchDB_DBNAME": "your-database",
"couchDB_URI": "https://couchdb.example.com",
"couchDB_USER": "username",
"couchDB_PASSWORD": "password",
"couchDB_DBNAME": "vault",
"isConfigured": true,
"liveSync": true,
"syncOnSave": true
}
```
After editing, reload the page.
The settings must match the remote database, including any encryption and compatibility settings which are already in use. The file may contain credentials and passphrases, so protect it accordingly. Review individual values instead of copying an Obsidian `data.json` file wholesale.
## Architecture
### Optional P2P
### Directory Structure
```
webapp/
├── adapters/ # FileSystem API adapters
│ ├── FSAPITypes.ts
│ ├── FSAPIPathAdapter.ts
│ ├── FSAPITypeGuardAdapter.ts
│ ├── FSAPIConversionAdapter.ts
│ ├── FSAPIStorageAdapter.ts
│ ├── FSAPIVaultAdapter.ts
│ └── FSAPIFileSystemAdapter.ts
├── managers/ # Event managers
│ ├── FSAPIStorageEventManagerAdapter.ts
│ └── StorageEventManagerFSAPI.ts
├── serviceModules/ # Service implementations
│ ├── FileAccessFSAPI.ts
│ ├── ServiceFileAccessImpl.ts
│ ├── DatabaseFileAccess.ts
│ └── FSAPIServiceModules.ts
├── bootstrap.ts # Vault picker + startup orchestration
├── main.ts # LiveSync core bootstrap (after vault selected)
├── vaultSelector.ts # FileSystem handle history and permission flow
├── webapp.html # Main HTML entry
├── index.html # Redirect entry for compatibility
├── package.json
├── vite.config.ts
└── README.md
```
### Key Components
1. **Adapters**: Implement `IFileSystemAdapter` interface using FileSystem API
2. **Managers**: Handle storage events and file watching
3. **Service Modules**: Integrate with LiveSyncBaseCore
4. **Main**: Application initialisation and lifecycle management
### Service Hub
Uses `BrowserServiceHub` which provides:
- Database service (IndexedDB via PouchDB)
- Settings service (file-based in `.livesync/settings.json`)
- Replication service
- File processing service
- And more...
The visible P2P controls are optional. Saving them does not select P2P as the main remote or mark an otherwise unconfigured Vault as configured. Use **Scan local files** before offering existing Vault content to a peer when automatic file observation is unavailable.
## Limitations
- **Real-time file watching**: Requires Chrome 124+ with FileSystemObserver
- Without it, changes are only detected on manual refresh
- **Performance**: Slower than native file system access
- **Permissions**: Requires user to grant directory access (cached via IndexedDB)
- **Browser support**: Limited to browsers with FileSystem API support
## Differences from CLI Version
- Uses `BrowserServiceHub` instead of `HeadlessServiceHub`
- Uses FileSystem API instead of Node.js `fs`
- Settings stored in `.livesync/settings.json` in vault
- Real-time file watching only with FileSystemObserver (Chrome 124+)
## Differences from Obsidian Plug-in
- No Obsidian-specific modules (UI, settings dialogue, etc.)
- Simplified configuration
- No plug-in/theme sync features
- No internal file handling (`.obsidian` folder)
## Development Notes
- TypeScript configuration: Uses project's tsconfig.json
- Module resolution: Aliased paths via Vite config
- External dependencies: Bundled by Vite
- There is no general settings screen, setup wizard, or command palette.
- Object Storage and other main-remote configurations remain experimental and are not covered by the current WebApp browser tests.
- Without `FileSystemObserver`, external changes are detected only by a start-up or manual scan.
- A browser may suspend the page, discard permissions, or evict origin-scoped storage.
- The File System Access API is not available in every browser.
- Customisation Sync and Obsidian-specific Vault features are unavailable.
## Troubleshooting
### "Failed to get directory access"
### A Vault cannot be opened
- Make sure you're using a supported browser
- Try refreshing the page
- Clear browser cache and IndexedDB
- Confirm that WebApp is served over HTTPS or from `localhost`.
- Grant read-write access when the browser prompts.
- If a saved handle no longer has permission, select **Choose new vault folder** and choose the same directory again.
### "Settings not found"
### Settings are not loaded
- Check that `.livesync/settings.json` exists in your vault directory
- Verify the JSON format is valid
- Create the file manually if needed
- Confirm that `.livesync/settings.json` exists under the selected Vault root.
- Confirm that the file contains one valid JSON object.
- Reload WebApp after editing the file.
- Check the status line and browser console for the first reported error.
### "File watching not working"
### External file changes are not detected
- Make sure you're using Chrome 124 or later
- Check browser console for FileSystemObserver messages
- Try manually triggering sync if automatic watching isn't available
- Select **Scan local files** after making changes outside WebApp.
- Check whether the browser provides `FileSystemObserver`.
- Keep the page open while automatic watching or synchronisation is expected.
### "Sync not working"
### CouchDB does not synchronise
- Verify CouchDB credentials
- Check browser console for errors
- Ensure CouchDB server is accessible (CORS enabled)
- Confirm the URL, credentials, database name, encryption settings, and compatibility settings.
- Confirm that CouchDB permits requests from the WebApp origin.
- Check the browser network inspector and console for the rejected request.
## License
## Development
Same as the main Self-hosted LiveSync project.
From the repository root:
```bash
npm run dev --workspace livesync-webapp
npm run build --workspace livesync-webapp
npm run test:unit --workspace livesync-webapp
npm run test:browser --workspace livesync-webapp
```
The production files are written to `src/apps/webapp/dist/`. App-owned unit tests are stored in `test/apps/webapp/`, outside the Community Review source boundary. The browser test builds the production artefact, selects an OPFS-backed test Vault in Chromium, and verifies start-up and P2P setting isolation.
## Composition
`WebAppRuntime.ts` owns the LiveSync service composition and lifecycle. `main.ts` owns Vault selection and the small browser shell, while `adapters/`, `managers/`, and `serviceModules/` provide the File System Access API boundary.
## Licence
The same licence as the main Self-hosted LiveSync project applies.
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import P2PReplicatorPane from "@/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte";
import BrowserP2PTransportSettings from "@/apps/browser/BrowserP2PTransportSettings.svelte";
import type { WebAppRuntime } from "./WebAppRuntime";
interface Props {
runtime: WebAppRuntime;
}
let { runtime }: Props = $props();
let isScanning = $state(false);
let scanStatus = $state("");
async function scanLocalFiles() {
isScanning = true;
scanStatus = "Scanning local files…";
try {
scanStatus = (await runtime.scanLocalFiles())
? "Local files are ready for synchronisation."
: "The local file scan could not be completed.";
} catch (error) {
scanStatus = `The local file scan failed: ${String(error)}`;
} finally {
isScanning = false;
}
}
</script>
<div class="local-file-actions">
<button type="button" disabled={isScanning} onclick={scanLocalFiles}>Scan local files</button>
<span role="status" aria-live="polite">{scanStatus}</span>
</div>
<BrowserP2PTransportSettings host={runtime.p2pPaneHost} />
<P2PReplicatorPane
host={runtime.p2pPaneHost}
/>
<style>
.local-file-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
</style>
+329
View File
@@ -0,0 +1,329 @@
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { initialiseServiceModulesFSAPI, type FSAPIServiceModules } from "./serviceModules/FSAPIServiceModules";
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type LOG_LEVEL,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
collectFilesOnStorage,
updateToDatabase,
useOfflineScanner,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { useRedFlagFeatures } from "@/serviceFeatures/redFlag";
import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize";
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import {
createLiveSyncBrowserServiceHub,
type LiveSyncBrowserServiceHub,
type LiveSyncBrowserServiceHubOptions,
} from "@/apps/browser/createLiveSyncBrowserServiceHub";
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
const SETTINGS_DIR = ".livesync";
const SETTINGS_FILE = "settings.json";
const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
liveSync: false,
syncOnSave: true,
syncOnStart: false,
savingDelay: 200,
lessInformationInLog: false,
gcDelay: 0,
periodicReplication: false,
periodicReplicationInterval: 60,
isConfigured: false,
// CouchDB settings - user needs to configure these
couchDB_URI: "",
couchDB_USER: "",
couchDB_PASSWORD: "",
couchDB_DBNAME: "",
// Disable features which are not available in the WebApp.
usePluginSync: false,
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
};
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
export interface WebAppRuntimeOptions {
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
scheduleReload?: (delayMilliseconds: number) => void;
}
export class WebAppRuntime {
private readonly rootHandle: FileSystemDirectoryHandle;
private readonly reportStatus: NonNullable<WebAppRuntimeOptions["reportStatus"]>;
private readonly scheduleReload: NonNullable<WebAppRuntimeOptions["scheduleReload"]>;
private core: LiveSyncBaseCore<ServiceContext, never> | null = null;
private serviceHub: LiveSyncBrowserServiceHub<ServiceContext> | null = null;
private platformServiceModules: FSAPIServiceModules | null = null;
private p2p: UseP2PReplicatorResult | null = null;
private paneHost: P2PReplicatorPaneHost | null = null;
private restartScheduled = false;
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) {
this.rootHandle = rootHandle;
this.reportStatus = options.reportStatus ?? (() => {});
this.scheduleReload =
options.scheduleReload ??
((delayMilliseconds) => {
compatGlobal.setTimeout(() => {
compatGlobal.location.reload();
}, delayMilliseconds);
});
}
private addLog(message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void {
this.serviceHub?.API.addLog(message, level, key);
}
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
return {
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
settings: {
save: async (data) => {
try {
await this.saveSettingsToFile(data);
this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
} catch (error) {
this.addLog(`Failed to save settings: ${String(error)}`, LOG_LEVEL_NOTICE, "settings");
}
},
load: async () => {
try {
const data = await this.loadSettingsFromFile();
if (data) {
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
}
} catch {
this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings");
}
return DEFAULT_SETTINGS as ObsidianLiveSyncSettings;
},
},
restart: {
schedule: () => this.scheduleRestart(),
perform: () => this.scheduleRestart(),
ask: () => this.scheduleRestart(),
isScheduled: () => this.restartScheduled,
},
};
}
private scheduleRestart(): void {
if (this.restartScheduled) {
return;
}
this.restartScheduled = true;
void (async () => {
this.addLog("Restart requested", LOG_LEVEL_INFO, "app-lifecycle");
await this.shutdown();
this.scheduleReload(1000);
})();
}
get events(): LiveSyncEventHub {
if (!this.serviceHub) {
throw new Error("The WebApp service hub is not initialised");
}
return this.serviceHub.context.events;
}
get p2pPaneHost(): P2PReplicatorPaneHost {
if (!this.paneHost) {
throw new Error("The WebApp P2P pane host is not initialised");
}
return this.paneHost;
}
async scanLocalFiles(): Promise<boolean> {
const core = this.core;
const fileAccess = this.platformServiceModules?.vaultAccess;
if (!core || !fileAccess) {
throw new Error("The WebApp core is not initialised");
}
fileAccess.fsapiAdapter.clearCache();
await fileAccess.fsapiAdapter.scanDirectory();
const log = (message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void => {
this.addLog(message, level, key);
};
const settings = core.services.setting.currentSettings();
const { storageFileNameMap, storageFileNames } = await collectFilesOnStorage(core, settings, log);
let succeeded = true;
for (const path of storageFileNames) {
try {
await updateToDatabase(core, log, LOG_LEVEL_INFO, storageFileNameMap[path]);
} catch (error) {
succeeded = false;
this.addLog(`Failed to import ${path}: ${String(error)}`, LOG_LEVEL_NOTICE, "scan");
}
}
return succeeded;
}
async start(): Promise<void> {
if (this.core) {
throw new Error("The WebApp runtime has already been started");
}
// Create service context and hub
this.serviceHub = createLiveSyncBrowserServiceHub<ServiceContext>(this.createServiceOptions());
this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise");
this.addLog("Initialising...", LOG_LEVEL_VERBOSE, "initialise");
this.addLog(`Vault directory: ${this.rootHandle.name}`, LOG_LEVEL_VERBOSE, "initialise");
// Create LiveSync core
this.core = new LiveSyncBaseCore<ServiceContext, never>(
this.serviceHub,
(core, serviceHub) => {
const serviceModules = initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub);
this.platformServiceModules = serviceModules;
return serviceModules;
},
() => [],
() => [] as never[], // No add-ons
(core) => {
useOfflineScanner(core);
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useRemoteConfiguration(core);
this.p2p = useP2PReplicatorFeature(core);
this.paneHost = {
services: core.services,
p2p: this.p2p,
};
}
);
try {
await this.startCore();
} catch (error) {
try {
await this.shutdown();
} catch (shutdownError) {
this.addLog(`Failed to clean up after start failure: ${String(shutdownError)}`, LOG_LEVEL_NOTICE);
}
throw error;
}
}
private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise<void> {
// Create .livesync directory if it does not exist
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true });
// Create/overwrite settings.json
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
}
private async loadSettingsFromFile(): Promise<Partial<ObsidianLiveSyncSettings> | null> {
try {
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR);
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE);
const file = await fileHandle.getFile();
const text = await file.text();
const parsed: unknown = JSON.parse(text);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("The WebApp settings file does not contain an object");
}
return parsed;
} catch {
// The file does not exist yet.
return null;
}
}
private async startCore(): Promise<void> {
if (!this.core) {
throw new Error("Core not initialised");
}
try {
this.addLog("Initialising LiveSync...", LOG_LEVEL_INFO, "start");
const loadResult = await this.core.services.control.onLoad();
if (!loadResult) {
this.addLog("Failed to initialise LiveSync", LOG_LEVEL_NOTICE, "start");
throw new Error("Failed to initialise LiveSync");
}
await this.core.services.control.onReady();
this.addLog("LiveSync is running", LOG_LEVEL_INFO, "ready");
// Check if configured
const settings = this.core.services.setting.currentSettings();
if (!settings.isConfigured) {
this.addLog("LiveSync is not configured yet", LOG_LEVEL_NOTICE, "configuration");
this.showWarning("Please configure CouchDB connection in settings");
} else {
this.addLog("LiveSync is configured and ready", LOG_LEVEL_INFO, "configuration");
this.addLog(`Database: ${settings.couchDB_DBNAME}`, LOG_LEVEL_VERBOSE, "configuration");
this.showSuccess("LiveSync is ready!");
}
// Scan the directory to populate file cache
const fileAccess = this.platformServiceModules?.vaultAccess;
if (fileAccess) {
this.addLog("Scanning vault directory...", LOG_LEVEL_VERBOSE, "scan");
await fileAccess.fsapiAdapter.scanDirectory();
const files = await fileAccess.fsapiAdapter.getFiles();
this.addLog(`Found ${files.length} files`, LOG_LEVEL_VERBOSE, "scan");
}
} catch (error) {
this.addLog(`Failed to start: ${String(error)}`, LOG_LEVEL_NOTICE, "start");
this.showError(`Failed to start: ${String(error)}`);
throw error;
}
}
async shutdown(): Promise<void> {
const core = this.core;
if (!core) {
return;
}
this.core = null;
this.paneHost = null;
this.addLog("Shutting down...", LOG_LEVEL_INFO, "shutdown");
const storageEventManager = this.platformServiceModules?.storageEventManager;
this.platformServiceModules = null;
try {
if (storageEventManager) {
await storageEventManager.cleanup();
}
} finally {
await core.services.control.onUnload();
this.p2p = null;
this.addLog("Shutdown complete", LOG_LEVEL_INFO, "shutdown");
this.serviceHub = null;
}
}
private showError(message: string): void {
this.reportStatus("error", `Error: ${message}`);
}
private showWarning(message: string): void {
this.reportStatus("warning", `Warning: ${message}`);
}
private showSuccess(message: string): void {
this.reportStatus("success", message);
}
}
-144
View File
@@ -1,144 +0,0 @@
import { LiveSyncWebApp } from "./main";
import { createNativeElement } from "@/apps/browserDom";
import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector";
import { compatGlobal, _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
const historyStore = new VaultHistoryStore();
let app: LiveSyncWebApp | null = null;
type LiveSyncWebAppDebugApi = {
getApp: () => LiveSyncWebApp | null;
historyStore: VaultHistoryStore;
};
type LiveSyncWebAppGlobal = typeof compatGlobal & {
livesyncApp?: LiveSyncWebAppDebugApi;
};
function getRequiredElement<T extends HTMLElement>(id: string): T {
const element = _activeDocument.getElementById(id);
if (!element) {
throw new Error(`Missing element: #${id}`);
}
return element as T;
}
function setStatus(kind: "info" | "warning" | "error" | "success", message: string): void {
const statusEl = getRequiredElement<HTMLDivElement>("status");
statusEl.className = kind;
statusEl.textContent = message;
}
function setBusyState(isBusy: boolean): void {
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
pickNewBtn.disabled = isBusy;
const historyButtons = _activeDocument.querySelectorAll<HTMLButtonElement>(".vault-item button");
historyButtons.forEach((button) => {
button.disabled = isBusy;
});
}
function formatLastUsed(unixMillis: number): string {
if (!unixMillis) {
return "unknown";
}
return new Date(unixMillis).toLocaleString();
}
async function renderHistoryList(): Promise<VaultHistoryItem[]> {
const listEl = getRequiredElement<HTMLDivElement>("vault-history-list");
const emptyEl = getRequiredElement<HTMLParagraphElement>("vault-history-empty");
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
listEl.replaceChildren();
emptyEl.classList.toggle("is-hidden", items.length > 0);
for (const item of items) {
const row = createNativeElement(_activeDocument, "div");
row.className = "vault-item";
const info = createNativeElement(_activeDocument, "div");
info.className = "vault-item-info";
const name = createNativeElement(_activeDocument, "div");
name.className = "vault-item-name";
name.textContent = item.name;
const meta = createNativeElement(_activeDocument, "div");
meta.className = "vault-item-meta";
const label = item.id === lastUsedId ? "Last used" : "Used";
meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`;
info.append(name, meta);
const useButton = createNativeElement(_activeDocument, "button");
useButton.type = "button";
useButton.textContent = "Use this vault";
useButton.addEventListener("click", () => {
void startWithHistory(item);
});
row.append(info, useButton);
listEl.appendChild(row);
}
return items;
}
async function startWithHandle(handle: FileSystemDirectoryHandle): Promise<void> {
setStatus("info", `Starting LiveSync with vault: ${handle.name}`);
app = new LiveSyncWebApp(handle);
await app.initialize();
const selectorEl = getRequiredElement<HTMLDivElement>("vault-selector");
selectorEl.classList.add("is-hidden");
}
async function startWithHistory(item: VaultHistoryItem): Promise<void> {
setBusyState(true);
try {
const handle = await historyStore.activateHistoryItem(item);
await startWithHandle(handle);
} catch (error) {
setStatus("error", `Failed to open saved vault: ${String(error)}`);
setBusyState(false);
}
}
async function startWithNewPicker(): Promise<void> {
setBusyState(true);
try {
const handle = await historyStore.pickNewVault();
await startWithHandle(handle);
} catch (error) {
setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`);
setBusyState(false);
}
}
async function initializeVaultSelector(): Promise<void> {
setStatus("info", "Select a vault folder to start LiveSync.");
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
pickNewBtn.addEventListener("click", () => {
void startWithNewPicker();
});
await renderHistoryList();
}
compatGlobal.addEventListener("load", () => {
initializeVaultSelector().catch((error) => {
setStatus("error", `Initialization failed: ${String(error)}`);
});
});
compatGlobal.addEventListener("beforeunload", () => {
void app?.shutdown();
});
(compatGlobal as LiveSyncWebAppGlobal).livesyncApp = {
getApp: () => app,
historyStore,
};
+150 -253
View File
@@ -1,275 +1,172 @@
/**
* Self-hosted LiveSync WebApp
* Browser-based version of Self-hosted LiveSync plugin using FileSystem API
*/
import type { BrowserServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { initialiseServiceModulesFSAPI, type FSAPIServiceModules } from "./serviceModules/FSAPIServiceModules";
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type LOG_LEVEL,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
import type { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { useRedFlagFeatures } from "@/serviceFeatures/redFlag";
import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize";
import { useSetupURIFeature } from "@/serviceFeatures/setupObsidian/setupUri";
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { SetupManager } from "@/modules/features/SetupManager";
import { useSetupManagerHandlersFeature } from "@/serviceFeatures/setupObsidian/setupManagerHandlers";
import { useP2PReplicatorCommands } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorCommands";
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
import { createNativeElement } from "@/apps/browserDom";
import { compatGlobal, _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { createLiveSyncBrowserServiceHub } from "@/apps/browser/createLiveSyncBrowserServiceHub";
import { mount } from "svelte";
const SETTINGS_DIR = ".livesync";
const SETTINGS_FILE = "settings.json";
import { WebAppRuntime, type WebAppRuntimeStatusKind } from "./WebAppRuntime";
import WebAppP2P from "./WebAppP2P.svelte";
import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector";
/**
* Default settings for the webapp
*/
const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
liveSync: false,
syncOnSave: true,
syncOnStart: false,
savingDelay: 200,
lessInformationInLog: false,
gcDelay: 0,
periodicReplication: false,
periodicReplicationInterval: 60,
isConfigured: false,
// CouchDB settings - user needs to configure these
couchDB_URI: "",
couchDB_USER: "",
couchDB_PASSWORD: "",
couchDB_DBNAME: "",
// Disable features not needed in webapp
usePluginSync: false,
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
const historyStore = new VaultHistoryStore();
let runtime: WebAppRuntime | null = null;
type LiveSyncWebAppDebugApi = {
getRuntime: () => WebAppRuntime | null;
historyStore: VaultHistoryStore;
};
class LiveSyncWebApp {
private rootHandle: FileSystemDirectoryHandle;
private core: LiveSyncBaseCore<ServiceContext, never> | null = null;
private serviceHub: BrowserServiceHub<ServiceContext> | null = null;
private platformServiceModules: FSAPIServiceModules | null = null;
type LiveSyncWebAppGlobal = typeof compatGlobal & {
livesyncApp?: LiveSyncWebAppDebugApi;
};
constructor(rootHandle: FileSystemDirectoryHandle) {
this.rootHandle = rootHandle;
function getRequiredElement<T extends HTMLElement>(id: string): T {
const element = _activeDocument.getElementById(id);
if (!element) {
throw new Error(`Missing element: #${id}`);
}
return element as T;
}
private addLog(message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void {
this.serviceHub?.API.addLog(message, level, key);
function setStatus(kind: WebAppRuntimeStatusKind, message: string): void {
const statusEl = getRequiredElement<HTMLDivElement>("status");
statusEl.className = kind;
statusEl.textContent = message;
}
function setBusyState(isBusy: boolean): void {
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
pickNewBtn.disabled = isBusy;
const historyButtons = _activeDocument.querySelectorAll<HTMLButtonElement>(".vault-item button");
historyButtons.forEach((button) => {
button.disabled = isBusy;
});
}
function formatLastUsed(unixMillis: number): string {
if (!unixMillis) {
return "unknown";
}
return new Date(unixMillis).toLocaleString();
}
async initialize() {
// Create service context and hub
this.serviceHub = createLiveSyncBrowserServiceHub<ServiceContext>();
this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise");
this.addLog("Initialising...", LOG_LEVEL_VERBOSE, "initialise");
this.addLog(`Vault directory: ${this.rootHandle.name}`, LOG_LEVEL_VERBOSE, "initialise");
async function renderHistoryList(): Promise<VaultHistoryItem[]> {
const listEl = getRequiredElement<HTMLDivElement>("vault-history-list");
const emptyEl = getRequiredElement<HTMLParagraphElement>("vault-history-empty");
// Setup API service
(this.serviceHub.API as BrowserAPIService<ServiceContext>).getSystemVaultName.setHandler(
() => this.rootHandle?.name || "livesync-webapp"
);
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
// Setup settings handlers - save to .livesync folder
const settingService = this.serviceHub.setting as InjectableSettingService<ServiceContext>;
listEl.replaceChildren();
emptyEl.classList.toggle("is-hidden", items.length > 0);
settingService.saveData.setHandler(async (data: ObsidianLiveSyncSettings) => {
try {
await this.saveSettingsToFile(data);
this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
} catch (error) {
this.addLog(`Failed to save settings: ${String(error)}`, LOG_LEVEL_NOTICE, "settings");
}
for (const item of items) {
const row = createNativeElement(_activeDocument, "div");
row.className = "vault-item";
const info = createNativeElement(_activeDocument, "div");
info.className = "vault-item-info";
const name = createNativeElement(_activeDocument, "div");
name.className = "vault-item-name";
name.textContent = item.name;
const meta = createNativeElement(_activeDocument, "div");
meta.className = "vault-item-meta";
const label = item.id === lastUsedId ? "Last used" : "Used";
meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`;
info.append(name, meta);
const useButton = createNativeElement(_activeDocument, "button");
useButton.type = "button";
useButton.textContent = "Use this vault";
useButton.addEventListener("click", () => {
void startWithHistory(item);
});
settingService.loadData.setHandler(async (): Promise<ObsidianLiveSyncSettings | undefined> => {
try {
const data = await this.loadSettingsFromFile();
if (data) {
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
}
} catch {
this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings");
}
return DEFAULT_SETTINGS as ObsidianLiveSyncSettings;
});
// App lifecycle handlers
this.serviceHub.appLifecycle.scheduleRestart.setHandler(() => {
void (async () => {
this.addLog("Restart requested", LOG_LEVEL_INFO, "app-lifecycle");
await this.shutdown();
await this.initialize();
compatGlobal.setTimeout(() => {
compatGlobal.location.reload();
}, 1000);
})();
});
// Create LiveSync core
this.core = new LiveSyncBaseCore<ServiceContext, never>(
this.serviceHub,
(core, serviceHub) => {
const serviceModules = initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub);
this.platformServiceModules = serviceModules;
return serviceModules;
},
(core) => [
// new ModuleObsidianEvents(this, core),
// new ModuleObsidianSettingDialogue(this, core),
// new ModuleObsidianMenu(core),
// new ModuleObsidianSettingsAsMarkdown(core),
// new ModuleLog(this, core),
// new ModuleObsidianDocumentHistory(this, core),
// new ModuleInteractiveConflictResolver(this, core),
// new ModuleObsidianGlobalHistory(this, core),
// new ModuleDev(this, core),
// new ModuleReplicateTest(this, core),
// new ModuleIntegratedTest(this, core),
// new ModuleReplicatorP2P(core), // Register P2P replicator for CLI (useP2PReplicator is not used here)
new SetupManager(core),
],
() => [] as never[], // No add-ons
(core) => {
useOfflineScanner(core);
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useRemoteConfiguration(core);
const replicator = useP2PReplicatorFeature(core);
useP2PReplicatorCommands(core, replicator);
const setupManager = core.getModule(SetupManager);
useSetupManagerHandlersFeature(core, setupManager);
useSetupURIFeature(core);
}
);
// Start the core
await this.start();
row.append(info, useButton);
listEl.appendChild(row);
}
private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise<void> {
// Create .livesync directory if it does not exist
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true });
return items;
}
// Create/overwrite settings.json
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
async function startWithHandle(handle: FileSystemDirectoryHandle): Promise<void> {
setStatus("info", `Starting LiveSync with vault: ${handle.name}`);
const nextRuntime = new WebAppRuntime(handle, { reportStatus: setStatus });
await nextRuntime.start();
runtime = nextRuntime;
const selectorEl = getRequiredElement<HTMLDivElement>("vault-selector");
selectorEl.classList.add("is-hidden");
const p2pControl = getRequiredElement<HTMLElement>("p2p-control");
mount(WebAppP2P, {
target: p2pControl,
props: {
runtime: nextRuntime,
},
});
p2pControl.classList.remove("is-hidden");
}
async function startWithHistory(item: VaultHistoryItem): Promise<void> {
setBusyState(true);
let handle: FileSystemDirectoryHandle;
try {
handle = await historyStore.activateHistoryItem(item);
} catch (error) {
setStatus("error", `Failed to open saved vault: ${String(error)}`);
setBusyState(false);
return;
}
private async loadSettingsFromFile(): Promise<Partial<ObsidianLiveSyncSettings> | null> {
try {
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR);
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE);
const file = await fileHandle.getFile();
const text = await file.text();
const parsed: unknown = JSON.parse(text);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("The WebApp settings file does not contain an object");
}
return parsed;
} catch {
// File doesn't exist yet
return null;
}
}
private async start() {
if (!this.core) {
throw new Error("Core not initialized");
}
try {
this.addLog("Initialising LiveSync...", LOG_LEVEL_INFO, "start");
const loadResult = await this.core.services.control.onLoad();
if (!loadResult) {
this.addLog("Failed to initialise LiveSync", LOG_LEVEL_NOTICE, "start");
this.showError("Failed to initialize LiveSync");
return;
}
await this.core.services.control.onReady();
this.addLog("LiveSync is running", LOG_LEVEL_INFO, "ready");
// Check if configured
const settings = this.core.services.setting.currentSettings();
if (!settings.isConfigured) {
this.addLog("LiveSync is not configured yet", LOG_LEVEL_NOTICE, "configuration");
this.showWarning("Please configure CouchDB connection in settings");
} else {
this.addLog("LiveSync is configured and ready", LOG_LEVEL_INFO, "configuration");
this.addLog(`Database: ${settings.couchDB_DBNAME}`, LOG_LEVEL_VERBOSE, "configuration");
this.showSuccess("LiveSync is ready!");
}
// Scan the directory to populate file cache
const fileAccess = this.platformServiceModules?.vaultAccess;
if (fileAccess) {
this.addLog("Scanning vault directory...", LOG_LEVEL_VERBOSE, "scan");
await fileAccess.fsapiAdapter.scanDirectory();
const files = await fileAccess.fsapiAdapter.getFiles();
this.addLog(`Found ${files.length} files`, LOG_LEVEL_VERBOSE, "scan");
}
} catch (error) {
this.addLog(`Failed to start: ${String(error)}`, LOG_LEVEL_NOTICE, "start");
this.showError(`Failed to start: ${error}`);
}
}
async shutdown() {
if (this.core) {
this.addLog("Shutting down...", LOG_LEVEL_INFO, "shutdown");
// Stop file watching
const storageEventManager = this.platformServiceModules?.storageEventManager;
if (storageEventManager) {
await storageEventManager.cleanup();
}
await this.core.services.control.onUnload();
this.platformServiceModules = null;
this.addLog("Shutdown complete", LOG_LEVEL_INFO, "shutdown");
}
}
private showError(message: string) {
const statusEl = _activeDocument.getElementById("status");
if (statusEl) {
statusEl.className = "error";
statusEl.textContent = `Error: ${message}`;
}
}
private showWarning(message: string) {
const statusEl = _activeDocument.getElementById("status");
if (statusEl) {
statusEl.className = "warning";
statusEl.textContent = `Warning: ${message}`;
}
}
private showSuccess(message: string) {
const statusEl = _activeDocument.getElementById("status");
if (statusEl) {
statusEl.className = "success";
statusEl.textContent = message;
}
try {
await startWithHandle(handle);
} catch (error) {
setStatus("error", `Failed to start LiveSync: ${String(error)}`);
setBusyState(false);
}
}
export { LiveSyncWebApp };
async function startWithNewPicker(): Promise<void> {
setBusyState(true);
let handle: FileSystemDirectoryHandle;
try {
handle = await historyStore.pickNewVault();
} catch (error) {
setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`);
setBusyState(false);
return;
}
try {
await startWithHandle(handle);
} catch (error) {
setStatus("error", `Failed to start LiveSync: ${String(error)}`);
setBusyState(false);
}
}
async function initialiseVaultSelector(): Promise<void> {
setStatus("info", "Select a vault folder to start LiveSync.");
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
pickNewBtn.addEventListener("click", () => {
void startWithNewPicker();
});
await renderHistoryList();
}
compatGlobal.addEventListener("load", () => {
initialiseVaultSelector().catch((error) => {
setStatus("error", `Initialisation failed: ${String(error)}`);
});
});
compatGlobal.addEventListener("beforeunload", () => {
void runtime?.shutdown();
});
(compatGlobal as LiveSyncWebAppGlobal).livesyncApp = {
getRuntime: () => runtime,
historyStore,
};
@@ -74,7 +74,8 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onerror = () =>
reject(request.error ?? new Error("Failed to open the WebApp snapshot database"));
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
@@ -95,7 +96,8 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
await new Promise<void>((resolve, reject) => {
const request = store.put(snapshot, this.snapshotKey);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
request.onerror = () =>
reject(request.error ?? new Error("Failed to save the WebApp storage-event snapshot"));
});
db.close();
@@ -114,7 +116,8 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
const request = store.get(this.snapshotKey);
request.onsuccess = () =>
resolve((request.result as (FileEventItem | FileEventItemSentinel)[] | undefined) ?? null);
request.onerror = () => reject(request.error);
request.onerror = () =>
reject(request.error ?? new Error("Failed to load the WebApp storage-event snapshot"));
});
db.close();
@@ -191,11 +194,15 @@ class FSAPIWatchAdapter implements IStorageEventWatchAdapter {
) {}
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
// Use FileSystemObserver if available (Chrome 124+)
// Use FileSystemObserver when the browser provides it.
const FileSystemObserver = (compatGlobal as GlobalWithFileSystemObserver).FileSystemObserver;
if (!FileSystemObserver) {
this.addLog("FileSystemObserver is not available; file watching is disabled", LOG_LEVEL_INFO, "fsapi-watch");
this.addLog("Chrome 124 or later supports real-time file watching", LOG_LEVEL_INFO, "fsapi-watch");
this.addLog(
"FileSystemObserver is not available; file watching is disabled",
LOG_LEVEL_INFO,
"fsapi-watch"
);
this.addLog("Use 'Scan local files' after external changes", LOG_LEVEL_INFO, "fsapi-watch");
return Promise.resolve();
}
File diff suppressed because it is too large Load Diff
@@ -1,85 +0,0 @@
import { describe, expect, it } from "vitest";
import { fileURLToPath, path } from "@vrtmrz/livesync-commonlib/node";
import * as ts from "typescript";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const dependencyFacadePath = path.resolve(repositoryRoot, "src/deps.ts");
const obsidianMockPath = path.resolve(repositoryRoot, "src/apps/webapp/obsidianMock.ts");
function parseSourceFile(filePath: string): ts.SourceFile {
const source = ts.sys.readFile(filePath);
if (source === undefined) throw new Error(`Could not read ${filePath}`);
return ts.createSourceFile(filePath, source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
}
function hasExportModifier(statement: ts.Statement): boolean {
if (!ts.canHaveModifiers(statement)) return false;
return ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
}
function addBindingNames(name: ts.BindingName, names: Set<string>): void {
if (ts.isIdentifier(name)) {
names.add(name.text);
return;
}
for (const element of name.elements) {
if (!ts.isOmittedExpression(element)) addBindingNames(element.name, names);
}
}
describe("Webapp Obsidian mock exports", () => {
it("provides every value re-exported from Obsidian", () => {
const dependencyFacade = parseSourceFile(dependencyFacadePath);
const obsidianMock = parseSourceFile(obsidianMockPath);
const requiredExports = new Set<string>();
for (const statement of dependencyFacade.statements) {
if (
ts.isExportDeclaration(statement) &&
statement.moduleSpecifier &&
ts.isStringLiteral(statement.moduleSpecifier) &&
statement.moduleSpecifier.text === "obsidian" &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause) &&
!statement.isTypeOnly
) {
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
requiredExports.add((element.propertyName ?? element.name).text);
}
}
}
}
const availableExports = new Set<string>();
for (const statement of obsidianMock.statements) {
if (
ts.isExportDeclaration(statement) &&
!statement.isTypeOnly &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) availableExports.add(element.name.text);
}
continue;
}
if (!hasExportModifier(statement)) continue;
if (
(ts.isClassDeclaration(statement) ||
ts.isFunctionDeclaration(statement) ||
ts.isEnumDeclaration(statement)) &&
statement.name
) {
availableExports.add(statement.name.text);
} else if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
addBindingNames(declaration.name, availableExports);
}
}
}
const missingExports = [...requiredExports].filter((name) => !availableExports.has(name)).sort();
expect(missingExports).toEqual([]);
});
});
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.0-rc.0-webapp",
"version": "1.0.1-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
@@ -9,7 +9,10 @@
"build": "vite build",
"build:docker": "docker build -f Dockerfile -t livesync-webapp ../../..",
"run:docker": "docker run -p 8002:80 livesync-webapp",
"preview": "vite preview"
"preview": "vite preview",
"test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webapp",
"pretest:browser": "npm run build",
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.51"
+3 -2
View File
@@ -68,7 +68,8 @@ export class VaultHistoryStore {
private async openHandleDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(HANDLE_DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onerror = () =>
reject(request.error ?? new Error("Could not open the WebApp Vault history database"));
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
@@ -93,7 +94,7 @@ export class VaultHistoryStore {
private async requestAsPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onerror = () => reject(request.error ?? new Error("The WebApp Vault history request failed"));
});
}
-2
View File
@@ -20,7 +20,6 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "../../"),
obsidian: path.resolve(__dirname, "./obsidianMock.ts"),
},
},
base: "./",
@@ -39,7 +38,6 @@ export default defineConfig({
MANIFEST_VERSION: JSON.stringify(process.env.MANIFEST_VERSION || manifestVersion || "0.0.0"),
PACKAGE_VERSION: JSON.stringify(process.env.PACKAGE_VERSION || packageVersion || "0.0.0"),
global: "globalThis",
hostPlatform: JSON.stringify(process.platform || "linux"),
},
server: {
port: 3000,
+12 -3
View File
@@ -31,7 +31,7 @@ body {
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 700px;
max-width: 1100px;
width: 100%;
}
@@ -161,10 +161,19 @@ button:disabled {
}
.empty-note.is-hidden,
.vault-selector.is-hidden {
.vault-selector.is-hidden,
.p2p-control.is-hidden {
display: none;
}
.p2p-control {
margin-bottom: 22px;
padding: 16px;
border: 1px solid #e6e9f2;
border-radius: 8px;
background: #fbfcff;
}
.info-section {
margin-top: 20px;
padding: 20px;
@@ -397,4 +406,4 @@ popup {
align-items: center;
justify-content: center;
backdrop-filter: blur(15px);
}
}
+40 -38
View File
@@ -1,45 +1,47 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Self-hosted LiveSync WebApp</title>
<link rel="stylesheet" href="./webapp.css">
</head>
<body>
<div class="container">
<h1>Self-hosted LiveSync on Web</h1>
<p class="subtitle">Browser-based Self-hosted LiveSync using FileSystem API</p>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Self-hosted LiveSync WebApp</title>
<link rel="stylesheet" href="./webapp.css" />
</head>
<body>
<div class="container">
<h1>Self-hosted LiveSync on Web</h1>
<p class="subtitle">Experimental browser proof of concept using the File System Access API</p>
<div id="status" class="info">Initialising...</div>
<div id="status" class="info">Initialising...</div>
<div id="vault-selector" class="vault-selector">
<h2>Select Vault Folder</h2>
<p>Open a vault you already used, or pick a new folder.</p>
<div id="vault-selector" class="vault-selector">
<h2>Select Vault Folder</h2>
<p>Open a vault you already used, or pick a new folder.</p>
<div id="vault-history-list" class="vault-list"></div>
<p id="vault-history-empty" class="empty-note">No saved vaults yet.</p>
<button id="pick-new-vault" type="button">Choose new vault folder</button>
<div id="vault-history-list" class="vault-list"></div>
<p id="vault-history-empty" class="empty-note">No saved vaults yet.</p>
<button id="pick-new-vault" type="button">Choose new vault folder</button>
</div>
<section id="p2p-control" class="p2p-control is-hidden"></section>
<div class="info-section">
<h2>How to Use</h2>
<ul>
<li>Select the local Vault root and grant access.</li>
<li>Create or edit <code>.livesync/settings.json</code> in that Vault.</li>
<li>Reload this page to apply the settings.</li>
<li>Use the P2P controls only as an optional secondary connection.</li>
</ul>
</div>
<div class="footer">
<p>
Powered by
<a href="https://github.com/vrtmrz/obsidian-livesync" target="_blank">Self-hosted LiveSync</a>
</p>
</div>
</div>
<div class="info-section">
<h2>How to Use</h2>
<ul>
<li>Select a vault folder and grant permission</li>
<li>Create <code>.livesync/settings.json</code> in your vault folder</li>
<li>Or use Setup-URI to apply settings</li>
<li>Your files will be synced after "replicate now"</li>
</ul>
</div>
<div class="footer">
<p>
Powered by
<a href="https://github.com/vrtmrz/obsidian-livesync" target="_blank">Self-hosted LiveSync</a>
</p>
</div>
</div>
<script type="module" src="./bootstrap.ts"></script>
</body>
<script type="module" src="./main.ts"></script>
</body>
</html>
+71 -18
View File
@@ -1,37 +1,90 @@
# A pseudo client for Self-hosted LiveSync Peer-to-Peer Sync mode
# Self-hosted LiveSync WebPeer
## What is it for?
WebPeer is an experimental, browser-hosted, P2P-only Self-hosted LiveSync peer. It can receive database changes from one peer and provide them to another without materialising ordinary Vault files.
This is a pseudo client for the Self-hosted LiveSync Peer-to-Peer Sync mode. It is a simple pure-client-side web-application that can be connected to the Self-hosted LiveSync in peer-to-peer.
It stores LiveSync metadata and chunks in an origin-scoped local database, but it does not present them as a Vault. This makes it useful as a temporary browser peer or transfer bridge. It is not a durable replacement for CouchDB, a backup, or an always-on server.
As long as you have a browser, it starts up, so if you leave it opened some device, it can replace your existing remote servers such as CouchDB.
## Requirements
> [!IMPORTANT]
> Of course, it has not been fully tested. Rather, it was created to be tested.
WebPeer requires:
This pseudo client actually receives the data from other devices, and sends if some device requests it. However, it does not store **files** in the local storage. If you want to purge the data, please purge the browser's cache and indexedDB, local storage, etc.
- a secure context, such as HTTPS or `localhost`;
- IndexedDB, WebRTC, WebSocket, and Web Crypto support;
- access to the configured signalling relay; and
- the same Group ID, passphrase, and compatible P2P settings as the other peers.
## How to use it?
TURN is optional and is needed only when a direct WebRTC connection cannot be established. The signalling relay is required for peer discovery, but it does not store or transfer Vault contents.
We can build the application from the repository root by running the following command:
## Use
Serve `src/apps/webpeer/dist/` over HTTPS, or from `localhost`, then open `index.html`.
1. Enable the P2P replicator.
2. Enter the signalling relay, Group ID, passphrase, and a unique device name.
3. Select **Save and Apply**.
4. Select **Connect** and wait for the intended peers to appear.
5. Use the peer actions to fetch, send, watch, or configure automatic synchronisation as required.
**Start change-broadcasting on Connect** allows watching peers to notice changes and fetch them. It does not send Vault data through the signalling relay. Optional TURN settings are available separately.
Keep the page open while WebPeer is expected to announce or transfer changes.
## Storage and lifecycle
WebPeer stores its settings, metadata, and chunks in storage belonging to the page origin. Consequently:
- changing the scheme, host, or port creates a different WebPeer state;
- clearing site data removes the local database and settings;
- browser storage eviction can remove the state; and
- page suspension, tab closure, device sleep, and network policy can interrupt P2P activity.
Use a separately deployed origin when isolation from the public Pages deployment is required. Do not treat WebPeer browser storage as the only copy of any data.
## Troubleshooting
### No peers appear
- Confirm that every peer uses the same signalling relay, Group ID, and passphrase.
- Confirm that each peer has a distinct device name.
- Confirm that P2P is enabled and that the signalling connection reports **Connected**.
- Check whether the browser or network blocks the relay WebSocket.
### Peers connect but changes do not transfer
- Confirm which peer should fetch and which should send.
- Start broadcasting on the source when another peer is watching it.
- Use the explicit fetch or send action to test one direction.
- Confirm that the peers use compatible Self-hosted LiveSync versions and P2P settings.
### Saved settings or data appear to be missing
- Confirm that the page is using the same origin as before.
- Check whether site data was cleared or evicted.
- Check the status line and WebPeer log for the first reported error.
For more detail about relay, TURN, announcing, and following behaviour, see the [P2P guide](../../../docs/p2p.md).
## Development
Build it from the repository root:
```bash
npm run build -w webpeer
npm run build --workspace webpeer
```
Or from the package directory:
The app-owned unit and Chromium tests can be run with:
```bash
cd src/apps/webpeer
npm run build
npm run test:unit --workspace webpeer
npm run test:browser --workspace webpeer
```
Then, open `dist/index.html` in the browser. It can be configured in the same way as Self-hosted LiveSync (the same components are used[^1]).
The unit tests are stored in `test/apps/webpeer/`, outside the Community Review source boundary.
## Some notes
## Composition
I will launch this application in the github pages later, so will be able to use it without building it. However, that shares the origin. Hence, the application that your have built and deployed would be more secure.
`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls.
## Licence
[^1]: Congrats! I made it modular. Finally...
The same licence as the main Self-hosted LiveSync project applies.
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.0-rc.0-webpeer",
"version": "1.0.1-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,7 +9,10 @@
"build:docker": "docker build -f Dockerfile -t livesync-webpeer ../../..",
"run:docker": "docker run -p 8001:80 livesync-webpeer",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
"test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webpeer",
"pretest:browser": "npm run build",
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.51"
-22
View File
@@ -1,22 +0,0 @@
import { LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { defaultLoggerEnv, setGlobalLogFunction } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { writable } from "svelte/store";
export const logs = writable([] as string[]);
let _logs = [] as string[];
const maxLines = 10000;
setGlobalLogFunction((msg, level) => {
const msgstr = typeof msg === "string" ? msg : JSON.stringify(msg);
const strLog = `${new Date().toISOString()}\u2001${msgstr}`;
_logs.push(strLog);
if (_logs.length > maxLines) {
_logs = _logs.slice(_logs.length - maxLines);
}
logs.set(_logs);
});
defaultLoggerEnv.minLogLevel = LOG_LEVEL_VERBOSE;
export const storeP2PStatusLine = writable("");
-341
View File
@@ -1,341 +0,0 @@
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import {
type EntryDoc,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
LOG_LEVEL_VERBOSE,
P2P_DEFAULT_SETTINGS,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import { LOG_LEVEL_NOTICE, Logger, type LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import {
EVENT_P2P_PEER_SHOW_EXTRA_MENU,
type PeerStatus,
type PluginShim,
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import { useP2PReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorCore";
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
import type { P2PReplicatorBase } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorBase";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { unique } from "octagonal-wheels/collection";
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { Menu } from "@/apps/browser/BrowserMenu";
import { SimpleStoreIDBv2 } from "octagonal-wheels/databases/SimpleStoreIDBv2";
import type { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
import type { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { createLiveSyncBrowserServiceHub } from "@/apps/browser/createLiveSyncBrowserServiceHub";
function addToList(item: string, list: string) {
return unique(
list
.split(",")
.map((e) => e.trim())
.concat(item)
.filter((p) => p)
).join(",");
}
function removeFromList(item: string, list: string) {
return list
.split(",")
.map((e) => e.trim())
.filter((p) => p !== item)
.filter((p) => p)
.join(",");
}
export class P2PReplicatorShim implements P2PReplicatorBase {
storeP2PStatusLine = reactiveSource("");
plugin!: PluginShim;
confirm!: Confirm;
db?: PouchDB.Database<EntryDoc>;
services: InjectableServiceHub<ServiceContext>;
getDB() {
if (!this.db) {
throw new Error("DB not initialized");
}
return this.db;
}
_simpleStore!: SimpleStore<unknown>;
async closeDB() {
if (this.db) {
await this.db.close();
this.db = undefined;
}
}
private _liveSyncReplicator?: LiveSyncTrysteroReplicator;
p2pLogCollector!: P2PLogCollector;
private _initP2PReplicator() {
const {
replicator,
p2pLogCollector,
storeP2PStatusLine: p2pStatusLine,
} = useP2PReplicator({ services: this.services } as unknown as Parameters<typeof useP2PReplicator>[0]);
this._liveSyncReplicator = replicator;
this.p2pLogCollector = p2pLogCollector;
p2pLogCollector.p2pReplicationLine.onChanged((line) => {
p2pStatusLine.value = line.value;
});
}
constructor() {
const browserServiceHub = createLiveSyncBrowserServiceHub<ServiceContext>();
this.services = browserServiceHub;
(this.services.API as BrowserAPIService<ServiceContext>).getSystemVaultName.setHandler(
() => "p2p-livesync-web-peer"
);
const repStore = SimpleStoreIDBv2.open<unknown>("p2p-livesync-web-peer");
this._simpleStore = repStore;
let _settings = { ...P2P_DEFAULT_SETTINGS, additionalSuffixOfDatabaseName: "" } as ObsidianLiveSyncSettings;
this.services.setting.settings = _settings;
(this.services.setting as InjectableSettingService<ServiceContext>).saveData.setHandler(async (data) => {
await repStore.set("settings", data);
this.services.context.events.emitEvent(EVENT_SETTING_SAVED, data);
});
(this.services.setting as InjectableSettingService<ServiceContext>).loadData.setHandler(async () => {
const settings = { ..._settings, ...((await repStore.get("settings")) as ObsidianLiveSyncSettings) };
return settings;
});
}
get settings() {
return this.services.setting.currentSettings() as P2PSyncSetting;
}
async init() {
this.confirm = this.services.UI.confirm;
if (this.db) {
try {
await this.closeDB();
} catch (ex) {
Logger("Error closing db", LOG_LEVEL_VERBOSE);
Logger(ex, LOG_LEVEL_VERBOSE);
}
}
await this.services.setting.loadSettings();
this.plugin = {
services: this.services,
core: {
services: this.services,
},
};
const database_name = this.settings.P2P_AppID + "-" + this.settings.P2P_roomID + "p2p-livesync-web-peer";
this.db = new PouchDB<EntryDoc>(database_name);
this._initP2PReplicator();
compatGlobal.setTimeout(() => {
if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) {
void this.open();
}
}, 1000);
return this;
}
_log(msg: unknown, level?: LOG_LEVEL): void {
Logger(msg, level);
}
_notice(msg: string, key?: string): void {
Logger(msg, LOG_LEVEL_NOTICE, key);
}
getSettings(): P2PSyncSetting {
return this.settings;
}
simpleStore(): SimpleStore<unknown> {
return this._simpleStore;
}
handleReplicatedDocuments(_docs: EntryDoc[]): Promise<boolean> {
return Promise.resolve(true);
}
getConfig(key: string) {
const vaultName = this.services.vault.getVaultName();
const dbKey = `${vaultName}-${key}`;
return compatGlobal.localStorage.getItem(dbKey);
}
setConfig(key: string, value: string) {
const vaultName = this.services.vault.getVaultName();
const dbKey = `${vaultName}-${key}`;
compatGlobal.localStorage.setItem(dbKey, value);
}
getDeviceName(): string {
return this.getConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? this.plugin.services.vault.getVaultName();
}
m?: Menu;
afterConstructor(): void {
this.services.context.events.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => {
if (this.m) {
this.m.hide();
}
this.m = new Menu()
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer)))
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer)))
.addSeparator()
.addItem((item) => {
const mark = peer.syncOnConnect ? "checkmark" : null;
item.setTitle("Toggle Sync on connect")
.onClick(async () => {
await this.toggleProp(peer, "syncOnConnect");
})
.setIcon(mark);
})
.addItem((item) => {
const mark = peer.watchOnConnect ? "checkmark" : null;
item.setTitle("Toggle Watch on connect")
.onClick(async () => {
await this.toggleProp(peer, "watchOnConnect");
})
.setIcon(mark);
})
.addItem((item) => {
const mark = peer.syncOnReplicationCommand ? "checkmark" : null;
item.setTitle("Toggle Sync on `Replicate now` command")
.onClick(async () => {
await this.toggleProp(peer, "syncOnReplicationCommand");
})
.setIcon(mark);
});
void this.m.showAtPosition({ x: event.x, y: event.y });
});
}
async open() {
await this._liveSyncReplicator?.open();
}
async close() {
await this._liveSyncReplicator?.close();
}
enableBroadcastCastings() {
return this._liveSyncReplicator?.enableBroadcastChanges();
}
disableBroadcastCastings() {
return this._liveSyncReplicator?.disableBroadcastChanges();
}
enableBroadcastChanges() {
return this._liveSyncReplicator?.enableBroadcastChanges();
}
disableBroadcastChanges() {
return this._liveSyncReplicator?.disableBroadcastChanges();
}
async makeDecision(decision: Parameters<LiveSyncTrysteroReplicator["makeDecision"]>[0]): Promise<void> {
await this._liveSyncReplicator?.makeDecision(decision);
}
async revokeDecision(decision: Parameters<LiveSyncTrysteroReplicator["revokeDecision"]>[0]): Promise<void> {
await this._liveSyncReplicator?.revokeDecision(decision);
}
watchPeer(peerId: string): void {
this._liveSyncReplicator?.watchPeer(peerId);
}
unwatchPeer(peerId: string): void {
this._liveSyncReplicator?.unwatchPeer(peerId);
}
async sync(peerId: string, showNotice?: boolean): Promise<unknown> {
return await this._liveSyncReplicator?.sync(peerId, showNotice);
}
get replicator() {
return this._liveSyncReplicator;
}
async replicateFrom(peer: PeerStatus) {
const r = this._liveSyncReplicator;
if (!r) return;
await r.replicateFrom(peer.peerId);
}
async replicateTo(peer: PeerStatus) {
await this._liveSyncReplicator?.requestSynchroniseToPeer(peer.peerId);
}
async getRemoteConfig(peer: PeerStatus) {
Logger(
`Requesting remote config for ${peer.name}. Please input the passphrase on the remote device`,
LOG_LEVEL_NOTICE
);
const remoteConfig = await this._liveSyncReplicator?.getRemoteConfig(peer.peerId);
if (remoteConfig) {
Logger(`Remote config for ${peer.name} is retrieved successfully`);
const DROP = "Yes, and drop local database";
const KEEP = "Yes, but keep local database";
const CANCEL = "No, cancel";
const yn = await this.confirm.askSelectStringDialogue(
`Do you really want to apply the remote config? This will overwrite your current config immediately and restart.
And you can also drop the local database to rebuild from the remote device.`,
[DROP, KEEP, CANCEL] as const,
{
defaultAction: CANCEL,
title: "Apply Remote Config ",
}
);
if (yn === DROP || yn === KEEP) {
if (yn === DROP) {
if (remoteConfig.remoteType !== REMOTE_P2P) {
const yn2 = await this.confirm.askYesNoDialog(
`Do you want to set the remote type to "P2P Sync" to rebuild by "P2P replication"?`,
{ title: "Rebuild from remote device" }
);
if (yn2 === "yes") {
remoteConfig.remoteType = REMOTE_P2P;
remoteConfig.P2P_RebuildFrom = peer.name;
}
}
}
await this.services.setting.applyExternalSettings(remoteConfig, true);
if (yn !== DROP) {
this.plugin.core.services.appLifecycle.scheduleRestart();
}
} else {
Logger(`Cancelled\nRemote config for ${peer.name} is not applied`, LOG_LEVEL_NOTICE);
}
} else {
Logger(`Cannot retrieve remote config for ${peer.peerId}`);
}
}
async toggleProp(peer: PeerStatus, prop: "syncOnConnect" | "watchOnConnect" | "syncOnReplicationCommand") {
const settingMap = {
syncOnConnect: "P2P_AutoSyncPeers",
watchOnConnect: "P2P_AutoWatchPeers",
syncOnReplicationCommand: "P2P_SyncOnReplication",
} as const;
const targetSetting = settingMap[prop];
const currentSettingAll = this.plugin.core.services.setting.currentSettings();
const currentSetting = {
[targetSetting]: currentSettingAll ? currentSettingAll[targetSetting] : "",
};
if (peer[prop]) {
currentSetting[targetSetting] = removeFromList(peer.name, currentSetting[targetSetting]);
} else {
currentSetting[targetSetting] = addToList(peer.name, currentSetting[targetSetting]);
}
await this.plugin.core.services.setting.applyPartial(currentSetting, true);
}
}
export const cmdSyncShim = new P2PReplicatorShim();
+22 -18
View File
@@ -1,34 +1,38 @@
<script lang="ts">
import { storeP2PStatusLine, logs } from "./CommandsShim";
import { logs } from "./WebPeerLogs";
import BrowserP2PTransportSettings from "@/apps/browser/BrowserP2PTransportSettings.svelte";
import P2PReplicatorPane from "@/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte";
import { onMount, tick } from "svelte";
import { cmdSyncShim } from "./P2PReplicatorShim";
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { WebPeerRuntime } from "./WebPeerRuntime";
let synchronised = $state(cmdSyncShim.init());
const runtime = new WebPeerRuntime();
const synchronised = runtime.start();
let elP: HTMLDivElement;
let statusLine = $state(runtime.statusLine.value);
onMount(() => {
void synchronised.then((shim) => shim.services.context.events.emitEvent(EVENT_LAYOUT_READY));
return () => {
synchronised.then((e) => e.close());
const onStatusLineChanged = (line: { readonly value: string }) => {
statusLine = line.value;
};
runtime.statusLine.onChanged(onStatusLineChanged);
const unsubscribeLogs = logs.subscribe(() => {
void tick().then(() => elP?.scrollTo({ top: elP.scrollHeight }));
});
return () => {
runtime.statusLine.offChanged(onStatusLineChanged);
unsubscribeLogs();
void runtime.shutdown();
};
});
let elP: HTMLDivElement;
logs.subscribe((log) => {
tick().then(() => elP?.scrollTo({ top: elP.scrollHeight }));
});
let statusLine = $state("");
storeP2PStatusLine.subscribe((status) => {
statusLine = status;
});
</script>
<main>
<div class="control">
{#await synchronised then cmdSync}
<P2PReplicatorPane {cmdSync} core={cmdSync.plugin.core}></P2PReplicatorPane>
{#await synchronised then activeRuntime}
<BrowserP2PTransportSettings host={activeRuntime.paneHost} />
<P2PReplicatorPane host={activeRuntime.paneHost}></P2PReplicatorPane>
{:catch error}
<p>{error.message}</p>
<p>{error instanceof Error ? error.message : String(error)}</p>
{/await}
</div>
<div class="log">
+6 -6
View File
@@ -19,16 +19,16 @@
async function testMenu(event: MouseEvent) {
const m = new Menu()
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => {}))
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => {}))
.addItem((item) => item.setTitle("📥 Only fetch").onClick(() => {}))
.addItem((item) => item.setTitle("📤 Only send").onClick(() => {}))
.addSeparator()
.addItem((item) => {
item.setTitle("🔧 Get Configuration").onClick(async () => {});
item.setTitle("🔧 Get configuration").onClick(async () => {});
})
.addSeparator()
.addItem((item) => {
const mark = "checkmark";
item.setTitle("Toggle Sync on connect")
item.setTitle("Toggle sync on connect")
.onClick(async () => {
// await this.toggleProp(peer, "syncOnConnect");
})
@@ -36,7 +36,7 @@
})
.addItem((item) => {
const mark = null;
item.setTitle("Toggle Watch on connect")
item.setTitle("Toggle watch on connect")
.onClick(async () => {
// await this.toggleProp(peer, "watchOnConnect");
})
@@ -44,7 +44,7 @@
})
.addItem((item) => {
const mark = null;
item.setTitle("Toggle Sync on `Replicate now` command")
item.setTitle("Toggle sync on `Replicate now` command")
.onClick(async () => {})
.setIcon(mark);
});
+18
View File
@@ -0,0 +1,18 @@
import { LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { defaultLoggerEnv, setGlobalLogFunction } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { writable } from "svelte/store";
export const logs = writable<string[]>([]);
let bufferedLogs: string[] = [];
const maxLines = 10_000;
setGlobalLogFunction((message) => {
const messageText = typeof message === "string" ? message : JSON.stringify(message);
bufferedLogs.push(`${new Date().toISOString()}\u2001${messageText}`);
if (bufferedLogs.length > maxLines) {
bufferedLogs = bufferedLogs.slice(bufferedLogs.length - maxLines);
}
logs.set(bufferedLogs);
});
defaultLoggerEnv.minLogLevel = LOG_LEVEL_VERBOSE;
@@ -0,0 +1,47 @@
import {
DEFAULT_SETTINGS,
P2P_DEFAULT_SETTINGS,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { SimpleStoreIDBv2 } from "octagonal-wheels/databases/SimpleStoreIDBv2";
import type { LiveSyncBrowserSettingsPersistence } from "@/apps/browser/createLiveSyncBrowserServiceHub";
export const WEBPEER_STORE_NAME = "p2p-livesync-web-peer";
export const WEBPEER_SETTINGS_KEY = "settings";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Creates WebPeer-owned settings persistence without retaining legacy database names. */
export function createWebPeerPersistence(
store: SimpleStore<unknown> = SimpleStoreIDBv2.open<unknown>(WEBPEER_STORE_NAME)
): {
readonly store: SimpleStore<unknown>;
readonly settings: LiveSyncBrowserSettingsPersistence;
} {
const settings: LiveSyncBrowserSettingsPersistence = {
async load() {
const savedSettings = await store.get(WEBPEER_SETTINGS_KEY);
return {
...DEFAULT_SETTINGS,
...P2P_DEFAULT_SETTINGS,
additionalSuffixOfDatabaseName: "",
suspendParseReplicationResult: true,
...(isRecord(savedSettings) ? savedSettings : {}),
remoteType: REMOTE_P2P,
isConfigured: true,
};
},
async save(currentSettings) {
await store.set(WEBPEER_SETTINGS_KEY, currentSettings);
},
};
return {
store,
settings,
};
}
+199
View File
@@ -0,0 +1,199 @@
import { type P2PSyncSetting, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { unique } from "octagonal-wheels/collection";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import {
createLiveSyncBrowserServiceHub,
type LiveSyncBrowserServiceHub,
} from "@/apps/browser/createLiveSyncBrowserServiceHub";
import { Menu } from "@/apps/browser/BrowserMenu";
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
import { translateLiveSyncMessage } from "@/common/translation";
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
export interface WebPeerRuntimeOptions {
context?: ServiceContext;
store?: SimpleStore<unknown>;
}
function addToList(item: string, list: string): string {
return unique(
list
.split(",")
.map((entry) => entry.trim())
.concat(item)
.filter(Boolean)
).join(",");
}
function removeFromList(item: string, list: string): string {
return list
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry !== item)
.filter(Boolean)
.join(",");
}
export class WebPeerRuntime {
readonly context: ServiceContext;
readonly services: LiveSyncBrowserServiceHub<ServiceContext>;
readonly p2p: UseP2PReplicatorResult;
readonly p2pLogCollector: P2PLogCollector;
readonly paneHost: P2PReplicatorPaneHost;
private menu?: Menu;
private restartScheduled = false;
private startPromise?: Promise<this>;
private shutdownPromise?: Promise<void>;
constructor(options: WebPeerRuntimeOptions = {}) {
const persistence = createWebPeerPersistence(options.store);
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
context: this.context,
getSystemVaultName: () => WEBPEER_STORE_NAME,
settings: persistence.settings,
restart: {
schedule: () => this.scheduleRestart(),
perform: () => this.scheduleRestart(),
ask: () => this.scheduleRestart(),
isScheduled: () => this.restartScheduled,
},
});
this.p2p = useP2PReplicatorFeature({
services: this.services,
serviceModules: {},
});
this.p2pLogCollector = new P2PLogCollector(this.events);
this.paneHost = {
services: this.services,
p2p: this.p2p,
showPeerMenu: (peer, event) => this.showPeerMenu(peer, event),
};
}
get events(): LiveSyncEventHub {
return this.context.events;
}
get currentReplicator(): LiveSyncTrysteroReplicator {
return this.p2p.replicator;
}
get settings(): P2PSyncSetting {
return this.services.setting.currentSettings();
}
get statusLine() {
return this.p2pLogCollector.p2pReplicationLine;
}
start(): Promise<this> {
this.startPromise ??= this.startRuntime();
return this.startPromise;
}
private async startRuntime(): Promise<this> {
await this.services.setting.loadSettings();
const opened = await this.services.database.openDatabase({
replicator: this.services.replicator,
databaseEvents: this.services.databaseEvents,
});
if (!opened) {
throw new Error("WebPeer local database could not be opened");
}
this.services.appLifecycle.markIsReady();
this.events.emitEvent(EVENT_LAYOUT_READY);
if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) {
compatGlobal.setTimeout(() => void this.currentReplicator.open(), 100);
}
return this;
}
shutdown(): Promise<void> {
this.shutdownPromise ??= this.shutdownRuntime();
return this.shutdownPromise;
}
private async shutdownRuntime(): Promise<void> {
this.menu?.hide();
this.menu = undefined;
if (!this.services.control.hasUnloaded()) {
await this.services.control.onUnload();
}
}
private scheduleRestart(): void {
if (this.restartScheduled) {
return;
}
this.restartScheduled = true;
compatGlobal.setTimeout(() => compatGlobal.location.reload(), 0);
}
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
this.menu?.hide();
this.menu = new Menu()
.addItem((item) =>
item.setTitle("📥 Only fetch").onClick(async () => {
await this.currentReplicator.replicateFrom(peer.peerId);
})
)
.addItem((item) =>
item.setTitle("📤 Only send").onClick(async () => {
await this.currentReplicator.requestSynchroniseToPeer(peer.peerId);
})
)
.addSeparator()
.addItem((item) => {
item.setTitle("Toggle sync on connect")
.onClick(() => this.togglePeerSetting(peer, "syncOnConnect"))
.setIcon(peer.syncOnConnect ? "checkmark" : null);
})
.addItem((item) => {
item.setTitle("Toggle watch on connect")
.onClick(() => this.togglePeerSetting(peer, "watchOnConnect"))
.setIcon(peer.watchOnConnect ? "checkmark" : null);
})
.addItem((item) => {
item.setTitle("Toggle sync on `Replicate now` command")
.onClick(() => this.togglePeerSetting(peer, "syncOnReplicationCommand"))
.setIcon(peer.syncOnReplicationCommand ? "checkmark" : null);
});
void this.menu.showAtPosition({ x: event.x, y: event.y });
}
private async togglePeerSetting(
peer: PeerStatus,
property: "syncOnConnect" | "watchOnConnect" | "syncOnReplicationCommand"
): Promise<void> {
const settingMap = {
syncOnConnect: "P2P_AutoSyncPeers",
watchOnConnect: "P2P_AutoWatchPeers",
syncOnReplicationCommand: "P2P_SyncOnReplication",
} as const;
const settingKey = settingMap[property];
const currentValue = this.services.setting.currentSettings()[settingKey] ?? "";
await this.services.setting.applyPartial(
{
[settingKey]: peer[property]
? removeFromList(peer.name, currentValue)
: addToList(peer.name, currentValue),
},
true
);
}
getDeviceName(): string {
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { type PluginManifest, TFile } from "@/deps.ts";
import type { PluginManifest, TFile } from "@/deps.ts";
import { type DatabaseEntry, type EntryBody, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
export type { CacheData, FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
@@ -1,14 +1,14 @@
<script lang="ts">
import { onMount, setContext } from "svelte";
import { onMount } from "svelte";
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
AcceptedStatus,
ConnectionStatus,
type PeerStatus,
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import type { P2PReplicatorPaneController } from "./P2PReplicatorPaneController";
import type { P2PReplicatorPaneHost } from "./P2PReplicatorPaneHost";
import PeerStatusRow from "@/features/P2PSync/P2PReplicator/PeerStatusRow.svelte";
import { EVENT_LAYOUT_READY, eventHub } from "@/common/events";
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import {
type PeerInfo,
type P2PServerInfo,
@@ -20,16 +20,16 @@
import { $msg as _msg } from "@/common/translation";
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { generateP2PRoomId } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
interface Props {
getCmdSync: () => P2PReplicatorPaneController;
core: Pick<LiveSyncBaseCore, "services">;
host: P2PReplicatorPaneHost;
}
let { getCmdSync, core }: Props = $props();
setContext("getReplicator", () => getCmdSync());
const currentSettings = () => core.services.setting.currentSettings() as P2PSyncSetting;
let { host }: Props = $props();
let services = $derived(host.services);
let events = $derived(services.context.events);
const currentSettings = () => services.setting.currentSettings() as P2PSyncSetting;
const currentReplicator = () => host.p2p.replicator;
const initialSettings = { ...currentSettings() } as P2PSyncSetting;
let settings = $state<P2PSyncSetting>(initialSettings);
@@ -81,7 +81,7 @@
// P2P_AutoStart: eAutoStart,
// P2P_AutoBroadcast: eAutoBroadcast,
// };
await core.services.setting.applyPartial(
await services.setting.applyPartial(
{
P2P_Enabled: eP2PEnabled,
P2P_relays: eRelay,
@@ -94,7 +94,7 @@
},
true
);
core.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
deviceName = eDeviceName;
}
async function revert() {
@@ -113,7 +113,7 @@
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
if (force) {
const initDeviceName =
core.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? core.services.vault.getVaultName();
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
deviceName = initDeviceName;
eDeviceName = initDeviceName;
}
@@ -131,21 +131,22 @@
settings = d;
};
onMount(() => {
const r = eventHub.onEvent("setting-saved", async (d) => {
const r = events.onEvent("setting-saved", async (d) => {
applyLoadSettings(d, false);
closeServer();
});
const rx = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
const rx = events.onEvent(EVENT_LAYOUT_READY, () => {
applyLoadSettings(currentSettings(), true);
});
const r2 = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
const r2 = events.onEvent(EVENT_SERVER_STATUS, (status) => {
serverInfo = status;
advertisements = status?.knownAdvertisements ?? [];
});
const r3 = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorInfo = status;
});
eventHub.emitEvent(EVENT_REQUEST_STATUS);
applyLoadSettings(currentSettings(), true);
events.emitEvent(EVENT_REQUEST_STATUS);
return () => {
r();
rx();
@@ -222,22 +223,22 @@
}
async function openServer() {
await getCmdSync().open();
await currentReplicator().open();
}
async function closeServer() {
await getCmdSync().close();
await currentReplicator().close();
}
function startBroadcasting() {
void getCmdSync().enableBroadcastChanges();
currentReplicator().enableBroadcastChanges();
}
function stopBroadcasting() {
void getCmdSync().disableBroadcastChanges();
currentReplicator().disableBroadcastChanges();
}
const initialDialogStatusKey = `p2p-dialog-status`;
const getDialogStatus = () => {
try {
const initialDialogStatus = JSON.parse(core.services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
notice?: boolean;
setting?: boolean;
};
@@ -254,10 +255,10 @@
notice: isNoticeOpened,
setting: isSettingOpened,
};
core.services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
});
let isObsidian = $derived.by(() => {
return core.services.API.getPlatform() === "obsidian";
return services.API.getPlatform() === "obsidian";
});
</script>
@@ -434,7 +435,7 @@
</thead>
<tbody>
{#each peers as peer}
<PeerStatusRow peerStatus={peer}></PeerStatusRow>
<PeerStatusRow peerStatus={peer} p2p={host.p2p} showPeerMenu={host.showPeerMenu}></PeerStatusRow>
{/each}
</tbody>
</table>
@@ -1,17 +0,0 @@
import type {
AcceptanceDecision,
RevokeAcceptanceDecision,
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
/** The operations used by the shared P2P pane, independent of its replicator implementation. */
export interface P2PReplicatorPaneController {
open(): Promise<void>;
close(): Promise<void>;
enableBroadcastChanges(): void;
disableBroadcastChanges(): void;
makeDecision(decision: AcceptanceDecision): Promise<void>;
revokeDecision(decision: RevokeAcceptanceDecision): Promise<void>;
watchPeer(peerId: string): void;
unwatchPeer(peerId: string): void;
sync(peerId: string, showNotice?: boolean): Promise<unknown>;
}
@@ -0,0 +1,12 @@
import type { RequiredServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
export type P2PReplicatorHandle = Pick<UseP2PReplicatorResult, "replicator">;
/** Host capabilities consumed by the shared P2P pane. */
export interface P2PReplicatorPaneHost {
readonly services: RequiredServices<"API" | "config" | "setting" | "vault">;
readonly p2p: P2PReplicatorHandle;
readonly showPeerMenu?: (peer: PeerStatus, event: MouseEvent) => void;
}
@@ -2,12 +2,11 @@ import { Menu, WorkspaceLeaf } from "@/deps.ts";
import ReplicatorPaneComponent from "./P2PReplicatorPane.svelte";
import { mount } from "svelte";
import { SvelteItemView } from "@/common/SvelteItemView.ts";
import { eventHub } from "@/common/events.ts";
import { unique } from "octagonal-wheels/collection";
import { LOG_LEVEL_NOTICE, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { EVENT_P2P_PEER_SHOW_EXTRA_MENU, type PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import type { P2PPaneParams } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
export const VIEW_TYPE_P2P = "p2p-replicator";
@@ -127,46 +126,45 @@ And you can also drop the local database to rebuild from the remote device.`,
super(leaf);
this.core = core;
this._p2pResult = p2pResult;
eventHub.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => {
if (this.m) {
this.m.hide();
}
this.m = new Menu()
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer)))
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer)))
.addSeparator()
.addItem((item) => {
item.setTitle("🔧 Get Configuration").onClick(async () => {
await this.getRemoteConfig(peer);
});
})
.addSeparator()
.addItem((item) => {
const mark = peer.syncOnConnect ? "checkmark" : null;
item.setTitle("Toggle Sync on connect")
.onClick(async () => {
await this.toggleProp(peer, "syncOnConnect");
})
.setIcon(mark);
})
.addItem((item) => {
const mark = peer.watchOnConnect ? "checkmark" : null;
item.setTitle("Toggle Watch on connect")
.onClick(async () => {
await this.toggleProp(peer, "watchOnConnect");
})
.setIcon(mark);
})
.addItem((item) => {
const mark = peer.syncOnReplicationCommand ? "checkmark" : null;
item.setTitle("Toggle Sync on `Replicate now` command")
.onClick(async () => {
await this.toggleProp(peer, "syncOnReplicationCommand");
})
.setIcon(mark);
}
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
this.m?.hide();
this.m = new Menu()
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer)))
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer)))
.addSeparator()
.addItem((item) => {
item.setTitle("🔧 Get Configuration").onClick(async () => {
await this.getRemoteConfig(peer);
});
this.m.showAtPosition({ x: event.x, y: event.y });
});
})
.addSeparator()
.addItem((item) => {
const mark = peer.syncOnConnect ? "checkmark" : null;
item.setTitle("Toggle Sync on connect")
.onClick(async () => {
await this.toggleProp(peer, "syncOnConnect");
})
.setIcon(mark);
})
.addItem((item) => {
const mark = peer.watchOnConnect ? "checkmark" : null;
item.setTitle("Toggle Watch on connect")
.onClick(async () => {
await this.toggleProp(peer, "watchOnConnect");
})
.setIcon(mark);
})
.addItem((item) => {
const mark = peer.syncOnReplicationCommand ? "checkmark" : null;
item.setTitle("Toggle Sync on `Replicate now` command")
.onClick(async () => {
await this.toggleProp(peer, "syncOnReplicationCommand");
})
.setIcon(mark);
});
void this.m.showAtPosition({ x: event.x, y: event.y });
}
getViewType() {
@@ -187,8 +185,11 @@ And you can also drop the local database to rebuild from the remote device.`,
return mount(ReplicatorPaneComponent, {
target: target,
props: {
getCmdSync: () => this._p2pResult.replicator,
core: this.core,
host: {
services: this.core.services,
p2p: this._p2pResult,
showPeerMenu: (peer: PeerStatus, event: MouseEvent) => this.showPeerMenu(peer, event),
},
},
});
}
@@ -1,20 +1,22 @@
<script lang="ts">
import { getContext } from "svelte";
import { AcceptedStatus, type PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import type { P2PReplicatorPaneController } from "./P2PReplicatorPaneController";
import { eventHub } from "@/common/events";
import { EVENT_P2P_PEER_SHOW_EXTRA_MENU } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
import type { P2PReplicatorHandle } from "./P2PReplicatorPaneHost";
interface Props {
peerStatus: PeerStatus;
p2p: P2PReplicatorHandle;
showPeerMenu?: (peer: PeerStatus, event: MouseEvent) => void;
}
let { peerStatus }: Props = $props();
let { peerStatus, p2p, showPeerMenu }: Props = $props();
let peer = $derived(peerStatus);
const currentReplicator = () => p2p.replicator;
function select<T extends string | number | symbol, U>(d: T, cond: Record<T, U>): U;
function select<T extends string | number | symbol, U, V>(d: T, cond: Record<T, U>, def: V): U | V;
function select<T extends string | number | symbol, U>(d: T, cond: Record<T, U>, def?: U): U | undefined {
function select<T extends PropertyKey, U, V = undefined>(
d: T,
cond: Partial<Record<T, U>>,
def: V | undefined = undefined
): U | V | undefined {
return d in cond ? cond[d] : def;
}
@@ -36,7 +38,7 @@
[AcceptedStatus.UNKNOWN]: "NEW",
},
""
)
) ?? ""
);
const classList = {
["SENDING"]: "connected",
@@ -57,7 +59,7 @@
let isNew = $derived.by(() => peer.accepted === AcceptedStatus.UNKNOWN);
function makeDecision(isAccepted: boolean, isTemporary: boolean) {
getReplicator().makeDecision({
currentReplicator().makeDecision({
peerId: peer.peerId,
name: peer.name,
decision: isAccepted,
@@ -65,13 +67,11 @@
});
}
function revokeDecision() {
getReplicator().revokeDecision({
currentReplicator().revokeDecision({
peerId: peer.peerId,
name: peer.name,
});
}
const getReplicator = getContext<() => P2PReplicatorPaneController>("getReplicator");
const peerAttrLabels = $derived.by(() => {
const attrs = [];
if (peer.syncOnConnect) {
@@ -86,18 +86,18 @@
return attrs;
});
function startWatching() {
getReplicator().watchPeer(peer.peerId);
currentReplicator().watchPeer(peer.peerId);
}
function stopWatching() {
getReplicator().unwatchPeer(peer.peerId);
currentReplicator().unwatchPeer(peer.peerId);
}
function sync() {
void getReplicator().sync(peer.peerId, false);
void currentReplicator().sync(peer.peerId, false);
}
function moreMenu(evt: MouseEvent) {
eventHub.emitEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, { peer, event: evt });
showPeerMenu?.(peer, evt);
}
</script>
@@ -159,7 +159,9 @@
{:else}
<button class="button" onclick={startWatching} title="live"></button>
{/if}
<button class="button" onclick={moreMenu}>...</button>
{#if showPeerMenu}
<button class="button" onclick={moreMenu}>...</button>
{/if}
</div>
</div>
{/if}
@@ -41,8 +41,7 @@
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
<Options>
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
This is the most suitable synchronisation method for the design. All functions are available. You must have
set up a CouchDB instance.
{translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc")}
</Option>
<Option
selectedValue={TYPE_BUCKET}
+22 -9
View File
@@ -2,18 +2,16 @@ import { Modal } from "@/deps";
import {
SvelteDialogManagerBase,
SvelteDialogMixIn,
type ComponentHasResult,
type SvelteDialogManagerDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
export const SvelteDialogBase = SvelteDialogMixIn(Modal, DialogHost);
export class SvelteDialogObsidian<
T,
U,
C extends ObsidianServiceContext = ObsidianServiceContext,
> extends SvelteDialogBase<T, U, C> {
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
export class SvelteDialogObsidian<T, U, C extends ObsidianServiceContext = ObsidianServiceContext> extends Modal {
private readonly session: SvelteDialogSession<T, U, C>;
constructor(
context: C,
dependents: SvelteDialogManagerDependencies<C>,
@@ -21,13 +19,28 @@ export class SvelteDialogObsidian<
initialData?: U
) {
super(context.app);
this.initDialog(context, dependents, component, initialData);
this.session = new SvelteDialogSession({
surface: this,
context,
dependencies: dependents,
dialogHost: DialogHost,
component,
initialData,
});
}
override onOpen(): void {
super.onOpen();
this.session.onOpen();
this.contentEl.closest(".modal-container")?.classList.add("livesync-svelte-dialog-container");
}
override onClose(): void {
this.session.onClose();
}
waitForClose(): Promise<T | undefined> {
return this.session.waitForClose();
}
}
export class ObsidianSvelteDialogManager<T extends ObsidianServiceContext> extends SvelteDialogManagerBase<T> {
+138
View File
@@ -0,0 +1,138 @@
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import {
setupDialogContext,
type ComponentHasResult,
type DialogContext,
type DialogHostProps,
type DialogSvelteComponentBaseProps,
type SvelteDialogManagerDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
import { mount, unmount, type Component } from "svelte";
/** Host dialogue surface controlled by an explicit session. */
export interface SvelteDialogSurface {
readonly contentEl: HTMLElement;
setTitle(title: string): unknown;
close(): void;
}
type MountedDialog = ReturnType<typeof mount>;
/** Injectable renderer used to keep the dialogue lifecycle independently testable. */
export interface SvelteDialogRenderer {
mount(dialogHost: Component<DialogHostProps>, target: HTMLElement, props: DialogHostProps): MountedDialog;
unmount(component: MountedDialog): Promise<void>;
}
export interface SvelteDialogSessionOptions<TResult, TInitial, TContext extends ServiceContext> {
surface: SvelteDialogSurface;
context: TContext;
dependencies: SvelteDialogManagerDependencies<TContext>;
dialogHost: Component<DialogHostProps>;
component: ComponentHasResult<TResult, TInitial>;
initialData?: TInitial;
renderer?: SvelteDialogRenderer;
}
const svelteDialogRenderer: SvelteDialogRenderer = {
mount(dialogHost, target, props) {
return mount(dialogHost, { target, props });
},
async unmount(component) {
await unmount(component);
},
};
/** Owns one Svelte dialogue result and mount lifecycle for a concrete host surface. */
export class SvelteDialogSession<
TResult,
TInitial,
TContext extends ServiceContext = ServiceContext,
> {
private readonly surface: SvelteDialogSurface;
private readonly context: TContext;
private readonly dependencies: SvelteDialogManagerDependencies<TContext>;
private readonly dialogHost: Component<DialogHostProps>;
private readonly component: ComponentHasResult<TResult, TInitial>;
private readonly initialData?: TInitial;
private readonly renderer: SvelteDialogRenderer;
private result?: TResult;
private pendingResult?: PromiseWithResolvers<TResult | undefined>;
private mountedDialog?: MountedDialog;
private unregisterUnload?: () => void;
constructor(options: SvelteDialogSessionOptions<TResult, TInitial, TContext>) {
this.surface = options.surface;
this.context = options.context;
this.dependencies = options.dependencies;
this.dialogHost = options.dialogHost;
this.component = options.component;
this.initialData = options.initialData;
this.renderer = options.renderer ?? svelteDialogRenderer;
}
onOpen(): void {
this.surface.contentEl.replaceChildren();
this.unregisterUnload?.();
this.unregisterUnload = undefined;
if (this.pendingResult) {
this.pendingResult.reject(new Error("Dialogue opened again"));
}
this.result = undefined;
const pendingResult = promiseWithResolvers<TResult | undefined>();
this.pendingResult = pendingResult;
this.unregisterUnload = this.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
if (this.pendingResult !== pendingResult) {
return;
}
this.pendingResult = undefined;
pendingResult.reject(new Error("Plug-in unloaded"));
this.surface.close();
});
this.mountedDialog = this.renderer.mount(this.dialogHost, this.surface.contentEl, {
onSetupContext: (props: DialogSvelteComponentBaseProps<TResult, TInitial>) => {
setupDialogContext({
...props,
context: this.context,
services: this.dependencies,
} satisfies DialogContext<TContext, TResult, TInitial>);
},
setTitle: (title: string) => {
this.surface.setTitle(title);
},
closeDialog: () => {
this.surface.close();
},
setResult: (result: TResult) => {
this.result = result;
},
getInitialData: () => this.initialData,
mountComponent: this.component,
});
}
waitForClose(): Promise<TResult | undefined> {
if (!this.pendingResult) {
throw new Error("Dialogue has not been opened");
}
return this.pendingResult.promise;
}
onClose(): void {
this.unregisterUnload?.();
this.unregisterUnload = undefined;
this.pendingResult?.resolve(this.result);
this.pendingResult = undefined;
const mountedDialog = this.mountedDialog;
this.mountedDialog = undefined;
if (mountedDialog) {
fireAndForget(() => this.renderer.unmount(mountedDialog));
}
}
}
-4
View File
@@ -360,10 +360,6 @@ body {
justify-content: center;
}
body:not(.is-mobile):has(.sls-setting) .notice:has(.sls-onboarding-invitation-action) {
margin-right: 96px;
}
.sls-review-harness {
box-sizing: border-box;
max-width: 100%;
@@ -0,0 +1,88 @@
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import type {
ComponentHasResult,
DialogHostProps,
SvelteDialogManagerDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { Component } from "svelte";
import { describe, expect, it, vi } from "vitest";
import {
SvelteDialogSession,
type SvelteDialogRenderer,
type SvelteDialogSurface,
} from "@/modules/services/SvelteDialogSession";
describe("SvelteDialogSession", () => {
it("mounts, resolves the selected result, and unmounts through the owning surface", async () => {
const context = createServiceContext();
const replaceChildren = vi.fn();
const setTitle = vi.fn();
const close = vi.fn();
const mounted = {};
const unmount = vi.fn().mockResolvedValue(undefined);
let mountedProps: DialogHostProps<string, string> | undefined;
const renderer: SvelteDialogRenderer = {
mount(_dialogHost, _target, props) {
mountedProps = props as DialogHostProps<string, string>;
return mounted as never;
},
unmount,
};
const session = new SvelteDialogSession({
surface: {
contentEl: { replaceChildren } as unknown as HTMLElement,
close,
setTitle,
},
context,
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
component: (() => {}) as unknown as ComponentHasResult<string, string>,
initialData: "initial",
renderer,
});
session.onOpen();
const result = session.waitForClose();
mountedProps?.setTitle("Dialogue title");
mountedProps?.setResult("selected");
session.onClose();
await expect(result).resolves.toBe("selected");
expect(replaceChildren).toHaveBeenCalledOnce();
expect(setTitle).toHaveBeenCalledWith("Dialogue title");
const getInitialData = mountedProps?.getInitialData;
expect(getInitialData).toBeTypeOf("function");
expect(getInitialData?.()).toBe("initial");
await vi.waitFor(() => expect(unmount).toHaveBeenCalledWith(mounted));
});
it("rejects the pending result and closes the surface when the owning context unloads", async () => {
const context = createServiceContext();
const close = vi.fn();
const session = new SvelteDialogSession({
surface: {
contentEl: { replaceChildren: vi.fn() } as unknown as HTMLElement,
close,
setTitle: vi.fn(),
},
context,
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
component: (() => {}) as unknown as ComponentHasResult<string, string>,
renderer: {
mount: () => ({}) as never,
unmount: vi.fn().mockResolvedValue(undefined),
},
});
session.onOpen();
const result = session.waitForClose();
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
await expect(result).rejects.toThrow("Plug-in unloaded");
expect(close).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,34 @@
import { LOG_LEVEL_INFO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FSAPIStorageEventManagerAdapter } from "@/apps/webapp/managers/FSAPIStorageEventManagerAdapter";
import type { FSAPIFile } from "@/apps/webapp/adapters/FSAPITypes";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("WebApp file watching guidance", () => {
it("offers a capability-based manual fallback when FileSystemObserver is unavailable", async () => {
vi.stubGlobal("FileSystemObserver", undefined);
const addLog = vi.fn();
const adapter = new FSAPIStorageEventManagerAdapter({} as FileSystemDirectoryHandle, addLog);
const handlers = {
onCreate: vi.fn(),
onChange: vi.fn(),
onDelete: vi.fn(),
onRename: vi.fn(),
onRaw: vi.fn(),
} satisfies IStorageEventWatchHandlers<FSAPIFile>;
await adapter.watch.beginWatch(handlers);
expect(addLog).toHaveBeenCalledWith(
"Use 'Scan local files' after external changes",
LOG_LEVEL_INFO,
"fsapi-watch"
);
expect(addLog.mock.calls.map(([message]) => String(message)).join("\n")).not.toMatch(/Chrome \d+/);
});
});
+242
View File
@@ -0,0 +1,242 @@
import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub";
const runtimeMocks = vi.hoisted(() => {
const addLog = vi.fn();
const cleanup = vi.fn();
const currentSettings = vi.fn();
const getFiles = vi.fn();
const onLoad = vi.fn();
const onReady = vi.fn();
const onUnload = vi.fn();
const scanVault = vi.fn();
const scanDirectory = vi.fn();
const clearCache = vi.fn();
const collectFilesOnStorage = vi.fn();
const updateToDatabase = vi.fn();
const p2p = {
replicator: {},
};
const serviceHub = {
API: { addLog },
control: { onLoad, onReady, onUnload },
setting: { currentSettings },
vault: { scanVault },
};
const platformModules = {
fileHandler: {},
storageAccess: {},
storageEventManager: { cleanup },
vaultAccess: {
fsapiAdapter: {
clearCache,
getFiles,
scanDirectory,
},
},
};
return {
addLog,
options: undefined as LiveSyncBrowserServiceHubOptions<never> | undefined,
clearCache,
cleanup,
collectFilesOnStorage,
currentSettings,
getFiles,
onLoad,
onReady,
onUnload,
platformModules,
p2p,
scanDirectory,
scanVault,
serviceHub,
updateToDatabase,
};
});
vi.mock("@/apps/browser/createLiveSyncBrowserServiceHub", () => ({
createLiveSyncBrowserServiceHub: vi.fn((options: LiveSyncBrowserServiceHubOptions<never>) => {
runtimeMocks.options = options;
return runtimeMocks.serviceHub;
}),
}));
vi.mock("@/apps/webapp/serviceModules/FSAPIServiceModules", () => ({
initialiseServiceModulesFSAPI: vi.fn(() => runtimeMocks.platformModules),
}));
vi.mock("@/LiveSyncBaseCore", () => ({
LiveSyncBaseCore: class {
readonly serviceModules;
readonly services;
constructor(
services: typeof runtimeMocks.serviceHub,
initialisePlatformModules: (core: unknown, serviceHub: typeof runtimeMocks.serviceHub) => unknown,
_initialiseCoreModules: () => unknown[],
_getAddOns: () => unknown[],
initialiseFeatures: (core: unknown) => void
) {
this.services = services;
this.serviceModules = initialisePlatformModules(this, services);
initialiseFeatures(this);
}
},
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", () => ({
collectFilesOnStorage: runtimeMocks.collectFilesOnStorage,
updateToDatabase: runtimeMocks.updateToDatabase,
useOfflineScanner: vi.fn(),
}));
vi.mock("@/serviceFeatures/redFlag", () => ({
useRedFlagFeatures: vi.fn(),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize", () => ({
useCheckRemoteSize: vi.fn(),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", () => ({
useRemoteConfiguration: vi.fn(),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature", () => ({
useP2PReplicatorFeature: vi.fn(() => runtimeMocks.p2p),
}));
import { WebAppRuntime } from "@/apps/webapp/WebAppRuntime";
const unconfiguredSettings = {
couchDB_DBNAME: "",
isConfigured: false,
} as ObsidianLiveSyncSettings;
function createRootHandle(name = "runtime-vault"): FileSystemDirectoryHandle {
return { name } as FileSystemDirectoryHandle;
}
async function waitForMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("WebAppRuntime lifecycle", () => {
beforeEach(() => {
vi.clearAllMocks();
runtimeMocks.options = undefined;
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
runtimeMocks.getFiles.mockResolvedValue([{ path: "one.md" }]);
runtimeMocks.onLoad.mockResolvedValue(true);
runtimeMocks.onReady.mockResolvedValue(undefined);
runtimeMocks.onUnload.mockResolvedValue(undefined);
runtimeMocks.cleanup.mockResolvedValue(undefined);
runtimeMocks.clearCache.mockReturnValue(undefined);
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
storageFileNameMap: {
"one.md": {
path: "one.md",
stat: { ctime: 1, mtime: 1, size: 3, type: "file" },
},
},
storageFileNames: ["one.md"],
storageFileNameCI2CS: { "one.md": "one.md" },
});
runtimeMocks.scanDirectory.mockResolvedValue(undefined);
runtimeMocks.scanVault.mockResolvedValue(false);
runtimeMocks.updateToDatabase.mockResolvedValue(undefined);
});
it("owns core start, directory scan, status reporting, and shutdown", async () => {
const reportStatus = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
await runtime.start();
expect(runtimeMocks.onLoad).toHaveBeenCalledOnce();
expect(runtimeMocks.onReady).toHaveBeenCalledOnce();
expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce();
expect(runtimeMocks.getFiles).toHaveBeenCalledOnce();
expect(runtimeMocks.addLog).toHaveBeenCalledWith("Found 1 files", expect.anything(), "scan");
expect(reportStatus).toHaveBeenCalledWith(
"warning",
"Warning: Please configure CouchDB connection in settings"
);
expect(runtime.p2pPaneHost.services).toBe(runtimeMocks.serviceHub);
expect(runtime.p2pPaneHost.p2p).toBe(runtimeMocks.p2p);
expect(runtime.p2pPaneHost.showPeerMenu).toBeUndefined();
expect("p2pController" in runtime).toBe(false);
await runtime.shutdown();
expect(runtimeMocks.cleanup).toHaveBeenCalledOnce();
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
});
it("imports local files for optional P2P while the main remote remains unconfigured", async () => {
const runtime = new WebAppRuntime(createRootHandle());
await runtime.start();
vi.clearAllMocks();
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
storageFileNameMap: {
"one.md": {
path: "one.md",
stat: { ctime: 1, mtime: 1, size: 3, type: "file" },
},
},
storageFileNames: ["one.md"],
storageFileNameCI2CS: { "one.md": "one.md" },
});
await expect(runtime.scanLocalFiles()).resolves.toBe(true);
expect(runtimeMocks.clearCache).toHaveBeenCalledOnce();
expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce();
expect(runtimeMocks.collectFilesOnStorage).toHaveBeenCalledWith(
expect.objectContaining({
services: runtimeMocks.serviceHub,
serviceModules: runtimeMocks.platformModules,
}),
unconfiguredSettings,
expect.any(Function)
);
expect(runtimeMocks.updateToDatabase).toHaveBeenCalledOnce();
expect(runtimeMocks.scanVault).not.toHaveBeenCalled();
expect(runtimeMocks.currentSettings()).toBe(unconfiguredSettings);
expect(unconfiguredSettings.isConfigured).toBe(false);
});
it("rejects a failed core start after cleaning up the partial runtime", async () => {
runtimeMocks.onLoad.mockResolvedValue(false);
const reportStatus = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync");
expect(runtimeMocks.cleanup).toHaveBeenCalledOnce();
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
expect(reportStatus).toHaveBeenCalledWith(
"error",
"Error: Failed to start: Error: Failed to initialise LiveSync"
);
});
it("shuts down once before scheduling a host reload", async () => {
const scheduleReload = vi.fn();
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload });
await runtime.start();
runtimeMocks.options?.restart?.schedule();
runtimeMocks.options?.restart?.schedule();
await waitForMicrotasks();
expect(runtimeMocks.options?.restart?.isScheduled?.()).toBe(true);
expect(runtimeMocks.cleanup).toHaveBeenCalledOnce();
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
expect(scheduleReload).toHaveBeenCalledOnce();
expect(scheduleReload).toHaveBeenCalledWith(1_000);
expect(runtimeMocks.addLog).toHaveBeenCalledWith("Restart requested", LOG_LEVEL_INFO, "app-lifecycle");
});
});
@@ -0,0 +1,58 @@
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { describe, expect, it } from "vitest";
import {
WEBPEER_SETTINGS_KEY,
createWebPeerPersistence,
} from "@/apps/webpeer/src/WebPeerPersistence";
function createMemoryStore(initial: Record<string, unknown> = {}): SimpleStore<unknown> {
const values = new Map(Object.entries(initial));
return {
db: Promise.resolve(undefined),
get: async (key) => values.get(key),
set: async (key, value) => {
values.set(key, value);
},
delete: async (key) => {
values.delete(key);
},
keys: async (from, to, count) => {
const selected = [...values.keys()]
.sort()
.filter((key) => (from === undefined || key >= from) && (to === undefined || key <= to));
return count === undefined ? selected : selected.slice(0, count);
},
};
}
describe("WebPeer settings persistence", () => {
it("loads P2P-only defaults and saves the complete settings object", async () => {
const store = createMemoryStore({
[WEBPEER_SETTINGS_KEY]: {
P2P_AppID: "custom-app",
P2P_roomID: "room-42",
P2P_Enabled: true,
},
});
const persistence = createWebPeerPersistence(store);
const loaded = await persistence.settings.load();
expect(loaded).toEqual(
expect.objectContaining({
P2P_AppID: "custom-app",
P2P_roomID: "room-42",
P2P_Enabled: true,
remoteType: REMOTE_P2P,
isConfigured: true,
additionalSuffixOfDatabaseName: "",
suspendParseReplicationResult: true,
})
);
const updated = { ...loaded!, P2P_AutoStart: true };
await persistence.settings.save(updated);
await expect(store.get(WEBPEER_SETTINGS_KEY)).resolves.toEqual(updated);
});
});
@@ -0,0 +1,76 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { describe, expect, it, vi } from "vitest";
import { WebPeerRuntime } from "@/apps/webpeer/src/WebPeerRuntime";
function createMemoryStore(): SimpleStore<unknown> {
const values = new Map<string, unknown>();
return {
db: Promise.resolve(undefined),
get: async (key) => values.get(key),
set: async (key, value) => {
values.set(key, value);
},
delete: async (key) => {
values.delete(key);
},
keys: async () => [...values.keys()],
};
}
describe("WebPeer runtime composition", () => {
it("preserves one context and live P2P result across the service graph and pane host", () => {
const context = createServiceContext({
translate: (key) => `webpeer:${key}`,
});
const runtime = new WebPeerRuntime({
context,
store: createMemoryStore(),
});
expect(runtime.context).toBe(context);
expect(runtime.services.context).toBe(context);
expect(runtime.events).toBe(context.events);
expect(runtime.currentReplicator).toBe(runtime.p2p.replicator);
expect(runtime.paneHost.services).toBe(runtime.services);
expect(runtime.paneHost.p2p).toBe(runtime.p2p);
expect(runtime.paneHost.showPeerMenu).toBeTypeOf("function");
expect("controller" in runtime).toBe(false);
});
it("loads settings and opens the local database before announcing layout readiness", async () => {
const runtime = new WebPeerRuntime({
store: createMemoryStore(),
});
const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({
...DEFAULT_SETTINGS,
P2P_AutoStart: false,
P2P_Enabled: false,
});
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true);
const markIsReady = vi.spyOn(runtime.services.appLifecycle, "markIsReady");
const layoutReady = vi.fn();
runtime.events.onEvent(EVENT_LAYOUT_READY, layoutReady);
await expect(runtime.start()).resolves.toBe(runtime);
expect(loadSettings).toHaveBeenCalledOnce();
expect(openDatabase).toHaveBeenCalledOnce();
expect(markIsReady).toHaveBeenCalledOnce();
expect(layoutReady).toHaveBeenCalledOnce();
});
it("rejects start when the browser-local database cannot be opened", async () => {
const runtime = new WebPeerRuntime({
store: createMemoryStore(),
});
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false);
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
});
});
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
FROM denoland/deno:bin-2.9.2 AS deno
FROM mcr.microsoft.com/playwright:v1.62.0-noble
COPY --from=deno /deno /usr/local/bin/deno
ENV DENO_DIR=/deno-dir
WORKDIR /opt/livesync-browser-apps-interop
COPY test/browser-apps/deno.json test/browser-apps/deno.lock ./
COPY test/browser-apps/helpers ./helpers
COPY test/browser-apps/interop.test.ts ./
RUN deno cache --frozen --config deno.json --lock deno.lock interop.test.ts
WORKDIR /workspace
CMD ["deno", "test", "-A", "--no-check", "--frozen", "--config", "test/browser-apps/deno.json", "--lock", "test/browser-apps/deno.lock", "test/browser-apps/interop.test.ts"]
+35
View File
@@ -0,0 +1,35 @@
# Browser application tests
Browser application tests use Deno and headless Chromium. They do not depend on Obsidian or the retired browser Harness.
Application unit tests are stored in `test/apps/webapp/` and `test/apps/webpeer/`. Both the unit and browser tests remain outside `src`, because test-file ignores do not apply to the Community Review source boundary.
Each application has its own production-bundle smoke-test directory outside `src`:
- `test/browser-apps/webapp/` covers Vault selection, OPFS start-up, and isolation between optional P2P settings and the main remote.
- `test/browser-apps/webpeer/` covers start-up, settings persistence, and reload.
- `test/browser-apps/pages/` covers the final GitHub Pages layout, application subpaths, and relative assets.
Run both app-owned tests with:
```bash
npm run test:browser-apps
```
`test/browser-apps/interop.test.ts` is the only cross-application scenario. It configures the real WebApp and WebPeer interfaces, transfers a file from WebApp to WebPeer, then starts the built CLI as the final peer and verifies the file through the production CLI. The CLI is a test fixture in this scenario; the PoC test and its support code are not owned by the CLI package.
Run the isolated relay and TURN scenario in Compose with:
```bash
npm run test:e2e:browser-apps:interop
```
After assembling the GitHub Pages files in `_site`, run the package smoke test with:
```bash
npm run test:browser-apps:pages
```
Set `PAGES_SITE_ROOT` to test an assembled site in another directory.
The Deno dependency lock is shared only by these browser application tests.
+65
View File
@@ -0,0 +1,65 @@
name: livesync-browser-apps-interop
services:
nostr-relay:
image: ghcr.io/hoytech/strfry:latest
entrypoint: sh
command:
- -lc
- |
cat > /tmp/strfry.conf <<'EOF'
db = "./strfry-db/"
relay {
bind = "0.0.0.0"
port = 7777
nofiles = 65536
info {
name = "livesync browser application test relay"
description = "local relay for the browser application interoperability test"
}
maxWebsocketPayloadSize = 131072
autoPingSeconds = 55
writePolicy {
plugin = ""
}
}
EOF
exec /app/strfry --config /tmp/strfry.conf relay
tmpfs:
- /app/strfry-db:rw,size=256m,mode=1777
healthcheck:
test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"]
interval: 2s
timeout: 5s
retries: 30
coturn:
image: coturn/coturn:latest
command:
- --log-file=stdout
- --listening-port=3478
- --user=testuser:testpass
- --realm=livesync.test
browser-apps-interop:
build:
context: ../..
dockerfile: test/browser-apps/Dockerfile.interop
depends_on:
nostr-relay:
condition: service_healthy
coturn:
condition: service_started
environment:
BROWSER_APPS_P2P_RELAY_URL: ws://nostr-relay:7777/
BROWSER_APPS_P2P_TURN_SERVERS: turn:coturn:3478
BROWSER_APPS_P2P_TURN_USERNAME: testuser
BROWSER_APPS_P2P_TURN_CREDENTIAL: testpass
init: true
ipc: host
volumes:
- ../..:/workspace
+7
View File
@@ -0,0 +1,7 @@
{
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.13",
"@std/path": "jsr:@std/path@^1.0.9",
"playwright": "npm:playwright@1.62.0"
}
}
+55
View File
@@ -0,0 +1,55 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@^1.0.13": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@std/internal@^1.0.14": "1.0.14",
"jsr:@std/path@^1.0.9": "1.1.6",
"npm:playwright@1.62.0": "1.62.0"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal@^1.0.12"
]
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/path@1.1.6": {
"integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe",
"dependencies": [
"jsr:@std/internal@^1.0.14"
]
}
},
"npm": {
"fsevents@2.3.2": {
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"os": ["darwin"],
"scripts": true
},
"playwright-core@1.62.0": {
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"bin": true
},
"playwright@1.62.0": {
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dependencies": [
"playwright-core"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@^1.0.13",
"jsr:@std/path@^1.0.9",
"npm:playwright@1.62.0"
]
}
}
+103
View File
@@ -0,0 +1,103 @@
import { assertEquals } from "@std/assert";
import { extname, relative, resolve } from "@std/path";
import type { Page } from "playwright";
function contentType(path: string): string {
switch (extname(path)) {
case ".css":
return "text/css; charset=utf-8";
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".json":
case ".map":
return "application/json; charset=utf-8";
case ".svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
}
function resolveStaticPath(root: string, pathname: string): string | undefined {
const requested = decodeURIComponent(pathname).replace(/^\/+/, "") || "index.html";
const candidate = resolve(root, requested);
const relativePath = relative(root, candidate);
if (relativePath.startsWith("..") || relativePath.includes("\0")) {
return undefined;
}
return candidate;
}
export async function startStaticServer(root: string): Promise<{
readonly baseUrl: string;
close(): Promise<void>;
}> {
let reportListening!: (port: number) => void;
const listening = new Promise<number>((resolvePromise) => {
reportListening = resolvePromise;
});
const server = Deno.serve(
{
hostname: "127.0.0.1",
onListen: ({ port }) => reportListening(port),
port: 0,
},
async (request) => {
const path = resolveStaticPath(root, new URL(request.url).pathname);
if (path === undefined) {
return new Response("Not found", { status: 404 });
}
try {
const stat = await Deno.stat(path);
const filePath = stat.isDirectory ? resolve(path, "index.html") : path;
const file = await Deno.open(filePath, { read: true });
return new Response(file.readable, {
headers: {
"content-type": contentType(filePath),
},
});
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return new Response("Not found", { status: 404 });
}
throw error;
}
}
);
const port = await listening;
return {
baseUrl: `http://127.0.0.1:${port}/`,
close: async () => {
await server.shutdown();
},
};
}
export function observePageFailures(page: Page): () => void {
const failures: string[] = [];
page.on("pageerror", (error) => {
failures.push(`pageerror: ${error.stack ?? error.message}`);
});
page.on("console", (message) => {
if (message.type() === "error") {
failures.push(`console.error: ${message.text()}`);
}
});
return () => assertEquals(failures, [], failures.join("\n"));
}
export async function waitFor(
predicate: () => Promise<boolean>,
message: string,
timeoutMilliseconds = 5_000
): Promise<void> {
const deadline = Date.now() + timeoutMilliseconds;
while (!(await predicate())) {
if (Date.now() >= deadline) {
throw new Error(message);
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
}
}
+172
View File
@@ -0,0 +1,172 @@
import { resolve } from "@std/path";
const repositoryRoot = resolve(import.meta.dirname!, "../../..");
const cliDirectory = resolve(repositoryRoot, "src/apps/cli");
const cliDist = resolve(cliDirectory, "dist/index.cjs");
export interface CliResult {
readonly code: number;
readonly combined: string;
readonly stderr: string;
readonly stdout: string;
}
export class TemporaryDirectory implements AsyncDisposable {
private constructor(readonly path: string) {}
static async create(prefix: string): Promise<TemporaryDirectory> {
return new TemporaryDirectory(await Deno.makeTempDir({ prefix: `${prefix}.` }));
}
resolve(...parts: string[]): string {
return resolve(this.path, ...parts);
}
async [Symbol.asyncDispose](): Promise<void> {
await Deno.remove(this.path, { recursive: true }).catch(() => {});
}
}
async function collectStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void): Promise<void> {
const reader = stream.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
append(decoder.decode());
return;
}
if (value !== undefined) {
append(decoder.decode(value, { stream: true }));
}
}
} finally {
reader.releaseLock();
}
}
export async function runCli(...args: string[]): Promise<CliResult> {
const output = await new Deno.Command("node", {
args: [cliDist, ...args],
cwd: cliDirectory,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).output();
const decoder = new TextDecoder();
const stdout = decoder.decode(output.stdout);
const stderr = decoder.decode(output.stderr);
return {
code: output.code,
combined: stdout + stderr,
stderr,
stdout,
};
}
export async function initSettingsFile(path: string): Promise<void> {
const result = await runCli("init-settings", "--force", path);
if (result.code !== 0) {
throw new Error(`CLI settings initialisation failed with code ${result.code}\n${result.combined}`);
}
}
export function sanitiseCatStdout(raw: string): string {
return raw
.split("\n")
.filter((line) => line !== "[CLIWatchAdapter] File watching is not enabled in CLI version")
.join("\n");
}
export class BackgroundCliProcess {
#stdout = "";
#stderr = "";
readonly #stdoutDone: Promise<void>;
readonly #stderrDone: Promise<void>;
constructor(readonly child: Deno.ChildProcess) {
this.#stdoutDone = collectStream(child.stdout, (text) => {
this.#stdout += text;
});
this.#stderrDone = collectStream(child.stderr, (text) => {
this.#stderr += text;
});
}
get combined(): string {
return this.#stdout + this.#stderr;
}
async stop(): Promise<number> {
try {
this.child.kill("SIGTERM");
} catch {
// The process has already exited.
}
const status = await this.child.status;
await Promise.all([this.#stdoutDone, this.#stderrDone]);
return status.code;
}
}
export function startCliInBackground(...args: string[]): BackgroundCliProcess {
return new BackgroundCliProcess(
new Deno.Command("node", {
args: [cliDist, ...args],
cwd: cliDirectory,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).spawn()
);
}
type WaitForPortOptions = {
timeoutMs?: number;
intervalMs?: number;
connectTimeoutMs?: number;
};
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const connection = await Promise.race([
Deno.connect({ hostname, port }),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(`Connection timed out after ${timeoutMs} ms`)), timeoutMs);
}),
]);
connection.close();
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
export async function waitForPort(
hostname: string,
port: number,
options: WaitForPortOptions = {}
): Promise<void> {
const timeoutMs = options.timeoutMs ?? 15_000;
const intervalMs = options.intervalMs ?? 250;
const connectTimeoutMs = options.connectTimeoutMs ?? 1_000;
const started = Date.now();
let lastError: unknown;
while (Date.now() - started < timeoutMs) {
try {
await connectWithTimeout(hostname, port, connectTimeoutMs);
return;
} catch (error) {
lastError = error;
await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
}
}
throw new Error(
`Port ${hostname}:${port} did not become ready within ${timeoutMs} ms` +
(lastError === undefined ? "" : ` (last error: ${String(lastError)})`)
);
}
+609
View File
@@ -0,0 +1,609 @@
import { assertEquals, assertStringIncludes } from "@std/assert";
import { extname, relative, resolve } from "@std/path";
import { chromium, type Locator, type Page } from "playwright";
import {
type BackgroundCliProcess,
initSettingsFile,
runCli,
sanitiseCatStdout,
startCliInBackground,
TemporaryDirectory,
waitForPort,
} from "./helpers/cli-fixture.ts";
const repositoryRoot = resolve(import.meta.dirname!, "../..");
const webAppDist = resolve(repositoryRoot, "src/apps/webapp/dist");
const webPeerDist = resolve(repositoryRoot, "src/apps/webpeer/dist");
function contentType(path: string): string {
switch (extname(path)) {
case ".css":
return "text/css; charset=utf-8";
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".map":
return "application/json; charset=utf-8";
default:
return "application/octet-stream";
}
}
function resolveStaticPath(pathname: string): string | undefined {
const decodedPath = decodeURIComponent(pathname);
const routes: Array<[string, string]> = [
["/webapp/", webAppDist],
["/webpeer/", webPeerDist],
];
for (const [prefix, root] of routes) {
if (!decodedPath.startsWith(prefix)) {
continue;
}
const requested = decodedPath.slice(prefix.length) || "index.html";
const candidate = resolve(root, requested);
const relativePath = relative(root, candidate);
if (relativePath.startsWith("..") || relativePath.includes("\0")) {
return undefined;
}
return candidate;
}
return undefined;
}
async function serveBrowserApplications(request: Request): Promise<Response> {
const filePath = resolveStaticPath(new URL(request.url).pathname);
if (!filePath) {
return new Response("Not found", { status: 404 });
}
try {
const stat = await Deno.stat(filePath);
const resolvedFile = stat.isDirectory
? resolve(filePath, "index.html")
: filePath;
const file = await Deno.open(resolvedFile, { read: true });
return new Response(file.readable, {
headers: {
"content-type": contentType(resolvedFile),
},
});
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return new Response("Not found", { status: 404 });
}
throw error;
}
}
function observePageFailures(page: Page): () => void {
const failures: string[] = [];
page.on("pageerror", (error) => {
failures.push(`pageerror: ${error.stack ?? error.message}`);
});
page.on("console", (message) => {
if (message.type() === "error") {
failures.push(`console.error: ${message.text()}`);
}
});
return () => assertEquals(failures, [], failures.join("\n"));
}
function formatError(error: unknown): string {
return error instanceof Error
? (error.stack ?? error.message)
: String(error);
}
async function captureBrowserPage(
page: Page,
screenshotDirectory: string | undefined,
filename: string,
fullPage = true,
): Promise<void> {
if (screenshotDirectory === undefined) {
return;
}
const screenshotPath = resolve(screenshotDirectory, filename);
await page.screenshot({
animations: "disabled",
caret: "hide",
fullPage,
path: screenshotPath,
});
console.log(`[Browser applications E2E] Captured ${screenshotPath}`);
}
async function webPeerLogs(page: Page): Promise<string> {
return await page
.locator(".logslist")
.innerText()
.catch(() => "<WebPeer logs unavailable>");
}
async function waitForWebPeerLog(
page: Page,
message: string,
timeoutMilliseconds: number,
): Promise<void> {
try {
await page.locator(".logslist").filter({ hasText: message }).waitFor({
timeout: timeoutMilliseconds,
});
} catch (error) {
throw new Error(
`${formatError(error)}\n\nWebPeer logs:\n${await webPeerLogs(page)}`,
);
}
}
async function waitFor(
predicate: () => Promise<boolean>,
message: string,
timeoutMilliseconds = 5_000,
): Promise<void> {
const deadline = Date.now() + timeoutMilliseconds;
while (!(await predicate())) {
if (Date.now() >= deadline) {
throw new Error(message);
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
}
}
async function configureP2PPane(
root: Locator,
settings: {
deviceName: string;
passphrase: string;
relay: string;
room: string;
turnCredential: string;
turnServers: string;
turnUsername: string;
},
): Promise<void> {
const pane = root.locator("article");
const transportSettings = root.locator(".browser-p2p-transport-settings");
await pane.locator("input[type='checkbox']").first().check();
await pane.getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx").fill(
settings.relay,
);
await pane.getByPlaceholder("anything-you-like").fill(settings.room);
await pane.getByPlaceholder("password").fill(settings.passphrase);
await pane.getByPlaceholder("iphone-16").fill(settings.deviceName);
if (settings.turnServers !== "") {
await transportSettings.getByText("Optional TURN server settings", {
exact: true,
}).click();
await transportSettings.getByPlaceholder("turn:turn.example.com:3478").fill(
settings.turnServers,
);
await transportSettings.getByPlaceholder("Enter TURN username").fill(
settings.turnUsername,
);
await transportSettings.getByPlaceholder("Enter TURN credential").fill(
settings.turnCredential,
);
const saveTurn = transportSettings.getByRole("button", {
name: "Save TURN settings",
exact: true,
});
await saveTurn.click();
await waitFor(
async () => await saveTurn.isDisabled(),
`${settings.deviceName} did not apply its TURN settings`,
);
}
const save = pane.getByRole("button", {
name: "Save and Apply",
exact: true,
});
await save.click();
await waitFor(
async () => await save.isDisabled(),
`${settings.deviceName} did not apply its P2P settings`,
);
}
async function configureCliSettings(
settingsPath: string,
settings: {
deviceName: string;
passphrase: string;
relay: string;
room: string;
turnCredential: string;
turnServers: string;
turnUsername: string;
},
): Promise<void> {
await initSettingsFile(settingsPath);
const current = JSON.parse(await Deno.readTextFile(settingsPath)) as Record<
string,
unknown
>;
Object.assign(current, {
P2P_AppID: "self-hosted-livesync",
P2P_AutoAcceptingPeers: "~.*",
P2P_AutoBroadcast: false,
P2P_AutoDenyingPeers: "",
P2P_AutoStart: false,
P2P_DevicePeerName: settings.deviceName,
P2P_Enabled: true,
P2P_IsHeadless: true,
P2P_passphrase: settings.passphrase,
P2P_relays: settings.relay,
P2P_roomID: settings.room,
P2P_turnCredential: settings.turnCredential,
P2P_turnServers: settings.turnServers,
P2P_turnUsername: settings.turnUsername,
encrypt: false,
isConfigured: true,
passphrase: "",
remoteType: "ONLY_P2P",
usePathObfuscation: false,
});
await Deno.writeTextFile(settingsPath, JSON.stringify(current, null, 2));
}
async function waitForBackgroundExit(
process: BackgroundCliProcess,
timeoutMilliseconds: number,
): Promise<number> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const status = await Promise.race([
process.child.status,
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error(`CLI process timed out\n${process.combined}`)),
timeoutMilliseconds,
);
}),
]);
await process.stop();
return status.code;
} catch (error) {
await process.stop();
throw error;
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
Deno.test({
name: "browser applications: WebApp to WebPeer to CLI",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const relay = Deno.env.get("BROWSER_APPS_P2P_RELAY_URL") ??
"ws://127.0.0.1:4000/";
const turnServers = Deno.env.get("BROWSER_APPS_P2P_TURN_SERVERS") ?? "";
const turnUsername = Deno.env.get("BROWSER_APPS_P2P_TURN_USERNAME") ?? "";
const turnCredential = Deno.env.get("BROWSER_APPS_P2P_TURN_CREDENTIAL") ??
"";
const port = Number(Deno.env.get("BROWSER_APPS_INTEROP_PORT") ?? "43920");
const screenshotDirectory =
Deno.env.get("BROWSER_APPS_SCREENSHOT_DIR")?.trim() || undefined;
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const room = `browser-interop-${nonce}`;
const p2pPassphrase = `browser-interop-passphrase-${nonce}`;
const webAppDeviceName = `webapp-${nonce}`;
const webPeerDeviceName = `webpeer-${nonce}`;
const cliDeviceName = `cli-${nonce}`;
const rootName = `browser-interop-root-${nonce}`;
const notePath = "webapp-to-cli.md";
const noteContent =
`# Browser interoperability\n\nTransferred through WebApp, WebPeer, and CLI.\n${nonce}\n`;
if (screenshotDirectory !== undefined) {
await Deno.mkdir(screenshotDirectory, { recursive: true });
}
await using workDir = await TemporaryDirectory.create(
"livesync-browser-apps-interop",
);
const cliDatabasePath = workDir.resolve("cli-database");
const cliSettingsPath = workDir.resolve("cli-settings.json");
await Deno.mkdir(cliDatabasePath, { recursive: true });
await configureCliSettings(cliSettingsPath, {
deviceName: cliDeviceName,
passphrase: p2pPassphrase,
relay,
room,
turnCredential,
turnServers,
turnUsername,
});
const relayUrl = new URL(relay);
await waitForPort(
relayUrl.hostname,
Number(relayUrl.port || (relayUrl.protocol === "wss:" ? 443 : 80)),
{
timeoutMs: 30_000,
},
);
if (turnServers.startsWith("turn:")) {
const [hostnameAndPort] = turnServers.slice("turn:".length).split("?");
const separatorIndex = hostnameAndPort.lastIndexOf(":");
const hostname = hostnameAndPort.slice(0, separatorIndex);
const turnPort = Number(hostnameAndPort.slice(separatorIndex + 1));
if (hostname === "" || !Number.isFinite(turnPort)) {
throw new Error(`Unsupported TURN server URL: ${turnServers}`);
}
await waitForPort(hostname, turnPort, { timeoutMs: 30_000 });
}
const server = Deno.serve(
{
hostname: "127.0.0.1",
onListen: () => {},
port,
},
serveBrowserApplications,
);
const browser = await chromium.launch({ headless: true });
const webAppContext = await browser.newContext();
const webPeerContext = await browser.newContext();
const webAppPage = await webAppContext.newPage();
const webPeerPage = await webPeerContext.newPage();
const assertNoWebAppFailures = observePageFailures(webAppPage);
const assertNoWebPeerFailures = observePageFailures(webPeerPage);
const baseUrl = `http://127.0.0.1:${port}/`;
const pickerScript = `
const rootName = ${JSON.stringify(rootName)};
const notePath = ${JSON.stringify(notePath)};
const noteContent = ${JSON.stringify(noteContent)};
Object.defineProperty(globalThis, "showDirectoryPicker", {
configurable: true,
value: async () => {
const originRoot = await navigator.storage.getDirectory();
try {
await originRoot.removeEntry(rootName, { recursive: true });
} catch (error) {
if (error?.name !== "NotFoundError") throw error;
}
const selectedRoot = await originRoot.getDirectoryHandle(rootName, { create: true });
const note = await selectedRoot.getFileHandle(notePath, { create: true });
const writable = await note.createWritable();
await writable.write(noteContent);
await writable.close();
return selectedRoot;
},
});
`;
await webAppPage.addInitScript(pickerScript);
try {
await webPeerPage.goto(new URL("webpeer/", baseUrl).href);
await webPeerPage.getByRole("heading", {
name: "Peer to Peer Replicator",
exact: true,
}).waitFor({
timeout: 30_000,
});
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-initial.png",
);
await configureP2PPane(webPeerPage.locator(".control"), {
deviceName: webPeerDeviceName,
passphrase: p2pPassphrase,
relay,
room,
turnCredential,
turnServers,
turnUsername,
});
await webPeerPage.getByRole("button", { name: "Connect", exact: true })
.click();
await webPeerPage.getByText("Connected to Signaling Server", {
exact: false,
}).waitFor({
timeout: 30_000,
});
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-configured.png",
);
await webAppPage.goto(new URL("webapp/webapp.html", baseUrl).href);
await webAppPage.locator("#vault-selector").waitFor({
state: "visible",
timeout: 30_000,
});
await captureBrowserPage(
webAppPage,
screenshotDirectory,
"webapp-root-selection.png",
);
await webAppPage.getByRole("button", {
name: "Choose new vault folder",
exact: true,
}).click();
await webAppPage.locator("#vault-selector").waitFor({
state: "hidden",
timeout: 30_000,
});
const webAppP2P = webAppPage.locator(".p2p-control");
await webAppP2P.getByRole("heading", {
name: "Peer to Peer Replicator",
exact: true,
}).waitFor();
await configureP2PPane(webAppP2P, {
deviceName: webAppDeviceName,
passphrase: p2pPassphrase,
relay,
room,
turnCredential,
turnServers,
turnUsername,
});
await webAppP2P.getByRole("button", {
name: "Scan local files",
exact: true,
}).click();
await webAppP2P
.getByRole("status")
.filter({ hasText: "Local files are ready for synchronisation." })
.waitFor({ timeout: 30_000 });
await webAppP2P.getByRole("button", { name: "Connect", exact: true })
.click();
await webAppP2P.getByText("Connected to Signaling Server", {
exact: false,
}).waitFor({
timeout: 30_000,
});
await captureBrowserPage(
webAppPage,
screenshotDirectory,
"webapp-configured.png",
);
const webAppPeerRow = webPeerPage.locator("table.peers tbody tr").filter({
hasText: webAppDeviceName,
});
await webAppPeerRow.waitFor({ timeout: 30_000 });
await webAppPeerRow.getByRole("button", { name: "Accept", exact: true })
.click();
await webAppPeerRow.getByRole("button", { name: "🔄", exact: true })
.click();
const webAppConnectionRequest = webAppPage.locator(
"dialog.vpk-browser-dialog[open]",
);
await webAppConnectionRequest.waitFor({
state: "visible",
timeout: 30_000,
});
assertEquals(
await webAppConnectionRequest.getByRole("heading").innerText(),
"P2P Connection Request",
);
await captureBrowserPage(
webAppPage,
screenshotDirectory,
"webapp-connection-request.png",
false,
);
await webAppConnectionRequest
.getByRole("button", { name: "Accept", exact: true })
.evaluate((button: { click(): void }) => button.click());
await webAppConnectionRequest.waitFor({
state: "hidden",
timeout: 5_000,
});
try {
await waitForWebPeerLog(
webPeerPage,
"P2P Replication has been done",
60_000,
);
} catch (error) {
throw new Error(
`${formatError(error)}\n\nWebApp state:\n${await webAppPage.locator(
"body",
).innerText()}`,
);
}
await captureBrowserPage(
webAppPage,
screenshotDirectory,
"webapp-webpeer-replicated.png",
);
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-webapp-replicated.png",
);
await webAppP2P.getByRole("button", { name: "Disconnect", exact: true })
.click();
await webAppP2P.getByText("No Connection", { exact: true }).waitFor({
timeout: 30_000,
});
await webAppContext.close();
const cliSync = startCliInBackground(
cliDatabasePath,
"--settings",
cliSettingsPath,
"p2p-sync",
webPeerDeviceName,
"20",
);
const cliPeerRow = webPeerPage.locator("table.peers tbody tr").filter({
hasText: cliDeviceName,
});
try {
await cliPeerRow.waitFor({ timeout: 20_000 });
const webPeerConnectionRequest = webPeerPage.locator(
"dialog.vpk-browser-dialog[open]",
);
await webPeerConnectionRequest.waitFor({
state: "visible",
timeout: 20_000,
});
assertEquals(
await webPeerConnectionRequest.getByRole("heading").innerText(),
"P2P Connection Request",
);
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-cli-connection-request.png",
false,
);
await webPeerConnectionRequest
.getByRole("button", { name: "Accept", exact: true })
.evaluate((button: { click(): void }) => button.click());
await webPeerConnectionRequest.waitFor({
state: "hidden",
timeout: 5_000,
});
const syncExitCode = await waitForBackgroundExit(cliSync, 45_000);
assertEquals(syncExitCode, 0, cliSync.combined);
} catch (error) {
await cliSync.stop();
throw new Error(
`${
formatError(error)
}\n\nCLI output:\n${cliSync.combined}\n\nWebPeer logs:\n${await webPeerLogs(
webPeerPage,
)}`,
);
}
const catResult = await runCli(
cliDatabasePath,
"--settings",
cliSettingsPath,
"cat",
notePath,
);
assertEquals(catResult.code, 0, catResult.combined);
assertEquals(sanitiseCatStdout(catResult.stdout), noteContent);
assertStringIncludes(catResult.stderr, "[Command] cat");
await captureBrowserPage(
webPeerPage,
screenshotDirectory,
"webpeer-cli-completed.png",
);
assertNoWebAppFailures();
assertNoWebPeerFailures();
} finally {
await webAppContext.close().catch(() => {});
await webPeerContext.close().catch(() => {});
await browser.close();
await server.shutdown();
}
},
});
@@ -0,0 +1,83 @@
import { assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import { observePageFailures, startStaticServer } from "../helpers/browser.ts";
const pagesSiteRoot = resolve(Deno.env.get("PAGES_SITE_ROOT") ?? resolve(import.meta.dirname!, "../../../_site"));
function observeNetworkFailures(page: Page): () => void {
const failures: string[] = [];
page.on("requestfailed", (request) => {
failures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText ?? "request failed"}`);
});
page.on("response", (response) => {
if (response.status() >= 400) {
failures.push(`${response.request().method()} ${response.url()}: HTTP ${response.status()}`);
}
});
return () => assertEquals(failures, [], failures.join("\n"));
}
Deno.test({
name: "GitHub Pages package serves browser applications from their final subpaths",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(pagesSiteRoot);
const browser = await chromium.launch({ headless: true });
try {
const aggregator = await browser.newPage();
const assertNoAggregatorFailures = observePageFailures(aggregator);
const assertNoAggregatorNetworkFailures = observeNetworkFailures(aggregator);
await aggregator.goto(new URL("aggregator.html#id=pages-smoke&n=2&i=0&d=first-", server.baseUrl).href);
await aggregator.getByText("1 / 2 Loaded", { exact: true }).waitFor();
await aggregator.goto(new URL("aggregator.html#id=pages-smoke&n=2&i=1&d=second", server.baseUrl).href);
assertEquals(
await aggregator.getByRole("link", { name: "Open Obsidian to complete setup" }).getAttribute("href"),
"obsidian://setuplivesync?settingsQR=first-second"
);
assertNoAggregatorFailures();
assertNoAggregatorNetworkFailures();
await aggregator.close();
const webApp = await browser.newPage();
const assertNoWebAppFailures = observePageFailures(webApp);
const assertNoWebAppNetworkFailures = observeNetworkFailures(webApp);
await webApp.addInitScript(`
Object.defineProperty(globalThis, "showDirectoryPicker", {
configurable: true,
value: async () => {
throw new DOMException("Cancelled by Pages browser test", "AbortError");
},
});
`);
await webApp.goto(new URL("webapp/", server.baseUrl).href);
await webApp.waitForURL(/\/webapp\/webapp\.html$/);
const vaultPicker = webApp.getByRole("button", { name: "Choose new vault folder" });
await vaultPicker.click();
await webApp.locator("#status.warning").filter({ hasText: "Vault selection was cancelled" }).waitFor();
assertEquals(await vaultPicker.isEnabled(), true);
assertNoWebAppFailures();
assertNoWebAppNetworkFailures();
await webApp.close();
const webPeer = await browser.newPage();
const assertNoWebPeerFailures = observePageFailures(webPeer);
const assertNoWebPeerNetworkFailures = observeNetworkFailures(webPeer);
await webPeer.goto(new URL("webpeer/", server.baseUrl).href);
await webPeer.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor({
timeout: 30_000,
});
await webPeer.getByText("No Connection", { exact: true }).waitFor();
assertEquals(new URL(webPeer.url()).pathname, "/webpeer/");
const manifest = await webPeer.request.get(new URL("webpeer/manifest.json", server.baseUrl).href);
assertEquals(manifest.status(), 200);
assertNoWebPeerFailures();
assertNoWebPeerNetworkFailures();
} finally {
await browser.close();
await server.close();
}
},
});
+33
View File
@@ -0,0 +1,33 @@
const repositoryRoot = await Deno.realPath(new URL("../../", import.meta.url));
const composeArgs = ["compose", "-f", "test/browser-apps/compose.yml"];
async function runDocker(args: string[]): Promise<Deno.CommandStatus> {
return await new Deno.Command("docker", {
args,
cwd: repositoryRoot,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
}).spawn().status;
}
let testStatus: Deno.CommandStatus | undefined;
try {
testStatus = await runDocker([...composeArgs, "run", "--build", "--rm", "browser-apps-interop"]);
} finally {
const cleanupStatus = await runDocker([...composeArgs, "down", "-v", "--remove-orphans"]);
if (!cleanupStatus.success) {
console.error(`[Browser applications E2E] Compose cleanup failed with exit code ${cleanupStatus.code}.`);
if (testStatus?.success) {
Deno.exit(cleanupStatus.code);
}
}
}
if (!testStatus?.success) {
const code = testStatus?.code ?? 1;
console.error(`[Browser applications E2E] Compose interoperability test failed with exit code ${code}.`);
Deno.exit(code);
}
console.log("\n[Browser applications E2E] Compose interoperability test passed.");
@@ -0,0 +1,161 @@
import { assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium } from "playwright";
import { observePageFailures, startStaticServer, waitFor } from "../helpers/browser.ts";
const webAppDist = resolve(import.meta.dirname!, "../../../src/apps/webapp/dist");
Deno.test({
name: "WebApp: production bundle selects a Vault and preserves the main remote while saving P2P settings",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(webAppDist);
const browser = await chromium.launch({ headless: true });
try {
const cancellationPage = await browser.newPage();
const assertNoCancellationFailures = observePageFailures(cancellationPage);
await cancellationPage.addInitScript(`
Object.defineProperty(globalThis, "showDirectoryPicker", {
configurable: true,
value: async () => {
throw new DOMException("Cancelled by WebApp browser test", "AbortError");
},
});
`);
await cancellationPage.goto(new URL("webapp.html", server.baseUrl).href);
await cancellationPage.locator("#status").filter({ hasText: "Select a vault folder" }).waitFor();
const picker = cancellationPage.getByRole("button", { name: "Choose new vault folder" });
await picker.click();
await cancellationPage
.locator("#status.warning")
.filter({ hasText: "Vault selection was cancelled" })
.waitFor();
assertEquals(await picker.isEnabled(), true);
assertEquals(await cancellationPage.locator("#vault-selector").isVisible(), true);
assertNoCancellationFailures();
await cancellationPage.close();
const runtimePage = await browser.newPage();
const assertNoRuntimeFailures = observePageFailures(runtimePage);
await runtimePage.addInitScript(`
Object.defineProperty(globalThis, "showDirectoryPicker", {
configurable: true,
value: async () => {
const originRoot = await navigator.storage.getDirectory();
try {
await originRoot.removeEntry("livesync-webapp-smoke", { recursive: true });
} catch (error) {
if (error?.name !== "NotFoundError") throw error;
}
return await originRoot.getDirectoryHandle("livesync-webapp-smoke", { create: true });
},
});
`);
await runtimePage.goto(new URL("webapp.html", server.baseUrl).href);
await runtimePage.getByRole("button", { name: "Choose new vault folder" }).click();
await runtimePage.locator("#vault-selector").waitFor({ state: "hidden", timeout: 30_000 });
await runtimePage.waitForFunction("globalThis.livesyncApp?.getRuntime() != null");
await runtimePage.locator("#status.warning").filter({ hasText: "Please configure CouchDB" }).waitFor();
const state = await runtimePage.evaluate(async () => {
const app = (
globalThis as typeof globalThis & {
livesyncApp?: {
getRuntime(): unknown;
historyStore: {
getVaultHistory(): Promise<Array<{ name: string }>>;
};
};
}
).livesyncApp;
return {
historyNames: (await app!.historyStore.getVaultHistory()).map((item) => item.name),
runtimeStarted: app!.getRuntime() != null,
};
});
assertEquals(state, {
historyNames: ["livesync-webapp-smoke"],
runtimeStarted: true,
});
await runtimePage.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor();
await runtimePage.evaluate(() => {
const runtime = (
globalThis as typeof globalThis & {
livesyncApp?: {
getRuntime(): {
events: {
emitEvent(name: string, payload: unknown): void;
};
} | null;
};
}
).livesyncApp?.getRuntime();
runtime?.events.emitEvent("p2p-server-status", {
isConnected: true,
knownAdvertisements: [
{
isAccepted: true,
name: "Peer without WebApp menu",
peerId: "peer-without-webapp-menu",
},
],
serverPeerId: "webapp-browser-smoke",
});
});
await runtimePage.getByText("Peer without WebApp menu", { exact: true }).waitFor();
assertEquals(
await runtimePage.getByRole("button", { name: "...", exact: true }).count(),
0,
"WebApp must not render a peer-menu action without a menu capability"
);
await runtimePage.locator(".p2p-control input[type='checkbox']").first().check();
await runtimePage
.getByPlaceholder("wss://exp-relay.vrtmrz.net, wss://xxxxx")
.fill("ws://127.0.0.1:4010/");
await runtimePage.getByPlaceholder("anything-you-like").fill("browser-e2e-room");
await runtimePage.getByPlaceholder("password").fill("browser-e2e-passphrase");
await runtimePage.getByPlaceholder("iphone-16").fill("browser-e2e-webapp");
const save = runtimePage.getByRole("button", { name: "Save and Apply", exact: true });
await save.click();
await waitFor(async () => await save.isDisabled(), "WebApp did not finish applying P2P settings");
const primaryRemote = await runtimePage.evaluate(() => {
const runtime = (
globalThis as typeof globalThis & {
livesyncApp?: {
getRuntime(): {
p2pPaneHost: {
services: {
setting: {
currentSettings(): {
isConfigured: boolean;
remoteType: string;
};
};
};
};
} | null;
};
}
).livesyncApp?.getRuntime();
const settings = runtime?.p2pPaneHost.services.setting.currentSettings();
return {
isConfigured: settings?.isConfigured,
remoteType: settings?.remoteType,
};
});
assertEquals(primaryRemote, {
isConfigured: false,
remoteType: "",
});
await runtimePage.getByRole("button", { name: "Scan local files", exact: true }).waitFor();
assertNoRuntimeFailures();
} finally {
await browser.close();
await server.close();
}
},
});
@@ -0,0 +1,58 @@
import { assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium } from "playwright";
import { observePageFailures, startStaticServer, waitFor } from "../helpers/browser.ts";
const webPeerDist = resolve(import.meta.dirname!, "../../../src/apps/webpeer/dist");
Deno.test({
name: "WebPeer: production bundle starts and reloads its persisted settings",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(webPeerDist);
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const assertNoPageFailures = observePageFailures(page);
await page.goto(server.baseUrl);
await page.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor({
timeout: 30_000,
});
await page.getByText("No Connection", { exact: true }).waitFor();
await page.getByPlaceholder("anything-you-like").fill("browser-e2e-room");
await page.getByPlaceholder("iphone-16").fill("browser-e2e-peer");
await page.getByText("Optional TURN server settings", { exact: true }).click();
await page.getByPlaceholder("turn:turn.example.com:3478").fill("turn:127.0.0.1:3478");
await page.getByPlaceholder("Enter TURN username").fill("browser-turn-user");
await page.getByPlaceholder("Enter TURN credential").fill("browser-turn-credential");
const saveTurn = page.getByRole("button", { name: "Save TURN settings", exact: true });
await saveTurn.click();
await waitFor(async () => await saveTurn.isDisabled(), "WebPeer did not finish applying its TURN settings");
const save = page.getByRole("button", { name: "Save and Apply", exact: true });
await save.click();
await waitFor(async () => await save.isDisabled(), "WebPeer did not finish applying its settings");
await page.reload();
await page.getByRole("heading", { name: "Peer to Peer Replicator", exact: true }).waitFor({
timeout: 30_000,
});
assertEquals(await page.getByPlaceholder("anything-you-like").inputValue(), "browser-e2e-room");
assertEquals(await page.getByPlaceholder("iphone-16").inputValue(), "browser-e2e-peer");
await page.getByText("Optional TURN server settings", { exact: true }).click();
assertEquals(await page.getByPlaceholder("turn:turn.example.com:3478").inputValue(), "turn:127.0.0.1:3478");
assertEquals(await page.getByPlaceholder("Enter TURN username").inputValue(), "browser-turn-user");
assertEquals(
await page.getByPlaceholder("Enter TURN credential").inputValue(),
"browser-turn-credential"
);
assertEquals(await page.getByRole("button", { name: "Connect", exact: true }).isVisible(), true);
assertNoPageFailures();
} finally {
await browser.close();
await server.close();
}
},
});
@@ -127,7 +127,9 @@ async function captureAndSelectMobileInvitation(): Promise<string> {
});
});
await withObsidianPage(port, async (page) => {
await onboardingNotice(page).locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
const invitation = onboardingNotice(page);
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
await invitation.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return screenshot;
}
@@ -178,6 +180,21 @@ async function openOnboardingFromSettings(): Promise<void> {
});
}
async function dismissVisibleNotices(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const notices = page.locator(".notice:visible");
while ((await notices.count()) > 0) {
const noticeCount = await page.locator(".notice").count();
await notices.first().click({ position: { x: 8, y: 8 }, timeout: uiTimeoutMs });
await page.waitForFunction(
(previousCount) => document.querySelectorAll(".notice").length < previousCount,
noticeCount,
{ timeout: uiTimeoutMs }
);
}
});
}
async function closeSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const settingsContainer = page.locator(".modal-container").filter({
@@ -217,11 +234,13 @@ async function main(): Promise<void> {
console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`);
const desktopInvitation = await captureDesktopInvitation();
await openOnboardingFromSettings();
const settingsIntro = await captureAndCloseIntro("onboarding-intro-settings-desktop.png", false);
await closeSettings();
const mobileInvitation = await captureAndSelectMobileInvitation();
const mobileIntro = await captureAndCloseIntro("onboarding-intro-mobile.png", true);
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
await openOnboardingFromSettings();
const settingsIntro = await captureAndCloseIntro("onboarding-intro-settings-desktop.png", false);
await dismissVisibleNotices();
await closeSettings();
console.log(
`Onboarding remained opt-in and kept unconfigured startup inert. Screenshots: ${[
+10 -1
View File
@@ -23,5 +23,14 @@
}
},
"include": ["**/*.ts", "test/**/*.test.ts", "**/*.unit.spec.ts", "**/*.svelte"],
"exclude": ["pouchdb-browser-webpack", "utils", "src/apps", "src/**/*.test.ts", "**/_test/**", "utilsdeno"]
"exclude": [
"pouchdb-browser-webpack",
"utils",
"src/apps",
"src/**/*.test.ts",
"**/_test/**",
"test/browser-apps",
"test/styles/*.deno.ts",
"utilsdeno"
]
}
+75 -161
View File
@@ -12,200 +12,114 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
## Unreleased
## 1.0.0-rc.0
## 1.0.1
29th July, 2026
I am taking this opportunity to update the experimental features as well.
This maintenance release mainly improves the robustness and maintainability of the experimental WebApp, WebPeer, and shared dialogue composition. Most plug-in users can skip it. I have reviewed the changes through CI and a real Obsidian instance, and I will validate the exact published build before merging the release commit.
### Interface
#### Improved
- Removed a custom positioning workaround from the onboarding Notice so that it follows Obsidian's standard placement and dismissal behaviour.
- WebApp now points users to **Scan local files** when automatic file observation is unavailable, instead of relying on a fixed browser-version recommendation.
## 1.0.0
27th July, 2026
The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here).
### Important
### Setup and compatibility
- This is the first 1.0 release candidate. It remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault.
- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. Existing automatic synchronisation choices are preserved and resume only after the decision has been saved.
### Changes consolidated from beta.0 through beta.5
#### Setup and compatibility
#### Improved
- An unconfigured Vault now waits for the user to start setup. Onboarding is offered through a persistent Notice and remains available from **Self-hosted LiveSync settings****Setup**.
- Setup now creates named CouchDB, Object Storage, and P2P connections. Setup URIs preserve their connection names and selections, and reserve Fetch or Rebuild before the ordinary start-up scan begins.
- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting.
- Manual CouchDB setup distinguishes creating the first database from connecting another device. Onboarding requires a successful connection, while Settings can explicitly save an unverified connection and offers each server-setting correction separately.
- Compatible differences limited to the chunk hash algorithm, chunk size, or splitter version are aligned automatically by default. Existing chunks remain readable, an explicit opt-out remains available, and differences involving incompatible settings still require review.
#### Conflict handling and recovery
#### Fixed
- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting.
#### Security
- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness.
- Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links.
### Conflict handling and recovery
#### Improved
- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch.
- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review.
- **Not now** postpones repeated automatic merge dialogues while retaining the unresolved-conflict warning. Three or more live revisions are reviewed one reproducible pair at a time, completed pairs remain resolved across restart, and explicit commands can reopen a postponed conflict.
- **Inspect conflicts and file/database differences** compares the Vault with the database winner and every live conflict revision. Compact indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflicts remain.
- Each reported file and live revision has a compact wrench menu for comparison, applying an exact readable revision, recording an exact byte match, storing the Vault content as a child of a selected branch, retrying missing chunks without changing the tree, or explicitly discarding one selected live branch.
#### Fixed
- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch.
- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review.
- Unreadable live revisions are preserved during automatic handling. An absent Vault file and a winning logical deletion are treated as agreement unless another live branch still requires attention.
- Garbage Collection V3 is limited to CouchDB and now protects every live conflict branch, required shared ancestry, and shared chunks. It stops when device progress cannot be verified and reports compaction failure without a contradictory success message.
#### P2P and optional synchronisation features
### P2P and optional synchronisation features
#### Improved
- P2P and Hidden File Sync remain supported opt-in features. Customisation Sync remains a supported Advanced workflow, while Data Compression remains available but disabled by default.
- P2P controls remain outside the ordinary CouchDB experience until P2P is configured. The current status pane distinguishes announcing changes, following a peer, and persistent per-device actions.
- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild.
- P2P setup and guidance now distinguish the required signalling relay from optional TURN, describe the replaceable public relay's privacy and availability limits, and reliably close and recreate relay connections across settings changes and database resets.
- P2P setup and guidance now distinguish the required signalling relay from optional TURN and describe the replaceable public relay's privacy and availability limits.
- Enabling Hidden File Sync opens one progress Notice before saving the setting and reuses it until the initial scan has finished instead of stacking phase, reload, and restart messages.
- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update.
#### Interface and operations
#### Fixed
- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild.
- P2P relay connections now close and are recreated reliably after settings changes and database resets.
### Interface, translation, and operations
#### Improved
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands retain their identifiers so that existing hotkeys continue to work.
- Setup and review dialogue text can be selected for copying or translation. Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand.
- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls.
- Setup and review dialogue text can be selected for copying or translation.
- Remote-size warnings use persistent clickable Notices. Initial uploads and Rebuild no longer ask to send every chunk in advance; ordinary replication completes the transfer.
- Obsolete controls for the plug-in trash setting and fixed chunk revisions were removed. The Change Log remains available but no longer opens automatically or tracks an unread count.
#### Other fixes and security
- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links, and the CLI rejects detected path traversal and symbolic-link components before Vault operations.
- Self-hosted LiveSync now owns its translation catalogue. Commonlib supplies canonical English to other consumers, while translation contributions can be made in the main Self-hosted LiveSync repository.
### Changes since beta.5
#### Fixed
- The Hatch action for **Inspect conflicts and file/database differences** is now labelled **Begin inspection** so that its purpose is clear without repeating the setting name.
- Start-up and full-inspection scans now omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged.
- Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand.
- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls.
### Testing
### Storage and file selection
#### Fixed
- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update.
- Start-up and full-inspection scans omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged.
### Command-line tool
#### Fixed
- CLI Setup URI validation now uses the supported Commonlib ESM package interface.
- The non-root Docker image no longer depends on permissions inherited from the source checkout.
#### Security
- The CLI rejects detected path traversal and symbolic-link components before Vault operations.
### Validation
#### Testing
- Expanded automated Real Obsidian coverage for upgrades, two-device synchronisation, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, conflict and revision recovery, failure diagnostics, and strict clean-up.
- Real CouchDB integration coverage verifies logical deletion, shared and conflict chunk retention, compaction, downstream replication, and recreation of content-addressed chunks.
- An encrypted Real Obsidian reconnect scenario replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation adopts the replacement without restoring the old value, and proves a bidirectional encrypted round-trip.
- The beta series was exercised through BRAT on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations. The exact RC artefact will be validated separately after publication.
## 1.0.0-beta.5
26th July, 2026
### Improved
- **Inspect conflicts and file/database differences** now compares the current Vault file with the database winner and every live conflict revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain.
- Each reported file and live revision now has a compact wrench menu. Its available actions can compare readable text, apply the selected revision to the Vault, record an exact byte match, store the Vault content as a child of the selected branch, retry retrieving missing chunks without changing the revision tree, or discard only the selected live branch after confirmation.
### Fixed
- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent.
### Testing
- Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions.
## 1.0.0-beta.4
25th July, 2026
### Improved
- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation.
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings****Setup**.
- Text in setup and review dialogues can now be selected for copying or translation.
- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue.
### Fixed
- An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device.
- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message.
### Testing
- Added regressions for revision repair, command availability, selectable dialogues, conflict-aware chunk reachability, device-progress safeguards, and compaction timeouts.
- Added a real CouchDB integration test for logical chunk deletion, shared and conflict chunk retention, compaction completion, downstream replication, and content-addressed chunk recreation.
- Added a real Obsidian encrypted reconnect scenario which replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation refreshes it without restoring the old value, and proves a bidirectional encrypted round-trip.
## 1.0.0-beta.3
24th July, 2026
### Improved
- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices.
- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits.
- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`.
- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation.
- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review.
### Fixed
- Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings.
- Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart.
### Testing
- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues.
## 1.0.0-beta.2
23rd July, 2026
### Improved
- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved.
### Fixed
- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred.
### Testing
- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages.
## 1.0.0-beta.1
22nd July, 2026
### Important
- This corrected opt-in integration preview follows `1.0.0-beta.0` and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault.
### Fixed
- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers.
- Edits, deletions, and renames made while a file is conflicted now extend the exact revision displayed on that device. If LiveSync cannot prove the displayed branch, it preserves the affected branches for review instead of silently applying the operation to the database winner.
### Testing
- Added revision-tree regressions and focused real-Obsidian scenarios for propagated resolutions and file operations performed while a conflict remains active.
## 1.0.0-beta.0
22nd July, 2026
### Important
- This is an opt-in 1.0 integration preview for BRAT and testing with existing Vaults. It does not replace the latest stable release. Use it with a current backup, and update every participating device before resuming synchronisation.
- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. The review preserves the existing automatic synchronisation choices and resumes them only after the decision has been saved successfully.
### Improved
- An unconfigured installation now waits for you to start setup. A long-lived Notice offers the setup action, and **Open onboarding wizard** remains available from the command palette instead of the dialogue opening automatically.
- The setup wizard now creates named remote profiles for CouchDB, Object Storage, and P2P. Current Setup URIs preserve their profile names and selections, and the wizard reserves Rebuild or Fetch before the ordinary start-up scan begins.
- Peer-to-Peer Synchronisation (P2P) and Hidden File Sync are supported opt-in features. JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews. Customisation Sync remains a supported advanced workflow.
- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the Data Compression specification.
- Compatibility review now runs before Config Doctor without overlapping it. Existing Vaults retain their automatic synchronisation choices and explicit file-name case setting. For installations created by earlier releases, LiveSync preserves whether setup had been completed and saves a missing legacy case setting as case-insensitive.
- P2P connections now restart reliably after settings are reapplied or the local database is reset. Setup on an additional device asks you to select the source device once. Disconnecting leaves the LiveSync room and closes its signalling relay connections so that reconnecting can establish a new room.
- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in issue #555.
- Warnings about estimated remote storage size now appear as long-lived clickable Notices instead of timed dialogues. Initial uploads and Rebuild operations no longer prompt to send every chunk in advance; ordinary replication completes the transfer.
- Removed the obsolete **Use the trash bin** control and the setting for fixed chunk revisions. Remote deletion still follows Obsidian's preference, and chunk revisions remain content-derived. The Change Log remains available but no longer opens automatically or tracks unread versions.
### Fixed
- The optional Custom HTTP Handler used by Object Storage now sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
- When selectors, ignore files, size limits, modification-time limits, or file-name case settings are broadened, LiveSync now rechecks previously received files without requiring another remote update.
- P2P setup on the first device no longer displays reset or upload steps for a central database, and Config Doctor now offers its chunk size recommendation for CouchDB only when a CouchDB remote profile is selected.
### Security
- Fly.io setup now generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates prevent excessive CPU use from specially crafted path patterns and `mailto:` links. The CLI rejects path traversal and symbolic-link components detected before Vault operations.
### Miscellaneous
- Self-hosted LiveSync now owns its translation catalogue. Commonlib provides English messages to other applications, and translation contributions can be made directly to the Self-hosted LiveSync repository.
### Testing
- Expanded automated testing in Obsidian for upgrades, synchronisation between two devices, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, and clean-up after failures.
- The plug-in code in this release was installed through BRAT and validated on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations.
- Native and non-root Docker CLI scenarios cover setup, write, read, list, information, deletion, conflict resolution, and revision retrieval with the packaged Commonlib dependency.
+1
View File
@@ -3,6 +3,7 @@
The release history is now kept as one chronological sequence across smaller files:
- [Current 1.x releases](updates.md)
- [1.0 beta and release-candidate history](docs/releases/1.0-previews.md)
- [0.25 releases](docs/releases/0.25.md)
- [Releases before 0.25](docs/releases/legacy.md)
+4 -3
View File
@@ -52,15 +52,16 @@ export function renderReleasePrBody(version, baseBranch) {
: "Confirm the draft GitHub Release assets; keep stable CLI publication deferred until BRAT validation passes";
const holdInstruction = isPrerelease
? `Publishing and validating this pre-release does not unblock this pull request. Keep it in draft and unmerged, and leave ${baseBranchCode} unchanged.`
: `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation and has been promoted to the latest stable release.`;
: `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation. Promotion remains on hold until the exact release commit has been integrated into the repository's default branch.`;
const completionInstructions = isPrerelease
? [
"- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action",
]
: [
"- [ ] Remove the pre-release designation and make this exact release the latest stable release",
`- [ ] After BRAT validation passes, mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`,
"- [ ] Integrate the exact release commit through the reviewed branch chain into the repository's default branch",
"- [ ] Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release",
"- [ ] Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate",
`- [ ] Mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`,
];
return [
+28 -6
View File
@@ -207,12 +207,26 @@ describe("release workflow", () => {
"Publish the GitHub Release initially as a pre-release without replacing the latest stable release"
);
expect(stable).toContain(
"Remove the pre-release designation and make this exact release the latest stable release"
"After BRAT validation passes, mark this pull request ready and merge it into `main` with a merge commit"
);
expect(stable).toContain(
"Integrate the exact release commit through the reviewed branch chain into the repository's default branch"
);
expect(stable).toContain(
"Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release"
);
expect(stable).toContain(
"Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate"
);
expect(stable).toContain("Mark this pull request ready and merge it into `main` with a merge commit");
expect(stable.indexOf("After BRAT validation passes")).toBeLessThan(
stable.indexOf("Integrate the exact release commit")
);
expect(stable.indexOf("Integrate the exact release commit")).toBeLessThan(
stable.indexOf("Confirm the default branch contains the exact release metadata")
);
expect(stable.indexOf("Confirm the default branch contains the exact release metadata")).toBeLessThan(
stable.indexOf("Create the stable CLI tag")
);
expect(stable).not.toContain("prerelease=false");
});
@@ -224,7 +238,10 @@ describe("release workflow", () => {
"Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action."
);
expect(workflow).toContain(
"After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before merging the release pull request."
"After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
);
expect(workflow).toContain(
"Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release."
);
expect(workflow).toContain(
'if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then'
@@ -234,16 +251,21 @@ describe("release workflow", () => {
);
});
it("dispatches the plug-in workflow and lets the CLI tag trigger its own workflow", () => {
it("dispatches the selected plug-in and CLI workflows explicitly", () => {
const workflow = readFileSync(finaliseReleaseWorkflow, "utf8");
expect(workflow).toContain("actions: write");
expect(workflow).toContain('node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"');
expect(workflow).toContain('git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"');
expect(workflow).not.toContain("Tag already exists");
expect(workflow).toContain("PUBLISH_CLI: ${{ inputs.publish_cli }}");
expect(workflow).toContain('if [[ "${PUBLISH_CLI}" == "true" ]]; then');
expect(workflow).toContain("gh workflow run cli-docker.yml");
expect(workflow).toContain('--ref "${VERSION}-cli"');
expect(workflow).toContain("--field dry_run=false");
expect(workflow).toContain("--field force=false");
expect(workflow).toContain("gh workflow run release.yml");
expect(workflow).not.toContain("gh workflow run cli-docker.yml");
expect(workflow).toContain("its tag event starts the container workflow");
expect(workflow).toContain("explicitly dispatched the CLI container workflow");
});
it("publishes only by explicit dispatch and validates the selected release", () => {
+4 -3
View File
@@ -1,8 +1,6 @@
{
"0.25.61": "1.7.2",
"0.25.60": "1.7.2",
"1.0.1": "0.9.12",
"1.0.0": "0.9.7",
"0.25.81": "1.7.2",
"0.25.82": "1.7.2",
"0.25.83": "1.7.2",
@@ -12,5 +10,8 @@
"1.0.0-beta.3": "1.7.2",
"1.0.0-beta.4": "1.7.2",
"1.0.0-beta.5": "1.7.2",
"1.0.0-rc.0": "1.7.2"
"1.0.0-rc.0": "1.7.2",
"1.0.0-rc.1": "1.7.2",
"1.0.0": "1.7.2",
"1.0.1": "1.7.2"
}
+1 -1
View File
@@ -20,7 +20,7 @@ export default mergeConfig(
// maxConcurrency: 2,
name: "unit-tests",
include: ["**/*unit.test.ts", "**/*.unit.spec.ts"],
exclude: ["node_modules/**", "test/**", "src/apps/**/testdeno/**"],
exclude: ["node_modules/**", ".devmemo.local/**", "src/apps/**/testdeno/**"],
coverage: {
include: ["src/**/*.ts"],
exclude: [