PDF Studio
Advanced PDF viewer & editor — annotate, organize, compress, watermark, passwords, forms. Open from Apps or chat PDF attachments.
Build sandboxed plugins for the ggbro desktop app. Custom packs show UI in Settings → Plugins. A small set of official host apps also open from the Apps (9-dot) grid. Local install only — no marketplace, no remote URL install.
Two related surfaces — both work for custom packs that declare ui.panel.
| Settings → Plugins (schema panels) | Apps grid (9-dot) | |
|---|---|---|
| Who | Any pack with ui.panel that calls ggbro.ui.registerPanel |
Official host apps and enabled packs with ui.panel (your custom packs) |
| Examples | Hello Sample, Client Lab controls, Pomodoro companion panel | PDF Studio, Youtube, Discord Theme, Spotify, Hello, … |
| UI source | Host draws your schema (inputs, buttons, selects) — no custom HTML/React in the sandbox | Official apps: host React / webview. Custom packs: same schema Controls inside an Apps window |
| Custom packs? | Yes — Controls under the pack card + Active plugin controls | Yes — tile appears when the pack is enabled and has ui.panel. Use Open controls in Apps on the card |
registerPanel, enable the pack, then open it from
Apps or use Controls on Settings → Plugins.
Inputs need Apply (or Enter) before Calculate sees new values.
Authors ship their own packs — there is no built-in Calculator. Follow this checklist so Controls and Apps tiles actually appear.
manifest.json and plugin.js in a dedicated folder
(e.g. Documents\my-calculator\), not loose in
Documents\ next to unrelated files. In the desktop app use
Install Plugin → Folder and select that folder
(or zip only those two files into a .ggbro-plugin).
my-calculator/ manifest.json plugin.js
manifest.json{
"id": "com.example.calculator",
"name": "Calculator",
"version": "1.0.0",
"author": "You",
"description": "Simple A + B calculator with history.",
"main": "plugin.js",
"permissions": ["ui.panel", "ui.toast", "storage"]
}
id must be reverse-domain lowercase (com.example.name) — unique on the PC.ui.panel; toasts need ui.toast; KV needs storage.ui.webview unless you are building an official host webview app (you aren’t).plugin.js — required panel rulesggbro.ui.registerPanel as early as possible (before slow storage / network). Controls stay empty until the first successful register.id ([a-zA-Z0-9._-]). Fields without id are dropped — your panel looks empty.select options must be objects: { "value": "+", "label": "+" } — not bare strings like "+".ggbro.onEvent and payload.fieldId (not payload.label).registerPanel again with the same panel id to refresh displayed values.const { value } = await ggbro.storage.get('key') — always { value }. Survives pack reinstall; wiped on Uninstall.// plugin.js — register the panel FIRST, then load storage
(async function () {
var numA = '0';
var numB = '0';
var operator = '+';
var resultDisplay = '0';
var history = [];
async function renderUI() {
var historyText = history.length
? history.slice(-5).reverse().join('\n')
: 'No previous calculations.';
await ggbro.ui.registerPanel({
id: 'main',
title: 'Quick Calculator',
schema: [
{ type: 'section', id: 's_inputs', title: 'Inputs & Operation' },
{ type: 'input', id: 'num_a', label: 'First Number (A)', value: numA },
{
type: 'select',
id: 'op',
label: 'Operator',
value: operator,
options: [
{ value: '+', label: '+' },
{ value: '-', label: '-' },
{ value: '*', label: '*' },
{ value: '/', label: '/' }
]
},
{ type: 'input', id: 'num_b', label: 'Second Number (B)', value: numB },
{ type: 'button', id: 'calc', label: 'Calculate' },
{ type: 'section', id: 's_result', title: 'Result' },
{ type: 'text', id: 'out', label: 'Output', value: resultDisplay },
{ type: 'section', id: 's_hist', title: 'History' },
{ type: 'textarea', id: 'hist', label: 'Last 5', value: historyText, rows: 4 },
{ type: 'button', id: 'clear', label: 'Clear History' }
]
});
}
// Show Controls immediately
await renderUI();
try {
var stored = await ggbro.storage.get('calc_history');
if (stored && stored.value) {
history = JSON.parse(stored.value);
await renderUI();
}
} catch (e) {
ggbro.log('storage', e && e.message);
}
await ggbro.toast({
message: 'Calculator ready — open Settings → Plugins for controls.',
type: 'success'
});
ggbro.onEvent(async function (event, payload) {
if (event !== 'panelAction' || !payload) return;
if (payload.fieldId === 'num_a') { numA = String(payload.value != null ? payload.value : ''); return; }
if (payload.fieldId === 'num_b') { numB = String(payload.value != null ? payload.value : ''); return; }
if (payload.fieldId === 'op') { operator = String(payload.value != null ? payload.value : '+'); return; }
if (payload.fieldId === 'clear') {
history = [];
await ggbro.storage.clear('calc_history');
await ggbro.toast({ message: 'History cleared', type: 'info' });
await renderUI();
return;
}
if (payload.fieldId !== 'calc') return;
var a = parseFloat(numA);
var b = parseFloat(numB);
if (isNaN(a) || isNaN(b)) {
resultDisplay = 'Error: Invalid Number';
await ggbro.toast({ message: 'Enter valid numbers.', type: 'error' });
await renderUI();
return;
}
var res = 0;
if (operator === '+') res = a + b;
else if (operator === '-') res = a - b;
else if (operator === '*') res = a * b;
else if (operator === '/') {
if (b === 0) {
resultDisplay = 'Error: Division by 0';
await ggbro.toast({ message: 'Cannot divide by zero.', type: 'error' });
await renderUI();
return;
}
res = a / b;
}
resultDisplay = String(res);
history.push(a + ' ' + operator + ' ' + b + ' = ' + res);
await ggbro.storage.set('calc_history', JSON.stringify(history));
await ggbro.toast({ message: 'Result: ' + res, type: 'success' });
await renderUI();
});
})();
# From inside my-calculator\ (PowerShell) — files must be at the zip root Compress-Archive -Path manifest.json,plugin.js -DestinationPath my-plugin.ggbro-plugin.zip Rename-Item my-plugin.ggbro-plugin.zip my-plugin.ggbro-plugin
Also accepted: leave it as .zip, or pick the folder in the install dialog.
.ggbro-plugin / .zip / folder. New packs install enabled.ui.toast)ui.panelOptional reference packs (Hello / Client Lab) are examples only — authors are expected to ship their own plugins. Clone hello/plugin.js if you want a starting point.
.ggbro-plugin pack and use Install Plugin.
Windows note: file and folder pickers are separate (Windows cannot filter .ggbro-plugin files in a combined dialog).
Re-installing the same id replaces the pack files and keeps the previous enabled/disabled state (catalog install re-enables).
Prefer one-click install from the desktop app: Settings → Plugins → Official examples
→ Install & Enable. You can still download a single .ggbro-plugin file
below and use Install Plugin. These packs are published on the ggbro site — still review
permissions before leaving them enabled.
Catalog JSON: /developers/examples/plugins/index.json
(used by the app for install + update checks).
View pack / View source opens the .ggbro-plugin contents.
registerPanel controls in Settings → Plugins.
Many also have a host Apps window for a richer demo. Custom packs with ui.panel get an Apps tile that opens their schema Controls.
Advanced PDF viewer & editor — annotate, organize, compress, watermark, passwords, forms. Open from Apps or chat PDF attachments.
Search YouTube with thumbnails, duration, and paging. Play or hand off to the downloader from results.
Download videos or entire PL… playlists. Quality presets, pause/resume, progress in Downloads.
Modern Discord-style layout — left server rail, blurple chrome, Appearance-friendly tokens.
Opens Spotify Web inside ggbro (Apps → Spotify). Log in and play in an in-app panel — not just a link.
Starter with a Settings panel — greet button, sound cue, visit counter, and live theme readout.
Kitchen-sink demo: panel buttons, storage stats, sounds, and local message decorations.
Focus timer with a control panel — start/pause/skip, duration presets, sounds, and cycle stats.
Advanced appearance studio — colors, radius, button styles, density, chat width & alignment, plus pulse / random look.
Wellness panel — tip button, check-in interval, optional auto nudges, and tip history.
Welcome panel — replay greeting, vibe style picker, optional sound, remembers last hello.
Catalog JSON: /developers/examples/plugins/index.json. Individual source files also live under each slug folder.
.ggbro-plugin file →
enable if needed → use Controls on that page (or Apps for official host apps).
Each plugin is a flat directory (or zip of those files). Nested folders are not kept — only declared basenames are installed.
{
"id": "com.example.hello",
"name": "Hello Plugin",
"version": "1.0.0",
"author": "Example",
"description": "Shows a toast on load",
"main": "plugin.js",
"styles": "styles.css",
"permissions": ["ui.toast", "theme.read", "app.info"]
}
| Field | Required | Rules |
|---|---|---|
id | yes | Reverse-domain, lowercase: com.example.name (letters, digits, ., _, -). Installed folder is named exactly this id. |
name | yes | 1–80 characters (shown in Settings and toast labels) |
version | yes | 1–32 characters (display only) |
author | no | Max 80 characters |
description | no | Max 280 characters |
main | yes | Basename only, e.g. plugin.js (safe chars, .js) |
styles | no | Basename only, e.g. styles.css |
permissions | yes* | Array of allowlisted permissions (duplicates ignored). Empty array is allowed if you only use ggbro.log. |
*Use [] if you need no host capabilities beyond logging.
Optional README.md may be copied for your own notes; it is never executed.
Only these files are installed: manifest.json, main, optional styles, optional README. Extra .js/.css in the zip are discarded.
.zip and .ggbro-plugin (same zip format — renaming .zip → .ggbro-plugin is fine)manifest.json and plugin.js at the zip root (or install the folder). Nested-only layouts often fail because only declared basenames are kept.# Example (PowerShell) — from the folder that contains the files Compress-Archive -Path manifest.json,plugin.js,styles.css,README.md -DestinationPath hello.ggbro-plugin.zip Rename-Item hello.ggbro-plugin.zip hello.ggbro-plugin
Wrong: zipping a parent folder so the archive contains my-plugin/manifest.json only. Prefer selecting the files themselves, or use Install Plugin → pick the folder.
Declare every host API you call (except ggbro.log). Missing permission → the call rejects. Enabling a pack that requests theme.write, ui.webview, net.fetch, or messages.* asks for a one-time confirm.
| Permission | API | Purpose |
|---|---|---|
app.info | ggbro.getAppInfo() | App version + platform |
app.context | ggbro.getContext() / context events | Read-only navigation context (view, server/channel/dm ids — no message bodies) |
theme.read | ggbro.getTheme() / onThemeChange | Read theme / chrome snapshot; listen for changes |
theme.write | ggbro.setTheme(patch) | Apply allowlisted Appearance settings (colors, shape, layout). Sensitive — confirm on enable. |
ui.toast | ggbro.toast({ message, type? }) | Show an in-app toast |
ui.navigate | ggbro.navigate({ type, ... }) | In-app navigation (see below) |
ui.openUrl | ggbro.openUrl({ url }) · panel link / button openUrl | Open https:// or spotify: via the OS / browser |
ui.webview | Host Plugin App | Official apps may open an allowlisted in-app webview (e.g. Spotify Web). Sensitive — confirm on enable. |
storage | ggbro.storage.get/set/clear | Durable local KV under userData/plugin-data/<id>/ (256 KB cap; survives pack reinstall) |
ui.sound | ggbro.playSound({ id }) | Play a built-in sound id (no arbitrary files) |
ui.panel | ggbro.ui.registerPanel | Show schema controls in Settings → Plugins. Official host apps also use this permission for Apps windows. |
messages.read_display | ggbro.onMessages(cb) | Redacted visible messages for the active view |
messages.decorate | ggbro.decorateMessages([...]) | Local-only prefix/suffix/highlights (never sent to the server) |
net.fetch | ggbro.fetch({ url, … }) | Host-proxied HTTPS to user allowlisted hosts only |
Your main script runs inside the sandbox with a frozen window.ggbro object. Talk to the host only through these methods (all async methods return Promises).
ggbro.getAppInfo()Requires app.info.
{ "version": "2.8.x", "platform": "desktop" }
ggbro.getTheme()Requires theme.read. Returns the live Appearance + chrome snapshot (string values).
{
"accent": "#5865f2",
"fontSize": "14px",
"accentPreset": "blurple",
"bgPreset": "classic",
"radius": "soft",
"density": "compact",
"buttonStyle": "soft",
"switchStyle": "filled",
"chatWidth": "full",
"msgAlign": "start",
"radiusBtn": "10px",
"chatMaxWidth": "720px",
"chromePad": "10px"
}
ggbro.setTheme(patch)Requires theme.write. Applies an allowlisted partial of Appearance settings (same store as User Settings → Appearance). Unknown keys are ignored; enums are clamped. Rate limit: 12 writes / 30s.
await ggbro.setTheme({
accentPreset: 'cyan',
radius: 'round',
buttonStyle: 'outline',
switchStyle: 'soft',
density: 'comfy',
chatWidth: 'wide',
msgAlign: 'center',
fontSize: 15
});
Allowed patch fields:
fontSize — 12–20accentPreset — blurple | green | pink | orange | cyan | customaccentCustom / bgCustom — hex colorsbgPreset — classic | midnight | slate | warm | customradius — sharp | soft | rounddensity — dense | compact | cozy | comfybuttonStyle — filled | outline | soft (buttons only)switchStyle — filled | outline | soft (toggles; independent of buttonStyle)chatWidth — full | wide | normal | narrowmsgAlign — start | centerggbro.onThemeChange(callback)Requires theme.read for events to be delivered. Returns an unsubscribe function. Max 8 listeners per plugin.
Safe surface. Plugins may restyle allowlisted chrome — not the whole React tree.
setTheme / Appearance — accent, background, radius, density, button style, switch style, chat column width/align, font size[data-gg-chrome="messages|composer|nav|sidebar"], .gg-chrome-*, and .gg-btn-* with allowlisted properties (padding, radius, colors, max-width, …):root / html / body (e.g. --gg-radius-btn, --gg-chat-max-width)Not allowed: arbitrary DOM, hiding UI with *, url() backgrounds, overlays, reading tokens, or sending messages as the user.
ggbro.storageRequires storage. Keys: [a-zA-Z0-9._-]{1,64}. Values are strings (max 16 KB each). Total JSON file max 256 KB.
Durable: data lives under userData/plugin-data/<id>/ — reinstalling or updating the pack does not wipe storage.
await ggbro.storage.set('count', '1');
const { value } = await ggbro.storage.get('count'); // string | null
await ggbro.storage.clear('count'); // or clear() for all
ggbro.playSound({ id })Requires ui.sound. Allowlisted ids only: notification, voiceJoin, voiceLeave, mute, unmute, deafen, undeafen, friendRequest, boing, airBurst.
ggbro.getContext()Requires app.context. Returns the current navigation snapshot:
{
view: "channel" | "dm" | "group-dm" | "friends" | "unknown",
serverId: string | null,
channelId: string | null,
dmUserId: string | null,
groupId: string | null
}
Also delivered as ggbro.onEvent with event === "context" whenever the user navigates. IDs are sanitized; no message content is included.
ggbro.ui.registerPanel({ id, title, schema })Requires ui.panel. The desktop host draws the panel in Settings → Plugins
(summary under Active plugin controls, and again under that pack’s card as Controls).
Call again with the same panel id to live-update labels/values. Listen with
ggbro.onEvent((event, payload) => …) when event === "panelAction".
Panel payload:
{
panelId: string, // your panel id, e.g. "main"
fieldId: string, // the field's id — NOT the label
value?: unknown // string | boolean | number | true (buttons)
}
id required; sanitized to [a-zA-Z0-9._-]{1,64}id with the same charset — label alone is not enoughselect.options: array of { value, label } objects (value required). String arrays are ignored.link.href / button openUrl: https:// only (plus spotify: for openUrl). Both require the ui.openUrl permission or the click is ignored.Field types (each row also needs id):
| type | Key props | panelAction value |
|---|---|---|
section | title | — |
text | label, value | — |
notice | text, tone? info|success|warning|error | — |
input | label, value?, placeholder?, password? | string (Apply / Enter) |
textarea | label, value?, rows? | string (Apply) |
toggle | label, value? | boolean |
select | label, options: [{value,label}], value? | string |
slider | label, min?, max?, step?, value? | number (on release) |
color | label, value? (#rgb / #rrggbb) | hex string |
progress | label, value?, max? | display only |
button | label, openUrl? (needs ui.openUrl) | true |
link | label, href (https; needs ui.openUrl) | — |
See the full walkthrough under Build your first plugin, or peek at hello/plugin.js for a reference panel.
ggbro.onMessages(callback) / ggbro.decorateMessages(patches)Requires messages.read_display / messages.decorate. Payloads are redacted (id, authorName, content, isOwn). Patches: prefixText, suffixText, highlightRanges. Display-only — never written back to the API.
ggbro.fetch({ url, method?, headers?, body? })Requires net.fetch. The worker has no network; the host proxies HTTPS GET/POST/HEAD to hosts you allowlist in Settings → Plugins. No GGBRO session cookies or Authorization. ggbro.app / localhost blocked.
ggbro.openUrl({ url })Requires ui.openUrl. Opens an external link through the host (OS / browser). Allowed: https://… and spotify: URIs. Rate limit: 6 / 10s.
ggbro.toast({ message, type? })Requires ui.toast.
message — string, max 200 chars (control chars stripped)type — optional: success | error | warning | info (default info)[Plugin Name]ggbro.navigate(target)Requires ui.navigate. See Navigate payloads. Rate limit: 8 navigations / 30 seconds per plugin. IDs/codes must match [a-zA-Z0-9._-]{1,128}.
ggbro.log(...args)Always available. Writes to the desktop DevTools console as [plugin:<id>]. Max ~500 chars. No permission required.
// plugin.js
(async function () {
const info = await ggbro.getAppInfo();
ggbro.log('running on', info.platform, info.version);
await ggbro.toast({ message: 'Plugin loaded', type: 'success' });
const theme = await ggbro.getTheme();
ggbro.log('accent', theme.accent, theme.radius, theme.buttonStyle, theme.switchStyle);
// Optional — requires theme.write (sensitive confirm on enable)
// await ggbro.setTheme({ radius: 'round', buttonStyle: 'outline', switchStyle: 'soft', chatWidth: 'wide' });
const unsub = ggbro.onThemeChange(function (t) {
ggbro.log('theme changed', t.accent, t.density);
});
// Optional — requires ui.navigate
// await ggbro.navigate({ type: 'channel', channelId: '...' });
// unsub(); // when you no longer need theme events
})();
Only these shapes are accepted. Unknown fields are dropped; invalid payloads reject.
| type | Required fields | Effect |
|---|---|---|
channel | channelId | Jump to a channel (if you have access) |
message | channelId, messageId | Open channel and jump to message |
dm | targetUserId | Open DM with user |
dm-group | groupId | Open group DM |
invite | code | Open invite flow |
playlist | code | Open YouTube playlist share code |
await ggbro.navigate({ type: 'channel', channelId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' });
await ggbro.navigate({ type: 'message', channelId: '...', messageId: '...' });
await ggbro.navigate({ type: 'dm', targetUserId: '...' });
await ggbro.navigate({ type: 'invite', code: 'AbCdEf' });
await ggbro.navigate({ type: 'playlist', code: 'ABC123' });
Optional styles.css is injected into the host document after a strict sanitizer.
:root, html, body, [data-gg-chrome="…"], .gg-chrome-*, .gg-btn-*, .gg-pill--name: value)url(), @import, arbitrary selectors, overlays, and transforms are stripped/* Tokens */
:root {
--gg-radius-btn: 14px;
--gg-chat-max-width: 840px;
}
/* Safe chrome hooks */
[data-gg-chrome="composer"] {
padding-top: 12px;
padding-bottom: 16px;
}
.gg-btn-primary {
border-radius: 999px;
}
/* Stripped — will not affect the UI */
* { display: none }
.sidebar { position: fixed; background: url(https://evil.example) }
Prefer ggbro.setTheme() for Appearance presets; use CSS for fine-tuning chrome hooks only.
| Limit | Value |
|---|---|
| Installed plugins | 50 |
| Enabled plugins (running) | 25 |
| Pack size | 2 MB |
| Per-file size (main/styles) | 512 KB |
| Zip entries | 32 |
| Toast rate | 5 / 10s per plugin |
| Navigate rate | 8 / 30s per plugin |
| Theme write rate limit | 12 / 30s per plugin |
| Theme listeners | 8 per plugin |
| Panels per plugin | 8 |
| Fields per panel schema | 48 |
| Host request timeout | 10 seconds |
/developers/examples/plugins/*.ggbro-plugin) — no arbitrary URL/path install from the renderer
MessageChannel API with permission checks, payload allowlists, and rate limitspluginId in payloads is ignored for authelectronAPI, filesystem (except what you put in the pack), tokens, messages, or voiceggbro.fetch only when net.fetch is granted + allowlistedui.toast / ui.navigate lets it notify you or request in-app navigation.
Editing files under the plugins folder (Open Folder) and pressing Refresh reloads that code — only edit packs you trust.
index.json; each row has Install & Enable or Updateversion is newer than installedregisterPanelui.panel (schema-panel tile + Open controls in Apps)userData/plugins/ in the OS file managerplugin-data/<id>/ storageversion in the pack manifest.json and in
index.json, then republish the .ggbro-plugin file. Users click
Check for Updates (or open Settings → Plugins) and press the green Update
button on that pack — no need to re-download manually.
version to your installed pack.
Tip for authors: always bump the semver-style version string (e.g. 3.0.0 →
3.1.0) in both the pack manifest and the catalog entry, or clients will not show Update.
| Symptom | Check |
|---|---|
| No tile in the Apps (9-dot) grid | Pack must be enabled and declare ui.panel. Update to the latest desktop app. Press Refresh in Settings → Plugins. |
| No “Open controls in Apps” on the card | Needs ui.panel in the manifest. Official host apps show “Open … in Apps” instead. |
| Plugin enabled but empty Controls | Update to 2.8.168+ (packs now run in a Web Worker — older iframe hosts timed out). Press Refresh. Call registerPanel before slow storage. Every field needs id. Check the red Runtime: line / DevTools [plugin:…]. |
| Installed from Documents but nothing runs | Use a dedicated folder with only manifest.json + plugin.js (e.g. Documents\my-calculator\). Reinstall from that folder, then Refresh. |
| Buttons / inputs do nothing | Listen for payload.fieldId, not payload.label. Ensure ui.panel is in the manifest. |
| Select shows no options | Use options: [{ value, label }, …] — string arrays are ignored. |
| Install fails: invalid id | Use reverse-domain like com.example.hello |
| Install fails: missing main | main basename must exist next to manifest.json (zip root / folder root) |
| Plugin listed with error | Folder name must equal id; fix or reinstall |
| API rejects "Missing permission" | Add the permission to manifest.json and reinstall / refresh |
| Toast rate limit | Wait 10s; reduce toast spam |
| CSS does nothing | Only root custom properties and allowlisted chrome/button selectors survive sanitization |
| No toast / no logs | Ensure plugin is enabled; open DevTools Console for [plugin:…] lines |
| Edited files not applied | Click Refresh in Settings → Plugins |
| Can't enable | Disable another plugin (25 enabled max) |
| Looking for UI on web / mobile | Plugins are desktop-only |
ggbro.fetch + allowlist onlyAlso building bots? See the Bot API documentation and free examples in the Developer Portal.