diff --git a/miniapps/navigation/miniapp.json b/miniapps/navigation/miniapp.json index a5c7c048c0..4c4502de71 100644 --- a/miniapps/navigation/miniapp.json +++ b/miniapps/navigation/miniapp.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/@mentra/miniapp-cli/schema/miniapp.schema.json", "packageName": "com.mentra.navigation", - "version": "1.1.29", + "version": "1.1.30", "name": "Mentra Maps", "description": "Turn-by-turn navigation with visual or spoken directions.", "icon": "icon.png", diff --git a/miniapps/navigation/src/background/lib/capabilities.ts b/miniapps/navigation/src/background/lib/capabilities.ts index b9c1233060..abf5dfc03d 100644 --- a/miniapps/navigation/src/background/lib/capabilities.ts +++ b/miniapps/navigation/src/background/lib/capabilities.ts @@ -14,11 +14,15 @@ export function readGlassesCapabilities(capabilities: unknown): GlassesCapabilit const display = record.display && typeof record.display === "object" ? (record.display as Record) : null const modelName = typeof record.modelName === "string" ? record.modelName : null + // Respect an explicit top-level false. Some legacy profiles retain a display + // descriptor for shape/documentation even when no display is currently + // available; the descriptor alone must not turn the display back on. + const hasDisplay = typeof record.hasDisplay === "boolean" ? record.hasDisplay : display != null return { modelName, - hasDisplay: record.hasDisplay === true || !!display, - canPosition: display != null && display.canPosition !== false, + hasDisplay, + canPosition: hasDisplay && display != null && display.canPosition !== false, hasSpeaker: record.hasSpeaker === true, hasButton: record.hasButton === true, } diff --git a/miniapps/navigation/src/test/capabilities.test.ts b/miniapps/navigation/src/test/capabilities.test.ts index 37033b9091..4e62b4539c 100644 --- a/miniapps/navigation/src/test/capabilities.test.ts +++ b/miniapps/navigation/src/test/capabilities.test.ts @@ -46,4 +46,14 @@ describe("navigation glasses capabilities", () => { test("does not claim positioning support without a display", () => { expect(readGlassesCapabilities({modelName: "Mentra Live", hasDisplay: false}).canPosition).toBe(false) }) + + test("respects hasDisplay false when a legacy descriptor is still present", () => { + expect( + readGlassesCapabilities({ + modelName: "None", + hasDisplay: false, + display: {resolution: {width: 640, height: 480}}, + }), + ).toMatchObject({hasDisplay: false, canPosition: false}) + }) }) diff --git a/mobile/assets/miniapps/com.mentra.navigation-1.1.29.zip b/mobile/assets/miniapps/com.mentra.navigation-1.1.30.zip similarity index 88% rename from mobile/assets/miniapps/com.mentra.navigation-1.1.29.zip rename to mobile/assets/miniapps/com.mentra.navigation-1.1.30.zip index 2a92151790..392ac4d8bd 100644 Binary files a/mobile/assets/miniapps/com.mentra.navigation-1.1.29.zip and b/mobile/assets/miniapps/com.mentra.navigation-1.1.30.zip differ diff --git a/mobile/modules/engine/src/services/LocalMiniappRuntime.ts b/mobile/modules/engine/src/services/LocalMiniappRuntime.ts index 3611bc4733..958c079920 100644 --- a/mobile/modules/engine/src/services/LocalMiniappRuntime.ts +++ b/mobile/modules/engine/src/services/LocalMiniappRuntime.ts @@ -102,6 +102,8 @@ type SpeakerStateValue = "idle" | "loading" | "playing" | "stopped" | "error" interface ConnectedMiniapp { subscriptions: Set + /** Invalidates async CONNECT completions when this context is reset. */ + handshakeGeneration: number /** Transcription streams this app explicitly requires to stay on-device. */ forceLocalTranscriptionStreams: Set /** Transcription streams with at least one listener using the default/cloud route. */ @@ -400,6 +402,8 @@ class LocalMiniappRuntime { private cloudResultsWired = false private cloudStatusWired = false private cloudAudioSubscriptionSync = new CloudAudioSubscriptionSync() + /** Pushes a fresh capability profile when pairing promotes a new wearable. */ + private capabilityUpdatesUnsubscribe: (() => void) | null = null /** * Per-miniapp language hints from TRANSCRIPTION_CONFIG (issue 021 WP3). @@ -759,6 +763,7 @@ class LocalMiniappRuntime { } this.connectedApps.set(packageName, { subscriptions: new Set(), + handshakeGeneration: 0, forceLocalTranscriptionStreams: new Set(), cloudTranscriptionStreams: new Set(), sendMessage: sendFn, @@ -776,6 +781,7 @@ class LocalMiniappRuntime { // The WebView will re-SUBSCRIBE shortly and the rate will reappear. this.recomputeLocationTier() this.ensureCloudStatusWired() + this.ensureCapabilityUpdatesWired() this.ensurePingLoop() // Show the system boot message ("Starting …") on the glasses for the @@ -834,6 +840,8 @@ class LocalMiniappRuntime { * are failed so a wake mid-respawn retries rather than hanging. */ public resetHandshake(packageName: string): void { + const app = this.connectedApps.get(packageName) + if (app) app.handshakeGeneration += 1 this.handshookApps.delete(packageName) this.flushConnectWaiters(packageName, new Error(`${packageName} respawning`)) } @@ -998,6 +1006,7 @@ class LocalMiniappRuntime { if (this.connectedApps.size === 0) { this.stopPingLoop() + this.stopCapabilityUpdates() } } @@ -1363,17 +1372,12 @@ class LocalMiniappRuntime { console.warn(`${LOG_TAG}: CONNECT from unregistered app ${packageName}, ignoring`) return } + const handshakeGeneration = ++existing.handshakeGeneration // Update lastPongAt so it doesn't time out right away existing.lastPongAt = Date.now() existing.unansweredPingRounds = 0 - // Read current glasses capabilities from the settings store - const defaultWearable = useSettingsStore.getState().getSetting(ISLAND_SETTINGS_KEYS.defaultWearable) as - | DeviceTypes - | undefined - const capabilities = getModelCapabilities(defaultWearable || DeviceTypes.NONE) - // Build the declared-permission record for the SDK's session.permissions // module. Lower-cased to match v3's PermissionType union (microphone, // camera, location, notifications, calendar). Missing permissions default @@ -1387,6 +1391,16 @@ class LocalMiniappRuntime { this.clearMiniappAuthDeliveryRetry(packageName) const authPromise = this.requestMiniappAuth(packageName) const initialAuth = await withTimeout(authPromise, 1_500) + + // CONNECT is asynchronous because auth minting may take up to 1.5s. A + // crash-respawn can replace this package's ConnectedMiniapp during that + // window; never let the stale handshake send into or mark the replacement + // context as connected. + if (this.connectedApps.get(packageName) !== existing || existing.handshakeGeneration !== handshakeGeneration) { + console.log(`${LOG_TAG}: ignoring stale CONNECT completion for replaced app ${packageName}`) + return + } + const userId = initialAuth?.mentraUserId ?? "" if (initialAuth) { existing.authDelivered = true @@ -1399,7 +1413,9 @@ class LocalMiniappRuntime { type: MiniappResponseType.CONNECT_ACK, userId, packageName, - capabilities, + // Resolve after the async auth mint. Pairing may promote the wearable + // while CONNECT is waiting, and the ACK must carry the newest profile. + capabilities: this.currentCapabilities(), permissions: declaredPermissions, ...(initialAuth ? {auth: initialAuth} : {}), }, @@ -1413,7 +1429,7 @@ class LocalMiniappRuntime { // trying to mint (and re-drive on cloud-connect) so the app authenticates // to its backend on its own, instead of staying dead until a full manual // Cloud V2 reconnect re-runs the whole handshake. - this.deliverInitialMiniappAuth(packageName, authPromise, 0) + this.deliverInitialMiniappAuth(packageName, existing, handshakeGeneration, authPromise, 0) } this.sendCloudStatusToMiniapp(packageName) @@ -1510,45 +1526,53 @@ class LocalMiniappRuntime { */ private deliverInitialMiniappAuth( packageName: string, + app: ConnectedMiniapp, + handshakeGeneration: number, inFlight: Promise | undefined, attempt: number, ): void { - const app = this.connectedApps.get(packageName) - if (!app || app.authDelivered) return + if (!this.isCurrentHandshake(packageName, app, handshakeGeneration) || app.authDelivered) return const mint = inFlight ?? this.requestMiniappAuth(packageName) void mint .then((auth) => { - const current = this.connectedApps.get(packageName) - if (!current || current.authDelivered) return + if (!this.isCurrentHandshake(packageName, app, handshakeGeneration) || app.authDelivered) return if (!auth) return // no auth hook — permanent, don't spin - current.authDelivered = true + app.authDelivered = true this.clearMiniappAuthDeliveryRetry(packageName) this.scheduleMiniappAuthRefresh(packageName, auth) this.sendToMiniapp(packageName, {type: MiniappResponseType.AUTH_UPDATE, auth}) }) .catch((err) => { + if (!this.isCurrentHandshake(packageName, app, handshakeGeneration)) return console.warn( `${LOG_TAG}: miniapp auth mint failed for ${packageName} (attempt ${attempt}): ${ (err as Error)?.message ?? err }`, ) - this.scheduleInitialMiniappAuthRetry(packageName, attempt) + this.scheduleInitialMiniappAuthRetry(packageName, app, handshakeGeneration, attempt) }) } - private scheduleInitialMiniappAuthRetry(packageName: string, attempt: number): void { - const app = this.connectedApps.get(packageName) - if (!app || app.authDelivered) return + private scheduleInitialMiniappAuthRetry( + packageName: string, + app: ConnectedMiniapp, + handshakeGeneration: number, + attempt: number, + ): void { + if (!this.isCurrentHandshake(packageName, app, handshakeGeneration) || app.authDelivered) return this.clearMiniappAuthDeliveryRetry(packageName) const delay = Math.min(MINIAPP_AUTH_RETRY_MAX_MS, MINIAPP_AUTH_RETRY_BASE_MS * 2 ** attempt) app.authRetryTimerId = BgTimer.setTimeout(() => { - const current = this.connectedApps.get(packageName) - if (!current) return - current.authRetryTimerId = null - this.deliverInitialMiniappAuth(packageName, undefined, attempt + 1) + app.authRetryTimerId = null + if (!this.isCurrentHandshake(packageName, app, handshakeGeneration)) return + this.deliverInitialMiniappAuth(packageName, app, handshakeGeneration, undefined, attempt + 1) }, delay) } + private isCurrentHandshake(packageName: string, app: ConnectedMiniapp, handshakeGeneration: number): boolean { + return this.connectedApps.get(packageName) === app && app.handshakeGeneration === handshakeGeneration + } + private clearMiniappAuthDeliveryRetry(packageName: string): void { const app = this.connectedApps.get(packageName) if (!app || app.authRetryTimerId === null) return @@ -1570,7 +1594,7 @@ class LocalMiniappRuntime { // state; skip anything still mid-handshake (handleConnect owns that). if (!this.handshookApps.has(packageName)) continue this.clearMiniappAuthDeliveryRetry(packageName) - this.deliverInitialMiniappAuth(packageName, undefined, 0) + this.deliverInitialMiniappAuth(packageName, app, app.handshakeGeneration, undefined, 0) } } @@ -3753,6 +3777,43 @@ class LocalMiniappRuntime { ) } + private currentCapabilities() { + const defaultWearable = useSettingsStore.getState().getSetting(ISLAND_SETTINGS_KEYS.defaultWearable) as + | DeviceTypes + | undefined + return getModelCapabilities(defaultWearable || DeviceTypes.NONE) + } + + /** + * Keep already-connected MiniappSession capability snapshots synchronized + * with pairing identity. CONNECT_ACK is only an initial snapshot; a wearable + * can be promoted after a miniapp starts (the normal pairing-success race), + * so every completed handshake also needs CAPABILITIES_UPDATE. + */ + private ensureCapabilityUpdatesWired(): void { + if (this.capabilityUpdatesUnsubscribe) return + this.capabilityUpdatesUnsubscribe = useSettingsStore.subscribe( + (state) => state.getSetting(ISLAND_SETTINGS_KEYS.defaultWearable) as DeviceTypes | undefined, + (defaultWearable) => { + const capabilities = getModelCapabilities(defaultWearable || DeviceTypes.NONE) + console.log( + `${LOG_TAG}: wearable capabilities changed to ${capabilities.modelName}; notifying ${this.handshookApps.size} miniapp(s)`, + ) + for (const packageName of this.handshookApps) { + this.sendToMiniapp(packageName, { + type: MiniappResponseType.CAPABILITIES_UPDATE, + capabilities, + }) + } + }, + ) + } + + private stopCapabilityUpdates(): void { + this.capabilityUpdatesUnsubscribe?.() + this.capabilityUpdatesUnsubscribe = null + } + private currentCloudStatus(): CloudClientStatusSnapshot { const base = cloudClientService.getStatus() const fallbackActive = @@ -4557,6 +4618,7 @@ class LocalMiniappRuntime { public cleanup(): void { console.log(`${LOG_TAG}: cleanup()`) this.stopPingLoop() + this.stopCapabilityUpdates() // Copy keys since unregisterApp mutates the map const packageNames = [...this.connectedApps.keys()] diff --git a/mobile/src/generated/bundledMiniapps.ts b/mobile/src/generated/bundledMiniapps.ts index 68439e7a64..eae53253f0 100644 --- a/mobile/src/generated/bundledMiniapps.ts +++ b/mobile/src/generated/bundledMiniapps.ts @@ -10,7 +10,7 @@ export const BUNDLED_MINIAPPS: number[] = [ require("@assets/miniapps/com.mentra.captions-1.0.15.zip"), require("@assets/miniapps/com.mentra.livestreamer-1.0.15.zip"), require("@assets/miniapps/com.mentra.merge-0.1.29.zip"), - require("@assets/miniapps/com.mentra.navigation-1.1.29.zip"), + require("@assets/miniapps/com.mentra.navigation-1.1.30.zip"), require("@assets/miniapps/com.mentra.notes-1.0.16.zip"), require("@assets/miniapps/com.mentra.recorder-1.0.7.zip"), require("@assets/miniapps/com.mentra.teleprompter-1.0.6.zip"), diff --git a/mobile/src/services/__tests__/localMiniappCapabilities.test.ts b/mobile/src/services/__tests__/localMiniappCapabilities.test.ts new file mode 100644 index 0000000000..019ba08415 --- /dev/null +++ b/mobile/src/services/__tests__/localMiniappCapabilities.test.ts @@ -0,0 +1,159 @@ +/* eslint-disable no-restricted-imports, import/first */ +import {MiniappRequestType, MiniappResponseType, parseEnvelope, serializeEnvelope} from "@mentra/miniapp" + +jest.mock("react-native-share", () => ({__esModule: true, default: {open: jest.fn()}})) +jest.unmock("../../../modules/engine/src/services/LocalMiniappRuntime") + +import {ISLAND_SETTINGS_KEYS, type MiniappAuthToken} from "../../../modules/engine/src/runtime/config" +import {cloudClientService} from "../../../modules/engine/src/services/CloudClientService" +import localMiniappRuntime from "../../../modules/engine/src/services/LocalMiniappRuntime" +import {useSettingsStore} from "../../../modules/engine/src/stores/settings" +import {DeviceTypes} from "../../../modules/engine/src/types" + +describe("LocalMiniappRuntime capability updates", () => { + const packageName = "com.example.capability-subscriber" + const sent: string[] = [] + let originalWearable: unknown + + beforeEach(() => { + sent.length = 0 + originalWearable = useSettingsStore.getState().getSetting(ISLAND_SETTINGS_KEYS.defaultWearable) + setDefaultWearable(DeviceTypes.SIMULATED) + jest.spyOn(cloudClientService, "getMiniappAuthToken").mockResolvedValue(null as never) + localMiniappRuntime.registerApp(packageName, (raw) => sent.push(raw)) + }) + + afterEach(() => { + localMiniappRuntime.unregisterApp(packageName) + setDefaultWearable(originalWearable) + jest.restoreAllMocks() + }) + + it("refreshes a handshook session when pairing promotes a different wearable", async () => { + await connect("connect-1") + + const ack = payloads().find((payload) => payload.type === MiniappResponseType.CONNECT_ACK) + expect((ack?.capabilities as {modelName?: string})?.modelName).toBe(DeviceTypes.SIMULATED) + + sent.length = 0 + setDefaultWearable(DeviceTypes.G2) + + const update = payloads().find((payload) => payload.type === MiniappResponseType.CAPABILITIES_UPDATE) + expect(update?.capabilities).toMatchObject({ + modelName: DeviceTypes.G2, + hasDisplay: true, + display: {canPosition: true}, + }) + }) + + it("uses the latest wearable in CONNECT_ACK when pairing changes during auth", async () => { + let finishAuth!: (value: null) => void + jest + .mocked(cloudClientService.getMiniappAuthToken) + .mockReturnValueOnce(new Promise((resolve) => (finishAuth = resolve)) as never) + + const connected = connect("connect-during-auth") + setDefaultWearable(DeviceTypes.G2) + finishAuth(null) + await connected + + const ack = payloads().find((payload) => payload.type === MiniappResponseType.CONNECT_ACK) + expect(ack?.capabilities).toMatchObject({ + modelName: DeviceTypes.G2, + hasDisplay: true, + display: {canPosition: true}, + }) + }) + + it("ignores a stale CONNECT completion after the app is replaced", async () => { + let finishOldAuth!: (value: null) => void + jest + .mocked(cloudClientService.getMiniappAuthToken) + .mockReturnValueOnce(new Promise((resolve) => (finishOldAuth = resolve)) as never) + + localMiniappRuntime.handleRawMessage( + packageName, + serializeEnvelope({payload: {type: MiniappRequestType.CONNECT}, requestId: "old-connect"}), + ) + + const replacementSent: string[] = [] + localMiniappRuntime.registerApp(packageName, (raw) => replacementSent.push(raw)) + finishOldAuth(null) + await expect(localMiniappRuntime.waitForConnect(packageName, 25)).rejects.toThrow("did not connect") + expect(replacementSent).toEqual([]) + + await connect("replacement-connect") + }) + + it("invalidates an in-flight CONNECT when crash recovery resets the handshake", async () => { + let finishOldAuth!: (value: null) => void + jest + .mocked(cloudClientService.getMiniappAuthToken) + .mockReturnValueOnce(new Promise((resolve) => (finishOldAuth = resolve)) as never) + + localMiniappRuntime.handleRawMessage( + packageName, + serializeEnvelope({payload: {type: MiniappRequestType.CONNECT}, requestId: "pre-reset-connect"}), + ) + localMiniappRuntime.resetHandshake(packageName) + + const replacementWait = localMiniappRuntime.waitForConnect(packageName, 25) + finishOldAuth(null) + await expect(replacementWait).rejects.toThrow("did not connect") + expect(payloads().find((payload) => payload.type === MiniappResponseType.CONNECT_ACK)).toBeUndefined() + + sent.length = 0 + await connect("post-reset-connect") + expect(payloads().find((payload) => payload.type === MiniappResponseType.CONNECT_ACK)).toBeDefined() + }) + + it("drops a late initial auth mint after the handshake is reset", async () => { + let finishAuth!: (value: MiniappAuthToken) => void + jest + .mocked(cloudClientService.getMiniappAuthToken) + .mockReturnValueOnce(new Promise((resolve) => (finishAuth = resolve))) + + await connect("auth-timeout-connect", 2_500) + localMiniappRuntime.resetHandshake(packageName) + sent.length = 0 + + finishAuth({mentraUserId: "user-1", token: "late-token", expiresAt: Date.now() + 60_000}) + await Promise.resolve() + expect(payloads().find((payload) => payload.type === MiniappResponseType.AUTH_UPDATE)).toBeUndefined() + }) + + it("reports a display-less wearable and stops updates after the last app unregisters", async () => { + await connect("display-less-connect") + sent.length = 0 + + setDefaultWearable(DeviceTypes.NONE) + const update = payloads().find((payload) => payload.type === MiniappResponseType.CAPABILITIES_UPDATE) + expect(update?.capabilities).toMatchObject({modelName: DeviceTypes.NONE, hasDisplay: false}) + + sent.length = 0 + localMiniappRuntime.unregisterApp(packageName) + setDefaultWearable(DeviceTypes.G2) + expect(sent).toEqual([]) + }) + + async function connect(requestId: string, timeoutMs = 1_000): Promise { + const connected = localMiniappRuntime.waitForConnect(packageName, timeoutMs) + localMiniappRuntime.handleRawMessage( + packageName, + serializeEnvelope({payload: {type: MiniappRequestType.CONNECT}, requestId}), + ) + await connected + } + + function payloads(): Array> { + return sent + .map((raw) => parseEnvelope(raw)?.payload) + .filter((payload): payload is Record => typeof payload === "object" && payload !== null) + } + + function setDefaultWearable(value: unknown): void { + useSettingsStore.setState((state) => ({ + settings: {...state.settings, [ISLAND_SETTINGS_KEYS.defaultWearable]: value}, + })) + } +})