Module Anatomy

Every file, and what the host does with it

A Speculum plugin is small enough to read in one sitting. This walks the whole project in the order the host touches it.

On this page

Project layout

src/main/kotlin/org/speculum/modules/example/
  ExampleModule.kt          the module: lifecycle + Compose UI
  ExampleModuleFactory.kt   the SPI entry point
src/main/resources/META-INF/services/
  org.speculum.core.ModuleFactory   one line: the factory's FQN
src/test/kotlin/…              unit + Compose UI tests
build.gradle.kts            compileOnly deps, deployToMirror task
gradle/libs.versions.toml   mirror-api and toolchain versions

The ServiceLoader manifest

One line, and the single thing that makes the JAR discoverable:

# src/main/resources/META-INF/services/org.speculum.core.ModuleFactory
org.speculum.modules.example.ExampleModuleFactory
Easy to get wrong: the file name must be the fully-qualified interface name, and the contents the fully-qualified implementation name. A typo in either produces no error — the module is silently never loaded.

ModuleFactory

The SPI the host instantiates. It names the module, builds instances, and supplies the defaults that let the module appear without anyone editing config:

class ExampleModuleFactory : ModuleFactory {
    override val name: String = "example"

    override fun create(config: ModuleConfig): MirrorModule = ExampleModule(config)

    override fun defaultConfig(): ModuleConfig =
        ModuleConfig(
            module = "example",
            position = "top_center",
            refreshIntervalMs = 3000,
            config = mapOf("greeting" to "Loaded from JAR!", "tickStep" to "2"),
        )
}
MemberPurpose
nameThe module key in config that this factory answers to.
create(config)Builds one instance per config entry. Called once at boot.
defaultConfig()Appended automatically when no entry names this module. Return null to require an explicit entry.
settingsSchema()Declares the options for the admin console. See Settings Schema.
orderStacking order inside a region; lower sits higher. JARs load in no particular order, so this keeps layout deterministic.

MirrorModule lifecycle

Read your options once in the constructor, from the ModuleConfig you were handed. The typed accessors fall back when a key is absent:

class ExampleModule(config: ModuleConfig) : MirrorModule(config) {
    private val greeting = config.string("greeting", "Hello, Speculum!")
    private val tickStep = config.int("tickStep", 1)

    override val refreshIntervalMs: Long = config.refreshIntervalMs.coerceAtLeast(1000)
}

Then three hooks, called by the engine:

HookWhenUse it for
start(scope)Once at boot, with the engine's CoroutineScope.Long-lived background work. The scope is cancelled for you on shutdown.
refresh()Every refreshIntervalMs, as a suspend function.Fetching data. Keep it cancellable; never block the thread.
stop()Once on shutdown.Releasing anything the scope does not own.
Clamp your interval. The example uses coerceAtLeast(1000) so a misconfigured refreshInterval of 0 cannot spin the refresh loop.

Rendering with Compose

State is ordinary Compose state; writing to it from refresh() recomposes the UI:

private var ticks by mutableStateOf(0)

override suspend fun refresh() { ticks += tickStep }

@Composable
override fun Content() {
    Column {
        Text(greeting, color = Color.White, fontSize = 28.sp)
        Text("Refreshes: $ticks", color = MirrorColors.Normal, fontSize = 18.sp)
    }
}

Use MirrorColors rather than hand-picked greys. A mirror is a display behind two-way glass, so the palette is tuned for that: Normal, Dimmed and friends stay legible through the coating.

The notification bus

Modules receive broadcasts from the engine and from each other. Override the hook and keep it cheap — it runs on the caller's thread:

override fun onNotification(notification: Notification) {
    lastEvent = notification.name
}

Gradle: staying thin

Everything the host provides is compileOnly, which is what keeps the JAR at roughly 11 KB:

dependencies {
    compileOnly(libs.mirror.api)
    compileOnly(libs.compose.runtime)
    compileOnly(libs.compose.foundation)
    compileOnly(libs.compose.material3)
    compileOnly(libs.kotlinx.coroutines.core)

    // Tests need the real artifacts, since compileOnly does not reach them.
    testImplementation(libs.mirror.api)
    testImplementation(libs.compose.runtime)
}
The test gotcha: compileOnly dependencies are not on the test compile classpath. Every one you need in tests has to be repeated as testImplementation.

The deployToMirror task copies the built JAR into a mirror's plugins/ folder; see Quickstart.