Plugins
A plugin adds tools, games and radios to pokeldn without changing it. It is a folder of Python files with a plugin.toml, shipped as one .pokeplugin file that players add on the app’s Plugins page. The project does not distribute, review or support third-party plugins.
Code: pokeldn/plugins.py (the loader and the API), pokeldn/app/plugin_store.py (packages), pokeldn/ldn/radios.py (the radio lookup), gui/views/plugins.py (the page).
Two examples are a base to start from: examples/plugins/hello is the smallest plugin, one tool in two files; examples/plugins/test_kit uses every part of the API.
Installing a plugin
- Open Plugins at the bottom of the side menu.
- Drop the
.pokepluginfile on the page, or press Add a plugin and choose it. - Read what the plugin is and the risks, tick the acknowledgement, and press Install.
The plugin’s tools appear on the Games page at once, marked with a plug and “From the plugin …”. Each plugin’s card has a switch to turn it off and on, and a menu to open its tools, update it from a newer file, show its folder, visit its page and remove it. Every install and update shows the risk screen again. A plugin copied into the plugins folder by hand shows “Added by hand” and asks for the same acceptance the first time it is turned on.
A plugin that closes or freezes the app while it starts is turned off at the next start, and the app names it. Removing a plugin asks whether to delete what it saved as well.
A plugin runs as Python inside pokeldn, with the same access to files, keys, the network and the board. Nothing in a package runs before it is installed and turned on.
Guardrails
Two checks stop the obvious harm. Neither is a sandbox: a plugin written to get around them can.
The install screen reads the package’s Python files and lists what they do: use the network, start other programs, delete files, call native code (ctypes, cffi), run code built at run time (exec, eval, compile, marshal), name prod.keys, or ship compiled files the reading cannot see into. An empty list means none was found, not that the plugin is safe.
While a plugin’s code is on the call stack, in the app and in every run, an audit hook (pokeldn/plugin_guard.py) refuses with PermissionError:
| refused | allowed |
|---|---|
| deleting, emptying, moving or overwriting a file outside the plugin’s folders | the same inside its folder, its data folder, the session folder, the logs folder and the temporary folder |
starting rm, del, rd, Remove-Item, shred, dd, format, mkfs, diskutil, diskpart, sudo, su and similar, directly or inside a shell command, or any command with -delete or --no-preserve-root | starting any other program |
| creating a new file anywhere |
Code with no plugin on its call stack, pokeldn’s own included, is never refused; pokeldn code a plugin calls is. A plugin that needs a refused action asks the player to do it.
Making a plugin
The folder
my-plugin/
plugin.toml what the plugin is
plugin.py register(api): what it adds
hello.py a tool's script
icon.png optional, shown on the Plugins page and the Games page
mylib_common.py optional, any module the scripts share
plugin.toml
id = "my-plugin" # required: lowercase letters, digits, dots, dashes, underscores
name = "My plugin" # shown in the app
version = "1.0.0" # compared on update: 1.2.0 updates 1.0.0
author = "Your name"
description = "One or two sentences shown on the install screen and the plugin's card."
homepage = "https://example.org/my-plugin" # optional, opened from the plugin's menu
updates = "https://github.com/you/my-plugin" # optional, where newer versions are published (Updates)
api = "1.1" # required: the plugin API it needs (see Versions); 1 means 1.0
icon = "icon.png" # optional; a square PNG, 96 x 96 looks sharp
module = "plugin" # optional; the module holding register()
plugin.py
register(api) runs when the plugin is turned on, in the app and in every run the app starts.
from pokeldn.plugins import Field, Game, Tool
def register(api):
api.add_tool("frlg", Tool(
"my-hello", # a key no other tool uses
"Hello", # the name on the Games page
"hello.py", # the script, relative to the plugin folder
"Says hello.", # the line under the tool's name
("Nothing to do on the console.", "Press Start."), # the console steps the app lists
fields=(Field("--name", "Your name", default="Red"),),
fixed=("--trainer", "{ot}"), # always passed; {ot} is the trainer name in Settings
))
A tool’s script
The app imports the script to read its options for the Advanced tab, then runs it as its own child process in the session folder. The script defines build_parser() and does its work under if __name__ == "__main__"; work at the top level runs on import and breaks the Games page.
import argparse
def build_parser():
parser = argparse.ArgumentParser()
parser.add_argument("--name")
parser.add_argument("--trainer")
return parser
if __name__ == "__main__":
args = build_parser().parse_args()
print(f"Hello {args.name}, from {args.trainer}'s pokeldn.")
| the script | the app |
|---|---|
| prints a line | shows it in the session’s output |
prints [done] trade N complete (pokeldn.ldn.show_done()) | ticks one queued trade off |
| exits with code 0 | ends the session as done |
| exits with another code or raises | shows the session as failed, with the traceback in the output |
receives KeyboardInterrupt | the player pressed Stop; leave cleanly within 15 s or the process is ended |
Give shared modules a name no other package uses (mylib_common, not utils): the plugin’s folder is added to Python’s search path.
What a plugin can import
The released app carries its own Python 3.13 and no pip. A plugin can import:
| what | where it comes from |
|---|---|
| the standard library | bundled, except multiprocessing, tkinter, curses, venv, ensurepip, profile, cProfile, pstats, trace, compileall, modulefinder, pyclbr, tabnanny and the modules of another platform (scripts/pack_app.py, PLUGIN_STDLIB) |
trio, pycryptodome (Crypto), zstandard, pyserial (serial), websockets, certifi, bleak, unicorn, flet | pokeldn’s own dependencies, at the versions in requirements.txt |
ldn and pokeldn | pokeldn itself; only the API table below is stable |
| any pure-Python module | shipped in the plugin’s folder |
A package with compiled code (numpy, Pillow) works only from source runs where it is installed. Test a plugin in a released app before publishing it: the Plugin Test Kit’s self-test runs the same way there.
Fields
A Field is one input on the tool’s Basic tab; the app passes its value after its flag.
kind | the input | passed as |
|---|---|---|
text (default) | a text box | --flag VALUE |
number | a number box | --flag VALUE |
choice | a dropdown of choices=((value, label), ...) | --flag VALUE |
switch | an on/off switch | --flag when on |
file | a file chooser, exts=("bin",) | --flag PATH |
What a plugin keeps
An update replaces the plugin’s folder. Settings, caches and anything else meant to last go in api.data from register, or pokeldn.plugins.data_folder("my-plugin") from a tool’s script: the data folder’s plugin-data/<id>, kept across updates and deleted on removal only if the player asks.
help= puts a line under the input, default= fills it, and hidden=True moves it to the Advanced tab. The fields the built-in tools use, and the rest of Field, are in pokeldn/app/catalog.py. A tool’s fixed arguments may carry {ot}, {tid}, {sid} and {language} (the trainer in Settings), {received} (the Received folder) and {stamp} (the run’s start time).
A game of its own
api.add_game(Game("my-game", "My game", "Mine", "", (tool_one, tool_two)), icon="icon.png")
Game(key, name, short, doc, tools); the game’s section lists its tools in order, under the built-in games. api.add_tool("my-game", tool) adds more to it.
A radio
A radio carries the wireless messages in place of the ESP32 board. api.add_radio(scheme, install, label) lists it on the Plugins page under Radio; a run started with that radio calls install(argument, log) before its first wireless call.
import contextlib
from ldn import wlan
def install(argument, log):
@contextlib.asynccontextmanager
async def factory():
yield MyFactory() # the interface of ldn.wlan.Factory
wlan.set_factory(factory)
def register(api):
api.add_radio("myradio", install, "My radio")
pokeldn/ldn/esp32_wlan.py is the board’s factory and the reference for one with no kernel interface. The app runs a plugin radio as POKELDN_RADIO=myradio: with an empty argument; from the command line the argument is what follows the colon.
Packing and testing
python scripts/pack_plugin.py my-plugin --key ~/my-plugin-signing.pem
writes my-plugin-1.0.0.pokeplugin, signed, and checks it the way the app does before installing. Any zip works if plugin.toml sits at its top or inside one top folder, with no path leaving that folder and no symbolic link.
While writing, skip the package: name the folder in POKELDN_PLUGINS and the plugin is on in that process and the runs it starts, whatever the Plugins page says.
POKELDN_PLUGINS=~/my-plugin python -m pokeldn --plugins
POKELDN_PLUGINS=~/my-plugin python -m pokeldn my-hello -- --name Blue
POKELDN_PLUGINS=~/my-plugin python gui/main.py
--plugins lists every plugin found and why one failed to load. A plugin that raises in register, targets another API version or takes a key in use adds nothing; its card shows the error and the other plugins still load. A plugin can also ship as a Python package with an entry point in the pokeldn.plugins group naming the module that holds register.
Signing
--key FILE signs the package with the author’s Ed25519 key, made in FILE the first time, and prints the key’s fingerprint (ABCD-EFGH-IJKL-MNOP). The package carries plugin.sig: the public key and a signature over the plugin’s id, its version and the SHA-256 of every other file.
| the app sees | it shows |
|---|---|
| a signature that does not match the files | refuses the file: it was changed after its author signed it |
| a signed plugin it has not seen before | the fingerprint, and remembers the key for that plugin id |
| an update signed with the remembered key | “Signed by the same author” |
| an update signed with another key, or unsigned where the earlier copy was signed | a red warning and a second acknowledgement before Install works |
| an unsigned plugin never seen signed | “Not signed” |
Keep the key file private and backed up: a lost key makes every later version look like someone else’s, and anyone holding it can sign as the author. Publish the fingerprint on the plugin’s page, so players can compare it with the one the install screen shows. The first install cannot prove who the author is; signing proves that later versions come from the same one.
Updates
updates in plugin.toml names where newer versions are published:
updates | the app reads |
|---|---|
https://github.com/OWNER/REPO | the latest release: its tag (v1.3.0 or 1.3.0) and its first .pokeplugin asset |
| any other HTTPS address | a JSON file {"version": "1.3.0", "url": "https://.../my-plugin-1.3.0.pokeplugin", "page": "..."} |
With Settings, Updates on, the app asks each installed plugin’s address at start and when the Plugins page’s refresh is pressed. A newer version turns the Plugins menu entry green and puts Update to 1.3.0 on the plugin’s card; pressing it downloads the file and opens the install screen with its signature check. Nothing installs without the player’s acceptance. HTTP is accepted for 127.0.0.1 and localhost only, to test a release locally.
The Plugin Test Kit
examples/plugins/test_kit, packed with scripts/pack_plugin.py, adds:
| where | tool | what it does |
|---|---|---|
| Plugin Test Kit | Self-test | checks the API version, its own loading, its imports, every field kind, the trainer token, the session folder, its data folder (a run counter that survives updates) and its radio; prints PASS or FAIL for each |
| Plugin Test Kit | Simulated session | prints a trade’s steps and a [done] line per trade; Stop ends it cleanly |
| Plugin Test Kit | Fail on purpose | raises, or exits with code 3 |
| FireRed & LeafGreen | Hello from a plugin | a plugin tool among the built-in ones |
| Radio | Test Kit radio | installs and carries nothing; a session started with it says so |
Versions
The plugin API has a major and a level, written 1.1. A plugin’s api names the lowest version it needs; the app shows its own on the Plugins page and in python -m pokeldn --plugins.
| change to the API | the app |
|---|---|
| an addition | raises the level; a plugin needing a higher level is refused with “Update pokeldn” |
| a removal or a change of meaning | raises the major |
| an old major still served | DEPRECATED in pokeldn/plugins.py names the release that drops it; the plugin loads, and its card and install screen say so |
| an old major dropped | API_OLDEST passes it; the plugin is refused with “Ask its author for a newer version” |
A plugin’s own version is only compared with its installed copy, to show an update, a reinstall or a downgrade.
| API | added |
|---|---|
| 1.0 | add_tool, add_game, add_radio, folder, version |
| 1.1 | api.data, api.level, pokeldn.plugins.data_folder(id) |
The API, version 1
| name | what it is |
|---|---|
api.add_tool(game, tool) | adds a Tool under the game whose key is game: frlg, lgpe, swsh, bdsp, pla, sv, za, or one the plugin added |
api.add_game(game, icon="") | adds a game section with its tools |
api.add_radio(scheme, install, label="") | adds a radio |
api.folder | the plugin’s folder (a Path), or None for a Python package |
api.version, api.level | the major and level the app runs, 1 and 1 |
api.data | a folder kept across updates, created on first use (1.1) |
pokeldn.plugins.data_folder(id) | the same folder from a tool’s script (1.1) |
Tool, Field, Game | the app’s catalog types, imported from pokeldn.plugins |
pokeldn.ldn.show_done() | prints the line that ticks a trade off |
This table is the supported surface. Anything else a plugin imports from pokeldn can change in any release.