mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-01 09:21:23 +00:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a722244a92 | |||
| 4f14d7f160 | |||
| 7955ab54d3 | |||
| 133ef34d7b | |||
| 0890a97222 | |||
| b7da78ce45 | |||
| 53d4bd527e | |||
| 64870a32ea | |||
| 5ede11d032 | |||
| bcf123c67a | |||
| f3b0fbb29b | |||
| 73e34aef7c | |||
| 166e431103 | |||
| 3ba9feaac7 | |||
| 42f37753b8 | |||
| ec1fc4a97e | |||
| 146e25907d | |||
| c97346e262 | |||
| 48c398948a | |||
| e32e3f545e | |||
| 0aed7f8556 | |||
| 50a51bf07e | |||
| 1570136703 | |||
| 5b6bbc8118 | |||
| 54f0262b3f | |||
| 7d33613017 | |||
| b700c631ca | |||
| 00152523db | |||
| fbfb77440d | |||
| 89d4667d95 | |||
| 21dd24682b | |||
| 5c52d69d66 | |||
| a251648e48 | |||
| 57c9726ae5 | |||
| a6977da9e8 | |||
| 397a9b69c8 | |||
| ce573f18a6 | |||
| 0df1f57f3b | |||
| 0518fab62a | |||
| e296708845 | |||
| b6746be73e | |||
| c385bd7ce7 | |||
| b21c3114e5 | |||
| 17fe6c1518 | |||
| d4994fdd05 | |||
| 93bbab4253 | |||
| 8b7d410327 | |||
| 68b6358345 | |||
| 29c26c43b7 | |||
| 72197a5df0 | |||
| 4723771ee0 | |||
| b22c1bd777 | |||
| 8876213127 | |||
| 61ffdde9b5 | |||
| f1e382c6ed | |||
| 2b95766d4f | |||
| c3f2163204 | |||
| d5a40a7e3d | |||
| 3ce6ccf7a6 | |||
| 8c6824b7f6 | |||
| e35583a752 | |||
| 866575add5 | |||
| 4f01e7f687 | |||
| 18e4d16c6f | |||
| da4b188b38 | |||
| 12061b7bf3 | |||
| 51a749099d | |||
| 4194a0067c | |||
| ad762d4ddf | |||
| 127d460e18 | |||
| 12fc43a69c | |||
| bea0e68091 | |||
| 99eb9cd46f | |||
| a7bc337fd1 | |||
| 0d68b6530f | |||
| c24fc1dc82 | |||
| c8162fd0cd | |||
| 0a7a3b1635 | |||
| 20fb027055 | |||
| 07cba4ce83 | |||
| 658ad6dfe4 | |||
| 0e5475b7e3 | |||
| c4d6b768da |
@@ -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
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ jobs:
|
||||
VERSION: ${{ inputs.version }}
|
||||
EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }}
|
||||
PRERELEASE: ${{ inputs.prerelease }}
|
||||
PUBLISH_CLI: ${{ inputs.publish_cli }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ACTUAL_HEAD_SHA="$(git rev-parse HEAD)"
|
||||
@@ -73,6 +74,10 @@ jobs:
|
||||
echo "Version ${VERSION} is a pre-release version, but prerelease was not enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then
|
||||
echo "A stable version staged as a pre-release must use publish_cli=false so that the CLI latest and major-minor image tags do not advance before BRAT validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
node utils/release-notes.mjs validate "${VERSION}"
|
||||
|
||||
- name: Ensure and push release tags
|
||||
@@ -96,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}" \
|
||||
@@ -113,15 +125,22 @@ 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
|
||||
echo ""
|
||||
echo "Dispatched the plug-in release workflow for \`${VERSION}\`. After approval for the release environment, it creates a draft GitHub Release."
|
||||
echo ""
|
||||
if [[ "${PRERELEASE}" == "true" ]]; then
|
||||
echo "Publish the draft as a pre-release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
|
||||
if [[ "${VERSION}" == *-* ]]; then
|
||||
echo "Publish the draft as a pre-release without replacing the latest stable release."
|
||||
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 its manifest reaches the default branch."
|
||||
echo "Confirm the release is no longer a pre-release, then merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
|
||||
echo "This order prevents Community Directory review from inspecting a stable manifest version while the matching GitHub Release remains a pre-release."
|
||||
echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate."
|
||||
else
|
||||
echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
|
||||
fi
|
||||
|
||||
@@ -23,6 +23,7 @@ Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling de
|
||||
- No central data-storage server is required, but a signalling relay is still required for peer discovery.
|
||||
- At least one device containing the required data must be online while another device synchronises.
|
||||
- Follow the [Peer-to-Peer Setup](docs/setup_p2p.md) after reviewing the [P2P communication model](docs/p2p.md).
|
||||
- If you would like to see how P2P setup and connectivity behave on your devices, try the guided connection check in the experimental [WebPeer browser utility](https://vrtmrz.github.io/obsidian-livesync/webpeer/). It uses an empty Vault and does not test note synchronisation. See the [WebPeer documentation](src/apps/webpeer/README.md) for its scope and limitations.
|
||||
|
||||
This plug-in may be particularly useful for researchers, engineers, and developers who need to keep their notes fully self-hosted for security reasons. It is also suitable for anyone seeking the peace of mind that comes with knowing their notes remain entirely private.
|
||||
|
||||
|
||||
+4
-3
@@ -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)};`
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -244,7 +248,10 @@ export class ModuleExample extends AbstractObsidianModule {
|
||||
- Use SemVer beta identifiers such as `1.0.0-beta.0` for immutable integration previews. Increment the beta number when a published preview needs a correction. Reserve `1.0.0-rc.0` for the first feature- and contract-frozen release candidate. Historical `-patchedN` releases remain unchanged in the release history.
|
||||
- Publish a pre-release from an immutable reviewed tag, mark its GitHub Release as a pre-release, and do not replace the latest stable release.
|
||||
- A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag.
|
||||
- Keep the release pull request in draft until the exact published plug-in has passed BRAT validation. If validation fails, prepare the next pre-release version rather than moving the existing tag.
|
||||
- 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, remove the GitHub pre-release designation and make the exact release the latest stable release first. Confirm that promotion, then merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. This order prevents Community Directory review from inspecting a stable manifest version while the matching GitHub Release remains a pre-release. Publish stable CLI tags through a separate maintainer gate.
|
||||
- If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version.
|
||||
|
||||
## Release Notes
|
||||
|
||||
@@ -262,15 +269,17 @@ 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.
|
||||
- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. For a selected CLI publication, confirm the image tags appropriate to a stable or pre-release version.
|
||||
- Publish a stable draft as the latest release, or publish a pre-release draft without replacing the latest stable release. In either case, keep the release PR in draft and leave its base branch unchanged until BRAT validation succeeds. Record that state in the PR.
|
||||
- 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 BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit. This keeps the tagged release commit in that branch's history.
|
||||
- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action.
|
||||
- After a stable version passes, remove the GitHub pre-release designation and make that exact release the latest stable release. Confirm that the release is no longer a pre-release, then mark its release pull request ready, merge the exact release commit into the selected base branch with a merge commit, and integrate that exact commit through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
|
||||
- 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.
|
||||
- For a pre-release, set `prerelease=true` in `Finalise Release Tags`. A hyphenated version is rejected unless that input is enabled.
|
||||
- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`.
|
||||
|
||||
### Release Cheat Sheet
|
||||
|
||||
@@ -290,15 +299,16 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
|
||||
- `version`: the same target version.
|
||||
- `release_branch`: leave blank unless the release branch used a custom name.
|
||||
- `expected_head_sha`: the full head commit SHA reviewed in the release PR.
|
||||
- `prerelease`: enable for a version such as `1.0.0-rc.0`.
|
||||
- `publish_cli`: disable when the reviewed release is plug-in-only.
|
||||
- `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 CLI publication was selected, confirm that the CLI tag event published the expected image tags.
|
||||
7. Publish the draft as a stable release or pre-release as selected, but keep the release PR in draft and leave its base branch unchanged.
|
||||
8. Update the PR state message to describe the published release and state that merging remains on hold until BRAT validation is complete.
|
||||
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 BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit.
|
||||
11. 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.
|
||||
10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action.
|
||||
11. After a stable version passes, remove the pre-release designation and make the exact release the latest stable release. Confirm that promotion, then mark the release PR ready, merge the exact release commit into the selected base branch, and integrate it through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata, then publish stable CLI tags through a separate maintainer gate if selected.
|
||||
12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Architectural Decision Record: Use Native Batch CAS for Adaptive PostgREST
|
||||
|
||||
## Status
|
||||
|
||||
Proposed as the final experimental provider, after the S3 and WebDAV delivery sequences have established the common
|
||||
protocol, CLI, host, and end-to-end boundaries.
|
||||
|
||||
## Context
|
||||
|
||||
PostgREST can expose PostgreSQL uniqueness, row-level security, bounded set operations, and transactions. Treating it
|
||||
as another opaque object store works, but leaves Metadata and Chunks combined and cannot use a multi-key Chunk query.
|
||||
Using one HTTP request per logical Chunk would make latency dominate synchronisation.
|
||||
|
||||
The common protocol decision is recorded in
|
||||
[Adaptive Journal as an explicit protocol](2026_07_adaptive_journal_protocol.md). The tables, binary RPC framing,
|
||||
transaction rules, and privacy model are specified in the
|
||||
[Adaptive Journal Sync design](../design_docs/adaptive_journal_sync.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Adaptive PostgREST separates Metadata publication from immutable Chunk storage physically as well as logically.
|
||||
|
||||
- Vault- and repository-scoped Chunk rows use the Remote Chunk key as an insert-only unique address.
|
||||
- Bounded binary RPCs implement `hasMany`, `getMany`, and `putMany` while preserving input order and per-entry status.
|
||||
- Writer descriptors, Metadata batches, and commits remain small append-only records.
|
||||
- A transactional commit verifies the sorted required-Chunk-key digest and the existence of every referenced Chunk
|
||||
before making Metadata visible.
|
||||
- Concurrent insertion of a different encrypted frame for the same logical Chunk reads, validates, and accepts the
|
||||
winning plaintext only when it represents the same logical value.
|
||||
- Row-level security scopes every operation to the configured Vault. Server-visible Remote Chunk keys reveal equality
|
||||
within that repository but do not reveal plaintext Chunk IDs.
|
||||
|
||||
PostgREST does not use object packs, catalogue deltas, or Range retrieval for native Chunk rows. The shared binary Chunk
|
||||
record remains independently verifiable, but PostgreSQL supplies the batch index and uniqueness boundary.
|
||||
|
||||
The SQL schema and RPC contract are versioned together with the Adaptive format. A missing or incompatible schema is
|
||||
detected before publication and requires applying the reviewed schema or rebuilding the remote. The client does not
|
||||
perform an implicit data-format migration.
|
||||
|
||||
## Staged acceptance
|
||||
|
||||
### Adapter and Commonlib integration
|
||||
|
||||
Unit tests own binary-envelope limits, ordering, status decoding, insert conflicts, rollback, error classification,
|
||||
Vault isolation, and malformed responses. Disposable PostgreSQL and PostgREST integration tests own the real SQL
|
||||
schema, row-level security, RPC transactions, and two-client Metadata and Chunk synchronisation.
|
||||
|
||||
### CLI end-to-end acceptance
|
||||
|
||||
The built CLI applies an Adaptive PostgREST Setup URI to a second independent database and synchronises text and binary
|
||||
Chunk-backed files through disposable PostgreSQL and PostgREST services. The scenario proves bounded native batching at
|
||||
the headless product boundary without repeating the SQL failure matrix.
|
||||
|
||||
### Host settings and UI
|
||||
|
||||
The PostgREST dialogue persists the endpoint, Vault identifier, authentication configuration, Adaptive format, and
|
||||
expected repository ID. Focused tests own connection-string and Setup URI preservation, validation, and format-mismatch
|
||||
guidance; they do not execute SQL.
|
||||
|
||||
### Real-host end-to-end acceptance
|
||||
|
||||
One real-Obsidian workflow applies the reviewed schema and performs a representative Chunk-backed transfer. The
|
||||
disposable Commonlib integration remains authoritative for RLS, transaction rollback, and the complete RPC matrix.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Reuse immutable object packs in PostgreSQL
|
||||
|
||||
This would preserve portability at the cost of native multi-key lookup and transaction guarantees. The repository
|
||||
contract already permits a different physical representation.
|
||||
|
||||
### Store raw file content with Metadata
|
||||
|
||||
Metadata must remain small and must continue to refer to immutable logical Chunks. Combining content with Metadata
|
||||
would break the maintained PouchDB model and duplicate unchanged content across revisions.
|
||||
|
||||
### Encode Chunk bodies as JSON values
|
||||
|
||||
Base64 and large JSON arrays add framing and memory overhead and make limits harder to enforce. Bounded binary `bytea`
|
||||
RPC envelopes provide deterministic lengths and status ordering.
|
||||
|
||||
## Consequences
|
||||
|
||||
- PostgREST has the largest provider-specific implementation because it includes reviewed SQL, RLS, RPC framing, and
|
||||
transaction behaviour.
|
||||
- Placing it last lets the common protocol, CLI, and host boundaries stabilise before introducing that larger surface.
|
||||
- Native batching can reduce request count substantially relative to object-per-Chunk storage, but actual speed still
|
||||
depends on database, proxy, network, and workload measurements.
|
||||
- PostgREST and object stores share logical records and correctness rules without pretending to share a physical
|
||||
layout.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Architectural Decision Record: Introduce Adaptive Journal as an Explicit Protocol
|
||||
|
||||
## Status
|
||||
|
||||
Proposed for staged implementation. This decision does not change the current default Journal format or promise a
|
||||
compatible in-place migration.
|
||||
|
||||
## Context
|
||||
|
||||
The existing Journal protocol publishes PouchDB Metadata and Chunk documents together in opaque immutable objects.
|
||||
That representation is portable and remains the compatibility baseline, but it prevents a remote from answering a
|
||||
bounded multi-key Chunk query or amortising object-store requests independently from Metadata publication.
|
||||
|
||||
S3-compatible Object Storage, WebDAV, and PostgREST have materially different physical capabilities. Making each one
|
||||
implement a separate replication algorithm would duplicate ordering, encryption, recovery, and Chunk-delivery rules.
|
||||
Making every one use an identical physical layout would discard useful native batching and transactions.
|
||||
|
||||
The detailed binary formats, state machines, privacy properties, and recovery rules are specified in the
|
||||
[Adaptive Journal Sync design](../design_docs/adaptive_journal_sync.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Adaptive Journal is a second, explicit Journal protocol version owned by Commonlib. It keeps one logical repository
|
||||
contract while allowing provider-specific physical storage.
|
||||
|
||||
- Metadata events and raw Chunk records remain separate throughout publication.
|
||||
- Logical Chunks are immutable and content-addressable. A changed value has a new logical Chunk ID.
|
||||
- A repository-scoped Remote Chunk key is derived locally from the exact logical Chunk ID after accepting the
|
||||
repository manifest.
|
||||
- Chunks become durable before a commit makes Metadata references visible.
|
||||
- Ordinary publication creates immutable records. Catalogue snapshots, caches, and indexes are derived and
|
||||
reconstructible.
|
||||
- Each writer publishes a dense sequence identified by a stable host ID and a persisted random writer epoch. A reader
|
||||
tracks one frontier per writer stream; it does not depend on a remote `startAfter` ordering contract.
|
||||
- Remote operations return typed outcomes which distinguish absence, conflict, permanent rejection, retryable failure,
|
||||
and an ambiguous mutation which must be verified before retrying.
|
||||
|
||||
The adaptive repository exposes batched Chunk availability, publication, and retrieval even when its implementation
|
||||
uses immutable packs internally. The caller does not issue one remote request per Chunk.
|
||||
|
||||
### Repository identity and compatibility
|
||||
|
||||
A new repository has one immutable, conditionally created manifest. The first device generates its candidate locally,
|
||||
and the winning manifest fixes the repository ID, Security Seed, protocol parameters, and required capabilities.
|
||||
Every client pins the accepted repository ID in local repository state. A Setup URI exported from an accepted binding
|
||||
should carry that non-secret expected ID.
|
||||
|
||||
`opaque-v1` and `adaptive-v1` are separate remote formats. Format selection is explicit in the remote configuration,
|
||||
and a mismatch fails before publication. The implementation detects incompatible remote data, but it does not migrate
|
||||
it. Changing format requires an explicit remote rebuild or a new remote namespace.
|
||||
|
||||
Deprecated data formats do not enlarge the Adaptive protocol. Compatibility remains at the existing decoding
|
||||
boundaries, and rebuilding the remote is the recovery path for an unsupported Adaptive layout.
|
||||
|
||||
### Provider delivery sequence
|
||||
|
||||
Each provider is delivered and reviewed through four boundaries, in order:
|
||||
|
||||
1. **Adapter and Commonlib integration.** Implement semantic capabilities, typed failure handling, format detection,
|
||||
unit tests, and disposable real-service integration tests.
|
||||
2. **CLI end-to-end acceptance.** Use the built CLI and a real disposable service to apply a Setup URI, synchronise two
|
||||
independent local databases, and restore text and binary Chunk content. This proves the headless product boundary
|
||||
without Obsidian or Svelte.
|
||||
3. **Host settings and UI.** Add only provider-specific controls, validation, profile persistence, Setup URI transport,
|
||||
and focused host tests.
|
||||
4. **Real-host end-to-end acceptance.** Exercise one representative setup and synchronisation path in a real Obsidian
|
||||
instance. This test verifies host composition and does not repeat the adapter capability matrix or the complete CLI
|
||||
conflict suite.
|
||||
|
||||
The initial order is S3-compatible Object Storage, WebDAV, then PostgREST. A provider completes these boundaries before
|
||||
the next provider is presented for integration review. The sequence keeps each review independently attributable and
|
||||
keeps the real-host tests small.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### One cross-provider implementation change
|
||||
|
||||
This hides which provider requires a shared-core change, makes failures difficult to attribute, and forces reviewers to
|
||||
understand SQL RPCs, object packs, WebDAV behaviour, CLI composition, and Obsidian UI in one change.
|
||||
|
||||
### Add the Host UI before headless acceptance
|
||||
|
||||
This makes an application-level failure ambiguous between the protocol, adapter, CLI-independent host composition, and
|
||||
presentation. The CLI provides the smaller executable boundary first.
|
||||
|
||||
### Use one physical representation for every provider
|
||||
|
||||
One-object-per-Chunk storage causes excessive object requests, while forcing packs into PostgreSQL discards bounded
|
||||
multi-key RPCs and transactions. Common semantics do not require common physical storage.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Commonlib owns protocol correctness and provider adapters; Self-hosted LiveSync owns CLI composition, Obsidian host
|
||||
integration, and presentation.
|
||||
- Provider reviews can stop at the first failed boundary without involving later UI or real-host tests.
|
||||
- The final end-to-end suite remains an acceptance layer rather than a duplicate protocol test suite.
|
||||
- Adding another provider requires the same semantic contract and staged evidence, not another replication algorithm.
|
||||
- Remote rebuild remains an explicit operational requirement while Adaptive Journal is evolving.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Architectural Decision Record: Use S3 as the Reference Adaptive Object Store
|
||||
|
||||
## Status
|
||||
|
||||
Proposed as an improvement to the maintained S3-compatible Journal path. Adaptive Journal remains explicit and
|
||||
opt-in; the existing opaque format remains the compatibility default.
|
||||
|
||||
## Context
|
||||
|
||||
S3-compatible Object Storage already supplies the maintained Journal object model and is the smallest provider on
|
||||
which to prove Adaptive immutable packs. It has a standard conditional-create request, paginated listing, binary
|
||||
objects, and optional byte-range retrieval, but compatible endpoints can still differ in consistency, proxy behaviour,
|
||||
and Range handling.
|
||||
|
||||
The common protocol decision is recorded in
|
||||
[Adaptive Journal as an explicit protocol](2026_07_adaptive_journal_protocol.md). The complete object-pack and catalogue
|
||||
formats are specified in the [Adaptive Journal Sync design](../design_docs/adaptive_journal_sync.md).
|
||||
|
||||
## Decision
|
||||
|
||||
S3-compatible storage is the reference implementation of the Adaptive object-store strategy.
|
||||
|
||||
- It stores the manifest, writer control records, Metadata batches, commits, Chunk packs, indexes, and catalogue records
|
||||
as immutable objects under the configured bucket namespace.
|
||||
- Manifest and every immutable publication use conditional create. A successful create response is sufficient; the
|
||||
client does not add a confirmation request solely for caution.
|
||||
- A lost or ambiguous mutation response returns `verify-first`. The caller reads the exact key before retrying.
|
||||
- A conditional-request conflict which does not establish an existing immutable value remains retryable and is not
|
||||
reported as a successful create.
|
||||
- Listing follows every continuation token and proves complete prefix enumeration. Folder marker objects and stale
|
||||
capability-probe objects are not repository data.
|
||||
- Format inspection distinguishes empty, `opaque-v1`, `adaptive-v1`, and mixed repositories. Mixed or mismatched data
|
||||
fails closed, and reset remains an explicit batched operation.
|
||||
|
||||
The portable retrieval policy is `whole-pack`. A user may select `range` when the endpoint capability probe has
|
||||
confirmed exact byte-range behaviour. Range responses must use `206`, carry a matching `Content-Range`, and return the
|
||||
requested number of bytes. Loss of optional Range support falls back to whole-pack retrieval after reporting the
|
||||
capability change; it does not make valid packs unreadable.
|
||||
|
||||
Capability results which prove support or lack of support may be cached for the endpoint identity. A transient probe
|
||||
failure is not cached as a permanent result. Probe objects use a reserved random prefix and are removed when possible;
|
||||
incomplete cleanup is reported and ignored by format detection.
|
||||
|
||||
## Staged acceptance
|
||||
|
||||
### Adapter and Commonlib integration
|
||||
|
||||
Focused tests cover conditional creation, ambiguous responses, binary fidelity, read-after-write visibility, paginated
|
||||
listing, deletion visibility, Range validation, format detection, reset batching, and probe cleanup. A disposable
|
||||
MinIO integration proves two-client Adaptive Metadata and Chunk synchronisation and both retrieval paths.
|
||||
|
||||
### CLI end-to-end acceptance
|
||||
|
||||
One real-MinIO scenario uses two independent CLI databases. Device A uses whole-pack retrieval. Device B receives an
|
||||
Adaptive S3 Setup URI selecting Range retrieval. The scenario verifies that the URI preserves the format and policy,
|
||||
that both devices can publish and receive more than one synchronisation round, and that text and binary content are
|
||||
reconstructed from Chunks.
|
||||
|
||||
This test owns the built CLI, settings persistence, Setup URI decoding, and headless composition. It does not repeat
|
||||
every S3 error classification already owned by Commonlib.
|
||||
|
||||
### Host settings and UI
|
||||
|
||||
The Object Storage dialogue exposes `opaque-v1` and `adaptive-v1`, with whole-pack as the Adaptive default and Range as
|
||||
an explicit preference. It preserves the expected repository ID and read policy through saved profiles and Setup URI
|
||||
handling. Focused host tests own normalisation, validation, persistence, and warnings; they do not contact S3.
|
||||
|
||||
### Real-host end-to-end acceptance
|
||||
|
||||
One real-Obsidian workflow applies an Adaptive S3 configuration and proves a representative Chunk-backed file transfer
|
||||
against disposable MinIO. It verifies the Obsidian composition boundary only. The CLI suite remains the broader
|
||||
headless synchronisation acceptance test.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Re-upload a mutable pack when one Chunk changes
|
||||
|
||||
Packs are immutable publication units. A changed logical Chunk is added to a new pack, and a new catalogue delta makes
|
||||
that location discoverable. Existing packs are replaced only by a separately protected compaction process.
|
||||
|
||||
### Require Range for Adaptive S3
|
||||
|
||||
Whole-pack reads often provide better throughput and work on more endpoints. Range is a deployment preference, not a
|
||||
correctness requirement.
|
||||
|
||||
### Confirm every successful mutation with another request
|
||||
|
||||
This doubles request count in the ordinary success path. Exact-key verification is reserved for ambiguous outcomes and
|
||||
explicit diagnostics.
|
||||
|
||||
## Consequences
|
||||
|
||||
- S3 establishes the object-store contract before the more variable WebDAV implementation.
|
||||
- The two retrieval policies are exercised without duplicating the complete CLI scenario.
|
||||
- Existing opaque S3 repositories remain readable and never become Adaptive implicitly.
|
||||
- Endpoint capability evidence, rather than the S3-compatible label alone, controls optional behaviour.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Architectural Decision Record: Gate Adaptive WebDAV with an Endpoint Safety Check
|
||||
|
||||
## Status
|
||||
|
||||
Proposed as an experimental provider after the S3 Adaptive path has completed adapter, CLI, host, and real-host
|
||||
acceptance.
|
||||
|
||||
## Context
|
||||
|
||||
WebDAV exposes an object-shaped interface suitable for immutable Commit Bundles and Packs, but method support and
|
||||
conditional semantics vary across servers, gateways, reverse proxies, and authentication layers. A server can
|
||||
advertise WebDAV while failing binary fidelity, replacing an object despite `If-None-Match: *`, returning incomplete
|
||||
listings, or ignoring Range.
|
||||
|
||||
The common protocol decision is recorded in
|
||||
[Adaptive Journal as an explicit protocol](2026_07_adaptive_journal_protocol.md). The Pack background, flat object
|
||||
mapping, and detailed safety-check sequence are specified in the
|
||||
[Adaptive Journal Sync design](../design_docs/adaptive_journal_sync.md). Commonlib owns the implemented
|
||||
`commit-bundle-v1` wire contract.
|
||||
|
||||
## Decision
|
||||
|
||||
Adaptive WebDAV uses the same immutable Commit Bundle and external Pack semantics proven by S3, with a WebDAV-specific
|
||||
flat object mapping inside the configured collection. Its Catalogue is derived locally from authenticated routes in
|
||||
received Bundles rather than stored as a separate mutable remote object. Logical writer ordering comes from the host
|
||||
ID, writer epoch, and dense sequence embedded in authenticated records. Correctness does not depend on a
|
||||
server-provided `startAfter` order.
|
||||
|
||||
Before a new Adaptive repository becomes writable, a non-destructive endpoint safety checker exercises random reserved
|
||||
probe keys and reports observed semantic capabilities:
|
||||
|
||||
- binary write and exact read-back;
|
||||
- read-after-write visibility;
|
||||
- complete collection listing for the probe keys;
|
||||
- `If-None-Match: *` preventing replacement;
|
||||
- delete visibility; and
|
||||
- exact byte-range behaviour, reported separately as optional.
|
||||
|
||||
The checker is an implementation acceptance target for compatible servers, not a claim that every WebDAV server must
|
||||
support Adaptive Journal. It touches no repository objects, attempts to remove every probe, reports incomplete cleanup,
|
||||
and never interprets authentication, permission, timeout, malformed response, or server failure as absence.
|
||||
|
||||
Conditional create, binary fidelity, complete listing, read-after-write visibility, and delete visibility are required.
|
||||
Range remains optional. The user selects whole-pack or Range retrieval based on their endpoint and own latency and
|
||||
throughput preference; the checker does not benchmark or recommend a policy.
|
||||
|
||||
Successful mutations do not receive unconditional confirmation requests. An ambiguous response is classified as
|
||||
`verify-first`, and the exact immutable key is read before retrying. The existing opaque WebDAV layout and Adaptive
|
||||
layout are detected separately; a mismatch requires a remote rebuild or another namespace.
|
||||
|
||||
## Working hypotheses and illustrative estimates
|
||||
|
||||
The following values are planning assumptions for the first WebDAV experiment. They are not benchmark results, a
|
||||
provider-pricing statement, or a performance guarantee. The implementation must record the corresponding measurements
|
||||
before this ADR presents either Journal format as generally faster or smaller.
|
||||
|
||||
### Cost and lifecycle assumptions
|
||||
|
||||
The initial deployment model is a self-hosted or quota-priced WebDAV service without a direct per-request tariff.
|
||||
Request count still matters because every request consumes a round trip, server work, connection capacity, and a share
|
||||
of any rate limit. Commercial services, gateways, and managed hosting may use a different charging model, so the
|
||||
adapter must not infer cost policy from the WebDAV label.
|
||||
|
||||
Storage capacity is expected to be the more visible constraint. RFC 4331 defines optional
|
||||
[`DAV:quota-used-bytes` and `DAV:quota-available-bytes`](https://www.rfc-editor.org/rfc/rfc4331.html) properties for
|
||||
collections. When both are available, a connection diagnostic may report them as server-supplied evidence. Their
|
||||
absence means 'unknown', not 'unlimited', and quota reporting is not an Adaptive safety requirement.
|
||||
|
||||
The first experiment has no ordinary remote Garbage Collection or repacking guarantee. Commit Bundles and external
|
||||
Packs remain immutable, unreachable data may remain retained, and an interrupted publication may leave an unreferenced
|
||||
Pack. A remote Rebuild is therefore the only guaranteed way to reclaim all experimental history. This makes retained
|
||||
bytes and object count acceptance measurements rather than later operational details.
|
||||
|
||||
Adaptive and Opaque Journal consume the same local PouchDB Chunk split. A smaller average changed Chunk can reduce
|
||||
retained history in either format. Adaptive adds authenticated routes and independently framed records, while Opaque
|
||||
adds its own container, compression, and encryption overhead. No comparative storage saving is assumed before both
|
||||
representations are measured with the same changes.
|
||||
|
||||
### Ordinary request model
|
||||
|
||||
The object-profile model is shared with S3. Let:
|
||||
|
||||
- `B` be the number of Metadata batches published as distinct Commit Bundles;
|
||||
- `X` be the number of new external Pack objects needed by those batches;
|
||||
- `J` be the number of Opaque Journal objects produced for the same changes;
|
||||
- `W` be the number of visible Writer streams;
|
||||
- `K` be the number of those Writer descriptors not already cached by the opened process;
|
||||
- `P` be the number of additional complete Pack-container reads needed for missing Chunks; and
|
||||
- `M` be the number of missing Chunk frames fetched with Range.
|
||||
|
||||
After the endpoint safety result and repository binding have been cached for the active configuration, ordinary
|
||||
publication performs approximately `J` Opaque PUTs or `B + X` Adaptive PUTs. Every Adaptive batch creates one Bundle;
|
||||
each external Pack adds one preceding PUT. A confirmed conditional create does not add a read-back. An ambiguous PUT
|
||||
adds one exact-key GET before any retry and remains outside the successful-first-attempt estimate.
|
||||
|
||||
The generic object-store receive path performs approximately:
|
||||
|
||||
| Retrieval policy | Requests without WebDAV listing reuse |
|
||||
| --- | ---: |
|
||||
| `whole-pack` | `1 + W + K + B + P` |
|
||||
| `range` | `1 + W + K + B + M` |
|
||||
|
||||
The `1 + W` term represents one Writer listing and one Commit listing per Writer. A flat WebDAV collection normally
|
||||
cannot apply those logical prefixes on the server: each call may require another Depth-one `PROPFIND` over the same
|
||||
collection. The WebDAV target is therefore one complete listing at the start of a receive phase, filtered locally for
|
||||
every Writer and Commit prefix in that phase. This receive-phase listing must expire before the next receive phase; it
|
||||
must never become an unbounded cross-synchronisation cache. A Commit which becomes visible after the listing response
|
||||
is handled by the next receive phase, which is consistent with immutable eventual replication.
|
||||
|
||||
With that explicitly scoped reuse, the request estimates become `1 + K + B + P` and `1 + K + B + M`. For 100 new
|
||||
inline-Pack Bundles which require no additional Pack read, the difference is illustrative rather than a benchmark:
|
||||
|
||||
| Visible Writers | Descriptor state | Repeated-prefix listing | One receive-phase listing |
|
||||
| ---: | --- | ---: | ---: |
|
||||
| 1 | first receive (`K = 1`) | 103 | 102 |
|
||||
| 1 | warm process (`K = 0`) | 102 | 101 |
|
||||
| 10 | first receive (`K = 10`) | 121 | 111 |
|
||||
| 10 | warm process (`K = 0`) | 111 | 101 |
|
||||
|
||||
Opaque catch-up is approximately one listing plus one GET per new Journal object, or `1 + J`. Holding `B = J = 100`
|
||||
would therefore give 101 operations. That comparison does not assume that the two batchers produce equal object counts
|
||||
for a real editing history.
|
||||
|
||||
### Listing-volume hypothesis
|
||||
|
||||
Request count alone understates WebDAV discovery cost. Let `N` be the number of resources in the configured flat
|
||||
collection and `L` the average XML response bytes per resource for a minimal Depth-one `PROPFIND`. Repeating the
|
||||
listing for each logical prefix transfers roughly `(1 + W) * N * L`; one receive-phase listing transfers roughly
|
||||
`N * L`.
|
||||
|
||||
Assuming `L = 1 KiB` only to make the scale visible gives:
|
||||
|
||||
| Collection resources (`N`) | One listing | Repeated listing, `W = 1` | Repeated listing, `W = 10` |
|
||||
| ---: | ---: | ---: | ---: |
|
||||
| 1,000 | about 1 MiB | about 2 MiB | about 11 MiB |
|
||||
| 10,000 | about 10 MiB | about 20 MiB | about 107 MiB |
|
||||
| 100,000 | about 98 MiB | about 195 MiB | about 1.05 GiB |
|
||||
|
||||
Actual XML size, selected properties, URL length, compression, server implementation, and collection scope can change
|
||||
these values materially. The adapter should request only the properties it needs and measure both response bytes and
|
||||
wall time. The table explains the reuse target; it is not an estimate of every WebDAV server.
|
||||
|
||||
Ignoring cleaned probe resources and abandoned Packs, the current Commit Bundle layout contains approximately
|
||||
`1 + W + B + X` remote objects: one manifest, one descriptor per Writer, one object per Bundle, and one per external
|
||||
Pack. Object count therefore grows with synchronisation batches even when the retained payload is small, and it feeds
|
||||
back into subsequent `PROPFIND` cost.
|
||||
|
||||
### Retained-byte hypothesis
|
||||
|
||||
For a localised edit history, let `E` be the number of separately published changes and `D` the average bytes of newly
|
||||
created encoded Chunk frames per change. Before Garbage Collection or Rebuild, retained Chunk bytes grow approximately
|
||||
as `E * D`, plus Metadata, Commit, route, frame, server-metadata, and abandoned-publication overhead.
|
||||
|
||||
For a 10 MiB incompressible binary file whose content-defined split resumes after one changed Chunk:
|
||||
|
||||
| Published changes (`E`) | Average changed Chunk | Approximate new Chunk bytes retained | New Bundle objects when each Pack stays inline |
|
||||
| ---: | ---: | ---: | ---: |
|
||||
| 100 | 1 MiB | 100 MiB | 100 |
|
||||
| 1,000 | 1 MiB | about 0.98 GiB | 1,000 |
|
||||
| 100 | 256 KiB | 25 MiB | 100 |
|
||||
| 1,000 | 256 KiB | 250 MiB | 1,000 |
|
||||
|
||||
These figures exclude Metadata and protocol overhead and do not predict text splitting. They show why a 10 MiB live
|
||||
file can consume much more than 10 MiB remotely after many committed edits. They also show why reducing Chunk size is a
|
||||
storage-versus-record-count decision shared with Opaque Journal, not an Adaptive-only recommendation.
|
||||
|
||||
### Pack-target hypothesis
|
||||
|
||||
Changing the external Pack target does not normally duplicate Chunk frames: a Pack is their concatenation without
|
||||
capacity padding. Smaller targets chiefly increase object count, route data, PUTs, listing work, and later whole-Pack
|
||||
GETs. Larger targets increase peak buffer size, retry cost, transfer duration, and exposure to mobile watchdog or proxy
|
||||
timeouts.
|
||||
|
||||
For about 100 MiB of newly required encoded Chunk frames in one batch, the following serial-transfer model ignores RTT,
|
||||
TLS setup, encryption, server processing, contention, and retries:
|
||||
|
||||
| Pack target | External Packs (`X`) | PUTs including the Bundle | One target-sized upload at 5 / 10 Mbit/s |
|
||||
| ---: | ---: | ---: | ---: |
|
||||
| 8 MiB | 13 | 14 | about 13.4 / 6.7 seconds |
|
||||
| 16 MiB | 7 | 8 | about 26.8 / 13.4 seconds |
|
||||
| 32 MiB | 4 | 5 | about 53.7 / 26.8 seconds |
|
||||
| 64 MiB | 2 | 3 | about 107.4 / 53.7 seconds |
|
||||
|
||||
The total Chunk payload remains about 100 MiB in each row. The initial WebDAV experiment therefore retains the current
|
||||
32 MiB preferred target as a compromise, not as a universal optimum or a user-facing recommendation. Measurements on
|
||||
the disposable server must record peak memory, per-PUT duration, retry behaviour, total object count, and watchdog
|
||||
survival. A later decision may lower the internal target for timeout-constrained endpoints without changing the wire
|
||||
format.
|
||||
|
||||
## Staged acceptance
|
||||
|
||||
### Adapter and Commonlib integration
|
||||
|
||||
Unit tests own HTTP status classification, conditional-create verification, flat-name round trips, complete listing,
|
||||
probe isolation, cleanup reporting, Range validation, and whole-pack fallback. A disposable WebDAV integration runs
|
||||
the safety checker before exercising Adaptive Metadata and Chunk synchronisation. The integration also proves that one
|
||||
receive phase reuses a single collection listing, then invalidates it so the next phase can observe a new Commit.
|
||||
|
||||
### CLI end-to-end acceptance
|
||||
|
||||
The built CLI applies an Adaptive WebDAV Setup URI to a second independent database and synchronises text and binary
|
||||
Chunk-backed files through a real disposable server. One client uses whole-pack retrieval; Range is added to this layer
|
||||
only when the selected test server proves it. The CLI test does not repeat the complete endpoint capability matrix.
|
||||
|
||||
### Host settings and UI
|
||||
|
||||
The WebDAV dialogue exposes Adaptive mode only with clear capability-check results. It persists the expected repository
|
||||
ID and the selected retrieval policy, defaults to whole-pack, and reports that Range is optional. Focused tests own
|
||||
profile and Setup URI preservation without contacting a server.
|
||||
|
||||
### Real-host end-to-end acceptance
|
||||
|
||||
One real-Obsidian workflow uses the same known disposable WebDAV implementation, runs the safety gate, and proves a
|
||||
representative Chunk-backed transfer. Servers outside that fixture are diagnosed by the checker rather than added to a
|
||||
large real-host matrix.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Trust advertised WebDAV methods
|
||||
|
||||
Method advertisement does not prove the conditional, listing, visibility, and byte semantics required for immutable
|
||||
publication.
|
||||
|
||||
### Treat Range as mandatory
|
||||
|
||||
Whole-pack retrieval is correct and often competitive for throughput. Requiring Range would exclude otherwise safe
|
||||
servers for an optional optimisation.
|
||||
|
||||
### Add server-specific compatibility branches
|
||||
|
||||
The remote may be any implementation or proxy composition. Semantic checks produce a maintainable contract, while a
|
||||
growing server-name table would remain incomplete and become stale.
|
||||
|
||||
## Consequences
|
||||
|
||||
- WebDAV remains experimental because suitability is endpoint-specific.
|
||||
- The safety checker gives a concrete reason when a server cannot host Adaptive Journal.
|
||||
- Common object-pack behaviour is inherited from the earlier S3 boundary, keeping WebDAV-specific tests focused on HTTP
|
||||
semantics and name mapping.
|
||||
- Optional Range support can improve request efficiency without becoming a data-availability requirement.
|
||||
@@ -37,9 +37,9 @@ The Obsidian host injects the screen wake-lock manager from the `octagonal-wheel
|
||||
|
||||
Finite replication enters both counts only after readiness checks have succeeded and leaves them after `openReplication(..., continuous: false, ...)` settles. A successful completion has reached the latest sequence in that operation's scope and is therefore an authoritative quiescence boundary for chunk retrieval. A failed operation does not prove latest state, but can no longer deliver documents from that attempt. Failure handling runs afterwards so a mismatch or recovery dialogue does not retain the activity. This includes the direct start-up synchronisation path as well as manual, event-driven, and periodic calls through `ReplicationService`. The unbounded continuous channel does not enter either boundary, but its finite initial pull-only catch-up does; the one-shot parameter fallback chain remains inside that boundary.
|
||||
|
||||
Delivery into the local database and application to the Obsidian Vault are deliberately separate lifetimes. A mobile device can have only a short opportunity to obtain remote data, while applying a large downloaded batch to the Vault is durable, offline-capable work which can continue or resume later. The replication-result queue and its recovery snapshot therefore remain outside `boundedRemoteActivityCount`. Finite remote activity ends when the transfer operation settles, even when the `📥` queue still contains documents awaiting Vault application.
|
||||
Delivery into the local database and application to the Obsidian Vault are deliberately separate lifetimes. Finite remote activity ends when the transfer operation settles, even when the `📥` queue still contains documents awaiting Vault application. The Obsidian host tracks that queue through a separate `boundedLocalApplicationActivityCount`, so local Vault writes do not increment either remote-activity count or keep the `📲` indicator visible.
|
||||
|
||||
This separation also keeps the activity indicators truthful: `📲` describes a finite remote operation and must not remain active solely because local Vault writes are pending. The existing replication-result count continues to describe that local queue. If a future feature offers screen-awake protection while applying downloaded documents, it must use a separately typed local-application activity or power-policy boundary, preserve the current behaviour by default, and avoid incrementing either remote-activity count.
|
||||
Applying downloaded document changes enters one local-application boundary from the first queued document until the queue and its in-progress set are both empty. The final empty recovery snapshot is attempted before the boundary settles; snapshot failure is logged and still releases the boundary. Additional batches share the existing boundary. Suspending replication-result processing releases it, and resuming reacquires it while work remains. The same host activity runner supplies best-effort Wake Lock protection, while the visibility lifecycle waits for both remote and local bounded activity to finish.
|
||||
|
||||
Manual P2P commands which bypass `ReplicationService` enter the broad boundary. Direct P2P pull and push entry points are therefore both protected as finite remote work, covering the Obsidian panes, CLI, and Webapp. A pull or bidirectional synchronisation also enters the narrower finite-replication boundary because it can place documents in the local database. A push-only request remains broad-only: it cannot satisfy a local missing-chunk read and must not present itself as a delivery source. Automatic synchronisation on peer discovery, a pull requested by a remote peer, and a watched pull following a peer progress notification enter both boundaries because each can deliver local documents. A normal P2P peer-selection dialogue represents one broad finite session: it remains inside the boundary while waiting for a peer and while the person may perform repeated synchronisations, then settles when the dialogue closes and any in-flight synchronisation has finished. Closing without synchronising returns a failed result and releases the boundary. The 'Start Sync & Close' action completes its synchronisation before closing. This deliberately protects peer discovery and selection, because display sleep can interrupt discovery or connection establishment and require the person to start detection again. It may therefore retain a Wake Lock longer than the network transfer alone. A transfer performed inside that session temporarily adds a nested activity; the count remains a logical-operation count rather than a connection total.
|
||||
|
||||
@@ -76,6 +76,8 @@ P2P does not yet contribute to the physical-request count because it does not ha
|
||||
|
||||
The platform activity runner remains injected. Common library and headless consumers can omit it while retaining the same bounded activity count and operation semantics.
|
||||
|
||||
The Obsidian-specific `ObsidianReplicatorService` owns `boundedLocalApplicationActivityCount` and reuses the injected activity runner. It is deliberately absent from the common service contract: CLI and Webapp processing continue directly, while the Obsidian host uses the count for Wake Lock and visibility-lifecycle policy without changing remote-operation reporting.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not count continuous replication as a bounded activity.
|
||||
@@ -85,11 +87,11 @@ The platform activity runner remains injected. Common library and headless consu
|
||||
- Do not guarantee protection against operating-system suspension, closing a laptop lid, forced termination, network loss, or a user-initiated sleep action.
|
||||
- Do not add a lifecycle timeout which would abort an unusually slow rebuild. A genuinely stalled operation may postpone LiveSync's visibility suspension until it settles, but the platform may still suspend or terminate background work.
|
||||
- Do not broaden `keepReplicationActiveInBackground`; it remains an opt-in desktop policy for continuous and periodic operation after finite work has ended.
|
||||
- Do not include offline scans, unrelated local storage reflection, or the durable replication-result queue in this boundary. They are offline-capable and require a separate decision if activity reporting or power policy is added later.
|
||||
- Do not include offline scans, unrelated local storage reflection, or the durable replication-result queue in either remote-activity count. The queue uses its separate local-application boundary.
|
||||
|
||||
## Verification
|
||||
|
||||
Before changing the transfer/application separation, add a deterministic regression scenario which leaves downloaded documents queued after finite transfer settles, verifies that remote activity has ended, persists the queued state, and resumes Vault application after suspension or restart. An optional local-application Wake Lock feature requires its own enabled and disabled cases; existing remote-operation E2E is not evidence for that separate policy.
|
||||
Before changing the transfer/application separation, keep deterministic coverage which leaves downloaded documents queued after finite transfer settles, verifies that remote activity has ended, and retains the separate local-application boundary until Vault application and the final recovery snapshot settle.
|
||||
|
||||
Unit tests cover:
|
||||
|
||||
@@ -106,6 +108,8 @@ Unit tests cover:
|
||||
- remote chunk fetching remaining inside the shared boundary from synchronous queue acceptance through local persistence and terminal notification;
|
||||
- missing-chunk waiters rechecking local storage when observed per-identifier claims and finite replication have settled;
|
||||
- the finite-replication count excluding other bounded work;
|
||||
- replicated document application sharing one local boundary, settling after the final recovery snapshot, and releasing around processing suspension;
|
||||
- local application activity leaving both remote-activity counts unchanged;
|
||||
- standard, fast, remote, and combined rebuild activity boundaries;
|
||||
- Rebuilder-owned confirmation and completion dialogues remaining outside rebuild activity;
|
||||
- fallback from fast fetch avoiding a nested activity boundary;
|
||||
@@ -125,5 +129,5 @@ The exact Fancy Kit screen wake-lock behaviour is covered by its package and Har
|
||||
- One finite activity definition drives Wake Lock, lifecycle protection, and status UI without coupling common library code to Obsidian or browser globals.
|
||||
- Callers can observe accurate logical activity even in CLI and Webapp hosts which do not inject a Wake Lock implementation.
|
||||
- Rebuild operations now retain Wake Lock and lifecycle protection across their longest interruption-sensitive phases without retaining them for Rebuilder-owned pre-operation or completion dialogues. Post-reset P2P discovery and selection remain protected as an intentional part of completing the rebuild.
|
||||
- Downloaded documents may remain in the durable Vault-application queue after remote activity has ended, allowing transfer and offline application to follow different mobile lifetimes without presenting local writes as communication.
|
||||
- Downloaded documents may remain in the durable Vault-application queue after remote activity has ended, while a separate local-application boundary retains best-effort Wake Lock and lifecycle protection without presenting local writes as communication.
|
||||
- Users can now distinguish the lifetime of a finite remote operation from approximate request activity within it.
|
||||
|
||||
@@ -65,7 +65,7 @@ The special meaning would duplicate `activeConfigurationId`, make a user-visible
|
||||
|
||||
### Add profile naming and full list editing to onboarding
|
||||
|
||||
That would make the first-run path longer and duplicate the established Remote Databases interface. Automatic descriptive names and later renaming keep this change limited to data integrity and consistent selection.
|
||||
That would make the first-run path longer and duplicate the established Saved connections interface. Automatic descriptive names and later renaming keep this change limited to data integrity and consistent selection.
|
||||
|
||||
### Replace the compatibility fields immediately
|
||||
|
||||
@@ -77,11 +77,11 @@ Commonlib unit tests cover preserving existing profiles, opaque-ID insertion, ge
|
||||
|
||||
Self-hosted LiveSync unit tests cover preserving modern Setup URI profiles and their active selection, retaining legacy Setup URI and QR migration, adding CouchDB and Object Storage profiles beside an existing profile, independent P2P selection, fresh P2P selection as both main and P2P remote, and cancellation without mutation.
|
||||
|
||||
The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, safe-area and touch-target checks, cancellation, and command reopening. It does not contact a remote or submit credentials. Remote connection correctness remains owned by the CouchDB, Object Storage, P2P, and two-Vault suites. The end-to-end Setup URI and provisioning acceptance workflow remains a separate release gate.
|
||||
The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, safe-area and touch-target checks, cancellation, and reopening from the Setup pane. It does not contact a remote or submit credentials. Remote connection correctness remains owned by the CouchDB, Object Storage, P2P, and two-Vault suites. The end-to-end Setup URI and provisioning acceptance workflow remains a separate release gate.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Manual onboarding and the Remote Databases pane share one Commonlib profile contract.
|
||||
- Manual onboarding and the Saved connections list share one Commonlib profile contract.
|
||||
- Existing profiles survive reconfiguration, and a newly configured connection becomes explicitly selectable.
|
||||
- Modern imports retain user-assigned profile identity and names.
|
||||
- Legacy Setup URIs continue to work through an isolated compatibility boundary.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Architectural Decision Record: Browser-assisted P2P Connection Check
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — WebPeer provides a disposable, browser-observed connection check with a clean one-device path and an optional same-room second-device attempt.
|
||||
|
||||
## Context
|
||||
|
||||
P2P availability depends on the browser, device, signalling relay, NAT, firewall, and current network. A Setup URI can make a trial easier, but a connection observed between a browser and one device does not prove that two other devices can connect to each other, and a WebRTC connection does not prove that LiveSync documents can be synchronised correctly.
|
||||
|
||||
Showing this experiment inside the ordinary LiveSync interface would mix a temporary connectivity check with the state of a real Vault. It would also make a browser-owned diagnostic result appear to be a plug-in-owned synchronisation result.
|
||||
|
||||
The Commonlib diagnostic counters are cumulative within one JavaScript realm. A negotiation may create more than one `RTCPeerConnection`, a failed attempt may later be followed by a successful retry, and closing a successful connection is not a failure. The counters therefore cannot be interpreted as peer counts or as mutually exclusive outcomes.
|
||||
|
||||
## Decision
|
||||
|
||||
WebPeer provides a separate page labelled 'P2P connection check'. It acts as a temporary reference peer and creates a fresh, random P2P configuration locally in the browser. The page displays an encrypted Setup URI, a QR code containing that URI, and the separate Setup URI passphrase.
|
||||
|
||||
The initial implementation is part of the WebPeer production build and is served over HTTPS or from `localhost`. A downloaded copy of `check.html` alone is not a supported standalone application because the build uses separate module assets and origin-scoped browser storage.
|
||||
|
||||
Each check has these boundaries:
|
||||
|
||||
| Participant | Responsibility |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Browser page | Joins the generated room as the diagnostic reference peer and displays its own diagnostic counters. |
|
||||
| Desktop or mobile LiveSync | Opens the generated Setup URI in a dedicated empty Vault and attempts the ordinary P2P connection. |
|
||||
| User | Uses an empty Vault for every device and chooses either a fresh room or the explicitly less isolated same-room second-device attempt. |
|
||||
|
||||
The generated device configuration:
|
||||
|
||||
- uses a dedicated P2P application identifier so that test rooms do not overlap ordinary LiveSync rooms;
|
||||
- uses random room, P2P, Vault-encryption, and Setup URI secrets;
|
||||
- enables P2P auto-start so that a dedicated empty test Vault attempts the connection after setup;
|
||||
- does not enable automatic broadcasting, peer acceptance, watching, or replication;
|
||||
- omits device-local names from the Setup URI; and
|
||||
- enables diagnostic WebRTC wrapping only on the browser reference peer.
|
||||
|
||||
The browser joins the room only after the user explicitly starts monitoring. Preparing the Setup URI and QR code does not contact the signalling relay. The page must remain open while monitoring.
|
||||
|
||||
The primary path checks one target device. The user selects either desktop or mobile before generating the configuration. Starting a fresh check reloads the page, creates a new room, and resets the page-scoped diagnostic baseline; this remains the clearest way to compare devices.
|
||||
|
||||
After the first connection succeeds, the page can scroll back to the existing QR code without generating new credentials. It also offers an optional same-room second-device attempt. This keeps the browser and first device connected, records the current diagnostic totals and active `RTCPeerConnection` identifiers as a local baseline, and reuses the exact Setup URI for another empty Vault. It reports an additional connection only when both the successful-connection total and the number of simultaneous active connections increase, with an active connection identifier absent from the baseline. A reconnect which replaces the first connection can therefore increase negotiation counters without satisfying the additional-connection result.
|
||||
|
||||
The same-room result remains a convenience rather than an isolated comparison. Its totals are still cumulative, a connection is not a device identity, it depends on the first device remaining connected, and unusual reconnect timing can be harder to interpret than a fresh room. The interface keeps the fresh-check action available and explains this limitation beside the additional result.
|
||||
|
||||
The page displays the following Commonlib diagnostic totals:
|
||||
|
||||
- new connection states;
|
||||
- successful connection states;
|
||||
- failed connection states; and
|
||||
- closed connection states.
|
||||
|
||||
A successful total greater than zero proves that this browser and the target device established at least one WebRTC connection in the generated room. That successful result remains valid if a later closed or failed total increases. A failed total without a successful total describes a failed attempt, not a final verdict, because the transport may retry. If no success has been observed after the documented observation period, the result is inconclusive for P2P on the current network. The page recommends CouchDB for predictable synchronisation when repeated checks on the intended networks do not connect.
|
||||
|
||||
The page does not claim to verify document transfer, conflict handling, sustained connectivity, background execution, or a direct desktop-to-mobile path. An optional final test can use two disposable empty Vaults and a note round trip directly between the user's devices. That direct test is more representative, but its result cannot be observed reliably by the browser page without a separate reporting protocol.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
All credentials are generated with Web Crypto in the browser. The Setup URI is encrypted, its passphrase is shown separately, and neither value is uploaded merely by preparing the check. Their read-only fields disable browser autocomplete and spell-check; copying either value to the system clipboard remains an explicit user action. Starting monitoring uses the configured signalling relay, which is the existing public relay by default, and exposes the usual P2P signalling metadata to that service. WebRTC and optional relay or TURN behaviour retain their existing Commonlib security and privacy properties.
|
||||
|
||||
The production page does not accept a relay override from a deployed origin. A `relay` query parameter is honoured only when the page itself is served from a loopback host, and only for `ws:` or `wss:` URLs. This narrow boundary allows the real-Obsidian E2E scenario to use the local Compose relay without making an arbitrary relay query part of the hosted user workflow.
|
||||
|
||||
The generated configuration is disposable. It must be used only in a dedicated empty Vault, must not replace a production Vault's settings, and must not be retained as the credentials for a later production setup. The browser page stores no ordinary Vault files for this workflow, although browser-local database infrastructure is opened to satisfy the existing WebPeer runtime contract.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Display the result in the LiveSync plug-in
|
||||
|
||||
The observation belongs to the browser reference peer and does not prove LiveSync document synchronisation. Keeping the result beside the QR code makes the evidence and its owner explicit.
|
||||
|
||||
### Use browser, desktop, and mobile simultaneously as the primary comparison
|
||||
|
||||
The diagnostic totals are cumulative and do not attribute an `RTCPeerConnection` to a named target device. Separate random rooms provide a clearer result. Same-room reuse is retained only as an explicit convenience, with a counter baseline and another simultaneous active connection required for its result.
|
||||
|
||||
### Treat any failure increment as a final failure
|
||||
|
||||
Negotiation can fail and then retry successfully. A later successful increment takes precedence over earlier failure increments.
|
||||
|
||||
### Treat browser success as proof of device-to-device synchronisation
|
||||
|
||||
Browser-to-device success is a useful preflight check, but it does not exercise the exact desktop-to-mobile network path or a LiveSync note round trip.
|
||||
|
||||
### Put the Setup URI passphrase in the QR code
|
||||
|
||||
Keeping the encrypted URI and passphrase separate preserves the existing Setup URI boundary and avoids turning one captured QR code into the complete configuration secret.
|
||||
|
||||
### Distribute one standalone downloaded HTML file
|
||||
|
||||
WebPeer already depends on a compiled module graph, origin-scoped storage, and secure browser capabilities. A hosted production build reuses those boundaries and can be updated consistently; a single self-contained file would require a separate bundling and trust model.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit tests decode each generated Setup URI and verify its isolation identifier, random credentials, remote-profile selection, automatic-start policy, absence of a device-local name, and browser-only diagnostic setting. Pure outcome tests cover waiting, failed attempts, later success, and closure after success.
|
||||
|
||||
The WebPeer browser smoke test verifies that the production page can prepare a desktop or mobile check, render the QR code, display a separately formatted passphrase, expose zeroed counters without contacting the signalling relay, and return to the unchanged QR code. Pure tests verify that a reconnect which replaces the first connection does not satisfy the same-room additional-connection rule. The focused real-Obsidian E2E test serves the production page from loopback, starts the local Compose Nostr relay, applies the browser-generated Setup URI through visible onboarding in one empty Vault, records the same-room baseline, reuses the unchanged URI in a second empty Vault, and requires the browser to observe another simultaneous active connection before retaining a result screenshot. Existing Commonlib tests remain authoritative for diagnostic WebRTC wrapping, Trystero negotiation, and P2P lifecycle behaviour.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users can assess whether P2P is plausible on each intended device and network before relying on it.
|
||||
- Results remain visibly scoped to connectivity rather than synchronisation correctness.
|
||||
- A fresh test requires a fresh page session and disposable empty Vault; same-room reuse requires another empty Vault and retains cumulative counters.
|
||||
- CouchDB remains the recommended predictable option when representative P2P checks repeatedly fail.
|
||||
- Direct device-to-device note transfer remains a separate, optional verification step.
|
||||
@@ -60,7 +60,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
|
||||
|
||||
### Onboarding activation and initialisation
|
||||
|
||||
- Keep an unconfigured Vault outside database initialisation, offline scanning, and configured-only checks. Offer setup through the long-lived onboarding Notice and the permanent command instead of opening a competing dialogue automatically.
|
||||
- Keep an unconfigured Vault outside database initialisation, offline scanning, and configured-only checks. Offer setup through the long-lived onboarding Notice, and allow the wizard to be reopened from the Setup pane instead of opening a competing dialogue automatically.
|
||||
- For new-device onboarding, reserve Rebuild before enabling and saving the accepted settings.
|
||||
- For an unconfigured existing device, reserve Fetch before enabling and saving imported or manually confirmed settings.
|
||||
- Suspend the current runtime after the flag has been written, apply the accepted settings through the scheduler's preparation callback, and request restart only after that callback succeeds.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -48,7 +48,7 @@ A TURN provider cannot read LiveSync's encrypted Vault contents, but it can obse
|
||||
|
||||
The **P2P Status** pane is the current Obsidian interface for P2P connections.
|
||||
|
||||
- The command **Self-hosted LiveSync: P2P Sync : Open P2P Status** remains available from the command palette.
|
||||
- After a P2P configuration exists, the command **Self-hosted LiveSync: P2P Sync : Open P2P Status** is available from the command palette.
|
||||
- The P2P ribbon icon appears only after a P2P configuration exists.
|
||||
- LiveSync does not open the pane merely because Obsidian has started. If the pane was already part of the saved Obsidian workspace, Obsidian may restore it.
|
||||
- Workspaces containing the retired P2P pane are migrated to the current status pane. The retired command is no longer exposed.
|
||||
|
||||
+23
-1
@@ -32,6 +32,28 @@ While suspended:
|
||||
|
||||
The flag deliberately enables file logging, which may affect performance. Remove it after the emergency has been understood.
|
||||
|
||||
## Recover a conflicted or mismatched file
|
||||
|
||||
Use this workflow when one file, or a small number of known files, has conflicts, missing chunks, or a difference between the current Vault file and the local LiveSync database. The inspection is device-local: it does not query a remote database or prove that another device has the same chunks.
|
||||
|
||||
The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other live version.
|
||||
|
||||
1. Stop editing the affected file, pause replication on the participating devices, and keep a separate copy of every readable version.
|
||||
2. If another device or backup has the intended content, preserve that copy before changing any revision.
|
||||
3. If the current Vault file is readable, select **Recreate current chunks**. This can restore only chunks derived from the current Vault contents; it cannot reconstruct unique bytes from an unavailable historical or conflict revision.
|
||||
4. Select **Inspect conflicts and file/database differences** → **Begin inspection**.
|
||||
5. Review the database winner, every conflict revision, and any unavailable shared ancestor separately. Revision identifiers, `Δsize`, `Δtime`, and chunk availability are diagnostic evidence; they do not decide which content is correct.
|
||||
6. Use the wrench menu on the exact revision:
|
||||
- **Compare with Vault** opens a read-only comparison for readable text.
|
||||
- **Apply this revision to Vault** replaces the Vault file with that readable database revision.
|
||||
- **Mark this revision as the Vault version** records an exact byte-for-byte match without creating a child revision.
|
||||
- **Store Vault file as a child of this revision** preserves the current Vault bytes on that selected branch.
|
||||
- **Retry reading revision** retries configured chunk retrieval without changing the revision tree.
|
||||
- **Apply logical deletion to Vault**, **Discard this branch**, and **Discard unreadable revision** are destructive decisions. Use them only after preserving every version which may still be needed.
|
||||
7. Synchronise the healthy source if chunks were restored, scan again, and confirm that the expected conflict or difference has disappeared before resuming ordinary editing.
|
||||
|
||||
An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another live branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files).
|
||||
|
||||
## Reset synchronisation on this device
|
||||
|
||||
Use this when the remote copy is trusted but this device's local LiveSync database is incomplete, corrupt, or no longer aligned with it.
|
||||
@@ -69,7 +91,7 @@ Garbage Collection removes unreferenced chunks while preserving the current data
|
||||
- all relevant devices have synchronised; and
|
||||
- the remaining historical and deletion state is understood.
|
||||
|
||||
Deleted documents and tombstones are not free, and historical revisions may keep chunks reachable. Garbage Collection therefore cannot promise the smallest possible remote.
|
||||
Deleted documents, tombstones, live conflicts, and retained metadata are not free. Live conflict branches keep the chunks needed for review, while an ordinary superseded linear revision does not protect its former chunks. Garbage Collection can therefore make old content unreadable and cannot promise the smallest possible remote. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
|
||||
|
||||
Rebuild is a different operation. It reconstructs the database from a chosen authoritative state and is the more certain way to remove unwanted history or repair a damaged remote, but it is also more disruptive and can discard changes which exist only elsewhere.
|
||||
|
||||
|
||||
@@ -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.
|
||||
+38
-12
@@ -12,7 +12,7 @@ The following status applies to optional and compatibility features in the 1.0 l
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Supported, opt-in | Peer-to-Peer Synchronisation, Hidden File Sync, and Customisation Sync | Maintained and covered by focused real-runtime tests. Enable them only where their separate setup and operational constraints are acceptable. |
|
||||
| Maintained, advanced | Data Compression | Available as an explicit storage and bandwidth trade-off. It remains disabled by default because the measured processing and memory costs outweigh the mixed-dataset saving. |
|
||||
| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. |
|
||||
| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 for CouchDB | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. |
|
||||
| Compatibility only | V1 dynamic iteration counts, the old IndexedDB adapter, non-current hash algorithms, Eden chunks, and the stored `doNotUseFixedRevisionForChunks` key | Existing settings and data remain readable. New Vaults use the current defaults, and compatibility controls are shown only where a migration or recovery path still needs them. |
|
||||
|
||||
| Icon | Description |
|
||||
@@ -40,7 +40,7 @@ Internal database or settings compatibility reviews use a separate safety dialog
|
||||
|
||||
This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync.
|
||||
|
||||
An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action, and **Open onboarding wizard** remains available from the command palette after that Notice closes.
|
||||
An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, open **Self-hosted LiveSync settings** → **Setup** → **Rerun Onboarding Wizard**.
|
||||
|
||||
Choose the new-device path when this device owns the files which should initialise synchronisation. Choose the existing-device path when it should receive an established remote state. The wizard reserves Rebuild or Fetch respectively before enabling the settings and requesting a restart, so the selected initialisation runs before the ordinary start-up scan.
|
||||
|
||||
@@ -169,9 +169,9 @@ Show verbose log. Please enable when you report the logs
|
||||
|
||||
## 3. Remote Configuration
|
||||
|
||||
### 1. Remote Server
|
||||
### 1. Connection settings
|
||||
|
||||
Self-hosted LiveSync supports multiple remote connection profiles under **Remote Server** -> **Remote Databases**. This allows you to save and switch between multiple databases or bucket configurations in a single vault.
|
||||
Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings** → **Saved connections**. Each profile represents a CouchDB database, an Object Storage connection, or a P2P configuration, and several profiles can be kept in one Vault.
|
||||
|
||||
Each profile has an opaque identifier and a presentation name. The name does not need to be unique and is not used to select the profile. The main remote and the P2P remote are selected independently, so code and settings imports must preserve both selections rather than relying on a special identifier such as `default`.
|
||||
|
||||
@@ -185,7 +185,7 @@ Each profile has an opaque identifier and a presentation name. The name does not
|
||||
|
||||
Setting key: remoteType
|
||||
|
||||
The active remote server type. This is automatically projected to the legacy configuration when you activate a connection profile.
|
||||
The active connection type. This is automatically projected to the legacy configuration when you activate a connection profile.
|
||||
|
||||
### 2. Notification
|
||||
|
||||
@@ -301,6 +301,18 @@ Setting key: bucketCustomHeaders
|
||||
|
||||
Custom HTTP headers to include in every request sent to the Object Storage bucket. Specify them in the format `Header-Name: Value`, with each header on a new line.
|
||||
|
||||
#### Journal data format
|
||||
|
||||
Setting key: journalFormat
|
||||
|
||||
Existing Object Storage profiles use `opaque-v1` unless Adaptive Journal is selected explicitly. `adaptive-v1` uses an authenticated manifest and immutable Commit Bundles under a separate namespace, with larger Packs stored separately. Changing between formats requires an explicit remote Rebuild; LiveSync does not migrate or read both representations.
|
||||
|
||||
#### Pack retrieval
|
||||
|
||||
Setting key: packReadPolicy
|
||||
|
||||
Adaptive Journal can download a complete immutable Pack (`whole-pack`) or request only the required byte ranges (`range`). Complete Pack retrieval is the portable, throughput-oriented default. Selecting Range requires exact byte-range support from the configured S3-compatible endpoint. The connection test verifies that capability, and synchronisation refuses an unsupported selection before writing.
|
||||
|
||||
#### Test Connection
|
||||
|
||||
#### Apply Settings
|
||||
@@ -698,6 +710,12 @@ Open the dialogue
|
||||
|
||||
#### Make report to inform the issue
|
||||
|
||||
#### Copy database information for a file
|
||||
|
||||
Select a file to copy its local database information. The command **Copy database information for the active file** performs the same inspection for the file open in the editor.
|
||||
|
||||
The report includes the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote or include file contents. Paths and identifiers can still be private metadata, so review the report before sharing it.
|
||||
|
||||
#### Write logs into the file
|
||||
|
||||
Setting key: writeLogToTheFile
|
||||
@@ -721,17 +739,19 @@ Stop reflecting database changes to storage files.
|
||||
|
||||
### 3. Recovery and Repair
|
||||
|
||||
#### Recreate missing chunks for all files
|
||||
#### Recreate chunks for current Vault files
|
||||
|
||||
This will recreate chunks for all files. If there were missing chunks, this may fix the errors.
|
||||
Recreate chunks from files currently present in the Vault. This can repair missing chunks for those exact current contents after they have been confirmed as authoritative. It cannot reconstruct unavailable historical or conflict content.
|
||||
|
||||
#### Inspect conflicts and file/database differences
|
||||
|
||||
Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded.
|
||||
|
||||
Select **Begin inspection** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history.
|
||||
|
||||
#### Resolve All conflicted files by the newer one
|
||||
|
||||
Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.
|
||||
|
||||
#### Verify and repair all files
|
||||
|
||||
Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.
|
||||
After confirmation, resolve every conflict by modification time. This logically deletes every version except the newest one. It is a destructive policy choice and cannot recover content which is already unavailable.
|
||||
|
||||
#### Check and convert non-path-obfuscated files
|
||||
|
||||
@@ -1039,6 +1059,12 @@ Purge all download/upload cache.
|
||||
|
||||
Delete all data on the remote server.
|
||||
|
||||
### 6. Garbage Collection V3 (CouchDB only)
|
||||
|
||||
Garbage Collection V3 identifies chunk documents which are not reachable from any current file or live conflict branch, creates logical deletions for those chunks locally, propagates the deletions to CouchDB, and requests remote compaction.
|
||||
|
||||
Use it only when the Vault, local database, and remote are healthy, and every relevant device has synchronised. It can make an ordinary superseded file revision unreadable when no live state still needs its chunks. It does not repair corruption or replace a deliberate rebuild. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
|
||||
|
||||
### 7. Reset
|
||||
|
||||
#### Delete local database to reset or uninstall Self-hosted LiveSync
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ Before starting:
|
||||

|
||||
|
||||
7. Keep optional features disabled until ordinary note synchronisation works.
|
||||
8. Open `Self-hosted LiveSync: P2P Sync : Open P2P Status` from the command palette. After a P2P profile exists, the P2P ribbon icon provides the same destination. Select `Open connection` if signalling is disconnected.
|
||||
8. After saving the P2P profile, open `Self-hosted LiveSync: P2P Sync : Open P2P Status` from the command palette. The P2P ribbon icon provides the same destination. Select `Open connection` if signalling is disconnected.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -46,6 +46,37 @@ The all-branch history check prevents a resolved conflict from being recreated m
|
||||
|
||||
The compatibility implementation currently selects the newer modification time for differing binary conflicts even when the general **Always overwrite with a newer file** option is disabled. This is existing behaviour, not a new 1.0 guarantee. Changing it to explicit selection only is a separate compatibility decision.
|
||||
|
||||
## Unreadable revisions and repair
|
||||
|
||||
A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing.
|
||||
|
||||
**Hatch** → **Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
|
||||
|
||||
Each reported live revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:
|
||||
|
||||
- **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files.
|
||||
- **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation.
|
||||
- **Mark this revision as the Vault version** is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection.
|
||||
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected live branch.
|
||||
- **Apply logical deletion to Vault** removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance.
|
||||
- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree.
|
||||
- **Discard this branch** is available for each exact live revision while at least one other live branch remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
|
||||
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only live leaf. It requires confirmation because no other database branch remains.
|
||||
|
||||
Every mutating action rechecks that the selected revision is still a current live leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
|
||||
|
||||
The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win.
|
||||
|
||||
A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
|
||||
|
||||
Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible.
|
||||
|
||||
**Recreate chunks for current Vault files** can recreate chunks only from files which are readable in the current Vault. It cannot reconstruct unique bytes from an unavailable historical or conflict revision.
|
||||
|
||||
Garbage Collection V3 treats every live conflict revision and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
|
||||
|
||||
A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that live revision. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted.
|
||||
|
||||
### Two devices independently create the same path
|
||||
|
||||
If two devices create the same full synchronised path before either device has
|
||||
@@ -220,8 +251,8 @@ Do not:
|
||||
|
||||
## Verification
|
||||
|
||||
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple live leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
|
||||
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple live leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
|
||||
|
||||
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one live result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other live branches remain intact.
|
||||
|
||||
The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three live versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the live pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning.
|
||||
The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three live versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the live pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable live revision remains in the tree, and exercises explicit retry and discard controls without deleting another live revision.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Garbage Collection V3
|
||||
|
||||
Garbage Collection V3 is a beta maintenance operation for CouchDB remotes. It removes chunk documents which are no longer required by the current local revision tree, propagates those logical deletions to CouchDB, and then requests remote compaction.
|
||||
|
||||
It is not a repair operation. Use it only when the Vault, the local LiveSync database, and the CouchDB remote are healthy, every relevant device has synchronised, and recoverable backups exist.
|
||||
|
||||
## Supported scope
|
||||
|
||||
Garbage Collection V3 is available in Edge Case mode for CouchDB. It is not offered for Object Storage or P2P:
|
||||
|
||||
- Object Storage has a different journal and object lifecycle.
|
||||
- P2P has no central database to compact and cannot provide the accepted-device progress information required by this workflow.
|
||||
|
||||
The operation requires **Fetch chunks on demand** to be off so that its local reachability result is not confused with chunks which exist only on the remote.
|
||||
|
||||
## Workflow
|
||||
|
||||
After the user starts Garbage Collection V3, LiveSync:
|
||||
|
||||
1. completes a one-shot bidirectional CouchDB synchronisation;
|
||||
2. reads the accepted-device list and current progress recorded on the remote;
|
||||
3. requires parseable progress information, warns when an accepted device has no current information or device progress differs, then requires explicit confirmation;
|
||||
4. computes the chunks reachable from the local PouchDB revision tree;
|
||||
5. creates a logical deletion for each locally present chunk which is not reachable;
|
||||
6. completes a push-only replication so that those deletions reach CouchDB;
|
||||
7. requests CouchDB compaction and waits for it for up to two minutes; and
|
||||
8. clears the local chunk caches.
|
||||
|
||||
If the initial synchronisation, device inspection, or confirmation fails, the workflow stops before collection. Missing or invalid device progress is treated as a failed inspection rather than offered as a confirmation override. If push-only replication fails, the local logical deletions have already been created, but remote compaction is not started; synchronise again before retrying or using another device. A compaction failure or completion timeout is reported separately and is not also reported as a successful completion.
|
||||
|
||||
## Reachability rules
|
||||
|
||||
A chunk remains reachable when it is referenced by any of the following:
|
||||
|
||||
- the current database winner for a file;
|
||||
- any other live conflict revision for that file;
|
||||
- an available revision on either side of a live conflict which is required to describe the divergence; or
|
||||
- the nearest available revision shared by both live conflict branches.
|
||||
|
||||
Chunk identifiers are content-derived and shared between files. Reachability is therefore collected into one set across the database. A chunk used by two or more current files remains protected even when one file is updated or deleted.
|
||||
|
||||
An ordinary superseded linear revision does not protect its former chunks. Once no current file or live conflict branch references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible.
|
||||
|
||||
## Consequences
|
||||
|
||||
Garbage Collection deliberately trades historical recoverability for storage. A metadata revision may remain in the revision tree after a chunk which only that superseded revision used has been collected, so that historical body can become unreadable. Remote compaction can then discard old CouchDB revision bodies. Tombstones and retained metadata also consume storage, so the operation does not promise the smallest possible database.
|
||||
|
||||
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
|
||||
|
||||
Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Inspect conflicts and file/database differences**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required.
|
||||
|
||||
## Verification
|
||||
|
||||
Commonlib tests use real in-memory PouchDB revision trees to verify:
|
||||
|
||||
- collection eligibility after a normal file update;
|
||||
- protection of chunks shared by multiple current files;
|
||||
- protection of all live conflict branches and their nearest available shared ancestor;
|
||||
- eligibility of losing-branch and ancestor-only chunks after conflict resolution;
|
||||
- propagation of chunk deletion to another PouchDB database; and
|
||||
- recreation and propagation when the same content is written again.
|
||||
|
||||
Self-hosted LiveSync unit tests verify that Garbage Collection V3 uses Commonlib's revision-aware result, deletes only unreachable chunks, performs the initial bidirectional and final push-only replications in order, and requests remote compaction.
|
||||
|
||||
A disposable real-CouchDB integration test verifies logical deletion on the server, retention of shared and conflict chunks, compaction completion, replication after collection, and recreation of a content-addressed chunk. CouchDB's choice and timing of physical byte reclamation remain an external database boundary, so the test does not assert a particular reduction in database size.
|
||||
+1
-1
@@ -27,7 +27,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
|
||||
- Boot-up sequence (boot-sequence)
|
||||
- The initialisation process of the plug-in when Obsidian starts. It starts with the loading of the plug-in, setting up core services, loading saved settings, and opening the local database. Once the layout is ready, the plug-in checks for the presence of flag files, runs configuration diagnostics, connects to the remote database, and begins file watching. The sequence finishes once the plug-in is fully ready and operational.
|
||||
- Broken files (Size mismatch)
|
||||
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be detected and resolved by running validation tools such as `Verify and repair all files` on the Hatch pane.
|
||||
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be inspected with `Inspect conflicts and file/database differences` on the Hatch pane, then handled one exact revision at a time.
|
||||
- Chunk / Chunks
|
||||
- Divided units of data stored in the database or object storage to facilitate efficient synchronisation.
|
||||
- Compaction
|
||||
|
||||
+15
-4
@@ -53,9 +53,18 @@ Check Obsidian's `Detect all file extensions`, LiveSync selectors, ignore files,
|
||||
|
||||
If the log reports missing chunks or a size mismatch:
|
||||
|
||||
1. restart Obsidian once to rule out an interrupted fetch;
|
||||
2. on a device which has the correct file, run `Recreate missing chunks for all files`, then synchronise; and
|
||||
3. if the mismatch remains, run `Verify and repair all files` from `Hatch` and review which copy is authoritative.
|
||||
1. stop editing the affected file and keep a separate copy of any readable content;
|
||||
2. restart Obsidian once to rule out an interrupted fetch;
|
||||
3. synchronise a device or restore a backup which still has the correct content;
|
||||
4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise;
|
||||
5. follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file); run `Inspect conflicts and file/database differences` from `Hatch`, then use each revision's wrench menu to review and act on that exact branch; and
|
||||
6. use `Discard this branch` only after confirming that the exact live branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole live leaf.
|
||||
|
||||
The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other live branches still need a decision. Every mutating action rechecks that its selected revision is still live. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted.
|
||||
|
||||
`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact live revision while another live branch remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable live leaf. Neither action purges history or reconstructs missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
|
||||
|
||||
`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision.
|
||||
|
||||
## A configuration mismatch dialogue blocks synchronisation
|
||||
|
||||
@@ -110,6 +119,8 @@ Enable Obsidian's `Detect all file extensions`, then check LiveSync selectors, i
|
||||
|
||||
Run `Generate full report for opening the issue with debug info` to copy the current settings summary and recent verbose log lines. Remove credentials, remote URLs, Vault names, file contents, and other private information before sharing it.
|
||||
|
||||
When a problem concerns one file, run **Copy database information for the active file**, or use **Hatch** → **Copy database information for a file** to select another file. The report describes this device's local database view, including the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote server or include file contents. Treat paths and identifiers as private metadata before sharing.
|
||||
|
||||
Use `Show log` for live inspection. Logs are intentionally kept in memory for a limited time to reduce accidental disclosure. Enable `Write logs into the file` only while reproducing a problem, then disable it and remove the file after review because persistent logging affects performance and may contain private data.
|
||||
|
||||

|
||||
@@ -120,7 +131,7 @@ Browser security errors, particularly CORS failures, may reach the plug-in only
|
||||
|
||||
LiveSync stores file metadata, chunks, revision history, conflicts, deletions, and tombstones. Deleting or shortening a file therefore does not immediately remove every object which once represented it.
|
||||
|
||||
Garbage Collection can remove unreferenced chunks, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Tombstones and retained revisions are not free, so Garbage Collection does not guarantee a minimal database.
|
||||
Garbage Collection V3 can remove unreferenced chunks from a healthy CouchDB setup, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Current files and live conflict branches protect their required chunks; an ordinary superseded revision does not. Tombstones and retained metadata are not free, so Garbage Collection does not guarantee a minimal database. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
|
||||
|
||||
`Overwrite Server Data with This Device's Files` is a separate rebuild operation and is the more certain way to reconstruct a central remote from a chosen authoritative Vault. It is also destructive and may discard changes which exist only on another device. Review [Recovery and flag files](recovery.md#garbage-collection-is-not-rebuild) before choosing between them.
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.0-beta.0",
|
||||
"version": "1.0.2",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+43
-32
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.0-beta.0",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.0-beta.0",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -22,15 +22,17 @@
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0-rc.11",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@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",
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
@@ -56,7 +58,7 @@
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.5",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
@@ -4763,10 +4765,19 @@
|
||||
"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-rc.11",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.11.tgz",
|
||||
"integrity": "sha512-o811duZFajxDFI6cy7zwtXPg6lihx5e1MH6Xse1sx4HseYurbaDf9DtZJdAtfkjTxl5wOZRSnVMoMLn+I/xD+g==",
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0.tgz",
|
||||
"integrity": "sha512-rdzEubzLStioanE67pps2XSGZ2UGyMIyeEoKsdPztQLLW8wM05PWApOPbJujIydHcDr2hFanbDFe89Lk7Mn4XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -4827,21 +4838,21 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/obsidian-test-session": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.5.tgz",
|
||||
"integrity": "sha512-ZsI+Yx3z6IEFfh5Ey5mEUBNI0SUD6oDhP7D9LSZVEqPmdJz2JMiag7d8u/p1i6KpsoI2HbDvtw4RHpB+BQReAw==",
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.6.tgz",
|
||||
"integrity": "sha512-7xDTmTW3igBxwQGgbbpoRZU0JvOBpGgdRT4qF2WVrKz03OtKMJdkfJILY04/HZ+n4tvn9Ze8phJBr0dx3wewcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -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": {
|
||||
@@ -5975,15 +5986,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -11521,9 +11532,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/octagonal-wheels": {
|
||||
"version": "0.1.51",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.51.tgz",
|
||||
"integrity": "sha512-KTlfqKPjobHJg/t3A539srnFf+VHr1aXkHSmsNDDpiI5UFC7FamZ95dWpJfGE2EI/HULR5hveQDgkazmz8SAcg==",
|
||||
"version": "0.1.52",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.52.tgz",
|
||||
"integrity": "sha512-9WJN2UveNh90Op1S07cIso1WyNrQbO/unibDLfUGnpomIcU4g6F+p8reZHW0Ed8sGzKF5qGnQp991Y9MB1TwNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "^8.0.3"
|
||||
@@ -15913,11 +15924,11 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.0-beta.0-cli",
|
||||
"version": "1.0.2-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
@@ -15938,9 +15949,9 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.0-beta.0-webapp",
|
||||
"version": "1.0.2-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
@@ -15950,9 +15961,9 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.0-beta.0-webpeer",
|
||||
"version": "1.0.2-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
+24
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.0-beta.0",
|
||||
"version": "1.0.2",
|
||||
"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",
|
||||
@@ -37,6 +44,8 @@
|
||||
"test:contract:context:obsidian": "npm run build && npm run test:e2e:obsidian:smoke",
|
||||
"test:e2e:cli": "npm run test:e2e:ci --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:cli:adaptive-s3": "npm run test:e2e:adaptive-s3 --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:cli:adaptive-webdav": "npm run test:e2e:adaptive-webdav --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
|
||||
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
|
||||
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
|
||||
@@ -50,6 +59,8 @@
|
||||
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
|
||||
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
|
||||
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
|
||||
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
|
||||
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
|
||||
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
|
||||
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
|
||||
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
|
||||
@@ -58,11 +69,16 @@
|
||||
"test:e2e:obsidian:couchdb-manual-setup-workflow": "tsx test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts",
|
||||
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
|
||||
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
|
||||
"test:e2e:obsidian:adaptive-s3": "tsx test/e2e-obsidian/scripts/minio-upload.ts --adaptive",
|
||||
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
|
||||
"test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts",
|
||||
"pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer",
|
||||
"test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts",
|
||||
"test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p",
|
||||
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
|
||||
"test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts",
|
||||
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
|
||||
"test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts",
|
||||
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
||||
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||
"test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts",
|
||||
@@ -114,7 +130,7 @@
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.5",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
@@ -163,15 +179,17 @@
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0-rc.11",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@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",
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ import { AbstractModule } from "./modules/AbstractModule";
|
||||
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
|
||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
||||
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
|
||||
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
|
||||
import { ModuleReplicatorJournal } from "./modules/core/ModuleReplicatorJournal";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
@@ -139,7 +139,7 @@ export class LiveSyncBaseCore<
|
||||
public registerModules(extraModules: AbstractModule[] = []) {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleReplicatorMinIO(this));
|
||||
this._registerModule(new ModuleReplicatorJournal(this));
|
||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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">;
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
isJournalRemoteType,
|
||||
type EntryMilestoneInfo,
|
||||
type EntryDoc,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
@@ -25,6 +25,8 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
|
||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import type { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
||||
import { journalProtocolConfigurationForSettings } from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
||||
|
||||
function redactConnectionString(uri: string): string {
|
||||
@@ -59,8 +61,20 @@ async function verifyRemoteState(
|
||||
return false;
|
||||
}
|
||||
milestone = await dbRet.db.get(MILESTONE_DOCID);
|
||||
} else if (settings.remoteType === REMOTE_MINIO) {
|
||||
milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json");
|
||||
} else if (isJournalRemoteType(settings.remoteType)) {
|
||||
const journalReplicator = replicator as LiveSyncJournalReplicator;
|
||||
if (journalProtocolConfigurationForSettings(settings).journalFormat === "adaptive-v1") {
|
||||
try {
|
||||
await journalReplicator.client.ensureCheckpointCachesAreFresh();
|
||||
standardIo.writeStderr("[Verification] Adaptive Journal repository is available.\n");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
standardIo.writeStderr(`[Verification] Failed to verify Adaptive Journal repository: ${message}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
milestone = await (journalReplicator.client as JournalSyncCore).downloadJson("_00000000-milestone.json");
|
||||
}
|
||||
|
||||
if (milestone) {
|
||||
|
||||
@@ -2,7 +2,13 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
REMOTE_WEBDAV,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
@@ -717,6 +723,60 @@ describe("runCommand abnormal cases", () => {
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies an Adaptive Journal repository without reading the legacy milestone", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteType = REMOTE_MINIO;
|
||||
settings.journalFormat = "adaptive-v1";
|
||||
settings.packReadPolicy = "whole-pack";
|
||||
|
||||
const ensureCheckpointCachesAreFresh = vi.fn(async () => {});
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue({
|
||||
nodeid: "test-node-id",
|
||||
initializeDatabaseForReplication: vi.fn(async () => {}),
|
||||
client: {
|
||||
ensureCheckpointCachesAreFresh,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(ensureCheckpointCachesAreFresh).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
"[Verification] Adaptive Journal repository is available.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the Adaptive verification path for a WebDAV remote", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteType = REMOTE_WEBDAV;
|
||||
settings.webDAVactiveConnectionURI = "sls+webdav://dav.example/dav";
|
||||
settings.journalFormat = "adaptive-v1";
|
||||
settings.packReadPolicy = "whole-pack";
|
||||
|
||||
const ensureCheckpointCachesAreFresh = vi.fn(async () => {});
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue({
|
||||
nodeid: "test-node-id",
|
||||
initializeDatabaseForReplication: vi.fn(async () => {}),
|
||||
client: {
|
||||
ensureCheckpointCachesAreFresh,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(ensureCheckpointCachesAreFresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+27
-4
@@ -37,6 +37,14 @@ defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
|
||||
/** Injectable command boundary used by CLI integration probes. */
|
||||
export type CliCommandRunner = (options: CLIOptions, context: CLICommandContext) => Promise<boolean>;
|
||||
|
||||
const SETTINGS_MANAGEMENT_COMMANDS: ReadonlySet<CLICommand> = new Set([
|
||||
"setup",
|
||||
"remote-add",
|
||||
"remote-rm",
|
||||
"remote-set",
|
||||
"remote-activate",
|
||||
]);
|
||||
|
||||
function printHelp(standardIo: StandardIo): void {
|
||||
writeStdoutLine(
|
||||
standardIo,
|
||||
@@ -274,7 +282,10 @@ export async function main(
|
||||
) {
|
||||
const options = parseArgs(standardIo);
|
||||
if (options.interval && options.command !== "daemon") {
|
||||
writeStderrLine(standardIo, `Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`
|
||||
);
|
||||
}
|
||||
const avoidStdoutNoise =
|
||||
options.command === "cat" ||
|
||||
@@ -399,12 +410,15 @@ export async function main(
|
||||
if (!options.verbose) return;
|
||||
}
|
||||
writeStderrLine(standardIo, prefix, message);
|
||||
});
|
||||
}, true);
|
||||
// Prevent replication result from being processed automatically in non-daemon commands.
|
||||
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||
if (options.command !== "daemon") {
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
writeStderrLine(standardIo, `[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Info] Replication result received, but not processed automatically in CLI mode.`
|
||||
);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
}
|
||||
@@ -506,7 +520,7 @@ export async function main(
|
||||
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
||||
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
||||
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!);
|
||||
let settingsBackup: string | null = await fs.readFile(settingsPath, "utf-8").catch(() => null);
|
||||
|
||||
// Restore settings file on any exit to undo lifecycle mutations.
|
||||
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
||||
@@ -576,6 +590,15 @@ export async function main(
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
});
|
||||
if (result && SETTINGS_MANAGEMENT_COMMANDS.has(options.command)) {
|
||||
// Settings management is intentional, unlike the temporary changes made by suspendAllSync().
|
||||
// setup replaces the complete configuration, while remote profile commands must retain the
|
||||
// synchronisation mode which was active before the command lifecycle suspended it.
|
||||
if (options.command !== "setup") {
|
||||
await core.services.setting.applyPartial(originalSyncSettings, true);
|
||||
}
|
||||
settingsBackup = await fs.readFile(settingsPath, "utf-8");
|
||||
}
|
||||
if (!result) {
|
||||
writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.0-cli",
|
||||
"version": "1.0.2-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -22,6 +22,10 @@
|
||||
"pretest:e2e:ci": "npm run build",
|
||||
"test:e2e:ci": "deno task --cwd testdeno test:ci",
|
||||
"test:e2e:p2p": "deno task --cwd testdeno test:p2p:compose",
|
||||
"pretest:e2e:adaptive-s3": "npm run build",
|
||||
"test:e2e:adaptive-s3": "deno task --cwd testdeno test:adaptive-journal-s3",
|
||||
"pretest:e2e:adaptive-webdav": "npm run build",
|
||||
"test:e2e:adaptive-webdav": "deno task --cwd testdeno test:adaptive-journal-webdav",
|
||||
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
|
||||
"test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh",
|
||||
"pretest:e2e:all": "npm run build",
|
||||
@@ -37,7 +41,7 @@
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
|
||||
@@ -25,6 +25,10 @@ type SerializableContainer =
|
||||
| {
|
||||
[NODE_KV_TYPED_KEY]: "ArrayBuffer";
|
||||
[NODE_KV_VALUES_KEY]: number[];
|
||||
}
|
||||
| {
|
||||
[NODE_KV_TYPED_KEY]: "BigInt";
|
||||
[NODE_KV_VALUES_KEY]: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -32,6 +36,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function serializeForNodeKV(value: unknown): unknown {
|
||||
if (typeof value === "bigint") {
|
||||
return {
|
||||
[NODE_KV_TYPED_KEY]: "BigInt",
|
||||
[NODE_KV_VALUES_KEY]: value.toString(10),
|
||||
} satisfies SerializableContainer;
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
return {
|
||||
[NODE_KV_TYPED_KEY]: "Set",
|
||||
@@ -78,6 +88,9 @@ function deserializeFromNodeKV(value: unknown): unknown {
|
||||
if (taggedType === "ArrayBuffer" && Array.isArray(taggedValues)) {
|
||||
return Uint8Array.from(taggedValues).buffer;
|
||||
}
|
||||
if (taggedType === "BigInt" && typeof taggedValues === "string" && /^-?(?:0|[1-9]\d*)$/u.test(taggedValues)) {
|
||||
return BigInt(taggedValues);
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deserializeFromNodeKV(v)]));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
|
||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||
|
||||
function createInitialisableDependencies(): {
|
||||
dependencies: NodeKeyValueDBDependencies;
|
||||
initialise: () => Promise<boolean>;
|
||||
} {
|
||||
let initialise: (() => Promise<boolean>) | undefined;
|
||||
const dependencies = {
|
||||
appLifecycle: {
|
||||
onSettingLoaded: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
initialise = handler;
|
||||
}),
|
||||
},
|
||||
},
|
||||
databaseEvents: {
|
||||
onResetDatabase: { addHandler: vi.fn() },
|
||||
onDatabaseInitialisation: { addHandler: vi.fn() },
|
||||
onUnloadDatabase: { addHandler: vi.fn() },
|
||||
onCloseDatabase: { addHandler: vi.fn() },
|
||||
},
|
||||
vault: {},
|
||||
} as unknown as NodeKeyValueDBDependencies;
|
||||
return {
|
||||
dependencies,
|
||||
initialise: async () => {
|
||||
if (!initialise) throw new Error("Initialisation handler was not registered");
|
||||
return await initialise();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("NodeKeyValueDBService.openSimpleStore", () => {
|
||||
it("creates a namespaced store handle before the backing database is initialised", () => {
|
||||
const dependencies = {
|
||||
@@ -44,4 +75,29 @@ describe("NodeKeyValueDBService.openSimpleStore", () => {
|
||||
|
||||
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
|
||||
});
|
||||
|
||||
it("preserves bigint values used by Adaptive Journal state", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-node-kv-bigint-"));
|
||||
const filePath = path.join(tempDir, "keyvalue-db.json");
|
||||
const writerState = {
|
||||
lastCommittedSequence: 9007199254740993n,
|
||||
pendingCommit: { sequence: 18446744073709551615n },
|
||||
writerEpoch: "test-writer-epoch",
|
||||
};
|
||||
|
||||
try {
|
||||
const firstLifecycle = createInitialisableDependencies();
|
||||
const first = new NodeKeyValueDBService(createServiceContext(), firstLifecycle.dependencies, filePath);
|
||||
await expect(firstLifecycle.initialise()).resolves.toBe(true);
|
||||
await first.openSimpleStore("adaptive").set("writer-state", writerState);
|
||||
|
||||
const secondLifecycle = createInitialisableDependencies();
|
||||
const second = new NodeKeyValueDBService(createServiceContext(), secondLifecycle.dependencies, filePath);
|
||||
await expect(secondLifecycle.initialise()).resolves.toBe(true);
|
||||
|
||||
await expect(second.openSimpleStore("adaptive").get("writer-state")).resolves.toEqual(writerState);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"test:e2e-matrix:couchdb-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc0' test-e2e-two-vaults-matrix.ts",
|
||||
"test:e2e-matrix:couchdb-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc1' test-e2e-two-vaults-matrix.ts",
|
||||
"test:e2e-matrix:minio-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc0' test-e2e-two-vaults-matrix.ts",
|
||||
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts"
|
||||
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts",
|
||||
"test:adaptive-journal-s3": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-s3.ts",
|
||||
"test:adaptive-journal-webdav": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-webdav.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@std/assert": "jsr:@std/assert@^1.0.13",
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface CliResult {
|
||||
export const TEE_ENABLED = Deno.env.get("LIVESYNC_TEST_TEE") === "1";
|
||||
const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1";
|
||||
const DEBUG_ENABLED = Deno.env.get("LIVESYNC_CLI_DEBUG") === "1";
|
||||
const SETUP_URI_PREFIX = "obsidian://setuplivesync?settings=";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -40,7 +41,13 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
||||
}
|
||||
|
||||
export function formatTeeCommand(args: string[]): string {
|
||||
return ["node", CLI_DIST, ...args].map((part) => JSON.stringify(part)).join(" ");
|
||||
const redactArgument = (argument: string): string => {
|
||||
if (argument.startsWith(SETUP_URI_PREFIX)) {
|
||||
return `${SETUP_URI_PREFIX}<redacted>`;
|
||||
}
|
||||
return argument.replace(/^(sls\+[^:]+:\/\/)[^/?#@]*@/u, "$1<redacted>@");
|
||||
};
|
||||
return ["node", CLI_DIST, ...args.map(redactArgument)].map((part) => JSON.stringify(part)).join(" ");
|
||||
}
|
||||
|
||||
export function createLineTeeWriter(
|
||||
|
||||
@@ -330,6 +330,39 @@ const MINIO_CONTAINER = "minio-test";
|
||||
const MINIO_IMAGE = "minio/minio";
|
||||
const MINIO_MC_IMAGE = "minio/mc";
|
||||
|
||||
const WEBDAV_CONTAINER = "webdav-test";
|
||||
const WEBDAV_IMAGE = "httpd:2.4.68";
|
||||
const WEBDAV_HTTPD_CONFIG = `ServerRoot "/usr/local/apache2"
|
||||
Listen 80
|
||||
|
||||
LoadModule mpm_event_module modules/mod_mpm_event.so
|
||||
LoadModule authn_core_module modules/mod_authn_core.so
|
||||
LoadModule authz_core_module modules/mod_authz_core.so
|
||||
LoadModule dav_module modules/mod_dav.so
|
||||
LoadModule dav_fs_module modules/mod_dav_fs.so
|
||||
LoadModule unixd_module modules/mod_unixd.so
|
||||
|
||||
User www-data
|
||||
Group www-data
|
||||
ServerName localhost
|
||||
DocumentRoot "/usr/local/apache2/htdocs"
|
||||
PidFile "/tmp/httpd.pid"
|
||||
ErrorLog "/proc/self/fd/2"
|
||||
LogLevel warn
|
||||
|
||||
DavLockDB "/usr/local/apache2/var/DavLock"
|
||||
DavLockDiscovery Off
|
||||
|
||||
<Directory "/usr/local/apache2/htdocs">
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
<Directory "/usr/local/apache2/htdocs/dav">
|
||||
Dav On
|
||||
</Directory>
|
||||
`;
|
||||
|
||||
export async function stopCouchdb(): Promise<void> {
|
||||
await stopAndRemoveContainer(COUCHDB_CONTAINER);
|
||||
untrackContainer(COUCHDB_CONTAINER);
|
||||
@@ -466,6 +499,70 @@ export async function stopMinio(): Promise<void> {
|
||||
untrackContainer(MINIO_CONTAINER);
|
||||
}
|
||||
|
||||
export async function listMinioObjectKeys(
|
||||
minioEndpoint: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
bucket: string
|
||||
): Promise<string[]> {
|
||||
const cmd =
|
||||
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
|
||||
`mc ls --recursive --json myminio/${shQuote(bucket)}`;
|
||||
const result = await docker(
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
MINIO_MC_IMAGE,
|
||||
"-c",
|
||||
cmd
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Could not list MinIO objects: ${result.stderr.trim()}`);
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line) as { key?: unknown })
|
||||
.map(({ key }) => {
|
||||
if (typeof key !== "string") {
|
||||
throw new Error("MinIO returned an object without a string key");
|
||||
}
|
||||
return key;
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
export async function readMinioObjectText(
|
||||
minioEndpoint: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
bucket: string,
|
||||
key: string
|
||||
): Promise<string> {
|
||||
const cmd =
|
||||
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
|
||||
`mc cat myminio/${shQuote(bucket)}/${shQuote(key)}`;
|
||||
const result = await docker(
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
MINIO_MC_IMAGE,
|
||||
"-c",
|
||||
cmd
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Could not read MinIO object ${key}: ${result.stderr.trim()}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function initMinioBucket(
|
||||
minioEndpoint: string,
|
||||
accessKey: string,
|
||||
@@ -561,6 +658,113 @@ export async function startMinio(
|
||||
await waitForMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebDAV
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function stopWebDAV(): Promise<void> {
|
||||
await stopAndRemoveContainer(WEBDAV_CONTAINER);
|
||||
untrackContainer(WEBDAV_CONTAINER);
|
||||
}
|
||||
|
||||
async function waitForWebDAV(endpoint: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "PROPFIND",
|
||||
headers: { Depth: "0" },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (response.status === 207) return;
|
||||
} catch {
|
||||
// The container is still starting.
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`WebDAV collection did not become ready: ${endpoint}`);
|
||||
}
|
||||
|
||||
export async function startWebDAV(endpoint: string): Promise<void> {
|
||||
const url = new URL(endpoint);
|
||||
if (url.protocol !== "http:" || (url.hostname !== "127.0.0.1" && url.hostname !== "localhost")) {
|
||||
throw new Error(`Managed WebDAV requires a local HTTP endpoint, received: ${endpoint}`);
|
||||
}
|
||||
if (url.pathname.replace(/\/+$/u, "") !== "/dav") {
|
||||
throw new Error(`Managed WebDAV requires the /dav collection, received: ${endpoint}`);
|
||||
}
|
||||
const hostPort = url.port || "80";
|
||||
const encodedConfig = btoa(WEBDAV_HTTPD_CONFIG);
|
||||
const startCommand = `printf '%s' '${encodedConfig}' | base64 -d > /tmp/httpd.conf && exec httpd -DFOREGROUND -f /tmp/httpd.conf`;
|
||||
|
||||
console.log("[INFO] stopping leftover WebDAV container if present");
|
||||
await stopWebDAV().catch(() => {});
|
||||
|
||||
console.log("[INFO] starting Apache WebDAV test container");
|
||||
await dockerOrFail(
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
WEBDAV_CONTAINER,
|
||||
"-p",
|
||||
`${hostPort}:80`,
|
||||
"--tmpfs",
|
||||
"/usr/local/apache2/htdocs/dav:mode=0777",
|
||||
"--tmpfs",
|
||||
"/usr/local/apache2/var:mode=0777",
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
WEBDAV_IMAGE,
|
||||
"-c",
|
||||
startCommand
|
||||
);
|
||||
trackContainer(WEBDAV_CONTAINER);
|
||||
await waitForWebDAV(endpoint);
|
||||
}
|
||||
|
||||
function directWebDAVObjectUrl(collectionEndpoint: string, key: string): string {
|
||||
return `${collectionEndpoint.replace(/\/+$/u, "")}/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
function webDAVRequestHeaders(): HeadersInit {
|
||||
const username = Deno.env.get("WEBDAV_USERNAME") ?? "";
|
||||
const password = Deno.env.get("WEBDAV_PASSWORD") ?? "";
|
||||
if (!username && !password) return {};
|
||||
return { Authorization: `Basic ${btoa(`${username}:${password}`)}` };
|
||||
}
|
||||
|
||||
export async function listWebDAVObjectKeys(collectionEndpoint: string): Promise<string[]> {
|
||||
const collectionUrl = new URL(`${collectionEndpoint.replace(/\/+$/u, "")}/`);
|
||||
const response = await fetch(collectionUrl, {
|
||||
method: "PROPFIND",
|
||||
headers: { ...webDAVRequestHeaders(), Depth: "1" },
|
||||
});
|
||||
if (response.status !== 207) {
|
||||
throw new Error(`Could not list WebDAV objects: HTTP ${response.status}`);
|
||||
}
|
||||
const xml = await response.text();
|
||||
const hrefs = [
|
||||
...xml.matchAll(/<(?:[A-Za-z_][\w.-]*:)?href\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?href>/giu),
|
||||
].map((match) => match[1].trim());
|
||||
const basePath = decodeURIComponent(collectionUrl.pathname);
|
||||
const keys = new Set<string>();
|
||||
for (const href of hrefs) {
|
||||
const path = decodeURIComponent(new URL(href, collectionUrl).pathname);
|
||||
if (!path.startsWith(basePath)) continue;
|
||||
const key = path.slice(basePath.length).replace(/\/$/u, "");
|
||||
if (key && !key.includes("/")) keys.add(key);
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
export async function readWebDAVObjectText(collectionEndpoint: string, key: string): Promise<string> {
|
||||
const response = await fetch(directWebDAVObjectUrl(collectionEndpoint, key), { headers: webDAVRequestHeaders() });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not read WebDAV object ${key}: HTTP ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P2P relay (strfry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,7 +13,12 @@ export async function initSettingsFile(settingsFile: string): Promise<void> {
|
||||
* Generate a full setup URI from a settings file via the Commonlib package API.
|
||||
* Mirrors the bash flow in test-setup-put-cat-linux.sh.
|
||||
*/
|
||||
export async function generateSetupUriFromSettings(settingsFile: string, setupPassphrase: string): Promise<string> {
|
||||
export async function generateSetupUriFromSettings(
|
||||
settingsFile: string,
|
||||
setupPassphrase: string,
|
||||
preserveSettings = false,
|
||||
runtimeVaultPassphrase?: string
|
||||
): Promise<string> {
|
||||
const script = [
|
||||
"import { fs } from '@vrtmrz/livesync-commonlib/node';",
|
||||
"import { encodeSettingsToSetupURI } from '@vrtmrz/livesync-commonlib/compat/API/processSetting';",
|
||||
@@ -21,13 +26,18 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
|
||||
" const settingsPath = process.env.SETTINGS_FILE;",
|
||||
" const passphrase = process.env.SETUP_PASSPHRASE;",
|
||||
" const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));",
|
||||
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
|
||||
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
|
||||
" settings.couchDB_USER = 'dummy';",
|
||||
" settings.couchDB_PASSWORD = 'dummy';",
|
||||
" settings.liveSync = false;",
|
||||
" settings.syncOnStart = false;",
|
||||
" settings.syncOnSave = false;",
|
||||
" if (process.env.RUNTIME_VAULT_PASSPHRASE !== undefined) {",
|
||||
" settings.passphrase = process.env.RUNTIME_VAULT_PASSPHRASE;",
|
||||
" }",
|
||||
" if (process.env.PRESERVE_SETTINGS !== 'true') {",
|
||||
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
|
||||
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
|
||||
" settings.couchDB_USER = 'dummy';",
|
||||
" settings.couchDB_PASSWORD = 'dummy';",
|
||||
" settings.liveSync = false;",
|
||||
" settings.syncOnStart = false;",
|
||||
" settings.syncOnSave = false;",
|
||||
" }",
|
||||
" const uri = await encodeSettingsToSetupURI(settings, passphrase);",
|
||||
" process.stdout.write(uri.trim());",
|
||||
"})();",
|
||||
@@ -41,13 +51,18 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
|
||||
await Deno.writeTextFile(scriptPath, script);
|
||||
|
||||
try {
|
||||
const env: Record<string, string> = {
|
||||
SETTINGS_FILE: settingsFile,
|
||||
SETUP_PASSPHRASE: setupPassphrase,
|
||||
PRESERVE_SETTINGS: preserveSettings ? "true" : "false",
|
||||
};
|
||||
if (runtimeVaultPassphrase !== undefined) {
|
||||
env.RUNTIME_VAULT_PASSPHRASE = runtimeVaultPassphrase;
|
||||
}
|
||||
const cmd = new Deno.Command("npx", {
|
||||
args: ["tsx", scriptPath],
|
||||
cwd: CLI_DIR,
|
||||
env: {
|
||||
SETTINGS_FILE: settingsFile,
|
||||
SETUP_PASSPHRASE: setupPassphrase,
|
||||
},
|
||||
env,
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
@@ -112,7 +127,7 @@ export async function applyCouchdbSettings(
|
||||
export async function applyRemoteSyncSettings(
|
||||
settingsFile: string,
|
||||
options: {
|
||||
remoteType: "COUCHDB" | "MINIO";
|
||||
remoteType: "COUCHDB" | "MINIO" | "WEBDAV";
|
||||
couchdbUri?: string;
|
||||
couchdbUser?: string;
|
||||
couchdbPassword?: string;
|
||||
@@ -121,10 +136,14 @@ export async function applyRemoteSyncSettings(
|
||||
minioEndpoint?: string;
|
||||
minioAccessKey?: string;
|
||||
minioSecretKey?: string;
|
||||
webDAVConnectionURI?: string;
|
||||
encrypt?: boolean;
|
||||
passphrase?: string;
|
||||
enableCompression?: boolean;
|
||||
usePathObfuscation?: boolean;
|
||||
journalFormat?: "adaptive-v1" | "opaque-v1";
|
||||
expectedRepositoryId?: string;
|
||||
packReadPolicy?: "range" | "whole-pack";
|
||||
}
|
||||
): Promise<void> {
|
||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
@@ -135,7 +154,7 @@ export async function applyRemoteSyncSettings(
|
||||
data.couchDB_USER = options.couchdbUser;
|
||||
data.couchDB_PASSWORD = options.couchdbPassword;
|
||||
data.couchDB_DBNAME = options.couchdbDbname;
|
||||
} else {
|
||||
} else if (options.remoteType === "MINIO") {
|
||||
data.remoteType = "MINIO";
|
||||
data.bucket = options.minioBucket;
|
||||
data.endpoint = options.minioEndpoint;
|
||||
@@ -143,6 +162,18 @@ export async function applyRemoteSyncSettings(
|
||||
data.secretKey = options.minioSecretKey;
|
||||
data.region = "auto";
|
||||
data.forcePathStyle = true;
|
||||
} else {
|
||||
data.remoteType = "WEBDAV";
|
||||
data.webDAVactiveConnectionURI = options.webDAVConnectionURI;
|
||||
}
|
||||
if (options.journalFormat !== undefined) {
|
||||
data.journalFormat = options.journalFormat;
|
||||
}
|
||||
if (options.expectedRepositoryId !== undefined) {
|
||||
data.expectedRepositoryId = options.expectedRepositoryId;
|
||||
}
|
||||
if (options.packReadPolicy !== undefined) {
|
||||
data.packReadPolicy = options.packReadPolicy;
|
||||
}
|
||||
|
||||
data.liveSync = true;
|
||||
|
||||
@@ -11,6 +11,8 @@ const TASKS = [
|
||||
"test:e2e-matrix:couchdb-enc1",
|
||||
"test:e2e-matrix:minio-enc0",
|
||||
"test:e2e-matrix:minio-enc1",
|
||||
"test:adaptive-journal-s3",
|
||||
"test:adaptive-journal-webdav",
|
||||
] as const;
|
||||
|
||||
for (const [index, task] of TASKS.entries()) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { assertFilesEqual, runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { listMinioObjectKeys, readMinioObjectText, startMinio, stopMinio } from "./helpers/docker.ts";
|
||||
|
||||
const EXTERNAL_PACK_TEST_BYTES = 9 * 1024 * 1024;
|
||||
|
||||
function deterministicBytes(length: number, seed: number): Uint8Array {
|
||||
const bytes = new Uint8Array(length);
|
||||
let state = seed;
|
||||
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
bytes[index] = state & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function requireEnv(...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = Deno.env.get(key)?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
throw new Error(`Required environment variable is missing: ${keys.join(" or ")}`);
|
||||
}
|
||||
|
||||
Deno.test("e2e: two CLI vaults synchronise through Adaptive Journal S3", async () => {
|
||||
const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
const endpoint = requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/u, "");
|
||||
const accessKey = requireEnv("MINIO_ACCESS_KEY", "accessKey");
|
||||
const secretKey = requireEnv("MINIO_SECRET_KEY", "secretKey");
|
||||
const bucket = `${requireEnv("MINIO_BUCKET_NAME", "bucketName")}-${suffix}`;
|
||||
const passphrase = "adaptive-journal-cli-e2e-passphrase";
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-adaptive-journal-s3");
|
||||
const vaultA = workDir.join("vault-a");
|
||||
const vaultB = workDir.join("vault-b");
|
||||
const settingsA = workDir.join("settings-a.json");
|
||||
const settingsB = workDir.join("settings-b.json");
|
||||
const binarySourceA = workDir.join("source-a.bin");
|
||||
const binarySourceB = workDir.join("source-b.bin");
|
||||
const binaryDestinationA = workDir.join("destination-a.bin");
|
||||
const binaryDestinationB = workDir.join("destination-b.bin");
|
||||
await Deno.mkdir(vaultA, { recursive: true });
|
||||
await Deno.mkdir(vaultB, { recursive: true });
|
||||
|
||||
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
||||
await startMinio(endpoint, accessKey, secretKey, bucket);
|
||||
|
||||
try {
|
||||
await initSettingsFile(settingsA);
|
||||
await initSettingsFile(settingsB);
|
||||
await applyRemoteSyncSettings(settingsA, {
|
||||
remoteType: "MINIO",
|
||||
minioBucket: bucket,
|
||||
minioEndpoint: endpoint,
|
||||
minioAccessKey: accessKey,
|
||||
minioSecretKey: secretKey,
|
||||
encrypt: true,
|
||||
passphrase,
|
||||
enableCompression: false,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
});
|
||||
await applyRemoteSyncSettings(settingsB, {
|
||||
remoteType: "MINIO",
|
||||
minioBucket: bucket,
|
||||
minioEndpoint: endpoint,
|
||||
minioAccessKey: accessKey,
|
||||
minioSecretKey: secretKey,
|
||||
encrypt: true,
|
||||
passphrase,
|
||||
enableCompression: false,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
});
|
||||
|
||||
const textPath = "adaptive/text.md";
|
||||
const binaryPath = "adaptive/data.bin";
|
||||
await runCliWithInputOrFail(`created-by-a-${suffix}\n`, vaultA, "--settings", settingsA, "put", textPath);
|
||||
await Deno.writeFile(binarySourceA, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x1a2b3c4d));
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "push", binarySourceA, binaryPath);
|
||||
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
assertEquals(
|
||||
sanitiseCatStdout(await runCliOrFail(vaultB, "--settings", settingsB, "cat", textPath)).trimEnd(),
|
||||
`created-by-a-${suffix}`
|
||||
);
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "pull", binaryPath, binaryDestinationB);
|
||||
await assertFilesEqual(binarySourceA, binaryDestinationB, "Adaptive Journal Range transfer differs");
|
||||
|
||||
await runCliWithInputOrFail(`updated-by-b-${suffix}\n`, vaultB, "--settings", settingsB, "put", textPath);
|
||||
await Deno.writeFile(binarySourceB, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x5e6f7788));
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "push", binarySourceB, binaryPath);
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
assertEquals(
|
||||
sanitiseCatStdout(await runCliOrFail(vaultA, "--settings", settingsA, "cat", textPath)).trimEnd(),
|
||||
`updated-by-b-${suffix}`
|
||||
);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "pull", binaryPath, binaryDestinationA);
|
||||
await assertFilesEqual(binarySourceB, binaryDestinationA, "Adaptive Journal whole-Pack transfer differs");
|
||||
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "rm", binaryPath);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
const deleted = await runCli(vaultB, "--settings", settingsB, "cat", binaryPath);
|
||||
assert(deleted.code !== 0, `Deleted binary remained readable:\n${deleted.combined}`);
|
||||
|
||||
const objectKeys = await listMinioObjectKeys(endpoint, accessKey, secretKey, bucket);
|
||||
assert(objectKeys.includes("a1~manifest.json"), `Adaptive manifest is missing:\n${objectKeys.join("\n")}`);
|
||||
for (const prefix of ["a1~writer~", "a1~pack~", "a1~commit~"]) {
|
||||
assert(
|
||||
objectKeys.some((key) => key.startsWith(prefix)),
|
||||
`Adaptive object with prefix ${prefix} is missing:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
}
|
||||
const packKeys = objectKeys.filter((key) => key.startsWith("a1~pack~"));
|
||||
assert(packKeys.length >= 2, `Expected external Packs from both CLI writers:\n${objectKeys.join("\n")}`);
|
||||
for (const legacyPrefix of ["a1~index~", "a1~delta~", "a1~metadata~"]) {
|
||||
assert(
|
||||
!objectKeys.some((key) => key.startsWith(legacyPrefix)),
|
||||
`Legacy Adaptive object with prefix ${legacyPrefix} was written:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
await readMinioObjectText(endpoint, accessKey, secretKey, bucket, "a1~manifest.json")
|
||||
) as { objectLayout?: unknown };
|
||||
assertEquals(manifest.objectLayout, "commit-bundle-v1");
|
||||
assert(
|
||||
!objectKeys.some((key) => key.startsWith("a1~probe~")),
|
||||
`Adaptive capability probe objects were not removed:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
assert(
|
||||
!objectKeys.includes("_00000000-milestone.json"),
|
||||
`Legacy Journal milestone was written into the Adaptive repository:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
} finally {
|
||||
if (!keepDocker) {
|
||||
await stopMinio().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { assertFilesEqual, runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { applyRemoteSyncSettings, generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { listWebDAVObjectKeys, readWebDAVObjectText, startWebDAV, stopWebDAV } from "./helpers/docker.ts";
|
||||
|
||||
const EXTERNAL_PACK_TEST_BYTES = 9 * 1024 * 1024;
|
||||
|
||||
function deterministicBytes(length: number, seed: number): Uint8Array {
|
||||
const bytes = new Uint8Array(length);
|
||||
let state = seed;
|
||||
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
bytes[index] = state & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function webDAVConnectionURI(endpoint: string, prefix: string): string {
|
||||
const endpointUrl = new URL(endpoint);
|
||||
const proxyUrl = new URL(`https://${endpointUrl.host}${endpointUrl.pathname}`);
|
||||
const username = Deno.env.get("WEBDAV_USERNAME") ?? "";
|
||||
const password = Deno.env.get("WEBDAV_PASSWORD") ?? "";
|
||||
proxyUrl.username = username;
|
||||
proxyUrl.password = password;
|
||||
if (endpointUrl.protocol === "http:") proxyUrl.searchParams.set("insecure", "true");
|
||||
proxyUrl.searchParams.set("prefix", prefix);
|
||||
return `sls+webdav:${proxyUrl.toString().slice("https:".length)}`;
|
||||
}
|
||||
|
||||
function setPackReadPolicy(connectionURI: string, policy: "range" | "whole-pack"): string {
|
||||
const url = new URL(connectionURI);
|
||||
url.searchParams.set("packReadPolicy", policy);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function selectAdaptiveJournal(connectionURI: string): string {
|
||||
const url = new URL(connectionURI);
|
||||
url.searchParams.set("journalFormat", "adaptive-v1");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function remoteIdFromListing(listing: string): string {
|
||||
const line = listing
|
||||
.split(/\r?\n/u)
|
||||
.find((candidate) => candidate.includes("\tWebDAV Remote\t") || candidate.includes("\tWebDAV "));
|
||||
const id = line?.split("\t", 1)[0];
|
||||
if (!id) throw new Error(`WebDAV remote profile was not listed:\n${listing}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
Deno.test("e2e: two CLI vaults synchronise through Adaptive Journal WebDAV", async () => {
|
||||
const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
const endpoint = (Deno.env.get("WEBDAV_ENDPOINT") ?? "http://127.0.0.1:8088/dav").replace(/\/+$/u, "");
|
||||
const prefix = `adaptive-cli-${suffix}/`;
|
||||
const collectionEndpoint = `${endpoint}/${prefix}`;
|
||||
const connectionURI = webDAVConnectionURI(endpoint, prefix);
|
||||
const adaptiveConnectionURI = selectAdaptiveJournal(connectionURI);
|
||||
const vaultPassphrase = "adaptive-journal-webdav-cli-e2ee";
|
||||
const setupPassphrase = "adaptive-journal-webdav-cli-setup";
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-adaptive-journal-webdav");
|
||||
const vaultA = workDir.join("vault-a");
|
||||
const vaultB = workDir.join("vault-b");
|
||||
const settingsA = workDir.join("settings-a.json");
|
||||
const settingsB = workDir.join("settings-b.json");
|
||||
const binarySourceA = workDir.join("source-a.bin");
|
||||
const binarySourceB = workDir.join("source-b.bin");
|
||||
const binaryDestinationA = workDir.join("destination-a.bin");
|
||||
const binaryDestinationB = workDir.join("destination-b.bin");
|
||||
await Deno.mkdir(vaultA, { recursive: true });
|
||||
await Deno.mkdir(vaultB, { recursive: true });
|
||||
|
||||
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
|
||||
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
||||
if (shouldStartDocker) await startWebDAV(endpoint);
|
||||
|
||||
try {
|
||||
await initSettingsFile(settingsA);
|
||||
await applyRemoteSyncSettings(settingsA, {
|
||||
remoteType: "WEBDAV",
|
||||
webDAVConnectionURI: connectionURI,
|
||||
encrypt: true,
|
||||
passphrase: vaultPassphrase,
|
||||
enableCompression: false,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
});
|
||||
const addedRemote = await runCliOrFail(
|
||||
vaultA,
|
||||
"--settings",
|
||||
settingsA,
|
||||
"remote-add",
|
||||
"WebDAV E2E",
|
||||
adaptiveConnectionURI
|
||||
);
|
||||
const remoteId = addedRemote.trim().split("\t", 1)[0];
|
||||
assert(remoteId, `remote-add did not return a profile ID:\n${addedRemote}`);
|
||||
const settingsAfterRemoteAdd = JSON.parse(await Deno.readTextFile(settingsA)) as {
|
||||
remoteConfigurations?: Record<string, unknown>;
|
||||
liveSync?: boolean;
|
||||
};
|
||||
assert(
|
||||
settingsAfterRemoteAdd.remoteConfigurations?.[remoteId],
|
||||
`remote-add did not persist profile ${remoteId}: ${Object.keys(settingsAfterRemoteAdd.remoteConfigurations ?? {}).join(", ")}`
|
||||
);
|
||||
assertEquals(settingsAfterRemoteAdd.liveSync, true, "remote-add changed the persisted synchronisation mode");
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "remote-activate", remoteId);
|
||||
|
||||
const textPath = "adaptive/text.md";
|
||||
const binaryPath = "adaptive/data.bin";
|
||||
await runCliWithInputOrFail(`created-by-a-${suffix}\n`, vaultA, "--settings", settingsA, "put", textPath);
|
||||
await Deno.writeFile(binarySourceA, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x1a2b3c4d));
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "push", binarySourceA, binaryPath);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
|
||||
const remoteListing = await runCliOrFail(vaultA, "--settings", settingsA, "remote-ls");
|
||||
assertEquals(remoteIdFromListing(remoteListing), remoteId);
|
||||
assert(
|
||||
remoteListing
|
||||
.split(/\r?\n/u)
|
||||
.some((line) => line.startsWith(`${remoteId}\t`) && line.includes("\tactive\t")),
|
||||
`Activated WebDAV profile was not listed as active:\n${remoteListing}`
|
||||
);
|
||||
const exportedConnection = (
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "remote-export", remoteId)
|
||||
).trim();
|
||||
assert(exportedConnection.startsWith("sls+webdav://"));
|
||||
assert(exportedConnection.includes("journalFormat=adaptive-v1"));
|
||||
assert(!exportedConnection.includes("packReadPolicy="));
|
||||
|
||||
const setupURI = await generateSetupUriFromSettings(settingsA, setupPassphrase, true, vaultPassphrase);
|
||||
await initSettingsFile(settingsB);
|
||||
await runCliWithInputOrFail(`${setupPassphrase}\n`, vaultB, "--settings", settingsB, "setup", setupURI);
|
||||
const settingsAfterSetup = JSON.parse(await Deno.readTextFile(settingsB)) as {
|
||||
encryptedPassphrase?: string;
|
||||
};
|
||||
assert(
|
||||
typeof settingsAfterSetup.encryptedPassphrase === "string" &&
|
||||
settingsAfterSetup.encryptedPassphrase.length > 0,
|
||||
"setup did not persist the encrypted Vault passphrase"
|
||||
);
|
||||
await runCliOrFail(
|
||||
vaultB,
|
||||
"--settings",
|
||||
settingsB,
|
||||
"remote-set",
|
||||
remoteId,
|
||||
setPackReadPolicy(exportedConnection, "range")
|
||||
);
|
||||
const rangeConnection = (await runCliOrFail(vaultB, "--settings", settingsB, "remote-export", remoteId)).trim();
|
||||
assert(rangeConnection.includes("packReadPolicy=range"));
|
||||
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
assertEquals(
|
||||
sanitiseCatStdout(await runCliOrFail(vaultB, "--settings", settingsB, "cat", textPath)).trimEnd(),
|
||||
`created-by-a-${suffix}`
|
||||
);
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "pull", binaryPath, binaryDestinationB);
|
||||
await assertFilesEqual(binarySourceA, binaryDestinationB, "Adaptive Journal Range transfer differs");
|
||||
|
||||
await runCliWithInputOrFail(`updated-by-b-${suffix}\n`, vaultB, "--settings", settingsB, "put", textPath);
|
||||
await Deno.writeFile(binarySourceB, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x5e6f7788));
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "push", binarySourceB, binaryPath);
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
assertEquals(
|
||||
sanitiseCatStdout(await runCliOrFail(vaultA, "--settings", settingsA, "cat", textPath)).trimEnd(),
|
||||
`updated-by-b-${suffix}`
|
||||
);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "pull", binaryPath, binaryDestinationA);
|
||||
await assertFilesEqual(binarySourceB, binaryDestinationA, "Adaptive Journal whole-Pack transfer differs");
|
||||
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "rm", binaryPath);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
const deleted = await runCli(vaultB, "--settings", settingsB, "cat", binaryPath);
|
||||
assert(deleted.code !== 0, `Deleted binary remained readable:\n${deleted.combined}`);
|
||||
|
||||
const objectKeys = await listWebDAVObjectKeys(collectionEndpoint);
|
||||
assert(objectKeys.includes("a1~manifest.json"), `Adaptive manifest is missing:\n${objectKeys.join("\n")}`);
|
||||
for (const objectPrefix of ["a1~writer~", "a1~pack~", "a1~commit~"]) {
|
||||
assert(
|
||||
objectKeys.some((key) => key.startsWith(objectPrefix)),
|
||||
`Adaptive object with prefix ${objectPrefix} is missing:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
}
|
||||
const packKeys = objectKeys.filter((key) => key.startsWith("a1~pack~"));
|
||||
assert(packKeys.length >= 2, `Expected external Packs from both CLI writers:\n${objectKeys.join("\n")}`);
|
||||
for (const legacyPrefix of ["a1~index~", "a1~delta~", "a1~metadata~"]) {
|
||||
assert(
|
||||
!objectKeys.some((key) => key.startsWith(legacyPrefix)),
|
||||
`Legacy Adaptive object with prefix ${legacyPrefix} was written:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
}
|
||||
const manifest = JSON.parse(await readWebDAVObjectText(collectionEndpoint, "a1~manifest.json")) as {
|
||||
objectLayout?: unknown;
|
||||
};
|
||||
assertEquals(manifest.objectLayout, "commit-bundle-v1");
|
||||
assert(
|
||||
!objectKeys.some((key) => key.startsWith("a1~probe~")),
|
||||
`Adaptive capability probe objects were not removed:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
assert(
|
||||
!objectKeys.includes("_00000000-milestone.json"),
|
||||
`Legacy Journal milestone was written into the Adaptive repository:\n${objectKeys.join("\n")}`
|
||||
);
|
||||
} finally {
|
||||
if (shouldStartDocker && !keepDocker) {
|
||||
await stopWebDAV().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
+75
-161
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.0-webapp",
|
||||
"version": "1.0.2-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
@@ -9,10 +9,13 @@
|
||||
"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"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+102
-17
@@ -1,37 +1,122 @@
|
||||
# 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.
|
||||
|
||||
## P2P connection check
|
||||
|
||||
`check.html` provides a disposable preflight check for P2P connectivity. It creates a random encrypted Setup URI and QR code locally, joins the generated room as a browser reference peer only after explicit confirmation, and displays the browser's WebRTC diagnostic totals beside the QR code.
|
||||
|
||||
Use a dedicated empty Vault for every device:
|
||||
|
||||
1. Select desktop or mobile, then prepare the check.
|
||||
2. Start the browser connection monitor.
|
||||
3. Open or scan the Setup URI in an empty Vault with Self-hosted LiveSync installed and enabled.
|
||||
4. Enter the separately displayed Setup URI passphrase.
|
||||
5. Keep both peers open for the observation period and watch the successful-connection total.
|
||||
6. Use **Show the Setup QR again** to return to the existing configuration without regenerating it.
|
||||
7. For the clearest device comparison, start a fresh check before testing the other device.
|
||||
|
||||
A successful total greater than zero means that the browser and target established at least one WebRTC connection. It does not verify note synchronisation, sustained connectivity, or a direct desktop-to-mobile path. If representative checks repeatedly do not connect, CouchDB is the more predictable synchronisation option. An optional final check can use two disposable empty Vaults to verify a note round trip directly between the user's devices.
|
||||
|
||||
After the first device connects, **Try another device without resetting** keeps the browser, room, credentials, first device, and cumulative counters in place. The page records a local baseline, shows the same QR code for another empty Vault, and reports an additional connection only after both a new successful connection state and another simultaneous active connection appear. Keep the first device connected. Connections are not device identities, so this same-room route is convenient but a fresh check remains easier to interpret because reconnect activity is isolated.
|
||||
|
||||
Serve the production build over HTTPS or from `localhost`. Downloading `check.html` alone is not supported because the page uses built module assets and origin-scoped browser storage. The generated credentials are temporary and must not replace the settings of a production Vault.
|
||||
|
||||
The detailed scope and result semantics are recorded in the [browser-assisted P2P connection-check ADR](../../../docs/adr/2026_07_p2p_connection_check.md).
|
||||
|
||||
## Storage and lifecycle
|
||||
|
||||
WebPeer stores its settings, metadata, and chunks in storage belonging to the page origin. Consequently:
|
||||
|
||||
- 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 focused browser-to-Obsidian E2E test builds both production artefacts, manages the local Compose P2P relay, applies the browser-generated Setup URI to two isolated empty Vaults without resetting the browser room, and retains the successful additional-device result under the Obsidian diagnostics directory:
|
||||
|
||||
## Some notes
|
||||
```bash
|
||||
npm run test:e2e:obsidian:p2p-connection-check:services
|
||||
```
|
||||
|
||||
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.
|
||||
Configure `OBSIDIAN_BINARY` and `OBSIDIAN_CLI` when the E2E runner cannot discover them automatically.
|
||||
|
||||
The unit tests are stored in `test/apps/webpeer/`, outside the Community Review source boundary.
|
||||
|
||||
[^1]: Congrats! I made it modular. Finally...
|
||||
## Composition
|
||||
|
||||
The WebPeer production bundle has two HTML entry points. `index.html` loads `src/main.ts` and the ordinary browser peer, while `check.html` loads `src/check.ts` and the isolated P2P connection check. The ordinary page links to the check, so it is a WebPeer feature without sharing its saved settings or runtime session.
|
||||
|
||||
`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls. The connection-check modules create an isolated runtime with in-memory test settings and derive user-visible results from Commonlib's browser-side diagnostic events. Both entry points use the same WebPeer visual foundation.
|
||||
|
||||
## Licence
|
||||
|
||||
The same licence as the main Self-hosted LiveSync project applies.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Prepare a disposable Setup URI and check a Self-hosted LiveSync P2P connection from the browser."
|
||||
/>
|
||||
<title>P2P connection check · Self-hosted LiveSync</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/check.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,7 +6,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Peer-to-Peer Daemon on Browser</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Run a temporary Self-hosted LiveSync P2P peer and inspect its activity in the browser."
|
||||
/>
|
||||
<title>WebPeer · Self-hosted LiveSync</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -14,4 +18,4 @@
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.0-webpeer",
|
||||
"version": "1.0.2-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,10 +9,13 @@
|
||||
"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"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
|
||||
@@ -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("");
|
||||
@@ -0,0 +1,601 @@
|
||||
<script lang="ts">
|
||||
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import qrcode from "qrcode-generator";
|
||||
import { onDestroy, tick } from "svelte";
|
||||
|
||||
import {
|
||||
generateP2PCheckSetup,
|
||||
resolveLocalP2PCheckRelayOverride,
|
||||
type GeneratedP2PCheckSetup,
|
||||
type P2PCheckTarget,
|
||||
} from "./P2PCheckSetup";
|
||||
import { P2PCheckSession } from "./P2PCheckSession";
|
||||
import {
|
||||
EMPTY_P2P_CHECK_DIAGNOSTICS,
|
||||
P2P_CHECK_OBSERVATION_MILLISECONDS,
|
||||
captureP2PAdditionalCheckBaseline,
|
||||
countActiveP2PConnections,
|
||||
deriveP2PAdditionalCheckProgress,
|
||||
deriveP2PCheckOutcome,
|
||||
type P2PAdditionalCheckBaseline,
|
||||
type P2PAdditionalCheckOutcome,
|
||||
type P2PCheckOutcome,
|
||||
} from "./P2PCheckState";
|
||||
|
||||
const OUTCOME_COPY: Record<
|
||||
P2PCheckOutcome,
|
||||
{ readonly title: string; readonly body: string; readonly tone: string }
|
||||
> = {
|
||||
idle: {
|
||||
title: "Not monitoring yet",
|
||||
body: "Prepare the reference peer, then start monitoring before opening the Setup URI on the target device.",
|
||||
tone: "neutral",
|
||||
},
|
||||
waiting: {
|
||||
title: "Waiting for the device",
|
||||
body: "The browser reference peer is ready. Keep this page open and complete setup in the empty test Vault.",
|
||||
tone: "neutral",
|
||||
},
|
||||
connecting: {
|
||||
title: "Negotiating a connection",
|
||||
body: "A WebRTC connection attempt is in progress. The counters can increase more than once during negotiation.",
|
||||
tone: "progress",
|
||||
},
|
||||
retrying: {
|
||||
title: "An attempt failed; waiting for a retry",
|
||||
body: "A failed attempt is not final. Leave both peers open because a later retry can still succeed.",
|
||||
tone: "warning",
|
||||
},
|
||||
connected: {
|
||||
title: "P2P connection observed",
|
||||
body: "This browser and the target device established at least one WebRTC connection. This checks connectivity, not note synchronisation.",
|
||||
tone: "success",
|
||||
},
|
||||
inconclusive: {
|
||||
title: "No P2P connection observed",
|
||||
body: "No successful connection was seen during the observation period. Repeat the check on the networks you intend to use; if it remains unsuccessful, CouchDB is the more predictable choice.",
|
||||
tone: "warning",
|
||||
},
|
||||
error: {
|
||||
title: "The browser monitor could not start",
|
||||
body: "Check browser support, the secure-context requirement, and access to the signalling relay, then start a fresh check.",
|
||||
tone: "error",
|
||||
},
|
||||
};
|
||||
|
||||
const ADDITIONAL_OUTCOME_COPY: Record<
|
||||
P2PAdditionalCheckOutcome,
|
||||
{ readonly title: string; readonly body: string; readonly tone: string }
|
||||
> = {
|
||||
waiting: {
|
||||
title: "Waiting for another device",
|
||||
body: "The same Setup URI is ready. Keep the browser and first device open while another empty Vault joins.",
|
||||
tone: "neutral",
|
||||
},
|
||||
negotiating: {
|
||||
title: "New connection activity observed",
|
||||
body: "The counters or active connections changed. Waiting for both a new successful state and another simultaneous active connection.",
|
||||
tone: "progress",
|
||||
},
|
||||
connected: {
|
||||
title: "An additional connection was observed",
|
||||
body: "The browser gained another simultaneous active WebRTC connection after this attempt began.",
|
||||
tone: "success",
|
||||
},
|
||||
inconclusive: {
|
||||
title: "Another device was not identified",
|
||||
body: "The same-room baseline did not produce both signals during this observation period. Use a fresh check for a clean per-device result.",
|
||||
tone: "warning",
|
||||
},
|
||||
};
|
||||
|
||||
interface AdditionalDeviceAttempt {
|
||||
readonly baseline: P2PAdditionalCheckBaseline;
|
||||
readonly startedAtElapsedMilliseconds: number;
|
||||
}
|
||||
|
||||
let target = $state<P2PCheckTarget>("desktop");
|
||||
let setup = $state<GeneratedP2PCheckSetup>();
|
||||
let qrDataURL = $state("");
|
||||
let preparing = $state(false);
|
||||
let preparationError = $state("");
|
||||
let monitorStarting = $state(false);
|
||||
let monitorActive = $state(false);
|
||||
let monitorError = $state("");
|
||||
let status = $state<P2PServerInfo>();
|
||||
let elapsedMilliseconds = $state(0);
|
||||
let copied = $state<"uri" | "passphrase">();
|
||||
let copyError = $state("");
|
||||
let freshCheckStarting = $state(false);
|
||||
let additionalDeviceAttempt = $state<AdditionalDeviceAttempt>();
|
||||
|
||||
let session: P2PCheckSession | undefined;
|
||||
let setupCard = $state<HTMLElement>();
|
||||
let setupHeading = $state<HTMLHeadingElement>();
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let monitorStartedAt = 0;
|
||||
|
||||
let diagnostics = $derived(status?.diag ?? EMPTY_P2P_CHECK_DIAGNOSTICS);
|
||||
let outcome = $derived(
|
||||
deriveP2PCheckOutcome(
|
||||
diagnostics,
|
||||
monitorActive,
|
||||
elapsedMilliseconds,
|
||||
monitorError !== ""
|
||||
)
|
||||
);
|
||||
let outcomeCopy = $derived(OUTCOME_COPY[outcome]);
|
||||
let activeConnections = $derived(countActiveP2PConnections(diagnostics));
|
||||
let observationSeconds = $derived(
|
||||
Math.floor(P2P_CHECK_OBSERVATION_MILLISECONDS / 1_000)
|
||||
);
|
||||
let elapsedSeconds = $derived(Math.floor(elapsedMilliseconds / 1_000));
|
||||
let targetLabel = $derived(target === "desktop" ? "desktop" : "mobile");
|
||||
let additionalElapsedMilliseconds = $derived(
|
||||
additionalDeviceAttempt
|
||||
? Math.max(
|
||||
0,
|
||||
elapsedMilliseconds -
|
||||
additionalDeviceAttempt.startedAtElapsedMilliseconds
|
||||
)
|
||||
: 0
|
||||
);
|
||||
let additionalElapsedSeconds = $derived(
|
||||
Math.floor(additionalElapsedMilliseconds / 1_000)
|
||||
);
|
||||
let additionalProgress = $derived(
|
||||
additionalDeviceAttempt
|
||||
? deriveP2PAdditionalCheckProgress(
|
||||
diagnostics,
|
||||
additionalDeviceAttempt.baseline,
|
||||
additionalElapsedMilliseconds
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
let additionalOutcomeCopy = $derived(
|
||||
additionalProgress
|
||||
? ADDITIONAL_OUTCOME_COPY[additionalProgress.outcome]
|
||||
: undefined
|
||||
);
|
||||
|
||||
function createQRCodeDataURL(setupURI: string): string {
|
||||
const code = qrcode(0, "L");
|
||||
code.addData(setupURI);
|
||||
code.make();
|
||||
return code.createDataURL(4, 4);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function prepareCheck(): Promise<void> {
|
||||
preparing = true;
|
||||
preparationError = "";
|
||||
try {
|
||||
const relay = resolveLocalP2PCheckRelayOverride(window.location);
|
||||
const generated = await generateP2PCheckSetup(target, { relay });
|
||||
qrDataURL = createQRCodeDataURL(generated.setupURI);
|
||||
setup = generated;
|
||||
} catch (error) {
|
||||
preparationError = formatError(error);
|
||||
} finally {
|
||||
preparing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateElapsedTime(): void {
|
||||
elapsedMilliseconds = Date.now() - monitorStartedAt;
|
||||
}
|
||||
|
||||
async function startMonitor(): Promise<void> {
|
||||
if (!setup || monitorStarting || monitorActive) {
|
||||
return;
|
||||
}
|
||||
monitorStarting = true;
|
||||
monitorError = "";
|
||||
const newSession = new P2PCheckSession();
|
||||
session = newSession;
|
||||
try {
|
||||
await newSession.start(setup.browserSettings, setup.browserDeviceName, (nextStatus) => {
|
||||
status = nextStatus;
|
||||
});
|
||||
monitorActive = true;
|
||||
monitorStartedAt = Date.now();
|
||||
elapsedMilliseconds = 0;
|
||||
elapsedTimer = setInterval(updateElapsedTime, 250);
|
||||
} catch (error) {
|
||||
monitorError = formatError(error);
|
||||
monitorActive = false;
|
||||
} finally {
|
||||
monitorStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(value: string, kind: "uri" | "passphrase"): Promise<void> {
|
||||
copyError = "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = kind;
|
||||
if (copyTimer !== undefined) {
|
||||
clearTimeout(copyTimer);
|
||||
}
|
||||
copyTimer = setTimeout(() => {
|
||||
copied = undefined;
|
||||
}, 2_000);
|
||||
} catch (error) {
|
||||
copyError = `Copying failed: ${formatError(error)}. Select the text and copy it manually.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function startFreshCheck(): Promise<void> {
|
||||
freshCheckStarting = true;
|
||||
if (elapsedTimer !== undefined) {
|
||||
clearInterval(elapsedTimer);
|
||||
}
|
||||
await session?.stop();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function scrollToSetupQRCode(): void {
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
setupCard?.scrollIntoView({
|
||||
behavior: reducedMotion ? "auto" : "smooth",
|
||||
block: "start",
|
||||
});
|
||||
setupHeading?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
async function showSetupQRCode(): Promise<void> {
|
||||
await tick();
|
||||
scrollToSetupQRCode();
|
||||
}
|
||||
|
||||
async function startAdditionalDeviceAttempt(): Promise<void> {
|
||||
if (
|
||||
!monitorActive ||
|
||||
outcome !== "connected" ||
|
||||
activeConnections === 0 ||
|
||||
additionalDeviceAttempt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
additionalDeviceAttempt = {
|
||||
baseline: captureP2PAdditionalCheckBaseline(diagnostics),
|
||||
startedAtElapsedMilliseconds: elapsedMilliseconds,
|
||||
};
|
||||
await showSetupQRCode();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (elapsedTimer !== undefined) {
|
||||
clearInterval(elapsedTimer);
|
||||
}
|
||||
if (copyTimer !== undefined) {
|
||||
clearTimeout(copyTimer);
|
||||
}
|
||||
void session?.stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="theme-color" content="#12233f" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="check-shell">
|
||||
<header class="hero">
|
||||
<a class="eyebrow" href="./index.html">Self-hosted LiveSync · WebPeer</a>
|
||||
<h1>P2P connection check</h1>
|
||||
<p class="hero-copy">
|
||||
See whether one LiveSync device can establish a WebRTC connection to this browser on
|
||||
the current network.
|
||||
</p>
|
||||
<div class="scope-note" role="note">
|
||||
<span aria-hidden="true">◇</span>
|
||||
<strong>Empty test Vaults only.</strong>
|
||||
This is a disposable connectivity check, not a synchronisation or backup test.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="step-card" aria-labelledby="choose-target-heading">
|
||||
<div class="step-number" aria-hidden="true">1</div>
|
||||
<div class="step-content">
|
||||
<p class="section-kicker">Choose one target</p>
|
||||
<h2 id="choose-target-heading">Which device will connect to this browser?</h2>
|
||||
<p class="section-copy">
|
||||
Check desktop and mobile separately. A fresh test gives each device its own random
|
||||
room and clear diagnostic counters.
|
||||
</p>
|
||||
|
||||
<fieldset class="target-picker" disabled={setup !== undefined}>
|
||||
<legend class="visually-hidden">Target device</legend>
|
||||
<label class:chosen={target === "desktop"} class="target-option">
|
||||
<input type="radio" bind:group={target} value="desktop" />
|
||||
<span class="target-icon" aria-hidden="true">▰</span>
|
||||
<span>
|
||||
<strong>Desktop LiveSync</strong>
|
||||
<small>Browser ↔ desktop plug-in</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class:chosen={target === "mobile"} class="target-option">
|
||||
<input type="radio" bind:group={target} value="mobile" />
|
||||
<span class="target-icon mobile" aria-hidden="true">▯</span>
|
||||
<span>
|
||||
<strong>Mobile LiveSync</strong>
|
||||
<small>Browser ↔ mobile plug-in</small>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button class="primary-action" type="button" onclick={prepareCheck} disabled={preparing || setup !== undefined}>
|
||||
{preparing ? "Preparing locally…" : `Prepare ${targetLabel} check`}
|
||||
</button>
|
||||
<p class="privacy-line">
|
||||
Preparing generates and encrypts everything in this browser. It does not contact
|
||||
the signalling relay.
|
||||
</p>
|
||||
{#if preparationError}
|
||||
<p class="inline-error" role="alert">{preparationError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if setup}
|
||||
<section bind:this={setupCard} class="step-card setup-card" aria-labelledby="setup-heading">
|
||||
<div class="step-number" aria-hidden="true">2</div>
|
||||
<div class="step-content">
|
||||
<p class="section-kicker">Set up the empty Vault</p>
|
||||
<h2 bind:this={setupHeading} id="setup-heading" tabindex="-1">
|
||||
{additionalDeviceAttempt
|
||||
? "Use this same one-off configuration on another device"
|
||||
: `Open this one-off configuration on ${targetLabel}`}
|
||||
</h2>
|
||||
{#if additionalDeviceAttempt}
|
||||
<p class="section-copy">
|
||||
Keep the browser and first device open. In another new empty Vault, scan
|
||||
this same QR code or open the same Setup URI, then enter the same separate
|
||||
passphrase.
|
||||
</p>
|
||||
<p class="reuse-note" role="note">
|
||||
The room, credentials, connection, and existing counters have not been
|
||||
reset. The additional-device result uses the values recorded when you
|
||||
selected the button.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="section-copy">
|
||||
In a new empty Vault with Self-hosted LiveSync installed and enabled, scan
|
||||
the QR code or open the Setup URI. Enter the separate passphrase when
|
||||
prompted.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="setup-grid">
|
||||
<div class="qr-panel">
|
||||
<img
|
||||
src={qrDataURL}
|
||||
alt={additionalDeviceAttempt
|
||||
? "Setup URI QR code for another device"
|
||||
: `Setup URI QR code for the ${targetLabel} check`}
|
||||
/>
|
||||
<p>
|
||||
{additionalDeviceAttempt
|
||||
? "This is the original encrypted Setup URI; it was not regenerated."
|
||||
: "QR contains the encrypted Setup URI only."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="credential-panel">
|
||||
<label for="setup-passphrase">Setup URI passphrase</label>
|
||||
<div class="copy-row compact">
|
||||
<input
|
||||
id="setup-passphrase"
|
||||
value={setup.setupPassphrase}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" onclick={() => copyText(setup!.setupPassphrase, "passphrase")}>
|
||||
{copied === "passphrase" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-help">Type this when LiveSync asks to decrypt the Setup URI.</p>
|
||||
|
||||
<label for="setup-uri">Setup URI</label>
|
||||
<textarea
|
||||
id="setup-uri"
|
||||
rows="4"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
readonly
|
||||
value={setup.setupURI}
|
||||
></textarea>
|
||||
<div class="button-row">
|
||||
<button type="button" onclick={() => copyText(setup!.setupURI, "uri")}>
|
||||
{copied === "uri" ? "Copied URI" : "Copy Setup URI"}
|
||||
</button>
|
||||
<a class="button-link" href={setup.setupURI}>Open in Obsidian</a>
|
||||
</div>
|
||||
{#if copyError}
|
||||
<p class="inline-error" role="alert">{copyError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="session-details">
|
||||
<div>
|
||||
<dt>Target</dt>
|
||||
<dd>{targetLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Test Group ID</dt>
|
||||
<dd>{setup.groupId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Signalling relay</dt>
|
||||
<dd>{setup.relay}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step-card results-card" aria-labelledby="monitor-heading">
|
||||
<div class="step-number" aria-hidden="true">3</div>
|
||||
<div class="step-content">
|
||||
<p class="section-kicker">Watch the browser diagnostics</p>
|
||||
<h2 id="monitor-heading">Start monitoring, then complete setup on {targetLabel}</h2>
|
||||
<p class="section-copy">
|
||||
Starting monitoring joins the configured signalling relay. Keep this page and
|
||||
the target Vault open for at least {observationSeconds} seconds.
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="primary-action monitor-action"
|
||||
type="button"
|
||||
onclick={startMonitor}
|
||||
disabled={monitorStarting || monitorActive}
|
||||
>
|
||||
{monitorStarting ? "Starting browser peer…" : monitorActive ? "Monitoring is active" : "Start connection monitor"}
|
||||
</button>
|
||||
|
||||
<div class="outcome" class:success={outcomeCopy.tone === "success"} class:warning={outcomeCopy.tone === "warning"} class:error={outcomeCopy.tone === "error"} class:progress={outcomeCopy.tone === "progress"} aria-live="polite">
|
||||
<div class="outcome-mark" aria-hidden="true">
|
||||
{outcome === "connected" ? "✓" : outcome === "error" ? "!" : outcome === "inconclusive" ? "?" : "•"}
|
||||
</div>
|
||||
<div>
|
||||
<h3>{outcomeCopy.title}</h3>
|
||||
<p>{outcomeCopy.body}</p>
|
||||
{#if monitorActive}
|
||||
<small>{elapsedSeconds}s observed · {activeConnections} currently connected</small>
|
||||
{/if}
|
||||
{#if monitorError}
|
||||
<code>{monitorError}</code>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics" aria-label="WebRTC diagnostic totals since this page opened">
|
||||
<article>
|
||||
<span>New</span>
|
||||
<strong data-testid="diag-new">{diagnostics.totalNewConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
<article class="successful">
|
||||
<span>Successful</span>
|
||||
<strong data-testid="diag-successful">{diagnostics.totalSuccessfulConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
<article class="failed">
|
||||
<span>Failed</span>
|
||||
<strong data-testid="diag-failed">{diagnostics.totalFailedConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Closed</span>
|
||||
<strong data-testid="diag-closed">{diagnostics.totalClosedConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p class="counter-note">
|
||||
These are negotiation events, not device counts. <strong>Successful > 0</strong>
|
||||
is the connection signal; later failures or closures do not erase it.
|
||||
</p>
|
||||
|
||||
{#if additionalProgress && additionalOutcomeCopy}
|
||||
<div
|
||||
class="outcome additional-outcome"
|
||||
class:success={additionalOutcomeCopy.tone === "success"}
|
||||
class:warning={additionalOutcomeCopy.tone === "warning"}
|
||||
class:progress={additionalOutcomeCopy.tone === "progress"}
|
||||
aria-live="polite"
|
||||
data-testid="additional-device-outcome"
|
||||
>
|
||||
<div class="outcome-mark" aria-hidden="true">
|
||||
{additionalProgress.outcome === "connected"
|
||||
? "✓"
|
||||
: additionalProgress.outcome === "inconclusive"
|
||||
? "?"
|
||||
: "•"}
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-kicker">Another device in this room</p>
|
||||
<h3>{additionalOutcomeCopy.title}</h3>
|
||||
<p>{additionalOutcomeCopy.body}</p>
|
||||
<small>
|
||||
{additionalElapsedSeconds}s observed ·
|
||||
<span data-testid="additional-successful">
|
||||
+{additionalProgress.successfulConnections} successful
|
||||
</span>
|
||||
·
|
||||
<span data-testid="additional-active-connections">
|
||||
+{additionalProgress.activeConnections} active
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<p class="counter-note additional-caveat">
|
||||
Same-room counters remain cumulative and connections are not device counts.
|
||||
The first device must stay connected; a fresh room remains the clearest
|
||||
per-device comparison.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="session-actions">
|
||||
<button class="secondary-action" type="button" onclick={showSetupQRCode}>
|
||||
Show the Setup QR again
|
||||
</button>
|
||||
{#if outcome === "connected" && !additionalDeviceAttempt}
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
onclick={startAdditionalDeviceAttempt}
|
||||
disabled={activeConnections === 0}
|
||||
>
|
||||
{activeConnections === 0
|
||||
? "Waiting for the first device to reconnect…"
|
||||
: "Try another device without resetting"}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="secondary-action"
|
||||
type="button"
|
||||
onclick={startFreshCheck}
|
||||
disabled={freshCheckStarting}
|
||||
>
|
||||
{freshCheckStarting ? "Closing this check…" : "Start a fresh check for the other device"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="next-step" aria-labelledby="next-step-heading">
|
||||
<p class="section-kicker">What this tells you</p>
|
||||
<h2 id="next-step-heading">Use the result as a P2P preflight</h2>
|
||||
<div class="next-step-grid">
|
||||
<div>
|
||||
<h3>If both devices are observed</h3>
|
||||
<p>
|
||||
P2P looks plausible on these networks. Separate fresh checks give the
|
||||
clearest device comparison. For a stronger final check, use two disposable
|
||||
empty Vaults and verify a note round trip directly between your devices.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>If checks repeatedly do not connect</h3>
|
||||
<p>
|
||||
Network policy or NAT may make P2P unreliable. Use CouchDB when you need a
|
||||
predictable synchronisation path across these networks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
The generated credentials are disposable. Delete the empty test Vault afterwards and
|
||||
generate new credentials for any real setup.
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
EVENT_SERVER_STATUS,
|
||||
type P2PServerInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
import { WEBPEER_SETTINGS_KEY } from "./WebPeerPersistence";
|
||||
import { WebPeerRuntime } from "./WebPeerRuntime";
|
||||
|
||||
export const P2P_CHECK_SYSTEM_VAULT_NAME = "p2p-livesync-connection-check";
|
||||
|
||||
function createMemoryStore(settings: ObsidianLiveSyncSettings): SimpleStore<unknown> {
|
||||
const values = new Map<string, unknown>([[WEBPEER_SETTINGS_KEY, settings]]);
|
||||
return {
|
||||
db: Promise.resolve(undefined),
|
||||
get: async (key) => values.get(key),
|
||||
set: async (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete: async (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
keys: async (from, to, count) => {
|
||||
const selected = [...values.keys()]
|
||||
.sort()
|
||||
.filter((key) => (from === undefined || key >= from) && (to === undefined || key <= to));
|
||||
return count === undefined ? selected : selected.slice(0, count);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class P2PCheckSession {
|
||||
private runtime?: WebPeerRuntime;
|
||||
private removeStatusListener?: () => void;
|
||||
|
||||
async start(
|
||||
settings: ObsidianLiveSyncSettings,
|
||||
browserDeviceName: string,
|
||||
onStatus: (status: P2PServerInfo) => void
|
||||
): Promise<void> {
|
||||
if (this.runtime) {
|
||||
throw new Error("This P2P connection-check session has already started");
|
||||
}
|
||||
|
||||
const runtime = new WebPeerRuntime({
|
||||
store: createMemoryStore(settings),
|
||||
deviceName: browserDeviceName,
|
||||
systemVaultName: P2P_CHECK_SYSTEM_VAULT_NAME,
|
||||
});
|
||||
this.runtime = runtime;
|
||||
this.removeStatusListener = runtime.events.onEvent(EVENT_SERVER_STATUS, onStatus);
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
await runtime.currentReplicator.makeSureOpened();
|
||||
} catch (error) {
|
||||
await this.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.removeStatusListener?.();
|
||||
this.removeStatusListener = undefined;
|
||||
const runtime = this.runtime;
|
||||
this.runtime = undefined;
|
||||
await runtime?.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import {
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
PREFERRED_BASE,
|
||||
createNewVaultSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { generateP2PRoomId } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
|
||||
export const P2P_CHECK_APP_ID = "self-hosted-livesync-p2p-check-v1";
|
||||
export const P2P_CHECK_REMOTE_NAME = "P2P connection check";
|
||||
|
||||
export type P2PCheckTarget = "desktop" | "mobile";
|
||||
|
||||
export interface GeneratedP2PCheckSetup {
|
||||
readonly target: P2PCheckTarget;
|
||||
readonly setupURI: string;
|
||||
readonly setupPassphrase: string;
|
||||
readonly groupId: string;
|
||||
readonly relay: string;
|
||||
readonly browserDeviceName: string;
|
||||
readonly browserSettings: ObsidianLiveSyncSettings;
|
||||
}
|
||||
|
||||
export interface P2PCheckSetupOptions {
|
||||
readonly relay?: string;
|
||||
}
|
||||
|
||||
export interface P2PCheckPageLocation {
|
||||
readonly hostname: string;
|
||||
readonly search: string;
|
||||
}
|
||||
|
||||
interface SharedP2PCheckCredentials {
|
||||
readonly groupId: string;
|
||||
readonly p2pPassphrase: string;
|
||||
readonly vaultPassphrase: string;
|
||||
}
|
||||
|
||||
const READABLE_SECRET_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz";
|
||||
const RANDOM_BYTE_ACCEPTANCE_LIMIT =
|
||||
Math.floor(256 / READABLE_SECRET_ALPHABET.length) * READABLE_SECRET_ALPHABET.length;
|
||||
|
||||
function generateReadableSecret(length: number): string {
|
||||
const crypto = compatGlobal.crypto;
|
||||
if (!crypto) {
|
||||
throw new Error("Web Crypto is required to prepare a P2P connection check");
|
||||
}
|
||||
|
||||
let result = "";
|
||||
const bytes = new Uint8Array(Math.max(16, length));
|
||||
while (result.length < length) {
|
||||
crypto.getRandomValues(bytes);
|
||||
for (const byte of bytes) {
|
||||
if (byte >= RANDOM_BYTE_ACCEPTANCE_LIMIT) {
|
||||
continue;
|
||||
}
|
||||
result += READABLE_SECRET_ALPHABET[byte % READABLE_SECRET_ALPHABET.length];
|
||||
if (result.length === length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateSetupPassphrase(): string {
|
||||
return generateReadableSecret(16).match(/.{4}/g)!.join("-");
|
||||
}
|
||||
|
||||
function createSettings(
|
||||
credentials: SharedP2PCheckCredentials,
|
||||
options: {
|
||||
readonly autoStart: boolean;
|
||||
readonly relay: string;
|
||||
readonly useDiagnostics: boolean;
|
||||
}
|
||||
): ObsidianLiveSyncSettings {
|
||||
const settings = createNewVaultSettings();
|
||||
Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, {
|
||||
isConfigured: true,
|
||||
encrypt: true,
|
||||
passphrase: credentials.vaultPassphrase,
|
||||
usePathObfuscation: true,
|
||||
P2P_Enabled: true,
|
||||
P2P_AppID: P2P_CHECK_APP_ID,
|
||||
P2P_roomID: credentials.groupId,
|
||||
P2P_passphrase: credentials.p2pPassphrase,
|
||||
P2P_relays: options.relay,
|
||||
P2P_AutoStart: options.autoStart,
|
||||
P2P_AutoBroadcast: false,
|
||||
P2P_DevicePeerName: "",
|
||||
P2P_useDiagRTC: options.useDiagnostics,
|
||||
});
|
||||
upsertRemoteConfigurationInPlace(settings, "p2p", {
|
||||
name: P2P_CHECK_REMOTE_NAME,
|
||||
activate: true,
|
||||
activateForP2P: true,
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function resolveLocalP2PCheckRelayOverride(location: P2PCheckPageLocation): string | undefined {
|
||||
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
||||
if (!loopbackHosts.has(location.hostname.toLowerCase())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requestedRelay = new URLSearchParams(location.search).get("relay")?.trim();
|
||||
if (!requestedRelay) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const relay = new URL(requestedRelay);
|
||||
if (relay.protocol !== "ws:" && relay.protocol !== "wss:") {
|
||||
return undefined;
|
||||
}
|
||||
return relay.href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateP2PCheckSetup(
|
||||
target: P2PCheckTarget,
|
||||
options: P2PCheckSetupOptions = {}
|
||||
): Promise<GeneratedP2PCheckSetup> {
|
||||
const relay = options.relay?.trim() || P2P_DEFAULT_SETTINGS.P2P_relays;
|
||||
const credentials: SharedP2PCheckCredentials = {
|
||||
groupId: generateP2PRoomId(),
|
||||
p2pPassphrase: generateReadableSecret(32),
|
||||
vaultPassphrase: generateReadableSecret(32),
|
||||
};
|
||||
const deviceSettings = createSettings(credentials, {
|
||||
autoStart: true,
|
||||
relay,
|
||||
useDiagnostics: false,
|
||||
});
|
||||
const browserSettings = createSettings(credentials, {
|
||||
autoStart: false,
|
||||
relay,
|
||||
useDiagnostics: true,
|
||||
});
|
||||
browserSettings.suspendParseReplicationResult = true;
|
||||
|
||||
const setupPassphrase = generateSetupPassphrase();
|
||||
const setupURI = await encodeSettingsToSetupURI(
|
||||
deviceSettings,
|
||||
setupPassphrase,
|
||||
["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks", "P2P_DevicePeerName", "deviceAndVaultName"],
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
target,
|
||||
setupURI: setupURI.trim(),
|
||||
setupPassphrase,
|
||||
groupId: credentials.groupId,
|
||||
relay: deviceSettings.P2P_relays,
|
||||
browserDeviceName: `p2p-check-browser-${target}-${credentials.groupId.slice(-3)}`,
|
||||
browserSettings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
|
||||
export const P2P_CHECK_OBSERVATION_MILLISECONDS = 60_000;
|
||||
|
||||
export type P2PCheckDiagnostics = P2PServerInfo["diag"];
|
||||
export type P2PCheckOutcome = "idle" | "waiting" | "connecting" | "retrying" | "connected" | "inconclusive" | "error";
|
||||
export type P2PAdditionalCheckOutcome = "waiting" | "negotiating" | "connected" | "inconclusive";
|
||||
|
||||
export interface P2PAdditionalCheckBaseline {
|
||||
readonly activeConnectionIds: readonly string[];
|
||||
readonly totalClosedConnections: number;
|
||||
readonly totalFailedConnections: number;
|
||||
readonly totalNewConnections: number;
|
||||
readonly totalSuccessfulConnections: number;
|
||||
}
|
||||
|
||||
export interface P2PAdditionalCheckProgress {
|
||||
readonly activeConnections: number;
|
||||
readonly closedConnections: number;
|
||||
readonly failedConnections: number;
|
||||
readonly newConnections: number;
|
||||
readonly newActiveConnectionIds: readonly string[];
|
||||
readonly outcome: P2PAdditionalCheckOutcome;
|
||||
readonly successfulConnections: number;
|
||||
}
|
||||
|
||||
export const EMPTY_P2P_CHECK_DIAGNOSTICS: P2PCheckDiagnostics = {
|
||||
totalNewConnections: 0,
|
||||
totalFailedConnections: 0,
|
||||
totalSuccessfulConnections: 0,
|
||||
totalClosedConnections: 0,
|
||||
details: {},
|
||||
};
|
||||
|
||||
export function deriveP2PCheckOutcome(
|
||||
diagnostics: P2PCheckDiagnostics,
|
||||
monitorActive: boolean,
|
||||
elapsedMilliseconds: number,
|
||||
monitorError = false
|
||||
): P2PCheckOutcome {
|
||||
if (monitorError) {
|
||||
return "error";
|
||||
}
|
||||
if (diagnostics.totalSuccessfulConnections > 0) {
|
||||
return "connected";
|
||||
}
|
||||
if (!monitorActive) {
|
||||
return "idle";
|
||||
}
|
||||
if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) {
|
||||
return "inconclusive";
|
||||
}
|
||||
if (diagnostics.totalFailedConnections > 0) {
|
||||
return "retrying";
|
||||
}
|
||||
if (diagnostics.totalNewConnections > 0 || Object.keys(diagnostics.details).length > 0) {
|
||||
return "connecting";
|
||||
}
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
export function countActiveP2PConnections(diagnostics: P2PCheckDiagnostics): number {
|
||||
return Object.values(diagnostics.details).filter(({ connectionState }) => connectionState === "connected").length;
|
||||
}
|
||||
|
||||
function activeConnectionIds(diagnostics: P2PCheckDiagnostics): string[] {
|
||||
return Object.entries(diagnostics.details)
|
||||
.filter(([, { connectionState }]) => connectionState === "connected")
|
||||
.map(([connectionId]) => connectionId);
|
||||
}
|
||||
|
||||
export function captureP2PAdditionalCheckBaseline(diagnostics: P2PCheckDiagnostics): P2PAdditionalCheckBaseline {
|
||||
return {
|
||||
activeConnectionIds: activeConnectionIds(diagnostics),
|
||||
totalClosedConnections: diagnostics.totalClosedConnections,
|
||||
totalFailedConnections: diagnostics.totalFailedConnections,
|
||||
totalNewConnections: diagnostics.totalNewConnections,
|
||||
totalSuccessfulConnections: diagnostics.totalSuccessfulConnections,
|
||||
};
|
||||
}
|
||||
|
||||
function counterIncrease(current: number, baseline: number): number {
|
||||
return Math.max(0, current - baseline);
|
||||
}
|
||||
|
||||
export function deriveP2PAdditionalCheckProgress(
|
||||
diagnostics: P2PCheckDiagnostics,
|
||||
baseline: P2PAdditionalCheckBaseline,
|
||||
elapsedMilliseconds: number
|
||||
): P2PAdditionalCheckProgress {
|
||||
const baselineConnectionIds = new Set(baseline.activeConnectionIds);
|
||||
const currentActiveConnectionIds = activeConnectionIds(diagnostics);
|
||||
const newActiveConnectionIds = currentActiveConnectionIds.filter(
|
||||
(connectionId) => !baselineConnectionIds.has(connectionId)
|
||||
);
|
||||
const activeConnections = counterIncrease(currentActiveConnectionIds.length, baseline.activeConnectionIds.length);
|
||||
const newConnections = counterIncrease(diagnostics.totalNewConnections, baseline.totalNewConnections);
|
||||
const successfulConnections = counterIncrease(
|
||||
diagnostics.totalSuccessfulConnections,
|
||||
baseline.totalSuccessfulConnections
|
||||
);
|
||||
const failedConnections = counterIncrease(diagnostics.totalFailedConnections, baseline.totalFailedConnections);
|
||||
const closedConnections = counterIncrease(diagnostics.totalClosedConnections, baseline.totalClosedConnections);
|
||||
|
||||
let outcome: P2PAdditionalCheckOutcome = "waiting";
|
||||
if (successfulConnections > 0 && activeConnections > 0 && newActiveConnectionIds.length > 0) {
|
||||
outcome = "connected";
|
||||
} else if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) {
|
||||
outcome = "inconclusive";
|
||||
} else if (
|
||||
newConnections > 0 ||
|
||||
successfulConnections > 0 ||
|
||||
failedConnections > 0 ||
|
||||
closedConnections > 0 ||
|
||||
newActiveConnectionIds.length > 0
|
||||
) {
|
||||
outcome = "negotiating";
|
||||
}
|
||||
|
||||
return {
|
||||
activeConnections,
|
||||
closedConnections,
|
||||
failedConnections,
|
||||
newConnections,
|
||||
newActiveConnectionIds,
|
||||
outcome,
|
||||
successfulConnections,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -1,111 +1,109 @@
|
||||
<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>
|
||||
{:catch error}
|
||||
<p>{error.message}</p>
|
||||
{/await}
|
||||
</div>
|
||||
<div class="log">
|
||||
<div class="status">
|
||||
{statusLine}
|
||||
</div>
|
||||
<div class="logslist" bind:this={elP}>
|
||||
{#each $logs as log}
|
||||
<p>{log}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<svelte:head>
|
||||
<meta name="theme-color" content="#12233f" />
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
max-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
main {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@media (device-orientation: portrait) {
|
||||
main {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
padding: 1em;
|
||||
min-width: 50%;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.log {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
@media (device-orientation: portrait) {
|
||||
.log {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
.control {
|
||||
padding: 1em 1em;
|
||||
overflow-y: scroll;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.status {
|
||||
flex-grow: 0;
|
||||
/* max-height: 40px; */
|
||||
/* height: 40px; */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logslist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
/* padding: 1em; */
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
/* max-height: calc(100% - 40px); */
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
<main class="webpeer-shell">
|
||||
<header class="webpeer-header">
|
||||
<div class="brand-lockup">
|
||||
<span class="brand-mark" aria-hidden="true"><span></span></span>
|
||||
<span>
|
||||
<small>Self-hosted LiveSync</small>
|
||||
<strong>WebPeer</strong>
|
||||
<span class="brand-description">A browser-hosted peer for temporary P2P transfers.</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="connection-check-link"
|
||||
href="./check.html"
|
||||
aria-label="Try the P2P connection check"
|
||||
>
|
||||
<span class="check-mark" aria-hidden="true">◇</span>
|
||||
<span>
|
||||
<small>Disposable connectivity check</small>
|
||||
<strong>Try the P2P connection check</strong>
|
||||
</span>
|
||||
<span class="check-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="workspace-grid">
|
||||
<section class="workspace-card control-card" aria-label="WebPeer controls">
|
||||
<div class="panel-meta">
|
||||
<p>Browser peer controls</p>
|
||||
<span>Stored in this browser</span>
|
||||
</div>
|
||||
|
||||
{#await synchronised then activeRuntime}
|
||||
<P2PReplicatorPane host={activeRuntime.paneHost}></P2PReplicatorPane>
|
||||
<BrowserP2PTransportSettings host={activeRuntime.paneHost} />
|
||||
{:catch error}
|
||||
<div class="runtime-error" role="alert">
|
||||
<strong>WebPeer could not start</strong>
|
||||
<p>{error instanceof Error ? error.message : String(error)}</p>
|
||||
</div>
|
||||
{/await}
|
||||
</section>
|
||||
|
||||
<aside class="workspace-card log-card" aria-labelledby="activity-heading">
|
||||
<header class="log-header">
|
||||
<div>
|
||||
<p class="section-kicker">Live diagnostics</p>
|
||||
<h2 id="activity-heading">Activity log</h2>
|
||||
</div>
|
||||
<div class="status-pill" aria-live="polite">
|
||||
<span aria-hidden="true"></span>
|
||||
{statusLine || "Initialising"}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="logslist"
|
||||
bind:this={elP}
|
||||
role="log"
|
||||
aria-label="WebPeer activity log"
|
||||
>
|
||||
{#if $logs.length === 0}
|
||||
<p class="empty-log">Waiting for peer activity.</p>
|
||||
{:else}
|
||||
{#each $logs as log}
|
||||
<p class="log-entry">{log}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer class="webpeer-footer">
|
||||
WebPeer is experimental browser software. Keep this page open while it is connected or
|
||||
transferring changes.
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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>;
|
||||
deviceName?: string;
|
||||
systemVaultName?: string;
|
||||
}
|
||||
|
||||
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(private readonly options: WebPeerRuntimeOptions = {}) {
|
||||
const persistence = createWebPeerPersistence(options.store);
|
||||
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
||||
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
||||
context: this.context,
|
||||
getSystemVaultName: () => options.systemVaultName ?? 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 deviceName = this.options.deviceName?.trim();
|
||||
if (deviceName) {
|
||||
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
||||
}
|
||||
const opened = await this.services.database.openDatabase({
|
||||
replicator: this.services.replicator,
|
||||
databaseEvents: this.services.databaseEvents,
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
+492
-99
@@ -1,112 +1,505 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
--background-primary: #ffffff;
|
||||
--background-primary-alt: #e9e9e9;
|
||||
--size-4-1: 0.25em;
|
||||
--tag-background: #f0f0f0;
|
||||
--tag-border-width: 1px;
|
||||
--tag-border-color: #cfffdd;
|
||||
--background-modifier-success: #d4f3e9;
|
||||
--background-secondary: #f0f0f0;
|
||||
--background-modifier-error: #f8d7da;
|
||||
--background-modifier-error-hover: #f5c6cb;
|
||||
--interactive-accent: #007bff;
|
||||
--interactive-accent-hover: #0056b3;
|
||||
--text-normal: #333;
|
||||
--text-warning: #f0ad4e;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
.webpeer-shell {
|
||||
width: min(92rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 1.5rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
.webpeer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1rem 1.1rem 1rem 1.25rem;
|
||||
border: 1px solid rgba(204, 214, 227, 0.9);
|
||||
border-radius: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #1a1a1a;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.25s;
|
||||
.brand-lockup > span:last-child,
|
||||
.connection-check-link > span:nth-child(2) {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.brand-lockup small,
|
||||
.connection-check-link small,
|
||||
.section-kicker,
|
||||
.panel-meta p {
|
||||
color: var(--blue);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
.brand-lockup strong {
|
||||
color: var(--navy);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
.brand-description {
|
||||
margin-top: 0.18rem;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3.2rem;
|
||||
height: 3.2rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 1rem;
|
||||
background: var(--navy);
|
||||
box-shadow: 0 10px 24px rgba(18, 35, 63, 0.2);
|
||||
}
|
||||
|
||||
.brand-mark::before,
|
||||
.brand-mark::after,
|
||||
.brand-mark span {
|
||||
position: absolute;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.brand-mark::before {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.brand-mark::after {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-color: #8eb1ff;
|
||||
background: #8eb1ff;
|
||||
}
|
||||
|
||||
.brand-mark span {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-width: 1px;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.connection-check-link {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
max-width: 25rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
color: #ffffff;
|
||||
border-radius: 0.95rem;
|
||||
background: var(--navy);
|
||||
box-shadow: 0 10px 26px rgba(18, 35, 63, 0.16);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
transform 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.connection-check-link:hover {
|
||||
color: #ffffff;
|
||||
background: #1d385f;
|
||||
box-shadow: 0 13px 30px rgba(18, 35, 63, 0.22);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.connection-check-link small {
|
||||
color: #8eb1ff;
|
||||
}
|
||||
|
||||
.connection-check-link strong {
|
||||
margin-top: 0.12rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 0.7rem;
|
||||
color: #b8ceff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.check-arrow {
|
||||
color: #b8ceff;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(22rem, 0.8fr);
|
||||
align-items: start;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(204, 214, 227, 0.9);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.control-card {
|
||||
padding: clamp(1.35rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 0.85rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-meta p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-meta span {
|
||||
padding: 0.35rem 0.55rem;
|
||||
color: #48627f;
|
||||
border: 1px solid #cfdae7;
|
||||
border-radius: 999px;
|
||||
background: #f5f8fb;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.control-card article {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.control-card article > h1 {
|
||||
margin: 0 0 1.15rem;
|
||||
color: var(--navy);
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.control-card h2 {
|
||||
margin: 1.45rem 0 0.75rem;
|
||||
padding-bottom: 0.45rem;
|
||||
color: var(--navy);
|
||||
border-bottom-color: var(--line);
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.control-card p {
|
||||
color: var(--muted);
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.control-card .panel-meta p {
|
||||
color: var(--blue);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.control-card details {
|
||||
margin: 0.8rem 0 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.control-card details[open] {
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.control-card summary {
|
||||
padding: 0.12rem 0;
|
||||
}
|
||||
|
||||
.control-card details[open] > summary {
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.control-card table {
|
||||
width: 100%;
|
||||
border-spacing: 0 0.35rem;
|
||||
}
|
||||
|
||||
.control-card th,
|
||||
.control-card td {
|
||||
padding: 0.55rem 0.4rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.control-card th {
|
||||
width: 32%;
|
||||
color: #40536b;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.control-card td {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.control-card input:not([type="checkbox"]):not([type="radio"]) {
|
||||
min-width: min(100%, 18rem);
|
||||
max-width: 100%;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.control-card input[type="checkbox"] {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
.control-card label.is-dirty {
|
||||
border-radius: 0.55rem;
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.control-card .important {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.control-card .important-sub {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.control-card .browser-p2p-transport-settings {
|
||||
margin-top: 1.3rem;
|
||||
padding-top: 0.3rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.control-card .browser-p2p-transport-settings label {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.55rem;
|
||||
}
|
||||
|
||||
.control-card .browser-p2p-transport-settings span {
|
||||
color: #40536b;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-error {
|
||||
padding: 1rem;
|
||||
color: var(--red);
|
||||
border: 1px solid #efb1b1;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.runtime-error p {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
position: sticky;
|
||||
top: 1.25rem;
|
||||
overflow: hidden;
|
||||
background: var(--navy);
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
margin: 0 0 0.25rem;
|
||||
color: #8eb1ff;
|
||||
}
|
||||
|
||||
.log-header h2 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
max-width: 55%;
|
||||
padding: 0.42rem 0.62rem;
|
||||
overflow: hidden;
|
||||
color: #dbe6f5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pill > span {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #8eb1ff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(142, 177, 255, 0.13);
|
||||
}
|
||||
|
||||
.logslist {
|
||||
min-height: 34rem;
|
||||
max-height: calc(100vh - 10rem);
|
||||
padding: 1rem 1.1rem 1.2rem;
|
||||
overflow: auto;
|
||||
color: #cad7e8;
|
||||
background:
|
||||
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
|
||||
var(--navy);
|
||||
background-size: 100% 1.8rem;
|
||||
scrollbar-color: #4e6280 transparent;
|
||||
}
|
||||
|
||||
.log-entry,
|
||||
.empty-log {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.8rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
color: #cad7e8;
|
||||
}
|
||||
|
||||
.empty-log {
|
||||
color: #8294ac;
|
||||
}
|
||||
|
||||
.webpeer-footer {
|
||||
max-width: 48rem;
|
||||
margin: 1.3rem auto 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.logslist {
|
||||
min-height: 18rem;
|
||||
max-height: 28rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.webpeer-shell {
|
||||
width: min(100% - 1rem, 46rem);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.webpeer-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.connection-check-link {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.control-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.control-card table.settings,
|
||||
.control-card table.settings tbody,
|
||||
.control-card table.settings tr,
|
||||
.control-card table.settings th,
|
||||
.control-card table.settings td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.control-card table.settings tr {
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.control-card table.settings th,
|
||||
.control-card table.settings td {
|
||||
min-height: 0;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.control-card table.peers {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.brand-description {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.panel-meta span {
|
||||
border-radius: 0.55rem;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
.setup-card h2:focus-visible {
|
||||
outline: 3px solid rgba(42, 102, 220, 0.38);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.check-shell {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 4.5rem 0 3rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 51rem;
|
||||
margin: 0 auto 2.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-kicker {
|
||||
color: var(--blue);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.eyebrow:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.8rem 0 1rem;
|
||||
color: var(--navy);
|
||||
font-size: clamp(2.45rem, 7vw, 4.5rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 43rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
|
||||
}
|
||||
|
||||
.scope-note {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e8d8a7;
|
||||
border-radius: 999px;
|
||||
color: #765119;
|
||||
background: #fff9e8;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.step-card,
|
||||
.next-step {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 1.35rem;
|
||||
margin-top: 1.25rem;
|
||||
padding: clamp(1.35rem, 4vw, 2.4rem);
|
||||
border: 1px solid rgba(204, 214, 227, 0.9);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
background: var(--navy);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0.65rem;
|
||||
color: var(--navy);
|
||||
font-size: clamp(1.35rem, 3vw, 1.85rem);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.section-copy,
|
||||
.field-help,
|
||||
.privacy-line,
|
||||
.counter-note,
|
||||
.next-step p,
|
||||
footer {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
max-width: 51rem;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
.target-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin: 0 0 1.2rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.target-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-height: 5.3rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
background: #fbfcfe;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
background 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.target-option:hover {
|
||||
border-color: #9cb7eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.target-option.chosen {
|
||||
border-color: var(--blue);
|
||||
background: var(--blue-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--blue);
|
||||
}
|
||||
|
||||
.target-option input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--blue);
|
||||
}
|
||||
|
||||
.target-option strong,
|
||||
.target-option small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.target-option small {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.target-icon {
|
||||
color: var(--blue);
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.target-icon.mobile {
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action,
|
||||
.credential-panel button {
|
||||
border: 1px solid transparent;
|
||||
padding: 0.72rem 1rem;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
color: #ffffff;
|
||||
background: var(--blue);
|
||||
box-shadow: 0 8px 20px rgba(42, 102, 220, 0.18);
|
||||
}
|
||||
|
||||
.primary-action:hover:not(:disabled) {
|
||||
background: var(--blue-dark);
|
||||
}
|
||||
|
||||
.privacy-line {
|
||||
margin: 0.8rem 0 0;
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
margin: 0.8rem 0 0;
|
||||
color: var(--red);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.28fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.qr-panel,
|
||||
.credential-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.qr-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.qr-panel img {
|
||||
display: block;
|
||||
width: min(100%, 31rem);
|
||||
height: auto;
|
||||
border: 0.7rem solid #ffffff;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.qr-panel p {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.credential-panel {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.credential-panel label {
|
||||
display: block;
|
||||
margin: 0 0 0.45rem;
|
||||
color: var(--navy);
|
||||
font-size: 0.83rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.credential-panel label:not(:first-child) {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.credential-panel input,
|
||||
.credential-panel textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #bdc9d8;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
color: #213048;
|
||||
background: #ffffff;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.credential-panel input {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.credential-panel textarea {
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.copy-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.credential-panel button,
|
||||
.secondary-action {
|
||||
color: #26405e;
|
||||
border-color: #bdc9d8;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.credential-panel button:hover,
|
||||
.secondary-action:hover:not(:disabled) {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue-dark);
|
||||
}
|
||||
|
||||
.button-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.68rem 1rem;
|
||||
color: #ffffff;
|
||||
background: var(--navy);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button-link:hover {
|
||||
background: #1d385f;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reuse-note {
|
||||
margin: -0.35rem 0 1.2rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border: 1px solid #acc1ec;
|
||||
border-radius: 0.8rem;
|
||||
color: #34547c;
|
||||
background: var(--blue-soft);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.session-details {
|
||||
display: grid;
|
||||
grid-template-columns: 0.8fr 1fr 1.5fr;
|
||||
gap: 0.7rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.session-details div {
|
||||
min-width: 0;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 0.7rem;
|
||||
background: #f2f5f9;
|
||||
}
|
||||
|
||||
.session-details dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-details dd {
|
||||
margin: 0.25rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--navy);
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.monitor-action {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.outcome {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.outcome.success {
|
||||
border-color: #9ad6c2;
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.outcome.warning {
|
||||
border-color: #e7cd83;
|
||||
background: var(--amber-soft);
|
||||
}
|
||||
|
||||
.outcome.error {
|
||||
border-color: #efb1b1;
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.outcome.progress {
|
||||
border-color: #acc1ec;
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.outcome-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
background: #7b8796;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.outcome.success .outcome-mark {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.outcome.warning .outcome-mark {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
.outcome.error .outcome-mark {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.outcome.progress .outcome-mark {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.outcome h3 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.outcome p,
|
||||
.outcome small,
|
||||
.outcome code {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #4f6074;
|
||||
}
|
||||
|
||||
.outcome small {
|
||||
margin-top: 0.45rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.outcome code {
|
||||
margin-top: 0.45rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.metrics article {
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.metrics span,
|
||||
.metrics small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.metrics span {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metrics strong {
|
||||
display: block;
|
||||
margin: 0.2rem 0;
|
||||
color: var(--navy);
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metrics .successful strong {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.metrics .failed strong {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.counter-note {
|
||||
margin: 0.7rem 0 1rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.additional-outcome {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.additional-outcome .section-kicker {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.additional-caveat {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.next-step {
|
||||
display: block;
|
||||
margin-top: 1.25rem;
|
||||
color: #ecf2fb;
|
||||
border-color: transparent;
|
||||
background: var(--navy);
|
||||
}
|
||||
|
||||
.next-step h2,
|
||||
.next-step h3 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.next-step .section-kicker {
|
||||
color: #87adff;
|
||||
}
|
||||
|
||||
.next-step p {
|
||||
color: #c5d0df;
|
||||
}
|
||||
|
||||
.next-step-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.next-step-grid h3 {
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.next-step-grid p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
max-width: 48rem;
|
||||
margin: 1.6rem auto 0;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.check-shell {
|
||||
width: min(100% - 1rem, 46rem);
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.scope-note {
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.target-picker,
|
||||
.setup-grid,
|
||||
.next-step-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.copy-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.copy-row button,
|
||||
.button-row > *,
|
||||
.session-actions > *,
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { mount } from "svelte";
|
||||
|
||||
import P2PCheck from "./P2PCheck.svelte";
|
||||
import "./theme.css";
|
||||
import "./check.css";
|
||||
|
||||
const app = mount(P2PCheck, {
|
||||
target: _activeDocument.getElementById("app")!,
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from "svelte";
|
||||
import "./theme.css";
|
||||
import "./app.css";
|
||||
import App from "./App.svelte";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
color: #17253b;
|
||||
background: #f3f6fa;
|
||||
line-height: 1.5;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--ink: #17253b;
|
||||
--muted: #5d6c80;
|
||||
--line: #d8e0ea;
|
||||
--navy: #12233f;
|
||||
--blue: #2a66dc;
|
||||
--blue-dark: #1e4fae;
|
||||
--blue-soft: #eaf1ff;
|
||||
--green: #14795c;
|
||||
--green-soft: #e5f6ef;
|
||||
--amber: #9c6510;
|
||||
--amber-soft: #fff4d6;
|
||||
--red: #a33a3a;
|
||||
--red-soft: #ffeded;
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f8fafc;
|
||||
--shadow: 0 18px 55px rgba(24, 42, 72, 0.08);
|
||||
|
||||
/* Browser-hosted aliases required by the shared Obsidian P2P controls. */
|
||||
--background-primary: var(--surface);
|
||||
--background-primary-alt: #edf1f6;
|
||||
--background-secondary: #f2f5f9;
|
||||
--background-modifier-border: var(--line);
|
||||
--background-modifier-success: var(--green-soft);
|
||||
--background-modifier-error: var(--red-soft);
|
||||
--background-modifier-error-hover: #f8dada;
|
||||
--interactive-accent: var(--blue);
|
||||
--interactive-accent-hover: var(--blue-dark);
|
||||
--text-normal: var(--ink);
|
||||
--text-muted: var(--muted);
|
||||
--text-error: var(--red);
|
||||
--text-warning: var(--amber);
|
||||
--input-height: 2.75rem;
|
||||
--size-4-1: 0.25rem;
|
||||
--tag-background: #f0f3f7;
|
||||
--tag-border-width: 1px;
|
||||
--tag-border-color: var(--line);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 12% 8%, rgba(66, 117, 222, 0.11), transparent 26rem),
|
||||
#f3f6fa;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.button-link {
|
||||
min-height: 2.75rem;
|
||||
border-radius: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #bdc9d8;
|
||||
padding: 0.68rem 1rem;
|
||||
color: #26405e;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
background 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue-dark);
|
||||
}
|
||||
|
||||
button.mod-cta {
|
||||
color: #ffffff;
|
||||
border-color: var(--blue);
|
||||
background: var(--blue);
|
||||
box-shadow: 0 8px 20px rgba(42, 102, 220, 0.18);
|
||||
}
|
||||
|
||||
button.mod-cta:hover:not(:disabled) {
|
||||
color: #ffffff;
|
||||
border-color: var(--blue-dark);
|
||||
background: var(--blue-dark);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
summary:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid rgba(42, 102, 220, 0.38);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]),
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid #bdc9d8;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.68rem 0.8rem;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
accent-color: var(--blue);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
summary {
|
||||
color: var(--navy);
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: var(--navy);
|
||||
background: #cddcff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#app *,
|
||||
#app *::before,
|
||||
#app *::after {
|
||||
scroll-behavior: auto;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: "index.html",
|
||||
check: "check.html",
|
||||
// uitest: "uitest.html",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
export const liveSyncProvisionalEnglishMessages = {
|
||||
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.":
|
||||
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
|
||||
"Setup Complete: Preparing to Fetch from Another Device":
|
||||
"Setup Complete: Preparing to Fetch from Another Device",
|
||||
"Setup Complete: Preparing to Fetch from Another Device": "Setup Complete: Preparing to Fetch from Another Device",
|
||||
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
|
||||
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
|
||||
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.":
|
||||
@@ -64,6 +63,86 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"This file has unresolved conflicts.": "This file has unresolved conflicts.",
|
||||
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.":
|
||||
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.",
|
||||
"Sync now": "Sync now",
|
||||
"Apply pending changes now": "Apply pending changes now",
|
||||
"Copy database information for the active file": "Copy database information for the active file",
|
||||
"Copy database information for a file": "Copy database information for a file",
|
||||
"Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents.":
|
||||
"Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents.",
|
||||
"Choose file": "Choose file",
|
||||
"Choose a file to inspect": "Choose a file to inspect",
|
||||
"Database information for ${FILE}": "Database information for ${FILE}",
|
||||
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.":
|
||||
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.",
|
||||
"📁 Vault: ${SIZE} B · ${TIME}": "📁 Vault: ${SIZE} B · ${TIME}",
|
||||
"📁 Vault: missing": "📁 Vault: missing",
|
||||
"🗄️ Local DB: missing": "🗄️ Local DB: missing",
|
||||
"Vault and database revision": "Vault and database revision",
|
||||
"Vault file": "Vault file",
|
||||
"Database revision": "Database revision",
|
||||
"Vault file is newer": "Vault file is newer",
|
||||
"Database revision is newer": "Database revision is newer",
|
||||
"Within the two-second comparison window": "Within the two-second comparison window",
|
||||
"Timestamp comparison unavailable": "Timestamp comparison unavailable",
|
||||
"${ROLE}: ${REVISION}": "${ROLE}: ${REVISION}",
|
||||
"Winner revision": "Winner revision",
|
||||
"Conflict revision": "Conflict revision",
|
||||
"Unknown revision": "Unknown revision",
|
||||
"🗑️ Logical deletion": "🗑️ Logical deletion",
|
||||
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}":
|
||||
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}",
|
||||
"🧩 Missing chunks: ${COUNT}": "🧩 Missing chunks: ${COUNT}",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B":
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable": "📦 DB: recorded ${RECORDED} B · decoded unavailable",
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B": "📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})":
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
|
||||
"✅ Matches Vault": "✅ Matches Vault",
|
||||
"⚠️ Differs from Vault": "⚠️ Differs from Vault",
|
||||
"✅ Vault matches winner": "✅ Vault matches winner",
|
||||
"⚠️ Conflicts: ${COUNT}": "⚠️ Conflicts: ${COUNT}",
|
||||
"Compare with Vault": "Compare with Vault",
|
||||
"Apply this revision to Vault": "Apply this revision to Vault",
|
||||
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.":
|
||||
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.",
|
||||
"Apply database revision to Vault": "Apply database revision to Vault",
|
||||
"Mark this revision as the Vault version": "Mark this revision as the Vault version",
|
||||
"Store Vault file as a child of this revision": "Store Vault file as a child of this revision",
|
||||
"Apply logical deletion to Vault": "Apply logical deletion to Vault",
|
||||
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.":
|
||||
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.",
|
||||
"Retry reading revision": "Retry reading revision",
|
||||
"Discard this branch": "Discard this branch",
|
||||
"Discard branch": "Discard branch",
|
||||
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.":
|
||||
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.",
|
||||
"Discard unreadable revision": "Discard unreadable revision",
|
||||
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.":
|
||||
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.",
|
||||
"Revision metadata is unavailable on this device": "Revision metadata is unavailable on this device",
|
||||
"Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.":
|
||||
"Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.",
|
||||
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.":
|
||||
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.",
|
||||
"More actions for revision ${REVISION}": "More actions for revision ${REVISION}",
|
||||
"More actions for ${FILE}": "More actions for ${FILE}",
|
||||
"Show revision history": "Show revision history",
|
||||
"Store Vault file as a new local database document": "Store Vault file as a new local database document",
|
||||
"Copy database information": "Copy database information",
|
||||
"Recreate chunks for current Vault files": "Recreate chunks for current Vault files",
|
||||
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.":
|
||||
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.",
|
||||
"Recreate current chunks": "Recreate current chunks",
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.":
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
|
||||
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
|
||||
"Inspect conflicts and file/database differences": "Inspect conflicts and file/database differences",
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
|
||||
"Begin inspection": "Begin inspection",
|
||||
"Connection settings": "Connection settings",
|
||||
"Saved connections": "Saved connections",
|
||||
} as const;
|
||||
|
||||
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user