host API referencecx.m42.superizer:host:0.1.0 — the service implementations, the platform actuals, activation and
push routing. Only a host depends on this module. An app that imports anything from
cx.m42.superizer.host has broken the boundary, and checkDependencyRules says so.
public fun Superizer.Companion.build(
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main),
block: SuperizerBuilder.() -> Unit,
): Superizer
scope is the host’s own: every app scope and instance scope is a child of it, so shutting the host
down takes all of it with it. A test passes its own (Dispatchers.Unconfined, or a
TestScope), which is also how the handler avoids reaching for a main dispatcher a unit test does
not have.
Building registers every app and enables them all (handler.enableAll() on the scope), wires
process lifecycle, attaches the push router and the device registrar, and starts collecting the
event ring buffer.
SuperizerBuilder| Call | Default | What it does |
|---|---|---|
host(info: HostInfo) |
required | identity, platform, contract version, debug flag. The builder copies the registered service keys into info.services. |
register(app: SuperizerApp<*>) |
— | explicit registration. The order is the order All Apps and the Service Menu list apps in. |
home(ids: List<AppId>) / home(vararg ids: String) |
empty | what a fresh install shows on Home. Read on the first run only; after that Home is the user’s. |
scheme(value: String) |
"superizer" |
the URL scheme: <scheme>://app/<id>/<path> and <scheme>://activate?… |
languages(vararg LanguageOption, fallback: String = "en") |
ru, en |
what the Settings picker offers |
promoCodes(table: Map<String, ActivationResult>) |
reject everything | a shipped code table, normalised (case, spaces and punctuation ignored) |
promoCodes(resolver: PromoCodeResolver) |
the same thing against a server: fun interface { suspend fun resolve(code: String): ActivationResult } |
|
serviceCode(code: String?) |
null |
the one door into the Service Menu in a release build. null leaves it debug-only (seven taps on the build stamp). |
service(key: ServiceKey<T>, implementation: T) |
none | registers an optional service. Apps reach it with runtime.service(key) / require(key). |
clock(value: Clock) |
SystemClock |
inject time |
network(value: NetworkService) |
Ktor | substitute the whole service, so no test reaches the network |
httpClient(value: HttpClient) |
KtorNetworkService.defaultClient() |
a client of your own, with the same service |
auth(value: AuthService) |
AnonymousAuth |
|
notifier(value: Notifier) |
none | draws a notification for a non-silent data message: fun interface { fun show(title: String?, body: String?, link: String) } |
lifecycle(value: StateFlow<HostLifecycle>) |
PlatformLifecycle.state |
drive foreground/background by hand in a test |
contexts(port: ContextPort) |
ContextPort.Single |
what the host is pointed at — servers, accounts — and the switcher at the top of the drawer. See below. |
qrScanner(scan: suspend () -> String?) |
none | the camera behind Activate’s scan button. Since 0.8.2 apps get it too, as QrScanner. |
appFolders(root: String?) |
the platform’s | where each app’s AppFolder goes: <root>/<app id>. Unset: <documents>/<host name> on desktop, the host’s Documents on iOS, its external files dir on Android, none on the web. null offers no folders. See below. |
A complete example is in Getting started §3.4.
Three optional services come with the builder, each unless the host registered its own under the same key:
| Service | When | What it is |
|---|---|---|
AppFolder |
wherever the platform has files and appFolders(null) was not called |
a folder per app, <root>/<app id>, that the person sees too |
FileDownloader |
when the host has its own HTTP client, i.e. network(…) was not substituted |
KtorFileDownloader on that client: bytes, a size limit, two minutes |
QrScanner |
when qrScanner { … } was set |
the same camera as Activate |
On iOS the folder shows in Files › On My iPhone › the host only when the host’s Info.plist says so:
<key>UIFileSharingEnabled</key><true/>
<key>LSSupportsOpeningDocumentsInPlace</key><true/>
Without the two keys the folder still works — the app reads and writes it — but the person cannot reach it.
public typealias HostSession = AppSession // re-exported, so a host file needs one import fewer
A context is what the whole host is pointed at: a server, an account, a workspace. The library draws the switcher — a row at the top of the drawer with the current one, a sheet from the bottom with the rest, as ChatGPT and GitHub switch accounts — and hands the host the id that was picked. What switching means is the host’s.
public data class HostContext(val id: String, val title: String, val subtitle: String? = null)
public interface ContextPort {
public val contexts: StateFlow<List<HostContext>> // the person's order
public val current: StateFlow<HostContext?>
public fun switchTo(id: String) // the drawer is already closing
public fun previous(): HostContext? = null // where a long press on the row goes
public fun title(langTag: String): String // the sheet's heading: "Servers"
public val addScreen: HostScreen? get() = null // the sheet's "+" row
public val manageScreen: HostScreen? get() = null // the sheet's last row
public companion object { public val Single: ContextPort }
}
ContextPort.Single — the default — is one context, the whole app: no row, no sheet, the drawer
titled “Menu” and Home titled with HostInfo.name, exactly as before contexts existed. The switcher
appears once there is more than one context, or one and an addScreen; Home is then titled with
the current context and its subtitle.
The pattern RestoApp uses: one Superizer per context, each built on that context’s own storage
and with its own apps, and a root composable that draws key(active) { SuperizerShell(active) }.
Before letting go of one, drive its lifecycle(...) to Background so the open app writes its
snapshot there — and comes back when the context does.
| Screen / behaviour | Where it comes from |
|---|---|
| Home — the chosen tiles, “+” to the catalog, long press to remove | ui, driven by Superizer.home |
| All Apps — the catalog; a tap adds to Home or takes off | ui, driven by registry.visible(unlocked) |
| The drawer — Home, every app on Home, All Apps, plus the footer | AppScaffold |
| The context switcher — a row atop the drawer and a sheet, when the host has contexts | SuperizerShell + ContextPort |
| Settings — language, haptics, a block per app, about, the 7-tap door | SettingsScreen + handler.settingsSections |
| Activate — promo code and QR text | ActivateScreen + ActivationPort |
| The Service Menu | ServiceMenuScreen + DiagnosticsPort |
| The app container, the error screen, the veto dialog | SuperizerShell |
| Deep links, push routing, session restore | host |
cx.m42.superizer.host)These are the implementations behind AppRuntime. A host normally never names them; they are
public so a different host can reuse one.
| Type | Role |
|---|---|
DefaultHostRuntimeFactory |
builds both runtime levels; scoped storage/logger/analytics/push per app id, shared locale/clock/events |
PrefsStorageService(appId) |
StorageService over SafePrefs, prefixed app.<id>.; erase(appId) for reset |
KtorNetworkService(client, online) |
NetworkService; defaultClient() sets a 15 s timeout and expectSuccess = false; TIMEOUT_MS = 15_000 |
ConsoleLogger(tag, buffer, verbose) |
ring buffer always, console only for warn/error unless verbose |
LogBuffer(capacity = 500) |
lines: StateFlow<List<String>>, add, clear |
LoggingAnalytics(appId, logger) |
prefixes the app id before anything leaves |
AppLocaleService(supported, fallback) |
LocaleService and HostSettingsPort; resolves stored choice → device → fallback; useForTesting(tag), refreshFromDevice() |
PlatformHaptics(enabled) |
a no-op when the Settings switch is off |
AnonymousAuth |
always anonymous, honestly |
RegistryView(registry, handler, unlocked) |
the read-only AppsService |
ServiceRegistry(services) |
the optional-service map behind runtime.service(key) |
SystemClock |
expect object — epoch millis per platform |
cx.m42.superizer.host.storage)| Type | Keeps |
|---|---|
PrefsStorage |
the expect object every platform implements: get/put/remove/keys. Desktop: a properties file under ~/.superizer (useDirectory(File) to move it). Android: MODE_PRIVATE shared prefs (initPrefs(context) first). Web: localStorage. |
SafePrefs |
the same, with every call guarded — a browser with site data blocked is “nothing was saved”, not a crash. Use this, not PrefsStorage. |
HostKeys |
host.language, host.haptics, host.unlocked, host.home, host.session, host.push.topics — a namespace no app prefix can reach |
UnlockStore |
which hidden apps this device has unlocked; unlock, lock, unlocked: StateFlow |
HomeStore(defaults) |
the user’s ordered Home set; absent = first run → defaults, present-but-empty = a choice to keep |
PrefsSnapshotStore |
the one SessionSnapshot; a snapshot an older build wrote and this one cannot parse means the same as no snapshot |
cx.m42.superizer.host.activation)| Type | Role |
|---|---|
QrPayloadParser(registry, scheme, json) |
parse(text) for both forms: a JSON payload and a <scheme>://activate?a=…&c=… URL. Never throws; every branch is a typed rejection. SCHEMA_VERSION = 1, TYPE = "app_activation" |
PromoCodeResolver |
fun interface { suspend fun resolve(code: String): ActivationResult } |
LocalPromoCodes(table) |
a shipped table; normalise(code) strips everything but letters and digits and upper-cases, so test 2026, TEST-2026 and test2026 are one code |
ActivationService |
the ActivationPort: the one place an activation turns into a running app |
cx.m42.superizer.host.push)| Type | Role |
|---|---|
PushTransport |
the platform seam: token, incoming, available, deliverTap, deliverMessage, requestPermission, subscribeTopic, unsubscribeTopic. A no-op actual on all three targets today. |
NoopPushTransport |
the shared no-op body the three actuals delegate to |
PushRouter |
payload → either data or a screen; enforces schema, app id, unlock, enabled, and link prefix |
PendingRoute |
the consume-once RoutePort |
TopicStore |
per-app topics, persisted; qualified(appId, topic) = "<id>.<topic>" |
RoutedPush |
the PushService an app sees; refuses an undeclared topic |
Notifier |
fun interface { fun show(title: String?, body: String?, link: String) } |
DeviceRegistrar |
token × session × app ids → the server. There is no server, so it logs. |
PushRegistration(token, platform), IncomingPush(data, tapped) |
wire types |
Adding Firebase is one actual file plus a google-services.json — the router, the topic store,
the Service Menu’s simulator and the bench app’s Push card all work against the seam today.
cx.m42.superizer.host.platform)public expect fun currentPlatform(): Platform
public expect fun deviceLanguageTag(): String // "" means "could not tell"
public expect fun openExternalUrl(url: String): Boolean
public expect object PlatformLifecycle { public val state: StateFlow<HostLifecycle> }
public expect object Connectivity { public val online: StateFlow<Boolean> }
Android additionally exposes AndroidHost.init(context) (vibrator, process lifecycle,
connectivity). On Android the lifecycle follows ON_START/ON_STOP, never
ON_RESUME/ON_PAUSE: a permission dialog pauses the activity, and a snapshot written on every one
of those is a snapshot written on every tap.
| Target | Before the first composition |
|---|---|
| Android | initPrefs(context) and AndroidHost.init(context); build the host once per process, not per activity; singleTask plus a VIEW intent filter for your scheme; hand intent.data to superizer.route.deliver(...) from both onCreate and onNewIntent |
| Desktop | nothing required. Optionally PrefsStorage.useDirectory(File(...)); parse --link=/--activate= into route.deliver(...) |
| Web (wasmJs) | nothing required. Parse ?link= and ?activate= (percent-decoded) into route.deliver(...); index.html needs a #composeTarget div |
Two doors, and only two:
HostInfo.debug = true);serviceCode(...) on the Activate screen. It resolves to
ActivationResult.HostCommand(OpenServiceMenu), which is matched by the shell — not a reserved
app id, because one magic name always becomes several.What it does, all through DiagnosticsPort: list every manifest with its outcome (including the
rejected ones and why), run an app, run it with a pasted config, unlock/lock a hidden app,
enable/disable, reset an app (storage + topics + snapshot + unlock, confirmed), show the last
hundred events, show the log ring buffer, and simulate a push payload end to end.
Superizer.build(scope) {
host(HostInfo(..., debug = true))
clock(FakeClock()) // cx.m42.superizer.testing
network(FakeNetwork().apply { respond("https://api…", """{"rates":{}}""") })
lifecycle(MutableStateFlow(HostLifecycle.Foreground))
register(MyApp())
}
Plus PrefsStorage.useDirectory(tmp) on desktop so a test never touches the developer’s own file,
and AppLocaleService.useForTesting(tag) to pin a language.