A Compose Multiplatform host framework: independent apps plug into a host through one contract and reach the platform through one object.
The rule the whole library is built on:
An app is written as if it were already standalone. The host is one possible runtime provider. An app never sees the host — only its
AppRuntime.
That is Adminizer's "App Boundary" rule, kept word for word. An app does not accept, store or depend
on a Superizer, an AppHandler or an AppRegistry; the build fails if it tries.
Targets: Android, desktop (JVM), wasm in the browser, iOS (iosArm64, iosSimulatorArm64). The
iOS klibs build and publish from any host; an app framework links on a Mac. MIT licensed.
Full documentation: adminization.github.io/superizer,
or docs/ in a checkout — getting started from scratch,
the architecture, writing an app, a reference for
core, host and ui,
testing, activation, deep links and push,
recipes, a brief to hand an agent, and
troubleshooting. What follows here is the two-minute version.
An app is one public class. Everything else in the module is internal.
@Serializable
public data class CalculatorConfig(val mode: String = "standard")
public class CalculatorApp : SuperizerApp<CalculatorConfig>() {
// The static half: what the host may know before running a line of your code.
override val manifest = AppManifest(
id = AppId("calculator"),
version = "1.1.0",
metadata = AppMetadata(
title = localized("en" to "Calculator", "ru" to "Калькулятор"),
icon = AppIcon.Named("calculator"), // or AppIcon.Path("M4 4h16…") — your own SVG
category = "Tools",
),
deepLinks = setOf("rate"), // paths you serve; setup() must register these
pushTopics = setOf("rates"), // topics you may subscribe to
)
override val configSpec = AppConfigSpec(CalculatorConfig.serializer(), CalculatorConfig())
// Once per enable: wire the behaviour behind the manifest. Everything registered here is torn
// down for you on disable, in reverse order.
override fun setup(ctx: AppSetupContext) {
ctx.deepLink("rate") { params -> configSpec.encode(CalculatorConfig(params["mode"] ?: "standard")) }
}
// Once per open: cheap. Anything slow belongs in AppInstance.onLaunch.
override fun launch(runtime: InstanceRuntime, config: CalculatorConfig) =
CalculatorInstance(runtime, config)
}An instance is one open screen and its ending:
internal class CalculatorInstance(
private val runtime: InstanceRuntime,
private val config: CalculatorConfig,
) : AppInstance() {
override suspend fun onLaunch() { runtime.analytics.screen("calculator") }
// What the screen would lose if the process died now. The host stores it and hands it back.
override fun saveState(): JsonObject = buildJsonObject { put("entry", JsonPrimitive(entry)) }
override fun restore(state: JsonObject) { entry = state["entry"]?.jsonPrimitive?.content ?: "0" }
// No top bar, no drawer, no back handler: the host draws the frame around this.
@Composable override fun Content() { CalculatorScreen(this) }
// Return false and the host asks the user before closing you.
override suspend fun onCloseRequested(): Boolean = true
}runtime is the whole of the platform an app can see. AppRuntime lives from enable to disable —
push topics and background work run on it — and InstanceRuntime adds navigation and a scope that
dies with the screen.
storage |
key/value that survives a restart, namespaced to your app id |
network |
strings in, strings out; online is a signal, not a guess |
logger, analytics |
tagged and prefixed with your app id by the host |
locale |
the language tag in effect, and pick(tables, fallback) |
haptics |
one tick; the host's setting is already applied |
push |
topics and a flow of messages — no token, no notifications, no navigation |
auth, apps, clock, lifecycle, events |
session, a read-only registry view, injectable time, foreground/background, the host's event flow |
navigation |
openApp, close, openSettings, openUrl — and nothing else |
service(key) |
optional services a host may or may not have |
Two rules keep this honest and are worth knowing before you add to it:
- Nothing in a service interface takes a type that cannot be JSON. No
KSerializer, noPainter, no platform class. - Every suspending call is main-safe. Call them from the main dispatcher without
withContext.
No host required.
class CalculatorContractTest : AppContractTest(CalculatorApp()) // the TCK, in one line
class CalculatorScreenTest {
@Test fun tappingKeysMovesTheDisplay() = runAppTest(CalculatorApp()) {
click("calculator:key-7")
click("calculator:key-=")
assertShown("calculator:display")
}
}runAppTest launches your app on fakes — InMemoryStorage, FakeNetwork, FakeClock,
RecordingAnalytics — inside a container that behaves like the host's. AppContractTest checks the
things a host assumes and therefore never re-checks at runtime: that your default config decodes,
that a broken one is reported rather than swallowed, that saveState → restore round-trips, that
setup registers only what the manifest declares, that onLaunch is under 200 ms and makes no
network call, and that Content() composes.
Find nodes by test tag, <appId>:<element>. Text changes with the language; tags do not.
val superizer = Superizer.build(scope) {
host(HostInfo("Unitool", version, build, currentPlatform(), SuperizerContract.VERSION, debug = BuildConfig.DEBUG))
scheme("unitool") // unitool://app/<id>/<path>
register(CalculatorApp())
register(CurrencyConverterApp())
promoCodes(mapOf("SCI" to ActivationResult.Success(Activation(AppId("calculator"), scientific))))
serviceCode("SERVICE") // the one door into the Service Menu in release
}
@Composable fun App() = SuperizerShell(superizer)Registration is explicit — no classpath scanning, which does not exist on wasm and which R8 would have to be told about. Reading this block tells you exactly what is in a build.
You get: Home (the apps the user chose, starting from home("calculator")), All Apps (the catalog
a tile is added from; a long press takes one off), the drawer, Settings (with a section per app),
Activate (promo code and QR text — activating a hidden app also puts it on Home), the Service Menu,
the app container, deep links, push routing, session restore after a process death, and the error
screen a failed launch lands on.
Published to GitHub Packages as cx.m42.superizer. The branch decides the channel and the
registry decides the number — nobody edits a version to cut a release, and there are no tags.
| Push to | You get | For |
|---|---|---|
main / master |
0.1.1 — the last release, patch bumped |
the release |
next |
0.1.1-next.3 |
a release being prepared |
alpha |
0.1.1-alpha.0 — its own counter |
something being tried |
commit |
0.1.0-commit.d66035c |
one build, pinned by the commit in it |
superizer.version and superizer.appsVersion in gradle.properties are floors, not
decisions: scripts/resolve-version.sh asks the registry what exists and goes one past it, and a
floor only wins while it is higher. Raise one by hand to start a new minor or major series. The
script runs on a laptop too, which is how you find out what the next push will publish:
GPR_USER=… GPR_TOKEN=… ./scripts/resolve-version.sh release cx/m42/superizer/core 0.1.0// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
google()
maven("https://maven.pkg.github.com/adminization/superizer") {
credentials {
username = providers.gradleProperty("gpr.user").orNull
?: providers.environmentVariable("GITHUB_ACTOR").orNull
password = providers.gradleProperty("gpr.key").orNull
?: providers.environmentVariable("GITHUB_TOKEN").orNull
}
}
}
}// build.gradle.kts — an app needs the first three, a host needs all four
implementation("cx.m42.superizer:core:0.1.1")
implementation("cx.m42.superizer:ui-theme:0.1.1")
implementation("cx.m42.superizer:ui:0.1.1")
implementation("cx.m42.superizer:host:0.1.1")
testImplementation("cx.m42.superizer:testing:0.1.1")Maven has no dist-tags, so there is no latest to follow and no floating version to resolve: pin
the number. To track a channel instead, name it — 0.1.+ for the releases, or the exact
-next.N you want. A version catalog is the right place for it, so a bump is one line.
The credentials are not optional. GitHub Packages authenticates downloads even for a public
repository — a build with no token gets a 401, not an anonymous read. Locally, put a classic PAT
with the read:packages scope in ~/.gradle/gradle.properties:
gpr.user=your-github-username
gpr.key=ghp_…In another repository's Actions, secrets.GITHUB_TOKEN is enough only for a repository inside
the same organisation with access granted to the package; anywhere else, pass an organisation
secret holding a PAT. This is the one real cost of GitHub Packages over Maven Central, and it is
worth knowing before wiring it into a public CI.
Publishing is the mirror image, and publish.yml does it for you. To publish by hand, the
repository name decides the property names Gradle looks for:
ORG_GRADLE_PROJECT_GitHubPackagesUsername=… ORG_GRADLE_PROJECT_GitHubPackagesPassword=… ./gradlew publish -Psuperizer.version=0.1.1 -Psuperizer.appsVersion=1.0.1SuperizerContract.VERSION is an integer, separate from the library's version. An app declares the
oldest host it tolerates as manifest.minHostContract; a host that is older rejects the app with
a reason the Service Menu shows, rather than crashing. The public API of every module is dumped to
*/api/*.api and checked by checkLegacyAbi; the diff of those files in a pull request is the
review of the contract.
| Module | What is in it |
|---|---|
core |
the contract, the runtime interfaces, the registry, the handler, the events |
ui-theme |
design tokens only, so a third-party component set can match a host without taking on its components |
ui |
components, the scaffold, the host screens, the shell |
host |
service implementations, platform actuals, activation, push routing |
testing |
FakeAppRuntime, runAppTest, AppContractTest |
apps/test-app |
the bench app: one card per runtime service |
fixture |
the smallest possible host, so the library can prove itself without a product |
samples/consumer |
a separate build that resolves the library from a repository |
Apps depend on core, ui-theme and ui. Never on host, never on each other, never on a host —
./gradlew checkDependencyRules fails the build if they do.
./scripts/verify.sh # static checks, tests, wasm, the web smoke test, publish, one reportThe report lands in build/verify/report.md and exits non-zero unless every line passes. Skips are
always named.
Dynamic app loading, a plugin system, and anything that would need reflection on wasm. A stack of
open apps (there is exactly one open app at a time). Adaptive layouts. A dark theme — the tokens are
ready for one, the values are not written. FCM: the push transport is an expect object with a
no-op actual on all three targets, so the router, the topic store and the bench app all work
today; adding Firebase is one actual file.