Release 0.9.7 — fix lint warnings from Obsidian review bot

This commit is contained in:
Andrew Kopylev 2026-05-03 02:08:59 +03:00
parent 09e0932fb7
commit 62e1da82ed
20 changed files with 4785 additions and 217 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ data.json
RELEASING.md
CLAUDE.md
КАКУБЛИКОВАТЬ_РЕЛИЗ.txt
pr-body.md

17
eslint.config.mjs Normal file
View file

@ -0,0 +1,17 @@
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
export default defineConfig([
...obsidianmd.configs.recommended,
{
files: ["**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: { project: "./tsconfig.json" },
},
},
{
ignores: ["main.js", "node_modules/**", "dist/**"],
},
]);

View file

@ -1,7 +1,7 @@
{
"id": "vault-bridge-sftp",
"name": "Vault Bridge SFTP",
"version": "0.9.6",
"version": "0.9.7",
"minAppVersion": "1.5.0",
"description": "Bridge your vault across devices through your own SSH/SFTP server. Bidirectional sync with conflict resolution, multi-device safety, and full self-hosting.",
"author": "Andrew Kopylev",

4471
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,29 @@
{
"name": "obsidian-vault-bridge-sftp",
"version": "0.9.6",
"version": "0.9.7",
"description": "Vault Bridge SFTP — bidirectional Obsidian vault sync over SSH/SFTP",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
},
"keywords": ["obsidian", "sftp", "ssh", "sync"],
"keywords": [
"obsidian",
"sftp",
"ssh",
"sync"
],
"author": "Andrew Kopylev",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.11.0",
"@types/ssh2-sftp-client": "^9.0.4",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"builtin-modules": "^3.3.0",
"esbuild": "^0.20.2",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.2.9",
"obsidian": "latest",
"tslib": "^2.6.2",
"typescript": "^5.4.5"

View file

@ -15,7 +15,7 @@ import { SyncEngine } from "./sync/sync-engine";
import { askBulkDeleteDecision } from "./ui/bulk-delete-modal";
import { askServerResetDecision } from "./ui/server-reset-modal";
import { rebuildRemoteManifest } from "./sync/manifest-rebuilder";
import { STATE_PATHS } from "./state/paths";
import { pluginPaths, PluginPaths } from "./state/paths";
export default class SftpSyncPlugin extends Plugin {
settings!: SftpSyncSettings;
@ -26,6 +26,7 @@ export default class SftpSyncPlugin extends Plugin {
lastSynced!: LastSyncedStore;
secretStore!: SecretStore;
knownHosts!: KnownHostsStore;
paths!: PluginPaths;
private statusBar: HTMLElement | null = null;
private syncInProgress = false;
@ -37,6 +38,8 @@ export default class SftpSyncPlugin extends Plugin {
private autoSyncDebouncer: ReturnType<typeof debounce> | null = null;
async onload() {
this.paths = pluginPaths(this.app.vault.configDir);
// SecretStore must be ready before loadSettings — encrypted secrets are decrypted on load.
this.secretStore = new SecretStore(this.app);
await this.secretStore.load();
@ -55,89 +58,89 @@ export default class SftpSyncPlugin extends Plugin {
this.lastSynced = new LastSyncedStore(this.app);
await this.lastSynced.load();
this.exclude = new ExcludeMatcher(this.settings);
this.exclude = new ExcludeMatcher(this.app, this.settings);
this.scanner = new Scanner(this.app, this.index, this.exclude);
this.statusBar = this.addStatusBarItem();
this.setStatus("idle");
this.statusBar.addEventListener("click", () => this.syncNow());
this.statusBar.addEventListener("click", () => { void this.syncNow(); });
this.addSettingTab(new SftpSyncSettingTab(this.app, this));
this.addCommand({
id: "test-connection",
name: "Test connection",
callback: () => this.testConnection(),
callback: () => { void this.testConnection(); },
});
this.addCommand({
id: "sync-now",
name: "Sync now",
callback: () => this.syncNow(),
callback: () => { void this.syncNow(); },
});
this.addCommand({
id: "rebuild-index",
name: "Rebuild local index",
callback: () => this.rebuildIndex(),
callback: () => { void this.rebuildIndex(); },
});
this.addCommand({
id: "index-stats",
name: "Show index stats",
callback: () => this.showIndexStats(),
callback: () => { this.showIndexStats(); },
});
this.addCommand({
id: "inspect-remote",
name: "Inspect remote state (manifest + lock)",
callback: () => this.inspectRemoteState(),
callback: () => { void this.inspectRemoteState(); },
});
this.addCommand({
id: "force-release-lock",
name: "Force-release remote sync lock",
callback: () => this.forceReleaseLock(),
callback: () => { void this.forceReleaseLock(); },
});
this.addCommand({
id: "force-push",
name: "Force push everything (re-upload all files)",
callback: () => this.forcePush(),
callback: () => { void this.forcePush(); },
});
this.addCommand({
id: "pull",
name: "Pull from server",
callback: () => this.pullNow(),
callback: () => { void this.pullNow(); },
});
this.addCommand({
id: "force-pull",
name: "Force pull everything (re-download all files)",
callback: () => this.pullNow({ forceDownload: true }),
callback: () => { void this.pullNow({ forceDownload: true }); },
});
this.addCommand({
id: "rebuild-remote-manifest",
name: "Rebuild remote manifest (walk server, re-hash)",
callback: () => this.rebuildRemoteManifestCmd(),
callback: () => { void this.rebuildRemoteManifestCmd(); },
});
this.addCommand({
id: "reset-snapshot",
name: "Reset local snapshot (treat next sync as fresh)",
callback: () => this.resetLocalSnapshot(),
callback: () => { void this.resetLocalSnapshot(); },
});
this.addCommand({
id: "forget-host-fingerprint",
name: "Forget remembered host fingerprint",
callback: () => this.forgetHostFingerprint(),
callback: () => { void this.forgetHostFingerprint(); },
});
// (e) Manual trigger: ribbon icon (in addition to the status bar click).
this.addRibbonIcon("refresh-cw", "Vault Bridge: Sync now", () => this.syncNow());
this.addRibbonIcon("refresh-cw", "Sync now", () => { void this.syncNow(); });
// (a) Startup: initial scan, then auto-sync if enabled.
this.app.workspace.onLayoutReady(() => {
@ -148,8 +151,13 @@ export default class SftpSyncPlugin extends Plugin {
this.rebuildAutoSyncDebouncer();
}
async onunload() {
// (b) Quit-time best-effort push — fire and forget with a timeout.
onunload(): void {
// Synchronous teardown the base class expects.
// Async work (best-effort quit-push, index flush) is fire-and-forget.
void this.runOnUnload();
}
private async runOnUnload(): Promise<void> {
if (
this.startupComplete &&
this.settings?.autoSyncOnQuit &&
@ -162,7 +170,11 @@ export default class SftpSyncPlugin extends Plugin {
}
}
// Force a final flush so we don't lose recent updates.
if (this.index) await this.index.flush().catch(() => {});
if (this.index) {
try {
await this.index.flush();
} catch (_flushErr) { /* ignore — best-effort on shutdown */ }
}
// Clean up any pending debounced calls.
for (const fn of this.pendingRefresh.values()) fn.cancel?.();
this.pendingRefresh.clear();
@ -179,9 +191,10 @@ export default class SftpSyncPlugin extends Plugin {
// Migration: workspace.json is now handled by the syncWorkspaceJson toggle, not the
// user-editable exclude list. Strip the auto-added entries so the toggle actually works.
const cfg = this.app.vault.configDir;
const RETIRED_AUTO_PATTERNS = new Set([
".obsidian/workspace.json",
".obsidian/workspace-mobile.json",
`${cfg}/workspace.json`,
`${cfg}/workspace-mobile.json`,
]);
if (Array.isArray(raw.excludePatterns)) {
raw.excludePatterns = (raw.excludePatterns as string[]).filter(
@ -216,7 +229,7 @@ export default class SftpSyncPlugin extends Plugin {
for (const k of Object.keys(DEFAULT_SETTINGS)) {
if (k in raw) known[k] = raw[k];
}
this.settings = Object.assign({}, DEFAULT_SETTINGS, known) as SftpSyncSettings;
this.settings = Object.assign({}, DEFAULT_SETTINGS, known);
if (needsResaveEncrypted) {
await this.persistSettings();
@ -239,7 +252,7 @@ export default class SftpSyncPlugin extends Plugin {
await this.persistSettings();
// Re-build matcher when settings change (toggles, exclude patterns).
if (this.exclude) {
this.exclude = new ExcludeMatcher(this.settings);
this.exclude = new ExcludeMatcher(this.app, this.settings);
this.scanner = new Scanner(this.app, this.index, this.exclude);
}
// Re-create the debouncer so a changed delay takes effect.
@ -270,23 +283,23 @@ export default class SftpSyncPlugin extends Plugin {
this.startupComplete = true;
if (this.settings.autoSyncOnStartup) {
// Brief delay so any open files settle before we start poking the network.
setTimeout(() => { void this.autoSync("startup"); }, 1000);
activeWindow.setTimeout(() => { void this.autoSync("startup"); }, 1000);
}
}
/** Auto-sync: like syncNow, but quieter and auto-cancels bulk-delete prompts. */
private async autoSync(reason: "startup" | "change"): Promise<void> {
if (this.syncInProgress) {
console.log(`Vault Bridge: auto-sync (${reason}) skipped — already running`);
console.debug(`Vault Bridge: auto-sync (${reason}) skipped — already running`);
return;
}
if (!isConnectionConfigured(this.settings)) {
console.log(`Vault Bridge: auto-sync (${reason}) skipped — connection not configured`);
console.debug(`Vault Bridge: auto-sync (${reason}) skipped — connection not configured`);
return;
}
this.syncInProgress = true;
this.setStatus("syncing");
console.log(`Vault Bridge: auto-sync triggered (${reason})`);
console.debug(`Vault Bridge: auto-sync triggered (${reason})`);
try {
const engine = new SyncEngine(
this.app, this.settings, this.deviceStore,
@ -297,12 +310,12 @@ export default class SftpSyncPlugin extends Plugin {
this.setStatus(p.total === 0 ? "syncing" : `syncing ${p.processed}/${p.total}`);
},
// For unattended syncs we never want to block on a modal — auto-cancel and tell the user.
confirmBulkDelete: async (info) => {
confirmBulkDelete: (info) => {
new Notice(
`Vault Bridge: auto-sync paused — ${info.outgoingDeletes.length + info.incomingDeletes.length} pending deletions need review. Run "Sync now" manually.`,
`Auto-sync paused — ${info.outgoingDeletes.length + info.incomingDeletes.length} pending deletions need review. Run "Sync now" manually.`,
10000,
);
return "cancel";
return Promise.resolve("cancel");
},
});
@ -319,7 +332,7 @@ export default class SftpSyncPlugin extends Plugin {
if (c.deleteLocal) parts.push(`del-local ${c.deleteLocal}`);
if (c.conflict) parts.push(`conflicts ${c.conflict}`);
if (c.restore) parts.push(`restored ${c.restore}`);
let msg = `Vault Bridge (${reason}): ${parts.join(", ")} (${secs}s)`;
let msg = `Auto-sync (${reason}): ${parts.join(", ")} (${secs}s)`;
if (result.conflictCopies.length) {
msg += `\nConflict copies: ${result.conflictCopies.length}`;
}
@ -329,7 +342,7 @@ export default class SftpSyncPlugin extends Plugin {
this.setStatus("ok");
} catch (err) {
console.error(`Vault Bridge: auto-sync (${reason}) failed`, err);
new Notice(`Vault Bridge auto-sync failed — ${(err as Error).message}`, 8000);
new Notice(`Auto-sync failed — ${(err as Error).message}`, 8000);
this.setStatus("error");
} finally {
this.syncInProgress = false;
@ -346,7 +359,7 @@ export default class SftpSyncPlugin extends Plugin {
await engine.pushAll();
})();
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`quit-push timed out after ${timeoutMs}ms`)), timeoutMs),
activeWindow.setTimeout(() => reject(new Error(`quit-push timed out after ${timeoutMs}ms`)), timeoutMs),
);
await Promise.race([work, timeout]);
}
@ -358,17 +371,17 @@ export default class SftpSyncPlugin extends Plugin {
// Subsequent loads: index exists → cheap mtime check.
const isFirstScan = this.index.size() === 0;
if (isFirstScan) {
new Notice("Vault Bridge: building initial index…", 3000);
new Notice("Building initial index…", 3000);
}
try {
const { entries, took } = await this.scanner.fullScan();
console.log(`Vault Bridge: scan finished — ${entries.length} files in ${took}ms`);
console.debug(`Vault Bridge: scan finished — ${entries.length} files in ${took}ms`);
if (isFirstScan) {
new Notice(`Vault Bridge: indexed ${entries.length} files (${took}ms)`, 4000);
new Notice(`Indexed ${entries.length} files (${took}ms)`, 4000);
}
} catch (err) {
console.error("Vault Bridge: initial scan failed", err);
new Notice(`Vault Bridge: initial scan failed — ${(err as Error).message}`, 8000);
new Notice(`Initial scan failed — ${(err as Error).message}`, 8000);
}
}
@ -445,10 +458,10 @@ export default class SftpSyncPlugin extends Plugin {
try {
await client.connect();
await client.ensureRemoteRoot();
new Notice("Vault Bridge: connection OK", 4000);
new Notice("Connection OK", 4000);
} catch (err) {
console.error("Vault Bridge: connection failed", err);
new Notice(`Vault Bridge: connection FAILED — ${(err as Error).message}`, 8000);
new Notice(`Connection FAILED — ${(err as Error).message}`, 8000);
} finally {
await client.end();
}
@ -457,7 +470,7 @@ export default class SftpSyncPlugin extends Plugin {
/** Bidirectional sync — the everyday command. Uses SyncEngine (3-way diff). */
async syncNow(): Promise<void> {
if (this.syncInProgress) {
new Notice("Vault Bridge: already running", 3000);
new Notice("Already running", 3000);
return;
}
this.syncInProgress = true;
@ -496,12 +509,12 @@ export default class SftpSyncPlugin extends Plugin {
if (result.cancelled) {
new Notice(
`Vault Bridge: cancelled by user (no changes made, ${secs}s)`,
`Cancelled by user (no changes made, ${secs}s)`,
5000,
);
} else if (result.noChanges || c.ioOps === 0) {
new Notice(
`Vault Bridge: nothing to do — already in sync (gen=${result.generation}, ${secs}s)`,
`Already in sync (gen=${result.generation}, ${secs}s)`,
5000,
);
} else {
@ -513,15 +526,15 @@ export default class SftpSyncPlugin extends Plugin {
if (c.conflict) parts.push(`conflicts ${c.conflict}`);
if (c.restore) parts.push(`restored ${c.restore}`);
let msg = `Vault Bridge: ${parts.join(", ")} (gen=${result.generation}, ${secs}s)`;
let msg = `${parts.join(", ")} (gen=${result.generation}, ${secs}s)`;
if (result.deletesSkipped) {
msg += "\n⚠️ Deletes were skipped at user request — re-run sync to address them.";
msg += "\nDeletes were skipped at user request — re-run sync to address them.";
}
if (result.conflictCopies.length) {
msg += `\nConflict copies created:\n ${result.conflictCopies.join("\n ")}`;
}
if (result.selfHealedMissing?.length) {
msg += `\n⚠️ ${result.selfHealedMissing.length} file(s) were missing on server — dropped from manifest.`;
msg += `\n${result.selfHealedMissing.length} file(s) were missing on server — dropped from manifest.`;
}
new Notice(msg, 10000);
}
@ -529,7 +542,7 @@ export default class SftpSyncPlugin extends Plugin {
this.setStatus("ok");
} catch (err) {
console.error("Vault Bridge: sync failed", err);
new Notice(`Vault Bridge: sync FAILED — ${(err as Error).message}`, 10000);
new Notice(`Sync FAILED — ${(err as Error).message}`, 10000);
this.setStatus("error");
} finally {
this.syncInProgress = false;
@ -540,16 +553,16 @@ export default class SftpSyncPlugin extends Plugin {
private async handleServerReset(info: NonNullable<Awaited<ReturnType<SyncEngine["syncBoth"]>>["serverReset"]>): Promise<void> {
const choice = await askServerResetDecision(this.app, info);
if (choice === "cancel") {
new Notice("Vault Bridge: server-reset cancelled — no changes made.", 5000);
new Notice("Server-reset cancelled — no changes made.", 5000);
return;
}
if (choice === "force-push") {
// syncInProgress is already true here (from syncNow), so call the inner method
// directly — wrapping forcePush() would short-circuit on its in-progress guard.
new Notice("Vault Bridge: forcing push from local…", 3000);
new Notice("Forcing push from local…", 3000);
try {
await this._runForcePush();
} catch (err) {
} catch (_err) {
// Already reported by _runForcePush; nothing more to do.
}
return;
@ -557,7 +570,7 @@ export default class SftpSyncPlugin extends Plugin {
if (choice === "reset-snapshot") {
await this.resetLocalSnapshot({ silent: true });
new Notice(
"Vault Bridge: local snapshot reset. Next 'Sync now' will treat all files as fresh additions.",
"Local snapshot reset. Next sync will treat all files as fresh additions.",
6000,
);
}
@ -567,29 +580,29 @@ export default class SftpSyncPlugin extends Plugin {
async resetLocalSnapshot(opts: { silent?: boolean } = {}): Promise<void> {
const adapter = this.app.vault.adapter;
try {
if ((await adapter.exists(STATE_PATHS.lastSynced)) === true) {
await adapter.remove(STATE_PATHS.lastSynced);
if ((await adapter.exists(this.paths.lastSynced)) === true) {
await adapter.remove(this.paths.lastSynced);
}
await this.lastSynced.load(); // re-loads as empty
if (!opts.silent) {
new Notice("Vault Bridge: local snapshot reset.", 5000);
new Notice("Local snapshot reset.", 5000);
}
} catch (err) {
console.error("Vault Bridge: snapshot reset failed", err);
new Notice(`Vault Bridge: snapshot reset failed — ${(err as Error).message}`, 8000);
new Notice(`Snapshot reset failed — ${(err as Error).message}`, 8000);
}
}
/** Walk the remote vault, hash every file, rewrite manifest.json. Recovery for scenario 1. */
async rebuildRemoteManifestCmd(): Promise<void> {
if (this.syncInProgress) {
new Notice("Vault Bridge: another operation is running, try again later", 4000);
new Notice("Another operation is running, try again later", 4000);
return;
}
this.syncInProgress = true;
this.setStatus("rebuilding");
try {
new Notice("Vault Bridge: walking server and rebuilding manifest — this can take a while…", 4000);
new Notice("Walking server and rebuilding manifest — this can take a while…", 4000);
const result = await rebuildRemoteManifest(
this.settings,
this.deviceStore,
@ -603,13 +616,13 @@ export default class SftpSyncPlugin extends Plugin {
const mb = (result.totalBytes / (1024 * 1024)).toFixed(2);
const secs = (result.took / 1000).toFixed(1);
new Notice(
`Vault Bridge: manifest rebuilt — ${result.filesIndexed} files (${mb} MB) in ${secs}s (gen=${result.generation}). Run 'Sync now' to reconcile locally.`,
`Manifest rebuilt — ${result.filesIndexed} files (${mb} MB) in ${secs}s (gen=${result.generation}). Run "Sync now" to reconcile locally.`,
8000,
);
this.setStatus("ok");
} catch (err) {
console.error("Vault Bridge: rebuild failed", err);
new Notice(`Vault Bridge: rebuild FAILED — ${(err as Error).message}`, 10000);
new Notice(`Rebuild FAILED — ${(err as Error).message}`, 10000);
this.setStatus("error");
} finally {
this.syncInProgress = false;
@ -619,7 +632,7 @@ export default class SftpSyncPlugin extends Plugin {
/** Force-push: re-upload everything without 3-way diff. Use after manifest corruption. */
async forcePush(): Promise<void> {
if (this.syncInProgress) {
new Notice("Vault Bridge: already running", 3000);
new Notice("Already running", 3000);
return;
}
this.syncInProgress = true;
@ -653,13 +666,13 @@ export default class SftpSyncPlugin extends Plugin {
const mb = (result.totalBytes / (1024 * 1024)).toFixed(2);
const secs = (result.took / 1000).toFixed(1);
new Notice(
`Vault Bridge: force-pushed ${result.uploaded} files (${mb} MB) in ${secs}s (gen=${result.generation})`,
`Force-pushed ${result.uploaded} files (${mb} MB) in ${secs}s (gen=${result.generation})`,
6000,
);
this.setStatus("ok");
} catch (err) {
console.error("Vault Bridge: force-push failed", err);
new Notice(`Vault Bridge: force-push FAILED — ${(err as Error).message}`, 10000);
new Notice(`Force-push FAILED — ${(err as Error).message}`, 10000);
this.setStatus("error");
throw err;
}
@ -667,7 +680,7 @@ export default class SftpSyncPlugin extends Plugin {
async pullNow(opts: { forceDownload?: boolean } = {}): Promise<void> {
if (this.syncInProgress) {
new Notice("Vault Bridge: already running", 3000);
new Notice("Already running", 3000);
return;
}
this.syncInProgress = true;
@ -694,13 +707,13 @@ export default class SftpSyncPlugin extends Plugin {
const secs = (result.took / 1000).toFixed(1);
const msg =
result.downloaded === 0
? `Vault Bridge: nothing to pull (${result.skipped} already up to date, ${secs}s, gen=${result.generation})`
: `Vault Bridge: pulled ${result.downloaded} files (${mb} MB), skipped ${result.skipped} up-to-date, in ${secs}s (gen=${result.generation})`;
? `Nothing to pull (${result.skipped} already up to date, ${secs}s, gen=${result.generation})`
: `Pulled ${result.downloaded} files (${mb} MB), skipped ${result.skipped} up-to-date, in ${secs}s (gen=${result.generation})`;
new Notice(msg, 6000);
this.setStatus("ok");
} catch (err) {
console.error("Vault Bridge: pull failed", err);
new Notice(`Vault Bridge: pull FAILED — ${(err as Error).message}`, 10000);
new Notice(`Pull FAILED — ${(err as Error).message}`, 10000);
this.setStatus("error");
} finally {
this.syncInProgress = false;
@ -718,13 +731,13 @@ export default class SftpSyncPlugin extends Plugin {
}
async rebuildIndex(): Promise<void> {
new Notice("Vault Bridge: rebuilding local index…", 3000);
new Notice("Rebuilding local index…", 3000);
try {
const { entries, took } = await this.scanner.fullScan({ forceRehash: true });
new Notice(`Vault Bridge: rebuilt — ${entries.length} files (${took}ms)`, 4000);
new Notice(`Rebuilt — ${entries.length} files (${took}ms)`, 4000);
} catch (err) {
console.error("Vault Bridge: rebuild failed", err);
new Notice(`Vault Bridge: rebuild failed — ${(err as Error).message}`, 8000);
new Notice(`Rebuild failed — ${(err as Error).message}`, 8000);
}
}
@ -733,7 +746,7 @@ export default class SftpSyncPlugin extends Plugin {
const mb = (this.index.totalBytes() / (1024 * 1024)).toFixed(2);
const last = this.index.lastScannedAt();
const ago = last === 0 ? "never" : `${Math.round((Date.now() - last) / 1000)}s ago`;
new Notice(`Vault Bridge: ${n} files, ${mb} MB, last full scan: ${ago}`, 6000);
new Notice(`${n} files, ${mb} MB, last full scan: ${ago}`, 6000);
}
/** Connect, read manifest + lock, print summary. Used to verify Phase 3. */
@ -761,11 +774,11 @@ export default class SftpSyncPlugin extends Plugin {
? `lock: HELD by ${lock.deviceLabel} (${lock.deviceId.slice(0, 6)}), age ${Math.round((Date.now() - lock.acquiredAt) / 1000)}s`
: "lock: free";
new Notice(`Vault Bridge remote state\n${manifestSummary}\n${lockSummary}`, 10000);
console.log("Vault Bridge remote state", { manifest, lock });
new Notice(`Remote state\n${manifestSummary}\n${lockSummary}`, 10000);
console.debug("Vault Bridge remote state", { manifest, lock });
} catch (err) {
console.error("Vault Bridge: inspect failed", err);
new Notice(`Vault Bridge: inspect failed — ${(err as Error).message}`, 8000);
new Notice(`Inspect failed — ${(err as Error).message}`, 8000);
} finally {
await client.end();
}
@ -784,21 +797,21 @@ export default class SftpSyncPlugin extends Plugin {
);
const before = await remote.readLock();
if (!before) {
new Notice("Vault Bridge: no lock to release", 4000);
new Notice("No lock to release", 4000);
return;
}
if (before.deviceId !== this.deviceStore.id) {
new Notice(
`Vault Bridge: lock is held by ${before.deviceLabel}, not us. Won't touch it. (Wait until it goes stale, ~5min.)`,
`Lock is held by ${before.deviceLabel}, not us. Won't touch it. (Wait until it goes stale, ~5min.)`,
8000,
);
return;
}
await remote.releaseLock();
new Notice("Vault Bridge: lock released", 4000);
new Notice("Lock released", 4000);
} catch (err) {
console.error("Vault Bridge: force release failed", err);
new Notice(`Vault Bridge: force release failed — ${(err as Error).message}`, 8000);
new Notice(`Force release failed — ${(err as Error).message}`, 8000);
} finally {
await client.end();
}
@ -808,18 +821,18 @@ export default class SftpSyncPlugin extends Plugin {
* Use after a deliberate server reinstall so the next connect re-runs TOFU. */
async forgetHostFingerprint(): Promise<void> {
if (!this.settings.host) {
new Notice("Vault Bridge: no host configured", 4000);
new Notice("No host configured", 4000);
return;
}
const port = this.settings.port || 22;
const removed = await this.knownHosts.forget(this.settings.host, port);
if (removed) {
new Notice(
`Vault Bridge: forgot fingerprint for ${this.settings.host}:${port}. Next connection will trust the new server key on first contact.`,
`Forgot fingerprint for ${this.settings.host}:${port}. Next connection will trust the new server key on first contact.`,
6000,
);
} else {
new Notice(`Vault Bridge: no remembered fingerprint for ${this.settings.host}:${port}`, 5000);
new Notice(`No remembered fingerprint for ${this.settings.host}:${port}`, 5000);
}
}
}

View file

@ -1,4 +1,4 @@
import { App, PluginSettingTab, Setting, Notice } from "obsidian";
import { App, PluginSettingTab, Setting } from "obsidian";
import type SftpSyncPlugin from "./main";
export type AuthMethod = "password" | "key";
@ -88,9 +88,9 @@ export class SftpSyncSettingTab extends PluginSettingTab {
t
.setPlaceholder("example.com")
.setValue(this.plugin.settings.host)
.onChange(async (v) => {
.onChange((v) => {
this.plugin.settings.host = v.trim();
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
@ -101,10 +101,10 @@ export class SftpSyncSettingTab extends PluginSettingTab {
t
.setPlaceholder("22")
.setValue(String(this.plugin.settings.port))
.onChange(async (v) => {
.onChange((v) => {
const n = parseInt(v, 10);
this.plugin.settings.port = Number.isFinite(n) && n > 0 ? n : 22;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
@ -113,23 +113,24 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.addText((t) =>
t
.setValue(this.plugin.settings.username)
.onChange(async (v) => {
.onChange((v) => {
this.plugin.settings.username = v.trim();
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Authentication")
.setDesc("Password or SSH private key. The server's SSH host key is pinned on first connect (TOFU); subsequent mismatches refuse to connect. Use \"Forget remembered host fingerprint\" after a deliberate server reinstall.")
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setDesc("Password or SSH private key. The server's SSH host key is pinned on first connect (TOFU); subsequent mismatches refuse to connect. Use the \"forget remembered host fingerprint\" command after a deliberate server reinstall.")
.addDropdown((d) =>
d
.addOption("password", "Password")
.addOption("key", "Private key")
.setValue(this.plugin.settings.authMethod)
.onChange(async (v) => {
.onChange((v) => {
this.plugin.settings.authMethod = v as AuthMethod;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
this.display();
}),
);
@ -140,22 +141,24 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.setDesc("Encrypted at rest (AES-256-GCM) with a per-device key in state/secret.key. The state directory never syncs, so a leaked data.json on the SFTP server cannot be decrypted without local access. SSH keys are still preferred on shared machines.")
.addText((t) => {
t.inputEl.type = "password";
t.setValue(this.plugin.settings.password).onChange(async (v) => {
t.setValue(this.plugin.settings.password).onChange((v) => {
this.plugin.settings.password = v;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
});
});
} else {
new Setting(containerEl)
.setName("Private key path")
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setDesc("Absolute filesystem path, e.g. /home/user/.ssh/id_ed25519.")
.addText((t) =>
t
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setPlaceholder("/home/user/.ssh/id_ed25519")
.setValue(this.plugin.settings.privateKeyPath)
.onChange(async (v) => {
.onChange((v) => {
this.plugin.settings.privateKeyPath = v.trim();
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
@ -164,23 +167,25 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.setDesc("Empty if the key is unencrypted.")
.addText((t) => {
t.inputEl.type = "password";
t.setValue(this.plugin.settings.passphrase).onChange(async (v) => {
t.setValue(this.plugin.settings.passphrase).onChange((v) => {
this.plugin.settings.passphrase = v;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
});
});
}
new Setting(containerEl)
.setName("Remote root")
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setDesc("Absolute path on the server, e.g. /home/user/obsidian-vault. Created if it doesn't exist.")
.addText((t) =>
t
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setPlaceholder("/home/user/obsidian-vault")
.setValue(this.plugin.settings.remoteRoot)
.onChange(async (v) => {
.onChange((v) => {
this.plugin.settings.remoteRoot = v.trim();
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
@ -188,37 +193,37 @@ export class SftpSyncSettingTab extends PluginSettingTab {
new Setting(containerEl).setName("Sync scope").setHeading();
new Setting(containerEl)
.setName("Sync everything (.obsidian too)")
.setDesc("When ON, plugins/themes/snippets/hotkeys are synced so all devices look identical.")
.setName("Sync the Obsidian config folder too")
.setDesc("When enabled, plugins/themes/snippets/hotkeys are synced so all devices look identical.")
.addToggle((t) =>
t.setValue(this.plugin.settings.syncEverything).onChange(async (v) => {
t.setValue(this.plugin.settings.syncEverything).onChange((v) => {
this.plugin.settings.syncEverything = v;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Sync workspace.json")
.setDesc("OFF by default. Workspace files describe open tabs/panels for this specific device. Turning this ON will cause flapping if you work on two devices at once.")
.setDesc("Off by default. Workspace files describe open tabs/panels for this specific device. Turning this on will cause flapping if you work on two devices at once.")
.addToggle((t) =>
t.setValue(this.plugin.settings.syncWorkspaceJson).onChange(async (v) => {
t.setValue(this.plugin.settings.syncWorkspaceJson).onChange((v) => {
this.plugin.settings.syncWorkspaceJson = v;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName("Exclude patterns")
.setDesc("One pattern per line. Gitignore-style. The plugin's own state/ directory is ALWAYS excluded regardless.")
.setDesc("One pattern per line. Gitignore-style. The plugin's own state/ directory is always excluded regardless.")
.addTextArea((t) => {
t.inputEl.rows = 6;
t.inputEl.addClass("vbsftp-textarea-full");
t.setValue(this.plugin.settings.excludePatterns.join("\n")).onChange(async (v) => {
t.setValue(this.plugin.settings.excludePatterns.join("\n")).onChange((v) => {
this.plugin.settings.excludePatterns = v
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
await this.plugin.saveSettings();
void this.plugin.saveSettings();
});
});
@ -229,9 +234,9 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.setName("Sync on startup")
.setDesc("Run a full bidirectional sync once Obsidian finishes loading.")
.addToggle((t) =>
t.setValue(this.plugin.settings.autoSyncOnStartup).onChange(async (v) => {
t.setValue(this.plugin.settings.autoSyncOnStartup).onChange((v) => {
this.plugin.settings.autoSyncOnStartup = v;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
@ -239,9 +244,9 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.setName("Sync on quit")
.setDesc("Best-effort push when Obsidian closes (5s timeout, push-only — no prompts).")
.addToggle((t) =>
t.setValue(this.plugin.settings.autoSyncOnQuit).onChange(async (v) => {
t.setValue(this.plugin.settings.autoSyncOnQuit).onChange((v) => {
this.plugin.settings.autoSyncOnQuit = v;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
@ -249,9 +254,9 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.setName("Sync after changes")
.setDesc("Debounced sync triggered by vault edits (create / modify / delete / rename).")
.addToggle((t) =>
t.setValue(this.plugin.settings.autoSyncOnChange).onChange(async (v) => {
t.setValue(this.plugin.settings.autoSyncOnChange).onChange((v) => {
this.plugin.settings.autoSyncOnChange = v;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
@ -261,11 +266,11 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.addText((t) =>
t
.setValue(String(this.plugin.settings.autoSyncDebounceSeconds))
.onChange(async (v) => {
.onChange((v) => {
const n = parseInt(v, 10);
this.plugin.settings.autoSyncDebounceSeconds =
Number.isFinite(n) && n >= 2 && n <= 600 ? n : 10;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
@ -278,11 +283,11 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.addText((t) =>
t
.setValue(String(this.plugin.settings.concurrency))
.onChange(async (v) => {
.onChange((v) => {
const n = parseInt(v, 10);
this.plugin.settings.concurrency =
Number.isFinite(n) && n >= MIN_CONCURRENCY && n <= MAX_CONCURRENCY ? n : 8;
await this.plugin.saveSettings();
void this.plugin.saveSettings();
}),
);
@ -310,7 +315,7 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.setName("Sync now")
.setDesc("Run a full sync immediately.")
.addButton((b) =>
b.setButtonText("Sync now").onClick(() => this.plugin.syncNow()),
b.setButtonText("Sync now").onClick(() => { void this.plugin.syncNow(); }),
);
// ─── Device ───────────────────────────────────────────────────────────
@ -321,10 +326,11 @@ export class SftpSyncSettingTab extends PluginSettingTab {
.setDesc("Used in conflict-copy filenames so you can tell which device the conflicting edit came from. Lives in state/device.json — each machine has its own.")
.addText((t) =>
t
// eslint-disable-next-line obsidianmd/ui/sentence-case
.setPlaceholder("home-desktop")
.setValue(this.plugin.deviceStore.label)
.onChange(async (v) => {
await this.plugin.deviceStore.setLabel(v);
.onChange((v) => {
void this.plugin.deviceStore.setLabel(v);
}),
);

View file

@ -153,7 +153,7 @@ export class RemoteState {
// Race verification: pause briefly then re-read to make sure no other client
// wrote on top of us during the window.
await new Promise((r) => setTimeout(r, RACE_VERIFY_MS));
await new Promise((r) => activeWindow.setTimeout(r, RACE_VERIFY_MS));
const after = await this.readLock();
if (!after || after.deviceId !== our.deviceId || after.acquiredAt !== our.acquiredAt) {
return null;

View file

@ -1,7 +1,6 @@
import type { DataAdapter } from "obsidian";
import { SftpClient } from "./client";
import { remotePathOf, parentDir } from "../sync/path-utils";
import { STATE_PATHS } from "../state/paths";
/** Cache of remote directories already known to exist within a single sync transaction.
* Concurrency-safe: every ancestor along a path is ensured top-down through the same
@ -97,20 +96,22 @@ export async function downloadToBuffer(
return buf;
}
/** Write a Buffer into the vault at `vaultPath`, atomically via state/tmp/. */
/** Write a Buffer into the vault at `vaultPath`, atomically via the given tmp directory.
* The caller (engine) provides `tmpDir` because it owns the path layout. */
export async function writeBufferToVault(
adapter: DataAdapter,
vaultPath: string,
buf: Buffer,
tmpDir: string,
): Promise<void> {
const parent = parentDir(vaultPath);
if (parent && (await adapter.exists(parent)) === false) {
await adapter.mkdir(parent);
}
if ((await adapter.exists(STATE_PATHS.tmp)) === false) {
await adapter.mkdir(STATE_PATHS.tmp);
if ((await adapter.exists(tmpDir)) === false) {
await adapter.mkdir(tmpDir);
}
const tmp = `${STATE_PATHS.tmp}/dl.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
const tmp = `${tmpDir}/dl.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
await adapter.writeBinary(tmp, ab);
try {
@ -119,7 +120,10 @@ export async function writeBufferToVault(
}
await adapter.rename(tmp, vaultPath);
} catch (err) {
try { await adapter.remove(tmp); } catch {}
// Best-effort cleanup of the staging file; never let cleanup errors mask the rename failure.
try {
await adapter.remove(tmp);
} catch (_cleanupErr) { /* ignore */ }
throw err;
}
}

View file

@ -1,6 +1,6 @@
import type { App, DataAdapter } from "obsidian";
import { randomBytes } from "crypto";
import { STATE_PATHS } from "./paths";
import { pluginPaths, PluginPaths } from "./paths";
interface DeviceInfo {
id: string;
@ -13,10 +13,12 @@ interface DeviceInfo {
*/
export class DeviceStore {
private adapter: DataAdapter;
private paths: PluginPaths;
private info: DeviceInfo = { id: "", label: "" };
constructor(app: App) {
this.adapter = app.vault.adapter;
this.paths = pluginPaths(app.vault.configDir);
}
get id(): string { return this.info.id; }
@ -25,9 +27,16 @@ export class DeviceStore {
async load(): Promise<void> {
let needSave = false;
try {
if (await this.adapter.exists(STATE_PATHS.device)) {
const raw = await this.adapter.read(STATE_PATHS.device);
this.info = JSON.parse(raw);
if (await this.adapter.exists(this.paths.device)) {
const raw = await this.adapter.read(this.paths.device);
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
const obj = parsed as Partial<DeviceInfo>;
this.info = {
id: typeof obj.id === "string" ? obj.id : "",
label: typeof obj.label === "string" ? obj.label : "",
};
}
}
} catch (err) {
console.warn("Vault Bridge: failed to load device info, regenerating", err);
@ -51,9 +60,9 @@ export class DeviceStore {
}
private async save(): Promise<void> {
if (!(await this.adapter.exists(STATE_PATHS.dir))) {
await this.adapter.mkdir(STATE_PATHS.dir);
if (!(await this.adapter.exists(this.paths.stateDir))) {
await this.adapter.mkdir(this.paths.stateDir);
}
await this.adapter.write(STATE_PATHS.device, JSON.stringify(this.info, null, 2));
await this.adapter.write(this.paths.device, JSON.stringify(this.info, null, 2));
}
}

View file

@ -1,7 +1,5 @@
import type { App } from "obsidian";
import { STATE_PATHS } from "./paths";
const KNOWN_HOSTS_PATH = `${STATE_PATHS.dir}/known-hosts.json`;
import { pluginPaths, PluginPaths } from "./paths";
export interface KnownHostEntry {
fingerprint: string; // SHA-256 of the server host key, base64
@ -20,18 +18,23 @@ interface KnownHostsFile {
* indicate a man-in-the-middle attack.
*/
export class KnownHostsStore {
private paths: PluginPaths;
private filePath: string;
private hosts: Record<string, KnownHostEntry> = {};
constructor(private app: App) {}
constructor(private app: App) {
this.paths = pluginPaths(app.vault.configDir);
this.filePath = `${this.paths.stateDir}/known-hosts.json`;
}
async load(): Promise<void> {
const adapter = this.app.vault.adapter;
if ((await adapter.exists(KNOWN_HOSTS_PATH)) === false) {
if ((await adapter.exists(this.filePath)) === false) {
this.hosts = {};
return;
}
try {
const raw = await adapter.read(KNOWN_HOSTS_PATH);
const raw = await adapter.read(this.filePath);
const parsed = JSON.parse(raw) as KnownHostsFile;
if (parsed.schemaVersion !== 1) throw new Error("unsupported known-hosts schema");
this.hosts = parsed.hosts ?? {};
@ -43,11 +46,11 @@ export class KnownHostsStore {
private async save(): Promise<void> {
const adapter = this.app.vault.adapter;
if ((await adapter.exists(STATE_PATHS.dir)) === false) {
await adapter.mkdir(STATE_PATHS.dir);
if ((await adapter.exists(this.paths.stateDir)) === false) {
await adapter.mkdir(this.paths.stateDir);
}
const file: KnownHostsFile = { schemaVersion: 1, hosts: this.hosts };
await adapter.write(KNOWN_HOSTS_PATH, JSON.stringify(file, null, 2));
await adapter.write(this.filePath, JSON.stringify(file, null, 2));
}
static keyFor(host: string, port: number): string {

View file

@ -2,16 +2,31 @@
export const PLUGIN_ID = "vault-bridge-sftp";
const STATE_DIR = `.obsidian/plugins/${PLUGIN_ID}/state`;
export interface PluginPaths {
configDir: string;
/** Per-device state directory; never synced. */
stateDir: string;
index: string;
lastSynced: string;
device: string;
tmp: string;
log: string;
/** Hard-coded path prefix that must NEVER be synced — recursion would corrupt the index. */
stateDirPrefix: string;
}
export const STATE_PATHS = {
dir: STATE_DIR,
index: `${STATE_DIR}/index.json`,
lastSynced: `${STATE_DIR}/last-synced.json`,
device: `${STATE_DIR}/device.json`,
tmp: `${STATE_DIR}/tmp`,
log: `${STATE_DIR}/log.jsonl`,
} as const;
/** Hard-coded path prefix that must NEVER be synced — recursion would corrupt the index. */
export const STATE_DIR_PREFIX = STATE_DIR + "/";
/** Build per-plugin paths rooted at the user's Obsidian config folder.
* Pass `app.vault.configDir` Obsidian lets users rename ".obsidian", so this must be dynamic. */
export function pluginPaths(configDir: string): PluginPaths {
const stateDir = `${configDir}/plugins/${PLUGIN_ID}/state`;
return {
configDir,
stateDir,
index: `${stateDir}/index.json`,
lastSynced: `${stateDir}/last-synced.json`,
device: `${stateDir}/device.json`,
tmp: `${stateDir}/tmp`,
log: `${stateDir}/log.jsonl`,
stateDirPrefix: stateDir + "/",
};
}

View file

@ -1,8 +1,7 @@
import type { App } from "obsidian";
import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
import { STATE_PATHS } from "./paths";
import { pluginPaths, PluginPaths } from "./paths";
const SECRET_PATH = `${STATE_PATHS.dir}/secret.key`;
const ENC_PREFIX = "enc:v1:";
const KEY_BYTES = 32; // AES-256
const IV_BYTES = 12; // 96-bit IV recommended for GCM
@ -17,28 +16,33 @@ const IV_BYTES = 12; // 96-bit IV recommended for GCM
* locally with the user's privileges.
*/
export class SecretStore {
private paths: PluginPaths;
private secretPath: string;
private key: Buffer | null = null;
constructor(private app: App) {}
constructor(private app: App) {
this.paths = pluginPaths(app.vault.configDir);
this.secretPath = `${this.paths.stateDir}/secret.key`;
}
async load(): Promise<void> {
const adapter = this.app.vault.adapter;
if ((await adapter.exists(SECRET_PATH)) === true) {
const ab = await adapter.readBinary(SECRET_PATH);
if ((await adapter.exists(this.secretPath)) === true) {
const ab = await adapter.readBinary(this.secretPath);
const buf = Buffer.from(ab);
if (buf.length !== KEY_BYTES) {
throw new Error(`Vault Bridge: ${SECRET_PATH} has unexpected length ${buf.length}`);
throw new Error(`Vault Bridge: ${this.secretPath} has unexpected length ${buf.length}`);
}
this.key = buf;
return;
}
if ((await adapter.exists(STATE_PATHS.dir)) === false) {
await adapter.mkdir(STATE_PATHS.dir);
if ((await adapter.exists(this.paths.stateDir)) === false) {
await adapter.mkdir(this.paths.stateDir);
}
const fresh = randomBytes(KEY_BYTES);
const ab = fresh.buffer.slice(fresh.byteOffset, fresh.byteOffset + fresh.byteLength) as ArrayBuffer;
await adapter.writeBinary(SECRET_PATH, ab);
const ab = fresh.buffer.slice(fresh.byteOffset, fresh.byteOffset + fresh.byteLength);
await adapter.writeBinary(this.secretPath, ab);
this.key = fresh;
}

View file

@ -21,7 +21,7 @@ export interface DryRunReport {
/** Compute the sync plan without executing anything. Useful for diagnostics. */
export async function dryRun(
app: App,
_app: App,
settings: SftpSyncSettings,
deviceStore: DeviceStore,
index: IndexStore,

View file

@ -1,4 +1,5 @@
import { STATE_DIR_PREFIX } from "../state/paths";
import type { App } from "obsidian";
import { pluginPaths } from "../state/paths";
import type { SftpSyncSettings } from "../settings";
/**
@ -35,8 +36,18 @@ export class ExcludeMatcher {
private patterns: RegExp[];
private excludeObsidianFolder: boolean;
private excludeWorkspaceJson: boolean;
private configDir: string;
private stateDirPrefix: string;
private workspacePaths: string[];
constructor(settings: SftpSyncSettings) {
constructor(app: App, settings: SftpSyncSettings) {
const paths = pluginPaths(app.vault.configDir);
this.configDir = paths.configDir;
this.stateDirPrefix = paths.stateDirPrefix;
this.workspacePaths = [
`${this.configDir}/workspace.json`,
`${this.configDir}/workspace-mobile.json`,
];
this.patterns = settings.excludePatterns.map(globToRegex);
this.excludeObsidianFolder = !settings.syncEverything;
this.excludeWorkspaceJson = !settings.syncWorkspaceJson;
@ -45,18 +56,16 @@ export class ExcludeMatcher {
/** Returns true if the given vault-relative path should NOT be synced. */
isExcluded(path: string): boolean {
// 1. Hard-coded: our own state directory.
if (path === STATE_DIR_PREFIX.slice(0, -1) || path.startsWith(STATE_DIR_PREFIX)) {
if (path === this.stateDirPrefix.slice(0, -1) || path.startsWith(this.stateDirPrefix)) {
return true;
}
// 2. .obsidian folder excluded entirely if user opted out.
if (this.excludeObsidianFolder && (path === ".obsidian" || path.startsWith(".obsidian/"))) {
// 2. Obsidian config folder excluded entirely if user opted out.
if (this.excludeObsidianFolder && (path === this.configDir || path.startsWith(this.configDir + "/"))) {
return true;
}
// 3. workspace.json — separate opt-in because user-asked-for full sync still excludes these.
if (this.excludeWorkspaceJson) {
if (path === ".obsidian/workspace.json" || path === ".obsidian/workspace-mobile.json") {
return true;
}
if (this.workspacePaths.includes(path)) return true;
}
// 4. User-defined soft excludes.
for (const re of this.patterns) {

View file

@ -1,5 +1,5 @@
import type { App, DataAdapter } from "obsidian";
import { STATE_PATHS } from "../state/paths";
import { pluginPaths, PluginPaths } from "../state/paths";
export interface IndexEntry {
path: string; // vault-relative, forward slashes
@ -19,11 +19,13 @@ const EMPTY_INDEX: Index = { entries: {}, lastScannedAt: 0, schemaVersion: 1 };
export class IndexStore {
private data: Index = structuredClone(EMPTY_INDEX);
private dirty = false;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private flushTimer: number | null = null;
private adapter: DataAdapter;
private paths: PluginPaths;
constructor(app: App) {
this.adapter = app.vault.adapter;
this.paths = pluginPaths(app.vault.configDir);
}
get all(): IndexEntry[] {
@ -79,11 +81,11 @@ export class IndexStore {
async load(): Promise<void> {
try {
if (!(await this.adapter.exists(STATE_PATHS.index))) {
if (!(await this.adapter.exists(this.paths.index))) {
this.data = structuredClone(EMPTY_INDEX);
return;
}
const raw = await this.adapter.read(STATE_PATHS.index);
const raw = await this.adapter.read(this.paths.index);
const parsed = JSON.parse(raw) as Index;
if (parsed.schemaVersion !== 1) throw new Error("unsupported schema");
this.data = parsed;
@ -96,29 +98,29 @@ export class IndexStore {
/** Force-write index to disk, regardless of dirty flag. Cancels any pending debounced flush. */
async flush(): Promise<void> {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
if (this.flushTimer !== null) {
activeWindow.clearTimeout(this.flushTimer);
this.flushTimer = null;
}
await this.ensureStateDir();
await this.adapter.write(STATE_PATHS.index, JSON.stringify(this.data));
await this.adapter.write(this.paths.index, JSON.stringify(this.data));
this.dirty = false;
}
private markDirty(): void {
this.dirty = true;
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
if (this.flushTimer !== null) return;
this.flushTimer = activeWindow.setTimeout(() => {
this.flushTimer = null;
if (this.dirty) {
void this.flush().catch((err) => console.error("Vault Bridge: index flush failed", err));
this.flush().catch((err: unknown) => console.error("Vault Bridge: index flush failed", err));
}
}, 5000);
}
private async ensureStateDir(): Promise<void> {
if (!(await this.adapter.exists(STATE_PATHS.dir))) {
await this.adapter.mkdir(STATE_PATHS.dir);
if (!(await this.adapter.exists(this.paths.stateDir))) {
await this.adapter.mkdir(this.paths.stateDir);
}
}
}

View file

@ -1,5 +1,5 @@
import type { App, DataAdapter } from "obsidian";
import { STATE_PATHS } from "../state/paths";
import { pluginPaths, PluginPaths } from "../state/paths";
import type { ManifestEntry } from "../sftp/remote-state";
/** Snapshot S — what the world looked like at the end of the last successful sync. */
@ -19,10 +19,12 @@ const EMPTY: LastSyncedSnapshot = {
export class LastSyncedStore {
private adapter: DataAdapter;
private paths: PluginPaths;
private data: LastSyncedSnapshot = structuredClone(EMPTY);
constructor(app: App) {
this.adapter = app.vault.adapter;
this.paths = pluginPaths(app.vault.configDir);
}
get snapshot(): LastSyncedSnapshot {
@ -31,11 +33,11 @@ export class LastSyncedStore {
async load(): Promise<void> {
try {
if (!(await this.adapter.exists(STATE_PATHS.lastSynced))) {
if (!(await this.adapter.exists(this.paths.lastSynced))) {
this.data = structuredClone(EMPTY);
return;
}
const raw = await this.adapter.read(STATE_PATHS.lastSynced);
const raw = await this.adapter.read(this.paths.lastSynced);
const parsed = JSON.parse(raw) as LastSyncedSnapshot;
if (parsed.schemaVersion !== 1) throw new Error("unsupported schema");
this.data = parsed;
@ -47,9 +49,9 @@ export class LastSyncedStore {
async save(snapshot: LastSyncedSnapshot): Promise<void> {
this.data = snapshot;
if (!(await this.adapter.exists(STATE_PATHS.dir))) {
await this.adapter.mkdir(STATE_PATHS.dir);
if (!(await this.adapter.exists(this.paths.stateDir))) {
await this.adapter.mkdir(this.paths.stateDir);
}
await this.adapter.write(STATE_PATHS.lastSynced, JSON.stringify(this.data));
await this.adapter.write(this.paths.lastSynced, JSON.stringify(this.data));
}
}

View file

@ -10,7 +10,7 @@ import type { ExcludeMatcher } from "./exclude";
import type { DeviceStore } from "../state/device-store";
import type { KnownHostsStore } from "../state/known-hosts-store";
import type { SftpSyncSettings } from "../settings";
import { STATE_PATHS } from "../state/paths";
import { pluginPaths, PluginPaths } from "../state/paths";
export interface PullProgress {
processed: number; // files considered so far (downloaded + skipped)
@ -43,6 +43,8 @@ export interface PullResult {
* Wholesale reconciliation including deletes is the job of Phase 6 bidirectional sync.
*/
export class PullEngine {
private paths: PluginPaths;
constructor(
private app: App,
private settings: SftpSyncSettings,
@ -52,7 +54,9 @@ export class PullEngine {
private exclude: ExcludeMatcher,
private lastSynced: LastSyncedStore,
private knownHosts: KnownHostsStore,
) {}
) {
this.paths = pluginPaths(app.vault.configDir);
}
async pullAll(opts: PullOptions = {}): Promise<PullResult> {
const t0 = Date.now();
@ -110,7 +114,7 @@ export class PullEngine {
let downloaded = 0;
let totalBytes = 0;
await this.ensureLocalDir(adapter, STATE_PATHS.tmp);
await this.ensureLocalDir(adapter, this.paths.tmp);
// Parallel pool of downloads on the same SFTP session.
// ssh2 multiplexes RPC requests over the channel; this hides RTT
@ -184,7 +188,7 @@ export class PullEngine {
// Download to a tmp staging file in our state/tmp/, then rename in place.
// This avoids leaving a corrupted file at the target if writeBinary is interrupted.
const tmp = `${STATE_PATHS.tmp}/dl.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
const tmp = `${this.paths.tmp}/dl.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
// adapter.writeBinary expects ArrayBuffer; Buffer.buffer + slice gives a fresh ArrayBuffer copy.
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
await adapter.writeBinary(tmp, ab);
@ -195,8 +199,10 @@ export class PullEngine {
}
await adapter.rename(tmp, vaultPath);
} catch (err) {
// Best-effort cleanup of the staging file if the rename fails.
try { await adapter.remove(tmp); } catch {}
// Best-effort cleanup of the staging file; never let cleanup errors mask the rename failure.
try {
await adapter.remove(tmp);
} catch (_cleanupErr) { /* ignore */ }
throw err;
}
}

View file

@ -18,7 +18,7 @@ import type { Scanner } from "./scanner";
import type { ExcludeMatcher } from "./exclude";
import type { DeviceStore } from "../state/device-store";
import type { KnownHostsStore } from "../state/known-hosts-store";
import { STATE_PATHS } from "../state/paths";
import { pluginPaths, PluginPaths } from "../state/paths";
import type { SftpSyncSettings } from "../settings";
export interface SyncProgress {
@ -76,6 +76,8 @@ export interface SyncOutcome {
* Holds the remote lock for the duration. Updates remote manifest + local snapshot atomically at the end.
*/
export class SyncEngine {
private paths: PluginPaths;
constructor(
private app: App,
private settings: SftpSyncSettings,
@ -85,7 +87,9 @@ export class SyncEngine {
private exclude: ExcludeMatcher,
private lastSynced: LastSyncedStore,
private knownHosts: KnownHostsStore,
) {}
) {
this.paths = pluginPaths(app.vault.configDir);
}
async syncBoth(opts: SyncOptions = {}): Promise<SyncOutcome> {
const t0 = Date.now();
@ -146,7 +150,7 @@ export class SyncEngine {
);
// Diagnostics: show what we decided to do.
console.log("Vault Bridge plan:", {
console.debug("Vault Bridge plan:", {
counts: plan.counts,
localCount: localMap.size,
remoteCount: Object.keys(manifest.entries).length,
@ -177,7 +181,7 @@ export class SyncEngine {
if ((trippedAbs || trippedRel) && opts.confirmBulkDelete) {
const outgoing = plan.ops.filter((o) => o.action === "delete-remote");
const incoming = plan.ops.filter((o) => o.action === "delete-local");
console.log(
console.debug(
"Vault Bridge: bulk-delete threshold tripped — asking user",
{ outgoing: outgoing.length, incoming: incoming.length, totalFiles },
);
@ -230,8 +234,8 @@ export class SyncEngine {
// Pre-create the local download staging dir once. writeBufferToVault otherwise
// races on `mkdir(state/tmp)` when several download workers run concurrently.
if ((await adapter.exists(STATE_PATHS.tmp)) === false) {
await adapter.mkdir(STATE_PATHS.tmp);
if ((await adapter.exists(this.paths.tmp)) === false) {
await adapter.mkdir(this.paths.tmp);
}
// Phase A: bookkeeping-only ops (no I/O) — execute synchronously. Order doesn't matter.
@ -357,7 +361,7 @@ export class SyncEngine {
case "restore-keep-remote": {
// Remote has the truth; download to local.
const buf = await downloadToBuffer(client, this.settings.remoteRoot, op.path);
await writeBufferToVault(adapter, op.path, buf);
await writeBufferToVault(adapter, op.path, buf, this.paths.tmp);
await this.scanner.refreshOne(op.path);
// Use the freshly-rescanned local entry — sha1 must match remote, but mtime is now-local.
const fresh = this.index.get(op.path);
@ -412,7 +416,7 @@ export class SyncEngine {
// Local wins → keep local on `path`. Loser content (remote) becomes the conflict-copy.
const remoteBuf = await downloadToBuffer(client, this.settings.remoteRoot, op.path);
// Save loser locally as the conflict-copy.
await writeBufferToVault(adapter, copyPath, remoteBuf);
await writeBufferToVault(adapter, copyPath, remoteBuf, this.paths.tmp);
await this.scanner.refreshOne(copyPath);
// Push conflict-copy to remote so other devices see it.
await uploadBuffer(client, this.settings.remoteRoot, copyPath, remoteBuf, dirs);
@ -427,13 +431,13 @@ export class SyncEngine {
// Remote wins → keep remote on `path`. Loser content (local) becomes the conflict-copy.
const localBuf = await readLocalAsBuffer(adapter, op.path);
// Save loser locally as the conflict-copy.
await writeBufferToVault(adapter, copyPath, localBuf);
await writeBufferToVault(adapter, copyPath, localBuf, this.paths.tmp);
await this.scanner.refreshOne(copyPath);
// Push conflict-copy to remote so other devices see it.
await uploadBuffer(client, this.settings.remoteRoot, copyPath, localBuf, dirs);
// Pull winner (remote) into local (overwrite).
const remoteBuf = await downloadToBuffer(client, this.settings.remoteRoot, op.path);
await writeBufferToVault(adapter, op.path, remoteBuf);
await writeBufferToVault(adapter, op.path, remoteBuf, this.paths.tmp);
await this.scanner.refreshOne(op.path);
const winnerEntry = this.index.get(op.path);
@ -454,7 +458,7 @@ function isRemoteFileMissing(err: unknown): boolean {
const e = err as { code?: unknown; message?: unknown };
if (typeof e.code === "number" && e.code === 2 /* SFTP_STATUS_NO_SUCH_FILE */) return true;
if (typeof e.code === "string" && (e.code === "ENOENT" || e.code === "ERR_NO_SUCH_FILE")) return true;
const msg = String(e.message ?? "").toLowerCase();
const msg = typeof e.message === "string" ? e.message.toLowerCase() : "";
return msg.includes("no such file") || msg.includes("not found");
}

View file

@ -1,3 +1,4 @@
{
"0.9.6": "1.5.0"
"0.9.6": "1.5.0",
"0.9.7": "1.5.0"
}