Skip to content
Merged
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
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob:;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob: om-asset: https:;" />
<title>Open Markdown</title>
</head>
<body>
Expand Down
11 changes: 11 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +25,10 @@ interface PendingWindow {
const pendingWindows = new Map<number, PendingWindow>();
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
*/
Expand Down Expand Up @@ -108,6 +116,9 @@ async function initialize(): Promise<void> {
await getPreferencesService().initialize();
await getRecentFilesService().initialize();

// Serve local image assets referenced by markdown documents
registerAssetProtocolHandler();

// Register IPC handlers before creating windows
registerAllHandlers();

Expand Down
86 changes: 86 additions & 0 deletions src/main/services/AssetProtocolService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* 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<string, string> = {
'.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 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 });
}

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 });
}
});
}
65 changes: 65 additions & 0 deletions src/preload/assetResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* 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, ASSET_PROTOCOL_HOST } 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);

// 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}`;
}
7 changes: 7 additions & 0 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron';

import { IPC_CHANNELS } from '@shared/types/api';
import { resolveAssetUrl } from './assetResolver';

import type {
ElectronAPI,
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/components/MarkdownViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
*/
Expand All @@ -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) {
Expand Down
75 changes: 75 additions & 0 deletions src/renderer/utils/assetPaths.ts
Original file line number Diff line number Diff line change
@@ -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 `<img>`/`<source>` 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 `<picture>`
* elements which use `<source srcset>` plus an `<img>` fallback.
*/
export function rewriteAssetPaths(
container: HTMLElement,
resolve: AssetResolver
): void {
const srcElements = container.querySelectorAll<HTMLElement>(
'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<HTMLElement>(
'img[srcset], source[srcset]'
);
srcsetElements.forEach((element) => {
const srcset = element.getAttribute('srcset');
if (srcset) {
element.setAttribute('srcset', rewriteSrcset(srcset, resolve));
}
});
}
13 changes: 13 additions & 0 deletions src/shared/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ 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';

/**
* 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
*/
Expand Down
13 changes: 13 additions & 0 deletions src/shared/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,18 @@ export interface ShellAPI {
openExternal: (url: string) => Promise<void>;
}

/**
* 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
*/
Expand All @@ -273,6 +285,7 @@ export interface ElectronAPI {
recentFiles: RecentFilesAPI;
menu: MenuAPI;
shell: ShellAPI;
assets: AssetsAPI;
}

/**
Expand Down
Loading
Loading