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 3918 additions and 2770 deletions
+26 -176
View File
@@ -1,198 +1,48 @@
# LiveSync WebApp
Browser-based implementation of Self-hosted LiveSync using the FileSystem API.
Note: (I vrtmrz have not tested this so much yet).
# Self-hosted LiveSync WebApp
## Features
WebApp is an experimental proof of concept for running Self-hosted LiveSync against a browser-authorised local Vault. It is not a replacement for the Obsidian plug-in, and it does not provide the plug-in's settings screen, command palette, or setup wizard.
- 🌐 Runs entirely in the browser
- 📁 Uses FileSystem API to access your local vault
- 🔄 Syncs with CouchDB, Object Storage server (compatible with Self-hosted LiveSync plug-in)
- 🚫 No server-side code required!!
- 💾 Settings stored in `.livesync/settings.json` within your vault
- 👁️ Real-time file watching (Chrome 124+ with FileSystemObserver)
## Use
## Requirements
WebApp requires a browser which provides the File System Access API and IndexedDB. Serve the built files over HTTPS, or from `localhost`, then open `webapp.html`.
- **FileSystem API support**:
- Chrome/Edge 86+ (required)
- Opera 72+ (required)
- Safari 15.2+ (experimental, limited support)
- Firefox: Not supported yet
1. Select the local Vault root.
2. Create or edit `.livesync/settings.json` in that Vault.
3. Reload WebApp to apply the settings.
- **FileSystemObserver support** (optional, for real-time file watching):
- Chrome 124+ (recommended)
- Without this, files are only scanned on startup
## Getting Started
### Installation
```bash
# Install dependencies (ensure you are in repository root directory, not src/apps/cli)
# due to shared dependencies with webapp and main library
npm install
```
### Development
From the repository root:
```bash
npm run dev -w livesync-webapp
```
Or from the package directory:
```bash
cd src/apps/webapp
npm run dev
```
This will start a development server at `http://localhost:3000`.
### Build
From the repository root:
```bash
npm run build -w livesync-webapp
```
Or from the package directory:
```bash
cd src/apps/webapp
npm run build
```
The built files will be in the `dist` directory.
### Usage
1. Open the webapp in your browser (`webapp.html`)
2. Select a vault from history or grant access to a new directory
3. Configure CouchDB connection by editing `.livesync/settings.json` in your vault
- You can also copy data.json from Obsidian's plug-in folder.
Example `.livesync/settings.json`:
For example, a manually configured CouchDB connection includes:
```json
{
"couchDB_URI": "https://your-couchdb-server.com",
"couchDB_USER": "your-username",
"couchDB_PASSWORD": "your-password",
"couchDB_DBNAME": "your-database",
"couchDB_URI": "https://couchdb.example.com",
"couchDB_USER": "username",
"couchDB_PASSWORD": "password",
"couchDB_DBNAME": "vault",
"isConfigured": true,
"liveSync": true,
"syncOnSave": true
}
```
After editing, reload the page.
The visible P2P controls are optional. Saving them does not select P2P as the main remote or mark an otherwise unconfigured Vault as configured.
## Architecture
CouchDB must permit access from the WebApp origin. Other remote types, browser background behaviour, and browsers without the required file-system APIs remain experimental.
### Directory Structure
## Development
```
webapp/
├── adapters/ # FileSystem API adapters
│ ├── FSAPITypes.ts
│ ├── FSAPIPathAdapter.ts
│ ├── FSAPITypeGuardAdapter.ts
│ ├── FSAPIConversionAdapter.ts
│ ├── FSAPIStorageAdapter.ts
│ ├── FSAPIVaultAdapter.ts
│ └── FSAPIFileSystemAdapter.ts
├── managers/ # Event managers
│ ├── FSAPIStorageEventManagerAdapter.ts
│ └── StorageEventManagerFSAPI.ts
├── serviceModules/ # Service implementations
│ ├── FileAccessFSAPI.ts
│ ├── ServiceFileAccessImpl.ts
│ ├── DatabaseFileAccess.ts
│ └── FSAPIServiceModules.ts
├── bootstrap.ts # Vault picker + startup orchestration
├── main.ts # LiveSync core bootstrap (after vault selected)
├── vaultSelector.ts # FileSystem handle history and permission flow
├── webapp.html # Main HTML entry
├── index.html # Redirect entry for compatibility
├── package.json
├── vite.config.ts
└── README.md
From the repository root:
```bash
npm run dev --workspace livesync-webapp
npm run build --workspace livesync-webapp
npm run test:unit --workspace livesync-webapp
npm run test:browser --workspace livesync-webapp
```
### Key Components
The production files are written to `src/apps/webapp/dist/`. App-owned unit tests are stored in `test/apps/webapp/`, outside the Community Review source boundary. The browser test builds the production artefact, selects an OPFS-backed test Vault in Chromium, and verifies start-up and P2P setting isolation.
1. **Adapters**: Implement `IFileSystemAdapter` interface using FileSystem API
2. **Managers**: Handle storage events and file watching
3. **Service Modules**: Integrate with LiveSyncBaseCore
4. **Main**: Application initialisation and lifecycle management
`WebAppRuntime.ts` owns the LiveSync service composition and lifecycle. `main.ts` owns Vault selection and the small browser shell, while `adapters/`, `managers/`, and `serviceModules/` provide the File System Access API boundary.
### Service Hub
## Licence
Uses `BrowserServiceHub` which provides:
- Database service (IndexedDB via PouchDB)
- Settings service (file-based in `.livesync/settings.json`)
- Replication service
- File processing service
- And more...
## Limitations
- **Real-time file watching**: Requires Chrome 124+ with FileSystemObserver
- Without it, changes are only detected on manual refresh
- **Performance**: Slower than native file system access
- **Permissions**: Requires user to grant directory access (cached via IndexedDB)
- **Browser support**: Limited to browsers with FileSystem API support
## Differences from CLI Version
- Uses `BrowserServiceHub` instead of `HeadlessServiceHub`
- Uses FileSystem API instead of Node.js `fs`
- Settings stored in `.livesync/settings.json` in vault
- Real-time file watching only with FileSystemObserver (Chrome 124+)
## Differences from Obsidian Plug-in
- No Obsidian-specific modules (UI, settings dialogue, etc.)
- Simplified configuration
- No plug-in/theme sync features
- No internal file handling (`.obsidian` folder)
## Development Notes
- TypeScript configuration: Uses project's tsconfig.json
- Module resolution: Aliased paths via Vite config
- External dependencies: Bundled by Vite
## Troubleshooting
### "Failed to get directory access"
- Make sure you're using a supported browser
- Try refreshing the page
- Clear browser cache and IndexedDB
### "Settings not found"
- Check that `.livesync/settings.json` exists in your vault directory
- Verify the JSON format is valid
- Create the file manually if needed
### "File watching not working"
- Make sure you're using Chrome 124 or later
- Check browser console for FileSystemObserver messages
- Try manually triggering sync if automatic watching isn't available
### "Sync not working"
- Verify CouchDB credentials
- Check browser console for errors
- Ensure CouchDB server is accessible (CORS enabled)
## License
Same as the main Self-hosted LiveSync project.
The same licence as the main Self-hosted LiveSync project applies.
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import P2PReplicatorPane from "@/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte";
import BrowserP2PTransportSettings from "@/apps/browser/BrowserP2PTransportSettings.svelte";
import type { WebAppRuntime } from "./WebAppRuntime";
interface Props {
runtime: WebAppRuntime;
}
let { runtime }: Props = $props();
let isScanning = $state(false);
let scanStatus = $state("");
async function scanLocalFiles() {
isScanning = true;
scanStatus = "Scanning local files…";
try {
scanStatus = (await runtime.scanLocalFiles())
? "Local files are ready for synchronisation."
: "The local file scan could not be completed.";
} catch (error) {
scanStatus = `The local file scan failed: ${String(error)}`;
} finally {
isScanning = false;
}
}
</script>
<div class="local-file-actions">
<button type="button" disabled={isScanning} onclick={scanLocalFiles}>Scan local files</button>
<span role="status" aria-live="polite">{scanStatus}</span>
</div>
<BrowserP2PTransportSettings host={runtime.p2pPaneHost} />
<P2PReplicatorPane
host={runtime.p2pPaneHost}
/>
<style>
.local-file-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
</style>
+329
View File
@@ -0,0 +1,329 @@
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { initialiseServiceModulesFSAPI, type FSAPIServiceModules } from "./serviceModules/FSAPIServiceModules";
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type LOG_LEVEL,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
collectFilesOnStorage,
updateToDatabase,
useOfflineScanner,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { useRedFlagFeatures } from "@/serviceFeatures/redFlag";
import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize";
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import {
createLiveSyncBrowserServiceHub,
type LiveSyncBrowserServiceHub,
type LiveSyncBrowserServiceHubOptions,
} from "@/apps/browser/createLiveSyncBrowserServiceHub";
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
const SETTINGS_DIR = ".livesync";
const SETTINGS_FILE = "settings.json";
const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
liveSync: false,
syncOnSave: true,
syncOnStart: false,
savingDelay: 200,
lessInformationInLog: false,
gcDelay: 0,
periodicReplication: false,
periodicReplicationInterval: 60,
isConfigured: false,
// CouchDB settings - user needs to configure these
couchDB_URI: "",
couchDB_USER: "",
couchDB_PASSWORD: "",
couchDB_DBNAME: "",
// Disable features which are not available in the WebApp.
usePluginSync: false,
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
};
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
export interface WebAppRuntimeOptions {
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
scheduleReload?: (delayMilliseconds: number) => void;
}
export class WebAppRuntime {
private readonly rootHandle: FileSystemDirectoryHandle;
private readonly reportStatus: NonNullable<WebAppRuntimeOptions["reportStatus"]>;
private readonly scheduleReload: NonNullable<WebAppRuntimeOptions["scheduleReload"]>;
private core: LiveSyncBaseCore<ServiceContext, never> | null = null;
private serviceHub: LiveSyncBrowserServiceHub<ServiceContext> | null = null;
private platformServiceModules: FSAPIServiceModules | null = null;
private p2p: UseP2PReplicatorResult | null = null;
private paneHost: P2PReplicatorPaneHost | null = null;
private restartScheduled = false;
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) {
this.rootHandle = rootHandle;
this.reportStatus = options.reportStatus ?? (() => {});
this.scheduleReload =
options.scheduleReload ??
((delayMilliseconds) => {
compatGlobal.setTimeout(() => {
compatGlobal.location.reload();
}, delayMilliseconds);
});
}
private addLog(message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void {
this.serviceHub?.API.addLog(message, level, key);
}
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
return {
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
settings: {
save: async (data) => {
try {
await this.saveSettingsToFile(data);
this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
} catch (error) {
this.addLog(`Failed to save settings: ${String(error)}`, LOG_LEVEL_NOTICE, "settings");
}
},
load: async () => {
try {
const data = await this.loadSettingsFromFile();
if (data) {
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
}
} catch {
this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings");
}
return DEFAULT_SETTINGS as ObsidianLiveSyncSettings;
},
},
restart: {
schedule: () => this.scheduleRestart(),
perform: () => this.scheduleRestart(),
ask: () => this.scheduleRestart(),
isScheduled: () => this.restartScheduled,
},
};
}
private scheduleRestart(): void {
if (this.restartScheduled) {
return;
}
this.restartScheduled = true;
void (async () => {
this.addLog("Restart requested", LOG_LEVEL_INFO, "app-lifecycle");
await this.shutdown();
this.scheduleReload(1000);
})();
}
get events(): LiveSyncEventHub {
if (!this.serviceHub) {
throw new Error("The WebApp service hub is not initialised");
}
return this.serviceHub.context.events;
}
get p2pPaneHost(): P2PReplicatorPaneHost {
if (!this.paneHost) {
throw new Error("The WebApp P2P pane host is not initialised");
}
return this.paneHost;
}
async scanLocalFiles(): Promise<boolean> {
const core = this.core;
const fileAccess = this.platformServiceModules?.vaultAccess;
if (!core || !fileAccess) {
throw new Error("The WebApp core is not initialised");
}
fileAccess.fsapiAdapter.clearCache();
await fileAccess.fsapiAdapter.scanDirectory();
const log = (message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void => {
this.addLog(message, level, key);
};
const settings = core.services.setting.currentSettings();
const { storageFileNameMap, storageFileNames } = await collectFilesOnStorage(core, settings, log);
let succeeded = true;
for (const path of storageFileNames) {
try {
await updateToDatabase(core, log, LOG_LEVEL_INFO, storageFileNameMap[path]);
} catch (error) {
succeeded = false;
this.addLog(`Failed to import ${path}: ${String(error)}`, LOG_LEVEL_NOTICE, "scan");
}
}
return succeeded;
}
async start(): Promise<void> {
if (this.core) {
throw new Error("The WebApp runtime has already been started");
}
// Create service context and hub
this.serviceHub = createLiveSyncBrowserServiceHub<ServiceContext>(this.createServiceOptions());
this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise");
this.addLog("Initialising...", LOG_LEVEL_VERBOSE, "initialise");
this.addLog(`Vault directory: ${this.rootHandle.name}`, LOG_LEVEL_VERBOSE, "initialise");
// Create LiveSync core
this.core = new LiveSyncBaseCore<ServiceContext, never>(
this.serviceHub,
(core, serviceHub) => {
const serviceModules = initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub);
this.platformServiceModules = serviceModules;
return serviceModules;
},
() => [],
() => [] as never[], // No add-ons
(core) => {
useOfflineScanner(core);
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useRemoteConfiguration(core);
this.p2p = useP2PReplicatorFeature(core);
this.paneHost = {
services: core.services,
p2p: this.p2p,
};
}
);
try {
await this.startCore();
} catch (error) {
try {
await this.shutdown();
} catch (shutdownError) {
this.addLog(`Failed to clean up after start failure: ${String(shutdownError)}`, LOG_LEVEL_NOTICE);
}
throw error;
}
}
private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise<void> {
// Create .livesync directory if it does not exist
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true });
// Create/overwrite settings.json
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
}
private async loadSettingsFromFile(): Promise<Partial<ObsidianLiveSyncSettings> | null> {
try {
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR);
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE);
const file = await fileHandle.getFile();
const text = await file.text();
const parsed: unknown = JSON.parse(text);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("The WebApp settings file does not contain an object");
}
return parsed;
} catch {
// The file does not exist yet.
return null;
}
}
private async startCore(): Promise<void> {
if (!this.core) {
throw new Error("Core not initialised");
}
try {
this.addLog("Initialising LiveSync...", LOG_LEVEL_INFO, "start");
const loadResult = await this.core.services.control.onLoad();
if (!loadResult) {
this.addLog("Failed to initialise LiveSync", LOG_LEVEL_NOTICE, "start");
throw new Error("Failed to initialise LiveSync");
}
await this.core.services.control.onReady();
this.addLog("LiveSync is running", LOG_LEVEL_INFO, "ready");
// Check if configured
const settings = this.core.services.setting.currentSettings();
if (!settings.isConfigured) {
this.addLog("LiveSync is not configured yet", LOG_LEVEL_NOTICE, "configuration");
this.showWarning("Please configure CouchDB connection in settings");
} else {
this.addLog("LiveSync is configured and ready", LOG_LEVEL_INFO, "configuration");
this.addLog(`Database: ${settings.couchDB_DBNAME}`, LOG_LEVEL_VERBOSE, "configuration");
this.showSuccess("LiveSync is ready!");
}
// Scan the directory to populate file cache
const fileAccess = this.platformServiceModules?.vaultAccess;
if (fileAccess) {
this.addLog("Scanning vault directory...", LOG_LEVEL_VERBOSE, "scan");
await fileAccess.fsapiAdapter.scanDirectory();
const files = await fileAccess.fsapiAdapter.getFiles();
this.addLog(`Found ${files.length} files`, LOG_LEVEL_VERBOSE, "scan");
}
} catch (error) {
this.addLog(`Failed to start: ${String(error)}`, LOG_LEVEL_NOTICE, "start");
this.showError(`Failed to start: ${String(error)}`);
throw error;
}
}
async shutdown(): Promise<void> {
const core = this.core;
if (!core) {
return;
}
this.core = null;
this.paneHost = null;
this.addLog("Shutting down...", LOG_LEVEL_INFO, "shutdown");
const storageEventManager = this.platformServiceModules?.storageEventManager;
this.platformServiceModules = null;
try {
if (storageEventManager) {
await storageEventManager.cleanup();
}
} finally {
await core.services.control.onUnload();
this.p2p = null;
this.addLog("Shutdown complete", LOG_LEVEL_INFO, "shutdown");
this.serviceHub = null;
}
}
private showError(message: string): void {
this.reportStatus("error", `Error: ${message}`);
}
private showWarning(message: string): void {
this.reportStatus("warning", `Warning: ${message}`);
}
private showSuccess(message: string): void {
this.reportStatus("success", message);
}
}
-144
View File
@@ -1,144 +0,0 @@
import { LiveSyncWebApp } from "./main";
import { createNativeElement } from "@/apps/browserDom";
import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector";
import { compatGlobal, _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
const historyStore = new VaultHistoryStore();
let app: LiveSyncWebApp | null = null;
type LiveSyncWebAppDebugApi = {
getApp: () => LiveSyncWebApp | null;
historyStore: VaultHistoryStore;
};
type LiveSyncWebAppGlobal = typeof compatGlobal & {
livesyncApp?: LiveSyncWebAppDebugApi;
};
function getRequiredElement<T extends HTMLElement>(id: string): T {
const element = _activeDocument.getElementById(id);
if (!element) {
throw new Error(`Missing element: #${id}`);
}
return element as T;
}
function setStatus(kind: "info" | "warning" | "error" | "success", message: string): void {
const statusEl = getRequiredElement<HTMLDivElement>("status");
statusEl.className = kind;
statusEl.textContent = message;
}
function setBusyState(isBusy: boolean): void {
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
pickNewBtn.disabled = isBusy;
const historyButtons = _activeDocument.querySelectorAll<HTMLButtonElement>(".vault-item button");
historyButtons.forEach((button) => {
button.disabled = isBusy;
});
}
function formatLastUsed(unixMillis: number): string {
if (!unixMillis) {
return "unknown";
}
return new Date(unixMillis).toLocaleString();
}
async function renderHistoryList(): Promise<VaultHistoryItem[]> {
const listEl = getRequiredElement<HTMLDivElement>("vault-history-list");
const emptyEl = getRequiredElement<HTMLParagraphElement>("vault-history-empty");
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
listEl.replaceChildren();
emptyEl.classList.toggle("is-hidden", items.length > 0);
for (const item of items) {
const row = createNativeElement(_activeDocument, "div");
row.className = "vault-item";
const info = createNativeElement(_activeDocument, "div");
info.className = "vault-item-info";
const name = createNativeElement(_activeDocument, "div");
name.className = "vault-item-name";
name.textContent = item.name;
const meta = createNativeElement(_activeDocument, "div");
meta.className = "vault-item-meta";
const label = item.id === lastUsedId ? "Last used" : "Used";
meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`;
info.append(name, meta);
const useButton = createNativeElement(_activeDocument, "button");
useButton.type = "button";
useButton.textContent = "Use this vault";
useButton.addEventListener("click", () => {
void startWithHistory(item);
});
row.append(info, useButton);
listEl.appendChild(row);
}
return items;
}
async function startWithHandle(handle: FileSystemDirectoryHandle): Promise<void> {
setStatus("info", `Starting LiveSync with vault: ${handle.name}`);
app = new LiveSyncWebApp(handle);
await app.initialize();
const selectorEl = getRequiredElement<HTMLDivElement>("vault-selector");
selectorEl.classList.add("is-hidden");
}
async function startWithHistory(item: VaultHistoryItem): Promise<void> {
setBusyState(true);
try {
const handle = await historyStore.activateHistoryItem(item);
await startWithHandle(handle);
} catch (error) {
setStatus("error", `Failed to open saved vault: ${String(error)}`);
setBusyState(false);
}
}
async function startWithNewPicker(): Promise<void> {
setBusyState(true);
try {
const handle = await historyStore.pickNewVault();
await startWithHandle(handle);
} catch (error) {
setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`);
setBusyState(false);
}
}
async function initializeVaultSelector(): Promise<void> {
setStatus("info", "Select a vault folder to start LiveSync.");
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
pickNewBtn.addEventListener("click", () => {
void startWithNewPicker();
});
await renderHistoryList();
}
compatGlobal.addEventListener("load", () => {
initializeVaultSelector().catch((error) => {
setStatus("error", `Initialization failed: ${String(error)}`);
});
});
compatGlobal.addEventListener("beforeunload", () => {
void app?.shutdown();
});
(compatGlobal as LiveSyncWebAppGlobal).livesyncApp = {
getApp: () => app,
historyStore,
};
+150 -253
View File
@@ -1,275 +1,172 @@
/**
* Self-hosted LiveSync WebApp
* Browser-based version of Self-hosted LiveSync plugin using FileSystem API
*/
import type { BrowserServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { initialiseServiceModulesFSAPI, type FSAPIServiceModules } from "./serviceModules/FSAPIServiceModules";
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type LOG_LEVEL,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
import type { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { useRedFlagFeatures } from "@/serviceFeatures/redFlag";
import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize";
import { useSetupURIFeature } from "@/serviceFeatures/setupObsidian/setupUri";
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { SetupManager } from "@/modules/features/SetupManager";
import { useSetupManagerHandlersFeature } from "@/serviceFeatures/setupObsidian/setupManagerHandlers";
import { useP2PReplicatorCommands } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorCommands";
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
import { createNativeElement } from "@/apps/browserDom";
import { compatGlobal, _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { createLiveSyncBrowserServiceHub } from "@/apps/browser/createLiveSyncBrowserServiceHub";
import { mount } from "svelte";
const SETTINGS_DIR = ".livesync";
const SETTINGS_FILE = "settings.json";
import { WebAppRuntime, type WebAppRuntimeStatusKind } from "./WebAppRuntime";
import WebAppP2P from "./WebAppP2P.svelte";
import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector";
/**
* Default settings for the webapp
*/
const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
liveSync: false,
syncOnSave: true,
syncOnStart: false,
savingDelay: 200,
lessInformationInLog: false,
gcDelay: 0,
periodicReplication: false,
periodicReplicationInterval: 60,
isConfigured: false,
// CouchDB settings - user needs to configure these
couchDB_URI: "",
couchDB_USER: "",
couchDB_PASSWORD: "",
couchDB_DBNAME: "",
// Disable features not needed in webapp
usePluginSync: false,
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
const historyStore = new VaultHistoryStore();
let runtime: WebAppRuntime | null = null;
type LiveSyncWebAppDebugApi = {
getRuntime: () => WebAppRuntime | null;
historyStore: VaultHistoryStore;
};
class LiveSyncWebApp {
private rootHandle: FileSystemDirectoryHandle;
private core: LiveSyncBaseCore<ServiceContext, never> | null = null;
private serviceHub: BrowserServiceHub<ServiceContext> | null = null;
private platformServiceModules: FSAPIServiceModules | null = null;
type LiveSyncWebAppGlobal = typeof compatGlobal & {
livesyncApp?: LiveSyncWebAppDebugApi;
};
constructor(rootHandle: FileSystemDirectoryHandle) {
this.rootHandle = rootHandle;
function getRequiredElement<T extends HTMLElement>(id: string): T {
const element = _activeDocument.getElementById(id);
if (!element) {
throw new Error(`Missing element: #${id}`);
}
return element as T;
}
private addLog(message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void {
this.serviceHub?.API.addLog(message, level, key);
function setStatus(kind: WebAppRuntimeStatusKind, message: string): void {
const statusEl = getRequiredElement<HTMLDivElement>("status");
statusEl.className = kind;
statusEl.textContent = message;
}
function setBusyState(isBusy: boolean): void {
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
pickNewBtn.disabled = isBusy;
const historyButtons = _activeDocument.querySelectorAll<HTMLButtonElement>(".vault-item button");
historyButtons.forEach((button) => {
button.disabled = isBusy;
});
}
function formatLastUsed(unixMillis: number): string {
if (!unixMillis) {
return "unknown";
}
return new Date(unixMillis).toLocaleString();
}
async initialize() {
// Create service context and hub
this.serviceHub = createLiveSyncBrowserServiceHub<ServiceContext>();
this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise");
this.addLog("Initialising...", LOG_LEVEL_VERBOSE, "initialise");
this.addLog(`Vault directory: ${this.rootHandle.name}`, LOG_LEVEL_VERBOSE, "initialise");
async function renderHistoryList(): Promise<VaultHistoryItem[]> {
const listEl = getRequiredElement<HTMLDivElement>("vault-history-list");
const emptyEl = getRequiredElement<HTMLParagraphElement>("vault-history-empty");
// Setup API service
(this.serviceHub.API as BrowserAPIService<ServiceContext>).getSystemVaultName.setHandler(
() => this.rootHandle?.name || "livesync-webapp"
);
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
// Setup settings handlers - save to .livesync folder
const settingService = this.serviceHub.setting as InjectableSettingService<ServiceContext>;
listEl.replaceChildren();
emptyEl.classList.toggle("is-hidden", items.length > 0);
settingService.saveData.setHandler(async (data: ObsidianLiveSyncSettings) => {
try {
await this.saveSettingsToFile(data);
this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
} catch (error) {
this.addLog(`Failed to save settings: ${String(error)}`, LOG_LEVEL_NOTICE, "settings");
}
for (const item of items) {
const row = createNativeElement(_activeDocument, "div");
row.className = "vault-item";
const info = createNativeElement(_activeDocument, "div");
info.className = "vault-item-info";
const name = createNativeElement(_activeDocument, "div");
name.className = "vault-item-name";
name.textContent = item.name;
const meta = createNativeElement(_activeDocument, "div");
meta.className = "vault-item-meta";
const label = item.id === lastUsedId ? "Last used" : "Used";
meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`;
info.append(name, meta);
const useButton = createNativeElement(_activeDocument, "button");
useButton.type = "button";
useButton.textContent = "Use this vault";
useButton.addEventListener("click", () => {
void startWithHistory(item);
});
settingService.loadData.setHandler(async (): Promise<ObsidianLiveSyncSettings | undefined> => {
try {
const data = await this.loadSettingsFromFile();
if (data) {
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
}
} catch {
this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings");
}
return DEFAULT_SETTINGS as ObsidianLiveSyncSettings;
});
// App lifecycle handlers
this.serviceHub.appLifecycle.scheduleRestart.setHandler(() => {
void (async () => {
this.addLog("Restart requested", LOG_LEVEL_INFO, "app-lifecycle");
await this.shutdown();
await this.initialize();
compatGlobal.setTimeout(() => {
compatGlobal.location.reload();
}, 1000);
})();
});
// Create LiveSync core
this.core = new LiveSyncBaseCore<ServiceContext, never>(
this.serviceHub,
(core, serviceHub) => {
const serviceModules = initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub);
this.platformServiceModules = serviceModules;
return serviceModules;
},
(core) => [
// new ModuleObsidianEvents(this, core),
// new ModuleObsidianSettingDialogue(this, core),
// new ModuleObsidianMenu(core),
// new ModuleObsidianSettingsAsMarkdown(core),
// new ModuleLog(this, core),
// new ModuleObsidianDocumentHistory(this, core),
// new ModuleInteractiveConflictResolver(this, core),
// new ModuleObsidianGlobalHistory(this, core),
// new ModuleDev(this, core),
// new ModuleReplicateTest(this, core),
// new ModuleIntegratedTest(this, core),
// new ModuleReplicatorP2P(core), // Register P2P replicator for CLI (useP2PReplicator is not used here)
new SetupManager(core),
],
() => [] as never[], // No add-ons
(core) => {
useOfflineScanner(core);
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useRemoteConfiguration(core);
const replicator = useP2PReplicatorFeature(core);
useP2PReplicatorCommands(core, replicator);
const setupManager = core.getModule(SetupManager);
useSetupManagerHandlersFeature(core, setupManager);
useSetupURIFeature(core);
}
);
// Start the core
await this.start();
row.append(info, useButton);
listEl.appendChild(row);
}
private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise<void> {
// Create .livesync directory if it does not exist
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true });
return items;
}
// Create/overwrite settings.json
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
async function startWithHandle(handle: FileSystemDirectoryHandle): Promise<void> {
setStatus("info", `Starting LiveSync with vault: ${handle.name}`);
const nextRuntime = new WebAppRuntime(handle, { reportStatus: setStatus });
await nextRuntime.start();
runtime = nextRuntime;
const selectorEl = getRequiredElement<HTMLDivElement>("vault-selector");
selectorEl.classList.add("is-hidden");
const p2pControl = getRequiredElement<HTMLElement>("p2p-control");
mount(WebAppP2P, {
target: p2pControl,
props: {
runtime: nextRuntime,
},
});
p2pControl.classList.remove("is-hidden");
}
async function startWithHistory(item: VaultHistoryItem): Promise<void> {
setBusyState(true);
let handle: FileSystemDirectoryHandle;
try {
handle = await historyStore.activateHistoryItem(item);
} catch (error) {
setStatus("error", `Failed to open saved vault: ${String(error)}`);
setBusyState(false);
return;
}
private async loadSettingsFromFile(): Promise<Partial<ObsidianLiveSyncSettings> | null> {
try {
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR);
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE);
const file = await fileHandle.getFile();
const text = await file.text();
const parsed: unknown = JSON.parse(text);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("The WebApp settings file does not contain an object");
}
return parsed;
} catch {
// File doesn't exist yet
return null;
}
}
private async start() {
if (!this.core) {
throw new Error("Core not initialized");
}
try {
this.addLog("Initialising LiveSync...", LOG_LEVEL_INFO, "start");
const loadResult = await this.core.services.control.onLoad();
if (!loadResult) {
this.addLog("Failed to initialise LiveSync", LOG_LEVEL_NOTICE, "start");
this.showError("Failed to initialize LiveSync");
return;
}
await this.core.services.control.onReady();
this.addLog("LiveSync is running", LOG_LEVEL_INFO, "ready");
// Check if configured
const settings = this.core.services.setting.currentSettings();
if (!settings.isConfigured) {
this.addLog("LiveSync is not configured yet", LOG_LEVEL_NOTICE, "configuration");
this.showWarning("Please configure CouchDB connection in settings");
} else {
this.addLog("LiveSync is configured and ready", LOG_LEVEL_INFO, "configuration");
this.addLog(`Database: ${settings.couchDB_DBNAME}`, LOG_LEVEL_VERBOSE, "configuration");
this.showSuccess("LiveSync is ready!");
}
// Scan the directory to populate file cache
const fileAccess = this.platformServiceModules?.vaultAccess;
if (fileAccess) {
this.addLog("Scanning vault directory...", LOG_LEVEL_VERBOSE, "scan");
await fileAccess.fsapiAdapter.scanDirectory();
const files = await fileAccess.fsapiAdapter.getFiles();
this.addLog(`Found ${files.length} files`, LOG_LEVEL_VERBOSE, "scan");
}
} catch (error) {
this.addLog(`Failed to start: ${String(error)}`, LOG_LEVEL_NOTICE, "start");
this.showError(`Failed to start: ${error}`);
}
}
async shutdown() {
if (this.core) {
this.addLog("Shutting down...", LOG_LEVEL_INFO, "shutdown");
// Stop file watching
const storageEventManager = this.platformServiceModules?.storageEventManager;
if (storageEventManager) {
await storageEventManager.cleanup();
}
await this.core.services.control.onUnload();
this.platformServiceModules = null;
this.addLog("Shutdown complete", LOG_LEVEL_INFO, "shutdown");
}
}
private showError(message: string) {
const statusEl = _activeDocument.getElementById("status");
if (statusEl) {
statusEl.className = "error";
statusEl.textContent = `Error: ${message}`;
}
}
private showWarning(message: string) {
const statusEl = _activeDocument.getElementById("status");
if (statusEl) {
statusEl.className = "warning";
statusEl.textContent = `Warning: ${message}`;
}
}
private showSuccess(message: string) {
const statusEl = _activeDocument.getElementById("status");
if (statusEl) {
statusEl.className = "success";
statusEl.textContent = message;
}
try {
await startWithHandle(handle);
} catch (error) {
setStatus("error", `Failed to start LiveSync: ${String(error)}`);
setBusyState(false);
}
}
export { LiveSyncWebApp };
async function startWithNewPicker(): Promise<void> {
setBusyState(true);
let handle: FileSystemDirectoryHandle;
try {
handle = await historyStore.pickNewVault();
} catch (error) {
setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`);
setBusyState(false);
return;
}
try {
await startWithHandle(handle);
} catch (error) {
setStatus("error", `Failed to start LiveSync: ${String(error)}`);
setBusyState(false);
}
}
async function initialiseVaultSelector(): Promise<void> {
setStatus("info", "Select a vault folder to start LiveSync.");
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
pickNewBtn.addEventListener("click", () => {
void startWithNewPicker();
});
await renderHistoryList();
}
compatGlobal.addEventListener("load", () => {
initialiseVaultSelector().catch((error) => {
setStatus("error", `Initialisation failed: ${String(error)}`);
});
});
compatGlobal.addEventListener("beforeunload", () => {
void runtime?.shutdown();
});
(compatGlobal as LiveSyncWebAppGlobal).livesyncApp = {
getRuntime: () => runtime,
historyStore,
};
@@ -74,7 +74,8 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onerror = () =>
reject(request.error ?? new Error("Failed to open the WebApp snapshot database"));
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
@@ -95,7 +96,8 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
await new Promise<void>((resolve, reject) => {
const request = store.put(snapshot, this.snapshotKey);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
request.onerror = () =>
reject(request.error ?? new Error("Failed to save the WebApp storage-event snapshot"));
});
db.close();
@@ -114,7 +116,8 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
const request = store.get(this.snapshotKey);
request.onsuccess = () =>
resolve((request.result as (FileEventItem | FileEventItemSentinel)[] | undefined) ?? null);
request.onerror = () => reject(request.error);
request.onerror = () =>
reject(request.error ?? new Error("Failed to load the WebApp storage-event snapshot"));
});
db.close();
File diff suppressed because it is too large Load Diff
@@ -1,85 +0,0 @@
import { describe, expect, it } from "vitest";
import { fileURLToPath, path } from "@vrtmrz/livesync-commonlib/node";
import * as ts from "typescript";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const dependencyFacadePath = path.resolve(repositoryRoot, "src/deps.ts");
const obsidianMockPath = path.resolve(repositoryRoot, "src/apps/webapp/obsidianMock.ts");
function parseSourceFile(filePath: string): ts.SourceFile {
const source = ts.sys.readFile(filePath);
if (source === undefined) throw new Error(`Could not read ${filePath}`);
return ts.createSourceFile(filePath, source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
}
function hasExportModifier(statement: ts.Statement): boolean {
if (!ts.canHaveModifiers(statement)) return false;
return ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
}
function addBindingNames(name: ts.BindingName, names: Set<string>): void {
if (ts.isIdentifier(name)) {
names.add(name.text);
return;
}
for (const element of name.elements) {
if (!ts.isOmittedExpression(element)) addBindingNames(element.name, names);
}
}
describe("Webapp Obsidian mock exports", () => {
it("provides every value re-exported from Obsidian", () => {
const dependencyFacade = parseSourceFile(dependencyFacadePath);
const obsidianMock = parseSourceFile(obsidianMockPath);
const requiredExports = new Set<string>();
for (const statement of dependencyFacade.statements) {
if (
ts.isExportDeclaration(statement) &&
statement.moduleSpecifier &&
ts.isStringLiteral(statement.moduleSpecifier) &&
statement.moduleSpecifier.text === "obsidian" &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause) &&
!statement.isTypeOnly
) {
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) {
requiredExports.add((element.propertyName ?? element.name).text);
}
}
}
}
const availableExports = new Set<string>();
for (const statement of obsidianMock.statements) {
if (
ts.isExportDeclaration(statement) &&
!statement.isTypeOnly &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const element of statement.exportClause.elements) {
if (!element.isTypeOnly) availableExports.add(element.name.text);
}
continue;
}
if (!hasExportModifier(statement)) continue;
if (
(ts.isClassDeclaration(statement) ||
ts.isFunctionDeclaration(statement) ||
ts.isEnumDeclaration(statement)) &&
statement.name
) {
availableExports.add(statement.name.text);
} else if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
addBindingNames(declaration.name, availableExports);
}
}
}
const missingExports = [...requiredExports].filter((name) => !availableExports.has(name)).sort();
expect(missingExports).toEqual([]);
});
});
+4 -1
View File
@@ -9,7 +9,10 @@
"build": "vite build",
"build:docker": "docker build -f Dockerfile -t livesync-webapp ../../..",
"run:docker": "docker run -p 8002:80 livesync-webapp",
"preview": "vite preview"
"preview": "vite preview",
"test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webapp",
"pretest:browser": "npm run build",
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.51"
+3 -2
View File
@@ -68,7 +68,8 @@ export class VaultHistoryStore {
private async openHandleDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(HANDLE_DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onerror = () =>
reject(request.error ?? new Error("Could not open the WebApp Vault history database"));
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
@@ -93,7 +94,7 @@ export class VaultHistoryStore {
private async requestAsPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onerror = () => reject(request.error ?? new Error("The WebApp Vault history request failed"));
});
}
-2
View File
@@ -20,7 +20,6 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "../../"),
obsidian: path.resolve(__dirname, "./obsidianMock.ts"),
},
},
base: "./",
@@ -39,7 +38,6 @@ export default defineConfig({
MANIFEST_VERSION: JSON.stringify(process.env.MANIFEST_VERSION || manifestVersion || "0.0.0"),
PACKAGE_VERSION: JSON.stringify(process.env.PACKAGE_VERSION || packageVersion || "0.0.0"),
global: "globalThis",
hostPlatform: JSON.stringify(process.platform || "linux"),
},
server: {
port: 3000,
+12 -3
View File
@@ -31,7 +31,7 @@ body {
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 700px;
max-width: 1100px;
width: 100%;
}
@@ -161,10 +161,19 @@ button:disabled {
}
.empty-note.is-hidden,
.vault-selector.is-hidden {
.vault-selector.is-hidden,
.p2p-control.is-hidden {
display: none;
}
.p2p-control {
margin-bottom: 22px;
padding: 16px;
border: 1px solid #e6e9f2;
border-radius: 8px;
background: #fbfcff;
}
.info-section {
margin-top: 20px;
padding: 20px;
@@ -397,4 +406,4 @@ popup {
align-items: center;
justify-content: center;
backdrop-filter: blur(15px);
}
}
+40 -38
View File
@@ -1,45 +1,47 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Self-hosted LiveSync WebApp</title>
<link rel="stylesheet" href="./webapp.css">
</head>
<body>
<div class="container">
<h1>Self-hosted LiveSync on Web</h1>
<p class="subtitle">Browser-based Self-hosted LiveSync using FileSystem API</p>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Self-hosted LiveSync WebApp</title>
<link rel="stylesheet" href="./webapp.css" />
</head>
<body>
<div class="container">
<h1>Self-hosted LiveSync on Web</h1>
<p class="subtitle">Experimental browser proof of concept using the File System Access API</p>
<div id="status" class="info">Initialising...</div>
<div id="status" class="info">Initialising...</div>
<div id="vault-selector" class="vault-selector">
<h2>Select Vault Folder</h2>
<p>Open a vault you already used, or pick a new folder.</p>
<div id="vault-selector" class="vault-selector">
<h2>Select Vault Folder</h2>
<p>Open a vault you already used, or pick a new folder.</p>
<div id="vault-history-list" class="vault-list"></div>
<p id="vault-history-empty" class="empty-note">No saved vaults yet.</p>
<button id="pick-new-vault" type="button">Choose new vault folder</button>
<div id="vault-history-list" class="vault-list"></div>
<p id="vault-history-empty" class="empty-note">No saved vaults yet.</p>
<button id="pick-new-vault" type="button">Choose new vault folder</button>
</div>
<section id="p2p-control" class="p2p-control is-hidden"></section>
<div class="info-section">
<h2>How to Use</h2>
<ul>
<li>Select the local Vault root and grant access.</li>
<li>Create or edit <code>.livesync/settings.json</code> in that Vault.</li>
<li>Reload this page to apply the settings.</li>
<li>Use the P2P controls only as an optional secondary connection.</li>
</ul>
</div>
<div class="footer">
<p>
Powered by
<a href="https://github.com/vrtmrz/obsidian-livesync" target="_blank">Self-hosted LiveSync</a>
</p>
</div>
</div>
<div class="info-section">
<h2>How to Use</h2>
<ul>
<li>Select a vault folder and grant permission</li>
<li>Create <code>.livesync/settings.json</code> in your vault folder</li>
<li>Or use Setup-URI to apply settings</li>
<li>Your files will be synced after "replicate now"</li>
</ul>
</div>
<div class="footer">
<p>
Powered by
<a href="https://github.com/vrtmrz/obsidian-livesync" target="_blank">Self-hosted LiveSync</a>
</p>
</div>
</div>
<script type="module" src="./bootstrap.ts"></script>
</body>
<script type="module" src="./main.ts"></script>
</body>
</html>