@glassly/engine 0.3.7-dev.888 → 0.3.7-dev.892

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/build/engine.d.ts +6 -0
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +3 -0
  4. package/build/engine.js.map +1 -1
  5. package/build/facades/backup.d.ts +14 -0
  6. package/build/facades/backup.d.ts.map +1 -0
  7. package/build/facades/backup.js +22 -0
  8. package/build/facades/backup.js.map +1 -0
  9. package/build/generated/releaseMetadata.js +3 -3
  10. package/build/generated/releaseMetadata.js.map +1 -1
  11. package/build/index.d.ts +1 -0
  12. package/build/index.d.ts.map +1 -1
  13. package/build/index.js.map +1 -1
  14. package/build/services/AppStateBackup.d.ts +98 -0
  15. package/build/services/AppStateBackup.d.ts.map +1 -0
  16. package/build/services/AppStateBackup.js +224 -0
  17. package/build/services/AppStateBackup.js.map +1 -0
  18. package/build/services/CloudClientService.d.ts +1 -0
  19. package/build/services/CloudClientService.d.ts.map +1 -1
  20. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  21. package/build/services/LocalMiniappRuntime.js +18 -4
  22. package/build/services/LocalMiniappRuntime.js.map +1 -1
  23. package/build/services/MiniappEngine.d.ts +9 -0
  24. package/build/services/MiniappEngine.d.ts.map +1 -1
  25. package/build/services/MiniappEngine.js +60 -27
  26. package/build/services/MiniappEngine.js.map +1 -1
  27. package/build/services/NavigationService.d.ts +1 -0
  28. package/build/services/NavigationService.d.ts.map +1 -1
  29. package/build/services/NavigationService.js +20 -6
  30. package/build/services/NavigationService.js.map +1 -1
  31. package/build/services/appStateBackupFile.d.ts +32 -0
  32. package/build/services/appStateBackupFile.d.ts.map +1 -0
  33. package/build/services/appStateBackupFile.js +135 -0
  34. package/build/services/appStateBackupFile.js.map +1 -0
  35. package/package.json +7 -7
  36. package/src/engine.ts +3 -0
  37. package/src/facades/backup.ts +28 -0
  38. package/src/generated/releaseMetadata.ts +3 -3
  39. package/src/index.ts +1 -0
  40. package/src/services/AppStateBackup.ts +283 -0
  41. package/src/services/LocalMiniappRuntime.ts +21 -4
  42. package/src/services/MiniappEngine.ts +62 -27
  43. package/src/services/NavigationService.ts +28 -8
  44. package/src/services/appStateBackupFile.ts +171 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * App state backup files: the share-sheet export and file-picker restore
3
+ * behind Settings > Profile > Backup. What goes in the file is decided in
4
+ * AppStateBackup.ts; this side reads and writes the live stores.
5
+ */
6
+ import { File, Paths } from "expo-file-system";
7
+ import { Platform } from "react-native";
8
+ import Share from "react-native-share";
9
+ import { SETTINGS, useSettingsStore } from "../stores/settings";
10
+ import { useAppStatusStore } from "../stores/apps";
11
+ import { storage } from "../utils/storage/storage";
12
+ import appRegistry, { saveAlwaysOnOverride, saveWidgetExpandChoice } from "./AppRegistry";
13
+ import { AppStateBackupError, collectAppStateBackup, miniappPrefs, miniappsWithStorage, parseAppStateBackup, restorableSettings, restoreMiniappStorage, } from "./AppStateBackup";
14
+ import { cloudClientService } from "./CloudClientService";
15
+ import { memoryStore } from "./memoryStoreInstance";
16
+ import { respawnMiniapp, runningMiniappPackages, stopMiniapp } from "./MiniappEngine";
17
+ const PICK_MIME_TYPES = Platform.OS === "android" ? ["application/json", "application/octet-stream"] : ["application/json"];
18
+ const backend = {
19
+ get(key) {
20
+ const result = storage.load(key);
21
+ return result.is_ok() ? result.value : null;
22
+ },
23
+ set(key, value) {
24
+ const result = storage.save(key, value);
25
+ if (result.is_error())
26
+ throw result.error;
27
+ },
28
+ keys: () => storage.getAllKeys(),
29
+ };
30
+ export async function exportAppStateFile() {
31
+ const userId = await cloudClientService.resolveUserId();
32
+ const installed = [];
33
+ for (const app of await appRegistry.getInstalledMiniapps()) {
34
+ const version = await appRegistry.getActiveVersion(app.packageName);
35
+ if (!version)
36
+ continue;
37
+ installed.push({
38
+ packageName: app.packageName,
39
+ version,
40
+ source: appRegistry.getReleaseIdentity(app.packageName, version)?.source,
41
+ });
42
+ }
43
+ const backup = collectAppStateBackup({
44
+ backend,
45
+ userId,
46
+ settings: Object.values(SETTINGS),
47
+ installed,
48
+ memory: await memoryStore.exportBackup(),
49
+ now: Date.now(),
50
+ });
51
+ const filename = `glassly-backup-${localDate(backup.exportedAt)}.json`;
52
+ const file = new File(Paths.cache, filename);
53
+ file.writeSync(JSON.stringify(backup));
54
+ try {
55
+ await Share.open({ url: file.uri, type: "application/json", filename, title: "Glassly backup" });
56
+ return { success: true };
57
+ }
58
+ catch (error) {
59
+ // react-native-share rejects when the user dismisses the sheet.
60
+ if (error?.message?.includes("User did not share"))
61
+ return { success: false, cancelled: true };
62
+ throw error;
63
+ }
64
+ }
65
+ /** Resolves null when the user cancels the picker. */
66
+ export async function importAppStateFile(opts = {}) {
67
+ const picked = await File.pickFileAsync({ mimeTypes: PICK_MIME_TYPES });
68
+ if (picked.canceled)
69
+ return null;
70
+ let data;
71
+ try {
72
+ data = JSON.parse(await picked.result.text());
73
+ }
74
+ catch {
75
+ throw new AppStateBackupError("Not a Glassly app state backup");
76
+ }
77
+ const backup = parseAppStateBackup(data);
78
+ const userId = await cloudClientService.resolveUserId();
79
+ // First, so a memory section this build rejects fails the restore before anything is written.
80
+ const memory = backup.memory ? await memoryStore.importBackup(backup.memory) : null;
81
+ const { plain, indexed } = restorableSettings(backup, Object.values(SETTINGS));
82
+ const written = await useSettingsStore.getState().setManyLocally(plain);
83
+ if (written.is_error())
84
+ throw written.error;
85
+ // setManyLocally only takes registry keys; `key:index` values go to storage and the store directly.
86
+ for (const [key, value] of Object.entries(indexed))
87
+ backend.set(key, value);
88
+ if (Object.keys(indexed).length > 0) {
89
+ useSettingsStore.setState((state) => ({ settings: { ...state.settings, ...indexed } }));
90
+ }
91
+ // A running miniapp holds its old values in memory and could write them back over the
92
+ // restore, so the ones getting storage stop now and start again at the end.
93
+ const running = new Set(runningMiniappPackages());
94
+ const restarting = miniappsWithStorage(backup).filter((packageName) => running.has(packageName));
95
+ for (const packageName of restarting)
96
+ await stopMiniapp(packageName);
97
+ try {
98
+ const restored = restoreMiniappStorage(backend, userId, backup);
99
+ const apps = useAppStatusStore.getState();
100
+ for (const [packageName, record] of Object.entries(backup.miniapps)) {
101
+ const prefs = miniappPrefs(record);
102
+ if (prefs.hidden !== undefined)
103
+ apps.setHiddenStatus(packageName, prefs.hidden);
104
+ if (prefs.alwaysOn !== undefined)
105
+ saveAlwaysOnOverride(packageName, prefs.alwaysOn);
106
+ for (const [widgetId, expand] of Object.entries(prefs.widgetExpand)) {
107
+ saveWidgetExpandChoice(packageName, widgetId, expand);
108
+ }
109
+ }
110
+ appRegistry.markRefreshNeeded();
111
+ await apps.refresh();
112
+ const installedNow = new Set((await appRegistry.getInstalledMiniapps()).map((app) => app.packageName));
113
+ const missing = Object.entries(backup.miniapps)
114
+ .filter(([packageName, record]) => record.installed && !installedNow.has(packageName))
115
+ .map(([packageName]) => packageName);
116
+ if (missing.length > 0)
117
+ await opts.reinstall?.(missing);
118
+ return {
119
+ settings: Object.keys(plain).length + Object.keys(indexed).length,
120
+ miniapps: restored.packages.length,
121
+ memory,
122
+ missing,
123
+ };
124
+ }
125
+ finally {
126
+ for (const packageName of restarting)
127
+ await respawnMiniapp(packageName);
128
+ }
129
+ }
130
+ function localDate(ms) {
131
+ const d = new Date(ms);
132
+ const pad = (n) => String(n).padStart(2, "0");
133
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
134
+ }
135
+ //# sourceMappingURL=appStateBackupFile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"appStateBackupFile.js","sourceRoot":"","sources":["../../src/services/appStateBackupFile.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * App state backup files: the share-sheet export and file-picker restore\n * behind Settings > Profile > Backup. What goes in the file is decided in\n * AppStateBackup.ts; this side reads and writes the live stores.\n */\n\nimport type {MemoryImportResult} from \"@glassly/miniapp\"\nimport {File, Paths} from \"expo-file-system\"\nimport {Platform} from \"react-native\"\nimport Share from \"react-native-share\"\n\nimport {SETTINGS, useSettingsStore} from \"../stores/settings\"\nimport {useAppStatusStore} from \"../stores/apps\"\nimport {storage} from \"../utils/storage/storage\"\nimport appRegistry, {saveAlwaysOnOverride, saveWidgetExpandChoice} from \"./AppRegistry\"\nimport {\n AppStateBackupError,\n collectAppStateBackup,\n miniappPrefs,\n miniappsWithStorage,\n parseAppStateBackup,\n restorableSettings,\n restoreMiniappStorage,\n type AppStateBackupBackend,\n} from \"./AppStateBackup\"\nimport {cloudClientService} from \"./CloudClientService\"\nimport {memoryStore} from \"./memoryStoreInstance\"\nimport {respawnMiniapp, runningMiniappPackages, stopMiniapp} from \"./MiniappEngine\"\n\nexport interface AppStateExportResult {\n success: boolean\n cancelled?: boolean\n}\n\nexport interface AppStateRestoreResult {\n /** Settings written. */\n settings: number\n /** Miniapps whose storage was restored. */\n miniapps: number\n memory: MemoryImportResult | null\n /** Installed when the backup was made but not on this phone now. */\n missing: string[]\n}\n\nexport interface AppStateRestoreOptions {\n /**\n * Install what the backup had installed and this phone lacks. Runs before\n * the restored miniapps start again, so the dashboard comes back with every\n * widget provider its saved layout names: it drops unknown widgets from the\n * layout the next time it saves it.\n */\n reinstall?: (packageNames: string[]) => Promise<void>\n}\n\nconst PICK_MIME_TYPES =\n Platform.OS === \"android\" ? [\"application/json\", \"application/octet-stream\"] : [\"application/json\"]\n\nconst backend: AppStateBackupBackend = {\n get(key) {\n const result = storage.load<unknown>(key)\n return result.is_ok() ? result.value : null\n },\n set(key, value) {\n const result = storage.save(key, value)\n if (result.is_error()) throw result.error\n },\n keys: () => storage.getAllKeys(),\n}\n\nexport async function exportAppStateFile(): Promise<AppStateExportResult> {\n const userId = await cloudClientService.resolveUserId()\n const installed: Array<{packageName: string; version: string; source?: string}> = []\n for (const app of await appRegistry.getInstalledMiniapps()) {\n const version = await appRegistry.getActiveVersion(app.packageName)\n if (!version) continue\n installed.push({\n packageName: app.packageName,\n version,\n source: appRegistry.getReleaseIdentity(app.packageName, version)?.source,\n })\n }\n const backup = collectAppStateBackup({\n backend,\n userId,\n settings: Object.values(SETTINGS),\n installed,\n memory: await memoryStore.exportBackup(),\n now: Date.now(),\n })\n\n const filename = `glassly-backup-${localDate(backup.exportedAt)}.json`\n const file = new File(Paths.cache, filename)\n file.writeSync(JSON.stringify(backup))\n try {\n await Share.open({url: file.uri, type: \"application/json\", filename, title: \"Glassly backup\"})\n return {success: true}\n } catch (error: any) {\n // react-native-share rejects when the user dismisses the sheet.\n if (error?.message?.includes(\"User did not share\")) return {success: false, cancelled: true}\n throw error\n }\n}\n\n/** Resolves null when the user cancels the picker. */\nexport async function importAppStateFile(opts: AppStateRestoreOptions = {}): Promise<AppStateRestoreResult | null> {\n const picked = await File.pickFileAsync({mimeTypes: PICK_MIME_TYPES})\n if (picked.canceled) return null\n let data: unknown\n try {\n data = JSON.parse(await picked.result.text())\n } catch {\n throw new AppStateBackupError(\"Not a Glassly app state backup\")\n }\n const backup = parseAppStateBackup(data)\n const userId = await cloudClientService.resolveUserId()\n\n // First, so a memory section this build rejects fails the restore before anything is written.\n const memory = backup.memory ? await memoryStore.importBackup(backup.memory) : null\n\n const {plain, indexed} = restorableSettings(backup, Object.values(SETTINGS))\n const written = await useSettingsStore.getState().setManyLocally(plain)\n if (written.is_error()) throw written.error\n // setManyLocally only takes registry keys; `key:index` values go to storage and the store directly.\n for (const [key, value] of Object.entries(indexed)) backend.set(key, value)\n if (Object.keys(indexed).length > 0) {\n useSettingsStore.setState((state) => ({settings: {...state.settings, ...indexed}}))\n }\n\n // A running miniapp holds its old values in memory and could write them back over the\n // restore, so the ones getting storage stop now and start again at the end.\n const running = new Set(runningMiniappPackages())\n const restarting = miniappsWithStorage(backup).filter((packageName) => running.has(packageName))\n for (const packageName of restarting) await stopMiniapp(packageName)\n\n try {\n const restored = restoreMiniappStorage(backend, userId, backup)\n\n const apps = useAppStatusStore.getState()\n for (const [packageName, record] of Object.entries(backup.miniapps)) {\n const prefs = miniappPrefs(record)\n if (prefs.hidden !== undefined) apps.setHiddenStatus(packageName, prefs.hidden)\n if (prefs.alwaysOn !== undefined) saveAlwaysOnOverride(packageName, prefs.alwaysOn)\n for (const [widgetId, expand] of Object.entries(prefs.widgetExpand)) {\n saveWidgetExpandChoice(packageName, widgetId, expand)\n }\n }\n appRegistry.markRefreshNeeded()\n await apps.refresh()\n\n const installedNow = new Set((await appRegistry.getInstalledMiniapps()).map((app) => app.packageName))\n const missing = Object.entries(backup.miniapps)\n .filter(([packageName, record]) => record.installed && !installedNow.has(packageName))\n .map(([packageName]) => packageName)\n if (missing.length > 0) await opts.reinstall?.(missing)\n\n return {\n settings: Object.keys(plain).length + Object.keys(indexed).length,\n miniapps: restored.packages.length,\n memory,\n missing,\n }\n } finally {\n for (const packageName of restarting) await respawnMiniapp(packageName)\n }\n}\n\nfunction localDate(ms: number): string {\n const d = new Date(ms)\n const pad = (n: number) => String(n).padStart(2, \"0\")\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/engine",
3
- "version": "0.3.7-dev.888",
3
+ "version": "0.3.7-dev.892",
4
4
  "description": "Glassly Engine — miniapp registry, WebView bridge, and miniapp store",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -97,17 +97,17 @@
97
97
  "registry": "https://registry.npmjs.org/"
98
98
  },
99
99
  "dependencies": {
100
- "@glassly/bluetooth-sdk": "0.3.7-dev.888",
101
- "@glassly/cloud-client": "0.3.7-dev.888",
102
- "@glassly/cloud-protocol": "0.3.7-dev.888",
103
- "@glassly/crust": "0.3.7-dev.888",
104
- "@glassly/miniapp": "0.3.7-dev.888",
100
+ "@glassly/bluetooth-sdk": "0.3.7-dev.892",
101
+ "@glassly/cloud-client": "0.3.7-dev.892",
102
+ "@glassly/cloud-protocol": "0.3.7-dev.892",
103
+ "@glassly/crust": "0.3.7-dev.892",
104
+ "@glassly/miniapp": "0.3.7-dev.892",
105
105
  "buffer": "^6.0.3",
106
106
  "events": "^3.3.0",
107
107
  "react-native-marked": "8.3.2",
108
108
  "semver": "^7.7.4",
109
109
  "typesafe-ts": "^1.7.1",
110
- "@glassly/llm-providers": "0.3.7-dev.888",
110
+ "@glassly/llm-providers": "0.3.7-dev.892",
111
111
  "@huggingface/jinja": "^0.5.9",
112
112
  "jsonrepair": "^3.12.0"
113
113
  },
package/src/engine.ts CHANGED
@@ -49,6 +49,7 @@ import {permissions} from "./facades/permissions"
49
49
  import {notifications} from "./facades/notifications"
50
50
  import {maps} from "./facades/maps"
51
51
  import {memory} from "./facades/memory"
52
+ import {backup} from "./facades/backup"
52
53
  import {speakers} from "./facades/speakers"
53
54
  import {providerKeys} from "./facades/providerKeys"
54
55
  import {logBuffer} from "./utils/devLogging"
@@ -232,6 +233,8 @@ export const engine = {
232
233
  maps,
233
234
  /** Shared user-scoped memory timeline (Settings > Memory + session.memory). */
234
235
  memory,
236
+ /** Whole-app backup file: settings, miniapp storage, installed list, memory (Settings > Profile). */
237
+ backup,
235
238
  /** Device-local voice profiles for on-device speaker ID (Settings > Speakers). */
236
239
  speakers,
237
240
  /** AI/voice provider key validation — user-supplied keys (Settings > API Keys). */
@@ -0,0 +1,28 @@
1
+ /**
2
+ * backup facade — `engine.backup`: the whole-app backup behind Settings >
3
+ * Profile > Backup. One JSON file carries system settings, every miniapp's
4
+ * storage (so their sign-ins survive a logout or reinstall), the installed
5
+ * miniapp list, and the memory timeline.
6
+ */
7
+ import {AppStateBackupError} from "../services/AppStateBackup"
8
+ import {
9
+ exportAppStateFile,
10
+ importAppStateFile,
11
+ type AppStateExportResult,
12
+ type AppStateRestoreOptions,
13
+ type AppStateRestoreResult,
14
+ } from "../services/appStateBackupFile"
15
+ import {MemoryBackupError} from "../services/MemoryStore"
16
+
17
+ export const backup = {
18
+ /** Share the app state as a JSON backup file. */
19
+ exportFile: (): Promise<AppStateExportResult> => exportAppStateFile(),
20
+ /**
21
+ * Pick a backup file and restore it: backup values overwrite per key, and
22
+ * running miniapps whose storage changed restart after `opts.reinstall` has
23
+ * installed the ones this phone lacks. Resolves null on cancel.
24
+ */
25
+ importFile: (opts?: AppStateRestoreOptions): Promise<AppStateRestoreResult | null> => importAppStateFile(opts),
26
+ /** True for a file that isn't a backup this build can read. */
27
+ isBackupError: (err: unknown): boolean => err instanceof AppStateBackupError || err instanceof MemoryBackupError,
28
+ }
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "0.3.7",
15
- "releaseIdentity": "0.3.7-dev.888",
16
- "releaseSetId": "glassly-0.3.7-dev.888",
17
- "sourceCommit": "b2a2c570c204c2ecc6e03a27128de74957148be5",
15
+ "releaseIdentity": "0.3.7-dev.892",
16
+ "releaseSetId": "glassly-0.3.7-dev.892",
17
+ "sourceCommit": "496e2647219771dbe46bd637e26a1c01bfa0e4be",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })
package/src/index.ts CHANGED
@@ -69,6 +69,7 @@ export {DEFAULT_LOCAL_LLM_MODEL, isLocalLlmModelId} from "./services/LocalLlmMod
69
69
  // session shapes are canonical in @glassly/miniapp (the session.memory
70
70
  // surface); the stats read model is engine-side.
71
71
  export type {MemoryStats} from "./services/MemoryStore"
72
+ export type {AppStateExportResult, AppStateRestoreOptions, AppStateRestoreResult} from "./services/appStateBackupFile"
72
73
  export type {SpeakerProfile} from "./services/SpeakerProfileStore"
73
74
  export type {EnrollOutcome} from "./facades/speakers"
74
75
  export {MENTRA_LIVE_SETTING_KEYS, getBluetoothSettingKeysForDevice} from "./stores/bluetoothSettingKeys"
@@ -0,0 +1,283 @@
1
+ /**
2
+ * App state backups (Settings > Profile > Backup): one JSON file carrying the
3
+ * system settings, every miniapp's SimpleStorage, the installed miniapp list
4
+ * with each app's home flags, and the memory timeline.
5
+ *
6
+ * It exists so a logout or reinstall doesn't mean signing back into every
7
+ * miniapp, which is why the file holds live credentials (miniapp tokens,
8
+ * provider API keys) as plain JSON. Pairing identity, cloud endpoints, and
9
+ * consent/login state stay out: they belong to the install and the account,
10
+ * not to the user's preferences.
11
+ *
12
+ * Storage keys are written without their user segment, so a backup restores
13
+ * into whatever namespace the phone has now (account or `local_`). Restore
14
+ * overwrites key by key and leaves keys the backup doesn't carry alone.
15
+ *
16
+ * Pure: the MMKV view, settings registry, and memory snapshot are passed in by
17
+ * appStateBackupFile.ts.
18
+ */
19
+
20
+ import type {MemoryBackup} from "@glassly/miniapp"
21
+
22
+ /** Key-value surface (MMKV in production, values already JSON-decoded). */
23
+ export interface AppStateBackupBackend {
24
+ get(key: string): unknown | null
25
+ set(key: string, value: unknown): void
26
+ keys(): string[]
27
+ }
28
+
29
+ /** The fields of a SETTINGS descriptor that decide whether it is backed up. */
30
+ export interface BackupSettingDescriptor {
31
+ key: string
32
+ persist: boolean
33
+ writable: boolean
34
+ nativeAuthoritative?: true
35
+ resetOnBuildEnvChange?: true
36
+ indexer?: (key: string) => string
37
+ }
38
+
39
+ export interface AppStateBackupMiniapp {
40
+ /** Set when the miniapp was installed at export. */
41
+ installed?: {version: string; source?: string}
42
+ /** SimpleStorage key → stored value. */
43
+ storage: Record<string, unknown>
44
+ /** Home flags by suffix: `hidden`, `always_on`, `widget_expand_<widgetId>`. */
45
+ prefs: Record<string, unknown>
46
+ }
47
+
48
+ export interface AppStateBackup {
49
+ format: "glassly-app-state"
50
+ version: 1
51
+ /** Epoch ms the backup was written. */
52
+ exportedAt: number
53
+ /** Stored setting values; indexed settings keep their `key:index` storage key. */
54
+ settings: Record<string, unknown>
55
+ miniapps: Record<string, AppStateBackupMiniapp>
56
+ memory: MemoryBackup | null
57
+ }
58
+
59
+ /** Thrown for a file that isn't an app state backup this build can read. */
60
+ export class AppStateBackupError extends Error {
61
+ constructor(message: string) {
62
+ super(message)
63
+ this.name = "AppStateBackupError"
64
+ }
65
+ }
66
+
67
+ const STORAGE_ROOT = "localstorage_"
68
+ const PREF_SUFFIXES = ["hidden", "always_on"] as const
69
+ const WIDGET_EXPAND_PREFIX = "widget_expand_"
70
+ // Same rule as the miniapp.json schema; keeps a hand-edited file from writing keys no app reads.
71
+ const PACKAGE_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/
72
+
73
+ /**
74
+ * Persisted, writable settings that are still not the user's to carry between
75
+ * installs: account and consent state, the paired devices, and caches.
76
+ */
77
+ const EXCLUDED_SETTINGS = new Set([
78
+ "auth_email",
79
+ "disable_cloud",
80
+ "ai_data_consent_version",
81
+ "ai_data_consent_agreed",
82
+ "login_skipped",
83
+ "has_paired_glasses",
84
+ "keyboard_name",
85
+ "cached_required_version",
86
+ "offline_camera_running",
87
+ "offline_translation_running",
88
+ ])
89
+
90
+ export function isBackupSetting(setting: BackupSettingDescriptor): boolean {
91
+ return (
92
+ setting.persist &&
93
+ setting.writable &&
94
+ !setting.nativeAuthoritative &&
95
+ !setting.resetOnBuildEnvChange &&
96
+ !EXCLUDED_SETTINGS.has(setting.key)
97
+ )
98
+ }
99
+
100
+ export interface CollectAppStateInput {
101
+ backend: AppStateBackupBackend
102
+ userId: string
103
+ settings: BackupSettingDescriptor[]
104
+ installed: Array<{packageName: string; version: string; source?: string}>
105
+ memory: MemoryBackup | null
106
+ now: number
107
+ }
108
+
109
+ export function collectAppStateBackup(input: CollectAppStateInput): AppStateBackup {
110
+ const {backend, settings} = input
111
+ const keys = backend.keys()
112
+
113
+ const settingValues: Record<string, unknown> = {}
114
+ for (const setting of settings) {
115
+ if (!isBackupSetting(setting)) continue
116
+ if (setting.indexer) {
117
+ for (const key of keys) {
118
+ if (!key.startsWith(`${setting.key}:`)) continue
119
+ const value = backend.get(key)
120
+ if (value != null) settingValues[key] = value
121
+ }
122
+ continue
123
+ }
124
+ const value = backend.get(setting.key)
125
+ if (value != null) settingValues[setting.key] = value
126
+ }
127
+
128
+ const miniapps: Record<string, AppStateBackupMiniapp> = {}
129
+ const entry = (packageName: string) => (miniapps[packageName] ??= {storage: {}, prefs: {}})
130
+
131
+ for (const app of input.installed) {
132
+ const record = entry(app.packageName)
133
+ record.installed = app.source ? {version: app.version, source: app.source} : {version: app.version}
134
+ for (const suffix of PREF_SUFFIXES) {
135
+ const value = backend.get(`${app.packageName}_${suffix}`)
136
+ if (value != null) record.prefs[suffix] = value
137
+ }
138
+ const expandPrefix = `${app.packageName}_${WIDGET_EXPAND_PREFIX}`
139
+ for (const key of keys) {
140
+ if (!key.startsWith(expandPrefix)) continue
141
+ const value = backend.get(key)
142
+ if (value != null) record.prefs[key.slice(app.packageName.length + 1)] = value
143
+ }
144
+ }
145
+
146
+ // Longest first, so a package whose name extends another's still claims its own keys.
147
+ const known = input.installed.map((app) => app.packageName).sort((a, b) => b.length - a.length)
148
+ const prefix = `${STORAGE_ROOT}${input.userId}_`
149
+ for (const key of keys) {
150
+ if (!key.startsWith(prefix)) continue
151
+ const split = splitStorageKey(key.slice(prefix.length), known)
152
+ if (!split) continue
153
+ const value = backend.get(key)
154
+ if (value != null) entry(split.packageName).storage[split.key] = value
155
+ }
156
+
157
+ return {
158
+ format: "glassly-app-state",
159
+ version: 1,
160
+ exportedAt: input.now,
161
+ settings: settingValues,
162
+ miniapps,
163
+ memory: input.memory,
164
+ }
165
+ }
166
+
167
+ /** `{packageName}_{key}` → its parts. Package names may contain `_`, so known names win over the first `_`. */
168
+ function splitStorageKey(rest: string, known: string[]): {packageName: string; key: string} | null {
169
+ const packageName = known.find((name) => rest.startsWith(`${name}_`)) ?? rest.slice(0, rest.indexOf("_"))
170
+ const key = rest.slice(packageName.length + 1)
171
+ if (!packageName || !key) return null
172
+ return {packageName, key}
173
+ }
174
+
175
+ export function parseAppStateBackup(data: unknown): AppStateBackup {
176
+ if (!isRecord(data) || data.format !== "glassly-app-state") {
177
+ throw new AppStateBackupError("Not a Glassly app state backup")
178
+ }
179
+ if (typeof data.version !== "number" || data.version > 1) {
180
+ throw new AppStateBackupError(`Unsupported app state backup version ${String(data.version)}`)
181
+ }
182
+
183
+ const settings = isRecord(data.settings) ? data.settings : {}
184
+ const miniapps: Record<string, AppStateBackupMiniapp> = {}
185
+ if (isRecord(data.miniapps)) {
186
+ for (const [packageName, raw] of Object.entries(data.miniapps)) {
187
+ if (!PACKAGE_NAME_RE.test(packageName) || !isRecord(raw)) continue
188
+ const record: AppStateBackupMiniapp = {
189
+ storage: isRecord(raw.storage) ? raw.storage : {},
190
+ prefs: isRecord(raw.prefs) ? raw.prefs : {},
191
+ }
192
+ if (isRecord(raw.installed) && typeof raw.installed.version === "string") {
193
+ record.installed = {version: raw.installed.version}
194
+ if (typeof raw.installed.source === "string") record.installed.source = raw.installed.source
195
+ }
196
+ miniapps[packageName] = record
197
+ }
198
+ }
199
+
200
+ let memory: MemoryBackup | null = null
201
+ if (data.memory != null) {
202
+ if (!isRecord(data.memory) || data.memory.format !== "glassly-memory") {
203
+ throw new AppStateBackupError("The backup's memory section is not a memory backup")
204
+ }
205
+ memory = data.memory as unknown as MemoryBackup
206
+ }
207
+
208
+ return {format: "glassly-app-state", version: 1, exportedAt: Number(data.exportedAt) || 0, settings, miniapps, memory}
209
+ }
210
+
211
+ /**
212
+ * The backup's settings this build will restore, split by how they're written:
213
+ * `plain` through the settings store, `indexed` (`key:index`) straight to storage.
214
+ * Keys this build doesn't know, or doesn't back up, are dropped.
215
+ */
216
+ export function restorableSettings(
217
+ backup: AppStateBackup,
218
+ settings: BackupSettingDescriptor[],
219
+ ): {plain: Record<string, unknown>; indexed: Record<string, unknown>} {
220
+ const byKey = new Map(settings.map((setting) => [setting.key, setting]))
221
+ const plain: Record<string, unknown> = {}
222
+ const indexed: Record<string, unknown> = {}
223
+ for (const [key, value] of Object.entries(backup.settings)) {
224
+ if (value == null) continue
225
+ const colon = key.indexOf(":")
226
+ const setting = byKey.get(colon < 0 ? key : key.slice(0, colon))
227
+ if (!setting || !isBackupSetting(setting)) continue
228
+ if (colon < 0 && !setting.indexer) plain[key] = value
229
+ else if (colon > 0 && setting.indexer) indexed[key] = value
230
+ }
231
+ return {plain, indexed}
232
+ }
233
+
234
+ /** Miniapps the backup has storage for. */
235
+ export function miniappsWithStorage(backup: AppStateBackup): string[] {
236
+ return Object.entries(backup.miniapps)
237
+ .filter(([, record]) => Object.entries(record.storage).some(([key, value]) => key && value != null))
238
+ .map(([packageName]) => packageName)
239
+ }
240
+
241
+ /** Write every miniapp's storage into `userId`'s namespace. Returns the packages that got keys. */
242
+ export function restoreMiniappStorage(
243
+ backend: AppStateBackupBackend,
244
+ userId: string,
245
+ backup: AppStateBackup,
246
+ ): {packages: string[]; keys: number} {
247
+ const packages: string[] = []
248
+ let keys = 0
249
+ for (const [packageName, record] of Object.entries(backup.miniapps)) {
250
+ let wrote = 0
251
+ for (const [key, value] of Object.entries(record.storage)) {
252
+ if (!key || value == null) continue
253
+ backend.set(`${STORAGE_ROOT}${userId}_${packageName}_${key}`, value)
254
+ wrote++
255
+ }
256
+ if (wrote > 0) packages.push(packageName)
257
+ keys += wrote
258
+ }
259
+ return {packages, keys}
260
+ }
261
+
262
+ export interface MiniappPrefs {
263
+ hidden?: boolean
264
+ alwaysOn?: boolean
265
+ widgetExpand: Record<string, boolean>
266
+ }
267
+
268
+ /** A miniapp's home flags from the backup, with anything that isn't a boolean dropped. */
269
+ export function miniappPrefs(record: AppStateBackupMiniapp): MiniappPrefs {
270
+ const prefs: MiniappPrefs = {widgetExpand: {}}
271
+ if (typeof record.prefs.hidden === "boolean") prefs.hidden = record.prefs.hidden
272
+ if (typeof record.prefs.always_on === "boolean") prefs.alwaysOn = record.prefs.always_on
273
+ for (const [key, value] of Object.entries(record.prefs)) {
274
+ if (!key.startsWith(WIDGET_EXPAND_PREFIX) || typeof value !== "boolean") continue
275
+ const widgetId = key.slice(WIDGET_EXPAND_PREFIX.length)
276
+ if (widgetId) prefs.widgetExpand[widgetId] = value
277
+ }
278
+ return prefs
279
+ }
280
+
281
+ function isRecord(value: unknown): value is Record<string, unknown> {
282
+ return value != null && typeof value === "object" && !Array.isArray(value)
283
+ }
@@ -304,6 +304,20 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | nul
304
304
  })
305
305
  }
306
306
 
307
+ /** Bounds the cold-GPS wait for a one-shot poll; on a stall, any older fix beats an error. */
308
+ const FRESH_LOCATION_TIMEOUT_MS = 8_000
309
+
310
+ async function freshLocationOrStale(): Promise<Location.LocationObject> {
311
+ const fresh = await withTimeout(
312
+ Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Low}),
313
+ FRESH_LOCATION_TIMEOUT_MS,
314
+ )
315
+ if (fresh) return fresh
316
+ const stale = await Location.getLastKnownPositionAsync()
317
+ if (stale) return stale
318
+ throw new Error("No location fix available")
319
+ }
320
+
307
321
  const LOG_TAG = "LOCAL_MINIAPP"
308
322
  const DIAGNOSTIC_MAX_LIST_ITEMS = 100
309
323
  const DIAGNOSTIC_MAX_STRING_LENGTH = 512
@@ -3450,7 +3464,7 @@ class LocalMiniappRuntime {
3450
3464
  const locationStartedAt = Date.now()
3451
3465
  const cached = await Location.getLastKnownPositionAsync({maxAge: 60_000})
3452
3466
  console.log(`${LOG_TAG}: location poll cache hit=${cached !== null} elapsed=${Date.now() - locationStartedAt}ms`)
3453
- const location = cached ?? (await Location.getCurrentPositionAsync({accuracy: Location.Accuracy.Low}))
3467
+ const location = cached ?? (await freshLocationOrStale())
3454
3468
  console.log(`${LOG_TAG}: location poll resolved elapsed=${Date.now() - locationStartedAt}ms`)
3455
3469
 
3456
3470
  this.sendResult(packageName, requestId, true, {
@@ -6071,13 +6085,16 @@ class LocalMiniappRuntime {
6071
6085
  private async startInteropApp(packageName: string): Promise<boolean> {
6072
6086
  const app = this.interopApps().find((a) => a.packageName === packageName)
6073
6087
  if (!app) return false
6088
+ // session.miniapps.start comes from the glasses (the dashboard's Apps menu,
6089
+ // an Assist voice command), unlike a headless action wake, which carries no source.
6090
+ const launchSource = "glasses" as const
6074
6091
  // Running only for its widget: start() promotes it to a normal run, as opening it on the phone does.
6075
- if (app.widgetOnly) return useAppStatusStore.getState().start(app, {skipNavigation: true})
6092
+ if (app.widgetOnly) return useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
6076
6093
  if (app.local) {
6077
- await miniappLauncher.ensureConnected(packageName)
6094
+ await miniappLauncher.ensureConnected(packageName, undefined, {launchSource})
6078
6095
  return true
6079
6096
  }
6080
- return useAppStatusStore.getState().start(app, {skipNavigation: true})
6097
+ return useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
6081
6098
  }
6082
6099
 
6083
6100
  private stopInteropApp(packageName: string): Promise<void> {