From 76ad7047f1dd72de53b50ba42b1e23997fb364a0 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 8 Sep 2026 09:11:40 +0800 Subject: [PATCH] chore(dev): make the plugin bundle debuggable in Obsidian Obsidian appends its own sourceURL after plugin code and strips sourceMappingURL directives, so breakpoints in an attached Chromium debugger never resolved to Qoderian sources. The SDK timer patches also ran after esbuild emitted its map, shifting every position that followed. Wrap the dev bundle so both directives land in the order Chromium expects, bypass the directive stripping during reload, and keep patched regions line- and column-neutral. --- .gitignore | 3 +- .vscode/launch.json | 19 +++++++++ esbuild.config.mjs | 42 +++++++++++++++++++ scripts/dev-reloader/main.js | 41 +++++++++++++++++- scripts/renderer-safe-unref.js | 24 ++++++++++- .../unit/scripts/renderer-safe-unref.test.ts | 3 ++ 6 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.gitignore b/.gitignore index e2fafda..e4c516f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,8 @@ node_modules/ # Editors .idea/ -.vscode/ +.vscode/* +!.vscode/launch.json *.swp # OS diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..067e855 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-chrome", + "request": "attach", + "name": "Attach to Obsidian", + "address": "127.0.0.1", + "port": 9222, + "webRoot": "${workspaceFolder}", + "urlFilter": "app://obsidian.md/*", + "sourceMaps": true, + "sourceMapPathOverrides": { + "src/*": "${workspaceFolder}/src/*" + }, + "timeout": 30000 + } + ] +} diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 6ea22e9..455d3d4 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -145,6 +145,47 @@ const patchRendererUnsafeUnref = { }, }; +// Obsidian evaluates community plugins and appends its own `sourceURL` comment +// after the file contents. Chromium only associates a source map when the +// `sourceMappingURL` directive comes after `sourceURL`, so esbuild's normal +// inline map becomes invisible to attached debuggers. Keep production output +// unchanged, but evaluate the development bundle once more with the directives +// in the order Chromium expects. +const exposeDevSourceMapToDebugger = { + name: 'expose-dev-source-map-to-debugger', + setup(build) { + build.onEnd(async (result) => { + if (result.errors.length > 0 || !existsSync('main.js')) return; + + const bundlePath = path.join(process.cwd(), 'main.js'); + const contents = await fsPromises.readFile(bundlePath, 'utf8'); + const sourceMapPattern = /\n\/\/# sourceMappingURL=data:application\/json;base64,[A-Za-z0-9+/=]+\s*$/; + const match = sourceMapPattern.exec(contents); + + if (!match) { + throw new Error('Development bundle is missing its inline source map.'); + } + + const sourceMapDirective = match[0].trim(); + const sourceMapUrl = sourceMapDirective.slice('//# sourceMappingURL='.length); + const bundleWithoutMap = contents.slice(0, match.index); + const wrapper = [ + '// Development-only wrapper: exposes the inline source map to Chromium.', + // Obsidian strips source-map directives before evaluating community + // plugins. Assemble both directives at runtime so its source scanner + // cannot remove the map while reading this outer wrapper. + `const __qoderianDebugBundle = ${JSON.stringify(bundleWithoutMap)}`, + ` + '\\n//# source' + 'URL=plugin:qoderian-debug'`, + ` + '\\n//# sourceMapping' + 'URL=' + ${JSON.stringify(sourceMapUrl)} + '\\n';`, + 'eval(__qoderianDebugBundle);', + '', + ].join('\n'); + + await fsPromises.writeFile(bundlePath, wrapper, 'utf8'); + }); + }, +}; + // Obsidian plugin folder path (set via OBSIDIAN_VAULT env var or .env.local) const OBSIDIAN_VAULT = process.env.OBSIDIAN_VAULT; const OBSIDIAN_CONFIG_PATH = OBSIDIAN_VAULT && existsSync(OBSIDIAN_VAULT) @@ -288,6 +329,7 @@ const context = await esbuild.context({ plugins: [ patchSdkImportMeta, patchRendererUnsafeUnref, + ...(prod ? [] : [exposeDevSourceMapToDebugger]), ...(prod ? [] : [copyToObsidian]), ], external: [ diff --git a/scripts/dev-reloader/main.js b/scripts/dev-reloader/main.js index 4b85c6e..a013b5a 100644 --- a/scripts/dev-reloader/main.js +++ b/scripts/dev-reloader/main.js @@ -19,6 +19,7 @@ const WATCHED_ARTIFACTS = ['main.js', 'manifest.json', 'styles.css']; // Long enough for esbuild to finish copying all three artifacts, short enough to // still feel immediate. const RELOAD_DEBOUNCE_MS = 400; +const DEBUG_PLUGIN_STORAGE_KEY = 'debug-plugin'; module.exports = class QoderianDevReloader extends Plugin { onload() { @@ -56,12 +57,50 @@ module.exports = class QoderianDevReloader extends Plugin { // Respect a manually disabled target instead of force-enabling it. if (!plugins.enabledPlugins.has(TARGET_PLUGIN_ID)) return; + const previousDebugPlugin = window.localStorage.getItem(DEBUG_PLUGIN_STORAGE_KEY); + const restoreAdapterRead = this.preserveSourceMapDuringPluginRead(); + try { await plugins.disablePlugin(TARGET_PLUGIN_ID); - await plugins.enablePlugin(TARGET_PLUGIN_ID); + window.localStorage.setItem(DEBUG_PLUGIN_STORAGE_KEY, '1'); + + try { + await plugins.unloadPlugin(TARGET_PLUGIN_ID); + await plugins.loadPlugin(TARGET_PLUGIN_ID); + await plugins.enablePlugin(TARGET_PLUGIN_ID); + } finally { + if (previousDebugPlugin === null) { + window.localStorage.removeItem(DEBUG_PLUGIN_STORAGE_KEY); + } else { + window.localStorage.setItem(DEBUG_PLUGIN_STORAGE_KEY, previousDebugPlugin); + } + restoreAdapterRead(); + } + new Notice('Qoderian reloaded'); } catch (error) { + restoreAdapterRead(); new Notice(`Qoderian reload failed: ${error?.message ?? error}`); } } + + // Obsidian strips source map directives while loading community plugins. + // A trailing marker bypasses that rewrite for the development bundle, so + // Chromium receives the inline map that esbuild emitted. + preserveSourceMapDuringPluginRead() { + const adapter = this.app.vault.adapter; + const originalRead = adapter.read; + const targetSuffix = `/plugins/${TARGET_PLUGIN_ID}/main.js`; + + const guardedRead = function (filePath, ...args) { + const result = originalRead.call(this, filePath, ...args); + if (!filePath.endsWith(targetSuffix)) return result; + return Promise.resolve(result).then(contents => `${contents}\n/* nosourcemap */`); + }; + + adapter.read = guardedRead; + return () => { + if (adapter.read === guardedRead) adapter.read = originalRead; + }; + } }; diff --git a/scripts/renderer-safe-unref.js b/scripts/renderer-safe-unref.js index 3e093ea..77c5588 100644 --- a/scripts/renderer-safe-unref.js +++ b/scripts/renderer-safe-unref.js @@ -95,7 +95,15 @@ function patchRendererUnsafeUnrefSites(contents) { if (matchCount === 0) { continue; } - nextContents = nextContents.replace(patch.pattern, patch.replacement); + nextContents = nextContents.replace(patch.pattern, (matched, ...args) => { + const captures = args.slice(0, -2); + const expandedReplacement = patch.replacement.replace( + /\$(\d+)/g, + (_placeholder, index) => captures[Number(index) - 1] ?? '', + ); + + return preserveFollowingGeneratedPositions(matched, expandedReplacement); + }); appliedPatches.push({ name: patch.name, count: matchCount }); } @@ -105,6 +113,20 @@ function patchRendererUnsafeUnrefSites(contents) { }; } +// These rewrites run after esbuild has generated its source map. Preserve the +// matched region's newline count and ending column so mappings for all code +// after an SDK patch (including Qoderian's own sources) remain accurate. +function preserveFollowingGeneratedPositions(original, replacement) { + const newlineCount = (original.match(/\n/g) ?? []).length; + if (newlineCount === 0) return replacement.replace(/\s*\n\s*/g, ' '); + + const originalLastLineLength = original.length - original.lastIndexOf('\n') - 1; + const singleLineReplacement = replacement.replace(/\s*\n\s*/g, ' '); + return singleLineReplacement + + '\n'.repeat(newlineCount) + + ' '.repeat(originalLastLineLength); +} + function findUnsafeTimerUnrefSites(contents) { const matches = []; diff --git a/tests/unit/scripts/renderer-safe-unref.test.ts b/tests/unit/scripts/renderer-safe-unref.test.ts index 8bf0d32..f3453ed 100644 --- a/tests/unit/scripts/renderer-safe-unref.test.ts +++ b/tests/unit/scripts/renderer-safe-unref.test.ts @@ -27,6 +27,7 @@ describe('rendererSafeUnref helpers', () => { expect(result.contents).toContain('forceKillTimer.unref?.();'); expect(result.contents).toContain('closeTimeout.unref?.();'); expect(findUnsafeTimerUnrefSites(result.contents)).toEqual([]); + expect(result.contents.split('\n')).toHaveLength(input.split('\n').length); }); it('patches the current qoder-sdk shape with a block-bodied exit handler', () => { @@ -51,6 +52,7 @@ describe('rendererSafeUnref helpers', () => { expect(result.contents).toContain('forceKillTimer.unref?.();'); expect(result.contents).toContain('this.processExitHandler'); expect(findUnsafeTimerUnrefSites(result.contents)).toEqual([]); + expect(result.contents.split('\n')).toHaveLength(input.split('\n').length); }); it('patches the latest qoder-sdk async close callback shape', () => { @@ -82,6 +84,7 @@ describe('rendererSafeUnref helpers', () => { expect(result.contents).toContain('windowsForceKillTimer.unref?.();'); expect(result.contents).toContain('forceKillTimer.unref?.();'); expect(findUnsafeTimerUnrefSites(result.contents)).toEqual([]); + expect(result.contents.split('\n')).toHaveLength(input.split('\n').length); }); it('reports remaining direct timer .unref() calls but ignores guarded usage', () => {