@buoy-gg/storage 5.0.0 → 6.0.1

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 (43) hide show
  1. package/README.md +45 -572
  2. package/lib/commonjs/index.js +13 -0
  3. package/lib/commonjs/storage/components/GameUIStorageBrowser.js +21 -18
  4. package/lib/commonjs/storage/components/SelectionActionBar.js +8 -0
  5. package/lib/commonjs/storage/components/StorageEventCard.js +3 -2
  6. package/lib/commonjs/storage/components/StorageFilterCards.js +1 -2
  7. package/lib/commonjs/storage/components/StorageKeyRow.js +8 -3
  8. package/lib/commonjs/storage/hooks/useMMKVInstances.js +9 -20
  9. package/lib/commonjs/storage/hooks/useMMKVKeys.js +12 -21
  10. package/lib/commonjs/storage/hooks/useSecureStoreKeys.js +104 -0
  11. package/lib/commonjs/storage/hooks/useStorageEvents.js +25 -11
  12. package/lib/commonjs/storage/sync/storageSyncAdapter.js +82 -1
  13. package/lib/commonjs/storage/utils/SecureStoreRegistry.js +149 -0
  14. package/lib/module/index.js +5 -0
  15. package/lib/module/storage/components/GameUIStorageBrowser.js +20 -18
  16. package/lib/module/storage/components/SelectionActionBar.js +8 -0
  17. package/lib/module/storage/components/StorageEventCard.js +4 -3
  18. package/lib/module/storage/components/StorageFilterCards.js +1 -2
  19. package/lib/module/storage/components/StorageKeyRow.js +7 -2
  20. package/lib/module/storage/hooks/useMMKVInstances.js +6 -18
  21. package/lib/module/storage/hooks/useMMKVKeys.js +7 -18
  22. package/lib/module/storage/hooks/useSecureStoreKeys.js +101 -0
  23. package/lib/module/storage/hooks/useStorageEvents.js +25 -11
  24. package/lib/module/storage/sync/storageSyncAdapter.js +83 -1
  25. package/lib/module/storage/utils/SecureStoreRegistry.js +143 -0
  26. package/lib/typescript/index.d.ts +2 -0
  27. package/lib/typescript/index.d.ts.map +1 -1
  28. package/lib/typescript/storage/components/GameUIStorageBrowser.d.ts.map +1 -1
  29. package/lib/typescript/storage/components/SelectionActionBar.d.ts.map +1 -1
  30. package/lib/typescript/storage/components/StorageEventCard.d.ts.map +1 -1
  31. package/lib/typescript/storage/components/StorageFilterCards.d.ts.map +1 -1
  32. package/lib/typescript/storage/components/StorageKeyRow.d.ts +1 -1
  33. package/lib/typescript/storage/components/StorageKeyRow.d.ts.map +1 -1
  34. package/lib/typescript/storage/hooks/useMMKVInstances.d.ts.map +1 -1
  35. package/lib/typescript/storage/hooks/useMMKVKeys.d.ts.map +1 -1
  36. package/lib/typescript/storage/hooks/useSecureStoreKeys.d.ts +18 -0
  37. package/lib/typescript/storage/hooks/useSecureStoreKeys.d.ts.map +1 -0
  38. package/lib/typescript/storage/hooks/useStorageEvents.d.ts.map +1 -1
  39. package/lib/typescript/storage/sync/storageSyncAdapter.d.ts +24 -0
  40. package/lib/typescript/storage/sync/storageSyncAdapter.d.ts.map +1 -1
  41. package/lib/typescript/storage/utils/SecureStoreRegistry.d.ts +115 -0
  42. package/lib/typescript/storage/utils/SecureStoreRegistry.d.ts.map +1 -0
  43. package/package.json +6 -6
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.registerSecureStoreKeys = registerSecureStoreKeys;
7
+ exports.secureStoreRegistry = void 0;
8
+ exports.unregisterSecureStoreKey = unregisterSecureStoreKey;
9
+ /**
10
+ * Expo SecureStore Registry
11
+ *
12
+ * SecureStore (iOS Keychain / Android KeyStore) has NO key-enumeration API —
13
+ * unlike AsyncStorage (`getAllKeys`) and MMKV (`getAllKeys()`), there is no way
14
+ * to ask "what's stored?". Apps must therefore register the keys they want
15
+ * visible in the Storage tool.
16
+ *
17
+ * The SecureStore module itself is passed in at registration (never imported
18
+ * here) — the same Metro-safe pattern as `registerMMKVInstance`: this package
19
+ * has zero dependency on `expo-secure-store`, so bare RN apps without it
20
+ * bundle fine.
21
+ *
22
+ * @module SecureStoreRegistry
23
+ */
24
+
25
+ /**
26
+ * Subset of expo-secure-store's SecureStoreOptions that the tool tracks.
27
+ * `keychainService` MUST match what the value was written with, or reads
28
+ * return null. `requireAuthentication` keys trigger a biometric prompt on
29
+ * read, so the tool never auto-reads them.
30
+ *
31
+ * @public
32
+ */
33
+
34
+ /**
35
+ * Structural type for the expo-secure-store module. Matches
36
+ * `import * as SecureStore from "expo-secure-store"` without depending on it.
37
+ *
38
+ * @public
39
+ */
40
+
41
+ /**
42
+ * A key to expose in the Storage tool: either a bare key name, or a key with
43
+ * the SecureStore options it was written with (+ optional description shown
44
+ * in the expanded card).
45
+ *
46
+ * @public
47
+ */
48
+
49
+ /**
50
+ * Normalized info for a registered SecureStore key.
51
+ *
52
+ * @public
53
+ */
54
+
55
+ class SecureStoreRegistry {
56
+ module = null;
57
+ keys = new Map();
58
+
59
+ /**
60
+ * Register the SecureStore module and the keys to expose. Callable multiple
61
+ * times — keys accumulate, the latest module wins.
62
+ */
63
+ register(module, descriptors) {
64
+ this.module = module;
65
+ for (const d of descriptors) {
66
+ const entry = typeof d === "string" ? {
67
+ key: d,
68
+ options: {}
69
+ } : {
70
+ key: d.key,
71
+ description: d.description,
72
+ options: {
73
+ keychainService: d.keychainService,
74
+ requireAuthentication: d.requireAuthentication,
75
+ authenticationPrompt: d.authenticationPrompt
76
+ }
77
+ };
78
+ this.keys.set(entry.key, entry);
79
+ }
80
+ }
81
+
82
+ /** Remove a key from the registry (does not delete the stored value). */
83
+ unregister(key) {
84
+ this.keys.delete(key);
85
+ }
86
+
87
+ /** The registered SecureStore module, or null if none registered yet. */
88
+ getModule() {
89
+ return this.module;
90
+ }
91
+
92
+ /** All registered key entries. */
93
+ getAll() {
94
+ return Array.from(this.keys.values());
95
+ }
96
+
97
+ /** Number of registered keys. */
98
+ count() {
99
+ return this.keys.size;
100
+ }
101
+
102
+ /**
103
+ * Delete a stored value via the registered module, using the options the
104
+ * key was registered with (required for keychainService-scoped keys).
105
+ */
106
+ async deleteValue(key) {
107
+ const entry = this.keys.get(key);
108
+ if (!this.module || !entry) return;
109
+ await this.module.deleteItemAsync(key, entry.options);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Singleton registry instance (advanced usage — most apps only need
115
+ * {@link registerSecureStoreKeys}).
116
+ */
117
+ const secureStoreRegistry = exports.secureStoreRegistry = new SecureStoreRegistry();
118
+
119
+ /**
120
+ * Register Expo SecureStore keys with React Buoy DevTools.
121
+ *
122
+ * **⚠️ REQUIRED for SecureStore visibility**: SecureStore has no
123
+ * key-enumeration API, so the tool can only show keys you declare here.
124
+ * Pass the module itself so this package never has to import
125
+ * `expo-secure-store`.
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * import * as SecureStore from "expo-secure-store";
130
+ * import { registerSecureStoreKeys } from "@buoy-gg/storage";
131
+ *
132
+ * registerSecureStoreKeys(SecureStore, [
133
+ * "auth.token",
134
+ * { key: "session", keychainService: "com.myapp.auth" },
135
+ * // Never auto-read; shown as biometric-protected:
136
+ * { key: "pin", requireAuthentication: true },
137
+ * ]);
138
+ * ```
139
+ */
140
+ function registerSecureStoreKeys(module, keys) {
141
+ secureStoreRegistry.register(module, keys);
142
+ }
143
+
144
+ /**
145
+ * Remove a key from the DevTools registry (the stored value is untouched).
146
+ */
147
+ function unregisterSecureStoreKey(key) {
148
+ secureStoreRegistry.unregister(key);
149
+ }
@@ -55,6 +55,11 @@ export { undoOperation, jumpToState, canUndo } from "./storage/utils/storageTime
55
55
  // =============================================================================
56
56
  export { registerMMKVInstance, unregisterMMKVInstance } from "./storage/utils/MMKVInstanceRegistry";
57
57
 
58
+ // =============================================================================
59
+ // SECURE STORE REGISTRATION (For users to register their expo-secure-store keys)
60
+ // SecureStore has no key-enumeration API, so keys must be declared to appear.
61
+ // =============================================================================
62
+ export { registerSecureStoreKeys, unregisterSecureStoreKey } from "./storage/utils/SecureStoreRegistry";
58
63
  // =============================================================================
59
64
  // EXTERNAL SYNC (Adapter for @buoy-gg/external-sync's useExternalSync)
60
65
  // =============================================================================
@@ -10,17 +10,13 @@ import { StorageFilterCards } from "./StorageFilterCards";
10
10
  import { useAsyncStorageKeys } from "../hooks/useAsyncStorageKeys";
11
11
  import { addListener } from "../utils/AsyncStorageListener";
12
12
  import { useMultiMMKVKeys } from "../hooks/useMMKVKeys";
13
+ import { useSecureStoreKeys } from "../hooks/useSecureStoreKeys";
13
14
  import { useMMKVInstances } from "../hooks/useMMKVInstances";
14
- import { isMMKVAvailable } from "../utils/mmkvAvailability";
15
15
  import { StorageActionButtons } from "./StorageActionButtons";
16
16
  import { SelectionActionBar } from "./SelectionActionBar";
17
-
18
- // Conditionally import MMKV listener
19
- let addMMKVListener;
20
- if (isMMKVAvailable()) {
21
- const listener = require("../utils/MMKVListener");
22
- addMMKVListener = listener.addMMKVListener;
23
- }
17
+ // Pure JS (no react-native-mmkv dependency) — works with any registered
18
+ // instance, including remote-backed wrappers on the desktop dashboard.
19
+ import { addMMKVListener } from "../utils/MMKVListener";
24
20
 
25
21
  // Import shared Game UI components
26
22
  import { gameUIColors, macOSColors, HardDrive } from "@buoy-gg/shared-ui";
@@ -122,20 +118,29 @@ export function GameUIStorageBrowser({
122
118
  return allMMKVKeys.filter(k => k.instanceId === selectedMMKVInstance);
123
119
  }, [allMMKVKeys, selectedMMKVInstance]);
124
120
 
125
- // Merge storage keys from AsyncStorage and MMKV
121
+ // Use SecureStore hook — only surfaces keys declared via registerSecureStoreKeys()
122
+ const {
123
+ storageKeys: secureStorageKeys,
124
+ isLoading: isLoadingSecure,
125
+ error: secureError,
126
+ refresh: refreshSecure
127
+ } = useSecureStoreKeys();
128
+
129
+ // Merge storage keys from AsyncStorage, MMKV, and SecureStore
126
130
  const allStorageKeys = useMemo(() => {
127
- return [...asyncStorageKeys, ...mmkvStorageKeys];
128
- }, [asyncStorageKeys, mmkvStorageKeys]);
131
+ return [...asyncStorageKeys, ...mmkvStorageKeys, ...secureStorageKeys];
132
+ }, [asyncStorageKeys, mmkvStorageKeys, secureStorageKeys]);
129
133
 
130
134
  // Determine loading and error states
131
- const isLoading = isLoadingAsync || isLoadingMMKV;
132
- const error = asyncError || mmkvError;
135
+ const isLoading = isLoadingAsync || isLoadingMMKV || isLoadingSecure;
136
+ const error = asyncError || mmkvError || secureError;
133
137
 
134
138
  // Combined refresh function
135
139
  const refresh = useCallback(() => {
136
140
  refreshAsync();
137
141
  refreshMMKV();
138
- }, [refreshAsync, refreshMMKV]);
142
+ refreshSecure();
143
+ }, [refreshAsync, refreshMMKV, refreshSecure]);
139
144
 
140
145
  // Generate unique identifier for a storage key
141
146
  const getKeyIdentifier = useCallback(storageKey => {
@@ -248,11 +253,8 @@ export function GameUIStorageBrowser({
248
253
  };
249
254
  }, [refreshAsync]);
250
255
 
251
- // Auto-refresh on MMKV storage events (only if MMKV is available)
256
+ // Auto-refresh on MMKV storage events
252
257
  useEffect(() => {
253
- if (!isMMKVAvailable() || !addMMKVListener) {
254
- return; // Skip if MMKV not available
255
- }
256
258
  let timeoutId;
257
259
  const unsubscribe = addMMKVListener(event => {
258
260
  // Only refresh if event is for the selected instance
@@ -6,6 +6,7 @@ import { Trash2, CopyButton, CheckSquare, EyeOff, ProUpgradeModal } from "@buoy-
6
6
  import { macOSColors } from "@buoy-gg/shared-ui";
7
7
  import { useIsPro } from "@buoy-gg/license";
8
8
  import { removeMany } from "../utils/asyncStorageCompat";
9
+ import { secureStoreRegistry } from "../utils/SecureStoreRegistry";
9
10
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
11
  export function SelectionActionBar({
11
12
  selectedKeys,
@@ -68,12 +69,19 @@ export function SelectionActionBar({
68
69
  // Group keys by storage type
69
70
  const asyncKeys = selectedKeys.filter(k => k.storageType === "async");
70
71
  const mmkvKeys = selectedKeys.filter(k => k.storageType === "mmkv");
72
+ const secureKeys = selectedKeys.filter(k => k.storageType === "secure");
71
73
 
72
74
  // Delete AsyncStorage keys
73
75
  if (asyncKeys.length > 0) {
74
76
  await removeMany(asyncKeys.map(k => k.key));
75
77
  }
76
78
 
79
+ // Delete SecureStore keys (via the registry, which knows each
80
+ // key's keychainService options)
81
+ for (const k of secureKeys) {
82
+ await secureStoreRegistry.deleteValue(k.key);
83
+ }
84
+
77
85
  // Delete MMKV keys
78
86
  if (mmkvKeys.length > 0) {
79
87
  // Group by instance
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { memo } from "react";
12
- import { useRelativeTime, macOSColors, parseValue, CompactRow } from "@buoy-gg/shared-ui";
12
+ import { RelativeTime, macOSColors, parseValue, CompactRow } from "@buoy-gg/shared-ui";
13
13
  import { translateStorageAction } from "../utils/storageActionHelpers";
14
14
 
15
15
  /** Value type for storage events */
@@ -82,7 +82,6 @@ export const StorageEventCard = /*#__PURE__*/memo(function StorageEventCard({
82
82
  const actionColor = getActionColor(data.lastAction);
83
83
  const actionLabel = translateStorageAction(data.lastAction);
84
84
  const totalOps = data.totalOperations ?? 1;
85
- const relativeTime = useRelativeTime(data.lastEventTimestamp);
86
85
  return /*#__PURE__*/_jsx(CompactRow
87
86
  // Status section (left) - shows "Storage" with action-colored dot
88
87
  , {
@@ -99,7 +98,9 @@ export const StorageEventCard = /*#__PURE__*/memo(function StorageEventCard({
99
98
  showChevron: showChevron
100
99
  // Bottom right - timestamp
101
100
  ,
102
- bottomRightText: relativeTime
101
+ bottomRightText: /*#__PURE__*/_jsx(RelativeTime, {
102
+ timestamp: data.lastEventTimestamp
103
+ })
103
104
  // Interaction
104
105
  ,
105
106
  isSelected: isSelected,
@@ -6,7 +6,6 @@ import { isMMKVAvailable } from "../utils/mmkvAvailability";
6
6
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  // Feature flags - set to true when implemented
8
8
  const IS_MMKV_IMPLEMENTED = true; // ✅ MMKV support is now implemented
9
- const IS_SECURE_STORE_IMPLEMENTED = false;
10
9
 
11
10
  // Runtime availability checks
12
11
  const IS_MMKV_AVAILABLE = isMMKVAvailable();
@@ -179,7 +178,7 @@ export function StorageFilterCards({
179
178
  } : {
180
179
  color: macOSColors.text.muted
181
180
  }],
182
- children: "Coming Soon"
181
+ children: displayStats.secureCount
183
182
  })]
184
183
  })]
185
184
  })]
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
 
3
+ import { memo } from "react";
3
4
  import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
4
5
  import { macOSColors, CompactRow, TypeBadge, HardDrive, Square, CheckSquare, EyeOff, Pin, ExpandedInfoRow, PillBadge } from "@buoy-gg/shared-ui";
5
6
  import { getStorageTypeLabel } from "../utils/storageQueryUtils";
@@ -70,7 +71,11 @@ const formatValue = value => {
70
71
  const str = typeof value === "string" ? value : JSON.stringify(value);
71
72
  return str;
72
73
  };
73
- export function StorageKeyRow({
74
+
75
+ // Memo justified by profiling (tool-bench 2026-07-06): the browser tab
76
+ // renders every key row on each parent render (ScrollView, no
77
+ // virtualization) — memo keeps unchanged keys from re-rendering.
78
+ export const StorageKeyRow = /*#__PURE__*/memo(function StorageKeyRow({
74
79
  storageKey,
75
80
  isExpanded,
76
81
  onPress,
@@ -312,7 +317,7 @@ export function StorageKeyRow({
312
317
  })]
313
318
  })]
314
319
  });
315
- }
320
+ });
316
321
  const getStorageTypeColor = storageType => {
317
322
  switch (storageType) {
318
323
  case "mmkv":
@@ -15,14 +15,10 @@
15
15
  */
16
16
 
17
17
  import { useState, useEffect } from 'react';
18
- import { isMMKVAvailable } from '../utils/mmkvAvailability';
19
-
20
- // Conditionally import registry
21
- let mmkvInstanceRegistry;
22
- if (isMMKVAvailable()) {
23
- const registry = require('../utils/MMKVInstanceRegistry');
24
- mmkvInstanceRegistry = registry.mmkvInstanceRegistry;
25
- }
18
+ // The registry is pure JS (no react-native-mmkv dependency) — import it
19
+ // unconditionally so registered instances show up even without the native
20
+ // module (e.g. remote-backed wrappers on the desktop dashboard).
21
+ import { mmkvInstanceRegistry } from '../utils/MMKVInstanceRegistry';
26
22
 
27
23
  /**
28
24
  * Metadata about an MMKV instance with additional runtime information
@@ -67,12 +63,6 @@ export function useMMKVInstances(autoRefresh = false) {
67
63
  const fetchInstances = () => {
68
64
  setIsLoading(true);
69
65
  try {
70
- // Guard: Check if MMKV is available
71
- if (!isMMKVAvailable() || !mmkvInstanceRegistry) {
72
- setInstances([]);
73
- setIsLoading(false);
74
- return; // Silently return empty when MMKV not available
75
- }
76
66
  const allInstances = mmkvInstanceRegistry.getAll();
77
67
 
78
68
  // Enhance with metadata
@@ -160,8 +150,7 @@ export function useMMKVInstances(autoRefresh = false) {
160
150
  export function useMMKVInstance(instanceId) {
161
151
  const [instance, setInstance] = useState(null);
162
152
  useEffect(() => {
163
- // Guard: Check if MMKV is available
164
- if (!isMMKVAvailable() || !mmkvInstanceRegistry) {
153
+ if (!mmkvInstanceRegistry) {
165
154
  setInstance(null);
166
155
  return;
167
156
  }
@@ -205,8 +194,7 @@ export function useMMKVInstance(instanceId) {
205
194
  export function useMMKVInstanceExists(instanceId) {
206
195
  const [exists, setExists] = useState(false);
207
196
  useEffect(() => {
208
- // Guard: Check if MMKV is available
209
- if (!isMMKVAvailable() || !mmkvInstanceRegistry) {
197
+ if (!mmkvInstanceRegistry) {
210
198
  setExists(false);
211
199
  return;
212
200
  }
@@ -14,20 +14,14 @@
14
14
  */
15
15
 
16
16
  import { useState, useEffect, useCallback, useRef } from 'react';
17
- import { isMMKVAvailable } from '../utils/mmkvAvailability';
17
+ // Pure-JS utilities (no react-native-mmkv dependency) — safe to import
18
+ // unconditionally. They operate on whatever instance objects are registered,
19
+ // including remote-backed wrappers on the desktop dashboard.
20
+ import { detectMMKVType, isTypeMatch } from '../utils/mmkvTypeDetection';
18
21
 
19
22
  // Conditionally import MMKV types
20
-
21
23
  // Use 'any' to avoid hard dependency on react-native-mmkv
22
24
 
23
- // Conditionally import utilities only if MMKV is available
24
- let detectMMKVType;
25
- let isTypeMatch;
26
- if (isMMKVAvailable()) {
27
- const typeDetection = require('../utils/mmkvTypeDetection');
28
- detectMMKVType = typeDetection.detectMMKVType;
29
- isTypeMatch = typeDetection.isTypeMatch;
30
- }
31
25
  /**
32
26
  * Hook to fetch and monitor keys from an MMKV instance
33
27
  *
@@ -86,14 +80,9 @@ export function useMMKVKeys(instance, instanceId, requiredStorageKeys = []) {
86
80
  setIsLoading(true);
87
81
  setError(null);
88
82
  try {
89
- // Guard: Check if MMKV is available
90
- if (!isMMKVAvailable()) {
91
- setStorageKeys([]);
92
- setIsLoading(false);
93
- return; // Silently return empty when MMKV not available
94
- }
95
-
96
- // Guard: Check if instance is provided
83
+ // Guard: Check if instance is provided. (No isMMKVAvailable() gate —
84
+ // registered instances may be remote-backed wrappers with no native
85
+ // module, e.g. on the desktop dashboard.)
97
86
  if (!instance) {
98
87
  setStorageKeys([]);
99
88
  setIsLoading(false);
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { secureStoreRegistry } from "../utils/SecureStoreRegistry";
5
+
6
+ /**
7
+ * Placeholder value shown for `requireAuthentication` keys. Reading those
8
+ * triggers a biometric (Face ID / fingerprint) prompt, so the tool never
9
+ * auto-reads them — it just shows they exist.
10
+ */
11
+ const BIOMETRIC_PLACEHOLDER = "•••••• (biometric protected)";
12
+ /**
13
+ * Read all registered SecureStore keys into StorageKeyInfo[] for the browser.
14
+ *
15
+ * SecureStore has no enumeration API, so this only surfaces keys declared via
16
+ * `registerSecureStoreKeys()` (see SecureStoreRegistry). Values are read with
17
+ * the options each key was registered with (keychainService must match), and
18
+ * biometric-protected keys are listed without being read.
19
+ */
20
+ export function useSecureStoreKeys() {
21
+ const [storageKeys, setStorageKeys] = useState([]);
22
+ const [isLoading, setIsLoading] = useState(true);
23
+ const [error, setError] = useState(null);
24
+ const fetchSecureStoreData = useCallback(async () => {
25
+ const module = secureStoreRegistry.getModule();
26
+ const entries = secureStoreRegistry.getAll();
27
+ if (!module || entries.length === 0) {
28
+ setStorageKeys([]);
29
+ setIsLoading(false);
30
+ return;
31
+ }
32
+ setIsLoading(true);
33
+ setError(null);
34
+ try {
35
+ const keys = [];
36
+ for (const entry of entries) {
37
+ if (entry.options.requireAuthentication) {
38
+ // Never auto-read: reading would fire a biometric prompt.
39
+ keys.push({
40
+ key: entry.key,
41
+ value: BIOMETRIC_PLACEHOLDER,
42
+ storageType: "secure",
43
+ status: "optional_present",
44
+ category: "optional",
45
+ description: entry.description ?? "Protected by biometric authentication — value not read by DevTools",
46
+ ...(entry.options.keychainService && {
47
+ instanceId: entry.options.keychainService
48
+ })
49
+ });
50
+ continue;
51
+ }
52
+ let rawValue = null;
53
+ try {
54
+ rawValue = await module.getItemAsync(entry.key, entry.options);
55
+ } catch {
56
+ // Key unreadable (e.g. keychain state changed) — treat as missing.
57
+ }
58
+
59
+ // Parse JSON values for the DataViewer, same as the AsyncStorage hook.
60
+ let parsedValue = rawValue;
61
+ if (rawValue) {
62
+ try {
63
+ parsedValue = JSON.parse(rawValue);
64
+ } catch {
65
+ parsedValue = rawValue;
66
+ }
67
+ }
68
+ keys.push({
69
+ key: entry.key,
70
+ value: rawValue === null ? undefined : parsedValue,
71
+ storageType: "secure",
72
+ status: rawValue === null ? "required_missing" : "optional_present",
73
+ category: rawValue === null ? "required" : "optional",
74
+ ...(entry.description && {
75
+ description: entry.description
76
+ }),
77
+ ...(entry.options.keychainService && {
78
+ instanceId: entry.options.keychainService
79
+ })
80
+ });
81
+ }
82
+ setStorageKeys(keys);
83
+ } catch (err) {
84
+ setError(err instanceof Error ? err : new Error("Failed to read SecureStore"));
85
+ setStorageKeys([]);
86
+ } finally {
87
+ setIsLoading(false);
88
+ }
89
+ }, []);
90
+
91
+ // Initial fetch (registration happens at app startup, before the tool opens)
92
+ useEffect(() => {
93
+ fetchSecureStoreData();
94
+ }, [fetchSecureStoreData]);
95
+ return {
96
+ storageKeys,
97
+ isLoading,
98
+ error,
99
+ refresh: fetchSecureStoreData
100
+ };
101
+ }
@@ -48,24 +48,38 @@ export function useStorageEvents(options = {}) {
48
48
  setIsCapturing(false);
49
49
  return;
50
50
  }
51
- const unsubscribe = storageEventStore.subscribeToEvents(allEvents => {
52
- // Filter by storage type if specified
53
- let filtered = allEvents;
54
- if (storageType !== "all") {
55
- filtered = allEvents.filter(e => e.storageType === storageType);
56
- }
57
51
 
58
- // Limit events
59
- if (filtered.length > maxEvents) {
60
- filtered = filtered.slice(0, maxEvents);
61
- }
62
- setEvents(filtered);
52
+ // Frame-coalesced (same pattern as network's useNetworkEventList):
53
+ // bursts of storage writes land as ONE commit per frame instead of
54
+ // one per emission — the dominant live-churn cost measured on the
55
+ // network tool (tool-bench 2026-07-06).
56
+ let rafId = null;
57
+ let pending = null;
58
+ const unsubscribe = storageEventStore.subscribeToEvents(allEvents => {
59
+ pending = allEvents;
60
+ if (rafId != null) return;
61
+ rafId = requestAnimationFrame(() => {
62
+ rafId = null;
63
+ if (!pending) return;
64
+ // Filter by storage type if specified
65
+ let filtered = pending;
66
+ pending = null;
67
+ if (storageType !== "all") {
68
+ filtered = filtered.filter(e => e.storageType === storageType);
69
+ }
70
+ // Limit events
71
+ if (filtered.length > maxEvents) {
72
+ filtered = filtered.slice(0, maxEvents);
73
+ }
74
+ setEvents(filtered);
75
+ });
63
76
  });
64
77
 
65
78
  // Mark as capturing after successful subscription
66
79
  setIsCapturing(true);
67
80
  return () => {
68
81
  unsubscribe();
82
+ if (rafId != null) cancelAnimationFrame(rafId);
69
83
  // Note: Don't set isCapturing=false here because we might be
70
84
  // re-subscribing due to dependency change, not actually stopping
71
85
  };
@@ -5,6 +5,14 @@ import { isDevToolsStorageKey } from "@buoy-gg/shared-ui";
5
5
  import { readMany, writeMany, removeMany } from "../utils/asyncStorageCompat";
6
6
  import { storageEventStore } from "../stores/storageEventStore";
7
7
  import { clearAllAppStorage } from "../utils/clearAllStorage";
8
+ import { mmkvInstanceRegistry } from "../utils/MMKVInstanceRegistry";
9
+ import { detectMMKVType } from "../utils/mmkvTypeDetection";
10
+ import { secureStoreRegistry } from "../utils/SecureStoreRegistry";
11
+
12
+ /** One MMKV entry in a `mmkv.snapshot` result. */
13
+
14
+ /** One MMKV instance in a `mmkv.snapshot` result. */
15
+
8
16
  /**
9
17
  * Sync adapter for the storage tool, consumed by @buoy-gg/external-sync's
10
18
  * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
@@ -57,6 +65,80 @@ export const storageSyncAdapter = {
57
65
  "async.removeItem": params => AsyncStorage.removeItem(params.key),
58
66
  "async.multiRemove": params => removeMany(params.keys),
59
67
  "async.multiSet": params => writeMany(params.pairs),
60
- "async.clear": () => AsyncStorage.clear()
68
+ "async.clear": () => AsyncStorage.clear(),
69
+ // ── Remote MMKV proxy (desktop browse mode + MCP) ──
70
+ // MMKV has no remote presence off-device; these read/write the registered
71
+ // instances so sync clients can browse them like AsyncStorage.
72
+ "mmkv.snapshot": () => mmkvInstanceRegistry.getAll().map(({
73
+ id,
74
+ instance,
75
+ encrypted,
76
+ readOnly
77
+ }) => {
78
+ let entries = [];
79
+ try {
80
+ entries = instance.getAllKeys().filter(key => !isDevToolsStorageKey(key)).map(key => {
81
+ const {
82
+ value,
83
+ type: valueType
84
+ } = detectMMKVType(instance, key);
85
+ return {
86
+ key,
87
+ value,
88
+ valueType
89
+ };
90
+ });
91
+ } catch {
92
+ // Instance unreadable — return it with no entries rather than failing
93
+ }
94
+ return {
95
+ id,
96
+ encrypted,
97
+ readOnly,
98
+ entries
99
+ };
100
+ }),
101
+ "mmkv.set": params => {
102
+ const {
103
+ instanceId,
104
+ key,
105
+ value
106
+ } = params;
107
+ mmkvInstanceRegistry.get(instanceId)?.instance.set(key, value);
108
+ },
109
+ "mmkv.remove": params => {
110
+ const {
111
+ instanceId,
112
+ key
113
+ } = params;
114
+ const instance = mmkvInstanceRegistry.get(instanceId)?.instance;
115
+ // v4 renamed delete() to remove() — support both
116
+ if (instance?.remove) instance.remove(key);else instance?.delete?.(key);
117
+ },
118
+ // ── Remote SecureStore proxy ──
119
+ // Only registered keys are known (SecureStore has no enumeration API).
120
+ // Biometric-protected keys are never read — reads would fire an auth prompt.
121
+ "secure.keys": () => secureStoreRegistry.getAll().map(({
122
+ key,
123
+ options,
124
+ description
125
+ }) => ({
126
+ key,
127
+ description,
128
+ keychainService: options.keychainService,
129
+ requireAuthentication: options.requireAuthentication ?? false
130
+ })),
131
+ "secure.get": params => {
132
+ const {
133
+ key
134
+ } = params;
135
+ const entry = secureStoreRegistry.getAll().find(e => e.key === key);
136
+ const module = secureStoreRegistry.getModule();
137
+ if (!entry || !module || entry.options.requireAuthentication) {
138
+ return Promise.resolve(null);
139
+ }
140
+ return module.getItemAsync(key, entry.options);
141
+ },
142
+ "secure.delete": params => secureStoreRegistry.deleteValue(params.key)
61
143
  }
62
144
  };