diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts
index afa071a5de..419f653507 100644
--- a/core/datastore/build.gradle.kts
+++ b/core/datastore/build.gradle.kts
@@ -14,6 +14,11 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
+import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyTemplate
+
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kotlinx.serialization)
@@ -23,17 +28,60 @@ plugins {
kotlin {
android { withHostTest {} }
+ // Library module: bare wasmJs(), no browser() (that's for the eventual webApp executable).
+ @OptIn(ExperimentalWasmDsl::class)
+ wasmJs()
+
+ // nonWebMain: androidx.datastore:datastore (DataStore/OkioSerializer/CorruptionException) and
+ // androidx.datastore.preferences (the Preferences type) both publish no wasmJs variant at all — confirmed
+ // against their real Gradle Module Metadata, the same absence core:prefs hit for datastore-preferences alone
+ // (see core/prefs/build.gradle.kts). So the four proto serializers, CorePreferencesDataStore, and its three
+ // Preferences-backed consumers (RecentAddressesDataSource/BootloaderWarningDataSource/
+ // FirmwareRecoveryDataSource) all live here instead of commonMain. The four proto DataSources themselves
+ // (ChannelSetDataSource etc.) stay in commonMain unchanged — they depend only on the platform-neutral Store
+ // abstraction (see core/datastore/store/Store.kt), never on DataStore directly. Predicate, not
+ // withAndroidTarget()/withApple() — those silently drop androidMain under
+ // com.android.kotlin.multiplatform.library (KT-80409). See core/ble/build.gradle.kts for the same pattern.
+ @OptIn(ExperimentalKotlinGradlePluginApi::class)
+ applyHierarchyTemplate(KotlinHierarchyTemplate.default) {
+ common { group("nonWeb") { withCompilations { it.target.targetName != "wasmJs" } } }
+ }
+
+ // The predicate above misses iosMain itself (only reaches the two leaf iOS compilations), so any
+ // nonWebMain-only actual/declaration can't see nonWebMain's members without this explicit edge.
+ sourceSets.getByName("iosMain") { dependsOn(sourceSets.getByName("nonWebMain")) }
+
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(projects.core.model)
implementation(libs.meshtastic.protobufs)
- api(libs.androidx.datastore)
- api(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
+ // api, not implementation: androidApp (e.g. the google flavor's GoogleMapsDataStore/GoogleMapsPrefs) and
+ // other consumers import okio.* directly today with no dependency of their own, relying entirely on this
+ // module exposing it transitively (previously via androidx.datastore's own api-exposed okio dependency,
+ // now directly since androidx.datastore itself moved to nonWebMain — see below).
+ api(libs.okio)
}
- commonTest.dependencies { implementation(libs.okio) }
+ // android/jvm/ios only (see hierarchy template above) — androidx.datastore has no wasmJs variant, and the
+ // serializers/CorePreferencesDataStore (the sole consumers) live here. api, not implementation: androidApp's
+ // google flavor (GoogleMapsDataStore.kt/GoogleMapsPrefs.kt) imports androidx.datastore.* directly with no
+ // dependency of its own, relying entirely on this module's transitive exposure — confirmed by grepping
+ // androidApp/build.gradle.kts for a direct dependency (none exists). An intermediate source set's `api`
+ // dependency still propagates to every consumer of the targets under it (android/jvm/iOS here), while
+ // correctly staying invisible to wasmJs consumers, exactly preserving the original commonMain-level `api`'s
+ // effective reach.
+ getByName("nonWebMain").dependencies {
+ api(libs.androidx.datastore)
+ api(libs.androidx.datastore.preferences)
+ }
+
+ wasmJsMain.dependencies { implementation(libs.kotlinx.browser) }
+
+ // The only commonTest file (RecentAddressesDataSourceTest) constructs a real DataStore via
+ // PreferenceDataStoreFactory, so it moved to nonWebTest wholesale (same split core:prefs/core:database's
+ // DataStore-backed tests got) — commonTest is empty now.
}
}
diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
index d915c5e206..f8b08302aa 100644
--- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
+++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
@@ -36,6 +36,7 @@ import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
+import org.meshtastic.core.datastore.store.asStore
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
@@ -68,6 +69,7 @@ class LocalConfigDataStoreModule {
produceNewData = { LocalConfig() },
scope = scope,
)
+ .asStore()
.asCoreLocalConfigDataStore()
}
@@ -80,6 +82,7 @@ class ModuleConfigDataStoreModule {
produceNewData = { LocalModuleConfig() },
scope = scope,
)
+ .asStore()
.asCoreModuleConfigDataStore()
}
@@ -92,6 +95,7 @@ class ChannelSetDataStoreModule {
produceNewData = { ChannelSet() },
scope = scope,
)
+ .asStore()
.asCoreChannelSetDataStore()
}
@@ -104,6 +108,7 @@ class LocalStatsDataStoreModule {
produceNewData = { LocalStats() },
scope = scope,
)
+ .asStore()
.asCoreLocalStatsDataStore()
}
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDataStores.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDataStores.kt
index 86af5e698e..6d174a78b2 100644
--- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDataStores.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDataStores.kt
@@ -16,52 +16,48 @@
*/
package org.meshtastic.core.datastore.di
-import androidx.datastore.core.DataStore
-import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
+import org.meshtastic.core.datastore.store.Store
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
// One type per store, so the compiler distinguishes them instead of a Koin string qualifier. Each is a transparent
-// `DataStore` of its payload — inject and use it exactly like one.
+// `Store` of its payload — inject and use it exactly like one. `CorePreferencesDataStore` (DataStore)
+// is not here: `androidx.datastore.preferences`'s `Preferences` type has no wasmJs variant, so it can't be ported
+// behind `Store` at all — see nonWebMain's `CorePreferencesDataStore.kt`.
/**
- * Application-lifetime scope shared by every [DataStore]. Per the DataStore docs this must not be cancelled by UI
- * lifecycle events: `DataStore` has no `close()`, so its in-memory cache is released only when this job ends.
+ * Application-lifetime scope shared by every real `DataStore` (android/jvm/iOS only). Per the DataStore docs this must
+ * not be cancelled by UI lifecycle events: `DataStore` has no `close()`, so its in-memory cache is released only when
+ * this job ends. wasmJs's `LocalStorageStore` needs no scope — `localStorage` access is synchronous.
*/
interface DataStoreScope : CoroutineScope
/** Presents an existing scope as [DataStoreScope]; the wrapper adds nothing but identity. */
fun CoroutineScope.asDataStoreScope(): DataStoreScope = object : DataStoreScope, CoroutineScope by this {}
-interface CorePreferencesDataStore : DataStore
-
-/** Presents an existing store as [CorePreferencesDataStore]; the wrapper adds nothing but identity. */
-fun DataStore.asCorePreferencesDataStore(): CorePreferencesDataStore =
- object : CorePreferencesDataStore, DataStore by this {}
-
-interface CoreChannelSetDataStore : DataStore
+interface CoreChannelSetDataStore : Store
/** Presents an existing store as [CoreChannelSetDataStore]; the wrapper adds nothing but identity. */
-fun DataStore.asCoreChannelSetDataStore(): CoreChannelSetDataStore =
- object : CoreChannelSetDataStore, DataStore by this {}
+fun Store.asCoreChannelSetDataStore(): CoreChannelSetDataStore =
+ object : CoreChannelSetDataStore, Store by this {}
-interface CoreLocalConfigDataStore : DataStore
+interface CoreLocalConfigDataStore : Store
/** Presents an existing store as [CoreLocalConfigDataStore]; the wrapper adds nothing but identity. */
-fun DataStore.asCoreLocalConfigDataStore(): CoreLocalConfigDataStore =
- object : CoreLocalConfigDataStore, DataStore by this {}
+fun Store.asCoreLocalConfigDataStore(): CoreLocalConfigDataStore =
+ object : CoreLocalConfigDataStore, Store by this {}
-interface CoreLocalStatsDataStore : DataStore
+interface CoreLocalStatsDataStore : Store
/** Presents an existing store as [CoreLocalStatsDataStore]; the wrapper adds nothing but identity. */
-fun DataStore.asCoreLocalStatsDataStore(): CoreLocalStatsDataStore =
- object : CoreLocalStatsDataStore, DataStore by this {}
+fun Store.asCoreLocalStatsDataStore(): CoreLocalStatsDataStore =
+ object : CoreLocalStatsDataStore, Store by this {}
-interface CoreModuleConfigDataStore : DataStore
+interface CoreModuleConfigDataStore : Store
/** Presents an existing store as [CoreModuleConfigDataStore]; the wrapper adds nothing but identity. */
-fun DataStore.asCoreModuleConfigDataStore(): CoreModuleConfigDataStore =
- object : CoreModuleConfigDataStore, DataStore by this {}
+fun Store.asCoreModuleConfigDataStore(): CoreModuleConfigDataStore =
+ object : CoreModuleConfigDataStore, Store by this {}
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/store/Store.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/store/Store.kt
new file mode 100644
index 0000000000..604bcd35fb
--- /dev/null
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/store/Store.kt
@@ -0,0 +1,44 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.datastore.store
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Platform-neutral replacement for `androidx.datastore.core.DataStore`, mirroring its two members exactly.
+ * `androidx.datastore:datastore` (the core artifact providing `DataStore`/`OkioSerializer`/`CorruptionException`)
+ * publishes no wasmJs variant at all — confirmed against its Gradle Module Metadata, the same absence
+ * `androidx.datastore.preferences` hit for core:prefs (see `core/prefs/store/PrefsStore.kt`) — so this module's
+ * proto-payload stores (ChannelSet/LocalConfig/LocalStats/LocalModuleConfig) can't reference the real type from
+ * commonMain at all. Two implementations exist:
+ * - nonWebMain's `asStore()` — a thin wrapper over a real `DataStore`; android/jvm/iOS keep using the real DataStore
+ * machinery underneath, unchanged.
+ * - wasmJsMain's `LocalStorageStore` — backed by the browser's `localStorage`, encoding each value's proto bytes as
+ * base64 (localStorage is string-only).
+ *
+ * Unlike core:prefs's `PrefsStore` (many keys under one store), each `Store` here already holds one whole
+ * serializable value, so no key/snapshot abstraction is needed — this interface is a direct, simpler analog.
+ *
+ * `updateData`'s [transform] is deliberately non-suspend (androidx's is `suspend`) — no DataSource in this module needs
+ * suspension inside a transform, and a non-suspend lambda already satisfies a `suspend` function type, so nonWebMain's
+ * real-DataStore adapter passes it straight through with no wrapping needed.
+ */
+interface Store {
+ val data: Flow
+
+ suspend fun updateData(transform: (T) -> T): T
+}
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt
similarity index 100%
rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt
rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/FirmwareRecoveryDataSource.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/FirmwareRecoveryDataSource.kt
similarity index 100%
rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/FirmwareRecoveryDataSource.kt
rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/FirmwareRecoveryDataSource.kt
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt
similarity index 100%
rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt
rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt
diff --git a/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/di/CorePreferencesDataStore.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/di/CorePreferencesDataStore.kt
new file mode 100644
index 0000000000..09c00d117f
--- /dev/null
+++ b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/di/CorePreferencesDataStore.kt
@@ -0,0 +1,38 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.datastore.di
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+
+/**
+ * `androidx.datastore.preferences`'s `Preferences` type has no wasmJs variant at all (the same absence `core:prefs` hit
+ * — see `core/prefs/store/PrefsStore.kt`), so unlike the four proto-payload stores in `CoreDataStores.kt`, this one
+ * cannot be ported behind the generic `Store` abstraction: there is no way to construct a `Store` on
+ * wasmJs, because the `Preferences` type itself doesn't resolve there — this is not a missing-adapter gap, it's the
+ * payload type being unavailable. Stays directly typed against the real `DataStore`, android/jvm/iOS only.
+ * Its three consumers — `RecentAddressesDataSource`, `BootloaderWarningDataSource`, `FirmwareRecoveryDataSource` — move
+ * here with it, since they use `androidx.datastore.preferences`'s typed-key API directly. [DEFERRED]: a future pass
+ * could give these three web support by rewriting them against core:prefs's own `PrefsStore`/`PrefsKey` abstraction
+ * instead (which already solves "key-value settings on wasmJs" for the rest of the app) — not attempted here, out of
+ * this module's scope.
+ */
+interface CorePreferencesDataStore : DataStore
+
+/** Presents an existing store as [CorePreferencesDataStore]; the wrapper adds nothing but identity. */
+fun DataStore.asCorePreferencesDataStore(): CorePreferencesDataStore =
+ object : CorePreferencesDataStore, DataStore by this {}
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt
similarity index 100%
rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt
rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt
similarity index 100%
rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt
rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
similarity index 100%
rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt
similarity index 100%
rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt
rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt
diff --git a/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/store/DataStoreAdapter.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/store/DataStoreAdapter.kt
new file mode 100644
index 0000000000..40bcbb068e
--- /dev/null
+++ b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/store/DataStoreAdapter.kt
@@ -0,0 +1,32 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.datastore.store
+
+import androidx.datastore.core.DataStore
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Adapts a real `DataStore` (android/jvm/iOS only — `androidx.datastore:datastore` has no wasmJs variant) to the
+ * platform-neutral [Store]. Every `CoreDatastoreAndroidModule`/`DesktopPlatformModule` call site that used to hand a
+ * `DataStore` straight to an `asCoreXDataStore()` wrapper now inserts this adapter first:
+ * `protoStore(...).asStore().asCoreXDataStore()`.
+ */
+fun DataStore.asStore(): Store = object : Store {
+ override val data: Flow = this@asStore.data
+
+ override suspend fun updateData(transform: (T) -> T): T = this@asStore.updateData(transform)
+}
diff --git a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt b/core/datastore/src/nonWebTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt
similarity index 100%
rename from core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt
rename to core/datastore/src/nonWebTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt
diff --git a/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreWasmJsModule.kt b/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreWasmJsModule.kt
new file mode 100644
index 0000000000..502f9c32db
--- /dev/null
+++ b/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreWasmJsModule.kt
@@ -0,0 +1,77 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.datastore.di
+
+import org.koin.core.annotation.Module
+import org.koin.core.annotation.Single
+import org.meshtastic.core.datastore.store.LocalStorageStore
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.LocalStats
+
+/**
+ * Koin module providing wasmJs [org.meshtastic.core.datastore.store.Store]-backed proto stores, one per payload type,
+ * backed by the browser's `localStorage`. Mirrors `CoreDatastoreAndroidModule`/desktopApp's
+ * `desktopProtoDataStoreModule` — one singleton store per proto payload, minus the `DataStoreScope`/on-disk-path
+ * plumbing (`localStorage` access is synchronous, no scope or file path needed).
+ *
+ * `CorePreferencesDataStore` has no counterpart here: see its own KDoc in nonWebMain for why (the `Preferences` type
+ * itself has no wasmJs variant), which is also why `RecentAddressesDataSource`/`BootloaderWarningDataSource`/
+ * `FirmwareRecoveryDataSource` have no web binding this pass.
+ *
+ * Not yet registered anywhere: like `CorePrefsWasmJsModule`/core:database's `SingleDatabaseProvider`, nothing on wasmJs
+ * composes a Koin graph yet (no `webApp` module exists in this repo pass).
+ */
+@Module
+class CoreDatastoreWasmJsModule {
+ @Single
+ fun provideChannelSetDataStore(): CoreChannelSetDataStore = LocalStorageStore(
+ storageKey = "channel_set_ds",
+ defaultValue = ChannelSet(),
+ decode = ChannelSet.ADAPTER::decode,
+ encode = { value, sink -> ChannelSet.ADAPTER.encode(sink, value) },
+ )
+ .asCoreChannelSetDataStore()
+
+ @Single
+ fun provideLocalConfigDataStore(): CoreLocalConfigDataStore = LocalStorageStore(
+ storageKey = "local_config_ds",
+ defaultValue = LocalConfig(),
+ decode = LocalConfig.ADAPTER::decode,
+ encode = { value, sink -> LocalConfig.ADAPTER.encode(sink, value) },
+ )
+ .asCoreLocalConfigDataStore()
+
+ @Single
+ fun provideLocalStatsDataStore(): CoreLocalStatsDataStore = LocalStorageStore(
+ storageKey = "local_stats_ds",
+ defaultValue = LocalStats(),
+ decode = LocalStats.ADAPTER::decode,
+ encode = { value, sink -> LocalStats.ADAPTER.encode(sink, value) },
+ )
+ .asCoreLocalStatsDataStore()
+
+ @Single
+ fun provideModuleConfigDataStore(): CoreModuleConfigDataStore = LocalStorageStore(
+ storageKey = "module_config_ds",
+ defaultValue = LocalModuleConfig(),
+ decode = LocalModuleConfig.ADAPTER::decode,
+ encode = { value, sink -> LocalModuleConfig.ADAPTER.encode(sink, value) },
+ )
+ .asCoreModuleConfigDataStore()
+}
diff --git a/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/store/LocalStorageStore.kt b/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/store/LocalStorageStore.kt
new file mode 100644
index 0000000000..529bac3cc1
--- /dev/null
+++ b/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/store/LocalStorageStore.kt
@@ -0,0 +1,83 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.datastore.store
+
+import co.touchlab.kermit.Logger
+import kotlinx.browser.localStorage
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.map
+import okio.Buffer
+import okio.BufferedSink
+import okio.BufferedSource
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+/**
+ * Backed by the browser's `localStorage`. [decode]/[encode] run against an in-memory [Buffer] — it implements both
+ * `BufferedSource` and `BufferedSink` purely in memory (no file, works on every target) — to get/put the value's raw
+ * proto bytes, which are then [Base64]-encoded since `localStorage` only stores strings.
+ *
+ * Reads are live (no caching), matching `LocalStoragePrefsStore`: [revision] exists purely to give [data] a fresh
+ * emission after every [updateData] — [Revision] has no `equals` override, so each freshly-constructed instance is
+ * unequal by reference to the last, exactly what [MutableStateFlow] needs to avoid conflating the update away.
+ *
+ * Corruption policy mirrors this module's Android/JVM `ReplaceFileCorruptionHandler` (see
+ * `CoreDatastoreAndroidModule`'s `protoStore`): no value yet (first launch) or a value that fails to decode both fall
+ * back to [defaultValue]. A decode failure is also logged and the recovered default written back, so a corrupted read
+ * is not repeated on every subsequent access — "first launch" is not logged, since it isn't corruption.
+ */
+@OptIn(ExperimentalEncodingApi::class)
+internal class LocalStorageStore(
+ private val storageKey: String,
+ private val defaultValue: T,
+ private val decode: (BufferedSource) -> T,
+ private val encode: (T, BufferedSink) -> Unit,
+) : Store {
+ private class Revision
+
+ private val revision = MutableStateFlow(Revision())
+
+ override val data: Flow = revision.map { readCurrent() }
+
+ override suspend fun updateData(transform: (T) -> T): T {
+ val updated = transform(readCurrent())
+ writeCurrent(updated)
+ revision.value = Revision()
+ return updated
+ }
+
+ // Heterogeneous failure modes (Base64's IllegalArgumentException, Wire/okio's various decode-time exceptions) are
+ // deliberately all treated the same way: recover to defaultValue.
+ @Suppress("TooGenericExceptionCaught")
+ private fun readCurrent(): T {
+ val raw = localStorage.getItem(storageKey) ?: return defaultValue
+ return try {
+ decode(Buffer().apply { write(Base64.Default.decode(raw)) })
+ } catch (e: Exception) {
+ Logger.w(e) { "Corrupt localStorage value for '$storageKey', resetting to default" }
+ writeCurrent(defaultValue)
+ defaultValue
+ }
+ }
+
+ private fun writeCurrent(value: T) {
+ val buffer = Buffer()
+ encode(value, buffer)
+ localStorage.setItem(storageKey, Base64.Default.encode(buffer.readByteArray()))
+ }
+}
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
index 5ff8befa38..c66cbacda1 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt
@@ -52,6 +52,7 @@ import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
+import org.meshtastic.core.datastore.store.asStore
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.AnalyticsDataStore
import org.meshtastic.core.prefs.di.AppDataStore
@@ -192,21 +193,25 @@ private fun desktopProtoDataStoreModule() = module {
single {
protoStore(LocalConfigSerializer, "$protoDir/local_config.pb", { LocalConfig() }, get())
+ .asStore()
.asCoreLocalConfigDataStore()
}
single {
protoStore(ModuleConfigSerializer, "$protoDir/module_config.pb", { LocalModuleConfig() }, get())
+ .asStore()
.asCoreModuleConfigDataStore()
}
single {
protoStore(ChannelSetSerializer, "$protoDir/channel_set.pb", { ChannelSet() }, get())
+ .asStore()
.asCoreChannelSetDataStore()
}
single {
protoStore(LocalStatsSerializer, "$protoDir/local_stats.pb", { LocalStats() }, get())
+ .asStore()
.asCoreLocalStatsDataStore()
}
}