Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ node_modules/

# Editors
.idea/
.vscode/
.vscode/*
!.vscode/launch.json
*.swp

# OS
Expand Down
19 changes: 19 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
42 changes: 42 additions & 0 deletions esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -288,6 +329,7 @@ const context = await esbuild.context({
plugins: [
patchSdkImportMeta,
patchRendererUnsafeUnref,
...(prod ? [] : [exposeDevSourceMapToDebugger]),
...(prod ? [] : [copyToObsidian]),
],
external: [
Expand Down
41 changes: 40 additions & 1 deletion scripts/dev-reloader/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
};
}
};
24 changes: 23 additions & 1 deletion scripts/renderer-safe-unref.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand All @@ -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 = [];

Expand Down
3 changes: 3 additions & 0 deletions tests/unit/scripts/renderer-safe-unref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading