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.

On this page

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

PieceModule / FileRole
MirrorModule:mirror-api · core/MirrorModule.ktBase class every module extends
ModuleConfig:mirror-api · config/ModuleConfig.ktOne config entry + typed getters
ModuleFactory:mirror-api · core/ModuleFactory.ktSPI for external JAR modules
Region / MirrorColors:mirror-apiScreen positions / brightness palette
ModuleRegistry:composeApp · core/ModuleRegistry.ktMaps module name → class; register() for plugins
discoverPluginFactories:composeApp · core/PluginLoader.ktLoads JARs from plugins/ via ServiceLoader
MirrorEngine:composeApp · core/MirrorEngine.ktBoots modules, drives refresh, notification bus
ConfigLoader:composeApp · config/ConfigLoader.ktThe bundled default config.js equivalent
MirrorScreen:composeApp · ui/MirrorScreen.ktLays 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:

  1. External JAR plugin (recommended) — a standalone Gradle subproject built to a JAR in plugins/, discovered at runtime.
  2. Built-in — a class compiled into :composeApp and added to ModuleRegistry.builtins. Ships inside the app JAR; builtins is empty by default.

For an external plugin, at startup:

  1. It compiles against :mirror-api as compileOnly, so the JAR holds only its own classes — Compose and the API are provided by the host at runtime via the parent classloader.
  2. It implements ModuleFactory and declares it in META-INF/services/org.speculum.core.ModuleFactory.
  3. PluginLoader scans plugins/*.jar, loads each in a URLClassLoader (parent = app), and finds factories via the JDK ServiceLoader (reflection).
  4. Each factory is register()ed and its defaultConfig() is appended automatically (unless an entry already names that module), ordered by ModuleFactory.order so stacking within a region is deterministic.
Source vs. runtime folders: subprojects live under 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:

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.

API Reference → Developer Guide ↗