Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ last notification it received.
`src/main/resources/META-INF/services/org.speculum.core.ModuleFactory`.
- On startup the Speculum app scans its `plugins/*.jar`, loads each in a
`URLClassLoader`, and finds factories via the JDK `ServiceLoader`.
- The factory declares its config options through `settingsSchema()`, so the
Speculum admin console renders real controls for them. See
[Module settings](#module-settings).

## Requirements

Expand Down Expand Up @@ -76,9 +79,48 @@ Start the Speculum app pointed at that `plugins/` folder (see the Speculum repo
for run instructions). The example loads automatically and renders at
`top_center`.

## Module settings

The module reads two config keys, `greeting` and `tickStep`. Rather than leaving
the admin console to show them as raw key/value text rows, the factory declares
them with `settingsSchema()`:

```kotlin
override fun settingsSchema(): List<SettingSpec> =
listOf(
SettingSpec(
key = "greeting",
label = "Greeting",
default = "Hello, Speculum!",
help = "Headline text shown at the top of the module.",
),
SettingSpec(
key = "tickStep",
label = "Tick step",
type = SettingType.INT,
default = "1",
min = 1,
max = 100,
help = "How much the refresh counter advances on each refresh.",
),
)
```

The console knows nothing about this module by name — it renders whatever the
schema returns, served through the config-server's `GET /api/modules`. Types are
`STRING`, `INT`, `BOOL`, `ENUM`, `TEXT`, `URL`, `IP` and `CUSTOM`; `advanced =
true` folds a rarely-touched option away. Keep each `default` equal to the
fallback the module code itself reads, since that is what the console shows as
the hint for an unset key — `defaultConfig()` is a separate thing, the starting
config a freshly added module gets.

The method has a default empty implementation, so a module that declares nothing
still works and simply keeps the raw key/value rows. Any key saved in a config
but absent from the schema also falls back to a raw row.

## Bumping the API version

`mirror-api` is released on every `v*` tag of the Speculum project; the version
is the tag without its leading `v`. Update `mirrorApi` in
[`gradle/libs.versions.toml`](gradle/libs.versions.toml) to compile against a
different release (current: `0.5.7`).
different release (current: `1.3.0`).
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ compose = "1.12.1"
composeMaterial3 = "1.9.0" # material3 is versioned independently of the Compose BOM
coroutines = "1.11.0"
# The published Speculum plugin API to compile against (release tag without "v").
mirrorApi = "1.2.2"
mirrorApi = "1.3.0"
# Quality gates.
ktlint = "14.2.0" # gradle plugin org.jlleitschuh.gradle.ktlint
detekt = "1.23.8"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package org.speculum.modules.example

import org.speculum.config.ModuleConfig
import org.speculum.config.SettingSpec
import org.speculum.config.SettingType
import org.speculum.core.MirrorModule
import org.speculum.core.ModuleFactory

/**
* SPI entry point discovered by the host app via ServiceLoader (declared in
* META-INF/services/org.speculum.core.ModuleFactory). Provides the module
* name, a constructor, and a default placement so it appears automatically.
* name, a constructor, a default placement so it appears automatically, and
* the schema the admin console renders its settings from.
*/
class ExampleModuleFactory : ModuleFactory {
override val name: String = "example"
Expand All @@ -21,4 +24,30 @@ class ExampleModuleFactory : ModuleFactory {
refreshIntervalMs = 3000,
config = mapOf("greeting" to "Loaded from JAR!", "tickStep" to "2"),
)

/**
* Declares the `config` keys above so the Speculum admin console shows
* labelled, typed controls instead of raw key/value text rows. Each
* `default` is the fallback [ExampleModule] itself reads, which is what the
* console shows as a hint while the key is unset — so it is deliberately
* not the same as the starting value [defaultConfig] ships.
*/
override fun settingsSchema(): List<SettingSpec> =
listOf(
SettingSpec(
key = "greeting",
label = "Greeting",
default = "Hello, Speculum!",
help = "Headline text shown at the top of the module.",
),
SettingSpec(
key = "tickStep",
label = "Tick step",
type = SettingType.INT,
default = "1",
min = 1,
max = 100,
help = "How much the refresh counter advances on each refresh.",
),
)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package org.speculum.modules.example

import org.speculum.config.ModuleConfig
import org.speculum.config.SettingType
import org.speculum.config.undeclaredKeys
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue

class ExampleModuleFactoryTest {
private val factory = ExampleModuleFactory()
Expand All @@ -29,4 +32,36 @@ class ExampleModuleFactoryTest {
assertEquals("Loaded from JAR!", config.config["greeting"])
assertEquals("2", config.config["tickStep"])
}

@Test
fun `settingsSchema declares every key the default config ships`() {
val undeclared = factory.settingsSchema().undeclaredKeys(factory.defaultConfig().config)

assertEquals(emptyList(), undeclared, "defaultConfig keys missing from settingsSchema")
}

@Test
fun `settingsSchema describes the documented defaults and bounds`() {
val schema = factory.settingsSchema()

assertEquals(listOf("greeting", "tickStep"), schema.map { it.key })

val greeting = schema.first { it.key == "greeting" }
assertEquals(SettingType.STRING, greeting.type)
assertEquals("Hello, Speculum!", greeting.default)

val tickStep = schema.first { it.key == "tickStep" }
assertEquals(SettingType.INT, tickStep.type)
assertEquals("1", tickStep.default)
assertEquals(1, tickStep.min)
assertEquals(100, tickStep.max)
}

@Test
fun `every schema entry is labelled and helpful`() {
for (spec in factory.settingsSchema()) {
assertTrue(spec.label.isNotBlank(), "${spec.key}: blank label")
assertTrue(spec.help.isNotBlank(), "${spec.key}: blank help")
}
}
}
Loading