-
Notifications
You must be signed in to change notification settings - Fork 1
Plugin‐System
Plugins add their own line, their own values and their own settings card to the app. They are plain Python — no build step, no packaging, no compilation.
The full technical reference lives in the repository at
docs/PLUGIN_API.md. This page is the practical version.
Current API version: 2 (core.plugins.PLUGIN_API_VERSION)
~/.config/OSC-DreamChatbox/plugins/<id>/
├── plugin.json the manifest
├── main.py the code
├── logo.png optional, shown in the store
└── configs/ created by the app — never ship this
On Windows: %APPDATA%\OSC-DreamChatbox\plugins\<id>\
configs/ is the plugin's own writable folder. It survives an update
from a newer .zip, which is exactly why a plugin must not ship
one: the installer keeps the existing folder only when the archive has
none.
- On start, the app scans the plugins folder.
- Each
plugin.jsonis read.idmust match the folder name and the[a-z0-9_-]rule — it becomes a Python module name. - If the manifest declares an
apiversion higher than the app speaks, the row is greyed out with the reason and nothing is imported. - The file named in
mainis imported andsetup(api)is called. - On every chatbox frame the app calls the hooks it needs.
A hook that raises is caught, logged, and shown in the plugin's info popup. It can never take the chatbox down.
| Contribution | Hook | Appears as |
|---|---|---|
| One line of text | get_text() |
{<id>} |
| Several lines | get_lines() |
appended to the payload |
| Named values | get_values() |
{<id>_<key>} |
| Its own UI | build_widget() |
embedded in the plugin card |
| A filter on the final text | on_text() |
last chance before sending |
Plugin lines are anchored: each plugin picks which app its lines sit
above (status, media, hardware or aio).
mkdir -p ~/.config/OSC-DreamChatbox/plugins/hello
cd ~/.config/OSC-DreamChatbox/plugins/helloplugin.json:
{
"name": "Hello",
"id": "hello",
"version": "1.0.0",
"author": "you",
"main": "main.py",
"summary": "One line for the plugin store",
"description": "A longer explanation shown in the info popup.",
"enabled": false,
"api": 2,
"is_linux": true,
"is_windows": true,
"template": "👋 {hello_who}",
"placeholders": { "who": "who is being greeted" },
"settings": [
{ "key": "who", "type": "text", "label": "Greet", "default": "world" },
{ "key": "loud", "type": "bool", "label": "Shout", "default": false }
]
}main.py:
"""Hello - the smallest useful plugin."""
_api = None
def setup(api):
"""Called once after import. Keep the api object."""
global _api
_api = api
_api.log("hello plugin ready")
def teardown():
"""Called on disable and on exit. Stop threads here."""
pass
def get_values():
"""Values for {hello_<key>} placeholders.
Return None - not "" - for anything unknown. A None placeholder is
dropped together with its separators.
"""
who = _api.get("who", "world")
if _api.get("loud", False):
who = who.upper()
return {"who": who or None}
def on_settings(opts):
"""The user changed a setting."""
_api.log(f"settings changed: {opts}")Restart the app (or use the reload button), open the Plugins page and switch the row on. The settings from the manifest appear as a card.
👋 {hello_who}
in an All-in-one string, or as a Placeholder block on the Advanced canvas.
All optional. Implement what you need.
| Hook | When | Purpose |
|---|---|---|
setup(api) |
after import | store the api object |
teardown() |
on disable and exit | stop threads, close handles |
get_text() |
per frame |
str → {<id>}
|
get_lines() |
per frame |
list[str] appended to the payload |
get_values() |
per frame |
dict → {<id>_<key>}
|
on_tick() |
per frame, first | cheap polling without a thread |
on_settings(opts) |
on change | react to a setting |
on_text(text) |
before sending | last-chance filter on the final text |
on_event(name, data) |
as announced |
app.shutdown, more to come |
build_widget(parent) |
on card build | your own QWidget
|
-
on_tick()runs on the GUI thread. Anything that can block — network, subprocess, a file on a slow mount — belongs in a thread. - Build widgets only in
build_widget(), and never touch one from a worker thread. A Qt widget touched from another thread is a segfault, not an exception. Poll from aQTimeron the GUI thread instead. - Import Qt lazily, inside
build_widget()— not at module level.
| Member | Since | What |
|---|---|---|
api.log(msg) |
1 | into the debug console, prefixed with your id |
api.get(key, default) |
1 | one of your declared settings |
api.settings |
1 | the live dict |
api.plugin_dir |
1 | your folder |
api.data_dir |
1 |
configs/, writable, survives updates |
api.host |
1 | the MainWindow, or None headless |
api.app_name / api.app_version
|
1 | |
api.api_version |
2 | what the app speaks |
api.supports(feature) |
2 | feature detection |
api.set(key, value) |
2 | write one of your settings, persisted |
api.refresh() |
2 | ask for a fresh chatbox render |
api.data_path(*parts) |
2 | a path inside data_dir, parents created |
Check the feature, not the version:
def setup(api):
if api.supports("api.set"):
api.set("binary", found)
else:
_session_only = foundDeclare "api": 2 only when the plugin genuinely cannot work
without it. An app that speaks less refuses to import and greys the row
out — correct for a hard requirement, needlessly hostile for something
you could have feature-detected.
Types: text bool int slider choice group path emoji
action label.
{"key": "mode", "type": "choice", "label": "Data source",
"default": "keyless",
"choices": [{"value": "keyless", "label": "Keyless"},
{"value": "api", "label": "Official API"}]}
{"key": "token", "type": "text", "label": "API key", "secret": true,
"depends": "mode", "depends_value": "api"}
{"key": "twitch", "type": "group", "label": "Twitch", "expanded": false,
"items": [ "...more settings..." ]}-
dependshides a row while the named setting is falsy;depends_valuecompares against a value or a list instead. -
secretmasks the input. It is shoulder-surfing protection, not encryption — the value sits inconfig.jsonas plain text. Say so in thehint. - Keys are unique across the whole schema, groups included.
- Groups nest two levels deep.
Nothing is silently dropped, in either direction:
| Written by a newer app | What an older app does |
|---|---|
| a settings row of an unknown type | keeps the row and its default; api.get() works; the UI shows a disabled 🔒 line |
| extra keys on a settings row | kept in item["extra"]
|
extra keys in plugin.json
|
kept in Plugin.extra
|
extra keys in configs/config.json
|
kept and written back untouched |
So a plugin may ship options this app cannot edit yet and still rely on their values today, and a downgrade does not delete settings.
The Plugins page has a store that syncs a catalogue from GitHub. Install, update and remove happen there.
cd ~/.config/OSC-DreamChatbox/plugins/
unzip ~/Downloads/myplugin.zipThe folder name must match the id in the manifest.
- Switch Debug on under Options — plugin
api.log()output appears in the console. - Use the reload button on the Plugins page rather than restarting.
- Watch the preview column: a value returning
""instead ofNoneshows up as a stray separator.
-
teardown()stops every thread and child process you started - no Qt import at module level, only inside
build_widget() - no widget touched from a worker thread
-
get_values()returnsNone, not"", for unknown values - no
configs/folder in the.zip -
"api"declared only for hard requirements - the plugin still loads with every setting at its default
OSC-DreamChatbox · GPL-3.0-or-later · github.com/yakuda-stack/OSC-DreamChatbox