11th March, 2026

Now, Self-hosted LiveSync has finally begun to be split into the Self-hosted LiveSync plugin for Obsidian, and a properly abstracted version of it.
This may not offer much benefit to Obsidian plugin users, or might even cause a slight inconvenience, but I believe it will certainly help improve testability and make the ecosystem better.
However, I do not see the point in putting something with little benefit into beta, so I am handling this on the alpha branch. I would actually preferred to create an R&D branch, but I was not keen on the ampersand, and I feel it will eventually become a proper beta anyway.

### Refactored

- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`.
- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`.
- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`.

### Internal API changes

- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported.

### R&D

- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing.
- We can see the code in `src/apps/webapp` for the browser version.
This commit is contained in:
vorotamoroz
2026-03-11 05:47:00 +01:00
parent 9cf630320c
commit 0dfd42259d
77 changed files with 2849 additions and 909 deletions

View File

@@ -109,7 +109,7 @@ export async function generateHarness(
}
export async function waitForReady(harness: LiveSyncHarness): Promise<void> {
for (let i = 0; i < 10; i++) {
if (harness.plugin.services.appLifecycle.isReady()) {
if (harness.plugin.core.services.appLifecycle.isReady()) {
console.log("App Lifecycle is ready");
return;
}
@@ -122,11 +122,11 @@ export async function waitForIdle(harness: LiveSyncHarness): Promise<void> {
for (let i = 0; i < 20; i++) {
await delay(25);
const processing =
harness.plugin.services.replication.databaseQueueCount.value +
harness.plugin.services.fileProcessing.totalQueued.value +
harness.plugin.services.fileProcessing.batched.value +
harness.plugin.services.fileProcessing.processing.value +
harness.plugin.services.replication.storageApplyingCount.value;
harness.plugin.core.services.replication.databaseQueueCount.value +
harness.plugin.core.services.fileProcessing.totalQueued.value +
harness.plugin.core.services.fileProcessing.batched.value +
harness.plugin.core.services.fileProcessing.processing.value +
harness.plugin.core.services.replication.storageApplyingCount.value;
if (processing === 0) {
if (i > 0) {
@@ -139,7 +139,7 @@ export async function waitForIdle(harness: LiveSyncHarness): Promise<void> {
export async function waitForClosed(harness: LiveSyncHarness): Promise<void> {
await delay(100);
for (let i = 0; i < 10; i++) {
if (harness.plugin.services.control.hasUnloaded()) {
if (harness.plugin.core.services.control.hasUnloaded()) {
console.log("App has unloaded");
return;
}

View File

@@ -40,12 +40,12 @@ export async function storeFile(
expect(readContent).toBe(content);
}
}
await harness.plugin.services.fileProcessing.commitPendingFileEvents();
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
return file;
}
export async function readFromLocalDB(harness: LiveSyncHarness, path: string) {
const entry = await harness.plugin.localDatabase.getDBEntry(path as FilePath);
const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).not.toBe(false);
return entry;
}
@@ -95,11 +95,11 @@ export async function testFileWrite(
) {
const file = await storeFile(harness, path, content, false, fileOptions);
expect(file).toBeInstanceOf(TFile);
await harness.plugin.services.fileProcessing.commitPendingFileEvents();
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
const vaultFile = await readFromVault(harness, path, content instanceof Blob, fileOptions);
expect(await isDocContentSame(vaultFile, content)).toBe(true);
await harness.plugin.services.fileProcessing.commitPendingFileEvents();
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
if (skipCheckToBeWritten) {
return Promise.resolve();

View File

@@ -28,12 +28,12 @@ describe.skip("Plugin Integration Test (Local Database)", async () => {
});
it("should have services initialized", async () => {
expect(harness.plugin.services).toBeDefined();
expect(harness.plugin.core.services).toBeDefined();
return await Promise.resolve();
});
it("should have local database initialized", async () => {
expect(harness.plugin.localDatabase).toBeDefined();
expect(harness.plugin.localDatabase.isReady).toBe(true);
expect(harness.plugin.core.localDatabase).toBeDefined();
expect(harness.plugin.core.localDatabase.isReady).toBe(true);
return await Promise.resolve();
});
@@ -54,11 +54,11 @@ describe.skip("Plugin Integration Test (Local Database)", async () => {
const readContent = await harness.app.vault.read(file);
expect(readContent).toBe(content);
}
await harness.plugin.services.fileProcessing.commitPendingFileEvents();
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
// await delay(100); // Wait a bit for the local database to process
const entry = await harness.plugin.localDatabase.getDBEntry(path as FilePath);
const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).not.toBe(false);
if (entry) {
expect(readContent(entry)).toBe(content);
@@ -80,10 +80,10 @@ describe.skip("Plugin Integration Test (Local Database)", async () => {
const readContent = await harness.app.vault.read(file);
expect(readContent).toBe(content);
}
await harness.plugin.services.fileProcessing.commitPendingFileEvents();
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
const entry = await harness.plugin.localDatabase.getDBEntry(path as FilePath);
const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).not.toBe(false);
if (entry) {
expect(readContent(entry)).toBe(content);
@@ -108,9 +108,9 @@ describe.skip("Plugin Integration Test (Local Database)", async () => {
expect(await isDocContentSame(readContent, content)).toBe(true);
}
await harness.plugin.services.fileProcessing.commitPendingFileEvents();
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
const entry = await harness.plugin.localDatabase.getDBEntry(path as FilePath);
const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).not.toBe(false);
if (entry) {
const entryContent = await readContent(entry);

View File

@@ -68,7 +68,7 @@ export function syncBasicCase(label: string, { setting, fileOptions }: TestOptio
await waitForIdle(harnessInit);
});
afterAll(async () => {
await harnessInit.plugin.services.replicator.getActiveReplicator()?.closeReplication();
await harnessInit.plugin.core.services.replicator.getActiveReplicator()?.closeReplication();
await harnessInit.dispose();
await delay(1000);
});
@@ -81,7 +81,7 @@ export function syncBasicCase(label: string, { setting, fileOptions }: TestOptio
it("should be prepared for replication", async () => {
await waitForReady(harnessInit);
if (setting.remoteType !== RemoteTypes.REMOTE_P2P) {
const status = await harnessInit.plugin.services.replicator
const status = await harnessInit.plugin.core.services.replicator
.getActiveReplicator()
?.getRemoteStatus(sync_test_setting_init);
console.log("Connected devices after reset:", status);
@@ -120,12 +120,12 @@ export function syncBasicCase(label: string, { setting, fileOptions }: TestOptio
});
it("should have services initialized", () => {
expect(harnessUpload.plugin.services).toBeDefined();
expect(harnessUpload.plugin.core.services).toBeDefined();
});
it("should have local database initialized", () => {
expect(harnessUpload.plugin.localDatabase).toBeDefined();
expect(harnessUpload.plugin.localDatabase.isReady).toBe(true);
expect(harnessUpload.plugin.core.localDatabase).toBeDefined();
expect(harnessUpload.plugin.core.localDatabase.isReady).toBe(true);
});
it("should prepare remote database", async () => {
@@ -138,7 +138,7 @@ export function syncBasicCase(label: string, { setting, fileOptions }: TestOptio
const path = nameFile("store", "md", 0);
await testFileWrite(harnessUpload, path, content, false, fileOptions);
// Perform replication
// await harness.plugin.services.replication.replicate(true);
// await harness.plugin.core.services.replication.replicate(true);
});
it("should different content of several files have been created correctly", async () => {
await testFileWrite(harnessUpload, nameFile("test-diff-1", "md", 0), "Content A", false, fileOptions);
@@ -149,7 +149,7 @@ export function syncBasicCase(label: string, { setting, fileOptions }: TestOptio
test.each(FILE_SIZE_MD)("should large file of size %i bytes has been created", async (size) => {
const content = Array.from(generateFile(size)).join("");
const path = nameFile("large", "md", size);
const isTooLarge = harnessUpload.plugin.services.vault.isFileSizeTooLarge(size);
const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
console.log(`Skipping file of size ${size} bytes as it is too large to sync.`);
expect(true).toBe(true);
@@ -162,7 +162,7 @@ export function syncBasicCase(label: string, { setting, fileOptions }: TestOptio
const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" });
const path = nameFile("binary", "bin", size);
await testFileWrite(harnessUpload, path, content, true, fileOptions);
const isTooLarge = harnessUpload.plugin.services.vault.isFileSizeTooLarge(size);
const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
console.log(`Skipping file of size ${size} bytes as it is too large to sync.`);
expect(true).toBe(true);
@@ -210,12 +210,12 @@ export function syncBasicCase(label: string, { setting, fileOptions }: TestOptio
});
it("should have services initialized", () => {
expect(harnessDownload.plugin.services).toBeDefined();
expect(harnessDownload.plugin.core.services).toBeDefined();
});
it("should have local database initialized", () => {
expect(harnessDownload.plugin.localDatabase).toBeDefined();
expect(harnessDownload.plugin.localDatabase.isReady).toBe(true);
expect(harnessDownload.plugin.core.localDatabase).toBeDefined();
expect(harnessDownload.plugin.core.localDatabase.isReady).toBe(true);
});
it("should a file has been synchronised", async () => {
@@ -232,9 +232,9 @@ export function syncBasicCase(label: string, { setting, fileOptions }: TestOptio
test.each(FILE_SIZE_MD)("should the file %i bytes had been synchronised", async (size) => {
const content = Array.from(generateFile(size)).join("");
const path = nameFile("large", "md", size);
const isTooLarge = harnessDownload.plugin.services.vault.isFileSizeTooLarge(size);
const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
const entry = await harnessDownload.plugin.localDatabase.getDBEntry(path as FilePath);
const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath);
console.log(`Skipping file of size ${size} bytes as it is too large to sync.`);
expect(entry).toBe(false);
} else {
@@ -245,9 +245,9 @@ export function syncBasicCase(label: string, { setting, fileOptions }: TestOptio
test.each(FILE_SIZE_BINS)("should binary file of size %i bytes had been synchronised", async (size) => {
const path = nameFile("binary", "bin", size);
const isTooLarge = harnessDownload.plugin.services.vault.isFileSizeTooLarge(size);
const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
const entry = await harnessDownload.plugin.localDatabase.getDBEntry(path as FilePath);
const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath);
console.log(`Skipping file of size ${size} bytes as it is too large to sync.`);
expect(entry).toBe(false);
} else {

View File

@@ -7,11 +7,11 @@ import { commands } from "vitest/browser";
import { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
import { waitTaskWithFollowups } from "../lib/util";
async function waitForP2PPeers(harness: LiveSyncHarness) {
if (harness.plugin.settings.remoteType === RemoteTypes.REMOTE_P2P) {
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
// Wait for peers to connect
const maxRetries = 20;
let retries = maxRetries;
const replicator = await harness.plugin.services.replicator.getActiveReplicator();
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
}
@@ -38,8 +38,8 @@ async function waitForP2PPeers(harness: LiveSyncHarness) {
}
}
export async function closeP2PReplicatorConnections(harness: LiveSyncHarness) {
if (harness.plugin.settings.remoteType === RemoteTypes.REMOTE_P2P) {
const replicator = await harness.plugin.services.replicator.getActiveReplicator();
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
}
@@ -58,9 +58,9 @@ export async function closeP2PReplicatorConnections(harness: LiveSyncHarness) {
export async function performReplication(harness: LiveSyncHarness) {
await waitForP2PPeers(harness);
await delay(500);
const p = harness.plugin.services.replication.replicate(true);
const p = harness.plugin.core.services.replication.replicate(true);
const task =
harness.plugin.settings.remoteType === RemoteTypes.REMOTE_P2P
harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P
? waitTaskWithFollowups(
p,
() => {
@@ -74,17 +74,17 @@ export async function performReplication(harness: LiveSyncHarness) {
: p;
const result = await task;
// await waitForIdle(harness);
// if (harness.plugin.settings.remoteType === RemoteTypes.REMOTE_P2P) {
// if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
// await closeP2PReplicatorConnections(harness);
// }
return result;
}
export async function closeReplication(harness: LiveSyncHarness) {
if (harness.plugin.settings.remoteType === RemoteTypes.REMOTE_P2P) {
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
return await closeP2PReplicatorConnections(harness);
}
const replicator = await harness.plugin.services.replicator.getActiveReplicator();
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
if (!replicator) {
console.log("No active replicator to close");
return;
@@ -98,19 +98,21 @@ export async function prepareRemote(harness: LiveSyncHarness, setting: ObsidianL
if (setting.remoteType !== RemoteTypes.REMOTE_P2P) {
if (shouldReset) {
await delay(1000);
await harness.plugin.services.replicator
await harness.plugin.core.services.replicator
.getActiveReplicator()
?.tryResetRemoteDatabase(harness.plugin.settings);
?.tryResetRemoteDatabase(harness.plugin.core.settings);
} else {
await harness.plugin.services.replicator
await harness.plugin.core.services.replicator
.getActiveReplicator()
?.tryCreateRemoteDatabase(harness.plugin.settings);
?.tryCreateRemoteDatabase(harness.plugin.core.settings);
}
await harness.plugin.services.replicator.getActiveReplicator()?.markRemoteResolved(harness.plugin.settings);
// No exceptions should be thrown
const status = await harness.plugin.services.replicator
await harness.plugin.core.services.replicator
.getActiveReplicator()
?.getRemoteStatus(harness.plugin.settings);
?.markRemoteResolved(harness.plugin.core.settings);
// No exceptions should be thrown
const status = await harness.plugin.core.services.replicator
.getActiveReplicator()
?.getRemoteStatus(harness.plugin.core.settings);
console.log("Remote status:", status);
expect(status).not.toBeFalsy();
}

View File

@@ -69,7 +69,7 @@ describe("Dialog Tests", async () => {
it("should show copy to clipboard dialog and confirm", async () => {
const testString = "This is a test string to copy to clipboard.";
const title = "Copy Test";
const result = harness.plugin.services.UI.promptCopyToClipboard(title, testString);
const result = harness.plugin.core.services.UI.promptCopyToClipboard(title, testString);
const isDialogShown = await waitForDialogShown(title, 500);
expect(isDialogShown).toBe(true);
const copyButton = page.getByText("📋");