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', () => {