From b880141d8c323350464ed430b57364f9f5fee669 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:36:55 +0000 Subject: [PATCH 1/3] Resolve local image paths in markdown documents Markdown files commonly reference images with relative or absolute filesystem paths (e.g. the Kafka README's logo). Those never loaded: the document origin is the app bundle/dev server and the CSP forbids file: images. Add an om-asset: custom protocol that serves local image files, rewrite img/source src and srcset references against the document's directory after render, and allow the scheme through the CSP. https://claude.ai/code/session_01T2U6dtaZLWCuztQVRFTj7U --- index.html | 2 +- src/main/index.ts | 11 ++ src/main/services/AssetProtocolService.ts | 89 +++++++++++++++ src/preload/assetResolver.ts | 64 +++++++++++ src/preload/preload.ts | 7 ++ src/renderer/components/MarkdownViewer.ts | 20 ++++ src/renderer/utils/assetPaths.ts | 75 ++++++++++++ src/shared/constants/index.ts | 6 + src/shared/types/api.ts | 13 +++ .../services/AssetProtocolService.test.ts | 107 ++++++++++++++++++ tests/unit/preload/assetResolver.test.ts | 71 ++++++++++++ tests/unit/renderer/utils/assetPaths.test.ts | 97 ++++++++++++++++ 12 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 src/main/services/AssetProtocolService.ts create mode 100644 src/preload/assetResolver.ts create mode 100644 src/renderer/utils/assetPaths.ts create mode 100644 tests/unit/main/services/AssetProtocolService.test.ts create mode 100644 tests/unit/preload/assetResolver.test.ts create mode 100644 tests/unit/renderer/utils/assetPaths.test.ts diff --git a/index.html b/index.html index 1a78fbc..6110dcb 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + Open Markdown diff --git a/src/main/index.ts b/src/main/index.ts index e859db1..b012860 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,10 @@ import { app, BrowserWindow } from 'electron'; import path from 'node:path'; import { registerAllHandlers } from './ipc/handlers'; +import { + registerAssetProtocolScheme, + registerAssetProtocolHandler, +} from './services/AssetProtocolService'; import { setupApplicationMenu } from './menu/applicationMenu'; import { getPreferencesService } from './services/PreferencesService'; import { getRecentFilesService } from './services/RecentFilesService'; @@ -21,6 +25,10 @@ interface PendingWindow { const pendingWindows = new Map(); let preReadyFilePath: string | null = null; +// Custom protocol used to serve local image assets must be registered as a +// privileged scheme before the app `ready` event fires. +registerAssetProtocolScheme(); + /** * Check command-line arguments for markdown file */ @@ -108,6 +116,9 @@ async function initialize(): Promise { await getPreferencesService().initialize(); await getRecentFilesService().initialize(); + // Serve local image assets referenced by markdown documents + registerAssetProtocolHandler(); + // Register IPC handlers before creating windows registerAllHandlers(); diff --git a/src/main/services/AssetProtocolService.ts b/src/main/services/AssetProtocolService.ts new file mode 100644 index 0000000..7a21089 --- /dev/null +++ b/src/main/services/AssetProtocolService.ts @@ -0,0 +1,89 @@ +/** + * AssetProtocolService - Serves local image assets referenced by markdown + * documents through a dedicated custom protocol. + * + * Markdown files commonly reference images with relative or absolute + * filesystem paths (e.g. `docs/images/logo.svg`). Those cannot be loaded + * directly from the renderer: the document's origin is the app bundle (or the + * dev server), and the Content-Security-Policy forbids `file:` images. The + * renderer rewrites such references to `om-asset:` URLs which this handler + * resolves back to files on disk. + */ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { protocol } from 'electron'; + +import { ASSET_PROTOCOL_SCHEME } from '@shared/constants'; + +/** + * Image extensions the protocol is allowed to serve. Restricting to images + * keeps the handler from turning into an arbitrary local-file reader. + */ +const IMAGE_CONTENT_TYPES: Record = { + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.avif': 'image/avif', + '.apng': 'image/apng', +}; + +/** + * Privileged scheme registration. Must run before the app `ready` event. + */ +export function registerAssetProtocolScheme(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: ASSET_PROTOCOL_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + }, + }, + ]); +} + +/** + * Register the protocol handler. Must run after the app `ready` event. + */ +export function registerAssetProtocolHandler(): void { + protocol.handle(ASSET_PROTOCOL_SCHEME, async (request) => { + let filePath: string; + try { + // The renderer builds these URLs from `file:` URLs, so converting the + // scheme back yields a valid `file:` URL we can decode. + const fileUrl = request.url.replace( + new RegExp(`^${ASSET_PROTOCOL_SCHEME}:`), + 'file:' + ); + filePath = fileURLToPath(fileUrl); + } catch { + return new Response('Invalid asset URL', { status: 400 }); + } + + const contentType = IMAGE_CONTENT_TYPES[path.extname(filePath).toLowerCase()]; + if (!contentType) { + return new Response('Unsupported asset type', { status: 415 }); + } + + try { + const data = await readFile(filePath); + return new Response(data, { + headers: { + 'content-type': contentType, + 'cache-control': 'no-cache', + }, + }); + } catch { + return new Response('Asset not found', { status: 404 }); + } + }); +} diff --git a/src/preload/assetResolver.ts b/src/preload/assetResolver.ts new file mode 100644 index 0000000..99ad6fc --- /dev/null +++ b/src/preload/assetResolver.ts @@ -0,0 +1,64 @@ +/** + * Resolves image references found in markdown documents to `om-asset:` URLs + * that the main process can serve from disk. + * + * Runs in the preload context, where Node's `path` module is available to + * resolve relative references against the document's location. + */ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { ASSET_PROTOCOL_SCHEME } from '@shared/constants'; + +/** + * Resolve an image reference against the markdown file that contains it. + * + * @param baseFilePath - Absolute path to the markdown file being rendered. + * @param ref - The raw `src`/`srcset` reference from the document. + * @returns An `om-asset:` URL for local files, or `null` when the reference + * should be left untouched (already a URL, protocol-relative, or empty). + */ +export function resolveAssetUrl(baseFilePath: string, ref: string): string | null { + if (!baseFilePath || !ref) { + return null; + } + + const trimmed = ref.trim(); + if (!trimmed) { + return null; + } + + // Already an absolute URL (http:, https:, data:, blob:, file:, mailto:, ...). + // A single-character "scheme" is actually a Windows drive letter, so the + // scheme is required to be at least two characters long. + if (/^[a-z][a-z0-9+.-]+:/i.test(trimmed) || trimmed.startsWith('//')) { + return null; + } + + // Pure in-document anchors are not assets. + if (trimmed.startsWith('#')) { + return null; + } + + // Filesystem paths carry neither query strings nor fragments. + const cleaned = trimmed.replace(/[?#].*$/, ''); + if (!cleaned) { + return null; + } + + let decoded: string; + try { + decoded = decodeURIComponent(cleaned); + } catch { + decoded = cleaned; + } + + const resolved = path.isAbsolute(decoded) + ? decoded + : path.resolve(path.dirname(baseFilePath), decoded); + + return pathToFileURL(resolved).href.replace( + /^file:/, + `${ASSET_PROTOCOL_SCHEME}:` + ); +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index c9905bf..f4a575d 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -5,6 +5,7 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron'; import { IPC_CHANNELS } from '@shared/types/api'; +import { resolveAssetUrl } from './assetResolver'; import type { ElectronAPI, @@ -334,6 +335,12 @@ const electronAPI: ElectronAPI = { }, }, + assets: { + resolve: (baseFilePath: string, ref: string): string | null => { + return resolveAssetUrl(baseFilePath, ref); + }, + }, + }; // Expose the API to the renderer process diff --git a/src/renderer/components/MarkdownViewer.ts b/src/renderer/components/MarkdownViewer.ts index c4daf03..5ed32d6 100644 --- a/src/renderer/components/MarkdownViewer.ts +++ b/src/renderer/components/MarkdownViewer.ts @@ -15,6 +15,8 @@ import { Toast } from './Toast'; import { EditModeController, createEditModeController } from './EditModeController'; import type { EditModeCallbacks } from './EditModeController'; +import { rewriteAssetPaths } from '../utils/assetPaths'; + import type { MarkdownPlugin, ContextMenuData, @@ -108,6 +110,21 @@ export class MarkdownViewer { } } + /** + * Resolve relative and absolute local image paths in the rendered content + * to the app's asset protocol so they load correctly. No-op until a file + * path is known (e.g. unsaved content), since paths cannot be resolved + * without a document location. + */ + private rewriteAssetPaths(): void { + const filePath = this.state.filePath; + if (!filePath) return; + + rewriteAssetPaths(this.container, (ref) => + window.electronAPI.assets.resolve(filePath, ref) + ); + } + /** * Render markdown content */ @@ -127,6 +144,9 @@ export class MarkdownViewer { const html = this.pluginManager.render(markdown); this.container.innerHTML = html; + // Resolve relative/local image paths against the document's location + this.rewriteAssetPaths(); + // Run post-render hooks (for Mermaid diagrams, etc.) await this.pluginManager.postRender(this.container); } catch (error) { diff --git a/src/renderer/utils/assetPaths.ts b/src/renderer/utils/assetPaths.ts new file mode 100644 index 0000000..d047968 --- /dev/null +++ b/src/renderer/utils/assetPaths.ts @@ -0,0 +1,75 @@ +/** + * Rewrites relative/absolute image references in rendered markdown so they + * point at the app's local asset protocol instead of failing to load. + * + * markdown-it emits ``/`` elements with the `src`/`srcset` values + * exactly as written in the document. References like `docs/images/logo.svg` + * would otherwise resolve against the app origin (or dev server) and never + * reach the file the author intended. + */ + +/** + * Resolves a single document reference to a loadable URL, or returns `null` + * to leave the reference untouched (e.g. it is already an absolute URL). + */ +export type AssetResolver = (ref: string) => string | null; + +/** + * Rewrite a `srcset` attribute value, resolving each candidate's URL while + * preserving its descriptor (e.g. `2x`, `640w`). + */ +export function rewriteSrcset(srcset: string, resolve: AssetResolver): string { + return srcset + .split(',') + .map((candidate) => { + const trimmed = candidate.trim(); + if (!trimmed) { + return ''; + } + const parts = trimmed.split(/\s+/); + const url = parts[0]; + if (url) { + const resolved = resolve(url); + if (resolved) { + parts[0] = resolved; + } + } + return parts.join(' '); + }) + .filter((candidate) => candidate.length > 0) + .join(', '); +} + +/** + * Rewrite every image reference within a rendered markdown container. + * + * Handles markdown image syntax and raw HTML alike, including `` + * elements which use `` plus an `` fallback. + */ +export function rewriteAssetPaths( + container: HTMLElement, + resolve: AssetResolver +): void { + const srcElements = container.querySelectorAll( + 'img[src], source[src]' + ); + srcElements.forEach((element) => { + const src = element.getAttribute('src'); + if (src) { + const resolved = resolve(src); + if (resolved) { + element.setAttribute('src', resolved); + } + } + }); + + const srcsetElements = container.querySelectorAll( + 'img[srcset], source[srcset]' + ); + srcsetElements.forEach((element) => { + const srcset = element.getAttribute('srcset'); + if (srcset) { + element.setAttribute('srcset', rewriteSrcset(srcset, resolve)); + } + }); +} diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 92d4052..d3c7d7c 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -46,6 +46,12 @@ export const BUILTIN_PLUGINS = { GITHUB_FLAVORED: 'github-flavored', } as const; +/** + * Custom protocol scheme used to serve local image assets referenced by + * relative or absolute filesystem paths in markdown documents. + */ +export const ASSET_PROTOCOL_SCHEME = 'om-asset'; + /** * Theme IDs */ diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 1f8e777..a15dbe4 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -258,6 +258,18 @@ export interface ShellAPI { openExternal: (url: string) => Promise; } +/** + * Local asset resolution API exposed to renderer + */ +export interface AssetsAPI { + /** + * Resolve an image reference from a markdown document to an `om-asset:` URL + * the app can load. Returns `null` when the reference is already a URL or + * otherwise should be left untouched. + */ + resolve: (baseFilePath: string, ref: string) => string | null; +} + /** * Complete Electron API exposed via contextBridge */ @@ -273,6 +285,7 @@ export interface ElectronAPI { recentFiles: RecentFilesAPI; menu: MenuAPI; shell: ShellAPI; + assets: AssetsAPI; } /** diff --git a/tests/unit/main/services/AssetProtocolService.test.ts b/tests/unit/main/services/AssetProtocolService.test.ts new file mode 100644 index 0000000..2f7ffd0 --- /dev/null +++ b/tests/unit/main/services/AssetProtocolService.test.ts @@ -0,0 +1,107 @@ +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +import { protocol } from 'electron'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + registerAssetProtocolScheme, + registerAssetProtocolHandler, +} from '../../../../src/main/services/AssetProtocolService'; + +vi.mock('electron', () => ({ + protocol: { + registerSchemesAsPrivileged: vi.fn(), + handle: vi.fn(), + }, +})); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +type ProtocolHandler = (request: Request) => Promise; + +function getRegisteredHandler(): ProtocolHandler { + registerAssetProtocolHandler(); + const calls = vi.mocked(protocol.handle).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall?.[0]).toBe('om-asset'); + return lastCall?.[1] as ProtocolHandler; +} + +function assetUrl(absolutePath: string): string { + return pathToFileURL(absolutePath).href.replace(/^file:/, 'om-asset:'); +} + +describe('AssetProtocolService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('registerAssetProtocolScheme', () => { + it('registers the om-asset scheme as privileged', () => { + registerAssetProtocolScheme(); + expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([ + { + scheme: 'om-asset', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true, + }, + }, + ]); + }); + }); + + describe('protocol handler', () => { + it('serves an existing image with the correct content type', async () => { + vi.mocked(readFile).mockResolvedValue(Buffer.from('PNGDATA')); + const handler = getRegisteredHandler(); + + const response = await handler( + new Request(assetUrl('/assets/logo.png')) + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(await response.text()).toBe('PNGDATA'); + }); + + it('serves SVG files as image/svg+xml', async () => { + vi.mocked(readFile).mockResolvedValue(Buffer.from('')); + const handler = getRegisteredHandler(); + + const response = await handler( + new Request(assetUrl('/assets/logo.svg')) + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/svg+xml'); + }); + + it('rejects non-image extensions with 415', async () => { + const handler = getRegisteredHandler(); + + const response = await handler( + new Request(assetUrl('/assets/notes.txt')) + ); + + expect(response.status).toBe(415); + expect(readFile).not.toHaveBeenCalled(); + }); + + it('returns 404 when the file cannot be read', async () => { + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + const handler = getRegisteredHandler(); + + const response = await handler( + new Request(assetUrl('/assets/missing.png')) + ); + + expect(response.status).toBe(404); + }); + }); +}); diff --git a/tests/unit/preload/assetResolver.test.ts b/tests/unit/preload/assetResolver.test.ts new file mode 100644 index 0000000..3dc55e3 --- /dev/null +++ b/tests/unit/preload/assetResolver.test.ts @@ -0,0 +1,71 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { resolveAssetUrl } from '../../../src/preload/assetResolver'; + +const BASE = path.resolve('/docs/project/README.md'); + +function expectedUrl(absolutePath: string): string { + return pathToFileURL(absolutePath).href.replace(/^file:/, 'om-asset:'); +} + +describe('resolveAssetUrl', () => { + it('resolves a relative path against the document directory', () => { + const result = resolveAssetUrl(BASE, 'docs/images/logo.svg'); + expect(result).toBe( + expectedUrl(path.resolve('/docs/project/docs/images/logo.svg')) + ); + }); + + it('resolves parent-relative paths', () => { + const result = resolveAssetUrl(BASE, '../shared/pic.png'); + expect(result).toBe( + expectedUrl(path.resolve('/docs/shared/pic.png')) + ); + }); + + it('resolves absolute filesystem paths', () => { + const absolute = path.resolve('/var/assets/diagram.png'); + expect(resolveAssetUrl(BASE, absolute)).toBe(expectedUrl(absolute)); + }); + + it('leaves http(s) URLs untouched', () => { + expect(resolveAssetUrl(BASE, 'https://example.com/a.png')).toBeNull(); + expect(resolveAssetUrl(BASE, 'http://example.com/a.png')).toBeNull(); + }); + + it('leaves data and blob URLs untouched', () => { + expect(resolveAssetUrl(BASE, 'data:image/png;base64,AAAA')).toBeNull(); + expect(resolveAssetUrl(BASE, 'blob:abc-123')).toBeNull(); + }); + + it('leaves protocol-relative URLs untouched', () => { + expect(resolveAssetUrl(BASE, '//cdn.example.com/a.png')).toBeNull(); + }); + + it('leaves in-document anchors untouched', () => { + expect(resolveAssetUrl(BASE, '#section')).toBeNull(); + }); + + it('returns null for empty references', () => { + expect(resolveAssetUrl(BASE, '')).toBeNull(); + expect(resolveAssetUrl(BASE, ' ')).toBeNull(); + expect(resolveAssetUrl('', 'docs/logo.svg')).toBeNull(); + }); + + it('strips query strings and fragments from references', () => { + const result = resolveAssetUrl(BASE, 'images/logo.svg?v=2#frag'); + expect(result).toBe( + expectedUrl(path.resolve('/docs/project/images/logo.svg')) + ); + }); + + it('decodes percent-encoded references', () => { + const result = resolveAssetUrl(BASE, 'images/my%20logo.svg'); + expect(result).toBe( + expectedUrl(path.resolve('/docs/project/images/my logo.svg')) + ); + }); +}); diff --git a/tests/unit/renderer/utils/assetPaths.test.ts b/tests/unit/renderer/utils/assetPaths.test.ts new file mode 100644 index 0000000..dce8b80 --- /dev/null +++ b/tests/unit/renderer/utils/assetPaths.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest'; + +import { + rewriteSrcset, + rewriteAssetPaths, + type AssetResolver, +} from '../../../../src/renderer/utils/assetPaths'; + +// Resolver that mimics the preload behaviour: relative paths become +// `om-asset:` URLs, absolute URLs are left untouched. +const resolver: AssetResolver = (ref) => { + if (/^[a-z][a-z0-9+.-]+:/i.test(ref) || ref.startsWith('//')) { + return null; + } + return `om-asset:///docs/${ref}`; +}; + +describe('rewriteSrcset', () => { + it('rewrites a single-candidate srcset', () => { + expect(rewriteSrcset('images/logo.svg', resolver)).toBe( + 'om-asset:///docs/images/logo.svg' + ); + }); + + it('preserves descriptors for each candidate', () => { + const result = rewriteSrcset('a.png 1x, b.png 2x', resolver); + expect(result).toBe('om-asset:///docs/a.png 1x, om-asset:///docs/b.png 2x'); + }); + + it('leaves absolute URLs untouched', () => { + expect(rewriteSrcset('https://example.com/a.png 2x', resolver)).toBe( + 'https://example.com/a.png 2x' + ); + }); + + it('ignores empty candidates', () => { + expect(rewriteSrcset('a.png 1x, , b.png 2x', resolver)).toBe( + 'om-asset:///docs/a.png 1x, om-asset:///docs/b.png 2x' + ); + }); +}); + +describe('rewriteAssetPaths', () => { + function render(html: string): HTMLElement { + const container = document.createElement('div'); + container.innerHTML = html; + return container; + } + + it('rewrites a relative src', () => { + const container = render('logo'); + rewriteAssetPaths(container, resolver); + expect(container.querySelector('img')?.getAttribute('src')).toBe( + 'om-asset:///docs/images/logo.svg' + ); + }); + + it('rewrites with and fallback', () => { + const container = render(` + + + + Logo + + `); + rewriteAssetPaths(container, resolver); + + const sources = container.querySelectorAll('source'); + expect(sources[0]?.getAttribute('srcset')).toBe( + 'om-asset:///docs/images/light.svg' + ); + expect(sources[1]?.getAttribute('srcset')).toBe( + 'om-asset:///docs/images/dark.svg' + ); + expect(container.querySelector('img')?.getAttribute('src')).toBe( + 'om-asset:///docs/images/light.svg' + ); + }); + + it('leaves remote images untouched', () => { + const container = render(''); + rewriteAssetPaths(container, resolver); + expect(container.querySelector('img')?.getAttribute('src')).toBe( + 'https://example.com/a.png' + ); + }); + + it('leaves data URIs untouched', () => { + const dataUri = 'data:image/png;base64,AAAA'; + const container = render(``); + rewriteAssetPaths(container, resolver); + expect(container.querySelector('img')?.getAttribute('src')).toBe(dataUri); + }); +}); From 417be73cbf7aa036472d9a90bba0fb611967e0c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:49:32 +0000 Subject: [PATCH 2/3] Allow remote https images in markdown documents Markdown files routinely embed remote images (e.g. the CI status badges in the Kafka README). The CSP only permitted local assets, so https images were blocked. Add https: to img-src. https://claude.ai/code/session_01T2U6dtaZLWCuztQVRFTj7U --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 6110dcb..76df38c 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + Open Markdown From 52edbc6332dd980403282cf4565758ea55e8e5b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 13:56:07 +0000 Subject: [PATCH 3/3] Fix om-asset URL mangling by Chromium's standard-scheme parser A bare om-asset:///path URL had its first path segment consumed as the URL host (and lowercased), so local image requests 404'd/400'd. Carry the file path under a fixed `local` host instead. https://claude.ai/code/session_01T2U6dtaZLWCuztQVRFTj7U --- src/main/services/AssetProtocolService.ts | 11 ++++------- src/preload/assetResolver.ts | 11 ++++++----- src/shared/constants/index.ts | 7 +++++++ tests/unit/main/services/AssetProtocolService.test.ts | 2 +- tests/unit/preload/assetResolver.test.ts | 2 +- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/main/services/AssetProtocolService.ts b/src/main/services/AssetProtocolService.ts index 7a21089..b1bd2f2 100644 --- a/src/main/services/AssetProtocolService.ts +++ b/src/main/services/AssetProtocolService.ts @@ -58,13 +58,10 @@ export function registerAssetProtocolHandler(): void { protocol.handle(ASSET_PROTOCOL_SCHEME, async (request) => { let filePath: string; try { - // The renderer builds these URLs from `file:` URLs, so converting the - // scheme back yields a valid `file:` URL we can decode. - const fileUrl = request.url.replace( - new RegExp(`^${ASSET_PROTOCOL_SCHEME}:`), - 'file:' - ); - filePath = fileURLToPath(fileUrl); + // The renderer encodes the file path as the URL path under a fixed + // host; reattaching it to a `file:` URL yields the original path. + const { pathname } = new URL(request.url); + filePath = fileURLToPath(`file://${pathname}`); } catch { return new Response('Invalid asset URL', { status: 400 }); } diff --git a/src/preload/assetResolver.ts b/src/preload/assetResolver.ts index 99ad6fc..0f39000 100644 --- a/src/preload/assetResolver.ts +++ b/src/preload/assetResolver.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { ASSET_PROTOCOL_SCHEME } from '@shared/constants'; +import { ASSET_PROTOCOL_SCHEME, ASSET_PROTOCOL_HOST } from '@shared/constants'; /** * Resolve an image reference against the markdown file that contains it. @@ -57,8 +57,9 @@ export function resolveAssetUrl(baseFilePath: string, ref: string): string | nul ? decoded : path.resolve(path.dirname(baseFilePath), decoded); - return pathToFileURL(resolved).href.replace( - /^file:/, - `${ASSET_PROTOCOL_SCHEME}:` - ); + // Embed the (already percent-encoded) file path under a fixed host. A bare + // `om-asset:///path` URL is mangled by Chromium's standard-scheme parser, + // which treats the first path segment as the host; a fixed host keeps the + // full path intact. + return `${ASSET_PROTOCOL_SCHEME}://${ASSET_PROTOCOL_HOST}${pathToFileURL(resolved).pathname}`; } diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index d3c7d7c..7c8d7e4 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -52,6 +52,13 @@ export const BUILTIN_PLUGINS = { */ export const ASSET_PROTOCOL_SCHEME = 'om-asset'; +/** + * Fixed host used in `om-asset:` URLs. The file path is carried in the URL + * path; a fixed host prevents Chromium's standard-scheme parser from + * consuming the first path segment as the host. + */ +export const ASSET_PROTOCOL_HOST = 'local'; + /** * Theme IDs */ diff --git a/tests/unit/main/services/AssetProtocolService.test.ts b/tests/unit/main/services/AssetProtocolService.test.ts index 2f7ffd0..1dbce17 100644 --- a/tests/unit/main/services/AssetProtocolService.test.ts +++ b/tests/unit/main/services/AssetProtocolService.test.ts @@ -31,7 +31,7 @@ function getRegisteredHandler(): ProtocolHandler { } function assetUrl(absolutePath: string): string { - return pathToFileURL(absolutePath).href.replace(/^file:/, 'om-asset:'); + return `om-asset://local${pathToFileURL(absolutePath).pathname}`; } describe('AssetProtocolService', () => { diff --git a/tests/unit/preload/assetResolver.test.ts b/tests/unit/preload/assetResolver.test.ts index 3dc55e3..a628930 100644 --- a/tests/unit/preload/assetResolver.test.ts +++ b/tests/unit/preload/assetResolver.test.ts @@ -8,7 +8,7 @@ import { resolveAssetUrl } from '../../../src/preload/assetResolver'; const BASE = path.resolve('/docs/project/README.md'); function expectedUrl(absolutePath: string): string { - return pathToFileURL(absolutePath).href.replace(/^file:/, 'om-asset:'); + return `om-asset://local${pathToFileURL(absolutePath).pathname}`; } describe('resolveAssetUrl', () => {