Architecture
How Speculum boots
Global config + discovered plugins feed a single engine that starts each module, drives its refresh loop, fans out notifications, and renders it into a screen region.
Boot flow
ConfigLoader (global options) plugins/*.jar
│ │ ServiceLoader (reflection)
└──────────────┬───────────────────┘
▼
MirrorEngine.boot()
├─ ModuleRegistry.create() per entry
├─ module.start(scope)
├─ per-module refresh loop (interval)
└─ notification bus (broadcast)
│
MirrorScreen renders each module.Content() into its Region
Each ModuleFactory contributes its own defaultConfig(), so modules appear automatically once their JAR is in plugins/ — no config edit needed. A factory also declares its options through settingsSchema(), which the config-server serves on /api/modules: the admin console renders controls from that schema rather than knowing any module by name.
Module ↔ component map
| Piece | Module / File | Role |
|---|---|---|
MirrorModule | :mirror-api · core/MirrorModule.kt | Base class every module extends |
ModuleConfig | :mirror-api · config/ModuleConfig.kt | One config entry + typed getters |
ModuleFactory | :mirror-api · core/ModuleFactory.kt | SPI for external JAR modules |
Region / MirrorColors | :mirror-api | Screen positions / brightness palette |
ModuleRegistry | :composeApp · core/ModuleRegistry.kt | Maps module name → class; register() for plugins |
discoverPluginFactories | :composeApp · core/PluginLoader.kt | Loads JARs from plugins/ via ServiceLoader |
MirrorEngine | :composeApp · core/MirrorEngine.kt | Boots modules, drives refresh, notification bus |
ConfigLoader | :composeApp · config/ConfigLoader.kt | The bundled default config.js equivalent |
MirrorScreen | :composeApp · ui/MirrorScreen.kt | Lays modules out by region |
The shared API (MirrorModule, ModuleConfig, Region, MirrorColors, ModuleFactory) lives in :mirror-api so both the app and external plugins compile against it.
Plugin discovery
All default modules ship as external JAR plugins. There are two ways to add a module:
- External JAR plugin (recommended) — a standalone Gradle subproject built to a JAR in
plugins/, discovered at runtime. - Built-in — a class compiled into
:composeAppand added toModuleRegistry.builtins. Ships inside the app JAR;builtinsis empty by default.
For an external plugin, at startup:
- It compiles against
:mirror-apiascompileOnly, so the JAR holds only its own classes — Compose and the API are provided by the host at runtime via the parent classloader. - It implements
ModuleFactoryand declares it inMETA-INF/services/org.speculum.core.ModuleFactory. PluginLoaderscansplugins/*.jar, loads each in aURLClassLoader(parent = app), and finds factories via the JDKServiceLoader(reflection).- Each factory is
register()ed and itsdefaultConfig()is appended automatically (unless an entry already names that module), ordered byModuleFactory.orderso stacking within a region is deterministic.
modules/; their built JARs are deployed into plugins/ (the folder the app scans). They're kept separate so Gradle doesn't see build outputs landing inside the source tree.Config resolution
The web admin and the app share one config file. Resolution rule:
- A non-empty
moduleslist is authoritative — exactly those entries, in that order. - An empty list means "use every installed plugin's default placement" — the out-of-box mirror.
So the admin's first load shows all modules; saving captures an explicit, editable set.
Regions & layout
position maps (case-insensitively) to a Region. MirrorScreen anchors top regions to the top, centers the *_third bands in the remaining space (so they never overlap on resize), and anchors bottom regions + bottom_bar to the bottom. Multiple modules in the same region stack vertically.
top_left top_center top_right
upper_third middle_center lower_third
bottom_left bottom_center bottom_right
bottom_bar fullscreen_above fullscreen_below
Hot reload
The config server writes the same config.json the desktop app reads. The running mirror watches that file and reboots its engine in-process on save — no restart. On a packaged install both run in one process: the fullscreen mirror plus the admin on :8080.