diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index e96c4c62..26997197 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -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,8 +72,13 @@ 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: Configure GitHub Pages diff --git a/devs.md b/devs.md index a6acd9c6..c072f72d 100644 --- a/devs.md +++ b/devs.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 98b450ac..7632eed4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,10 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", + "@vrtmrz/browser-ui-kit": "0.1.0-rc.0", "@vrtmrz/livesync-commonlib": "0.1.0", - "@vrtmrz/obsidian-plugin-kit": "0.1.2", + "@vrtmrz/obsidian-plugin-kit": "0.1.3", + "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", "idb": "^8.0.3", @@ -4763,6 +4765,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vrtmrz/browser-ui-kit": { + "version": "0.1.0-rc.0", + "resolved": "https://registry.npmjs.org/@vrtmrz/browser-ui-kit/-/browser-ui-kit-0.1.0-rc.0.tgz", + "integrity": "sha512-ciwunYvOmre0qwiBYLQBRArfbD4emlOaTsri9mCUJ12p8uF8yquDGH153tBjxtr176VzLJsueNdopsQKMaHfhA==", + "license": "MIT", + "dependencies": { + "@vrtmrz/ui-interactions": "0.1.2" + } + }, "node_modules/@vrtmrz/livesync-commonlib": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0.tgz", @@ -4827,12 +4838,12 @@ } }, "node_modules/@vrtmrz/obsidian-plugin-kit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.2.tgz", - "integrity": "sha512-LNNV8QCaN6FvRA+of96GiqI8atAbnMKQvLbqpi3nsepEe6tN8SmXO3P7pqBl3axNXEHhQfU5ggj5HAq9GJPgwQ==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.3.tgz", + "integrity": "sha512-6fsKdhFZtBv6FXlZHtSmpqwROohFzDmres6q08nr2xYGVeh2ooBGU3zJS94WN/tOjDT+wa/Vr3yE42wmI0pIZA==", "license": "MIT", "dependencies": { - "@vrtmrz/ui-interactions": "0.1.1" + "@vrtmrz/ui-interactions": "0.1.2" }, "peerDependencies": { "obsidian": ">=1.8.7" @@ -4853,9 +4864,9 @@ } }, "node_modules/@vrtmrz/ui-interactions": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@vrtmrz/ui-interactions/-/ui-interactions-0.1.1.tgz", - "integrity": "sha512-XpO5fQzC7jyOW3xVF7YtFHI7X1BPQ7hJW56uw8atx8mDR7C9ejG2YSn0XcZ1H5FOCPgJbmkh/FLQRKLqSK8jkw==", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@vrtmrz/ui-interactions/-/ui-interactions-0.1.2.tgz", + "integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==", "license": "MIT" }, "node_modules/@wdio/config": { diff --git a/package.json b/package.json index 3d66a4b5..db9e7bbd 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,11 @@ "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: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", @@ -166,8 +171,10 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", + "@vrtmrz/browser-ui-kit": "0.1.0-rc.0", "@vrtmrz/livesync-commonlib": "0.1.0", - "@vrtmrz/obsidian-plugin-kit": "0.1.2", + "@vrtmrz/obsidian-plugin-kit": "0.1.3", + "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", "idb": "^8.0.3", diff --git a/src/apps/browser/BrowserConfirm.ts b/src/apps/browser/BrowserConfirm.ts index 3963cb2d..a7cbc5a7 100644 --- a/src/apps/browser/BrowserConfirm.ts +++ b/src/apps/browser/BrowserConfirm.ts @@ -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( - message: string, - buttons: U, - title: string, - commit: (ret: U[number]) => T, - actionLayout: ConfirmActionLayout = "vertical" -): Promise { - const el = createNativeElement(_activeDocument, "div"); - const p = promiseWithResolvers(); - 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 { - const el = createNativeElement(_activeDocument, "div"); - const p = promiseWithResolvers(); - 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 extends UiInteractionsConfirm { + readonly context: T; -export class BrowserConfirm 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 { - 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 { - return displayMessageBox(message, [...items] as const, "Confirm", (action) => action); - } - askSelectStringDialogue( - message: string, - buttons: T, - opt: { title?: string; defaultAction: T[number]; timeout?: number } - ): Promise { - 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(); + }); } } diff --git a/src/apps/browser/BrowserKeyValueDatabase.ts b/src/apps/browser/BrowserKeyValueDatabase.ts new file mode 100644 index 00000000..f1f17a1f --- /dev/null +++ b/src/apps/browser/BrowserKeyValueDatabase.ts @@ -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(); + + 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>; + private destroyed = false; + private destroyedPromise?: Promise; + + constructor(private readonly databaseKey: string) {} + + get isDestroyed(): boolean { + return this.destroyed; + } + + get ensuredDestroyed(): Promise { + return this.destroyedPromise ?? Promise.resolve(); + } + + async getIsReady(): Promise { + await this.ensureDatabase(); + return !this.destroyed; + } + + private ensureDatabase(): Promise> { + 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> { + return this.ensureDatabase(); + } + + private async closeDatabase(): Promise { + const databasePromise = this.databasePromise; + this.databasePromise = undefined; + if (databasePromise) { + (await databasePromise).close(); + } + } + + async get(key: IDBValidKey): Promise { + return (await (await this.database).get(this.databaseKey, key)) as T; + } + + async set(key: IDBValidKey, value: T): Promise { + await (await this.database).put(this.databaseKey, value, key); + return key; + } + + async del(key: IDBValidKey): Promise { + await (await this.database).delete(this.databaseKey, key); + } + + async clear(): Promise { + await (await this.database).clear(this.databaseKey); + } + + async keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise { + return await (await this.database).getAllKeys(this.databaseKey, query, count); + } + + async close(): Promise { + await this.closeDatabase(); + } + + async destroy(): Promise { + if (this.destroyedPromise) { + await this.destroyedPromise; + return; + } + this.destroyed = true; + this.destroyedPromise = (async () => { + await this.closeDatabase(); + await deleteDB(this.databaseKey); + })(); + await this.destroyedPromise; + } +} diff --git a/src/apps/browser/BrowserP2PTransportSettings.svelte b/src/apps/browser/BrowserP2PTransportSettings.svelte new file mode 100644 index 00000000..eed3bc52 --- /dev/null +++ b/src/apps/browser/BrowserP2PTransportSettings.svelte @@ -0,0 +1,133 @@ + + +
+
+ Optional TURN server settings +

+ Configure TURN only when a direct peer-to-peer connection cannot be established. +

+ + + +
+ + +
+
+
+ + diff --git a/src/apps/browser/BrowserSvelteDialogManager.ts b/src/apps/browser/BrowserSvelteDialogManager.ts index ebb10ed0..b0e89a0d 100644 --- a/src/apps/browser/BrowserSvelteDialogManager.ts +++ b/src/apps/browser/BrowserSvelteDialogManager.ts @@ -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 extends BrowserModal { + private readonly session: SvelteDialogSession; -export class LiveSyncBrowserDialog extends BrowserSvelteDialogBase< - T, - U, - C -> { constructor( context: C, dependents: SvelteDialogManagerDependencies, @@ -65,7 +60,26 @@ export class LiveSyncBrowserDialog { + return this.session.waitForClose(); } } export class BrowserSvelteDialogManager extends SvelteDialogManagerBase { diff --git a/src/apps/browser/LiveSyncBrowserAPIService.ts b/src/apps/browser/LiveSyncBrowserAPIService.ts new file mode 100644 index 00000000..4218772f --- /dev/null +++ b/src/apps/browser/LiveSyncBrowserAPIService.ts @@ -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?: (command: TCommand) => TCommand; + showWindow?: (type: string) => Promise; + registerWindow?: (type: string, factory: (leaf: T) => unknown) => void; + addRibbonIcon?: (icon: string, title: string, callback: (event: MouseEvent) => unknown) => HTMLElement; + registerProtocolHandler?: (action: string, handler: (params: Record) => unknown) => void; + addStatusBarItem?: () => HTMLElement | undefined; +} + +/** Browser application implementation of Commonlib's injected host API contract. */ +export class LiveSyncBrowserAPIService extends InjectableAPIService { + 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 { + 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(command: TCommand): TCommand { + return this.options.addCommand?.(command) ?? command; + } + + registerWindow(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) => unknown + ): void { + this.options.registerProtocolHandler?.(action, handler); + } + + override nativeFetch(request: string | Request, options?: RequestInit): Promise { + const fetchImplementation = this.options.fetch ?? _fetch; + return fetchImplementation(request, options); + } + + addStatusBarItem(): HTMLElement | undefined { + return this.options.addStatusBarItem?.(); + } +} diff --git a/src/apps/browser/LiveSyncBrowserUIService.ts b/src/apps/browser/LiveSyncBrowserUIService.ts index 79db6d23..ea23cf5a 100644 --- a/src/apps/browser/LiveSyncBrowserUIService.ts +++ b/src/apps/browser/LiveSyncBrowserUIService.ts @@ -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 { + API: InjectableAPIService; + appLifecycle: AppLifecycleService; + config: ConfigService; + control: ControlService; + replicator: ReplicatorService; +} + export class LiveSyncBrowserUIService extends UIService { override get dialogToCopy() { return DialogToCopy; } - constructor(context: T, dependents: BrowserServiceHostDependencies) { + constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies) { const browserConfirm = dependents.API.confirm; const obsidianSvelteDialogManager = new BrowserSvelteDialogManager(context, { appLifecycle: dependents.appLifecycle, diff --git a/src/apps/browser/UiInteractionsConfirm.ts b/src/apps/browser/UiInteractionsConfirm.ts new file mode 100644 index 00000000..f9043f79 --- /dev/null +++ b/src/apps/browser/UiInteractionsConfirm.ts @@ -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 { + 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 { + const result = await this.options.ui.pickOne( + { + items, + getText: (item) => item, + placeholder: message, + }, + "legacy-confirm.ask-select-string" + ); + return result ?? ""; + } + + async askSelectStringDialogue( + message: string, + buttons: T, + opt: { title?: string; defaultAction: T[number]; timeout?: number } + ): Promise { + 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; + } +} diff --git a/src/apps/browser/createLiveSyncBrowserServiceHub.ts b/src/apps/browser/createLiveSyncBrowserServiceHub.ts index f7cd13b0..76c671c0 100644 --- a/src/apps/browser/createLiveSyncBrowserServiceHub.ts +++ b/src/apps/browser/createLiveSyncBrowserServiceHub.ts @@ -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 = { +export interface LiveSyncBrowserSettingsPersistence { + load(): Promise; + save(settings: ObsidianLiveSyncSettings): Promise; +} + +export interface LiveSyncBrowserRestartPolicy { + schedule(): void; + perform?: () => void; + ask?: (message?: string) => void; + isScheduled?: () => boolean; +} + +export interface LiveSyncBrowserServiceHubOptions { context?: T; + getSystemVaultName?: () => string; + settings?: LiveSyncBrowserSettingsPersistence; + restart?: LiveSyncBrowserRestartPolicy; openKeyValueDatabase?: KeyValueDatabaseFactory; -}; + API?: Omit; +} -function createLiveSyncBrowserHost(): BrowserServiceHost { - 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 {} + +class LiveSyncBrowserDatabaseService extends DatabaseService {} + +class LiveSyncBrowserKeyValueDBService extends KeyValueDBService {} + +class LiveSyncBrowserConfigService extends ConfigService { + 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 extends InjectableServiceHub { + constructor(options: LiveSyncBrowserServiceHubOptions) { + 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( options: LiveSyncBrowserServiceHubOptions = {} -): BrowserServiceHub { - const context = options.context ?? (new ServiceContext({ translate: translateLiveSyncMessage }) as T); - return new BrowserServiceHub({ - ...options, - context, - onDisplayLanguageChanged: setLang, - host: createLiveSyncBrowserHost(), - }); +): LiveSyncBrowserServiceHub { + return new LiveSyncBrowserServiceHub(options); } diff --git a/src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts b/src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts index 1e0c3dbe..06f90938 100644 --- a/src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts +++ b/src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts @@ -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(); + 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> = { + 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"]); + }); }); diff --git a/src/apps/browser/ui/MessageBox.svelte b/src/apps/browser/ui/MessageBox.svelte deleted file mode 100644 index 64fd066b..00000000 --- a/src/apps/browser/ui/MessageBox.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - - -
{title}
-
{@html renderedMessage}
-
- {#each buttons as button} - - {/each} -
-
-
commit("")} onkeydown={handleEsc} role="none">
- - diff --git a/src/apps/browser/ui/TextInputBox.svelte b/src/apps/browser/ui/TextInputBox.svelte deleted file mode 100644 index 8066da4b..00000000 --- a/src/apps/browser/ui/TextInputBox.svelte +++ /dev/null @@ -1,126 +0,0 @@ - - - -
{title}
-
-
{message}
-
- -
-
- -
- - -
-
-
- - diff --git a/src/apps/browser/ui/renderMessageMarkdown.ts b/src/apps/browser/ui/renderMessageMarkdown.ts index 8e937561..256aff7b 100644 --- a/src/apps/browser/ui/renderMessageMarkdown.ts +++ b/src/apps/browser/ui/renderMessageMarkdown.ts @@ -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)); +} diff --git a/src/apps/browserDom.ts b/src/apps/browserDom.ts index 5b300442..1b0487b3 100644 --- a/src/apps/browserDom.ts +++ b/src/apps/browserDom.ts @@ -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; diff --git a/src/apps/webapp/README.md b/src/apps/webapp/README.md index 28afbf1e..beead82a 100644 --- a/src/apps/webapp/README.md +++ b/src/apps/webapp/README.md @@ -1,198 +1,48 @@ -# 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) +## Use -## Requirements +WebApp requires a browser which provides the File System Access API and IndexedDB. Serve the built files over HTTPS, or from `localhost`, then open `webapp.html`. -- **FileSystem API support**: - - Chrome/Edge 86+ (required) - - Opera 72+ (required) - - Safari 15.2+ (experimental, limited support) - - Firefox: Not supported yet +1. Select the local Vault root. +2. Create or edit `.livesync/settings.json` in that Vault. +3. Reload WebApp to apply the settings. -- **FileSystemObserver support** (optional, for real-time file watching): - - Chrome 124+ (recommended) - - Without this, files are only scanned on startup - -## Getting Started - -### Installation - -```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 -``` - -### Development - -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 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 visible P2P controls are optional. Saving them does not select P2P as the main remote or mark an otherwise unconfigured Vault as configured. -## Architecture +CouchDB must permit access from the WebApp origin. Other remote types, browser background behaviour, and browsers without the required file-system APIs remain experimental. -### Directory Structure +## Development -``` -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 +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 ``` -### Key Components +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. -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 +`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. -### Service Hub +## Licence -Uses `BrowserServiceHub` which provides: - -- Database service (IndexedDB via PouchDB) -- Settings service (file-based in `.livesync/settings.json`) -- Replication service -- File processing service -- And more... - -## 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 - -## Troubleshooting - -### "Failed to get directory access" - -- Make sure you're using a supported browser -- Try refreshing the page -- Clear browser cache and IndexedDB - -### "Settings not found" - -- Check that `.livesync/settings.json` exists in your vault directory -- Verify the JSON format is valid -- Create the file manually if needed - -### "File watching not working" - -- 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 - -### "Sync not working" - -- Verify CouchDB credentials -- Check browser console for errors -- Ensure CouchDB server is accessible (CORS enabled) - -## License - -Same as the main Self-hosted LiveSync project. +The same licence as the main Self-hosted LiveSync project applies. diff --git a/src/apps/webapp/WebAppP2P.svelte b/src/apps/webapp/WebAppP2P.svelte new file mode 100644 index 00000000..d36c67d3 --- /dev/null +++ b/src/apps/webapp/WebAppP2P.svelte @@ -0,0 +1,47 @@ + + +
+ + {scanStatus} +
+ + + + + diff --git a/src/apps/webapp/WebAppRuntime.ts b/src/apps/webapp/WebAppRuntime.ts new file mode 100644 index 00000000..a7a17168 --- /dev/null +++ b/src/apps/webapp/WebAppRuntime.ts @@ -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 = { + 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; + private readonly scheduleReload: NonNullable; + private core: LiveSyncBaseCore | null = null; + private serviceHub: LiveSyncBrowserServiceHub | 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 { + 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 { + 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 { + if (this.core) { + throw new Error("The WebApp runtime has already been started"); + } + + // Create service context and hub + this.serviceHub = createLiveSyncBrowserServiceHub(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( + 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 { + // 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 | 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 { + 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 { + 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); + } +} diff --git a/src/apps/webapp/bootstrap.ts b/src/apps/webapp/bootstrap.ts deleted file mode 100644 index a99aa4c7..00000000 --- a/src/apps/webapp/bootstrap.ts +++ /dev/null @@ -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(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("status"); - statusEl.className = kind; - statusEl.textContent = message; -} - -function setBusyState(isBusy: boolean): void { - const pickNewBtn = getRequiredElement("pick-new-vault"); - pickNewBtn.disabled = isBusy; - - const historyButtons = _activeDocument.querySelectorAll(".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 { - const listEl = getRequiredElement("vault-history-list"); - const emptyEl = getRequiredElement("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 { - setStatus("info", `Starting LiveSync with vault: ${handle.name}`); - app = new LiveSyncWebApp(handle); - await app.initialize(); - - const selectorEl = getRequiredElement("vault-selector"); - selectorEl.classList.add("is-hidden"); -} - -async function startWithHistory(item: VaultHistoryItem): Promise { - 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 { - 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 { - setStatus("info", "Select a vault folder to start LiveSync."); - - const pickNewBtn = getRequiredElement("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, -}; diff --git a/src/apps/webapp/main.ts b/src/apps/webapp/main.ts index a263df81..4c8c014e 100644 --- a/src/apps/webapp/main.ts +++ b/src/apps/webapp/main.ts @@ -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 = { - 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 | null = null; - private serviceHub: BrowserServiceHub | null = null; - private platformServiceModules: FSAPIServiceModules | null = null; +type LiveSyncWebAppGlobal = typeof compatGlobal & { + livesyncApp?: LiveSyncWebAppDebugApi; +}; - constructor(rootHandle: FileSystemDirectoryHandle) { - this.rootHandle = rootHandle; +function getRequiredElement(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("status"); + statusEl.className = kind; + statusEl.textContent = message; +} + +function setBusyState(isBusy: boolean): void { + const pickNewBtn = getRequiredElement("pick-new-vault"); + pickNewBtn.disabled = isBusy; + + const historyButtons = _activeDocument.querySelectorAll(".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(); - 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 { + const listEl = getRequiredElement("vault-history-list"); + const emptyEl = getRequiredElement("vault-history-empty"); - // Setup API service - (this.serviceHub.API as BrowserAPIService).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; + 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 => { - 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( - 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 { - // 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 { + setStatus("info", `Starting LiveSync with vault: ${handle.name}`); + const nextRuntime = new WebAppRuntime(handle, { reportStatus: setStatus }); + await nextRuntime.start(); + runtime = nextRuntime; + + const selectorEl = getRequiredElement("vault-selector"); + selectorEl.classList.add("is-hidden"); + + const p2pControl = getRequiredElement("p2p-control"); + mount(WebAppP2P, { + target: p2pControl, + props: { + runtime: nextRuntime, + }, + }); + p2pControl.classList.remove("is-hidden"); +} + +async function startWithHistory(item: VaultHistoryItem): Promise { + 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 | 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 { + 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 { + setStatus("info", "Select a vault folder to start LiveSync."); + + const pickNewBtn = getRequiredElement("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, +}; diff --git a/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts b/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts index 3584a966..bd23ea0d 100644 --- a/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts +++ b/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts @@ -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((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(); diff --git a/src/apps/webapp/obsidianMock.ts b/src/apps/webapp/obsidianMock.ts deleted file mode 100644 index 06b39421..00000000 --- a/src/apps/webapp/obsidianMock.ts +++ /dev/null @@ -1,1087 +0,0 @@ -/** - * Legacy Obsidian API compatibility implementation used only by the Webapp build. - * - * This file moved from the retired browser test Harness to make its current ownership explicit. It is not a faithful - * Obsidian mock, and must not become a general test environment for the plug-in. When the Webapp compatibility boundary - * is redesigned, replace this implementation here rather than extending it as a shared Obsidian simulation. - */ -import { createNativeElement, createNativeFragment } from "@/apps/browserDom"; -import type { - Command, - DataWriteOptions, - ListedFiles, - MarkdownFileInfo, - PluginManifest, - RequestUrlParam, - RequestUrlResponse, - ValueComponent, -} from "obsidian"; - -export type { - DataWriteOptions, - ListedFiles, - MarkdownFileInfo, - PluginManifest, - RequestUrlParam, - RequestUrlResponse, - ValueComponent, -}; - -type EventCallback = (...args: unknown[]) => unknown; - -declare global { - interface Window { - activeDocument: Document; - } -} - -export const SettingCache = new Map(); -window.activeDocument = document; - -declare const hostPlatform: string | undefined; - -Reflect.set(window, "process", { - platform: hostPlatform || "win32", -}); -export class TAbstractFile { - vault: Vault; - path: string; - name: string; - parent: TFolder | null; - - constructor(vault: Vault, path: string, name: string, parent: TFolder | null) { - this.vault = vault; - this.path = path; - this.name = name; - this.parent = parent; - } -} - -export class TFile extends TAbstractFile { - stat: { - ctime: number; - mtime: number; - size: number; - } = { ctime: Date.now(), mtime: Date.now(), size: 0 }; - - get extension(): string { - return this.name.split(".").pop() || ""; - } - - get basename(): string { - const parts = this.name.split("."); - if (parts.length > 1) parts.pop(); - return parts.join("."); - } -} - -export class TFolder extends TAbstractFile { - children: TAbstractFile[] = []; - - get isRoot(): boolean { - return this.path === "" || this.path === "/"; - } -} - -export class EventRef { - constructor( - readonly name: string, - readonly callback: EventCallback - ) {} -} - -// class StorageMap extends Map { -// constructor(saveName?: string) { -// super(); -// if (saveName) { -// this.saveName = saveName; -// void this.restore(saveName); -// } -// } -// private saveName: string = ""; -// async restore(saveName: string) { -// this.saveName = saveName; -// const db = await OpenKeyValueDatabase(saveName); -// const data = await db.get<{ [key: string]: U }>("data"); -// if (data) { -// for (const key of Object.keys(data)) { -// this.set(key as any as T, data[key]); -// } -// } -// db.close(); -// return this; -// } -// saving: boolean = false; -// async save() { -// if (this.saveName === "") { -// return; -// } -// if (this.saving) { -// return; -// } -// try { -// this.saving = true; - -// const db = await OpenKeyValueDatabase(this.saveName); -// const data: { [key: string]: U } = {}; -// for (const [key, value] of this.entries()) { -// data[key as any as string] = value; -// } -// await db.set("data", data); -// db.close(); -// } finally { -// this.saving = false; -// } -// } -// set(key: T, value: U): this { -// super.set(key, value); -// void this.save(); -// return this; -// } - -// } - -export class Vault { - adapter: DataAdapter; - vaultName: string = "MockVault"; - private files: Map = new Map(); - private contents: Map = new Map(); - private root: TFolder; - private listeners: Map> = new Map(); - - constructor(vaultName?: string) { - if (vaultName) { - this.vaultName = vaultName; - } - this.files = new Map(); - this.contents = new Map(); - this.adapter = new DataAdapter(this); - this.root = new TFolder(this, "", "", null); - this.files.set("", this.root); - } - - getAbstractFileByPath(path: string): TAbstractFile | null { - if (path === "/") path = ""; - const file = this.files.get(path); - return file || null; - } - getAbstractFileByPathInsensitive(path: string): TAbstractFile | null { - const lowerPath = path.toLowerCase(); - for (const [p, file] of this.files.entries()) { - if (p.toLowerCase() === lowerPath) { - return file; - } - } - return null; - } - - getFiles(): TFile[] { - return Array.from(this.files.values()).filter((f) => f instanceof TFile); - } - - async _adapterRead(path: string): Promise { - await Promise.resolve(); - const file = this.contents.get(path); - if (typeof file === "string") { - return file; - } - if (file instanceof ArrayBuffer) { - return new TextDecoder().decode(file); - } - return null; - } - - async _adapterReadBinary(path: string): Promise { - await Promise.resolve(); - const file = this.contents.get(path); - if (file instanceof ArrayBuffer) { - return file; - } - if (typeof file === "string") { - return new TextEncoder().encode(file).buffer; - } - return null; - } - - async read(file: TFile): Promise { - await Promise.resolve(); - const content = this.contents.get(file.path); - if (typeof content === "string") return content; - if (content instanceof ArrayBuffer) { - return new TextDecoder().decode(content); - } - return ""; - } - - async readBinary(file: TFile): Promise { - await Promise.resolve(); - const content = this.contents.get(file.path); - if (content instanceof ArrayBuffer) return content; - if (typeof content === "string") { - return new TextEncoder().encode(content).buffer; - } - return new ArrayBuffer(0); - } - - private async _create(path: string, data: string | ArrayBuffer, options?: DataWriteOptions): Promise { - if (this.files.has(path)) throw new Error("File already exists"); - const name = path.split("/").pop() || ""; - const parentPath = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ""; - const existingParent = this.getAbstractFileByPath(parentPath); - const parent = existingParent instanceof TFolder ? existingParent : await this.createFolder(parentPath); - - const file = new TFile(this, path, name, parent); - file.stat.size = typeof data === "string" ? new TextEncoder().encode(data).length : data.byteLength; - file.stat.ctime = options?.ctime ?? Date.now(); - file.stat.mtime = options?.mtime ?? Date.now(); - this.files.set(path, file); - this.contents.set(path, data); - parent.children.push(file); - - this.trigger("create", file); - return file; - } - async create(path: string, data: string, options?: DataWriteOptions): Promise { - return await this._create(path, data, options); - } - async createBinary(path: string, data: ArrayBuffer, options?: DataWriteOptions): Promise { - return await this._create(path, data, options); - } - - async _modify(file: TFile, data: string | ArrayBuffer, options?: DataWriteOptions): Promise { - await Promise.resolve(); - this.contents.set(file.path, data); - file.stat.mtime = options?.mtime ?? Date.now(); - file.stat.ctime = options?.ctime ?? file.stat.ctime ?? Date.now(); - file.stat.size = typeof data === "string" ? data.length : data.byteLength; - this.files.set(file.path, file); - this.trigger("modify", file); - } - async modify(file: TFile, data: string, options?: DataWriteOptions): Promise { - return await this._modify(file, data, options); - } - async modifyBinary(file: TFile, data: ArrayBuffer, options?: DataWriteOptions): Promise { - return await this._modify(file, data, options); - } - - async createFolder(path: string): Promise { - if (path === "") return this.root; - if (this.files.has(path)) { - const f = this.files.get(path); - if (f instanceof TFolder) return f; - throw new Error("Path is a file"); - } - const name = path.split("/").pop() || ""; - const parentPath = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ""; - const parent = await this.createFolder(parentPath); - const folder = new TFolder(this, path, name, parent); - this.files.set(path, folder); - parent.children.push(folder); - return folder; - } - - async delete(file: TAbstractFile, force?: boolean): Promise { - return this.removeFile(file); - } - - async trash(file: TAbstractFile, system: boolean): Promise { - return this.removeFile(file); - } - - private async removeFile(file: TAbstractFile): Promise { - await Promise.resolve(); - this.files.delete(file.path); - this.contents.delete(file.path); - if (file.parent) { - file.parent.children = file.parent.children.filter((c) => c !== file); - } - this.trigger("delete", file); - } - - async removeFromAdapter(file: TAbstractFile): Promise { - return this.removeFile(file); - } - - on(name: string, callback: EventCallback, ctx?: object): EventRef { - const listeners = this.listeners.get(name) ?? new Set(); - this.listeners.set(name, listeners); - const boundCallback = ctx ? (...args: unknown[]) => callback.apply(ctx, args) : callback; - listeners.add(boundCallback); - return new EventRef(name, boundCallback); - } - - off(name: string, callback: EventCallback) { - this.listeners.get(name)?.delete(callback); - } - - offref(ref: EventRef) { - this.off(ref.name, ref.callback); - } - - trigger(name: string, ...args: unknown[]) { - this.listeners.get(name)?.forEach((cb) => cb(...args)); - } - - getName(): string { - return this.vaultName; - } -} - -export class DataAdapter { - vault: Vault; - constructor(vault: Vault) { - this.vault = vault; - } - stat(path: string): Promise<{ ctime: number; mtime: number; size: number }> { - const file = this.vault.getAbstractFileByPath(path); - if (file && file instanceof TFile) { - return Promise.resolve({ - ctime: file.stat.ctime, - mtime: file.stat.mtime, - size: file.stat.size, - }); - } - return Promise.reject(new Error("File not found")); - } - async list(path: string): Promise<{ files: string[]; folders: string[] }> { - await Promise.resolve(); - const abstractFile = this.vault.getAbstractFileByPath(path); - if (abstractFile instanceof TFolder) { - const files: string[] = []; - const folders: string[] = []; - for (const child of abstractFile.children) { - if (child instanceof TFile) files.push(child.path); - else if (child instanceof TFolder) folders.push(child.path); - } - return { files, folders }; - } - return { files: [], folders: [] }; - } - async _write(path: string, data: string | ArrayBuffer, options?: DataWriteOptions): Promise { - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) { - if (typeof data === "string") { - await this.vault.modify(file, data, options); - } else { - await this.vault.modifyBinary(file, data, options); - } - } else { - if (typeof data === "string") { - await this.vault.create(path, data, options); - } else { - await this.vault.createBinary(path, data, options); - } - } - } - async write(path: string, data: string, options?: DataWriteOptions): Promise { - return await this._write(path, data, options); - } - async writeBinary(path: string, data: ArrayBuffer, options?: DataWriteOptions): Promise { - return await this._write(path, data, options); - } - - async read(path: string): Promise { - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) return await this.vault.read(file); - throw new Error("File not found"); - } - async readBinary(path: string): Promise { - const file = this.vault.getAbstractFileByPath(path); - if (file instanceof TFile) return await this.vault.readBinary(file); - throw new Error("File not found"); - } - - async exists(path: string): Promise { - await Promise.resolve(); - return this.vault.getAbstractFileByPath(path) !== null; - } - async mkdir(path: string): Promise { - await this.vault.createFolder(path); - } - async remove(path: string): Promise { - const file = this.vault.getAbstractFileByPath(path); - if (file) await this.vault.removeFromAdapter(file); - } -} - -class Events { - _eventEmitter = new EventTarget(); - _events = new Map(); - _eventTarget(callback: EventCallback): EventListener { - const registered = this._events.get(callback); - if (registered) { - return registered; - } - const eventListener = (event: Event) => { - callback(event instanceof CustomEvent ? event.detail : undefined); - }; - this._events.set(callback, eventListener); - return eventListener; - } - on(name: string, callback: EventCallback, ctx?: object) { - const registered = ctx ? (...args: unknown[]) => callback.apply(ctx, args) : callback; - this._eventEmitter.addEventListener(name, this._eventTarget(registered)); - } - trigger(name: string, args: unknown) { - const evt = new CustomEvent(name, { - detail: args, - }); - this._eventEmitter.dispatchEvent(evt); - } -} - -class Workspace extends Events { - getActiveFile(): null { - return null; - } - getMostRecentLeaf(): null { - return null; - } - - onLayoutReady(callback: () => void) { - // cb(); - // console.log("[Obsidian Mock] Workspace onLayoutReady registered"); - // this._eventEmitter.addEventListener("layout-ready", () => { - // console.log("[Obsidian Mock] Workspace layout-ready event triggered"); - window.setTimeout(() => { - callback(); - }, 200); - // }); - } - getLeavesOfType(): never[] { - return []; - } - getLeaf() { - return { setViewState: () => Promise.resolve(), revealLeaf: () => Promise.resolve() }; - } - revealLeaf() { - return Promise.resolve(); - } - containerEl: HTMLElement = createNativeElement(document, "div"); -} -export class App { - vaultName: string = "MockVault"; - constructor(vaultName?: string) { - if (vaultName) { - this.vaultName = vaultName; - } - this.vault = new Vault(this.vaultName); - } - vault: Vault; - workspace: Workspace = new Workspace(); - metadataCache = { - on: (name: string, callback: EventCallback, ctx?: object): EventRef => { - const registered = ctx ? (...args: unknown[]) => callback.apply(ctx, args) : callback; - return new EventRef(name, registered); - }, - getFileCache: (): null => null, - }; -} - -export class Plugin { - app: App; - manifest: PluginManifest; - settings: unknown; - commands: Map = new Map(); - constructor(app: App, manifest: PluginManifest) { - this.app = app; - this.manifest = manifest; - } - async loadData(): Promise { - await Promise.resolve(); - return SettingCache.get(this.app) ?? {}; - } - async saveData(data: unknown): Promise { - await Promise.resolve(); - SettingCache.set(this.app, data); - } - onload() {} - onunload() {} - addSettingTab(tab: PluginSettingTab) {} - addCommand(command: Command) { - this.commands.set(command.id, command); - } - addStatusBarItem() { - return { - setText: () => {}, - setClass: () => {}, - addClass: () => {}, - }; - } - addRibbonIcon() { - const icon = { - setAttribute: () => icon, - addClass: () => icon, - onclick: () => {}, - }; - return icon; - } - registerView(type: string, creator: () => ItemView) {} - registerObsidianProtocolHandler(handler: (params: Record) => unknown) {} - registerEvent(handler: EventRef) {} - registerDomEvent( - target: HTMLElement, - eventName: K, - handler: (event: HTMLElementEventMap[K]) => unknown - ) {} -} - -export class Notice { - private _key: number; - private static _counter = 0; - constructor(message: string) { - this._key = Notice._counter++; - } - setMessage(message: string) {} -} - -export class Modal { - app: App; - contentEl: HTMLElement; - titleEl: HTMLElement; - modalEl: HTMLElement; - isOpen: boolean = false; - - constructor(app: App) { - this.app = app; - this.contentEl = createNativeElement(document, "div"); - this.contentEl.className = "modal-content"; - this.titleEl = createNativeElement(document, "div"); - this.titleEl.className = "modal-title"; - this.modalEl = createNativeElement(document, "div"); - this.modalEl.className = "modal"; - this.modalEl.hidden = true; - this.modalEl.appendChild(this.titleEl); - this.modalEl.appendChild(this.contentEl); - } - open() { - this.isOpen = true; - this.modalEl.hidden = false; - if (!this.modalEl.parentElement) { - document.body.appendChild(this.modalEl); - } - this.onOpen(); - } - close() { - this.isOpen = false; - this.modalEl.hidden = true; - this.onClose(); - } - onOpen() {} - onClose() {} - setPlaceholder(p: string) {} - setTitle(t: string) { - this.titleEl.textContent = t; - } -} - -export class PluginSettingTab { - app: App; - plugin: Plugin; - containerEl: HTMLElement; - constructor(app: App, plugin: Plugin) { - this.app = app; - this.plugin = plugin; - this.containerEl = createNativeElement(document, "div"); - } - display() {} -} - -export function normalizePath(path: string): string { - return path.replace(/\\/g, "/").replace(/\/+$/, ""); -} - -export const Platform = { - isDesktop: true, - isMobile: false, -}; - -export class Menu { - addItem(cb: (item: MenuItem) => unknown) { - cb(new MenuItem()); - return this; - } - showAtMouseEvent(evt: MouseEvent) {} -} -export class MenuItem { - setTitle(title: string) { - return this; - } - setIcon(icon: string) { - return this; - } - onClick(cb: (evt: MouseEvent) => unknown) { - return this; - } -} -export class MenuSeparator {} - -export class Component { - load() {} - unload() {} -} - -export class ButtonComponent extends Component { - buttonEl: HTMLButtonElement = createNativeElement(document, "button"); - private clickHandler: ((evt: MouseEvent) => unknown) | null = null; - - constructor() { - super(); - this.buttonEl = createNativeElement(document, "button"); - this.buttonEl.type = "button"; - } - - setButtonText(text: string) { - this.buttonEl.textContent = text; - return this; - } - - setCta() { - this.buttonEl.classList.add("mod-cta"); - return this; - } - - onClick(cb: (evt: MouseEvent) => unknown) { - this.clickHandler = cb; - this.buttonEl.removeEventListener("click", this.clickHandler); - this.buttonEl.addEventListener("click", (evt) => cb(evt as MouseEvent)); - return this; - } - - setClass(c: string) { - this.buttonEl.classList.add(c); - return this; - } - - setTooltip(tooltip: string) { - this.buttonEl.title = tooltip; - return this; - } - - setDisabled(disabled: boolean) { - this.buttonEl.disabled = disabled; - return this; - } -} - -export class TextComponent extends Component { - inputEl: HTMLInputElement = createNativeElement(document, "input"); - private changeHandler: ((value: string) => unknown) | null = null; - - constructor() { - super(); - this.inputEl = createNativeElement(document, "input"); - this.inputEl.type = "text"; - } - - onChange(cb: (value: string) => unknown) { - this.changeHandler = cb; - this.inputEl.removeEventListener("change", this.handleChange); - this.inputEl.addEventListener("change", this.handleChange); - this.inputEl.addEventListener("input", (evt) => { - const target = evt.target as HTMLInputElement; - cb(target.value); - }); - return this; - } - - private handleChange = (evt: Event) => { - if (this.changeHandler) { - const target = evt.target as HTMLInputElement; - this.changeHandler(target.value); - } - }; - - setValue(v: string) { - this.inputEl.value = v; - return this; - } - - setPlaceholder(p: string) { - this.inputEl.placeholder = p; - return this; - } - - setDisabled(disabled: boolean) { - this.inputEl.disabled = disabled; - return this; - } -} - -export class ToggleComponent extends Component { - inputEl: HTMLInputElement = createNativeElement(document, "input"); - private changeHandler: ((value: boolean) => unknown) | null = null; - - constructor() { - super(); - this.inputEl = createNativeElement(document, "input"); - this.inputEl.type = "checkbox"; - } - - onChange(cb: (value: boolean) => unknown) { - this.changeHandler = cb; - this.inputEl.addEventListener("change", (evt) => { - const target = evt.target as HTMLInputElement; - cb(target.checked); - }); - return this; - } - - setValue(v: boolean) { - this.inputEl.checked = v; - return this; - } - - setDisabled(disabled: boolean) { - this.inputEl.disabled = disabled; - return this; - } -} - -export class DropdownComponent extends Component { - selectEl: HTMLSelectElement = createNativeElement(document, "select"); - private changeHandler: ((value: string) => unknown) | null = null; - - constructor() { - super(); - this.selectEl = createNativeElement(document, "select"); - } - - addOption(v: string, d: string) { - const option = createNativeElement(document, "option"); - option.value = v; - option.textContent = d; - this.selectEl.appendChild(option); - return this; - } - - addOptions(o: Record) { - for (const [value, display] of Object.entries(o)) { - this.addOption(value, display); - } - return this; - } - - onChange(cb: (value: string) => unknown) { - this.changeHandler = cb; - this.selectEl.addEventListener("change", (evt) => { - const target = evt.target as HTMLSelectElement; - cb(target.value); - }); - return this; - } - - setValue(v: string) { - this.selectEl.value = v; - return this; - } - - setDisabled(disabled: boolean) { - this.selectEl.disabled = disabled; - return this; - } -} - -export class SliderComponent extends Component { - inputEl: HTMLInputElement = createNativeElement(document, "input"); - private changeHandler: ((value: number) => unknown) | null = null; - - constructor() { - super(); - this.inputEl = createNativeElement(document, "input"); - this.inputEl.type = "range"; - } - - onChange(cb: (value: number) => unknown) { - this.changeHandler = cb; - this.inputEl.addEventListener("change", (evt) => { - const target = evt.target as HTMLInputElement; - cb(parseFloat(target.value)); - }); - this.inputEl.addEventListener("input", (evt) => { - const target = evt.target as HTMLInputElement; - cb(parseFloat(target.value)); - }); - return this; - } - - setValue(v: number) { - this.inputEl.value = String(v); - return this; - } - - setMin(min: number) { - this.inputEl.min = String(min); - return this; - } - - setMax(max: number) { - this.inputEl.max = String(max); - return this; - } - - setStep(step: number) { - this.inputEl.step = String(step); - return this; - } - - setDisabled(disabled: boolean) { - this.inputEl.disabled = disabled; - return this; - } -} - -export class Setting { - nameEl: HTMLElement; - descEl: HTMLElement; - controlEl: HTMLElement; - infoEl: HTMLElement; - - constructor(containerEl: HTMLElement) { - this.nameEl = containerEl.createDiv(); - this.descEl = containerEl.createDiv(); - this.controlEl = containerEl.createDiv(); - this.infoEl = containerEl.createDiv(); - } - setName(name: string) { - this.nameEl.setText(name); - return this; - } - setDesc(desc: string) { - this.descEl.setText(desc); - return this; - } - setClass(c: string) { - this.controlEl.addClass(c); - return this; - } - addText(cb: (text: TextComponent) => unknown) { - const component = new TextComponent(); - this.controlEl.appendChild(component.inputEl); - cb(component); - return this; - } - addToggle(cb: (toggle: ToggleComponent) => unknown) { - const component = new ToggleComponent(); - cb(component); - return this; - } - addButton(cb: (btn: ButtonComponent) => unknown) { - const btn = new ButtonComponent(); - this.controlEl.appendChild(btn.buttonEl); - cb(btn); - return this; - } - addDropdown(cb: (dropdown: DropdownComponent) => unknown) { - const component = new DropdownComponent(); - cb(component); - return this; - } - addSlider(cb: (slider: SliderComponent) => unknown) { - const component = new SliderComponent(); - cb(component); - return this; - } -} - -function applyDomElementInfo(element: HTMLElement, info?: DomElementInfo | string): void { - if (typeof info === "string") { - element.textContent = info; - return; - } - if (!info) return; - if (info.cls) { - const classes = Array.isArray(info.cls) ? info.cls : info.cls.split(" "); - element.classList.add(...classes.filter((className) => className !== "")); - } - if (info.text !== undefined) { - element.replaceChildren(info.text); - } - if (info.attr) { - for (const [name, value] of Object.entries(info.attr)) { - if (value === null) element.removeAttribute(name); - else element.setAttribute(name, String(value)); - } - } - if (info.title !== undefined) element.title = info.title; - if (info.value !== undefined && "value" in element) element.value = info.value; - if (info.type !== undefined && "type" in element) element.type = info.type; - if (info.placeholder !== undefined && "placeholder" in element) element.placeholder = info.placeholder; - if (info.href !== undefined) element.setAttribute("href", info.href); -} - -// HTMLElement extensions used by the Webapp compatibility implementation. -if (typeof HTMLElement !== "undefined") { - const proto = HTMLElement.prototype; - proto.createDiv = function ( - this: HTMLElement, - info?: DomElementInfo | string, - callback?: (element: HTMLDivElement) => void - ): HTMLDivElement { - const element = createNativeElement(document, "div"); - applyDomElementInfo(element, info); - this.appendChild(element); - callback?.(element); - return element; - }; - proto.createEl = function ( - this: HTMLElement, - tag: K, - info?: DomElementInfo | string, - callback?: (element: HTMLElementTagNameMap[K]) => void - ): HTMLElementTagNameMap[K] { - const element = createNativeElement(document, tag); - applyDomElementInfo(element, info); - this.appendChild(element); - callback?.(element); - return element; - }; - proto.createSpan = function ( - this: HTMLElement, - info?: DomElementInfo | string, - callback?: (element: HTMLSpanElement) => void - ): HTMLSpanElement { - const element = createNativeElement(document, "span"); - applyDomElementInfo(element, info); - this.appendChild(element); - callback?.(element); - return element; - }; - proto.empty = function (this: HTMLElement): void { - this.replaceChildren(); - }; - proto.setText = function (this: HTMLElement, text: string): void { - this.textContent = text; - }; - proto.addClass = function (this: HTMLElement, className: string): void { - this.classList.add(className); - }; - proto.removeClass = function (this: HTMLElement, className: string): void { - this.classList.remove(className); - }; - proto.toggleClass = function (this: HTMLElement, className: string, value: boolean): void { - this.classList.toggle(className, value); - }; - proto.hasClass = function (this: HTMLElement, className: string): boolean { - return this.classList.contains(className); - }; -} - -export class Editor {} - -export class FuzzySuggestModal { - constructor(app: App) {} - setPlaceholder(p: string) {} - open() {} - close() {} - private __dummy(_: T): never { - throw new Error("Not implemented."); - } -} - -function parseHtmlFragment(html: string): DocumentFragment { - const parsed = new DOMParser().parseFromString(html, "text/html"); - const fragment = createNativeFragment(document); - for (const child of [...parsed.body.childNodes]) { - fragment.appendChild(document.importNode(child, true)); - } - return fragment; -} - -export class MarkdownRenderer { - static render(app: App, md: string, el: HTMLElement, path: string, component: Component) { - el.replaceChildren(parseHtmlFragment(md)); - return Promise.resolve(); - } -} -export class MarkdownView {} -export class TextAreaComponent extends Component {} -export class ItemView {} -export class WorkspaceLeaf {} - -export function sanitizeHTMLToDom(html: string) { - const div = createNativeElement(document, "div"); - div.appendChild(parseHtmlFragment(html)); - return div; -} - -export function addIcon() {} -export function debounce( - fn: (...args: Arguments) => Result -): (...args: Arguments) => Result { - return fn; -} -export async function request(options: RequestUrlParam | string): Promise { - const result = await requestUrl(options); - return result.text; -} - -export async function requestUrl(options: RequestUrlParam | string): Promise { - const { body, headers, method, url, contentType } = typeof options === "string" ? { url: options } : options; - const reqHeadersObj: Record = {}; - for (const key of Object.keys(headers || {})) { - reqHeadersObj[key.toLowerCase()] = headers[key]; - } - if (contentType) { - reqHeadersObj["content-type"] = contentType; - } - reqHeadersObj["Cache-Control"] = "no-cache, no-store, must-revalidate"; - reqHeadersObj["Pragma"] = "no-cache"; - reqHeadersObj["Expires"] = "0"; - const result = await window.fetch(url, { - method, - headers: { - ...reqHeadersObj, - }, - - body, - }); - const headersObj: Record = {}; - result.headers.forEach((value, key) => { - headersObj[key] = value; - }); - let json: unknown; - let text = ""; - let arrayBuffer = new ArrayBuffer(0); - try { - const isJson = result.headers.get("content-type")?.includes("application/json"); - arrayBuffer = await result.arrayBuffer(); - const isText = result.headers.get("content-type")?.startsWith("text/"); - if (isText || isJson) { - text = new TextDecoder().decode(arrayBuffer); - } - if (isJson) { - json = await JSON.parse(text || "{}"); - } - } catch { - json = undefined; - } - return { - status: result.status, - headers: headersObj, - text: text, - json: json, - arrayBuffer: arrayBuffer, - }; -} -export function stringifyYaml(obj: unknown): string { - return JSON.stringify(obj); -} -export function parseYaml(s: string): unknown { - const parsed: unknown = JSON.parse(s); - return parsed; -} -export function getLanguage() { - return "en"; -} -/** The mock does not implement APIs gated by an Obsidian application version. */ -export function requireApiVersion(_version: string): boolean { - return false; -} -export function setIcon(el: HTMLElement, icon: string) {} -export function arrayBufferToBase64(buffer: ArrayBuffer): string { - return btoa(String.fromCharCode(...new Uint8Array(buffer))); -} -export function base64ToArrayBuffer(base64: string): ArrayBuffer { - return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)).buffer; -} diff --git a/src/apps/webapp/obsidianMockExports.unit.spec.ts b/src/apps/webapp/obsidianMockExports.unit.spec.ts deleted file mode 100644 index d2221c79..00000000 --- a/src/apps/webapp/obsidianMockExports.unit.spec.ts +++ /dev/null @@ -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): 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(); - 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(); - 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([]); - }); -}); diff --git a/src/apps/webapp/package.json b/src/apps/webapp/package.json index dd98602f..b33bcf4b 100644 --- a/src/apps/webapp/package.json +++ b/src/apps/webapp/package.json @@ -9,7 +9,10 @@ "build": "vite build", "build:docker": "docker build -f Dockerfile -t livesync-webapp ../../..", "run:docker": "docker run -p 8002:80 livesync-webapp", - "preview": "vite preview" + "preview": "vite preview", + "test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webapp", + "pretest:browser": "npm run build", + "test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts" }, "dependencies": { "octagonal-wheels": "^0.1.51" diff --git a/src/apps/webapp/vaultSelector.ts b/src/apps/webapp/vaultSelector.ts index 7c2d2321..87cc1816 100644 --- a/src/apps/webapp/vaultSelector.ts +++ b/src/apps/webapp/vaultSelector.ts @@ -68,7 +68,8 @@ export class VaultHistoryStore { private async openHandleDB(): Promise { 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(request: IDBRequest): Promise { 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")); }); } diff --git a/src/apps/webapp/vite.config.ts b/src/apps/webapp/vite.config.ts index 8857acf9..e48b74d1 100644 --- a/src/apps/webapp/vite.config.ts +++ b/src/apps/webapp/vite.config.ts @@ -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, diff --git a/src/apps/webapp/webapp.css b/src/apps/webapp/webapp.css index 0e3999b6..4e4c68c6 100644 --- a/src/apps/webapp/webapp.css +++ b/src/apps/webapp/webapp.css @@ -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); -} \ No newline at end of file +} diff --git a/src/apps/webapp/webapp.html b/src/apps/webapp/webapp.html index 72ba1585..711b31a9 100644 --- a/src/apps/webapp/webapp.html +++ b/src/apps/webapp/webapp.html @@ -1,45 +1,47 @@ - + - - - - Self-hosted LiveSync WebApp - - - -
-

Self-hosted LiveSync on Web

-

Browser-based Self-hosted LiveSync using FileSystem API

+ + + + Self-hosted LiveSync WebApp + + + +
+

Self-hosted LiveSync on Web

+

Experimental browser proof of concept using the File System Access API

-
Initialising...
+
Initialising...
-
-

Select Vault Folder

-

Open a vault you already used, or pick a new folder.

+
+

Select Vault Folder

+

Open a vault you already used, or pick a new folder.

-
-

No saved vaults yet.

- +
+

No saved vaults yet.

+ +
+ + + +
+

How to Use

+
    +
  • Select the local Vault root and grant access.
  • +
  • Create or edit .livesync/settings.json in that Vault.
  • +
  • Reload this page to apply the settings.
  • +
  • Use the P2P controls only as an optional secondary connection.
  • +
+
+ +
-
-

How to Use

-
    -
  • Select a vault folder and grant permission
  • -
  • Create .livesync/settings.json in your vault folder
  • -
  • Or use Setup-URI to apply settings
  • -
  • Your files will be synced after "replicate now"
  • -
-
- - -
- - - + + diff --git a/src/apps/webpeer/README.md b/src/apps/webpeer/README.md index 5494bd2c..863abff7 100644 --- a/src/apps/webpeer/README.md +++ b/src/apps/webpeer/README.md @@ -1,37 +1,26 @@ -# A pseudo client for Self-hosted LiveSync Peer-to-Peer Sync mode +# Self-hosted LiveSync WebPeer -## What is it for? +WebPeer is a 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. - -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. - -> [!IMPORTANT] -> Of course, it has not been fully tested. Rather, it was created to be tested. - -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. - -## How to use it? - -We can build the application from the repository root by running the following command: +Build it from the repository root: ```bash -npm run build -w webpeer +npm run build --workspace webpeer ``` -Or from the package directory: +Serve `src/apps/webpeer/dist/` over HTTPS, or from `localhost`, open `index.html`, and configure the same Group ID, passphrase, signalling relay, and optional TURN settings as the other peers. Keep the page open while it is expected to announce or transfer changes. + +WebPeer stores its settings, metadata, and chunks in origin-scoped browser storage. Clearing site data removes this state. Browser suspension, storage eviction, tab lifecycle, and network policy mean that WebPeer is not an always-on server. + +The app-owned unit and Chromium tests can be run with: ```bash -cd src/apps/webpeer -npm run build +npm run test:unit --workspace webpeer +npm run test:browser --workspace webpeer ``` -Then, open `dist/index.html` in the browser. It can be configured in the same way as Self-hosted LiveSync (the same components are used[^1]). +The unit tests are stored in `test/apps/webpeer/`, outside the Community Review source boundary. -## Some notes - -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. - - -[^1]: Congrats! I made it modular. Finally... +## Licence +The same licence as the main Self-hosted LiveSync project applies. diff --git a/src/apps/webpeer/package.json b/src/apps/webpeer/package.json index 7474c1cd..f0bc005a 100644 --- a/src/apps/webpeer/package.json +++ b/src/apps/webpeer/package.json @@ -9,7 +9,10 @@ "build:docker": "docker build -f Dockerfile -t livesync-webpeer ../../..", "run:docker": "docker run -p 8001:80 livesync-webpeer", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" + "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json", + "test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webpeer", + "pretest:browser": "npm run build", + "test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts" }, "dependencies": { "octagonal-wheels": "^0.1.51" diff --git a/src/apps/webpeer/src/CommandsShim.ts b/src/apps/webpeer/src/CommandsShim.ts deleted file mode 100644 index 31a9a352..00000000 --- a/src/apps/webpeer/src/CommandsShim.ts +++ /dev/null @@ -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(""); diff --git a/src/apps/webpeer/src/P2PReplicatorShim.ts b/src/apps/webpeer/src/P2PReplicatorShim.ts deleted file mode 100644 index 9fbfae5e..00000000 --- a/src/apps/webpeer/src/P2PReplicatorShim.ts +++ /dev/null @@ -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; - services: InjectableServiceHub; - - getDB() { - if (!this.db) { - throw new Error("DB not initialized"); - } - return this.db; - } - _simpleStore!: SimpleStore; - - 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[0]); - this._liveSyncReplicator = replicator; - this.p2pLogCollector = p2pLogCollector; - p2pLogCollector.p2pReplicationLine.onChanged((line) => { - p2pStatusLine.value = line.value; - }); - } - - constructor() { - const browserServiceHub = createLiveSyncBrowserServiceHub(); - this.services = browserServiceHub; - - (this.services.API as BrowserAPIService).getSystemVaultName.setHandler( - () => "p2p-livesync-web-peer" - ); - const repStore = SimpleStoreIDBv2.open("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).saveData.setHandler(async (data) => { - await repStore.set("settings", data); - this.services.context.events.emitEvent(EVENT_SETTING_SAVED, data); - }); - (this.services.setting as InjectableSettingService).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(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 { - return this._simpleStore; - } - handleReplicatedDocuments(_docs: EntryDoc[]): Promise { - 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[0]): Promise { - await this._liveSyncReplicator?.makeDecision(decision); - } - - async revokeDecision(decision: Parameters[0]): Promise { - 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 { - 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(); diff --git a/src/apps/webpeer/src/SyncMain.svelte b/src/apps/webpeer/src/SyncMain.svelte index 1cb9209d..a55909ad 100644 --- a/src/apps/webpeer/src/SyncMain.svelte +++ b/src/apps/webpeer/src/SyncMain.svelte @@ -1,34 +1,38 @@
- {#await synchronised then cmdSync} - + {#await synchronised then activeRuntime} + + {:catch error} -

{error.message}

+

{error instanceof Error ? error.message : String(error)}

{/await}
diff --git a/src/apps/webpeer/src/UITest.svelte b/src/apps/webpeer/src/UITest.svelte index 459006cc..6e586a07 100644 --- a/src/apps/webpeer/src/UITest.svelte +++ b/src/apps/webpeer/src/UITest.svelte @@ -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); }); diff --git a/src/apps/webpeer/src/WebPeerLogs.ts b/src/apps/webpeer/src/WebPeerLogs.ts new file mode 100644 index 00000000..3e0e99b5 --- /dev/null +++ b/src/apps/webpeer/src/WebPeerLogs.ts @@ -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([]); + +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; diff --git a/src/apps/webpeer/src/WebPeerPersistence.ts b/src/apps/webpeer/src/WebPeerPersistence.ts new file mode 100644 index 00000000..b588ab3d --- /dev/null +++ b/src/apps/webpeer/src/WebPeerPersistence.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Creates WebPeer-owned settings persistence without retaining legacy database names. */ +export function createWebPeerPersistence( + store: SimpleStore = SimpleStoreIDBv2.open(WEBPEER_STORE_NAME) +): { + readonly store: SimpleStore; + 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, + }; +} diff --git a/src/apps/webpeer/src/WebPeerRuntime.ts b/src/apps/webpeer/src/WebPeerRuntime.ts new file mode 100644 index 00000000..d47c649b --- /dev/null +++ b/src/apps/webpeer/src/WebPeerRuntime.ts @@ -0,0 +1,199 @@ +import { type P2PSyncSetting, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon"; +import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector"; +import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator"; +import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; +import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; +import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; +import { unique } from "octagonal-wheels/collection"; +import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; + +import { + createLiveSyncBrowserServiceHub, + type LiveSyncBrowserServiceHub, +} from "@/apps/browser/createLiveSyncBrowserServiceHub"; +import { Menu } from "@/apps/browser/BrowserMenu"; +import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost"; +import { translateLiveSyncMessage } from "@/common/translation"; +import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence"; + +export interface WebPeerRuntimeOptions { + context?: ServiceContext; + store?: SimpleStore; +} + +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; + readonly p2p: UseP2PReplicatorResult; + readonly p2pLogCollector: P2PLogCollector; + readonly paneHost: P2PReplicatorPaneHost; + + private menu?: Menu; + private restartScheduled = false; + private startPromise?: Promise; + private shutdownPromise?: Promise; + + constructor(options: WebPeerRuntimeOptions = {}) { + const persistence = createWebPeerPersistence(options.store); + this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage }); + this.services = createLiveSyncBrowserServiceHub({ + context: this.context, + getSystemVaultName: () => WEBPEER_STORE_NAME, + settings: persistence.settings, + restart: { + schedule: () => this.scheduleRestart(), + perform: () => this.scheduleRestart(), + ask: () => this.scheduleRestart(), + isScheduled: () => this.restartScheduled, + }, + }); + this.p2p = useP2PReplicatorFeature({ + services: this.services, + serviceModules: {}, + }); + this.p2pLogCollector = new P2PLogCollector(this.events); + this.paneHost = { + services: this.services, + p2p: this.p2p, + showPeerMenu: (peer, event) => this.showPeerMenu(peer, event), + }; + } + + get events(): LiveSyncEventHub { + return this.context.events; + } + + get currentReplicator(): LiveSyncTrysteroReplicator { + return this.p2p.replicator; + } + + get settings(): P2PSyncSetting { + return this.services.setting.currentSettings(); + } + + get statusLine() { + return this.p2pLogCollector.p2pReplicationLine; + } + + start(): Promise { + this.startPromise ??= this.startRuntime(); + return this.startPromise; + } + + private async startRuntime(): Promise { + await this.services.setting.loadSettings(); + const opened = await this.services.database.openDatabase({ + replicator: this.services.replicator, + databaseEvents: this.services.databaseEvents, + }); + if (!opened) { + throw new Error("WebPeer local database could not be opened"); + } + this.services.appLifecycle.markIsReady(); + this.events.emitEvent(EVENT_LAYOUT_READY); + if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) { + compatGlobal.setTimeout(() => void this.currentReplicator.open(), 100); + } + return this; + } + + shutdown(): Promise { + this.shutdownPromise ??= this.shutdownRuntime(); + return this.shutdownPromise; + } + + private async shutdownRuntime(): Promise { + 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 { + 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(); + } +} diff --git a/src/common/types.ts b/src/common/types.ts index c8e632d7..2be50128 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,4 +1,4 @@ -import { type PluginManifest, TFile } from "@/deps.ts"; +import type { PluginManifest, TFile } from "@/deps.ts"; import { type DatabaseEntry, type EntryBody, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; export type { CacheData, FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types"; diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte index a89eded8..13a04018 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte @@ -1,14 +1,14 @@ @@ -434,7 +435,7 @@ {#each peers as peer} - + {/each} diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneController.ts b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneController.ts deleted file mode 100644 index dcce70db..00000000 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneController.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { - AcceptanceDecision, - RevokeAcceptanceDecision, -} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; - -/** The operations used by the shared P2P pane, independent of its replicator implementation. */ -export interface P2PReplicatorPaneController { - open(): Promise; - close(): Promise; - enableBroadcastChanges(): void; - disableBroadcastChanges(): void; - makeDecision(decision: AcceptanceDecision): Promise; - revokeDecision(decision: RevokeAcceptanceDecision): Promise; - watchPeer(peerId: string): void; - unwatchPeer(peerId: string): void; - sync(peerId: string, showNotice?: boolean): Promise; -} diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts new file mode 100644 index 00000000..0118aa4b --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost.ts @@ -0,0 +1,12 @@ +import type { RequiredServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon"; +import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; + +export type P2PReplicatorHandle = Pick; + +/** Host capabilities consumed by the shared P2P pane. */ +export interface P2PReplicatorPaneHost { + readonly services: RequiredServices<"API" | "config" | "setting" | "vault">; + readonly p2p: P2PReplicatorHandle; + readonly showPeerMenu?: (peer: PeerStatus, event: MouseEvent) => void; +} diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts index 08626326..99671f81 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts @@ -2,12 +2,11 @@ import { Menu, WorkspaceLeaf } from "@/deps.ts"; import ReplicatorPaneComponent from "./P2PReplicatorPane.svelte"; import { mount } from "svelte"; import { SvelteItemView } from "@/common/SvelteItemView.ts"; -import { eventHub } from "@/common/events.ts"; import { unique } from "octagonal-wheels/collection"; import { LOG_LEVEL_NOTICE, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; -import { EVENT_P2P_PEER_SHOW_EXTRA_MENU, type PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon"; +import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; import type { P2PPaneParams } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; export const VIEW_TYPE_P2P = "p2p-replicator"; @@ -127,46 +126,45 @@ And you can also drop the local database to rebuild from the remote device.`, super(leaf); this.core = core; this._p2pResult = p2pResult; - eventHub.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => { - if (this.m) { - this.m.hide(); - } - this.m = new Menu() - .addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer))) - .addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer))) - .addSeparator() - .addItem((item) => { - item.setTitle("🔧 Get Configuration").onClick(async () => { - await this.getRemoteConfig(peer); - }); - }) - .addSeparator() - .addItem((item) => { - const mark = peer.syncOnConnect ? "checkmark" : null; - item.setTitle("Toggle Sync on connect") - .onClick(async () => { - await this.toggleProp(peer, "syncOnConnect"); - }) - .setIcon(mark); - }) - .addItem((item) => { - const mark = peer.watchOnConnect ? "checkmark" : null; - item.setTitle("Toggle Watch on connect") - .onClick(async () => { - await this.toggleProp(peer, "watchOnConnect"); - }) - .setIcon(mark); - }) - .addItem((item) => { - const mark = peer.syncOnReplicationCommand ? "checkmark" : null; - item.setTitle("Toggle Sync on `Replicate now` command") - .onClick(async () => { - await this.toggleProp(peer, "syncOnReplicationCommand"); - }) - .setIcon(mark); + } + + private showPeerMenu(peer: PeerStatus, event: MouseEvent): void { + this.m?.hide(); + this.m = new Menu() + .addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer))) + .addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer))) + .addSeparator() + .addItem((item) => { + item.setTitle("🔧 Get Configuration").onClick(async () => { + await this.getRemoteConfig(peer); }); - this.m.showAtPosition({ x: event.x, y: event.y }); - }); + }) + .addSeparator() + .addItem((item) => { + const mark = peer.syncOnConnect ? "checkmark" : null; + item.setTitle("Toggle Sync on connect") + .onClick(async () => { + await this.toggleProp(peer, "syncOnConnect"); + }) + .setIcon(mark); + }) + .addItem((item) => { + const mark = peer.watchOnConnect ? "checkmark" : null; + item.setTitle("Toggle Watch on connect") + .onClick(async () => { + await this.toggleProp(peer, "watchOnConnect"); + }) + .setIcon(mark); + }) + .addItem((item) => { + const mark = peer.syncOnReplicationCommand ? "checkmark" : null; + item.setTitle("Toggle Sync on `Replicate now` command") + .onClick(async () => { + await this.toggleProp(peer, "syncOnReplicationCommand"); + }) + .setIcon(mark); + }); + void this.m.showAtPosition({ x: event.x, y: event.y }); } getViewType() { @@ -187,8 +185,11 @@ And you can also drop the local database to rebuild from the remote device.`, return mount(ReplicatorPaneComponent, { target: target, props: { - getCmdSync: () => this._p2pResult.replicator, - core: this.core, + host: { + services: this.core.services, + p2p: this._p2pResult, + showPeerMenu: (peer: PeerStatus, event: MouseEvent) => this.showPeerMenu(peer, event), + }, }, }); } diff --git a/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte b/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte index b9b92a99..01f44048 100644 --- a/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte +++ b/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte @@ -1,20 +1,22 @@ @@ -159,7 +159,9 @@ {:else} {/if} - + {#if showPeerMenu} + + {/if}
{/if} diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemote.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemote.svelte index e019bb79..bf15de65 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemote.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemote.svelte @@ -41,8 +41,7 @@ {translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}