refactor: compose browser application runtimes

This commit is contained in:
vorotamoroz
2026-07-29 09:11:57 +00:00
parent b22c1bd777
commit 4723771ee0
65 changed files with 3919 additions and 2771 deletions
+33 -130
View File
@@ -1,136 +1,39 @@
import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import { createNativeElement } from "@/apps/browserDom";
import MessageBox from "./ui/MessageBox.svelte";
import TextInputBox from "./ui/TextInputBox.svelte";
import { mount } from "svelte";
import { promiseWithResolvers } from "octagonal-wheels/promises";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { _activeDocument, compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
function displayMessageBox<T, U extends string[]>(
message: string,
buttons: U,
title: string,
commit: (ret: U[number]) => T,
actionLayout: ConfirmActionLayout = "vertical"
): Promise<T> {
const el = createNativeElement(_activeDocument, "div");
const p = promiseWithResolvers<T>();
mount(MessageBox, {
target: el,
props: {
message,
buttons: buttons as string[],
title: title,
actionLayout,
commit: (action: U[number]) => {
const ret = commit(action);
p.resolve(ret);
},
},
});
_activeDocument.body.appendChild(el);
void p.promise.finally(() => {
el.remove();
});
return p.promise;
}
function promptForInput(
title: string,
key: string,
placeholder: string,
isPassword?: boolean
): Promise<string | false> {
const el = createNativeElement(_activeDocument, "div");
const p = promiseWithResolvers<string | false>();
mount(TextInputBox, {
target: el,
props: {
title,
message: key,
placeholder,
isPassword,
commit: (text: string | false) => {
p.resolve(text);
},
},
});
_activeDocument.body.appendChild(el);
void p.promise.finally(() => {
el.remove();
});
return p.promise;
}
import { createNativeElement } from "@/apps/browserDom";
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
/**
* Compatibility facade consumed by Commonlib while browser presentation is
* implemented through Fancy Kit's neutral `UiInteractions` contract.
*/
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
readonly context: T;
export class BrowserConfirm<T extends ServiceContext> implements Confirm {
_context: T;
constructor(context: T) {
this._context = context;
}
askYesNo(message: string): Promise<"yes" | "no"> {
return displayMessageBox(message, ["Yes", "No"] as const, "Confirm", (action) =>
action == "Yes" ? "yes" : "no"
);
}
askString(title: string, key: string, placeholder: string, isPassword?: boolean): Promise<string | false> {
return promptForInput(title, key, placeholder, isPassword);
}
askYesNoDialog(
message: string,
opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number }
): Promise<"yes" | "no"> {
return displayMessageBox(message, ["Yes", "No"] as const, opt.title ?? "Confirm", (action) =>
action == "Yes" ? "yes" : "no"
);
}
askSelectString(message: string, items: string[]): Promise<string> {
return displayMessageBox(message, [...items] as const, "Confirm", (action) => action);
}
askSelectStringDialogue<T extends readonly string[]>(
message: string,
buttons: T,
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
return displayMessageBox(message, [...buttons] as const, opt.title ?? "Confirm", (action) => action);
}
askInPopup(
key: string,
dialogText: string,
anchorCallback: (anchor: HTMLAnchorElement) => void,
durationMs: number = 20000
): void {
const existing = _activeDocument.querySelector(`[data-livesync-popup="${CSS.escape(key)}"]`);
existing?.remove();
const notice = createNativeElement(_activeDocument, "div");
notice.className = "livesync-browser-notice";
notice.dataset.livesyncPopup = key;
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
notice.append(beforeText);
const anchor = createNativeElement(_activeDocument, "a");
anchor.href = "#";
anchorCallback(anchor);
anchor.addEventListener("click", () => notice.remove());
notice.append(anchor, afterText ?? "");
_activeDocument.body.appendChild(notice);
compatGlobal.setTimeout(() => notice.remove(), durationMs);
}
confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
return displayMessageBox(
contentMd,
[...buttons] as const,
title ?? "Confirm",
(action) => action,
actionLayout
);
const dialogueController = new AbortController();
const notifications = new BrowserUiNotifications({
document: _activeDocument,
});
super({
ui: createBrowserUi({
document: _activeDocument,
signal: dialogueController.signal,
renderMarkdown: ({ container, markdown }) => {
renderMessageMarkdownInto(container, markdown);
},
}),
notifications,
createActionAnchor: () => createNativeElement(_activeDocument, "a"),
});
this.context = context;
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
dialogueController.abort();
notifications.dispose();
});
}
}
+122
View File
@@ -0,0 +1,122 @@
import type {
KeyValueDatabase,
KeyValueDatabaseFactory,
} from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
import { deleteDB, openDB, type IDBPDatabase } from "idb";
import { serialized } from "octagonal-wheels/concurrency/lock";
/** Creates an application-owned IndexedDB key-value factory for browser runtimes. */
export function createBrowserKeyValueDatabaseFactory(): KeyValueDatabaseFactory {
const cache = new Map<string, BrowserKeyValueDatabase>();
return async (databaseKey) =>
await serialized(`OpenBrowserKeyValueDatabase-${databaseKey}`, async () => {
const cached = cache.get(databaseKey);
if (cached && !cached.isDestroyed) {
return cached;
}
if (cached) {
await cached.ensuredDestroyed;
cache.delete(databaseKey);
}
const database = new BrowserKeyValueDatabase(databaseKey);
await database.getIsReady();
cache.set(databaseKey, database);
return database;
});
}
class BrowserKeyValueDatabase implements KeyValueDatabase {
private databasePromise?: Promise<IDBPDatabase<unknown>>;
private destroyed = false;
private destroyedPromise?: Promise<void>;
constructor(private readonly databaseKey: string) {}
get isDestroyed(): boolean {
return this.destroyed;
}
get ensuredDestroyed(): Promise<void> {
return this.destroyedPromise ?? Promise.resolve();
}
async getIsReady(): Promise<boolean> {
await this.ensureDatabase();
return !this.destroyed;
}
private ensureDatabase(): Promise<IDBPDatabase<unknown>> {
if (this.destroyed) {
throw new Error("Database is destroyed");
}
this.databasePromise ??= openDB(this.databaseKey, undefined, {
upgrade: (database) => {
if (!database.objectStoreNames.contains(this.databaseKey)) {
database.createObjectStore(this.databaseKey);
}
},
blocking: () => {
void this.closeDatabase();
},
terminated: () => {
this.databasePromise = undefined;
},
}).catch((error: unknown) => {
this.databasePromise = undefined;
throw error;
});
return this.databasePromise;
}
private get database(): Promise<IDBPDatabase<unknown>> {
return this.ensureDatabase();
}
private async closeDatabase(): Promise<void> {
const databasePromise = this.databasePromise;
this.databasePromise = undefined;
if (databasePromise) {
(await databasePromise).close();
}
}
async get<T>(key: IDBValidKey): Promise<T> {
return (await (await this.database).get(this.databaseKey, key)) as T;
}
async set<T>(key: IDBValidKey, value: T): Promise<IDBValidKey> {
await (await this.database).put(this.databaseKey, value, key);
return key;
}
async del(key: IDBValidKey): Promise<void> {
await (await this.database).delete(this.databaseKey, key);
}
async clear(): Promise<void> {
await (await this.database).clear(this.databaseKey);
}
async keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise<IDBValidKey[]> {
return await (await this.database).getAllKeys(this.databaseKey, query, count);
}
async close(): Promise<void> {
await this.closeDatabase();
}
async destroy(): Promise<void> {
if (this.destroyedPromise) {
await this.destroyedPromise;
return;
}
this.destroyed = true;
this.destroyedPromise = (async () => {
await this.closeDatabase();
await deleteDB(this.databaseKey);
})();
await this.destroyedPromise;
}
}
@@ -0,0 +1,133 @@
<script lang="ts">
import { onMount } from "svelte";
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
interface Props {
host: P2PReplicatorPaneHost;
}
let { host }: Props = $props();
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
const initialSettings = currentSettings();
let savedTurnServers = $state(initialSettings.P2P_turnServers);
let savedTurnUsername = $state(initialSettings.P2P_turnUsername);
let savedTurnCredential = $state(initialSettings.P2P_turnCredential);
let turnServers = $state(initialSettings.P2P_turnServers);
let turnUsername = $state(initialSettings.P2P_turnUsername);
let turnCredential = $state(initialSettings.P2P_turnCredential);
const isTurnServersModified = $derived(turnServers !== savedTurnServers);
const isTurnUsernameModified = $derived(turnUsername !== savedTurnUsername);
const isTurnCredentialModified = $derived(turnCredential !== savedTurnCredential);
const isModified = $derived(
isTurnServersModified || isTurnUsernameModified || isTurnCredentialModified
);
function loadSettings(settings: P2PSyncSetting): void {
savedTurnServers = settings.P2P_turnServers;
savedTurnUsername = settings.P2P_turnUsername;
savedTurnCredential = settings.P2P_turnCredential;
turnServers = savedTurnServers;
turnUsername = savedTurnUsername;
turnCredential = savedTurnCredential;
}
onMount(() =>
host.services.context.events.onEvent("setting-saved", (settings) => {
loadSettings(settings as P2PSyncSetting);
})
);
async function save(): Promise<void> {
await host.services.setting.applyPartial(
{
P2P_turnServers: turnServers,
P2P_turnUsername: turnUsername,
P2P_turnCredential: turnCredential,
},
true
);
loadSettings(currentSettings());
}
function revert(): void {
turnServers = savedTurnServers;
turnUsername = savedTurnUsername;
turnCredential = savedTurnCredential;
}
</script>
<section class="browser-p2p-transport-settings">
<details>
<summary>Optional TURN server settings</summary>
<p>
Configure TURN only when a direct peer-to-peer connection cannot be established.
</p>
<label class:is-dirty={isTurnServersModified}>
<span>TURN Server URLs (comma-separated)</span>
<input
type="text"
placeholder="turn:turn.example.com:3478"
bind:value={turnServers}
autocomplete="off"
spellcheck="false"
autocorrect="off"
/>
</label>
<label class:is-dirty={isTurnUsernameModified}>
<span>TURN Username</span>
<input
type="text"
placeholder="Enter TURN username"
bind:value={turnUsername}
autocomplete="off"
/>
</label>
<label class:is-dirty={isTurnCredentialModified}>
<span>TURN Credential</span>
<input
type="password"
placeholder="Enter TURN credential"
bind:value={turnCredential}
autocomplete="new-password"
/>
</label>
<div class="actions">
<button type="button" class="button mod-cta" disabled={!isModified} onclick={save}>
Save TURN settings
</button>
<button type="button" class="button" disabled={!isModified} onclick={revert}>
Revert TURN settings
</button>
</div>
</details>
</section>
<style>
.browser-p2p-transport-settings {
margin-bottom: 1rem;
}
p {
margin: 0.75rem 0;
}
label {
display: grid;
gap: 0.25rem;
margin: 0.75rem 0;
}
label.is-dirty {
background-color: var(--background-modifier-error);
}
input {
box-sizing: border-box;
width: 100%;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>
+24 -10
View File
@@ -1,15 +1,15 @@
import {
type ComponentHasResult,
SvelteDialogManagerBase,
SvelteDialogMixIn,
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import { createNativeElement } from "@/apps/browserDom";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
export class ShimModal {
export class BrowserModal {
contentEl: HTMLElement;
titleEl: HTMLElement;
modalEl: HTMLElement;
@@ -45,19 +45,14 @@ export class ShimModal {
}
onOpen() {}
onClose() {}
setPlaceholder(p: string) {}
setTitle(t: string) {
this.titleEl.textContent = t;
}
}
const BrowserSvelteDialogBase = SvelteDialogMixIn(ShimModal, DialogHost);
export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceContext> extends BrowserModal {
private readonly session: SvelteDialogSession<T, U, C>;
export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceContext> extends BrowserSvelteDialogBase<
T,
U,
C
> {
constructor(
context: C,
dependents: SvelteDialogManagerDependencies<C>,
@@ -65,7 +60,26 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
initialData?: U
) {
super();
this.initDialog(context, dependents, component, initialData);
this.session = new SvelteDialogSession({
surface: this,
context,
dependencies: dependents,
dialogHost: DialogHost,
component,
initialData,
});
}
override onOpen(): void {
this.session.onOpen();
}
override onClose(): void {
this.session.onClose();
}
waitForClose(): Promise<T | undefined> {
return this.session.waitForClose();
}
}
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
@@ -0,0 +1,110 @@
import type { LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { ICommandCompat } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
import { FetchHttpHandler } from "@smithy/fetch-http-handler";
import { _fetch } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
declare const MANIFEST_VERSION: string | undefined;
declare const PACKAGE_VERSION: string | undefined;
export interface LiveSyncBrowserAPIServiceOptions {
confirm: Confirm;
getSystemVaultName(): string;
appId?: string;
isMobile?: () => boolean;
fetch?: typeof _fetch;
addLog?: (message: unknown, level: LOG_LEVEL, key?: string) => void;
addCommand?: <TCommand extends ICommandCompat>(command: TCommand) => TCommand;
showWindow?: (type: string) => Promise<void>;
registerWindow?: <T>(type: string, factory: (leaf: T) => unknown) => void;
addRibbonIcon?: (icon: string, title: string, callback: (event: MouseEvent) => unknown) => HTMLElement;
registerProtocolHandler?: (action: string, handler: (params: Record<string, string>) => unknown) => void;
addStatusBarItem?: () => HTMLElement | undefined;
}
/** Browser application implementation of Commonlib's injected host API contract. */
export class LiveSyncBrowserAPIService<T extends ServiceContext> extends InjectableAPIService<T> {
private readonly options: LiveSyncBrowserAPIServiceOptions;
constructor(context: T, options: LiveSyncBrowserAPIServiceOptions) {
super(context);
this.options = options;
this.addLog.setHandler((message, level, key) => {
options.addLog?.(message, level, key);
});
}
get confirm(): Confirm {
return this.options.confirm;
}
getCustomFetchHandler(): FetchHttpHandler {
return new FetchHttpHandler();
}
isMobile(): boolean {
return this.options.isMobile?.() ?? false;
}
showWindow(type: string): Promise<void> {
return this.options.showWindow?.(type) ?? Promise.resolve();
}
getAppID(): string {
return this.options.appId ?? this.options.getSystemVaultName();
}
getSystemVaultName(): string {
return this.options.getSystemVaultName();
}
override getPlatform(): string {
return "browser";
}
getAppVersion(): string {
return MANIFEST_VERSION ?? "0.0.0";
}
getPluginVersion(): string {
return PACKAGE_VERSION ?? "0.0.0";
}
addCommand<TCommand extends ICommandCompat>(command: TCommand): TCommand {
return this.options.addCommand?.(command) ?? command;
}
registerWindow<T>(type: string, factory: (leaf: T) => unknown): void {
this.options.registerWindow?.(type, factory);
}
addRibbonIcon(
icon: string,
title: string,
callback: (event: MouseEvent) => unknown
): HTMLElement {
const element = this.options.addRibbonIcon?.(icon, title, callback);
if (!element) {
throw new Error("Ribbon icons are not supported by this browser application");
}
return element;
}
registerProtocolHandler(
action: string,
handler: (params: Record<string, string>) => unknown
): void {
this.options.registerProtocolHandler?.(action, handler);
}
override nativeFetch(request: string | Request, options?: RequestInit): Promise<Response> {
const fetchImplementation = this.options.fetch ?? _fetch;
return fetchImplementation(request, options);
}
addStatusBarItem(): HTMLElement | undefined {
return this.options.addStatusBarItem?.();
}
}
+14 -2
View File
@@ -1,14 +1,26 @@
import type { BrowserServiceHostDependencies } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
API: InjectableAPIService<T>;
appLifecycle: AppLifecycleService<T>;
config: ConfigService<T>;
control: ControlService<T>;
replicator: ReplicatorService<T>;
}
export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIService<T> {
override get dialogToCopy() {
return DialogToCopy;
}
constructor(context: T, dependents: BrowserServiceHostDependencies<T>) {
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>) {
const browserConfirm = dependents.API.confirm;
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(context, {
appLifecycle: dependents.appLifecycle,
+158
View File
@@ -0,0 +1,158 @@
import type {
Confirm,
ConfirmActionLayout,
} from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { UiInteractions, UiNotifications } from "@vrtmrz/ui-interactions";
const DEFAULT_LABELS = {
confirmationTitle: "Confirmation",
selectionTitle: "Select",
yes: "Yes",
no: "No",
} as const;
export interface UiInteractionsConfirmOptions {
ui: UiInteractions;
notifications: UiNotifications;
createActionAnchor: () => HTMLAnchorElement;
}
function timeoutOption(timeoutSeconds?: number): { timeoutMs?: number } {
return timeoutSeconds !== undefined && timeoutSeconds > 0
? { timeoutMs: timeoutSeconds * 1_000 }
: {};
}
/** Adapts Commonlib's legacy confirmation contract to Fancy Kit capabilities. */
export class UiInteractionsConfirm implements Confirm {
readonly notifications: UiNotifications;
constructor(private readonly options: UiInteractionsConfirmOptions) {
this.notifications = options.notifications;
}
async askYesNo(message: string): Promise<"yes" | "no"> {
const result = await this.options.ui.confirmAction(
{
title: DEFAULT_LABELS.confirmationTitle,
message,
actions: ["yes", "no"],
labels: { yes: DEFAULT_LABELS.yes, no: DEFAULT_LABELS.no },
defaultAction: "no",
actionLayout: "vertical",
},
"legacy-confirm.ask-yes-no"
);
return result === "yes" ? "yes" : "no";
}
async askString(
title: string,
key: string,
placeholder: string,
isPassword = false
): Promise<string | false> {
const prompt = isPassword
? this.options.ui.promptPassword.bind(this.options.ui)
: this.options.ui.promptText.bind(this.options.ui);
const result = await prompt(
{
title,
label: key,
placeholder,
},
"legacy-confirm.ask-string"
);
return result ?? false;
}
async askYesNoDialog(
message: string,
opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number } = {}
): Promise<"yes" | "no"> {
const result = await this.options.ui.confirmAction(
{
title: opt.title ?? DEFAULT_LABELS.confirmationTitle,
message,
actions: ["Yes", "No"],
labels: { Yes: DEFAULT_LABELS.yes, No: DEFAULT_LABELS.no },
defaultAction: opt.defaultOption ?? "No",
actionLayout: "vertical",
...timeoutOption(opt.timeout),
},
"legacy-confirm.ask-yes-no-dialog"
);
return result === "Yes" ? "yes" : "no";
}
async askSelectString(message: string, items: string[]): Promise<string> {
const result = await this.options.ui.pickOne(
{
items,
getText: (item) => item,
placeholder: message,
},
"legacy-confirm.ask-select-string"
);
return result ?? "";
}
async askSelectStringDialogue<T extends readonly string[]>(
message: string,
buttons: T,
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
const result = await this.options.ui.confirmAction(
{
title: opt.title ?? DEFAULT_LABELS.selectionTitle,
message,
actions: buttons,
defaultAction: opt.defaultAction,
actionLayout: "vertical",
...timeoutOption(opt.timeout),
},
"legacy-confirm.ask-select-string-dialogue"
);
return result ?? false;
}
askInPopup(
key: string,
dialogText: string,
anchorCallback: (anchor: HTMLAnchorElement) => void,
durationMs?: number
): void {
const anchor = this.options.createActionAnchor();
anchorCallback(anchor);
this.options.notifications.show(key, {
message: dialogText.replace("{HERE}", "").trim(),
action: {
label: anchor.textContent?.trim() || "Open",
onSelect: () => anchor.click(),
},
durationMs,
});
}
async confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
const result = await this.options.ui.confirmAction(
{
title,
message: contentMd,
actions: buttons,
defaultAction,
actionLayout: actionLayout ?? "vertical",
...timeoutOption(timeout),
},
"legacy-confirm.confirm-with-message"
);
return result ?? false;
}
}
@@ -1,37 +1,215 @@
import { BrowserServiceHub, type BrowserServiceHost } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
import { BrowserConfirm } from "./BrowserConfirm";
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableVaultService";
import { setLang, translateLiveSyncMessage } from "@/common/translation";
import { BrowserConfirm } from "./BrowserConfirm";
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
import {
LiveSyncBrowserAPIService,
type LiveSyncBrowserAPIServiceOptions,
} from "./LiveSyncBrowserAPIService";
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
export type LiveSyncBrowserServiceHubOptions<T extends ServiceContext> = {
export interface LiveSyncBrowserSettingsPersistence {
load(): Promise<ObsidianLiveSyncSettings | undefined>;
save(settings: ObsidianLiveSyncSettings): Promise<void>;
}
export interface LiveSyncBrowserRestartPolicy {
schedule(): void;
perform?: () => void;
ask?: (message?: string) => void;
isScheduled?: () => boolean;
}
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
context?: T;
getSystemVaultName?: () => string;
settings?: LiveSyncBrowserSettingsPersistence;
restart?: LiveSyncBrowserRestartPolicy;
openKeyValueDatabase?: KeyValueDatabaseFactory;
};
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
}
function createLiveSyncBrowserHost<T extends ServiceContext>(): BrowserServiceHost<T> {
return {
createAPI(context) {
return new BrowserAPIService(context, {
confirm: new BrowserConfirm(context),
});
},
createUI(context, dependencies) {
return new LiveSyncBrowserUIService(context, dependencies);
},
};
class LiveSyncBrowserAppLifecycleService<
T extends ServiceContext,
> extends InjectableAppLifecycleService<T> {}
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
class LiveSyncBrowserConfigService<T extends ServiceContext> extends ConfigService<T> {
constructor(
context: T,
private readonly setting: ISettingService
) {
super(context);
}
getSmallConfig(key: string): string | null {
return this.setting.getSmallConfig(key);
}
setSmallConfig(key: string, value: string): void {
this.setting.setSmallConfig(key, value);
}
deleteSmallConfig(key: string): void {
this.setting.deleteSmallConfig(key);
}
}
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
const context =
options.context ??
(new ServiceContext({
translate: translateLiveSyncMessage,
}) as T);
const API = new LiveSyncBrowserAPIService(context, {
...options.API,
confirm: new BrowserConfirm(context),
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
});
const conflict = new InjectableConflictService(context);
const fileProcessing = new InjectableFileProcessingService(context);
const setting = new InjectableSettingService(context, {
APIService: API,
onDisplayLanguageChanged: setLang,
});
const settingsPersistence = options.settings;
setting.loadData.setHandler(
settingsPersistence
? () => settingsPersistence.load()
: () => Promise.resolve(undefined)
);
setting.saveData.setHandler(
settingsPersistence
? (settings) => settingsPersistence.save(settings)
: () => Promise.resolve()
);
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
settingService: setting,
});
const restartPolicy = options.restart;
const scheduleRestart = restartPolicy ? () => restartPolicy.schedule() : () => {};
appLifecycle.scheduleRestart.setHandler(scheduleRestart);
appLifecycle.performRestart.setHandler(
restartPolicy?.perform ? () => restartPolicy.perform?.() : scheduleRestart
);
appLifecycle.askRestart.setHandler(
restartPolicy?.ask ? (message) => restartPolicy.ask?.(message) : scheduleRestart
);
appLifecycle.isReloadingScheduled.setHandler(
restartPolicy?.isScheduled ? () => restartPolicy.isScheduled?.() ?? false : () => false
);
const databaseEvents = new InjectableDatabaseEventService(context);
const path = new PathServiceCompat(context, {
settingService: setting,
});
const vault = new InjectableVaultServiceCompat(context, {
settingService: setting,
APIService: API,
});
const database = new LiveSyncBrowserDatabaseService(context, {
pouchDB: PouchDB,
path,
vault,
setting,
API,
});
const config = new LiveSyncBrowserConfigService(context, setting);
const replicator = new InjectableReplicatorService(context, {
settingService: setting,
appLifecycleService: appLifecycle,
databaseEventService: databaseEvents,
});
const remote = new InjectableRemoteService(context, {
pouchDB: PouchDB,
APIService: API,
appLifecycle,
setting,
});
const replication = new InjectableReplicationService(context, {
APIService: API,
appLifecycleService: appLifecycle,
replicatorService: replicator,
settingService: setting,
fileProcessingService: fileProcessing,
databaseService: database,
});
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
openKeyValueDatabase:
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
appLifecycle,
databaseEvents,
vault,
});
const control = new ControlService(context, {
appLifecycleService: appLifecycle,
databaseService: database,
fileProcessingService: fileProcessing,
settingService: setting,
APIService: API,
replicatorService: replicator,
});
const ui = new LiveSyncBrowserUIService(context, {
API,
appLifecycle,
config,
control,
replicator,
});
super(context, {
API,
appLifecycle,
conflict,
config,
control,
database,
databaseEvents,
fileProcessing,
keyValueDB,
path,
remote,
replication,
replicator,
setting,
test: new InjectableTestService(context),
tweakValue: new InjectableTweakValueService(context),
ui,
vault,
});
}
}
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
options: LiveSyncBrowserServiceHubOptions<T> = {}
): BrowserServiceHub<T> {
const context = options.context ?? (new ServiceContext({ translate: translateLiveSyncMessage }) as T);
return new BrowserServiceHub<T>({
...options,
context,
onDisplayLanguageChanged: setLang,
host: createLiveSyncBrowserHost<T>(),
});
): LiveSyncBrowserServiceHub<T> {
return new LiveSyncBrowserServiceHub(options);
}
@@ -1,12 +1,20 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
observeServiceComposition,
observeServiceContext,
SERVICE_CONTEXT_MEMBERS,
} from "../../../test/contracts/serviceContext";
import { createLiveSyncBrowserServiceHub } from "./createLiveSyncBrowserServiceHub";
import {
createLiveSyncBrowserServiceHub,
type LiveSyncBrowserServiceHubOptions,
} from "./createLiveSyncBrowserServiceHub";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("LiveSync browser service context contract", () => {
it("preserves one injected context and its API results throughout the Webapp composition", () => {
@@ -25,4 +33,57 @@ describe("LiveSync browser service context contract", () => {
[]
);
});
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
const deviceLocalSettings = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => deviceLocalSettings.get(key) ?? null,
setItem: (key: string, value: string) => deviceLocalSettings.set(key, value),
removeItem: (key: string) => deviceLocalSettings.delete(key),
clear: () => deviceLocalSettings.clear(),
});
const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
const restartCalls: string[] = [];
const nativeFetch = vi.fn(async () => new Response("ok"));
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
getSystemVaultName: () => "browser-vault",
settings: {
load: async () => ({
...DEFAULT_SETTINGS,
couchDB_DBNAME: "browser-database",
}),
save: async (settings) => {
savedSettings.push(settings);
},
},
restart: {
schedule: () => restartCalls.push("schedule"),
perform: () => restartCalls.push("perform"),
ask: (message) => restartCalls.push(`ask:${message ?? ""}`),
isScheduled: () => true,
},
API: {
fetch: nativeFetch as typeof fetch,
},
};
const hub = createLiveSyncBrowserServiceHub(options);
expect(hub.API.getSystemVaultName()).toBe("browser-vault");
expect(hub.API.getPlatform()).toBe("browser");
const response = await hub.API.nativeFetch("https://example.invalid/");
expect(await response.text()).toBe("ok");
expect(nativeFetch).toHaveBeenCalledWith("https://example.invalid/", undefined);
await hub.setting.loadSettings();
expect(hub.setting.currentSettings().couchDB_DBNAME).toBe("browser-database");
savedSettings.length = 0;
await hub.setting.saveSettingData();
expect(savedSettings).toHaveLength(1);
hub.appLifecycle.scheduleRestart();
hub.appLifecycle.performRestart();
hub.appLifecycle.askRestart("restart now");
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
});
});
-137
View File
@@ -1,137 +0,0 @@
<script lang="ts">
import { renderMessageMarkdown } from "./renderMessageMarkdown";
type Props = {
title: string;
message: string;
buttons: string[];
actionLayout?: ConfirmActionLayout;
commit: (button: string) => void;
};
type ConfirmActionLayout = "auto" | "vertical";
let { title, message, buttons, actionLayout, commit }: Props = $props();
const renderedMessage = $derived(renderMessageMarkdown(message));
function handleEsc(event: KeyboardEvent) {
if (event.key === "Escape") {
commit("");
}
}
</script>
<popup>
<header>{title}</header>
<article><div class="msg">{@html renderedMessage}</div></article>
<div class:vertical={actionLayout === "vertical"} class="buttons">
{#each buttons as button}
<button onclick={() => commit(button)}>{button}</button>
{/each}
</div>
</popup>
<div class="background" onclick={() => commit("")} onkeydown={handleEsc} role="none"></div>
<style>
popup {
z-index: 1000;
position: fixed;
background: rgba(255, 255, 255, 0.8);
max-width: 70vw;
max-height: 80vh;
margin: auto;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-width: 50vw;
min-height: 50vh;
backdrop-filter: blur(5px);
display: flex;
flex-direction: column;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
border: 1px solid var(--background-primary-alt);
justify-content: space-between;
}
popup header {
background: var(--background-primary);
color: var(--text-normal);
padding: 1em;
border-bottom: 1px solid var(--background-primary-alt);
font: size 1.4em;
}
popup article {
padding: 1em;
display: flex;
justify-content: center;
overflow-y: auto;
}
popup article .msg {
width: 100%;
line-height: 1.5;
overflow-wrap: anywhere;
}
popup article .msg :global(:first-child) {
margin-top: 0;
}
popup article .msg :global(:last-child) {
margin-bottom: 0;
}
popup article .msg :global(pre) {
overflow-x: auto;
padding: 0.75em;
border-radius: 4px;
background: var(--background-secondary);
}
popup article .msg :global(code) {
font-family: var(--font-monospace);
}
popup article .msg :global(blockquote) {
margin: 0;
padding-left: 1em;
border-left: 3px solid var(--background-modifier-border);
color: var(--text-muted);
}
popup article .msg :global(ul),
popup article .msg :global(ol) {
padding-left: 1.5em;
}
popup article .msg :global(table) {
width: 100%;
border-collapse: collapse;
}
popup article .msg :global(th),
popup article .msg :global(td) {
padding: 0.4em 0.6em;
border: 1px solid var(--background-modifier-border);
}
popup article .msg :global(a) {
color: var(--text-accent);
}
popup .buttons {
border-top: 1px solid var(--background-primary-alt);
display: flex;
justify-content: center;
align-items: center;
padding: 1em;
}
popup .buttons button {
margin: 0 0.5em;
background-color: var(--background-primary-alt);
}
popup .buttons.vertical {
align-items: stretch;
flex-direction: column;
}
popup .buttons.vertical button {
margin: 0.25em 0;
width: 100%;
}
popup ~ .background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.125);
}
</style>
-126
View File
@@ -1,126 +0,0 @@
<script lang="ts">
type Props = {
title: string;
message: string;
initialText?: string;
placeholder?: string;
isPassword?: boolean;
commit: (text: string | false) => void;
};
const { title, message, commit, initialText, placeholder, isPassword }: Props = $props();
function initialTextSeed(): string {
return initialText ?? "";
}
let text = $state(initialTextSeed());
const type = $derived(isPassword ? "password" : "text");
function cancel() {
commit(false);
}
function handleKey(event: KeyboardEvent) {
if (event.key === "Escape") {
handleCancel(event);
} else if (event.key === "Enter") {
handleCommit(event);
}
}
function handleCancel(event: KeyboardEvent | MouseEvent) {
cancel();
event.preventDefault();
}
function handleCommit(event: KeyboardEvent | MouseEvent) {
commit(text);
event.preventDefault();
}
let textEl: HTMLInputElement;
$effect(() => {
textEl.focus();
});
</script>
<popup>
<header>{title}</header>
<article>
<div class="msg">{message}</div>
<div class="input">
<input
bind:this={textEl}
{type}
bind:value={text}
{placeholder}
onkeydown={handleKey}
onkeyup={handleKey}
/>
</div>
</article>
<div class="buttons">
<button onclick={handleCommit}>OK</button>
<button onclick={handleCancel}>Cancel</button>
</div>
</popup>
<div class="background" onclick={handleCancel} onkeydown={handleKey} role="none"></div>
<style>
popup {
z-index: 1000;
position: fixed;
background: rgba(255, 255, 255, 0.8);
max-width: 70vw;
max-height: 80vh;
margin: auto;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-width: 50vw;
min-height: 50vh;
backdrop-filter: blur(5px);
display: flex;
flex-direction: column;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
border: 1px solid var(--background-primary-alt);
justify-content: space-between;
}
popup header {
background: var(--background-primary);
color: var(--text-normal);
padding: 1em;
border-bottom: 1px solid var(--background-primary-alt);
font: size 1.4em;
}
popup article {
align-items: center;
padding: 1em;
display: flex;
justify-content: center;
flex-direction: column;
}
popup article .msg {
overflow-y: auto;
white-space: pre-wrap;
}
popup .buttons {
border-top: 1px solid var(--background-primary-alt);
display: flex;
justify-content: center;
align-items: center;
padding: 1em;
}
popup .buttons button {
margin: 0 0.5em;
background-color: var(--background-primary-alt);
}
popup ~ .background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.125);
}
</style>
@@ -19,3 +19,13 @@ markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) =>
export function renderMessageMarkdown(message: string): string {
return markdownRenderer.render(message);
}
export function renderMessageMarkdownInto(container: HTMLElement, message: string): void {
const DOMParserConstructor = container.ownerDocument.defaultView?.DOMParser;
if (!DOMParserConstructor) {
container.textContent = message;
return;
}
const parsed = new DOMParserConstructor().parseFromString(renderMessageMarkdown(message), "text/html");
container.replaceChildren(...Array.from(parsed.body.childNodes));
}