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