diff --git a/server/plugins/headless-sync/plugin/src/core-sync-guard.js b/server/plugins/headless-sync/plugin/src/core-sync-guard.js index adbf0e8..1abf964 100644 --- a/server/plugins/headless-sync/plugin/src/core-sync-guard.js +++ b/server/plugins/headless-sync/plugin/src/core-sync-guard.js @@ -1,9 +1,14 @@ const { Notice } = require("obsidian"); -const fs = require("fs"); // Using fs shim +const fs = require("fs"); +const CORE_PLUGINS_PATH = ".obsidian/core-plugins.json"; + +// Reads core-plugins.json via the fs shim. When headless sync is active, +// the shim patches sync: false, so this returns false. When the flag is +// cleared (user action), this returns the real value. function isCoreSyncEnabled() { try { - const data = fs.readFileSync(".obsidian/core-plugins.json", "utf-8"); + const data = fs.readFileSync(CORE_PLUGINS_PATH, "utf-8"); const config = JSON.parse(data); return config.sync === true; } catch { @@ -27,11 +32,31 @@ function showConflictWarning(title, message) { }); } -function startCoreSyncWatcher(plugin, api, wsListener) { +function startCoreSyncGuard(plugin, api, wsListener) { + const app = plugin.app; + const vaultId = app.vault.getName(); + + // Monkey-patch syncPlugin.enable() to clear the shim flag before + // Obsidian writes core-plugins.json. This ensures the read transform + // doesn't block a user-initiated core sync enable. + const syncPlugin = app.internalPlugins.getPluginById("sync"); + let origEnable = null; + + if (syncPlugin) { + origEnable = syncPlugin.enable.bind(syncPlugin); + + syncPlugin.enable = function (...args) { + window.__ignisHeadlessSyncActive = false; + api.stopSync(vaultId).catch(() => {}); + return origEnable(...args); + }; + } + + // Watch for core-plugins.json changes via WebSocket. let wasEnabled = isCoreSyncEnabled(); const rawHandler = (msg) => { - if (msg.type === "modified" && msg.path === ".obsidian/core-plugins.json") { + if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) { handleCoreSyncChange(); } }; @@ -42,9 +67,6 @@ function startCoreSyncWatcher(plugin, api, wsListener) { const enabled = isCoreSyncEnabled(); if (enabled && !wasEnabled) { - const vaultId = plugin.app.vault.getName(); - - api.stopSync(vaultId).catch(() => {}); showConflictWarning( "Headless Sync Stopped", "Obsidian Sync has been enabled. Headless Sync has been automatically " + @@ -59,11 +81,15 @@ function startCoreSyncWatcher(plugin, api, wsListener) { return { cleanup() { wsListener.offRaw(); + + if (syncPlugin && origEnable) { + syncPlugin.enable = origEnable; + } }, }; } module.exports = { isCoreSyncEnabled, - startCoreSyncWatcher, + startCoreSyncGuard, }; diff --git a/server/plugins/headless-sync/plugin/src/main.js b/server/plugins/headless-sync/plugin/src/main.js index 6d6957e..04adce0 100644 --- a/server/plugins/headless-sync/plugin/src/main.js +++ b/server/plugins/headless-sync/plugin/src/main.js @@ -2,7 +2,7 @@ const { Plugin } = require("obsidian"); const { HeadlessSyncSettingTab } = require("./settings-tab"); const { WsListener } = require("./ws-listener"); const { initSyncStatusBar } = require("./sync-status-bar"); -const { startCoreSyncWatcher } = require("./core-sync-guard"); +const { startCoreSyncGuard } = require("./core-sync-guard"); const api = require("./api"); class IgnisHeadlessSyncPlugin extends Plugin { @@ -14,7 +14,7 @@ class IgnisHeadlessSyncPlugin extends Plugin { this.addSettingTab(new HeadlessSyncSettingTab(this.app, this)); - this._coreSyncWatcher = startCoreSyncWatcher(this, api, this.wsListener); + this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener); this.addCommand({ id: "start-sync", @@ -53,9 +53,11 @@ class IgnisHeadlessSyncPlugin extends Plugin { } onunload() { - if (this._coreSyncWatcher) { - this._coreSyncWatcher.cleanup(); - this._coreSyncWatcher = null; + window.__ignisHeadlessSyncActive = false; + + if (this._coreSyncGuard) { + this._coreSyncGuard.cleanup(); + this._coreSyncGuard = null; } if (this._syncStatusBarCleanup) { diff --git a/server/plugins/headless-sync/sync-manager.js b/server/plugins/headless-sync/sync-manager.js index 8182283..a89adeb 100644 --- a/server/plugins/headless-sync/sync-manager.js +++ b/server/plugins/headless-sync/sync-manager.js @@ -124,12 +124,6 @@ class SyncManager { throw new Error(`No sync configuration for vault: ${vaultId}`); } - if (this.isCoreSyncEnabled(state.vaultPath)) { - const msg = `Cannot start sync for ${vaultId}: Obsidian Sync core plugin is enabled`; - this.ctx.log(msg); - throw new Error(msg); - } - if (state.status === "running") { this.ctx.log(`Sync already running for ${vaultId}`); return this.getState(vaultId); @@ -316,31 +310,11 @@ class SyncManager { } } - isCoreSyncEnabled(vaultPath) { - try { - const configPath = path.join(vaultPath, ".obsidian", "core-plugins.json"); - const data = fs.readFileSync(configPath, "utf-8"); - const config = JSON.parse(data); - return config.sync === true; - } catch { - return false; - } - } - autoStartAll() { let started = 0; - let skipped = 0; for (const [vaultId, state] of this.states) { if (state.autoStart && state.status === "stopped") { - if (this.isCoreSyncEnabled(state.vaultPath)) { - this.ctx.log( - `Skipping auto-start for ${vaultId}: Obsidian Sync core plugin is enabled`, - ); - skipped++; - continue; - } - try { this.startSync(vaultId); started++; @@ -353,12 +327,6 @@ class SyncManager { if (started > 0) { this.ctx.log(`Auto-started sync for ${started} vault(s)`); } - - if (skipped > 0) { - this.ctx.log( - `Skipped ${skipped} vault(s) due to Obsidian Sync being enabled`, - ); - } } async shutdown() { diff --git a/src/shims/fs/index.js b/src/shims/fs/index.js index 966f411..1e70c89 100644 --- a/src/shims/fs/index.js +++ b/src/shims/fs/index.js @@ -7,6 +7,7 @@ import { createFsWatch } from "./watch.js"; import { createWatcherClient } from "./watcher-client.js"; import { createFdOps } from "./fd.js"; import { constants } from "./constants.js"; +import { registerReadTransform, removeReadTransform } from "./read-transforms.js"; const metadataCache = new MetadataCache(); const contentCache = new ContentCache(); @@ -43,6 +44,8 @@ export const fsShim = { _metadataCache: metadataCache, _contentCache: contentCache, _watcherClient: watcherClient, + _registerReadTransform: registerReadTransform, + _removeReadTransform: removeReadTransform, async _init(basePath) { const tree = await transport.fetchTree(basePath); diff --git a/src/shims/fs/promises.js b/src/shims/fs/promises.js index 8155fb7..3f2dbb3 100644 --- a/src/shims/fs/promises.js +++ b/src/shims/fs/promises.js @@ -1,5 +1,6 @@ import { markLocalOp } from "./echo-guard.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js"; +import { applyReadTransform } from "./read-transforms.js"; export function createFsPromises(metadataCache, contentCache, transport) { return { @@ -46,60 +47,52 @@ export function createFsPromises(metadataCache, contentCache, transport) { const wantText = encoding === "utf8" || encoding === "utf-8"; + let result = null; + // Check input cache for files picked via browser file dialogs. if (isInputCachePath(path)) { - const inputData = inputCacheGet(path); - - if (inputData !== null) { - if (wantText) { - return typeof inputData === "string" - ? inputData - : new TextDecoder().decode(inputData); - } - - if (typeof inputData === "string") { - return new TextEncoder().encode(inputData); - } - - return inputData; - } + result = inputCacheGet(path); } - const meta = metadataCache.get(path); - if (meta && meta.type === "directory") { - const e = new Error("EISDIR: illegal operation on a directory, read"); - e.code = "EISDIR"; - throw e; - } + if (result === null) { + const meta = metadataCache.get(path); - if (!meta && path) { - const e = new Error( - `ENOENT: no such file or directory, open '${path}'`, - ); - e.code = "ENOENT"; - throw e; - } - - const cached = contentCache.get(path); - - if (cached !== null) { - if (wantText) { - return typeof cached === "string" - ? cached - : new TextDecoder().decode(cached); + if (meta && meta.type === "directory") { + const e = new Error("EISDIR: illegal operation on a directory, read"); + e.code = "EISDIR"; + throw e; } - // binary. ensure we return a proper Uint8Array with .buffer - if (typeof cached === "string") { - return new TextEncoder().encode(cached); + if (!meta && path) { + const e = new Error( + `ENOENT: no such file or directory, open '${path}'`, + ); + e.code = "ENOENT"; + throw e; } - return cached; + result = contentCache.get(path); } - const data = await transport.readFile(path, encoding); - contentCache.set(path, data); - return data; + if (result === null) { + result = await transport.readFile(path, encoding); + contentCache.set(path, result); + } + + // Apply registered read transforms (e.g., patching synced config files). + result = applyReadTransform(path, result); + + if (wantText) { + return typeof result === "string" + ? result + : new TextDecoder().decode(result); + } + + if (typeof result === "string") { + return new TextEncoder().encode(result); + } + + return result; }, async writeFile(path, data, encoding) { diff --git a/src/shims/fs/read-transforms.js b/src/shims/fs/read-transforms.js new file mode 100644 index 0000000..b462199 --- /dev/null +++ b/src/shims/fs/read-transforms.js @@ -0,0 +1,49 @@ +// Post-read transforms for specific file paths. +// Allows patching file content after reading but before returning to the caller. +// Used to prevent synced config files from activating conflicting features. + +const transforms = new Map(); + +function normalize(p) { + return (p || "") + .replace(/\\/g, "/") + .replace(/^\/+/, "") + .replace(/\/+$/, ""); +} + +export function registerReadTransform(path, fn) { + transforms.set(normalize(path), fn); +} + +export function removeReadTransform(path) { + transforms.delete(normalize(path)); +} + +export function applyReadTransform(path, data) { + const norm = normalize(path); + const fn = transforms.get(norm); + + if (!fn) { + return data; + } + + try { + const result = fn(data); + + if (result !== data) { + const before = typeof data === "string" ? data : new TextDecoder().decode(data); + const after = typeof result === "string" ? result : new TextDecoder().decode(result); + console.log(`[shim:fs] Read transform applied: ${norm}`); + console.log(`[shim:fs] before:`, before); + console.log(`[shim:fs] after:`, after); + } + + return result; + } catch { + return data; + } +} + +export function hasReadTransform(path) { + return transforms.has(normalize(path)); +} diff --git a/src/shims/fs/sync.js b/src/shims/fs/sync.js index e102d3c..59c3f2a 100644 --- a/src/shims/fs/sync.js +++ b/src/shims/fs/sync.js @@ -1,5 +1,6 @@ import { markLocalOp } from "./echo-guard.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js"; +import { applyReadTransform } from "./read-transforms.js"; export function createFsSync(metadataCache, contentCache, transport) { return { @@ -58,6 +59,8 @@ export function createFsSync(metadataCache, contentCache, transport) { encoding = encoding?.encoding; } + const wantText = encoding === "utf8" || encoding === "utf-8"; + const meta = metadataCache.get(path); if (meta && meta.type === "directory") { const e = new Error("EISDIR: illegal operation on a directory, read"); @@ -65,39 +68,37 @@ export function createFsSync(metadataCache, contentCache, transport) { throw e; } + let result = null; + // Check input cache for files picked via browser file dialogs. - // These never hit the server; they exist only in browser memory. if (isInputCachePath(path)) { const inputData = inputCacheGet(path); if (inputData !== null) { - if (encoding === "utf8" || encoding === "utf-8") { - return typeof inputData === "string" - ? inputData - : new TextDecoder().decode(inputData); - } - - return inputData; + result = inputData; } } - const cached = contentCache.get(path); - if (cached !== null) { - if (encoding === "utf8" || encoding === "utf-8") { - return typeof cached === "string" - ? cached - : new TextDecoder().decode(cached); - } - - return cached; + if (result === null) { + result = contentCache.get(path); } - console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path); + if (result === null) { + console.warn("[shim:fs] readFileSync cache miss, using sync XHR:", path); + result = transport.readFileSync(path, encoding); + contentCache.set(path, result); + } - const data = transport.readFileSync(path, encoding); - contentCache.set(path, data); + // Apply registered read transforms (e.g., patching synced config files). + result = applyReadTransform(path, result); - return data; + if (wantText) { + return typeof result === "string" + ? result + : new TextDecoder().decode(result); + } + + return result; }, writeFileSync(path, data, encoding) { diff --git a/src/shims/init.js b/src/shims/init.js index cca9422..8b5d980 100644 --- a/src/shims/init.js +++ b/src/shims/init.js @@ -2,6 +2,7 @@ import { fsShim } from "./fs/index.js"; import { installRequestUrlShim } from "./request-url.js"; import { vaultService } from "../services/vault-service.js"; import { showPluginInstallDialog } from "../ui/bootstrap.js"; +import { registerReadTransform } from "./fs/read-transforms.js"; function resolveVaultId() { const urlParams = new URLSearchParams(window.location.search); @@ -107,11 +108,71 @@ function initPluginPrompt() { }); } +// if headless sync is active, we transform reads of core-plugins.json to hide the sync setting from Obsidian. +// this prevents headless sync from being disabled as a result of a different device syncing "Active core plugins list". +// i.e ensure Ignis always has sync: false if headless sync is active. +// This may be somewhat overengineered. Could revisit later. +function initCoreSyncGuard() { + const vaultId = window.__currentVaultId; + + if (!vaultId) { + return; + } + + try { + const xhr = new XMLHttpRequest(); + + xhr.open("GET", "/api/plugins", false); + xhr.send(); + + if (xhr.status !== 200) { + return; + } + + const plugins = JSON.parse(xhr.responseText); + const headlessSync = plugins.find( + (p) => p.id === "headless-sync" && p.bundledPluginId, + ); + + if (!headlessSync || !headlessSync.enabledVaults.includes(vaultId)) { + return; + } + + console.log( + "[ignis] Headless sync active for this vault, patching core-plugins.json reads", + ); + window.__ignisHeadlessSyncActive = true; + + registerReadTransform(".obsidian/core-plugins.json", (data) => { + if (!window.__ignisHeadlessSyncActive) { + return data; + } + + let text = + typeof data === "string" ? data : new TextDecoder().decode(data); + + try { + const config = JSON.parse(text); + + if (config.sync === true) { + config.sync = false; + return JSON.stringify(config); + } + } catch {} + + return data; + }); + } catch (e) { + console.warn("[ignis] Failed to init core sync guard:", e); + } +} + export function initialize() { resolveVaultId(); initVaultConfig(); initVaultList(); initMetadataCache(); + initCoreSyncGuard(); installRequestUrlShim(); initPluginPrompt(); }