Initial commit

This commit is contained in:
2026-04-08 11:55:27 +01:00
commit 470a1c15b8
36 changed files with 4932 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"id": "obsidian-sync",
"name": "Obsidian Sync",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Sync a vault through a self-hosted encrypted sync server.",
"author": "Local",
"isDesktopOnly": false
}

View File

@@ -0,0 +1,18 @@
{
"name": "@obsidian-sync/obsidian-plugin",
"private": true,
"version": "0.1.0",
"main": "dist/main.js",
"types": "dist/main.d.ts",
"scripts": {
"build": "tsc -b",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@obsidian-sync/sync-engine": "0.1.0",
"@obsidian-sync/sync-protocol": "0.1.0"
},
"devDependencies": {
"obsidian": "^1.8.7"
}
}

View File

@@ -0,0 +1,96 @@
import { Notice, Plugin } from "obsidian";
import { SyncSettingsTab } from "./settings/SyncSettingsTab";
import { DEFAULT_SETTINGS, type ObsidianSyncSettings } from "./settings/settings";
import { SyncService } from "./sync/SyncService";
export default class ObsidianSyncPlugin extends Plugin {
settings: ObsidianSyncSettings = DEFAULT_SETTINGS;
private syncService?: SyncService;
private scheduledSyncHandle?: number;
async onload(): Promise<void> {
await this.loadSettings();
this.syncService = new SyncService(
this.app,
() => this.settings,
async (settings) => {
this.settings = settings;
await this.saveSettings();
}
);
this.addSettingTab(new SyncSettingsTab(this.app, this, () => this.configureSyncSchedule()));
this.addCommand({
id: "obsidian-sync-run-manual",
name: "Run encrypted sync now",
callback: async () => {
await this.syncService?.runManualSync();
}
});
this.addCommand({
id: "obsidian-sync-rotate-vault-key",
name: "Rotate vault key",
callback: async () => {
const result = await this.getSyncServiceOrThrow().rotateVaultKey();
new Notice(
`Vault key rotated. Uploaded ${result.uploadedFiles} files. Export the fresh recovery bundle to update other devices.`
);
}
});
this.configureSyncSchedule();
new Notice("Obsidian Sync loaded.");
}
onunload(): void {
if (this.scheduledSyncHandle !== undefined) {
window.clearInterval(this.scheduledSyncHandle);
}
}
async loadSettings(): Promise<void> {
const loaded = (await this.loadData()) as Partial<ObsidianSyncSettings> | null;
this.settings = {
...DEFAULT_SETTINGS,
...loaded,
deviceName: loaded?.deviceName || this.app.vault.getName(),
syncState: {
...DEFAULT_SETTINGS.syncState,
...(loaded?.syncState ?? {}),
files: {
...DEFAULT_SETTINGS.syncState.files,
...(loaded?.syncState?.files ?? {})
}
}
};
await this.saveSettings();
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
getSyncServiceOrThrow(): SyncService {
if (!this.syncService) {
throw new Error("Sync service is not ready yet.");
}
return this.syncService;
}
private configureSyncSchedule(): void {
if (this.scheduledSyncHandle !== undefined) {
window.clearInterval(this.scheduledSyncHandle);
}
const intervalMinutes = Math.max(1, this.settings.syncIntervalMinutes);
this.scheduledSyncHandle = window.setInterval(() => {
void this.syncService?.runScheduledSync();
}, intervalMinutes * 60_000);
}
}

View File

@@ -0,0 +1,228 @@
import { Notice, PluginSettingTab, Setting, TextAreaComponent } from "obsidian";
import type ObsidianSyncPlugin from "../main";
export class SyncSettingsTab extends PluginSettingTab {
private exportedRecoveryBundle = "";
private importedRecoveryBundle = "";
constructor(
app: ObsidianSyncPlugin["app"],
private readonly plugin: ObsidianSyncPlugin,
private readonly onSettingsUpdated: () => void
) {
super(app, plugin);
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Obsidian Sync" });
const syncService = this.plugin.getSyncServiceOrThrow();
new Setting(containerEl)
.setName("Server URL")
.setDesc("Base URL of the sync server.")
.addText((text) =>
text
.setPlaceholder("http://localhost:8787")
.setValue(this.plugin.settings.serverUrl)
.onChange(async (value) => {
this.plugin.settings.serverUrl = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Vault ID")
.setDesc("Stable identifier used to group devices for the same vault.")
.addText((text) =>
text.setValue(this.plugin.settings.vaultId).onChange(async (value) => {
this.plugin.settings.vaultId = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Device name")
.setDesc("Human-readable name shown on the server.")
.addText((text) =>
text.setValue(this.plugin.settings.deviceName).onChange(async (value) => {
this.plugin.settings.deviceName = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Sync interval")
.setDesc("Minutes between scheduled sync runs.")
.addText((text) =>
text.setValue(String(this.plugin.settings.syncIntervalMinutes)).onChange(async (value) => {
const minutes = Number(value);
this.plugin.settings.syncIntervalMinutes = Number.isFinite(minutes) && minutes > 0 ? minutes : 5;
await this.plugin.saveSettings();
this.onSettingsUpdated();
})
);
new Setting(containerEl)
.setName("Pull batch size")
.setDesc("Maximum remote changes to request in a single pull page.")
.addText((text) =>
text.setValue(String(this.plugin.settings.pullBatchSize)).onChange(async (value) => {
const batchSize = Number(value);
this.plugin.settings.pullBatchSize = Number.isFinite(batchSize) && batchSize > 0 ? Math.floor(batchSize) : 50;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Push batch size")
.setDesc("Maximum local changes to upload in a single push request.")
.addText((text) =>
text.setValue(String(this.plugin.settings.pushBatchSize)).onChange(async (value) => {
const batchSize = Number(value);
this.plugin.settings.pushBatchSize = Number.isFinite(batchSize) && batchSize > 0 ? Math.floor(batchSize) : 50;
await this.plugin.saveSettings();
})
);
containerEl.createEl("h3", { text: "Recovery" });
let exportBundleTextArea: TextAreaComponent | undefined;
new Setting(containerEl)
.setName("Export recovery bundle")
.setDesc("Generate a bundle you can paste into another device to recover the same encrypted vault.")
.addTextArea((textArea) => {
exportBundleTextArea = textArea;
textArea.setValue(this.exportedRecoveryBundle);
textArea.inputEl.rows = 8;
textArea.inputEl.cols = 40;
})
.addButton((button) =>
button.setButtonText("Generate").onClick(async () => {
this.exportedRecoveryBundle = await syncService.exportRecoveryBundle();
exportBundleTextArea?.setValue(this.exportedRecoveryBundle);
new Notice("Recovery bundle generated.");
})
)
.addButton((button) =>
button.setButtonText("Clear").onClick(() => {
this.exportedRecoveryBundle = "";
exportBundleTextArea?.setValue("");
})
);
let importBundleTextArea: TextAreaComponent | undefined;
new Setting(containerEl)
.setName("Import recovery bundle")
.setDesc("Paste a bundle from another device to register this device against the same encrypted vault.")
.addTextArea((textArea) => {
importBundleTextArea = textArea;
textArea.setValue(this.importedRecoveryBundle);
textArea.inputEl.rows = 8;
textArea.inputEl.cols = 40;
textArea.onChange((value) => {
this.importedRecoveryBundle = value;
});
})
.addButton((button) =>
button.setButtonText("Import").onClick(async () => {
await syncService.importRecoveryBundle(this.importedRecoveryBundle);
this.importedRecoveryBundle = "";
this.exportedRecoveryBundle = "";
importBundleTextArea?.setValue("");
new Notice("Recovery bundle imported. Run sync to register this device.");
this.display();
})
);
containerEl.createEl("h3", { text: "Devices" });
const deviceListContainer = containerEl.createDiv();
new Setting(containerEl)
.setName("Connected devices")
.setDesc("Refresh the active device list and revoke devices that should no longer have access.")
.addButton((button) =>
button.setButtonText("Refresh").onClick(async () => {
await this.renderDeviceList(deviceListContainer);
})
);
void this.renderDeviceList(deviceListContainer);
containerEl.createEl("h3", { text: "Key rotation" });
new Setting(containerEl)
.setName("Rotate vault key")
.setDesc("Generate a new vault key, re-encrypt local content, and require your other devices to import a fresh recovery bundle.")
.addButton((button) =>
button.setButtonText("Rotate key").onClick(async () => {
const result = await syncService.rotateVaultKey();
this.exportedRecoveryBundle = result.recoveryBundle;
exportBundleTextArea?.setValue(result.recoveryBundle);
new Notice(
`Vault key rotated. Uploaded ${result.uploadedFiles} files. Share the fresh recovery bundle with your other devices.`
);
await this.renderDeviceList(deviceListContainer);
})
);
}
private async renderDeviceList(containerEl: HTMLElement): Promise<void> {
containerEl.empty();
if (!this.plugin.settings.deviceId || !this.plugin.settings.authToken) {
containerEl.createEl("p", {
text: "Run a sync or import a recovery bundle to register this device before managing devices."
});
return;
}
try {
const response = await this.plugin.getSyncServiceOrThrow().listDevices();
const summaryParts = [`Active server key: ${response.activeKeyId ?? "not set"}`];
if (response.keyRotatedAt) {
summaryParts.push(`Last key rotation: ${response.keyRotatedAt}`);
}
containerEl.createEl("p", {
text: summaryParts.join(" | ")
});
for (const device of response.devices) {
const isCurrentDevice = device.deviceId === this.plugin.settings.deviceId;
const description = [
`ID: ${device.deviceId}`,
`Registered: ${device.issuedAt}`,
device.revokedAt ? `Revoked: ${device.revokedAt}` : "Active"
].join(" | ");
new Setting(containerEl)
.setName(`${device.deviceName}${isCurrentDevice ? " (this device)" : ""}`)
.setDesc(description)
.addButton((button) => {
if (device.revokedAt) {
button.setButtonText("Revoked").setDisabled(true);
return;
}
button.setButtonText(isCurrentDevice ? "Revoke this device" : "Revoke").onClick(async () => {
const selfRevoked = await this.plugin.getSyncServiceOrThrow().revokeDevice(device.deviceId);
new Notice(
selfRevoked
? "This device was revoked. Import a recovery bundle or run sync again to register a new device identity."
: "Device revoked."
);
await this.renderDeviceList(containerEl);
});
});
}
} catch (error) {
containerEl.createEl("p", {
text: error instanceof Error ? `Failed to load devices: ${error.message}` : "Failed to load devices."
});
}
}
}

View File

@@ -0,0 +1,47 @@
import type { SyncFileKind } from "@obsidian-sync/sync-protocol";
export interface SyncedFileState {
kind: SyncFileKind;
revisionId: string;
contentHash: string;
lastSyncedContent?: string;
updatedAt: string;
pendingConflict?: boolean;
pendingConflictHash?: string;
}
export interface SyncState {
serverRevision: number;
files: Record<string, SyncedFileState>;
}
export interface ObsidianSyncSettings {
serverUrl: string;
vaultId: string;
deviceId: string;
deviceName: string;
authToken: string;
keyId: string;
exportedVaultKey: string;
syncIntervalMinutes: number;
pullBatchSize: number;
pushBatchSize: number;
syncState: SyncState;
}
export const DEFAULT_SETTINGS: ObsidianSyncSettings = {
serverUrl: "http://localhost:8787",
vaultId: "primary-vault",
deviceId: "",
deviceName: "",
authToken: "",
keyId: "",
exportedVaultKey: "",
syncIntervalMinutes: 5,
pullBatchSize: 50,
pushBatchSize: 50,
syncState: {
serverRevision: 0,
files: {}
}
};

View File

@@ -0,0 +1,91 @@
import type { ClientLogEntry, ClientLogUploadRequest } from "@obsidian-sync/sync-protocol";
type LogLevel = ClientLogEntry["level"];
function normalizeError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack
};
}
return {
value: String(error)
};
}
export class SyncRunLogger {
private readonly runId = globalThis.crypto.randomUUID();
private readonly entries: ClientLogEntry[] = [];
private deviceId: string;
constructor(deviceId: string) {
this.deviceId = deviceId || "unregistered-device";
}
setDeviceId(deviceId: string): void {
if (deviceId) {
this.deviceId = deviceId;
}
}
getRunId(): string {
return this.runId;
}
hasEntries(): boolean {
return this.entries.length > 0;
}
toUploadRequest(vaultId: string): ClientLogUploadRequest {
return {
vaultId,
deviceId: this.deviceId,
runId: this.runId,
entries: [...this.entries]
};
}
debug(message: string, context?: Record<string, unknown>): void {
this.log("debug", message, context);
}
info(message: string, context?: Record<string, unknown>): void {
this.log("info", message, context);
}
warn(message: string, context?: Record<string, unknown>): void {
this.log("warn", message, context);
}
error(message: string, error?: unknown, context?: Record<string, unknown>): void {
this.log("error", message, {
...context,
...(error === undefined ? {} : { error: normalizeError(error) })
});
}
private log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
const entry: ClientLogEntry = {
level,
message,
timestamp: new Date().toISOString(),
deviceId: this.deviceId,
context: {
runId: this.runId,
...(context ?? {})
}
};
this.entries.push(entry);
const consoleMethod =
level === "debug" ? console.debug : level === "info" ? console.info : level === "warn" ? console.warn : console.error;
consoleMethod(`[Obsidian Sync][${this.runId}] ${message}`, entry.context ?? {});
}
}

View File

@@ -0,0 +1,984 @@
import { App, Notice, TFile, normalizePath } from "obsidian";
import {
computeBinaryHash,
computeTextHash,
createConflictCopyPath,
decryptBytes,
decryptText,
encryptBytes,
encryptText,
generateVaultKey,
importVaultKey,
mergeTextRevisions,
type VaultKeyHandle
} from "@obsidian-sync/sync-engine";
import {
ClientLogUploadResponseSchema,
ListDevicesResponseSchema,
RecoveryBundleSchema,
RegisterDeviceResponseSchema,
RevokeDeviceResponseSchema,
RotateVaultKeyResponseSchema,
SyncPullResponseSchema,
SyncPushResponseSchema,
type ListDevicesResponse,
type RecoveryBundle,
type SyncConflict,
type SyncFileKind,
type SyncFileRecord,
type SyncPullResponse,
type Tombstone
} from "@obsidian-sync/sync-protocol";
import type { ObsidianSyncSettings, SyncedFileState } from "../settings/settings";
import { SyncRunLogger } from "./SyncRunLogger";
type SettingsReader = () => ObsidianSyncSettings;
type SettingsWriter = (settings: ObsidianSyncSettings) => Promise<void>;
type SyncMode = "manual" | "scheduled";
const DEFAULT_BATCH_SIZE = 50;
const IGNORED_PATH_PREFIXES = [".obsidian/"];
const TEXT_FILE_EXTENSIONS = new Set(["md", "txt", "markdown", "canvas", "json", "yaml", "yml", "csv"]);
interface CollectLocalChangeOptions {
forceAllFiles?: boolean;
}
interface LocalChangeSet {
files: SyncFileRecord[];
tombstones: Tombstone[];
statesByPath: Record<string, SyncedFileState>;
}
interface LocalFileSnapshot {
kind: SyncFileKind;
contentHash: string;
updatedAt: string;
sizeBytes: number;
textContent?: string;
binaryContent?: ArrayBuffer;
}
interface PullSummary {
pulledChanges: number;
finalServerRevision: number;
}
interface PushSummary {
acceptedServerRevision: number;
acceptedFilePaths: string[];
acceptedTombstones: string[];
conflicts: SyncConflict[];
}
export interface RotateVaultKeyResult {
recoveryBundle: string;
activeKeyId: string;
rotatedAt: string;
uploadedFiles: number;
}
export class SyncService {
constructor(
private readonly app: App,
private readonly readSettings: SettingsReader,
private readonly writeSettings: SettingsWriter
) {}
async runManualSync(): Promise<void> {
await this.runSync("manual");
}
async runScheduledSync(): Promise<void> {
await this.runSync("scheduled");
}
async exportRecoveryBundle(): Promise<string> {
await this.ensureVaultKey();
const settings = this.readSettings();
return this.serializeRecoveryBundle(settings);
}
async importRecoveryBundle(serializedBundle: string): Promise<void> {
const parsed = RecoveryBundleSchema.parse(JSON.parse(serializedBundle) as RecoveryBundle);
const nextSettings = this.cloneSettings();
nextSettings.serverUrl = parsed.serverUrl;
nextSettings.vaultId = parsed.vaultId;
nextSettings.keyId = parsed.keyId;
nextSettings.exportedVaultKey = parsed.exportedVaultKey;
nextSettings.deviceId = "";
nextSettings.authToken = "";
nextSettings.syncState = {
serverRevision: 0,
files: {}
};
await this.writeSettings(nextSettings);
}
async listDevices(): Promise<ListDevicesResponse> {
await this.ensureRegisteredDevice();
const settings = this.readSettings();
return this.postJson(
"/api/devices/list",
{
vaultId: settings.vaultId,
deviceId: settings.deviceId
},
ListDevicesResponseSchema,
settings.authToken
);
}
async revokeDevice(targetDeviceId: string): Promise<boolean> {
await this.ensureRegisteredDevice();
const settings = this.readSettings();
await this.postJson(
"/api/devices/revoke",
{
vaultId: settings.vaultId,
deviceId: settings.deviceId,
targetDeviceId
},
RevokeDeviceResponseSchema,
settings.authToken
);
if (targetDeviceId === settings.deviceId) {
const nextSettings = this.cloneSettings();
nextSettings.deviceId = "";
nextSettings.authToken = "";
await this.writeSettings(nextSettings);
return true;
}
return false;
}
async rotateVaultKey(): Promise<RotateVaultKeyResult> {
const diagnostics = new SyncRunLogger(this.readSettings().deviceId);
try {
const currentVaultKey = await this.ensureVaultKey();
diagnostics.info("vault-key-ready", {
keyId: currentVaultKey.keyId
});
await this.ensureDeviceRegistration(diagnostics);
diagnostics.setDeviceId(this.readSettings().deviceId);
const generatedKey = await generateVaultKey();
const localChanges = await this.collectLocalChanges(
{
keyId: generatedKey.keyId,
key: generatedKey.key
},
diagnostics,
{
forceAllFiles: true
}
);
const pushSummary = await this.pushLocalChanges(localChanges, diagnostics);
const settings = this.readSettings();
const rotated = await this.postJson(
"/api/keys/rotate",
{
vaultId: settings.vaultId,
deviceId: settings.deviceId,
nextKeyId: generatedKey.keyId,
previousKeyId: settings.keyId || undefined
},
RotateVaultKeyResponseSchema,
settings.authToken
);
const nextSettings = this.cloneSettings();
nextSettings.keyId = generatedKey.keyId;
nextSettings.exportedVaultKey = generatedKey.exportedKey;
nextSettings.syncState.serverRevision = Math.max(nextSettings.syncState.serverRevision, pushSummary.acceptedServerRevision);
await this.writeSettings(nextSettings);
diagnostics.info("vault-key-rotated", {
activeKeyId: rotated.activeKeyId,
rotatedAt: rotated.rotatedAt,
uploadedFiles: pushSummary.acceptedFilePaths.length
});
return {
recoveryBundle: this.serializeRecoveryBundle(nextSettings),
activeKeyId: rotated.activeKeyId,
rotatedAt: rotated.rotatedAt,
uploadedFiles: pushSummary.acceptedFilePaths.length
};
} catch (error) {
diagnostics.error("vault-key-rotation-failed", error);
throw error;
} finally {
await this.uploadDiagnostics(diagnostics);
}
}
private async runSync(mode: SyncMode): Promise<void> {
const settings = this.readSettings();
const diagnostics = new SyncRunLogger(settings.deviceId);
if (!settings.serverUrl || !settings.vaultId) {
if (mode === "manual") {
new Notice("Set a server URL and vault ID before syncing.");
}
return;
}
diagnostics.info("sync-started", {
mode,
serverRevision: settings.syncState.serverRevision
});
try {
const vaultKey = await this.ensureVaultKey();
diagnostics.info("vault-key-ready", {
keyId: vaultKey.keyId
});
await this.ensureDeviceRegistration(diagnostics);
diagnostics.setDeviceId(this.readSettings().deviceId);
const pullSummary = await this.pullAndApplyRemoteChanges(vaultKey, diagnostics);
const localChanges = await this.collectLocalChanges(vaultKey, diagnostics);
const pushSummary = await this.pushLocalChanges(localChanges, diagnostics);
const nextSettings = this.cloneSettings();
nextSettings.syncState.serverRevision = Math.max(pullSummary.finalServerRevision, pushSummary.acceptedServerRevision);
await this.writeSettings(nextSettings);
if (mode === "manual") {
new Notice(
`Sync complete. Pulled ${pullSummary.pulledChanges} changes, uploaded ${pushSummary.acceptedFilePaths.length} files.`
);
}
if (pushSummary.conflicts.length > 0) {
diagnostics.warn("server-conflicts-reported", {
count: pushSummary.conflicts.length,
paths: pushSummary.conflicts.map((conflict) => conflict.path)
});
new Notice(`${pushSummary.conflicts.length} server conflicts need review.`);
}
} catch (error) {
diagnostics.error("sync-failed", error, {
mode
});
console.error("Obsidian Sync failed", error);
new Notice(
error instanceof Error
? `Sync failed: ${error.message}. Run ${diagnostics.getRunId()}.`
: `Sync failed. Run ${diagnostics.getRunId()}.`
);
} finally {
await this.uploadDiagnostics(diagnostics);
}
}
private cloneSettings(): ObsidianSyncSettings {
const current = this.readSettings();
return {
...current,
syncState: {
...current.syncState,
files: { ...current.syncState.files }
}
};
}
private getBaseUrl(): string {
return this.readSettings().serverUrl.replace(/\/$/, "");
}
private getPullBatchSize(): number {
return Math.max(1, this.readSettings().pullBatchSize || DEFAULT_BATCH_SIZE);
}
private getPushBatchSize(): number {
return Math.max(1, this.readSettings().pushBatchSize || DEFAULT_BATCH_SIZE);
}
private getSyncableFiles(): TFile[] {
return this.app.vault.getFiles().filter((file) => !IGNORED_PATH_PREFIXES.some((prefix) => file.path.startsWith(prefix)));
}
private resolveFileKind(file: TFile, previous?: SyncedFileState): SyncFileKind {
if (previous?.kind) {
return previous.kind;
}
return TEXT_FILE_EXTENSIONS.has(file.extension.toLowerCase()) ? "text" : "binary";
}
private createSyncedState(
kind: SyncFileKind,
revisionId: string,
contentHash: string,
updatedAt: string,
lastSyncedContent?: string
): SyncedFileState {
return {
kind,
revisionId,
contentHash,
updatedAt,
...(kind === "text" && lastSyncedContent !== undefined ? { lastSyncedContent } : {})
};
}
private buildRecoveryBundle(settings: ObsidianSyncSettings): RecoveryBundle {
return RecoveryBundleSchema.parse({
version: 1,
serverUrl: settings.serverUrl,
vaultId: settings.vaultId,
keyId: settings.keyId,
exportedVaultKey: settings.exportedVaultKey,
generatedAt: new Date().toISOString()
});
}
private serializeRecoveryBundle(settings: ObsidianSyncSettings): string {
return JSON.stringify(this.buildRecoveryBundle(settings), null, 2);
}
private async readLocalFileSnapshot(file: TFile, previous?: SyncedFileState): Promise<LocalFileSnapshot> {
const kind = this.resolveFileKind(file, previous);
if (kind === "text") {
const textContent = await this.app.vault.cachedRead(file);
return {
kind,
textContent,
contentHash: await computeTextHash(textContent),
updatedAt: new Date(file.stat.mtime).toISOString(),
sizeBytes: new TextEncoder().encode(textContent).byteLength
};
}
const binaryContent = await this.app.vault.readBinary(file);
return {
kind,
binaryContent,
contentHash: await computeBinaryHash(binaryContent),
updatedAt: new Date(file.stat.mtime).toISOString(),
sizeBytes: file.stat.size
};
}
private async ensureVaultKey(): Promise<VaultKeyHandle> {
const settings = this.readSettings();
if (settings.exportedVaultKey && settings.keyId) {
return importVaultKey(settings.exportedVaultKey, settings.keyId);
}
const nextSettings = this.cloneSettings();
const generated = await generateVaultKey();
nextSettings.keyId = generated.keyId;
nextSettings.exportedVaultKey = generated.exportedKey;
await this.writeSettings(nextSettings);
return {
keyId: generated.keyId,
key: generated.key
};
}
private async ensureRegisteredDevice(): Promise<void> {
await this.ensureDeviceRegistration(new SyncRunLogger(this.readSettings().deviceId));
}
private async ensureDeviceRegistration(diagnostics: SyncRunLogger): Promise<void> {
const settings = this.readSettings();
if (settings.deviceId && settings.authToken) {
return;
}
const deviceName = settings.deviceName || this.app.vault.getName();
const response = await this.postJson(
"/api/devices/register",
{
vaultId: settings.vaultId,
deviceName
},
RegisterDeviceResponseSchema
);
diagnostics.info("device-registered", {
deviceId: response.deviceId,
deviceName
});
const nextSettings = this.cloneSettings();
nextSettings.deviceId = response.deviceId;
nextSettings.authToken = response.token;
nextSettings.deviceName = deviceName;
await this.writeSettings(nextSettings);
}
private ensureCompatibleVaultKey(pullResponse: SyncPullResponse): void {
const settings = this.readSettings();
if (pullResponse.activeKeyId && settings.keyId && pullResponse.activeKeyId !== settings.keyId) {
throw new Error("The server is using a different vault key. Import the latest recovery bundle before syncing.");
}
}
private async pullRemoteChangesPage(sinceServerRevision: number): Promise<SyncPullResponse> {
const settings = this.readSettings();
return this.postJson(
"/api/sync/pull",
{
vaultId: settings.vaultId,
deviceId: settings.deviceId,
sinceServerRevision,
limit: this.getPullBatchSize()
},
SyncPullResponseSchema,
settings.authToken
);
}
private async pullAndApplyRemoteChanges(vaultKey: VaultKeyHandle, diagnostics: SyncRunLogger): Promise<PullSummary> {
let pulledChanges = 0;
let nextSinceServerRevision = this.readSettings().syncState.serverRevision;
while (true) {
const pullResponse = await this.pullRemoteChangesPage(nextSinceServerRevision);
this.ensureCompatibleVaultKey(pullResponse);
if (pullResponse.changes.length === 0) {
diagnostics.info("remote-pull-finished", {
changes: pulledChanges,
serverRevision: pullResponse.serverRevision,
hasMore: pullResponse.hasMore
});
break;
}
await this.applyRemoteChanges(pullResponse, vaultKey, diagnostics);
pulledChanges += pullResponse.changes.length;
nextSinceServerRevision = pullResponse.nextSinceServerRevision;
diagnostics.info("remote-pull-page-applied", {
pageChanges: pullResponse.changes.length,
nextSinceServerRevision,
hasMore: pullResponse.hasMore
});
if (!pullResponse.hasMore) {
break;
}
}
return {
pulledChanges,
finalServerRevision: this.readSettings().syncState.serverRevision
};
}
private async pushLocalChanges(localChanges: LocalChangeSet, diagnostics: SyncRunLogger): Promise<PushSummary> {
const settings = this.readSettings();
if (localChanges.files.length === 0 && localChanges.tombstones.length === 0) {
return {
acceptedServerRevision: settings.syncState.serverRevision,
acceptedFilePaths: [],
acceptedTombstones: [],
conflicts: []
};
}
const acceptedFilePaths: string[] = [];
const acceptedTombstones: string[] = [];
const conflicts: SyncConflict[] = [];
let acceptedServerRevision = settings.syncState.serverRevision;
let batchNumber = 0;
const fileBatches = this.chunkEntries(localChanges.files, this.getPushBatchSize());
const tombstoneBatches = this.chunkEntries(localChanges.tombstones, this.getPushBatchSize());
for (const fileBatch of fileBatches) {
batchNumber += 1;
const response = await this.pushBatch(fileBatch, [], acceptedServerRevision);
await this.reconcileAcceptedLocalChanges(localChanges.statesByPath, response.acceptedFilePaths, response.acceptedTombstones);
acceptedServerRevision = Math.max(acceptedServerRevision, response.acceptedServerRevision);
acceptedFilePaths.push(...response.acceptedFilePaths);
acceptedTombstones.push(...response.acceptedTombstones);
conflicts.push(...response.conflicts);
await this.persistAcceptedServerRevision(acceptedServerRevision);
diagnostics.info("push-batch-finished", {
batchNumber,
batchKind: "files",
attempted: fileBatch.length,
accepted: response.acceptedFilePaths.length,
conflicts: response.conflicts.length
});
}
for (const tombstoneBatch of tombstoneBatches) {
batchNumber += 1;
const response = await this.pushBatch([], tombstoneBatch, acceptedServerRevision);
await this.reconcileAcceptedLocalChanges(localChanges.statesByPath, response.acceptedFilePaths, response.acceptedTombstones);
acceptedServerRevision = Math.max(acceptedServerRevision, response.acceptedServerRevision);
acceptedFilePaths.push(...response.acceptedFilePaths);
acceptedTombstones.push(...response.acceptedTombstones);
conflicts.push(...response.conflicts);
await this.persistAcceptedServerRevision(acceptedServerRevision);
diagnostics.info("push-batch-finished", {
batchNumber,
batchKind: "tombstones",
attempted: tombstoneBatch.length,
accepted: response.acceptedTombstones.length,
conflicts: response.conflicts.length
});
}
diagnostics.info("remote-push-finished", {
acceptedFiles: acceptedFilePaths.length,
acceptedDeletes: acceptedTombstones.length,
conflicts: conflicts.length
});
return {
acceptedServerRevision,
acceptedFilePaths,
acceptedTombstones,
conflicts
};
}
private async pushBatch(
files: SyncFileRecord[],
tombstones: Tombstone[],
knownServerRevision: number
): Promise<ReturnType<typeof SyncPushResponseSchema.parse>> {
const settings = this.readSettings();
return this.postJson(
"/api/sync/push",
{
vaultId: settings.vaultId,
deviceId: settings.deviceId,
knownServerRevision,
files,
tombstones
},
SyncPushResponseSchema,
settings.authToken
);
}
private chunkEntries<TValue>(entries: TValue[], batchSize: number): TValue[][] {
if (entries.length === 0) {
return [];
}
const batches: TValue[][] = [];
for (let index = 0; index < entries.length; index += batchSize) {
batches.push(entries.slice(index, index + batchSize));
}
return batches;
}
private async collectLocalChanges(
vaultKey: VaultKeyHandle,
diagnostics: SyncRunLogger,
options: CollectLocalChangeOptions = {}
): Promise<LocalChangeSet> {
const settings = this.readSettings();
const files: SyncFileRecord[] = [];
const tombstones: Tombstone[] = [];
const statesByPath: Record<string, SyncedFileState> = {};
const seenPaths = new Set<string>();
for (const file of this.getSyncableFiles()) {
const previous = settings.syncState.files[file.path];
const snapshot = await this.readLocalFileSnapshot(file, previous);
seenPaths.add(file.path);
if (previous?.pendingConflict && previous.pendingConflictHash === snapshot.contentHash) {
diagnostics.debug("skipped-pending-conflict-file", {
path: file.path,
kind: snapshot.kind
});
continue;
}
if (!options.forceAllFiles && previous && previous.contentHash === snapshot.contentHash) {
continue;
}
const envelope =
snapshot.kind === "text"
? await encryptText(snapshot.textContent ?? "", vaultKey)
: await encryptBytes(snapshot.binaryContent ?? new ArrayBuffer(0), vaultKey);
const revisionId = globalThis.crypto.randomUUID();
files.push({
manifest: {
path: file.path,
kind: snapshot.kind,
contentHash: snapshot.contentHash,
revisionId,
baseRevisionId: previous?.revisionId,
updatedAt: snapshot.updatedAt,
sizeBytes: snapshot.sizeBytes,
deviceId: settings.deviceId
},
envelope
});
statesByPath[file.path] = {
kind: snapshot.kind,
revisionId,
contentHash: snapshot.contentHash,
updatedAt: snapshot.updatedAt,
...(snapshot.kind === "text" ? { lastSyncedContent: snapshot.textContent } : {})
};
}
for (const [path, state] of Object.entries(settings.syncState.files)) {
if (seenPaths.has(path)) {
continue;
}
tombstones.push({
path,
revisionId: globalThis.crypto.randomUUID(),
baseRevisionId: state.revisionId,
deletedAt: new Date().toISOString(),
deviceId: settings.deviceId
});
}
diagnostics.info("local-scan-finished", {
stagedFiles: files.length,
stagedDeletes: tombstones.length,
forceAllFiles: Boolean(options.forceAllFiles)
});
return {
files,
tombstones,
statesByPath
};
}
private async persistAcceptedServerRevision(serverRevision: number): Promise<void> {
const nextSettings = this.cloneSettings();
nextSettings.syncState.serverRevision = Math.max(nextSettings.syncState.serverRevision, serverRevision);
await this.writeSettings(nextSettings);
}
private async reconcileAcceptedLocalChanges(
statesByPath: Record<string, SyncedFileState>,
acceptedFilePaths: string[],
acceptedTombstones: string[]
): Promise<void> {
if (acceptedFilePaths.length === 0 && acceptedTombstones.length === 0) {
return;
}
const nextSettings = this.cloneSettings();
for (const path of acceptedFilePaths) {
const state = statesByPath[path];
if (state) {
nextSettings.syncState.files[path] = state;
}
}
for (const path of acceptedTombstones) {
delete nextSettings.syncState.files[path];
}
await this.writeSettings(nextSettings);
}
private async applyRemoteChanges(
pullResponse: SyncPullResponse,
vaultKey: VaultKeyHandle,
diagnostics: SyncRunLogger
): Promise<void> {
if (pullResponse.changes.length === 0) {
return;
}
const nextSettings = this.cloneSettings();
for (const change of pullResponse.changes) {
if (change.file) {
const remotePath = normalizePath(change.file.manifest.path);
const localAbstract = this.app.vault.getAbstractFileByPath(remotePath);
const previous = nextSettings.syncState.files[remotePath];
if (change.file.manifest.kind === "text") {
const remoteContent = await decryptText(change.file.envelope, vaultKey);
if (!(localAbstract instanceof TFile)) {
await this.ensureParentFolders(remotePath);
await this.app.vault.create(remotePath, remoteContent);
nextSettings.syncState.files[remotePath] = this.createSyncedState(
"text",
change.file.manifest.revisionId,
change.file.manifest.contentHash,
change.file.manifest.updatedAt,
remoteContent
);
diagnostics.info("remote-text-created", {
path: remotePath
});
continue;
}
const localContent = await this.app.vault.cachedRead(localAbstract);
const localHash = await computeTextHash(localContent);
if (localHash === change.file.manifest.contentHash) {
nextSettings.syncState.files[remotePath] = this.createSyncedState(
"text",
change.file.manifest.revisionId,
change.file.manifest.contentHash,
change.file.manifest.updatedAt,
remoteContent
);
continue;
}
if (!previous || localHash === previous.contentHash || localContent === previous.lastSyncedContent) {
await this.app.vault.modify(localAbstract, remoteContent);
nextSettings.syncState.files[remotePath] = this.createSyncedState(
"text",
change.file.manifest.revisionId,
change.file.manifest.contentHash,
change.file.manifest.updatedAt,
remoteContent
);
diagnostics.info("remote-text-applied", {
path: remotePath
});
continue;
}
const merged = mergeTextRevisions({
base: previous.lastSyncedContent,
local: localContent,
remote: remoteContent
});
await this.app.vault.modify(localAbstract, merged.content);
if (merged.status === "conflict") {
const conflictCopyPath = createConflictCopyPath(remotePath);
await this.ensureParentFolders(conflictCopyPath);
await this.writeTextFile(conflictCopyPath, remoteContent);
nextSettings.syncState.files[remotePath] = {
...this.createSyncedState(
"text",
change.file.manifest.revisionId,
change.file.manifest.contentHash,
change.file.manifest.updatedAt,
remoteContent
),
pendingConflict: true,
pendingConflictHash: await computeTextHash(merged.content)
};
diagnostics.warn("remote-text-conflict", {
path: remotePath,
conflictCopyPath
});
new Notice(`Conflict markers added to ${remotePath}. Review the file before the next sync.`);
continue;
}
nextSettings.syncState.files[remotePath] = this.createSyncedState(
"text",
change.file.manifest.revisionId,
change.file.manifest.contentHash,
change.file.manifest.updatedAt,
remoteContent
);
continue;
}
const remoteBinary = await decryptBytes(change.file.envelope, vaultKey);
if (!(localAbstract instanceof TFile)) {
await this.ensureParentFolders(remotePath);
await this.app.vault.createBinary(remotePath, remoteBinary);
nextSettings.syncState.files[remotePath] = this.createSyncedState(
"binary",
change.file.manifest.revisionId,
change.file.manifest.contentHash,
change.file.manifest.updatedAt
);
diagnostics.info("remote-binary-created", {
path: remotePath
});
continue;
}
const localBinary = await this.app.vault.readBinary(localAbstract);
const localHash = await computeBinaryHash(localBinary);
if (localHash === change.file.manifest.contentHash) {
nextSettings.syncState.files[remotePath] = this.createSyncedState(
"binary",
change.file.manifest.revisionId,
change.file.manifest.contentHash,
change.file.manifest.updatedAt
);
continue;
}
if (!previous || localHash === previous.contentHash) {
await this.app.vault.modifyBinary(localAbstract, remoteBinary);
nextSettings.syncState.files[remotePath] = this.createSyncedState(
"binary",
change.file.manifest.revisionId,
change.file.manifest.contentHash,
change.file.manifest.updatedAt
);
diagnostics.info("remote-binary-applied", {
path: remotePath
});
continue;
}
const conflictCopyPath = createConflictCopyPath(remotePath);
await this.ensureParentFolders(conflictCopyPath);
await this.writeBinaryFile(conflictCopyPath, remoteBinary);
nextSettings.syncState.files[remotePath] = {
...this.createSyncedState(
"binary",
change.file.manifest.revisionId,
change.file.manifest.contentHash,
change.file.manifest.updatedAt
),
pendingConflict: true,
pendingConflictHash: localHash
};
diagnostics.warn("remote-binary-conflict", {
path: remotePath,
conflictCopyPath
});
new Notice(`Attachment conflict detected for ${remotePath}. Review the remote conflict copy before the next sync.`);
}
if (change.tombstone) {
const localAbstract = this.app.vault.getAbstractFileByPath(change.tombstone.path);
const previous = nextSettings.syncState.files[change.tombstone.path];
if (!(localAbstract instanceof TFile)) {
delete nextSettings.syncState.files[change.tombstone.path];
continue;
}
const snapshot = await this.readLocalFileSnapshot(localAbstract, previous);
if (!previous || snapshot.contentHash === previous.contentHash) {
await this.app.vault.delete(localAbstract, true);
delete nextSettings.syncState.files[change.tombstone.path];
diagnostics.info("remote-delete-applied", {
path: change.tombstone.path
});
continue;
}
diagnostics.warn("remote-delete-skipped", {
path: change.tombstone.path
});
new Notice(`Skipped remote delete for ${change.tombstone.path} because local changes still exist.`);
}
}
nextSettings.syncState.serverRevision = pullResponse.nextSinceServerRevision;
await this.writeSettings(nextSettings);
}
private async ensureParentFolders(path: string): Promise<void> {
const segments = normalizePath(path).split("/");
segments.pop();
let currentPath = "";
for (const segment of segments) {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
if (!this.app.vault.getAbstractFileByPath(currentPath)) {
await this.app.vault.createFolder(currentPath);
}
}
}
private async writeTextFile(path: string, content: string): Promise<void> {
const existing = this.app.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
return;
}
await this.app.vault.create(path, content);
}
private async writeBinaryFile(path: string, content: ArrayBuffer): Promise<void> {
const existing = this.app.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) {
await this.app.vault.modifyBinary(existing, content);
return;
}
await this.app.vault.createBinary(path, content);
}
private async uploadDiagnostics(diagnostics: SyncRunLogger): Promise<void> {
const settings = this.readSettings();
diagnostics.setDeviceId(settings.deviceId);
if (!diagnostics.hasEntries() || !settings.deviceId || !settings.authToken) {
return;
}
try {
await this.postJson(
"/api/logs",
diagnostics.toUploadRequest(settings.vaultId),
ClientLogUploadResponseSchema,
settings.authToken
);
} catch (error) {
console.warn("Failed to upload sync diagnostics", error);
}
}
private async postJson<TSchema>(
path: string,
body: unknown,
schema: { parse: (value: unknown) => TSchema },
authToken?: string
): Promise<TSchema> {
const response = await fetch(`${this.getBaseUrl()}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
...(authToken ? { authorization: `Bearer ${authToken}` } : {})
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
const json = (await response.json()) as unknown;
return schema.parse(json);
}
}

View File

@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"module": "CommonJS"
},
"references": [
{
"path": "../../packages/sync-protocol"
},
{
"path": "../../packages/sync-engine"
}
],
"include": [
"src/**/*.ts"
]
}