Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mobile/modules/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ export {
useSetForeground,
useClearForeground,
useRefresh,
useAppsInitialized,
useAppsLoading,
useAppsRefreshError,
useStopAll,
useInstall,
useUninstall,
Expand Down
87 changes: 87 additions & 0 deletions mobile/modules/engine/src/stores/__tests__/appsRefresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/// <reference types="bun-types" />

import {describe, expect, test} from "bun:test"

import type {ClientApp} from "../../types/applet"
import {runAppsRefresh} from "../appsRefresh"

function makeApp(packageName: string): ClientApp {
return {
packageName,
name: packageName,
webviewUrl: "",
logoUrl: "",
type: "standard",
permissions: [],
running: false,
healthy: true,
hardwareRequirements: [],
offline: false,
offlineRoute: "",
loading: false,
local: true,
hidden: false,
}
}

describe("runAppsRefresh (#1222)", () => {
test("on success: projects the fetched apps, saves the cache, and reports no error", async () => {
const fetched = [makeApp("com.example.a")]
const projected = [makeApp("com.example.a-projected")]
const saved: ClientApp[][] = []

const result = await runAppsRefresh(
async () => fetched,
(apps) => {
expect(apps).toBe(fetched)
return projected
},
(apps) => saved.push(apps),
)

expect(result.apps).toBe(projected)
expect(result.refreshError).toBeNull()
expect(saved).toEqual([projected])
})

test("a fetch failure is captured as refreshError, apps is omitted, and nothing is cached", async () => {
const saved: ClientApp[][] = []

const result = await runAppsRefresh(
async () => {
throw new Error("disk unavailable")
},
(apps) => apps,
(apps) => saved.push(apps),
)

expect(result.apps).toBeUndefined()
expect(result.refreshError).toBe("disk unavailable")
expect(saved).toEqual([])
})

test("a project() failure is also captured (not just the fetch)", async () => {
const result = await runAppsRefresh(
async () => [],
() => {
throw new Error("projectApps blew up")
},
() => {},
)

expect(result.apps).toBeUndefined()
expect(result.refreshError).toBe("projectApps blew up")
})

test("a non-Error throw is stringified rather than dropped", async () => {
const result = await runAppsRefresh(
async () => {
throw "just a string"
},
(apps) => apps,
() => {},
)

expect(result.refreshError).toBe("just a string")
})
})
50 changes: 47 additions & 3 deletions mobile/modules/engine/src/stores/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import sttModelManager from "../services/STTModelManager"
import {miniappLauncher} from "../services/MiniappLauncher"
import {miniappRunningRegistry} from "../services/MiniappRunningRegistry"
import {SETTINGS, useSettingsStore} from "./settings"
import {runAppsRefresh} from "./appsRefresh"
import BluetoothSdk from "@mentra/bluetooth-sdk"

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -77,6 +78,15 @@ interface AppStatusState {
* `projectApps` / `setForeground` / `clearForeground`.
*/
foregroundedPackage: string | null
/** True once the first refresh() (success or failure) has completed. `apps`
* before this point is only ever the persisted cache (or empty) — the host
* uses this to distinguish "still loading" from "confirmed empty" (#1222). */
initialized: boolean
/** True while a refresh() is in flight. */
loading: boolean
/** Message from the most recent failed refresh(); cleared on the next
* attempt (success or failure). Null when no refresh has failed. */
refreshError: string | null
refresh: () => Promise<void>
/** Resolves true if the app actually started; false if a host gate aborted it or its JS context failed to spawn. */
start: (app: ClientApp, opts?: StartOptions) => Promise<boolean>
Expand Down Expand Up @@ -143,6 +153,24 @@ export const sortAppsByPackageNamePriority = (a: ClientApp, b: ClientApp): numbe
return a.name.localeCompare(b.name)
}

// Last-known app list, persisted so the home screen has something to paint
// immediately on cold boot instead of an empty grid while the on-disk scan
// in AppRegistry.getInstalledMiniapps() completes (#1222). Best-effort only:
// a missing/corrupt cache just falls back to an empty list, same as before.
const CACHED_APPS_KEY = "cached_app_list"

export const loadCachedApps = (): ClientApp[] => {
const res = storage.load<ClientApp[]>(CACHED_APPS_KEY)
return res.is_ok() ? res.value : []
}

// Functions (onStart/onStop) don't survive JSON.stringify — they're silently
// dropped, which is fine: a cached snapshot is only ever shown for the brief
// window before the first live refresh() replaces it.
const saveCachedApps = (apps: ClientApp[]): void => {
storage.save(CACHED_APPS_KEY, apps)
}

export const saveLastOpenTime = (packageName: string): void => {
storage.save(`${packageName}_last_open_time`, Date.now())
}
Expand Down Expand Up @@ -269,13 +297,26 @@ function compatibilityEqual(a?: CompatibilityResult, b?: CompatibilityResult): b
}

export const useAppStatusStore = create<AppStatusState>((set, get) => ({
apps: [],
apps: loadCachedApps(),
foregroundedPackage: null,
initialized: false,
loading: false,
refreshError: null,

refresh: async () => {
const previousState = get()
const localApps = await appRegistry.getInstalledMiniapps()
set({apps: projectApps(previousState, localApps)})
set({loading: true})
const result = await runAppsRefresh(
() => appRegistry.getInstalledMiniapps(),
(localApps) => projectApps(previousState, localApps),
saveCachedApps,
)
set({
...(result.apps ? {apps: result.apps} : {}),
loading: false,
initialized: true,
refreshError: result.refreshError,
})
},

start: async (clientApp: ClientApp, opts?: StartOptions) => {
Expand Down Expand Up @@ -563,6 +604,9 @@ export const useStop = () => useAppStatusStore((state) => state.stop)
export const useSetForeground = () => useAppStatusStore((state) => state.setForeground)
export const useClearForeground = () => useAppStatusStore((state) => state.clearForeground)
export const useRefresh = () => useAppStatusStore((state) => state.refresh)
export const useAppsInitialized = () => useAppStatusStore((state) => state.initialized)
export const useAppsLoading = () => useAppStatusStore((state) => state.loading)
export const useAppsRefreshError = () => useAppStatusStore((state) => state.refreshError)
export const useStopAll = () => useAppStatusStore((state) => state.stopAll)
export const useInstall = () => useAppStatusStore((state) => state.install)
export const useUninstall = () => useAppStatusStore((state) => state.uninstall)
Expand Down
37 changes: 37 additions & 0 deletions mobile/modules/engine/src/stores/appsRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* apps store refresh orchestration — pulled out of `apps.ts` so the
* fetch → project → cache → error-capture sequence (#1222) is testable
* without pulling in the apps store's full singleton graph (AppRegistry,
* MiniappLauncher, MiniappRunningRegistry, ...). Every effect is injected.
*/

import type {ClientApp} from "../types/applet"

export interface AppsRefreshResult {
/** Omitted on failure — the caller keeps its previous `apps` snapshot. */
apps?: ClientApp[]
/** Message from this attempt; null on success. */
refreshError: string | null
}

/**
* Runs one refresh attempt: fetch installed apps, project them, and persist
* the result via `saveCache`. Never throws — a failure from either callback
* is captured into `refreshError` instead, so the caller can always flip
* `initialized`/`loading` and move on rather than hanging on a rejected
* promise (the bug behind #1222: a failed load with no visible outcome).
*/
export async function runAppsRefresh(
getInstalledApps: () => Promise<ClientApp[]>,
project: (apps: ClientApp[]) => ClientApp[],
saveCache: (apps: ClientApp[]) => void,
): Promise<AppsRefreshResult> {
try {
const projected = project(await getInstalledApps())
saveCache(projected)
return {apps: projected, refreshError: null}
} catch (error) {
console.error("ISLAND: refresh() failed", error)
return {refreshError: error instanceof Error ? error.message : String(error)}
}
}
34 changes: 29 additions & 5 deletions mobile/src/app/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ import MaskedView from "@react-native-masked-view/masked-view"
import {CustomBackground} from "@/components/home/CustomBackground"
import {AppsGrid} from "@/components/home/AppsGrid"
import {PairGlassesCard} from "@/components/home/PairGlassesCard"
import {Screen} from "@/components/ignite"
import {Button, Screen, Text} from "@/components/ignite"
import {Group} from "@/components/ui"
import {BgTimer, engine, useRefresh} from "@mentra/engine"
import {SETTINGS, useSetting} from "@mentra/engine"
import {
BgTimer,
engine,
useApps,
useAppsInitialized,
useAppsRefreshError,
useRefresh,
SETTINGS,
useSetting,
} from "@mentra/engine"
import {appSwitcherProgress} from "@/stores/appSwitcher"
import {useEngineSnapshot} from "@/hooks/useEngineSnapshot"
import AppSwitcherButton from "@/components/home/AppSwitcherButtton"
Expand All @@ -24,6 +32,13 @@ import {BlurTargetView, BlurView} from "expo-blur"

export default function Homepage() {
const refreshApps = useRefresh()
const apps = useApps()
const appsInitialized = useAppsInitialized()
const appsRefreshError = useAppsRefreshError()
// Nothing to show yet and the last attempt failed: without this, a failed
// refresh() (e.g. a corrupted install record) left the user staring at an
// empty grid forever, with a full app reboot as the only recovery (#1222).
const appsLoadFailed = appsInitialized && apps.length === 0 && appsRefreshError !== null
// Pairing-identity read-model: none | pending (chosen, never paired) | paired.
const identity = useEngineSnapshot(engine.pairing.identity, (onChange) => engine.pairing.onIdentity(onChange))
const pairedModel = identity.kind === "paired" ? identity.model : ""
Expand Down Expand Up @@ -71,7 +86,7 @@ export default function Homepage() {
</Group>
<View className="h-2" />
<View className="flex-1" />
<AppsGrid />
{appsLoadFailed ? <AppsLoadRetry onRetry={refreshApps} /> : <AppsGrid showPlaceholders={!appsInitialized} />}
</>
)
}
Expand All @@ -85,7 +100,7 @@ export default function Homepage() {
<ControllerStatus />
</Group>
<View className="h-2" />
<AppsGrid />
{appsLoadFailed ? <AppsLoadRetry onRetry={refreshApps} /> : <AppsGrid showPlaceholders={!appsInitialized} />}
</>
)
}
Expand Down Expand Up @@ -184,3 +199,12 @@ export default function Homepage() {
</>
)
}

function AppsLoadRetry({onRetry}: {onRetry: () => void}) {
return (
<View className="items-center py-8">
<Text tx="home:appsLoadFailed" className="text-foreground mb-3" />
<Button tx="home:appsLoadRetry" preset="secondary" onPress={onRetry} />
</View>
)
}
2 changes: 2 additions & 0 deletions mobile/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ const en = {
getSupportMessage: "You will be taken to our support page in your browser.",
connectingGlasses: "Connecting glasses...",
connectingController: "Connecting ring...",
appsLoadFailed: "Couldn't load your apps",
appsLoadRetry: "Retry",
emptyActiveAppListInfo: "Your active apps will appear here.",
emptyInactiveAppListInfo: "Your inactive apps will appear here.",
noActiveApps: "No Active Apps",
Expand Down