diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx
index d9594bc..3f1db7e 100644
--- a/src/components/MainView.tsx
+++ b/src/components/MainView.tsx
@@ -22,6 +22,7 @@ import { ModToolsView } from './mod-tools/ModToolsView';
import { NavigatorView } from './navigator/NavigatorView';
import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView';
import { NitropediaView } from './nitropedia/NitropediaView';
+import { ExternalPluginLoader } from './plugins/ExternalPluginLoader';
import { RightSideView } from './right-side/RightSideView';
import { RoomView } from './room/RoomView';
import { ToolbarView } from './toolbar/ToolbarView';
@@ -118,6 +119,7 @@ export const MainView: FC<{}> = props =>
+
>
);
};
diff --git a/src/components/plugins/ExternalPluginLoader.tsx b/src/components/plugins/ExternalPluginLoader.tsx
new file mode 100644
index 0000000..b97fb41
--- /dev/null
+++ b/src/components/plugins/ExternalPluginLoader.tsx
@@ -0,0 +1,61 @@
+import { FC, useEffect, useState } from 'react';
+import { GetConfigurationValue } from '../../api';
+import { subscribePlugins } from './NitroPluginApi';
+
+// Force the global API to be initialized
+import './NitroPluginApi';
+
+export const ExternalPluginLoader: FC<{}> = () =>
+{
+ const [, forceUpdate] = useState(0);
+
+ useEffect(() =>
+ {
+ return subscribePlugins(() => forceUpdate(n => n + 1));
+ }, []);
+
+ // MainView only renders after isReady=true in App.tsx,
+ // so the configuration is guaranteed to be loaded at this point.
+ useEffect(() =>
+ {
+ const scripts: HTMLScriptElement[] = [];
+
+ let pluginUrls: string[] = [];
+
+ try
+ {
+ pluginUrls = GetConfigurationValue('external.plugins', []);
+ }
+ catch (e)
+ {
+ console.warn('[NitroPlugins] Could not read external.plugins config:', e);
+ return;
+ }
+
+ if (!pluginUrls || pluginUrls.length === 0)
+ {
+ console.log('[NitroPlugins] No external plugins configured');
+ return;
+ }
+
+ console.log('[NitroPlugins] Loading external plugins:', pluginUrls);
+
+ for (const url of pluginUrls)
+ {
+ const script = document.createElement('script');
+ script.src = url;
+ script.async = true;
+ script.onload = () => console.log(`[NitroPlugins] Loaded: ${url}`);
+ script.onerror = () => console.warn(`[NitroPlugins] Failed to load: ${url}`);
+ document.head.appendChild(script);
+ scripts.push(script);
+ }
+
+ return () =>
+ {
+ scripts.forEach(s => s.remove());
+ };
+ }, []);
+
+ return null;
+};
diff --git a/src/components/plugins/NitroPluginApi.ts b/src/components/plugins/NitroPluginApi.ts
new file mode 100644
index 0000000..d52ffa0
--- /dev/null
+++ b/src/components/plugins/NitroPluginApi.ts
@@ -0,0 +1,193 @@
+import { GetRoomEngine } from '@nitrots/nitro-renderer';
+import { CreateLinkEvent, GetRoomSession, SendMessageComposer } from '../../api';
+
+/**
+ * Plugin descriptor registered by external plugin scripts.
+ */
+export interface INitroPlugin
+{
+ /** Unique plugin name */
+ name: string;
+ /** Label shown on the button in room tools */
+ label: string;
+ /** CSS class for the icon (nitro-icon class) */
+ icon?: string;
+ /** Called when the plugin button is clicked */
+ onOpen: () => void;
+ /** Called to close/destroy the plugin UI */
+ onClose?: () => void;
+ /** Called when the plugin is first loaded, receives the Nitro API */
+ onInit?: (api: INitroPluginApi) => void;
+}
+
+/**
+ * API exposed to external plugins via window.NitroPlugins
+ */
+export interface INitroPluginApi
+{
+ /** Register a plugin */
+ register: (plugin: INitroPlugin) => void;
+ /** Unregister a plugin by name */
+ unregister: (name: string) => void;
+ /** Get all registered plugins */
+ getPlugins: () => INitroPlugin[];
+ /** Fire a Nitro link event (e.g., 'navigator/toggle-room-info') */
+ createLinkEvent: (link: string) => void;
+ /** Get the room engine instance */
+ getRoomEngine: () => ReturnType;
+ /** Get the current room session */
+ getRoomSession: () => ReturnType;
+ /** Send a message composer to the server */
+ sendMessage: typeof SendMessageComposer;
+ /** Create a draggable floating window and return its container element */
+ createWindow: (id: string, title: string, width: number) => HTMLDivElement | null;
+ /** Destroy a floating window by id */
+ destroyWindow: (id: string) => void;
+}
+
+// Internal plugin storage
+const _plugins: INitroPlugin[] = [];
+const _listeners: Array<() => void> = [];
+
+function notifyListeners()
+{
+ _listeners.forEach(fn => fn());
+}
+
+const pluginApi: INitroPluginApi = {
+ register(plugin: INitroPlugin)
+ {
+ if (_plugins.some(p => p.name === plugin.name)) return;
+ _plugins.push(plugin);
+ plugin.onInit?.(pluginApi);
+ notifyListeners();
+ },
+
+ unregister(name: string)
+ {
+ const index = _plugins.findIndex(p => p.name === name);
+ if (index >= 0)
+ {
+ _plugins[index].onClose?.();
+ _plugins.splice(index, 1);
+ notifyListeners();
+ }
+ },
+
+ getPlugins()
+ {
+ return [..._plugins];
+ },
+
+ createLinkEvent(link: string)
+ {
+ CreateLinkEvent(link);
+ },
+
+ getRoomEngine()
+ {
+ return GetRoomEngine();
+ },
+
+ getRoomSession()
+ {
+ return GetRoomSession();
+ },
+
+ sendMessage: SendMessageComposer,
+
+ createWindow(id: string, title: string, width: number): HTMLDivElement | null
+ {
+ // Remove existing window with same id
+ pluginApi.destroyWindow(id);
+
+ // Create overlay container
+ const overlay = document.createElement('div');
+ overlay.id = `nitro-plugin-window-${id}`;
+ overlay.style.cssText = `position:fixed;z-index:500;top:50%;left:50%;transform:translate(-50%,-50%)`;
+
+ // Card wrapper
+ const card = document.createElement('div');
+ card.style.cssText = `width:${width}px;background:#2c3e50;border:1px solid #283F5D;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.5);overflow:hidden;font-family:Ubuntu,sans-serif`;
+
+ // Header (draggable)
+ const header = document.createElement('div');
+ header.style.cssText = `display:flex;align-items:center;justify-content:center;position:relative;min-height:33px;background:linear-gradient(180deg,#3c6a8e 0%,#2a4f6e 100%);cursor:move;user-select:none`;
+
+ const titleEl = document.createElement('span');
+ titleEl.textContent = title;
+ titleEl.style.cssText = `color:#fff;font-size:16px;text-shadow:0 1px 2px rgba(0,0,0,0.5)`;
+
+ const closeBtn = document.createElement('div');
+ closeBtn.style.cssText = `position:absolute;right:8px;width:20px;height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;border-radius:50%;background:rgba(255,255,255,0.1)`;
+ closeBtn.innerHTML = '✕';
+ closeBtn.addEventListener('click', () => pluginApi.destroyWindow(id));
+
+ header.appendChild(titleEl);
+ header.appendChild(closeBtn);
+
+ // Make draggable
+ let isDragging = false;
+ let offsetX = 0, offsetY = 0;
+
+ header.addEventListener('mousedown', (e: MouseEvent) =>
+ {
+ isDragging = true;
+ const rect = overlay.getBoundingClientRect();
+ offsetX = e.clientX - rect.left;
+ offsetY = e.clientY - rect.top;
+ overlay.style.transform = 'none';
+ overlay.style.left = rect.left + 'px';
+ overlay.style.top = rect.top + 'px';
+ });
+
+ document.addEventListener('mousemove', (e: MouseEvent) =>
+ {
+ if (!isDragging) return;
+ overlay.style.left = (e.clientX - offsetX) + 'px';
+ overlay.style.top = (e.clientY - offsetY) + 'px';
+ });
+
+ document.addEventListener('mouseup', () => { isDragging = false; });
+
+ // Content area
+ const content = document.createElement('div');
+ content.style.cssText = `padding:16px`;
+
+ card.appendChild(header);
+ card.appendChild(content);
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+
+ return content;
+ },
+
+ destroyWindow(id: string)
+ {
+ const existing = document.getElementById(`nitro-plugin-window-${id}`);
+ if (existing) existing.remove();
+ }
+};
+
+/**
+ * Subscribe to plugin list changes. Returns unsubscribe function.
+ */
+export function subscribePlugins(listener: () => void): () => void
+{
+ _listeners.push(listener);
+ return () =>
+ {
+ const idx = _listeners.indexOf(listener);
+ if (idx >= 0) _listeners.splice(idx, 1);
+ };
+}
+
+export function getRegisteredPlugins(): INitroPlugin[]
+{
+ return [..._plugins];
+}
+
+// Expose globally so external scripts can use it
+(window as any).NitroPlugins = pluginApi;
+
+export { pluginApi };