feat(service): enable wasmJs by extracting a TakServerIntegration seam

core:service is the central app-orchestration layer: MeshServiceOrchestrator
is constructed by both androidApp's MeshService.kt and desktopApp's
Main.kt, and will be needed by a future web client too -- it can't be
wholesale-excluded the way a leaf feature could be.

Its only web-hostile dependency was two constructor parameters typed
directly against core:takserver: TAKServerManager (already an
interface) and TAKMeshIntegration (a concrete class). core:takserver
can never get a wasmJs target -- its production implementation is a
TLS SSLServerSocket *listener* accepting inbound ATAK/iTAK
connections, and a browser sandbox can never accept inbound
connections at all, a more fundamental impossibility than the
outbound-only TCP case already excluded for MQTT.

MeshServiceOrchestrator only ever read takServerManager.isRunning and
called takMeshIntegration.start()/stop() -- the entire interaction
surface. Following this codebase's own convention (core:repository
hosts portable interfaces, core:*Impl-style modules hold the
platform-coupled implementation), a new minimal TakServerIntegration
interface in core:repository folds those three members into one seam.
TAKMeshIntegration now implements it directly; isRunning delegates to
the real takServerManager.isRunning (its own internal start/stop
re-entrancy latch, previously also named isRunning, is renamed to
isRunningState to keep the two states distinct). Its Koin provider
binds under both types (`@Single(binds = [TAKMeshIntegration::class,
TakServerIntegration::class])`) so feature/settings' debug UI still
resolves the concrete class while core:service resolves only the
interface -- verified sound against Koin's own K2-compiler-plugin
binding model via a real KoinVerificationTest run, not assumed.

core:service's dependency on core:takserver is removed entirely
(confirmed via grep: nothing else in the module referenced it).
wasmJs gets a real, honest no-op TakServerIntegration -- isRunning
always false, start/stop are no-ops -- documented as a permanent
platform impossibility, not a stand-in for future work.
androidApp/desktopApp needed zero changes: both already register
core:takserver's own Koin module directly, which still supplies the
real implementation there.

Three of six commonTest files move to a new nonWebTest source set,
for two unrelated, both-confirmed-empirically reasons:
SharedRadioInterfaceServiceLivenessTest.kt depends on core:testing (no
wasmJs target, the same gap every KMP module's test suite has hit this
session); RadioControllerImplTest.kt and RadioControllerRestoreTest.kt
crash the Kotlin/Wasm compiler ("Serialization of IrErrorType is not
supported anymore") when constructing a real RadioControllerImpl --
bisected to exactly these two files, which both differ from the four
that pass by constructing that class (interface delegation via `by`
plus Lazy<T> constructor params) -- a genuine backend limitation, not
a library gap, logged as deferred rather than worked around.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Sonnet 5 committed 2026-08-30 23:54:37 -05:00
1 parent 58d1d5068c
commit c3d19fb829
10 files changed
+182 -73

No files matched your search

@@ -0,0 +1,37 @@
/*
* 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.core.repository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
/**
* The minimal seam [org.meshtastic.core.service.MeshServiceOrchestrator] needs onto the TAK server integration —
* folding `TAKServerManager.isRunning` and `TAKMeshIntegration.start`/`stop` (core:takserver) into one interface so a
* platform with no TAK support (e.g. wasmJs, where core:takserver's TLS listener is a permanent browser-sandbox
* impossibility) can supply a real no-op without core:service depending on core:takserver at all.
*/
interface TakServerIntegration {
/** Whether the TAK server + mesh bridge are currently running. */
val isRunning: StateFlow<Boolean>
/** Start the TAK server and the mesh<->CoT bridge on [scope]. */
fun start(scope: CoroutineScope)
/** Stop the TAK server and the mesh<->CoT bridge. */
fun stop()
}
+38 -2
View File
@@ -23,6 +23,13 @@ plugins {
kotlin {
android { withHostTest { isIncludeAndroidResources = true } }
// Library module: bare wasmJs(), no browser() (that's for the eventual webApp executable). No custom
// hierarchy group is needed for MAIN: core:takserver (the one dependency with no wasmJs variant) is
// removed entirely below, not relocated, and nothing else in commonMain/androidMain/jvmMain is
// web-hostile — same shape as core:repository, unlike core:ble/core:database/core:prefs/core:network.
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs()
sourceSets {
commonMain.dependencies {
api(projects.core.repository)
@@ -37,7 +44,9 @@ kotlin {
implementation(projects.core.prefs)
implementation(projects.core.resources)
implementation(libs.meshtastic.protobufs)
implementation(projects.core.takserver)
// core:takserver removed (was only used by MeshServiceOrchestrator, which now depends on
// core:repository's TakServerIntegration seam instead — see TakServerIntegration.kt).
// Confirmed via grep: nothing else in this module references org.meshtastic.core.takserver.
implementation(libs.jetbrains.lifecycle.runtime)
implementation(libs.kotlinx.atomicfu)
@@ -60,6 +69,33 @@ kotlin {
}
}
commonTest.dependencies { implementation(projects.core.testing) }
// TEST only: three of this module's six commonTest files don't compile for wasmJs, for two
// unrelated reasons, both confirmed empirically via a real compileTestKotlinWasmJs run (never
// guessed): (1) SharedRadioInterfaceServiceLivenessTest.kt depends on core:testing, which has no
// wasmJs target (same gap core:ble/core:database/core:network/core:repository each hit).
// (2) RadioControllerImplTest.kt and RadioControllerRestoreTest.kt both crash the Kotlin/Wasm
// compiler ("Serialization of IrErrorType is not supported anymore") -- bisected to exactly these
// two files (the other four compile fine); the one thing both do that no passing file does is
// construct a real RadioControllerImpl, which delegates to four collaborators via `by` (interface
// delegation) alongside Lazy<T>/default-value constructor params. That combination is the leading
// suspect, not confirmed as the exact trigger -- this looks like a genuine Kotlin/Wasm backend
// limitation, not a missing library, and is out of this module's scope to fix. A plain additional
// source set -- not a full applyHierarchyTemplate reset -- keeps this scoped to test only, since
// MAIN needs no split at all (unlike core:ble/core:database/core:prefs/core:network).
//
// jvmTest/androidHostTest are leaf source sets tied 1:1 to their registered target, created
// synchronously and so already exist here — but the shared "iosTest" intermediate the default
// hierarchy template would otherwise provide is NOT materialized this early (its creation is
// deferred; confirmed empirically: `getByName("iosTest")` here throws "KotlinSourceSet with name
// 'iosTest' not found", while jvmTest/androidHostTest resolve fine). Wire the two iOS leaf test
// source sets directly instead.
val nonWebTest by creating {
dependsOn(commonTest.get())
dependencies { implementation(projects.core.testing) }
}
getByName("jvmTest") { dependsOn(nonWebTest) }
getByName("androidHostTest") { dependsOn(nonWebTest) }
matching { it.name == "iosArm64Test" || it.name == "iosSimulatorArm64Test" }
.configureEach { dependsOn(nonWebTest) }
}
}
@@ -42,11 +42,10 @@ import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.repository.TakServerIntegration
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.local_network_permission_denied_hint
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServerManager
// English fallback for local_network_permission_denied_hint when resource lookup is unavailable. Kept above the
// class KDoc so that KDoc still binds to the class. Internal so tests can assert the exact surfaced text.
@@ -70,8 +69,7 @@ class MeshServiceOrchestrator(
private val nodeManager: NodeManager,
private val messageProcessor: MeshMessageProcessor,
private val serviceNotifications: MeshNotificationManager,
private val takServerManager: TAKServerManager,
private val takMeshIntegration: TAKMeshIntegration,
private val takServerIntegration: TakServerIntegration,
private val takPrefs: TakPrefs,
private val databaseManager: DatabaseManager,
private val connectionManager: MeshConnectionManager,
@@ -167,12 +165,12 @@ class MeshServiceOrchestrator(
// Observe TAK server pref to start/stop
takPrefs.isTakServerEnabled
.onEach { isEnabled ->
if (isEnabled && !takServerManager.isRunning.value) {
if (isEnabled && !takServerIntegration.isRunning.value) {
Logger.i { "TAK Server enabled by preference, starting integration" }
takMeshIntegration.start(newScope)
takServerIntegration.start(newScope)
} else if (!isEnabled) {
Logger.i { "TAK Server disabled by preference, stopping integration" }
takMeshIntegration.stop()
takServerIntegration.stop()
}
}
.launchIn(newScope)
@@ -223,7 +221,7 @@ class MeshServiceOrchestrator(
*/
fun stop() {
Logger.i { "Stopping mesh service orchestrator" }
takMeshIntegration.stop()
takServerIntegration.stop()
// Best-effort polite goodbye on service teardown (onDestroy / process shutdown). We launch
// on a fresh detached scope — not the orchestrator's per-start scope — so the subsequent
// scope.cancel() below doesn't interrupt the short drain delay inside disconnect(). The
@@ -38,26 +38,20 @@ import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.safeCatchingAll
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.repository.ReceivedRadioFrame
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.repository.TakServerIntegration
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.local_network_permission_denied_hint
import org.meshtastic.core.takserver.MeshToCotBroadcaster
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServerManager
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MyNodeInfo
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -75,12 +69,9 @@ class MeshServiceOrchestratorTest {
private val nodeManager: NodeManager = mock(MockMode.autofill)
private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill)
private val commandSender: CommandSender = mock(MockMode.autofill)
private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill)
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
private val takServerManager: TAKServerManager = mock(MockMode.autofill)
private val takServerIntegration: TakServerIntegration = mock(MockMode.autofill)
private val takPrefs: TakPrefs = mock(MockMode.autofill)
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
@@ -120,40 +111,8 @@ class MeshServiceOrchestratorTest {
{
isSessionActive(it.args[0] as RadioSessionContext)
}
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
every { takPrefs.isMeshToCotEnabled } returns MutableStateFlow(false)
every { takPrefs.takServerChannel } returns MutableStateFlow(0)
every { takServerManager.isRunning } returns takRunningFlow
every { takServerManager.inboundMessages } returns MutableSharedFlow()
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
// Deliberately its own dispatcher, not the class-level testDispatcher: the broadcaster's
// scheduler doesn't need to be the same one driving this test, and a distinct name keeps
// that from reading as though the two are linked.
val broadcasterTestDispatcher = UnconfinedTestDispatcher()
val takMeshIntegration =
TAKMeshIntegration(
takServerManager = takServerManager,
commandSender = commandSender,
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
nodeRepository = nodeRepository,
takPrefs = takPrefs,
meshToCotBroadcaster =
MeshToCotBroadcaster(
takServerManager = takServerManager,
nodeRepository = nodeRepository,
takPrefs = takPrefs,
dispatchers =
CoroutineDispatchers(
io = broadcasterTestDispatcher,
main = broadcasterTestDispatcher,
default = broadcasterTestDispatcher,
),
),
)
every { takServerIntegration.isRunning } returns takRunningFlow
return MeshServiceOrchestrator(
radioInterfaceService = radioInterfaceService,
@@ -161,8 +120,7 @@ class MeshServiceOrchestratorTest {
nodeManager = nodeManager,
messageProcessor = messageProcessor,
serviceNotifications = serviceNotifications,
takServerManager = takServerManager,
takMeshIntegration = takMeshIntegration,
takServerIntegration = takServerIntegration,
takPrefs = takPrefs,
databaseManager = databaseManager,
connectionManager = connectionManager,
@@ -202,14 +160,23 @@ class MeshServiceOrchestratorTest {
// Toggle on
takEnabledFlow.value = true
verify { takServerManager.start(any()) }
// Exactly 1, not just "at least 1": nothing before this point could have called start() — the
// orchestrator's collector's only prior emission (isTakServerEnabled's initial `false`) takes the
// stop() branch instead — so this genuinely proves the toggle drove it.
verify(exactly(1)) { takServerIntegration.start(any()) }
// Update mock state to reflect it's running
takRunningFlow.value = true
// Toggle off
takEnabledFlow.value = false
verify { takServerManager.stop() }
// Exactly 2, not 1: the real TAKMeshIntegration this mock replaces has its own start()/stop()
// re-entrancy latch, so its initial no-op stop() (from isTakServerEnabled's starting `false`
// value, before "Toggle on" above) never reached the wrapped TAKServerManager it forwards to. A
// bare interface mock has no such latch — the orchestrator really does call stop() twice here
// (once at that initial emission, once for this toggle) — so asserting exactly(1) would fail,
// and a bare `verify { stop() }` would pass without proving *this* toggle caused a call.
verify(exactly(2)) { takServerIntegration.stop() }
orchestrator.stop()
}
@@ -219,14 +186,26 @@ class MeshServiceOrchestratorTest {
val takEnabledFlow = MutableStateFlow(false)
val takRunningFlow = MutableStateFlow(false)
val lifecycleEvents = mutableListOf<String>()
every { takServerManager.start(any()) } calls
// The real TAKMeshIntegration this mock replaces has its own start()/stop() re-entrancy latch
// (a CAS'd AtomicBoolean) so a redundant stop() while already stopped — or a redundant start()
// while already started — is a silent no-op instead of forwarding to TAKServerManager. A bare
// autofill mock has no such state, so replicate the latch here to keep asserting the real
// lifecycle sequence rather than every call the orchestrator happens to make.
var started = false
every { takServerIntegration.start(any()) } calls
{
lifecycleEvents += "start"
if (!started) {
started = true
lifecycleEvents += "start"
}
Unit
}
every { takServerManager.stop() } calls
every { takServerIntegration.stop() } calls
{
lifecycleEvents += "stop"
if (started) {
started = false
lifecycleEvents += "stop"
}
Unit
}
val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)
@@ -248,14 +227,23 @@ class MeshServiceOrchestratorTest {
val takEnabledFlow = MutableStateFlow(true)
val takRunningFlow = MutableStateFlow(false)
val lifecycleEvents = mutableListOf<String>()
every { takServerManager.start(any()) } calls
// See testTakServerCanRetryAfterFailedStart for why this latch replicates the real
// TAKMeshIntegration's own start()/stop() re-entrancy guard.
var started = false
every { takServerIntegration.start(any()) } calls
{
lifecycleEvents += "start"
if (!started) {
started = true
lifecycleEvents += "start"
}
Unit
}
every { takServerManager.stop() } calls
every { takServerIntegration.stop() } calls
{
lifecycleEvents += "stop"
if (started) {
started = false
lifecycleEvents += "stop"
}
Unit
}
val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)
@@ -0,0 +1,39 @@
/*
* 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.core.service
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.TakServerIntegration
// Auto-discovered by CoreServiceModule's existing @ComponentScan("org.meshtastic.core.service") in this target's
// compilation — no separate wasmJs Koin module needed (same "no per-target duplicate scan" reasoning as
// core:database's SingleDatabaseProvider).
//
// core:takserver's TAK server is a raw TLS listener (SSLServerSocket) — a browser sandbox can never accept inbound
// connections, so ATAK/TAK integration is a permanent web impossibility, not a pending feature. This is a real,
// honest no-op, not a stand-in for future work.
@Single(binds = [TakServerIntegration::class])
internal class NoopTakServerIntegration : TakServerIntegration {
override val isRunning: StateFlow<Boolean> = MutableStateFlow(false)
override fun start(scope: CoroutineScope) = Unit
override fun stop() = Unit
}
@@ -21,6 +21,7 @@ package org.meshtastic.core.takserver
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@@ -34,6 +35,7 @@ import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.repository.TakServerIntegration
import org.meshtastic.core.takserver.TAKPacketConversion.toCoTMessage
import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket
import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2
@@ -98,8 +100,13 @@ class TAKMeshIntegration(
private val nodeRepository: NodeRepository,
private val meshToCotBroadcaster: MeshToCotBroadcaster,
private val takPrefs: TakPrefs,
) {
private val isRunning = AtomicBoolean(false)
) : TakServerIntegration {
// This class's own start()/stop() re-entrancy latch (below) — distinct from isRunning, which reports
// the underlying TAK server's actual running state via TAKServerManager.
private val isRunningState = AtomicBoolean(false)
override val isRunning: StateFlow<Boolean>
get() = takServerManager.isRunning
// Immutable list reference replaced atomically in start()/stop(); never mutated in-place.
// @Volatile only guarantees visibility of the reference itself — any in-place mutation
@@ -115,8 +122,8 @@ class TAKMeshIntegration(
// from the single meshPacketFlow collector coroutine (handleMeshPacket), so no locking.
private val deliveryDedup = CotDeliveryDedup()
fun start(scope: CoroutineScope) {
if (!isRunning.compareAndSet(expectedValue = false, newValue = true)) return
override fun start(scope: CoroutineScope) {
if (!isRunningState.compareAndSet(expectedValue = false, newValue = true)) return
takServerManager.start(scope)
@@ -178,8 +185,8 @@ class TAKMeshIntegration(
Logger.i { "TAK Mesh Integration started — firmware=$fw, outbound=$proto" }
}
fun stop() {
if (!isRunning.compareAndSet(expectedValue = true, newValue = false)) return
override fun stop() {
if (!isRunningState.compareAndSet(expectedValue = true, newValue = false)) return
val toCancel = jobs
jobs = emptyList()
toCancel.forEach(Job::cancel)
@@ -24,6 +24,7 @@ import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.repository.TakServerIntegration
import org.meshtastic.core.takserver.MeshToCotBroadcaster
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServer
@@ -46,7 +47,10 @@ class CoreTakServerModule {
dispatchers: CoroutineDispatchers,
): MeshToCotBroadcaster = MeshToCotBroadcaster(takServerManager, nodeRepository, takPrefs, dispatchers)
@Single
// Bound under both types: feature/settings' debug TakMeshTestCard resolves the concrete TAKMeshIntegration
// (it needs members beyond TakServerIntegration's minimal seam), MeshServiceOrchestrator resolves
// TakServerIntegration (core:repository) so core:service never has to depend on core:takserver.
@Single(binds = [TAKMeshIntegration::class, TakServerIntegration::class])
fun provideTAKMeshIntegration(
takServerManager: TAKServerManager,
commandSender: CommandSender,