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’srender 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, andregisterMethod/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 withreplaceWithto trigger execution. As a result: - Library plugins (such as MarkdownLib) can
registerMethodas soon as they activate, without the user opening the plugin page - Dependent plugins can
callPlugininto an already activated library plugin as soon as they start sourceMapownership follows the last loaded inline plugin: multiple inline plugins share the main window, andsourceMapassociates 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
"render": "inline" explicitly.
API call notes
Functions cannot be passed through postMessage
Plugin API calls across windows usepostMessage (structured clone), and functions are not cloneable. Therefore:
- Pass only serializable data (strings, numbers, objects, arrays)
- The
fngiven toregisterMethodstays local to the plugin’s window; only the method name travels in the message req.signal(an AbortSignal) onproxyFetchStreamis stripped automatically before sending and routed through a dedicated abort message, so do not rely on passing an AbortSignal insidereq
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 theinstalled 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
minLauncherVersionis not validated at the moment, but fill it in for future compatibility- The backend only stores
permissionsand does not validate them; the frontend enforces them at runtime against theMETHOD_PERMISSIONStable, so a missing permission produces aPermission deniedAPI error contributes.commandsregisters keyboard shortcuts (such asdevtools:toggle), seePluginEventBridge.tsx;downloadSourcesis not used yet, so do not depend on it;settingsPagesis 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/uploadexpects the file field to be namedplugin(multipart)proxyFetchhas SSRF protection and blocks intranet and reserved addresses. Keep this in mind when proxying during local debugging (localhostis 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
sortByDependenciesskips 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-srcincludeshttps:) and fonts (font-srcincludeshttps:) - If an icon font comes from a CDN
<link>, its CSS is blocked bystyle-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 withproxyFetchand 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-originfetch(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
#roothas 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 allowsblob:, 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-srcmust include'unsafe-inline' http://localhost:5000 blob: data: 'wasm-unsafe-eval'worker-srcmust includeblob: data:connect-srcmust includehttp://localhost:5000 ws://localhost:5000 blob: data:style-srcmust includehttp://localhost:5000(missing → plugin pages have no styles at all, see the next section)font-srcmust includehttps:(CDN icon fonts)frame-srcmust includeblob:(overlays use blob URLs)
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 undernpm 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 reportsHTTP 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):
{ "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 declarecontributes.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
entriestable in the launcher’ssrc/components/BuiltinIcons.tsx(includingfa-brandsicons such asfa-markdown), then runnode scripts/gen-icon-catalog.mjsto sync it into@qomicex/plugin-uiandqomicex-cli(pnpm run test:icon-catalogguards 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 lintreport an error and suggest the closest built-in icons; at runtime an<Icon>miss logs aconsole.warn; you can also callawait __PLUGIN_API__.call('listIcons')in the launcher to get the full list. - In-page icons should use
<Glyph>from@qomicex/plugin-uiwith a per-icon subpath (@qomicex/plugin-ui/icons/server) rather than wrapping thegetIconSvgbridge 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.svgand render it with<img>.
File drag and drop cannot get local paths
- Tauri’s default
dragDropEnabledintercepts system file drops, so the native frontenddropevent does not expose.pathon theFileobject (and often does not fire at all) - You need a Rust-side listener using
window.on_window_event, matchingWindowEvent::DragDrop::Drop { paths, .. }, thenemit("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 throughcallPlugin
File reading APIs are readText / readBytes, not readFile
The plugin bridge’s file reading methods arereadText (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.