Skip to main content

Development notes and pitfalls

A collection of the most common traps in plugin development, so third-party developers can avoid repeating them.

Rendering and script execution

How inline rendering works

Plugins have two rendering modes, decided by the manifest’s render field (iframe by default):

L2 iframe sandbox

  • Fully isolated from the main UI (its own window), and scripts run automatically with srcdoc
  • Supports every __PLUGIN_API__ method, .p-* styles, and registerMethod/callPlugin (relayed through the main window)
  • Plugin scripts cannot touch the main UI DOM directly (security isolation); document.getElementById('root') is the plugin’s own content

Inline rendering

The launcher strips <!DOCTYPE>/<html>/<head>/<body> from the plugin HTML, injects the rest into a container in the main UI, and appends the plugin API bridge script. Key takeaways:
  • Scripts run immediately when the plugin is activation-loaded. Setting container.innerHTML = ... does not execute <script> by itself, so the launcher temporarily mounts the container on a hidden host node (to make sure it is connected to the document) and then re-parses each script node with replaceWith to trigger execution. As a result:
  • Library plugins (such as MarkdownLib) can registerMethod as soon as they activate, without the user opening the plugin page
  • Dependent plugins can callPlugin into an already activated library plugin as soon as they start
  • sourceMap ownership follows the last loaded inline plugin: multiple inline plugins share the main window, and sourceMap associates the last activated plugin id with the window, which can attribute calls to the wrong plugin. When several plugins call the API at the same time, double-check the plugin id.

Accessing document from scripts

Plugins run in an iframe sandbox (opaque origin, separate document) by default; only an explicit "render": "inline" switches to inline rendering. In inline mode the plugin script shares the same window context as the main UI, and PluginPage temporarily hijacks document.getElementById('root') to return the plugin container. Therefore:
  • Do not query main UI elements through the global document
  • Use document.getElementById('root') to get your own container (this works in inline mode with the current implementation)
  • If you depend on the main UI DOM, declare "render": "inline" explicitly; otherwise keep the iframe isolation
Need an isolated environment or a long-running script (AI assistant, tool panel)? Use the default iframe sandbox. A lightweight plugin that is pure UI or depends on the main UI DOM? Declare "render": "inline" explicitly.

API call notes

Functions cannot be passed through postMessage

Plugin API calls across windows use postMessage (structured clone), and functions are not cloneable. Therefore:
  • Pass only serializable data (strings, numbers, objects, arrays)
  • The fn given to registerMethod stays local to the plugin’s window; only the method name travels in the message
  • req.signal (an AbortSignal) on proxyFetchStream is stripped automatically before sending and routed through a dedicated abort message, so do not rely on passing an AbortSignal inside req

Determining when proxyFetchStream is done

  • The returned Promise resolves when the stream ends, but chunk callbacks may still fire after it resolves
  • Check for the SSE [DONE] marker or your own end-of-stream flag inside the chunk callback instead of relying on the Promise alone

Relative paths vs absolute URLs

  • Relative resource paths in plugin files (HTML/JS/CSS) are rewritten to {API_BASE}/plugins/{id}/files/..., so do not hard-code /api/plugins/... relative paths (there is no Vite proxy after packaging)
  • External CDN resources are limited by CSP: images and fonts can use full https:// URLs, but CSS/JS are blocked and must ship with the package (see CSP allowlist reference)

Dependencies and activation

Library plugins must declare entry.frontend

activatePlugin activates a plugin only when entry.frontend exists (it renders, registers methods, and marks the plugin active). A plugin without frontend is never activated, so its methods cannot be registered. A library plugin needs a page, even an empty one, even if it has no UI.

Activation depends on entry.frontend, not on layers

After installation a plugin starts in the installed state, and actual activation depends on two things: ① entry.frontend exists (activatePlugin only renders and marks a plugin active when entry.frontend exists); ② the user enables it in the plugin list. The layers declaration is irrelevant: a plugin with ["l3"] only is still activated if it has entry.frontend (in the default iframe sandbox; only "render":"inline" goes inline).

Activation order is handled automatically

The launcher uses a topological sort so depended-on plugins activate first; you do not need to care about manifest declaration order.

State changes need a restart

Toggling a plugin on or off takes effect after you restart the launcher (PUT /api/plugins/{id}/state); there is no hot activation while it runs. Tell users about this when they test a new plugin.

Manifest notes

  • minLauncherVersion is not validated at the moment, but fill it in for future compatibility
  • The backend only stores permissions and does not validate them; the frontend enforces them at runtime against the METHOD_PERMISSIONS table, so a missing permission produces a Permission denied API error
  • contributes.commands registers keyboard shortcuts (such as devtools:toggle), see PluginEventBridge.tsx; downloadSources is not used yet, so do not depend on it; settingsPages is implemented (Settings → Plugins → Plugin settings, see manifest)
  • The upload endpoint does not check dependencies; only directory install (install) checks required dependencies

Backend behavior

  • POST /api/plugins/upload expects the file field to be named plugin (multipart)
  • proxyFetch has SSRF protection and blocks intranet and reserved addresses. Keep this in mind when proxying during local debugging (localhost is blocked)
  • Plugin settings and cache are isolated per plugin id, under {data dir}/plugins/{id}/
  • Overwrite installs delete the old directory first (plugins/{id}/) and then copy

Debugging tips

  • Plugin page script errors show up in the launcher DevTools console (Ctrl+Shift+I), with logs prefixed [plugin:inline] / [sandbox]
  • Failed API calls are logged to the console (prefixed [API]) with the status code and error code
  • To check whether a plugin is actually active, look at its state in the plugin list; an active plugin has state === 'active'
  • To check dependencies, note that sortByDependencies skips missing dependencies with a warning log

Environment and build pitfalls (in practice)

Tauri/WebView2 framework environment (different from a normal browser)

Plugins run inside Tauri (WebView2, Chromium), not a normal browser, and that difference directly determines “which resources work”: Key dev vs release differences (the most common source of breakage):
  • npm run tauri dev / a plain browser preview: no CSP, so CSS/JS from external CDNs loads freely → many problems cannot be reproduced in dev
  • Release build: Tauri injects the CSP, so external resources are blocked by it → the classic “works in dev, blank page / missing styles / missing icons / broken worker in release” is almost always this

CSP allowlist reference (current launcher defaults)

What this means for plugin development:
  • A plugin’s own CSS/JS must ship with the package and load from the backend at http://localhost:5000/api/plugins/{id}/files/...; do not use CDN <link rel="stylesheet"> or <script src="https://...">, because release builds block them
  • External CDNs are only usable for images (img-src includes https:) and fonts (font-src includes https:)
  • If an icon font comes from a CDN <link>, its CSS is blocked by style-src; instead inline the font CSS (@font-face + base64) or ship the font files to the backend
  • Embedding an external web page in an iframe → blocked by frame-src; fetch the content with proxyFetch and render it yourself, or use an overlay window (blob URL)
  • Depending on an external script (a CDN library, for example) → download it, ship it in the package, and load it from the backend
  • Always call remote APIs through proxyFetch (the backend proxy) instead of a direct cross-origin fetch (which fails CORS and is caught by SSRF protection anyway)

Diagnosing blocked resources

  • Launcher DevTools (Ctrl+Shift+I) console: search for Refused to load / violates ... Content Security Policy
  • Open http://localhost:5000/api/plugins/{id}/files/{path} directly to confirm the resource is reachable: a 404 means a path problem; a 200 that still does not display usually means CSP blocked it
  • Works in dev but breaks in release → suspect CSP first (dev has no CSP)
  • Plugin page blank with no JS errors in the console → check whether #root has content; empty means the JS did not load (script-src), content but no styles means CSS was blocked (style-src)

Loading blob:/data:/workers in Tauri/WebView2

The launcher (Tauri + WebView2) CSP already allows blob:, data:, and 'wasm-unsafe-eval' by default (script-src / worker-src / connect-src), so new Worker(blobUrl), import(blobUrl), and pdf.js (module worker) work out of the box. If you replace the launcher CSP yourself, none of the following may be dropped, or plugins break in release builds:
  • script-src must include 'unsafe-inline' http://localhost:5000 blob: data: 'wasm-unsafe-eval'
  • worker-src must include blob: data:
  • connect-src must include http://localhost:5000 ws://localhost:5000 blob: data:
  • style-src must include http://localhost:5000 (missing → plugin pages have no styles at all, see the next section)
  • font-src must include https: (CDN icon fonts)
  • frame-src must include blob: (overlays use blob URLs)
Background: older CSPs did not allow blob:/data:, so new Worker(blobUrl) and import(blobUrl) failed (Cannot load script at: blob:...) and libraries that rely on Web Workers (such as pdf.js v4) were unusable. If you really cannot allow them, the fallback is to inline the worker code into the main bundle and use data:text/javascript;base64,... as workerSrc at runtime (pdf.js also needs the classic worker build).

Plugin pages with no styles at all in release builds (blank page)

Symptom: plugin styles look fine under npm run tauri dev, but once packaged as a release build the plugin page becomes blank or unstyled, with stacked text and no card backgrounds. Root cause: Tauri injects a CSP into the WebView in release builds (it does not in dev, which is why dev looks fine). If the CSP style-src does not allow the backend address that serves plugin resources (http://localhost:5000), the plugin’s <link rel="stylesheet" href=".../files/assets/index.css"> is blocked. The console reports Refused to load the stylesheet ... violates ... "style-src". JS is usually unaffected (script-src already includes 5000), so the page has “layout but no styles”. Fix: the launcher CSP style-src must include the backend address; the current launcher already includes http://localhost:5000 by default. Nothing needs to change on the plugin side. Diagnosis: works in dev + blank in release → suspect CSP first; open the launcher DevTools (Ctrl+Shift+I) console and look for Refused to load the stylesheet.

Avoid GitHub raw for the registry source

Symptom: the plugin store list is empty or reports HTTP 499. Root cause: the registry source uses raw.githubusercontent.com, which is blocked or extremely slow in some regions, so the backend proxyFetch times out after 15s and returns 499 (TaskCanceledException). Fix: switch the registry source to the GitHub contents API (api.github.com, reachable):
The response body is { "content": "<base64>", "encoding": "base64" }, so base64-decode it (browser atob + TextDecoder) before parsing the JSON.
  • The store plugin (top.qomicex.market) uses the GitHub API by default since v1.0.1
  • Mind the unauthenticated GitHub API rate limit (60 requests per hour); do not poll the registry too often

Dangerous sequences before inlining a bundle into HTML

When you inline a built JS bundle into <script>...</script>, any <!--, -->, <script, or </script> inside the JS can push the HTML parser into the script escaped / double-escaped state, which may end the <script> early or swallow what follows, truncating the JS and leaving methods unregistered. Escape them before injecting (unicode escapes are restored at runtime, so the semantics do not change):

Do not use String.replace for template injection

tpl.replace('<!--占位符-->', '<script>' + js + '</script>') expands $ patterns ($&, $', $`, $1, and so on) in the replacement, corrupting the injected JS. A typical accident: the "0$&" inside an xlsx library in the bundle gets expanded into the placeholder text, the whole bundle breaks, and registerMethod is never registered. Use split().join() instead, which performs no replacement expansion:

Plugin management/list icons: library plugins use top-level manifest.icon

  • The icon on a plugin management card is read as contributes.menuItems[0].icon ?? manifest.icon
  • For a library plugin to show an icon, use the top-level manifest field "icon": "fa-solid fa-file-lines" and do not declare contributes.menuItems (that creates a sidebar entry and may render the plugin page as well)
  • Prefer FontAwesome class names (fa-solid fa-xxx, i.e. the launcher’s built-in table): SVG paths rendered through <img> are fixed black and ignore the light/dark theme, while FA class names follow the theme
  • To use a new FA icon, add an entry to the entries table in the launcher’s src/components/BuiltinIcons.tsx (including fa-brands icons such as fa-markdown), then run node scripts/gen-icon-catalog.mjs to sync it into @qomicex/plugin-ui and qomicex-cli (pnpm run test:icon-catalog guards against drift)

Icons do not show up / only an empty slot remains

  • Most common cause: the class name is not in the launcher’s built-in table. Names that look right — fa-floppy-disk, fa-skull, fa-file-zipper, fa-keyboard, fa-folder-tree, fa-puzzle-piece, fa-cog — are all absent. Historically a name outside the table was silently rendered as nothing.
  • Diagnose: qomicex verify / qomicex lint report an error and suggest the closest built-in icons; at runtime an <Icon> miss logs a console.warn; you can also call await __PLUGIN_API__.call('listIcons') in the launcher to get the full list.
  • In-page icons should use <Glyph> from @qomicex/plugin-ui with a per-icon subpath (@qomicex/plugin-ui/icons/server) rather than wrapping the getIconSvg bridge yourself — that path costs one IPC round-trip per icon and gives no feedback when a name is wrong.
  • If the table genuinely lacks the icon you need, declare a private icon with contributes.iconTheme, or ship an .svg and render it with <img>.

File drag and drop cannot get local paths

  • Tauri’s default dragDropEnabled intercepts system file drops, so the native frontend drop event does not expose .path on the File object (and often does not fire at all)
  • You need a Rust-side listener using window.on_window_event, matching WindowEvent::DragDrop::Drop { paths, .. }, then emit("file-drop", paths) to the frontend
  • The frontend (including plugins that need this capability) receives an array of absolute paths with listen('file-drop') from @tauri-apps/api/event; a plugin iframe needs the main UI frontend to relay it through callPlugin

File reading APIs are readText / readBytes, not readFile

The plugin bridge’s file reading methods are readText (UTF-8 text, returns { content }) and readBytes (binary, returns { contentBase64 }), under the filesystem:read permission. There is no readFile method, and using it produces Unknown method: readFile. Writing uses writeText / writeBytes, and deleting uses deleteFile.