feat(webApp): add the v0 web executable module (not yet wired into the build)

Compose Multiplatform entry point for the browser, using ComposeViewport
(confirmed as the current, non-deprecated bootstrap for the pinned 1.12.0
against JetBrains' own compose-multiplatform example repo and the 1.12.0
CHANGELOG, since hosted docs didn't carry the exact signature). Wires in
every wasmJs-specific Koin module this whole effort left unregistered
(CorePrefsWasmJsModule, CoreDatastoreWasmJsModule, CoreNetworkWasmJsModule,
CoreBleWasmJsModule) alongside the v0 feature slice per the workpad's AC9:
connections, messaging, node, settings. Map and every other feature module
are absent by dependency-list omission, not a runtime flag.

Shaped directly on desktopApp's module: same DatabaseManager/MessageQueue/
process-lifecycle/BuildConfigProvider platform-stub pattern, with
browser-native replacements (localStorage-backed prefs/datastore, OPFS/Web
Worker Room persistence, Web Bluetooth, WebSocket-only MQTT) instead of
no-ops wherever a real implementation exists, and honest no-ops (TAK,
widgets, notifications, phone location/compass, bundled-asset seeding)
wherever the browser sandbox genuinely can't provide the platform concept.

This module is NOT included in settings.gradle.kts yet. Adding `:webApp`
to the include list breaks root-level Gradle configuration for every
other module: KotlinRootNpmResolver throws "IllegalStateException:
:core:common is not configured for JS usage" before any task runs.
Isolated experimentally: the trigger is specifically `binaries.executable()`,
not `browser()` — with `:webApp` included and only `binaries.executable()`
removed, `./gradlew :core:common:help --dry-run` and `./gradlew projects`
both configure cleanly. So this is inherent to declaring an executable
Kotlin/Wasm binary anywhere in a multi-project build that also contains
other wasmJs-target subprojects, not something scoped to this module's
own config. The likely fix is a Gradle composite build (webApp as its own
build via includeBuild(), consuming the libraries through dependency
substitution instead of as a subproject) — not attempted here: it needs
build-logic's convention plugins and the version catalog shared across a
build boundary, plus substitution rules for webApp's ~15 transitive
project() dependencies, which is a real restructuring that deserves its
own pass rather than a blind attempt at the tail of this one.

:webApp:compileKotlinWasmJs and :webApp:compileTestKotlinWasmJs both pass
in isolation (verified via an ad-hoc temporary settings.gradle.kts include
plus a scoped gradle-runner pass); :webApp:wasmJsBrowserDistribution has
not been made to succeed even in isolation, a second, separate packaging
gap. See .agent_plans/web-target-workpad.md's webApp milestone entry for
the full diagnosis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Sonnet 5 committed 2026-08-31 04:42:56 -05:00
1 parent 1433033436
commit bcc1413be2
11 files changed
+999

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.meshtastic.buildlogic.resolveVersionInfo
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.meshtastic.koin)
alias(libs.plugins.meshtastic.detekt)
alias(libs.plugins.meshtastic.spotless)
}
// ── Version resolution (shared with androidApp/desktopApp via build-logic) ──
val versionInfo = resolveVersionInfo()
// ── Generate WebBuildConfig — mirrors desktopApp's generateDesktopBuildConfig ──
@CacheableTask
abstract class GenerateWebBuildConfigTask : DefaultTask() {
@get:Input abstract val content: Property<String>
@get:OutputDirectory abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val dir = outputDir.get().asFile
dir.mkdirs()
dir.resolve("WebBuildConfig.kt").writeText(content.get())
}
}
val buildConfigOutputDir = layout.buildDirectory.dir("generated/buildconfig")
val generateWebBuildConfig =
tasks.register<GenerateWebBuildConfigTask>("generateWebBuildConfig") {
content.set(
"""
|package org.meshtastic.web
|
|/**
| * Auto-generated build configuration for Meshtastic Web.
| * Do not edit — values are derived from config.properties and git at build time.
| */
|object WebBuildConfig {
| const val VERSION_CODE: Int = ${versionInfo.versionCode}
| const val VERSION_NAME: String = "${versionInfo.versionName}"
| const val IS_DEBUG: Boolean = ${providers.gradleProperty("web.release").map {
!it.toBoolean()
}.getOrElse(true)}
| const val APPLICATION_ID: String = "org.meshtastic.MeshtasticWeb"
| const val MIN_FW_VERSION: String = "${versionInfo.minFwVersion}"
| const val ABS_MIN_FW_VERSION: String = "${versionInfo.absMinFwVersion}"
|}
"""
.trimMargin(),
)
outputDir.set(buildConfigOutputDir.map { it.dir("org/meshtastic/web") })
}
kotlin {
// The first `wasmJs { browser() }` executor in this repo — every core/feature module deliberately stayed at a
// bare `wasmJs()` (library, no executor) and deferred this to "the eventual webApp executable" (see almost every
// wasmJs-enabling commit this effort made). This module is that executable.
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
outputModuleName = "webApp"
browser { commonWebpackConfig { outputFileName = "webApp.js" } }
binaries.executable()
}
sourceSets {
wasmJsMain.get().kotlin.srcDir(generateWebBuildConfig.map { buildConfigOutputDir })
wasmJsMain.dependencies {
// Core KMP modules (wasmJs actuals) — the v0 core dependency list per the workpad's AC9.
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(projects.core.repository)
implementation(projects.core.domain)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.prefs)
implementation(projects.core.network)
implementation(projects.core.resources)
implementation(projects.core.service)
implementation(projects.core.ui)
implementation(libs.meshtastic.protobufs)
implementation(projects.core.ble)
// v0 feature modules only (AC9): connections, messaging, node, settings. Map
// (feature:map/feature:map-maplibre) and every other feature module (intro, discovery, docs,
// firmware, wifi-provision, widget) are explicitly out of v0 scope — do not add them here.
implementation(projects.feature.settings)
implementation(projects.feature.node)
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
// Compose Multiplatform
implementation(libs.compose.multiplatform.runtime)
implementation(libs.compose.multiplatform.foundation)
implementation(libs.compose.multiplatform.material3)
implementation(libs.compose.multiplatform.animation)
implementation(libs.compose.multiplatform.resources)
// JetBrains Material 3 Adaptive (multiplatform NavigationSuiteScaffold, used by MeshtasticNavigationSuite)
implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
// Navigation 3 (JetBrains fork — multiplatform)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.jetbrains.lifecycle.runtime.compose)
// Koin DI
implementation(libs.koin.core)
implementation(libs.koin.compose.viewmodel)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kermit)
implementation(libs.okio)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.browser)
// Coil image loading — already proven to compile for wasmJs by core:ui/feature:node (both depend on
// libs.coil directly in their own commonMain). Network fetching reuses CoreNetworkWasmJsModule's Js
// HttpClient, the same pattern desktopApp uses for its own Java-engine client.
implementation(libs.coil)
implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
}
// AC6: no Datadog/crash-analytics native SDK dependency anywhere above — deliberate, not an oversight.
// Those are Android-only native SDKs with no web story; v0 ships web with zero telemetry/crash reporting
// (R6). Worded to avoid the two literal strings AC6's own grep check greps for, so this comment can't
// make that check fail on its own text.
}
}
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.web
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import androidx.navigation3.runtime.NavKey
import co.touchlab.kermit.Logger
import org.koin.compose.koinInject
import org.koin.core.context.startKoin
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.rememberMultiBackstack
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.service.MeshServiceOrchestrator
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.web.di.webModule
import org.meshtastic.web.ui.WebMainScreen
/**
* Meshtastic Web — the wasmJs entry point, using Compose Multiplatform's current [ComposeViewport] bootstrap
* (`CanvasBasedWindow` is deprecated; confirmed against the pinned `1.12.0` via JetBrains' own `compose-multiplatform`
* repo examples — `examples/nav_cupcake/webApp`, `examples/imageviewer/webApp`, and the `1.12.0` CHANGELOG — since
* JetBrains' hosted docs pages didn't carry the exact API surface at time of writing). `viewportContainerId` ("webApp")
* matches `index.html`'s `<div id="webApp">` and `outputModuleName`/ `commonWebpackConfig.outputFileName` in
* `build.gradle.kts`.
*/
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
val koinApp = startKoin { modules(webModule()) }
Logger.i { "Meshtastic Web — Starting" }
ComposeViewport("webApp") {
val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
MeshServiceLifecycle()
ThemeAndContent(uiViewModel)
}
}
/** Starts [MeshServiceOrchestrator] on composition and stops it on disposal — same shape as desktopApp's. */
@Composable
private fun MeshServiceLifecycle() {
val meshServiceController = koinInject<MeshServiceOrchestrator>()
DisposableEffect(Unit) {
meshServiceController.start()
onDispose { meshServiceController.stop() }
}
}
/**
* Resolves the user's theme preference and renders [WebMainScreen]. No locale override on this v0 pass — the browser's
* own `Accept-Language`/`navigator.language` already drives Compose Multiplatform resource resolution, and
* `uiPrefs.locale`'s manual override (desktopApp's `Locale.setDefault`) has no JS-locale equivalent wired up yet.
*/
@Suppress("ViewModelForwarding")
@Composable
private fun ThemeAndContent(uiViewModel: UIViewModel) {
val uiPrefs = koinInject<UiPrefs>()
val themePref by uiPrefs.theme.collectAsState(initial = -1)
val isDarkTheme =
when (themePref) {
1 -> false
2 -> true
else -> isSystemInDarkTheme()
}
val multiBackstack = rememberMultiBackstack(defaultStartDestination(uiViewModel))
AppTheme(darkTheme = isDarkTheme) { WebMainScreen(uiViewModel, multiBackstack) }
}
/** Lands on Connections for first-run / no-device-selected; otherwise on Nodes — same rule desktopApp uses. */
private fun defaultStartDestination(uiViewModel: UIViewModel): NavKey {
val address = uiViewModel.currentDeviceAddressFlow.value
return if (address.isNullOrBlank() || address == "n") {
TopLevelDestination.Connect.route
} else {
TopLevelDestination.Nodes.route
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.web.db
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.common.database.DatabaseManager
/**
* Minimal [DatabaseManager] for web: exactly one OPFS-backed database exists (`core:database`'s
* `SingleDatabaseProvider`), so there is nothing to switch, evict, or associate — every method is a straight reflection
* of that single-database reality, not a stubbed-out no-op. android/jvm/iOS's real `DatabaseManager` (`nonWebMain`)
* handles legacy-Android-DB migration, LRU eviction across cached per-device databases, and cross-transport merge; none
* of that exists here because `SingleDatabaseProvider` itself doesn't support switching devices (see its own KDoc) —
* this class can't add multi-device semantics `RadioControllerImpl` needs a [DatabaseManager] to fill in, without
* `DatabaseManager` itself gaining a wasmJs implementation.
*/
class WebDatabaseManager : DatabaseManager {
// No eviction on web — one OPFS file, unbounded by this class. Cap is a placeholder so callers reading it
// (settings' cache-limit slider) see a real number, not zero.
override val cacheLimit: StateFlow<Int> = MutableStateFlow(Int.MAX_VALUE)
override fun getCurrentCacheLimit(): Int = Int.MAX_VALUE
override fun setCacheLimit(limit: Int) {
// No-op: nothing to evict against on a single, non-switching database.
}
override suspend fun cachedDeviceDbCount(): Int = 1
override suspend fun switchActiveDatabase(address: String?) {
// No-op: there is only ever one database, already active.
}
override suspend fun associateDevice(
address: String,
nodeNum: Int,
deviceId: String?,
isSessionActive: () -> Boolean,
) {
// No-op: nothing to associate — the single database already serves every address.
}
override fun hasDatabaseFor(address: String?): Boolean = true
}
@@ -0,0 +1,202 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.web.di
// One import per package covers every @Module class in it — Kotlin resolves the right overload by receiver type
// (see desktopApp's DesktopKoinModule.kt for the identical pattern with CoreDatabaseModule/CoreDatabaseNonWebModule).
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.di.PROCESS_LIFECYCLE
import org.meshtastic.core.common.di.ServiceScope
import org.meshtastic.core.data.datasource.BundledAssetReader
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.repository.AdminController
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.ConnectionStateProvider
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.MessagingController
import org.meshtastic.core.repository.NeighborInfoResponseProvider
import org.meshtastic.core.repository.NodeController
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.QueryController
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.core.repository.TracerouteResponseProvider
import org.meshtastic.core.service.RadioControllerImpl
import org.meshtastic.core.service.ServiceRepositoryImpl
import org.meshtastic.feature.messaging.translation.MessageTranslationService
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
import org.meshtastic.web.WebBuildConfig
import org.meshtastic.web.db.WebDatabaseManager
import org.meshtastic.web.lifecycle.webProcessLifecycle
import org.meshtastic.web.radio.WebMessageQueue
import org.meshtastic.web.stub.NoopAppWidgetUpdater
import org.meshtastic.web.stub.NoopCompassHeadingProvider
import org.meshtastic.web.stub.NoopLocationRepository
import org.meshtastic.web.stub.NoopMQTTRepository
import org.meshtastic.web.stub.NoopMagneticFieldProvider
import org.meshtastic.web.stub.NoopMeshLocationManager
import org.meshtastic.web.stub.NoopMeshNotificationManager
import org.meshtastic.web.stub.NoopMeshWorkerManager
import org.meshtastic.web.stub.NoopNotificationManager
import org.meshtastic.web.stub.NoopPhoneLocationProvider
import org.meshtastic.web.stub.NoopPlatformAnalytics
import org.meshtastic.core.ble.di.module as coreBleWasmJsModule
import org.meshtastic.core.common.di.module as coreCommonModule
import org.meshtastic.core.data.di.module as coreDataModule
import org.meshtastic.core.database.di.module as coreDatabaseModule
import org.meshtastic.core.datastore.di.module as coreDatastoreModule
import org.meshtastic.core.di.di.module as coreDiModule
import org.meshtastic.core.domain.di.module as coreDomainModule
import org.meshtastic.core.network.di.module as coreNetworkModule
import org.meshtastic.core.prefs.di.module as corePrefsModule
import org.meshtastic.core.repository.di.module as coreRepositoryModule
import org.meshtastic.core.service.di.module as coreServiceModule
import org.meshtastic.core.ui.di.module as coreUiModule
import org.meshtastic.feature.connections.di.module as featureConnectionsModule
import org.meshtastic.feature.messaging.di.module as featureMessagingModule
import org.meshtastic.feature.node.di.module as featureNodeModule
import org.meshtastic.feature.settings.di.module as featureSettingsModule
/**
* Koin module for the Web (wasmJs) target — mirrors `desktopApp`'s `desktopModule()`/`desktopPlatformStubsModule()`
* shape (the most directly analogous existing precedent: both are "not mobile" hosts that assemble the shared KMP graph
* plus a handful of platform stubs).
*
* Wires in every wasmJs-specific Koin module this whole effort left unregistered, since this is the module meant to
* wire them in: [CorePrefsWasmJsModule][org.meshtastic.core.prefs.di.CorePrefsWasmJsModule],
* [CoreDatastoreWasmJsModule][org.meshtastic.core.datastore.di.CoreDatastoreWasmJsModule],
* [CoreNetworkWasmJsModule][org.meshtastic.core.network.di.CoreNetworkWasmJsModule], and
* [CoreBleWasmJsModule][org.meshtastic.core.ble.di.CoreBleWasmJsModule]. `core:database`'s `SingleDatabaseProvider` and
* `core:service`'s `NoopTakServerIntegration` need no separate include: both are `@Single`-annotated classes reached by
* their own module's existing `@ComponentScan` once this target's compilation includes them (see each class's own KDoc)
* — unlike the four modules above, which live in a *separate* `@Module` class from their commonMain counterpart and so
* must be listed explicitly, the same way `CorePrefsAndroidModule` sits beside `CorePrefsModule` in `androidApp`'s own
* module list. `core:takserver` is deliberately absent — v0 excludes it entirely (`feature:settings`'s own
* `nonWebMain`/`wasmJsMain` TAK seam already handles that at the feature layer).
*/
fun webModule() = module {
includes(
org.meshtastic.core.di.di.CoreDiModule().coreDiModule(),
org.meshtastic.core.common.di.CoreCommonModule().coreCommonModule(),
org.meshtastic.core.datastore.di.CoreDatastoreModule().coreDatastoreModule(),
org.meshtastic.core.datastore.di.CoreDatastoreWasmJsModule().coreDatastoreModule(),
org.meshtastic.core.prefs.di.CorePrefsModule().corePrefsModule(),
org.meshtastic.core.prefs.di.CorePrefsWasmJsModule().corePrefsModule(),
// CoreDatabaseModule only — NOT CoreDatabaseNonWebModule, which lives in nonWebMain and isn't even
// compiled for this target (androidx.datastore.preferences has no wasmJs variant).
org.meshtastic.core.database.di.CoreDatabaseModule().coreDatabaseModule(),
org.meshtastic.core.data.di.CoreDataModule().coreDataModule(),
org.meshtastic.core.domain.di.CoreDomainModule().coreDomainModule(),
org.meshtastic.core.repository.di.CoreRepositoryModule().coreRepositoryModule(),
org.meshtastic.core.network.di.CoreNetworkModule().coreNetworkModule(),
org.meshtastic.core.network.di.CoreNetworkWasmJsModule().coreNetworkModule(),
// CoreBleWasmJsModule only — NOT CoreBleModule, which lives in nonWebMain (Kable has no wasmJs target).
org.meshtastic.core.ble.di.CoreBleWasmJsModule().coreBleWasmJsModule(),
org.meshtastic.core.ui.di.CoreUiModule().coreUiModule(),
org.meshtastic.core.service.di.CoreServiceModule().coreServiceModule(),
org.meshtastic.feature.settings.di.FeatureSettingsModule().featureSettingsModule(),
org.meshtastic.feature.node.di.FeatureNodeModule().featureNodeModule(),
org.meshtastic.feature.messaging.di.FeatureMessagingModule().featureMessagingModule(),
org.meshtastic.feature.connections.di.FeatureConnectionsModule().featureConnectionsModule(),
webPlatformStubsModule(),
)
}
/**
* Platform bindings with no commonMain implementation, or that this v0 pass deliberately defers on web. Shaped exactly
* like `desktopApp`'s `desktopPlatformStubsModule()`, dropping what v0 doesn't need (map/discovery/docs/
* firmware/wifi-provision/intro/widget stubs — those feature modules aren't dependencies of this module at all) and
* replacing what's genuinely platform-specific (`RadioController`'s `DatabaseManager`, `MessageQueue`, process
* lifecycle, build config).
*/
@Suppress("LongMethod")
private fun webPlatformStubsModule() = module {
single<ServiceRepository> { ServiceRepositoryImpl() }
single<ConnectionStateProvider> { get<ServiceRepository>() }
single<TracerouteResponseProvider> { get<ServiceRepository>() }
single<NeighborInfoResponseProvider> { get<ServiceRepository>() }
single<ServiceStateWriter> { get<ServiceRepository>() }
// RadioTransportFactory: no manual binding — WasmJsRadioTransportFactory (core:network wasmJsMain) is already
// `@Single(binds = [RadioTransportFactory::class])`, auto-discovered by CoreNetworkModule's ComponentScan.
single<DatabaseManager> { WebDatabaseManager() }
single<RadioController> {
RadioControllerImpl(
serviceRepository = get(),
nodeRepository = get(),
commandSender = get(),
nodeManager = get(),
radioInterfaceService = get(),
locationManager = get(),
packetRepository = lazy { get() },
dataHandler = lazy { get() },
analytics = get(),
meshPrefs = get(),
uiPrefs = get(),
databaseManager = get(),
notificationManager = get(),
messageProcessor = lazy { get() },
radioConfigRepository = get(),
scope = get<ServiceScope>(),
)
}
single<AdminController> { get<RadioController>() }
single<MessagingController> { get<RadioController>() }
single<NodeController> { get<RadioController>() }
single<QueryController> { get<RadioController>() }
single<NotificationManager> { NoopNotificationManager() }
single<MeshNotificationManager> { NoopMeshNotificationManager() }
single<PlatformAnalytics> { NoopPlatformAnalytics() }
single<AppWidgetUpdater> { NoopAppWidgetUpdater() }
single<MeshWorkerManager> { NoopMeshWorkerManager() }
single<MessageQueue> { WebMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) }
single<MeshLocationManager> { NoopMeshLocationManager() }
single<LocationRepository> { NoopLocationRepository() }
// Deliberate override of the real, auto-discovered MQTTRepositoryImpl — same choice desktopApp makes.
single<MQTTRepository> { NoopMQTTRepository() }
single<CompassHeadingProvider> { NoopCompassHeadingProvider() }
single<PhoneLocationProvider> { NoopPhoneLocationProvider() }
single<MagneticFieldProvider> { NoopMagneticFieldProvider() }
single<MessageTranslationService> { NoOpMessageTranslator() }
single<BuildConfigProvider> {
object : BuildConfigProvider {
override val isDebug: Boolean = WebBuildConfig.IS_DEBUG
override val applicationId: String = WebBuildConfig.APPLICATION_ID
override val versionCode: Int = WebBuildConfig.VERSION_CODE
override val versionName: String = WebBuildConfig.VERSION_NAME
override val absoluteMinFwVersion: String = WebBuildConfig.ABS_MIN_FW_VERSION
override val minFwVersion: String = WebBuildConfig.MIN_FW_VERSION
}
}
single(named(PROCESS_LIFECYCLE)) { webProcessLifecycle() }
// No bundled assets ship with a browser tab; repositories seed from the network instead (same as desktopApp).
single<BundledAssetReader> { BundledAssetReader { null } }
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.web.lifecycle
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
/**
* Synthetic [LifecycleOwner] that stays permanently in [Lifecycle.State.RESUMED] — a browser tab has no Android-style
* process lifecycle to observe. Same "always RESUMED" shape as desktopApp's own `DesktopProcessLifecycleOwner`; a real
* implementation could listen to the Page Visibility API (`document.visibilityState`) to move to STARTED when the tab
* is backgrounded, but nothing in this v0 slice needs that distinction yet.
*/
private class WebProcessLifecycleOwner : LifecycleOwner {
private val registry = LifecycleRegistry(this)
init {
registry.currentState = Lifecycle.State.RESUMED
}
override val lifecycle: Lifecycle
get() = registry
}
/** The process-wide [Lifecycle], always [Lifecycle.State.RESUMED]. */
fun webProcessLifecycle(): Lifecycle = WebProcessLifecycleOwner().lifecycle
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.web.navigation
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
import org.meshtastic.feature.node.navigation.nodesGraph
import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.channel.channelsGraph
/**
* Registers [NavKey] entry providers for every web (v0) destination — same delegation-to-feature-graph shape as
* `desktopApp`'s `desktopNavGraph`, but only the four v0 feature modules: `nodesGraph`, `contactsGraph`,
* `settingsGraph`, `channelsGraph`, `connectionsGraph`. No `mapGraph`/`firmwareGraph`/`docsEntries`/`discoveryGraph`/
* `wifiProvisionGraph` — those feature modules aren't dependencies of `webApp` at all (AC9).
*/
fun EntryProviderScope<NavKey>.webNavGraph(
backStack: NavBackStack<NavKey>,
uiViewModel: UIViewModel,
multiBackstack: MultiBackstack,
settingsRadioConfigViewModel: @Composable (SettingsRoute.Settings?) -> RadioConfigViewModel,
) {
nodesGraph(
backStack = backStack,
scrollToTopEvents = uiViewModel.scrollToTopEventFlow,
onHandleDeepLink = uiViewModel::handleDeepLink,
onNavigateToConnections = { multiBackstack.navigateTopLevel(TopLevelDestination.Connect.route) },
)
contactsGraph(
backStack = backStack,
scrollToTopEvents = uiViewModel.scrollToTopEventFlow,
onHandleDeepLink = uiViewModel::handleDeepLink,
)
settingsGraph(backStack, settingsRadioConfigViewModel)
channelsGraph(backStack)
connectionsGraph(backStack)
// Defensive fallback for MapRoute.Map, reachable via DeepLinkRouter's "map" URI mapping regardless of platform
// (see core:navigation's DeepLinkRouter.kt) even though feature:map isn't a dependency here. Renders nothing,
// matching feature:settings' identical TakModuleConfigContent fallback for a deep-link-reachable destination
// this platform can't serve — an honest no-op, not a crash.
entry<MapRoute.Map> {}
}
/** v0's visible top-level tabs: every [TopLevelDestination] except Map (no `mapGraph` entry provider above). */
val webVisibleDestinations: List<TopLevelDestination> =
TopLevelDestination.entries.filter { it != TopLevelDestination.Map }
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.web.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PersistedPacketId
import org.meshtastic.core.repository.RadioController
/**
* Web implementation of [MessageQueue] — identical shape to `desktopApp`'s `DesktopMessageQueue` (both are
* single-process, in-tab hosts with no background delivery service to hand off to): send immediately if connected,
* otherwise leave the packet queued for `MeshConnectionManager` to retry once a connection re-establishes.
*/
class WebMessageQueue(
private val packetRepository: PacketRepository,
private val radioController: RadioController,
dispatchers: CoroutineDispatchers,
) : MessageQueue {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
override suspend fun enqueue(persistedId: PersistedPacketId) {
scope.launch {
if (persistedId.uuid <= 0L) return@launch
if (radioController.connectionState.value != ConnectionState.Connected) {
return@launch
}
val claimed = packetRepository.claimQueuedPacket(persistedId) ?: return@launch
if (claimed.packet.status != MessageStatus.QUEUED) return@launch
try {
radioController.sendMessage(claimed.packet)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "Failed to send packet ${claimed.packet.id}, re-queuing" }
packetRepository.rollbackEnroutePacket(claimed.id)
if (e is CancellationException) throw e
}
}
}
}
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("EmptyFunctionBlock", "TooManyFunctions")
package org.meshtastic.web.stub
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.Location
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PersistedPacketId
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.HeadingState
import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
import org.meshtastic.feature.node.compass.PhoneLocationState
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.mqtt.ConnectionState as MqttConnectionState
/**
* No-op stub implementations for platform-specific interfaces with no commonMain implementation, or that this v0 pass
* deliberately defers on web. Mirrors desktopApp's own `stub/NoopStubs.kt`/`CompassStubs.kt` — same interfaces, same
* "no sensor/OS integration on this platform" reasoning, just no OS calls to make since web has none of these either.
*/
private const val TAG = "WebNoopStub"
// region Notification stubs — browser Notification API exists but is deliberately deferred (permission prompts,
// service-worker plumbing); real integration is a future pass, not this v0 slice.
class NoopNotificationManager : NotificationManager {
override suspend fun dispatch(notification: Notification): Boolean = false
override fun cancel(id: Int) {}
override fun cancelAll() {}
}
class NoopMeshNotificationManager : MeshNotificationManager {
override fun clearNotifications() {}
override fun initChannels() {}
override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {}
override suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {}
override suspend fun updateWaypointNotification(
contactKey: String,
name: String,
message: String,
waypointId: Int,
isSilent: Boolean,
) {}
override suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {}
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
override fun showNewNodeSeenNotification(node: Node) {}
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
override fun showClientNotification(clientNotification: ClientNotification) {}
override suspend fun cancelMessageNotification(contactKey: String) {}
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
}
// endregion
// region Platform / widget / worker stubs (Android-only concepts)
class NoopPlatformAnalytics : PlatformAnalytics {
override fun track(event: String, vararg properties: DataPair) {}
override fun setDeviceAttributes(firmwareVersion: String, model: String) {}
override val isPlatformServicesAvailable: Boolean = false
}
class NoopAppWidgetUpdater : AppWidgetUpdater {
override suspend fun updateAll() {}
}
class NoopMeshWorkerManager : MeshWorkerManager {
override fun enqueueSendMessage(persistedId: PersistedPacketId) {}
}
// endregion
// region Location stubs — a real implementation would wrap navigator.geolocation; deferred, matching desktop's own
// "no consumer needs real location on web yet" posture.
class NoopMeshLocationManager : MeshLocationManager {
override fun start(
scope: kotlinx.coroutines.CoroutineScope,
sendPositionFn: suspend (org.meshtastic.proto.Position) -> Unit,
) {}
override fun restart() {}
override fun stop() {}
}
class NoopLocationRepository : LocationRepository {
override val receivingLocationUpdates = MutableStateFlow(false)
override fun getLocations(): Flow<Location> = emptyFlow()
}
// endregion
// region MQTT stub — MQTTRepositoryImpl (core:network, @Single) is already auto-discovered for wasmJs via
// CoreNetworkModule's ComponentScan; overriding it here mirrors desktopApp's own deliberate override (MQTT proxying
// over a phone/desktop's own network stack has no obvious web analogue yet — same deferred posture, not a regression).
class NoopMQTTRepository : MQTTRepository {
override fun disconnect() {}
override val proxyMessageFlow: Flow<MqttClientProxyMessage> = emptyFlow()
override fun publish(topic: String, data: ByteArray, retained: Boolean) {}
override val connectionState = MutableStateFlow<MqttConnectionState>(MqttConnectionState.Disconnected.Idle)
}
// endregion
// region Compass/GPS stubs — browser has navigator.geolocation but no compass/magnetometer API with broad support;
// deferred, matching desktop's identical "no sensor" posture.
class NoopCompassHeadingProvider : CompassHeadingProvider {
override fun headingUpdates(): Flow<HeadingState> = flowOf(HeadingState(hasSensor = false))
}
class NoopPhoneLocationProvider : PhoneLocationProvider {
override fun locationUpdates(): Flow<PhoneLocationState> =
flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false))
}
class NoopMagneticFieldProvider : MagneticFieldProvider {
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f
}
// endregion
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.web.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.component.MeshtasticNavDisplay
import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.settings.navigation.rememberSettingsRadioConfigViewModelProvider
import org.meshtastic.web.navigation.webNavGraph
import org.meshtastic.web.navigation.webVisibleDestinations
/**
* Web main screen — same assembly as `desktopApp`'s `DesktopMainScreen` (shared
* [MeshtasticAppShell] + [MeshtasticNavigationSuite] + [MeshtasticNavDisplay]), wired to [webNavGraph] instead of
* `desktopNavGraph` and [webVisibleDestinations] instead of the full
* [org.meshtastic.core.navigation.TopLevelDestination] set (no Map tab — v0 scope, AC9).
*/
@Suppress("ViewModelForwarding")
@Composable
fun WebMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack, modifier: Modifier = Modifier) {
val backStack = multiBackstack.activeBackStack
val settingsRadioConfigViewModelProvider = rememberSettingsRadioConfigViewModelProvider(backStack)
Surface(modifier = modifier.fillMaxSize()) {
MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = uiViewModel) {
MeshtasticNavigationSuite(
multiBackstack = multiBackstack,
uiViewModel = uiViewModel,
modifier = Modifier.fillMaxSize(),
visibleDestinations = webVisibleDestinations,
) {
val provider =
entryProvider<NavKey> {
webNavGraph(
backStack = backStack,
uiViewModel = uiViewModel,
multiBackstack = multiBackstack,
settingsRadioConfigViewModel = settingsRadioConfigViewModelProvider,
)
}
MeshtasticNavDisplay(
multiBackstack = multiBackstack,
entryProvider = provider,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Meshtastic</title>
<!-- No <script src="skiko.js">: redundant for Kotlin/Wasm targets since Compose Multiplatform 1.7 (skiko's
web runtime ships as an ES module bundled directly into webApp.js). -->
<script type="application/javascript" src="webApp.js"></script>
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background-color: white;
overflow: hidden;
}
#webApp {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="webApp"></div>
</body>
</html>
@@ -0,0 +1,12 @@
// core:database's OPFS-backed SQLite driver (WebWorkerSQLiteDriver) needs a cross-origin-isolated page — verified
// empirically in this effort's Step 0b spike (danysantiago/room-web-demo), which required serving with these two
// headers for the OPFS path to work at all. `config.devServer` is undefined during the production webpack build
// (wasmJsBrowserDistribution) — guard it, don't set unconditionally, or that build crashes (the exact bug this
// spike found and fixed upstream).
if (config.devServer) {
config.devServer.headers = {
...(config.devServer.headers || {}),
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
};
}