diff --git a/packages/web/src/core/stores/nodeDBStore/index.ts b/packages/web/src/core/stores/nodeDBStore/index.ts index 0da05bb3..1b0a6237 100644 --- a/packages/web/src/core/stores/nodeDBStore/index.ts +++ b/packages/web/src/core/stores/nodeDBStore/index.ts @@ -4,7 +4,7 @@ import { Protobuf, type Types } from "@meshtastic/core"; import { produce } from "immer"; import { create as createStore } from "zustand"; import { persist } from "zustand/middleware"; -import type { NodeError, ProcessPacketParams } from "./types"; +import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types"; const CURRENT_STORE_VERSION = 0; @@ -22,7 +22,7 @@ export interface NodeDB { updateFavorite: (nodeNum: number, isFavorite: boolean) => void; updateIgnore: (nodeNum: number, isIgnored: boolean) => void; setNodeNum: (nodeNum: number) => void; - setNodeError: (nodeNum: number, error: string) => void; + setNodeError: (nodeNum: number, error: NodeErrorType) => void; clearNodeError: (nodeNum: number) => void; getNodesLength: () => number; @@ -58,17 +58,16 @@ type NodeDBPersisted = { nodeDBs: Map; }; -function mergeIntoCurrent(target: NodeDB, incoming: NodeDB) { - for (const [n, info] of incoming.nodeMap) { - if (!target.nodeMap.has(n)) { - target.nodeMap.set(n, info); - } +export function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.byteLength !== b.byteLength) { + return false; } - for (const [n, err] of incoming.nodeErrors) { - if (!target.nodeErrors.has(n)) { - target.nodeErrors.set(n, err); + for (let i = 0; i < a.byteLength; i++) { + if (a[i] !== b[i]) { + return false; } } + return true; } function nodeDBFactory( @@ -79,7 +78,7 @@ function nodeDBFactory( ): NodeDB { const nodeMap = data?.nodeMap ?? new Map(); const nodeErrors = data?.nodeErrors ?? new Map(); - let myNodeNum = data?.myNodeNum; + const myNodeNum = data?.myNodeNum; return { id, @@ -205,26 +204,30 @@ function nodeDBFactory( setNodeNum: (nodeNum) => set( produce((draft) => { - console.debug( - `Setting myNodeNum for nodeDB with id: ${id} to ${nodeNum}`, - ); - const current = draft.nodeDBs.get(id); - if (!current) { + const newDB = draft.nodeDBs.get(id); + if (!newDB) { throw new Error(`No nodeDB found for id: ${id}`); } - // There might already be an entry for this nodeNum, so we need to merge - // it into the current nodeDB. - myNodeNum = nodeNum; - current.myNodeNum = nodeNum; + newDB.myNodeNum = nodeNum; - for (const [k, other] of draft.nodeDBs) { - if (k === id) { + for (const [key, oldDB] of draft.nodeDBs) { + if (key === id) { continue; } - if (other.myNodeNum === nodeNum) { - mergeIntoCurrent(current, other); // current wins on conflicts - draft.nodeDBs.delete(k); + if (oldDB.myNodeNum === nodeNum) { + // The new DB is typically empty when nodenum is set, so we can safely copy over from the old DB + // otherwise, discard the old DB completely + if (newDB.nodeMap.size === 0) { + newDB.nodeMap = oldDB.nodeMap; + newDB.nodeErrors = oldDB.nodeErrors; + } else { + console.error( + `NodeDB with id: ${id} already has nodes, not merging with old DB`, + ); + } + + draft.nodeDBs.delete(key); } } }), @@ -384,7 +387,12 @@ export const useNodeDBStore = createStore()( if (!state) { return; } - console.debug("NodeDBStore: Rehydrating state", state); + console.debug( + "NodeDBStore: Rehydrating state with ", + state.nodeDBs.size, + " nodeDBs -", + state.nodeDBs, + ); useNodeDBStore.setState( produce((draft) => { @@ -392,15 +400,18 @@ export const useNodeDBStore = createStore()( for (const [id, data] of ( draft.nodeDBs as unknown as Map ).entries()) { - rebuilt.set( - id, - nodeDBFactory( + if (data.myNodeNum !== undefined) { + // Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate + rebuilt.set( id, - useNodeDBStore.getState, - useNodeDBStore.setState, - data, - ), - ); + nodeDBFactory( + id, + useNodeDBStore.getState, + useNodeDBStore.setState, + data, + ), + ); + } } draft.nodeDBs = rebuilt; }), diff --git a/packages/web/src/core/stores/nodeDBStore/types.ts b/packages/web/src/core/stores/nodeDBStore/types.ts index a1e36e6a..105ddbf0 100644 --- a/packages/web/src/core/stores/nodeDBStore/types.ts +++ b/packages/web/src/core/stores/nodeDBStore/types.ts @@ -1,6 +1,10 @@ +import type { Protobuf } from "@meshtastic/core"; + +type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI"; + type NodeError = { node: number; - error: string; + error: NodeErrorType; }; type ProcessPacketParams = { @@ -9,4 +13,4 @@ type ProcessPacketParams = { time: number; }; -export type { NodeError, ProcessPacketParams }; +export type { NodeError, ProcessPacketParams, NodeErrorType }; diff --git a/packages/web/src/core/stores/utils/indexDB.ts b/packages/web/src/core/stores/utils/indexDB.ts index 994bbaa3..6d220323 100644 --- a/packages/web/src/core/stores/utils/indexDB.ts +++ b/packages/web/src/core/stores/utils/indexDB.ts @@ -17,40 +17,91 @@ export const zustandIndexDBStorage: StateStorage = { }, }; -type SerializedMap = { - __dataType: "Map"; - value: Array<[K, V]>; +type AnyRecord = Record; + +type Envelope = { + __datatype: Tag; + value: V; }; -type JsonReplacer = (key: string, value: unknown) => unknown; -const replacer: JsonReplacer = (_, value) => { - if (value instanceof Map) { - const map = value as Map; - const serialized: SerializedMap = { - __dataType: "Map", - value: Array.from(map.entries()), - }; - return serialized; - } - return value; +interface Handler { + tag: Tag; + test(val: unknown): val is T; + serialize(val: T): Encoded; + revive(encoded: Encoded): T; +} + +function isObject(x: unknown): x is AnyRecord { + return typeof x === "object" && x !== null; +} +function isEnvelope(x: unknown): x is Envelope { + return isObject(x) && typeof x.__datatype === "string" && "value" in x; +} + +// Map handler +type SerializedMap = Array<[K, V]>; +const mapHandler: Handler, "Map", SerializedMap> = { + tag: "Map", + test: (val): val is Map => val instanceof Map, + serialize: (map) => Array.from(map.entries()), + revive: (pairs) => new Map(pairs), }; -type JsonReviver = (key: string, value: unknown) => unknown; -function isSerializedMap(value: unknown): value is SerializedMap { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return false; +// Uint8Array handler +const uint8ArrayHandler: Handler = { + tag: "Uint8Array", + test: (val): val is Uint8Array => val instanceof Uint8Array, + serialize: (uint8array) => Array.from(uint8array), + revive: (arr) => new Uint8Array(arr), +}; + +const defaultHandlers = [mapHandler, uint8ArrayHandler] as const; + +function makeJson>( + handlers: readonly H[], +) { + const byTag = new Map(); + for (const handler of handlers) { + byTag.set(handler.tag, handler); } - const potentialMap = value as Partial; - return potentialMap.__dataType === "Map" && Array.isArray(potentialMap.value); + + const replacer = (_: string, value: unknown): unknown => { + for (const handler of handlers) { + if (handler.test(value)) { + const encoded = handler.serialize(value as never); + const envelope: Envelope = { + __datatype: handler.tag, + value: encoded, + }; + return envelope; + } + } + return value; + }; + + const reviver = (_: string, value: unknown): unknown => { + if (isEnvelope(value)) { + const handler = byTag.get(value.__datatype); + if (handler) { + return handler.revive( + (value as Envelope).value as never, + ); + } + } + return value; + }; + + return { replacer, reviver }; } -const reviver: JsonReviver = (_, value) => { - if (isSerializedMap(value)) { - return new Map(value.value); - } - return value; -}; -export function createStorageWithMapSupport(): PersistStorage { +export function createStorageWithMapSupport< + T, + H extends Handler = never, +>(extraHandlers: readonly H[] = [] as const): PersistStorage { + const { replacer, reviver } = makeJson([ + ...defaultHandlers, + ...extraHandlers, + ]); return { getItem: async (name): Promise | null> => { const str = await zustandIndexDBStorage.getItem(name); diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 689d0efe..57048232 100644 --- a/packages/web/src/core/subscriptions.ts +++ b/packages/web/src/core/subscriptions.ts @@ -7,6 +7,7 @@ import { type NodeDB, } from "@core/stores"; import { type MeshDevice, Protobuf } from "@meshtastic/core"; +import { fromByteArray } from "base64-js"; export const subscribeAll = ( device: Device, @@ -72,6 +73,22 @@ export const subscribeAll = ( connection.events.onNodeInfoPacket.subscribe((nodeInfo) => { const nodeWithUser = ensureDefaultUser(nodeInfo); + + if ( + nodeWithUser.num !== myNodeNum && // Ignore my own node + nodeDB.getNode(nodeWithUser.num)?.user?.publicKey !== undefined && // Only flag if the old key is not undefined + fromByteArray( + nodeDB.getNode(nodeWithUser.num)?.user?.publicKey ?? new Uint8Array(), // ... and equal to the new key + ) !== fromByteArray(nodeWithUser.user?.publicKey ?? new Uint8Array()) + ) { + console.warn( + `Node ${nodeWithUser.num} has a different public key than expected`, + ); + nodeDB.setNodeError(nodeWithUser.num, "MISMATCH_PKI"); + + // TODO: Handle this error case properly (refactor PKI dialog?) + } + nodeDB.addNode(nodeWithUser); }); @@ -131,21 +148,21 @@ export const subscribeAll = ( if (routingPacket.data.variant.case === "errorReason") { switch (routingPacket.data.variant.value) { case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT: - console.error(`Routing Error: ${routingPacket.data.variant.value}`); + console.error(`Routing Error: $routingPacket.data.variant.value`); break; case Protobuf.Mesh.Routing_Error.NO_CHANNEL: - console.error(`Routing Error: ${routingPacket.data.variant.value}`); + console.error(`Routing Error: $routingPacket.data.variant.value`); nodeDB.setNodeError( routingPacket.from, - Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value], + routingPacket?.data?.variant?.value, ); device.setDialogOpen("refreshKeys", true); break; case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY: - console.error(`Routing Error: ${routingPacket.data.variant.value}`); + console.error(`Routing Error: $routingPacket.data.variant.value`); nodeDB.setNodeError( routingPacket.from, - Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value], + routingPacket?.data?.variant?.value, ); device.setDialogOpen("refreshKeys", true); break;