From e7c3fbe5665b6295fdd12baae392158595886497 Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 13:39:55 +0200
Subject: [PATCH 01/63] feat(storage): inspect and reset app data
Read MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore and SQLite
stores from the app's data directory without app code, mark keys changed since
the first read, and clear one store or all app data.
---
README.md | 5 +-
docs/telemetry.md | 13 +
skills/mobile-dev/SKILL.md | 2 +
src/server/app-storage/container.ts | 170 ++++++++++++
src/server/app-storage/formats.ts | 245 ++++++++++++++++++
src/server/app-storage/mmkv.ts | 121 +++++++++
src/server/app-storage/reader.ts | 158 +++++++++++
src/server/app-storage/tools.ts | 85 ++++++
src/server/plugin.ts | 2 +
src/server/telemetry.ts | 9 +-
src/shared/app-storage.ts | 59 +++++
src/shared/telemetry.ts | 2 +-
src/ui/components/storage-view.tsx | 237 +++++++++++++++++
src/ui/components/workspace.tsx | 35 ++-
src/ui/storage-panel.ts | 204 +++++++++++++++
src/ui/workspace-app.tsx | 6 +-
tests/app-storage.test.ts | 241 +++++++++++++++++
tests/fixtures/app-storage/mmkv/expiring | Bin 0 -> 37 bytes
tests/fixtures/app-storage/mmkv/expiring.crc | Bin 0 -> 112 bytes
tests/fixtures/app-storage/mmkv/fixture | Bin 0 -> 169 bytes
tests/fixtures/app-storage/mmkv/fixture.crc | Bin 0 -> 112 bytes
tests/fixtures/app-storage/mmkv/locked | Bin 0 -> 22 bytes
tests/fixtures/app-storage/mmkv/locked.crc | Bin 0 -> 112 bytes
tests/fixtures/app-storage/mmkv/mmkv.default | Bin 0 -> 26 bytes
.../app-storage/mmkv/mmkv.default.crc | Bin 0 -> 112 bytes
tests/react-ui.test.ts | 35 ++-
26 files changed, 1611 insertions(+), 18 deletions(-)
create mode 100644 src/server/app-storage/container.ts
create mode 100644 src/server/app-storage/formats.ts
create mode 100644 src/server/app-storage/mmkv.ts
create mode 100644 src/server/app-storage/reader.ts
create mode 100644 src/server/app-storage/tools.ts
create mode 100644 src/shared/app-storage.ts
create mode 100644 src/ui/components/storage-view.tsx
create mode 100644 src/ui/storage-panel.ts
create mode 100644 tests/app-storage.test.ts
create mode 100644 tests/fixtures/app-storage/mmkv/expiring
create mode 100644 tests/fixtures/app-storage/mmkv/expiring.crc
create mode 100644 tests/fixtures/app-storage/mmkv/fixture
create mode 100644 tests/fixtures/app-storage/mmkv/fixture.crc
create mode 100644 tests/fixtures/app-storage/mmkv/locked
create mode 100644 tests/fixtures/app-storage/mmkv/locked.crc
create mode 100644 tests/fixtures/app-storage/mmkv/mmkv.default
create mode 100644 tests/fixtures/app-storage/mmkv/mmkv.default.crc
diff --git a/README.md b/README.md
index 6ce0cf5f..85330914 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,9 @@ Track app CPU and memory, expand individual thread charts, and view device-wide

+### App storage
+See what an app has saved without adding code to it: MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite databases, and the largest files in its data directory. Keys added or changed since the first read are marked, so you can watch stale flags and cached logins change. Clear one store or reset all app data. Works on iOS simulators and debuggable Android builds; MMKV value types are guessed from the stored bytes, and encrypted MMKV instances show as locked.
+
## Requirements
@@ -87,7 +90,7 @@ Ask Codex to work with your app, for example:
- “Record CPU and memory for 30 seconds while I scroll checkout.”
- “Compare these two performance recordings.”
-Open Logs or Performance to inspect the selected device. Use Screenshot to attach
+Open Logs, Performance, or Storage to inspect the selected device. Use Screenshot to attach
an image to your next message, or Select to annotate an element or region with an
instruction. Physical iOS supports taps and drags; keyboard and hardware-button
controls are unavailable.
diff --git a/docs/telemetry.md b/docs/telemetry.md
index b223bbd5..5ee0127b 100644
--- a/docs/telemetry.md
+++ b/docs/telemetry.md
@@ -211,6 +211,19 @@ milliseconds with bounded aggregate windows, and counts `ui.app_tools.failure`.
Results that finish after a surface or device change are not recorded. Recent URLs
and the last picked app stay in the panel's local storage and are never sent.
+The Storage tool (`mobile_app_storage`, `mobile_clear_app_storage`) uses the `storage`
+surface. The server records `app_storage.operation.duration` in milliseconds and
+`app_storage.operation.samples` per operation (`read`, `clear`, `reset`) with the device
+platform and a success or failure outcome. The duration covers listing the data
+directory, reading and decoding every recognized store, and for clears stopping the
+app first. Unexpected failures report under `app_storage.` with a fixed
+message; a release Android build, a missing app, or a stopped device counts only
+through `server.error.outcome`. The panel records `ui.storage.list_apps`, `.read`,
+`.clear`, `.reset`, and `.launch` round trips, `ui.storage.process` for validating
+a snapshot and marking changes, gauges `ui.storage.stores` and `ui.storage.entries`,
+and counts `ui.storage.failure`. Bundle IDs, store names, paths, keys, values, and
+file names are never sent.
+
Since 0.1.152, `mobile_app_language` and `mobile_set_app_language` use the same
app control coverage under `app_control.language` and `app_control.set_language`.
Language tags are not sent. The device settings tools report unexpected Baguette or
diff --git a/skills/mobile-dev/SKILL.md b/skills/mobile-dev/SKILL.md
index 87d2eca2..286a61f1 100644
--- a/skills/mobile-dev/SKILL.md
+++ b/skills/mobile-dev/SKILL.md
@@ -39,6 +39,8 @@ These tools work on iOS simulators and Android devices with `{ platform, deviceI
- `mobile_set_app_permission` allows, denies, or resets (`ask`) a permission so system prompts do not interrupt a flow; `permission: "all"` with `state: "ask"` restores first-launch prompts. iOS simulators cannot change camera or notification access, and changes can restart the app.
- `mobile_send_push` sends an APNs payload to an app on an iOS simulator. Android push needs Firebase Cloud Messaging and is not supported.
- `mobile_set_app_language` runs one app in another language and region, such as `de`, `pt-BR`, or `ar` for right-to-left layout, then relaunches it. `language: null` returns the app to the device language. The device language does not change. Android needs version 13 or later.
+- `mobile_app_storage` reads what an app saved, without changes to the app: MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite tables, and the largest files. Use it for bugs that may come from stale state, such as a cached login or an old onboarding flag. MMKV types are guessed from bytes; encrypted MMKV stays locked. Android needs a debuggable build.
+- `mobile_clear_app_storage` stops the app, then clears one store ID from `mobile_app_storage`, or all app data when `store` is omitted. It does not relaunch; call `mobile_launch_app` afterwards.
The panel's settings button opens the same controls under its App tab. Its Device tab also switches to a clean status bar (9:41, full battery and signal) for screenshots and simulates LTE, 3G, Edge, or offline networks. Those device settings are panel-only, so ask the user to set them. Android throttling applies right away to standard app networking such as fetch and OkHttp, leaves local dev servers like Metro at full speed, and is unavailable while the device uses its own proxy. iOS throttling applies to URLSession and fetch requests in apps launched after it was turned on, so relaunch the app after the user enables it.
diff --git a/src/server/app-storage/container.ts b/src/server/app-storage/container.ts
new file mode 100644
index 00000000..cf1ea700
--- /dev/null
+++ b/src/server/app-storage/container.ts
@@ -0,0 +1,170 @@
+import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { join, relative, sep } from "node:path";
+import { ExpectedOperationError } from "../../shared/error-reporting.ts";
+import { commandError, commandOutput } from "../app-control/commands.ts";
+import type { Commands } from "../app-control/commands.ts";
+
+export type ContainerFile = { path: string; bytes: number };
+
+// One app's data directory. Paths are relative to it and use forward slashes.
+export interface AppContainer {
+ readonly platform: "ios" | "android";
+ list(): Promise;
+ read(path: string): Promise;
+ // Copies files into a local directory under their base names, for tools such as sqlite3.
+ copy(paths: string[], directory: string): Promise;
+ remove(paths: string[]): Promise;
+ // The current UserDefaults domain as plist XML, including values cfprefsd has not written yet.
+ preferences?(path: string): Promise;
+ forgetPreferences?(path: string): Promise;
+ // Removes everything the app stored, leaving it installed.
+ reset(): Promise;
+}
+
+// A container directory can hold caches with many files; listing stops here.
+export const maxContainerFiles = 20000;
+
+const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
+
+function safePath(path: string) {
+ if (path === "" || path.startsWith("/") || path.split("/").some(part => part === ".." || part === "")) throw new ExpectedOperationError("invalid_input", "The storage path is outside the app's data.");
+ return path;
+}
+
+export class SimulatorContainer implements AppContainer {
+ readonly platform = "ios";
+ private readonly root: string;
+ private readonly deviceId: string;
+ private readonly commands: Commands;
+
+ constructor(root: string, deviceId: string, commands: Commands) {
+ this.root = root;
+ this.deviceId = deviceId;
+ this.commands = commands;
+ }
+
+ static async open(deviceId: string, bundleId: string, commands: Commands) {
+ let output: string;
+ try { output = await commands.run("xcrun", ["simctl", "get_app_container", deviceId, bundleId, "data"], { timeout: 15000 }); }
+ catch (error) {
+ if (/No such file or directory|not installed/i.test(commandOutput(error))) throw new ExpectedOperationError("invalid_input", `${bundleId} is not installed on this simulator.`);
+ throw commandError(error, "Finding the app data");
+ }
+ return new SimulatorContainer(output.trim(), deviceId, commands);
+ }
+
+ async list() {
+ const files: ContainerFile[] = [];
+ const walk = async (directory: string) => {
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
+ for (const entry of entries) {
+ if (files.length >= maxContainerFiles) return;
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) await walk(path);
+ else if (entry.isFile()) {
+ const info = await stat(path).catch(() => undefined);
+ if (info) files.push({ path: relative(this.root, path).split(sep).join("/"), bytes: info.size });
+ }
+ }
+ };
+ await walk(this.root);
+ return files;
+ }
+
+ async read(path: string) { return readFile(join(this.root, safePath(path))); }
+
+ async copy(paths: string[], directory: string) {
+ await Promise.all(paths.map(async path => writeFile(join(directory, path.slice(path.lastIndexOf("/") + 1)), await this.read(path))));
+ }
+
+ async remove(paths: string[]) {
+ await Promise.all(paths.map(path => rm(join(this.root, safePath(path)), { recursive: true, force: true })));
+ }
+
+ private defaults(args: string[]) {
+ return this.commands.run("xcrun", ["simctl", "spawn", this.deviceId, "defaults", ...args], { timeout: 15000, maxBuffer: 32 * 1024 * 1024 });
+ }
+
+ // `defaults` asks cfprefsd, which may hold writes the app made after the plist was last saved.
+ async preferences(path: string) {
+ const domain = join(this.root, safePath(path)).replace(/\.plist$/, "");
+ try { return await this.defaults(["export", domain, "-"]); }
+ catch { return this.commands.run("plutil", ["-convert", "xml1", "-o", "-", `${domain}.plist`], { timeout: 10000, maxBuffer: 32 * 1024 * 1024 }); }
+ }
+
+ // Deleting only the file would leave cfprefsd's cached copy, which the app would read and save again.
+ async forgetPreferences(path: string) {
+ const domain = join(this.root, safePath(path)).replace(/\.plist$/, "");
+ await this.defaults(["delete", domain]).catch(() => "");
+ }
+
+ async reset() {
+ const preferences = (await this.list()).filter(file => /^Library\/Preferences\/[^/]+\.plist$/.test(file.path));
+ for (const file of preferences) await this.forgetPreferences(file.path);
+ // The container keeps its top-level layout and metadata; everything the app created inside goes.
+ for (const directory of ["Documents", "Library", "tmp"]) {
+ const entries = await readdir(join(this.root, directory)).catch(() => []);
+ await Promise.all(entries.map(entry => rm(join(this.root, directory, entry), { recursive: true, force: true })));
+ }
+ for (const directory of ["Documents", "Library/Caches", "Library/Preferences", "tmp"]) await mkdir(join(this.root, directory), { recursive: true });
+ }
+}
+
+export class AndroidContainer implements AppContainer {
+ readonly platform = "android";
+ private readonly serial: string;
+ private readonly bundleId: string;
+ private readonly commands: Commands;
+
+ constructor(serial: string, bundleId: string, commands: Commands) {
+ this.serial = serial;
+ this.bundleId = bundleId;
+ this.commands = commands;
+ }
+
+ // run-as starts in the app's data directory with the app's user, so it only works for debuggable builds.
+ private async runAs(action: string, command: string, timeout = 20000) {
+ try { return await this.commands.run(await this.commands.adb(), ["-s", this.serial, "shell", `run-as ${this.bundleId} sh -c ${shellQuote(command)}`], { timeout, maxBuffer: 32 * 1024 * 1024 }); }
+ catch (error) { throw runAsError(error, action, this.bundleId); }
+ }
+
+ async list() {
+ // toybox stat prints size and name; `find` without -print0 because older toybox lacks it.
+ const output = await this.runAs("Listing app data", `find . -type f -exec stat -c '%s %n' {} + 2>/dev/null | head -n ${maxContainerFiles}; true`, 30000);
+ if (/^run-as:/m.test(output)) throw runAsError({ stdout: output }, "Listing app data", this.bundleId);
+ return output.split("\n").flatMap(line => {
+ const match = /^(\d+) \.\/(.+)$/.exec(line.trimEnd());
+ return match ? [{ path: match[2], bytes: Number(match[1]) }] : [];
+ });
+ }
+
+ async read(path: string) {
+ const adb = await this.commands.adb();
+ try { return await this.commands.readBinary(adb, ["-s", this.serial, "exec-out", `run-as ${this.bundleId} cat ${shellQuote(safePath(path))}`], { timeout: 30000 }); }
+ catch (error) { throw runAsError(error, "Reading app data", this.bundleId); }
+ }
+
+ async copy(paths: string[], directory: string) {
+ for (const path of paths) await writeFile(join(directory, path.slice(path.lastIndexOf("/") + 1)), await this.read(path));
+ }
+
+ async remove(paths: string[]) {
+ if (paths.length === 0) return;
+ await this.runAs("Clearing app data", `rm -rf -- ${paths.map(path => shellQuote(safePath(path))).join(" ")}`);
+ }
+
+ // pm clear also stops the app and works for release builds.
+ async reset() {
+ let output: string;
+ try { output = await this.commands.run(await this.commands.adb(), ["-s", this.serial, "shell", `pm clear ${this.bundleId}`], { timeout: 30000 }); }
+ catch (error) { throw commandError(error, "Clearing app data"); }
+ if (!/Success/.test(output)) throw new ExpectedOperationError("invalid_input", `Clearing app data failed: ${output.trim().split("\n")[0] || "pm clear did not succeed."}`);
+ }
+}
+
+export function runAsError(error: unknown, action: string, bundleId: string): Error {
+ const output = commandOutput(error);
+ if (/not debuggable/.test(output)) return new ExpectedOperationError("unsupported_platform", `${bundleId} is a release build. Android only allows reading app data of debuggable builds; Reset app data still works.`);
+ if (/unknown package|Unknown package|is unknown/.test(output)) return new ExpectedOperationError("invalid_input", `${bundleId} is not installed on this device.`);
+ return commandError(error, action);
+}
diff --git a/src/server/app-storage/formats.ts b/src/server/app-storage/formats.ts
new file mode 100644
index 00000000..3585ca72
--- /dev/null
+++ b/src/server/app-storage/formats.ts
@@ -0,0 +1,245 @@
+import type { EntryType, StoreKind } from "../../shared/app-storage.ts";
+import { jsonType, readVarint } from "./mmkv.ts";
+
+export type ParsedEntry = { key: string; type: EntryType; value: string; bytes: number; guessed?: boolean };
+
+// ---- Property lists (UserDefaults), parsed from the XML that `defaults export` and `plutil -convert xml1` print.
+
+class PlistData { readonly bytes: Buffer; constructor(bytes: Buffer) { this.bytes = bytes; } }
+class PlistDate { readonly iso: string; constructor(iso: string) { this.iso = iso; } }
+type PlistValue = string | number | bigint | boolean | PlistData | PlistDate | PlistValue[] | { [key: string]: PlistValue };
+
+const entities: Record = { lt: "<", gt: ">", amp: "&", quot: "\"", apos: "'" };
+function unescapeXml(text: string) {
+ return text.replace(/&(#x[0-9a-f]+|#\d+|lt|gt|amp|quot|apos);/gi, (match, name: string) => {
+ if (name[0] !== "#") return entities[name.toLowerCase()] ?? match;
+ const code = name[1] === "x" || name[1] === "X" ? Number.parseInt(name.slice(2), 16) : Number.parseInt(name.slice(1), 10);
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match;
+ });
+}
+
+type Token = { tag: string; close: boolean; empty: boolean } | { text: string };
+function tokens(xml: string): Token[] {
+ const body = xml.replace(/<\?[\s\S]*?\?>||]*>/g, "");
+ const result: Token[] = [];
+ for (const match of body.matchAll(/<(\/?)([A-Za-z]+)(?:\s[^>]*?)?(\/?)>|([^<]+)/g)) {
+ if (match[4] !== undefined) result.push({ text: match[4] });
+ else result.push({ tag: match[2], close: match[1] === "/", empty: match[3] === "/" });
+ }
+ return result;
+}
+
+export function parsePlistXml(xml: string): Record {
+ const list = tokens(xml);
+ let index = 0;
+ const next = () => list[index++];
+ const skipText = () => { while (index < list.length && "text" in list[index] && /^\s*$/.test((list[index] as { text: string }).text)) index++; };
+ const textUntil = (tag: string) => {
+ let text = "";
+ for (let token = next(); token !== undefined; token = next()) {
+ if ("text" in token) text += token.text;
+ else if (token.close && token.tag === tag) return unescapeXml(text);
+ }
+ throw new Error(`Unclosed <${tag}>.`);
+ };
+ const value = (): PlistValue => {
+ skipText();
+ const token = next();
+ if (token === undefined || "text" in token || token.close) throw new Error("Expected a plist value.");
+ const { tag, empty } = token;
+ if (tag === "true" || tag === "false") return tag === "true";
+ if (tag === "dict") {
+ const dict: Record = {};
+ if (empty) return dict;
+ for (;;) {
+ skipText();
+ const key = next();
+ if (key === undefined || "text" in key) throw new Error("Expected a dict key.");
+ if (key.close && key.tag === "dict") return dict;
+ dict[key.empty ? "" : textUntil("key")] = value();
+ }
+ }
+ if (tag === "array") {
+ const array: PlistValue[] = [];
+ if (empty) return array;
+ for (;;) {
+ skipText();
+ const peek = list[index];
+ if (peek && !("text" in peek) && peek.close && peek.tag === "array") { index++; return array; }
+ array.push(value());
+ }
+ }
+ const text = empty ? "" : textUntil(tag);
+ if (tag === "string") return text;
+ if (tag === "integer") return Number.isSafeInteger(Number(text)) ? Number(text) : BigInt(text.trim());
+ if (tag === "real") return Number(text);
+ if (tag === "date") return new PlistDate(text.trim());
+ if (tag === "data") return new PlistData(Buffer.from(text.replace(/\s+/g, ""), "base64"));
+ throw new Error(`Unsupported plist element <${tag}>.`);
+ };
+ skipText();
+ const root = next();
+ if (root === undefined || "text" in root || root.tag !== "plist") throw new Error("Not a property list.");
+ if (root.empty) return {};
+ const top = value();
+ if (typeof top !== "object" || top === null || Array.isArray(top) || top instanceof PlistData || top instanceof PlistDate || typeof top === "bigint") throw new Error("The property list root is not a dictionary.");
+ return top;
+}
+
+function plainPlist(value: PlistValue): unknown {
+ if (value instanceof PlistData) return `<${value.bytes.length} bytes>`;
+ if (value instanceof PlistDate) return value.iso;
+ if (typeof value === "bigint") return value.toString();
+ if (Array.isArray(value)) return value.map(plainPlist);
+ if (typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, plainPlist(child)]));
+ return value;
+}
+
+export function plistEntries(dict: Record): ParsedEntry[] {
+ return Object.entries(dict).map(([key, value]) => {
+ if (value instanceof PlistData) return { key, type: "data", value: value.bytes.toString("hex"), bytes: value.bytes.length };
+ if (value instanceof PlistDate) return { key, type: "date", value: value.iso, bytes: value.iso.length };
+ if (typeof value === "boolean") return { key, type: "boolean", value: String(value), bytes: 1 };
+ if (typeof value === "bigint") return { key, type: "integer", value: value.toString(), bytes: 8 };
+ if (typeof value === "number") return { key, type: Number.isInteger(value) ? "integer" : "number", value: String(value), bytes: 8 };
+ if (typeof value === "string") return { key, type: jsonType(value) ?? "string", value, bytes: Buffer.byteLength(value) };
+ const json = JSON.stringify(plainPlist(value));
+ return { key, type: Array.isArray(value) ? "array" : "object", value: json, bytes: Buffer.byteLength(json) };
+ });
+}
+
+// ---- Android SharedPreferences XML.
+
+export function parseSharedPreferences(xml: string): ParsedEntry[] {
+ const entries: ParsedEntry[] = [];
+ const body = xml.replace(/<\?[\s\S]*?\?>|/g, "");
+ const element = /<(string|boolean|int|long|float|null|set)\s+name="([^"]*)"(?:\s+value="([^"]*)")?\s*(?:\/>|>([\s\S]*?)<\/\1>)/g;
+ for (const match of body.matchAll(element)) {
+ const [, tag, rawName, rawValue, content = ""] = match;
+ const key = unescapeXml(rawName);
+ if (tag === "string") {
+ const value = unescapeXml(content);
+ entries.push({ key, type: jsonType(value) ?? "string", value, bytes: Buffer.byteLength(value) });
+ } else if (tag === "set") {
+ const items = [...content.matchAll(/([\s\S]*?)<\/string>/g)].map(item => unescapeXml(item[1]));
+ const value = JSON.stringify(items);
+ entries.push({ key, type: "array", value, bytes: Buffer.byteLength(value) });
+ } else if (tag === "null") entries.push({ key, type: "null", value: "null", bytes: 0 });
+ else {
+ const value = unescapeXml(rawValue ?? "");
+ const type: EntryType = tag === "boolean" ? "boolean" : tag === "float" ? "number" : "integer";
+ entries.push({ key, type, value, bytes: tag === "long" ? 8 : tag === "boolean" ? 1 : 4 });
+ }
+ }
+ return entries;
+}
+
+// ---- Jetpack DataStore Preferences: protobuf PreferenceMap { map preferences = 1; }.
+
+type Field = { number: number; wire: number; varint?: bigint; bytes?: Buffer };
+function fields(buffer: Buffer): Field[] {
+ const result: Field[] = [];
+ let offset = 0;
+ while (offset < buffer.length) {
+ const tag = readVarint(buffer, offset);
+ if (tag === undefined) throw new Error("Truncated protobuf tag.");
+ offset = tag.next;
+ const number = Number(tag.value >> 3n);
+ const wire = Number(tag.value & 7n);
+ if (wire === 0) {
+ const value = readVarint(buffer, offset);
+ if (value === undefined) throw new Error("Truncated protobuf varint.");
+ result.push({ number, wire, varint: value.value });
+ offset = value.next;
+ } else if (wire === 2) {
+ const length = readVarint(buffer, offset);
+ if (length === undefined) throw new Error("Truncated protobuf length.");
+ const end = length.next + Number(length.value);
+ if (end > buffer.length) throw new Error("Protobuf field runs past the end.");
+ result.push({ number, wire, bytes: buffer.subarray(length.next, end) });
+ offset = end;
+ } else if (wire === 5 || wire === 1) {
+ const size = wire === 5 ? 4 : 8;
+ if (offset + size > buffer.length) throw new Error("Truncated protobuf fixed field.");
+ result.push({ number, wire, bytes: buffer.subarray(offset, offset + size) });
+ offset += size;
+ } else throw new Error(`Unsupported protobuf wire type ${wire}.`);
+ }
+ return result;
+}
+
+export function parseDataStorePreferences(buffer: Buffer): ParsedEntry[] {
+ return fields(buffer).filter(field => field.number === 1 && field.bytes).flatMap(field => {
+ const pair = fields(field.bytes!);
+ const key = pair.find(item => item.number === 1)?.bytes?.toString("utf8");
+ const value = pair.find(item => item.number === 2)?.bytes;
+ if (key === undefined || value === undefined) return [];
+ const [inner] = fields(value);
+ if (inner === undefined) return [{ key, type: "null", value: "null", bytes: 0 } satisfies ParsedEntry];
+ const entry = (type: EntryType, text: string, bytes: number): ParsedEntry => ({ key, type, value: text, bytes });
+ switch (inner.number) {
+ case 1: return [entry("boolean", String(inner.varint === 1n), 1)];
+ case 2: return [entry("number", String(inner.bytes!.readFloatLE(0)), 4)];
+ case 3: return [entry("integer", BigInt.asIntN(32, inner.varint!).toString(), 4)];
+ case 4: return [entry("integer", BigInt.asIntN(64, inner.varint!).toString(), 8)];
+ case 5: { const text = inner.bytes!.toString("utf8"); return [entry(jsonType(text) ?? "string", text, inner.bytes!.length)]; }
+ case 6: { const json = JSON.stringify(fields(inner.bytes!).map(item => item.bytes?.toString("utf8") ?? "")); return [entry("array", json, Buffer.byteLength(json))]; }
+ case 7: return [entry("number", String(inner.bytes!.readDoubleLE(0)), 8)];
+ case 8: return [entry("data", inner.bytes!.toString("hex"), inner.bytes!.length)];
+ default: return [];
+ }
+ });
+}
+
+// ---- Classifying files in the data directory.
+
+export type StoreCandidate = { kind: StoreKind; name: string; path: string; files: string[] };
+
+// System caches the app does not write itself; they stay in the file list but are not shown as databases.
+const systemPaths = /^(?:Library\/(?:Caches|HTTPStorages|WebKit|Cookies|SplashBoard|Saved Application State)\/|SystemData\/|cache\/|code_cache\/|app_webview\/|no_backup\/androidx\.work)/;
+const sqliteSuffix = /-(?:wal|shm|journal)$/;
+
+const basename = (path: string) => path.slice(path.lastIndexOf("/") + 1);
+const dirname = (path: string) => path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
+
+export function classifyFiles(paths: readonly string[], platform: "ios" | "android"): StoreCandidate[] {
+ const all = new Set(paths);
+ const stores: StoreCandidate[] = [];
+ for (const path of paths) {
+ const name = basename(path);
+ const directory = dirname(path);
+ // An MMKV instance is a data file with a `.crc` file next to it, wherever the app placed it.
+ if (name.endsWith(".crc") && all.has(path.slice(0, -4))) {
+ stores.push({ kind: "mmkv", name: name.slice(0, -4), path: path.slice(0, -4), files: [path.slice(0, -4), path] });
+ continue;
+ }
+ if (platform === "ios" && directory === "Library/Preferences" && name.endsWith(".plist")) {
+ stores.push({ kind: "user-defaults", name: name.slice(0, -6), path, files: [path] });
+ continue;
+ }
+ if (name === "manifest.json" && /^RCTAsyncLocalStorage/.test(basename(directory))) {
+ stores.push({ kind: "async-storage", name: directory.includes("ExponentExperienceData/") ? directory.split("/").at(-2) ?? "default" : "default", path, files: paths.filter(file => dirname(file) === directory) });
+ continue;
+ }
+ if (directory.endsWith("shared_prefs") && name.endsWith(".xml")) {
+ stores.push({ kind: "shared-preferences", name: name.slice(0, -4), path, files: [path, ...all.has(`${path}.bak`) ? [`${path}.bak`] : []] });
+ continue;
+ }
+ if (name.endsWith(".preferences_pb")) {
+ stores.push({ kind: "datastore", name: name.slice(0, -".preferences_pb".length), path, files: [path] });
+ continue;
+ }
+ if (name.endsWith(".realm")) {
+ const related = paths.filter(file => file !== path && (file.startsWith(`${path}.`) || file.startsWith(`${path}.management/`)));
+ stores.push({ kind: "realm", name: name.slice(0, -6), path, files: [path, ...related] });
+ continue;
+ }
+ const sqliteName = platform === "android" ? directory === "databases" && !sqliteSuffix.test(name) && !name.endsWith(".bak") : /\.(?:db|sqlite3?|sql)$/i.test(name);
+ if ((sqliteName || /\.(?:db|sqlite3?)$/i.test(name)) && !systemPaths.test(path)) {
+ stores.push({ kind: "sqlite", name, path, files: [path, ...["-wal", "-shm", "-journal"].map(suffix => path + suffix).filter(file => all.has(file))] });
+ }
+ }
+ return stores;
+}
+
+export function storeId(candidate: Pick) { return `${candidate.kind}:${candidate.path}`; }
diff --git a/src/server/app-storage/mmkv.ts b/src/server/app-storage/mmkv.ts
new file mode 100644
index 00000000..bf229e62
--- /dev/null
+++ b/src/server/app-storage/mmkv.ts
@@ -0,0 +1,121 @@
+import { crc32 } from "node:zlib";
+import type { EntryType } from "../../shared/app-storage.ts";
+
+// MMKV's on-disk format, from Tencent/MMKV Core (MMKV_IO.cpp, MiniPBCoder.cpp, MMKVMetaInfo.hpp):
+// - `` holds a 4-byte header, then a placeholder varint and appended records of
+// varint key length, key, varint value length, value. Later records win; an empty value removes the key.
+// - `.crc` holds the CRC, version, write sequence, AES IV, and since version 3 the data size.
+// Version 4 adds flags at byte 104; flag 1 appends a 4-byte expiry time to every value.
+// Values carry no type, so callers guess one from the bytes.
+
+export type MmkvRecord = { key: string; value: Buffer };
+export type MmkvFile =
+ | { status: "ok"; records: MmkvRecord[]; crcValid: boolean | undefined }
+ | { status: "encrypted" }
+ | { status: "unreadable"; reason: string };
+
+const versionRandomIv = 2;
+const versionActualSize = 3;
+const versionFlag = 4;
+const flagKeyExpire = 1n;
+
+export function readVarint(buffer: Buffer, offset: number, limit = 10): { value: bigint; next: number } | undefined {
+ let value = 0n;
+ let shift = 0n;
+ for (let index = 0; index < limit; index++) {
+ if (offset + index >= buffer.length) return undefined;
+ const byte = buffer[offset + index];
+ value |= BigInt(byte & 0x7f) << shift;
+ if ((byte & 0x80) === 0) return { value, next: offset + index + 1 };
+ shift += 7n;
+ }
+ return undefined;
+}
+
+function lengthDelimited(buffer: Buffer, offset: number) {
+ const length = readVarint(buffer, offset, 5);
+ if (length === undefined) throw new Error("truncated length");
+ const end = length.next + Number(length.value);
+ if (end > buffer.length) throw new Error("record runs past the data size");
+ return { bytes: buffer.subarray(length.next, end), next: end };
+}
+
+export function decodeMmkv(data: Buffer, meta?: Buffer): MmkvFile {
+ const version = meta && meta.length >= 8 ? meta.readUInt32LE(4) : 0;
+ if (meta && meta.length >= 28 && version >= versionRandomIv && meta.subarray(12, 28).some(byte => byte !== 0)) return { status: "encrypted" };
+ if (data.length < 4) return { status: "ok", records: [], crcValid: undefined };
+ const size = meta && meta.length >= 32 && version >= versionActualSize ? meta.readUInt32LE(28) : data.readUInt32LE(0);
+ if (size + 4 > data.length) return { status: "unreadable", reason: "The file is shorter than its recorded size." };
+ const body = data.subarray(4, 4 + size);
+ const crcValid = meta && meta.length >= 4 ? crc32(body) === meta.readUInt32LE(0) : undefined;
+ const expiring = meta !== undefined && meta.length >= 112 && version >= versionFlag && (meta.readBigUInt64LE(104) & flagKeyExpire) !== 0n;
+ const now = Math.floor(Date.now() / 1000);
+ const records = new Map();
+ try {
+ let offset = 0;
+ if (body.length > 0) {
+ const placeholder = readVarint(body, 0, 5);
+ if (placeholder === undefined) throw new Error("missing header");
+ offset = placeholder.next;
+ }
+ while (offset < body.length) {
+ const key = lengthDelimited(body, offset);
+ offset = key.next;
+ // MMKV skips records with an empty key without reading a value.
+ if (key.bytes.length === 0) continue;
+ const value = lengthDelimited(body, offset);
+ offset = value.next;
+ const name = key.bytes.toString("utf8");
+ let bytes = value.bytes;
+ if (expiring && bytes.length >= 4) {
+ const expiresAt = bytes.readUInt32LE(bytes.length - 4);
+ bytes = bytes.subarray(0, bytes.length - 4);
+ if (expiresAt !== 0 && expiresAt <= now) { records.delete(name); continue; }
+ }
+ if (value.bytes.length === 0) records.delete(name);
+ else records.set(name, bytes);
+ }
+ } catch (error) {
+ return { status: "unreadable", reason: `The data does not decode as MMKV (${error instanceof Error ? error.message : "invalid"}).` };
+ }
+ return { status: "ok", records: [...records].map(([key, value]) => ({ key, value })), crcValid };
+}
+
+const utf8 = new TextDecoder("utf-8", { fatal: true });
+function text(bytes: Buffer): string | undefined {
+ try {
+ const value = utf8.decode(bytes);
+ // Control characters other than whitespace mean binary data that happens to be valid UTF-8.
+ return /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value) ? undefined : value;
+ } catch { return undefined; }
+}
+
+export function jsonType(value: string): "json" | undefined {
+ const trimmed = value.trim();
+ if (!/^[[{]/.test(trimmed)) return undefined;
+ try { JSON.parse(trimmed); return "json"; } catch { return undefined; }
+}
+
+// Same tie-breaking order as Rozenite's MMKV adapter: strings, then byte buffers, then numbers, then booleans.
+// A single 0x00 byte is either false or an empty string; it reads as false.
+export function guessMmkvValue(bytes: Buffer): { type: EntryType; value: string } {
+ const prefix = readVarint(bytes, 0, 5);
+ if (bytes.length > 1 && prefix && prefix.next + Number(prefix.value) === bytes.length) {
+ const payload = bytes.subarray(prefix.next);
+ const string = text(payload);
+ if (string !== undefined) return { type: jsonType(string) ?? "string", value: string };
+ return { type: "data", value: payload.toString("hex") };
+ }
+ if (bytes.length === 1 && bytes[0] <= 1) return { type: "boolean", value: bytes[0] === 1 ? "true" : "false" };
+ if (bytes.length === 8) {
+ const number = bytes.readDoubleLE(0);
+ if (Number.isFinite(number)) return { type: "number", value: String(number) };
+ }
+ const varint = readVarint(bytes, 0);
+ if (varint && varint.next === bytes.length) return { type: "integer", value: BigInt.asIntN(64, varint.value).toString() };
+ if (bytes.length === 4) {
+ const number = bytes.readFloatLE(0);
+ if (Number.isFinite(number)) return { type: "number", value: String(number) };
+ }
+ return { type: "data", value: bytes.toString("hex") };
+}
diff --git a/src/server/app-storage/reader.ts b/src/server/app-storage/reader.ts
new file mode 100644
index 00000000..02183802
--- /dev/null
+++ b/src/server/app-storage/reader.ts
@@ -0,0 +1,158 @@
+import { createHash } from "node:crypto";
+import { join } from "node:path";
+import { ExpectedOperationError } from "../../shared/error-reporting.ts";
+import { storageLimits } from "../../shared/app-storage.ts";
+import type { StorageEntry, StorageFile, StorageSnapshot, StorageStore } from "../../shared/app-storage.ts";
+import { withTemporaryDirectory } from "../app-control/commands.ts";
+import type { CommandRunner } from "../app-control/commands.ts";
+import type { AppContainer, ContainerFile } from "./container.ts";
+import { classifyFiles, parseDataStorePreferences, parsePlistXml, parseSharedPreferences, plistEntries, storeId } from "./formats.ts";
+import type { ParsedEntry, StoreCandidate } from "./formats.ts";
+import { decodeMmkv, guessMmkvValue, jsonType } from "./mmkv.ts";
+
+export type ReadOptions = { entries: number; valueLength: number; bundleId: string; run: CommandRunner };
+type StoreContent = { status: StorageStore["status"]; note?: string; entries: ParsedEntry[] };
+
+const sqlite3 = process.platform === "darwin" ? "/usr/bin/sqlite3" : "sqlite3";
+const quoteIdentifier = (name: string) => `"${name.replaceAll("\"", "\"\"")}"`;
+
+async function sqlite(run: CommandRunner, file: string, sql: string): Promise[]> {
+ const output = await run(sqlite3, ["-json", file, sql], { timeout: 15000, maxBuffer: 64 * 1024 * 1024 });
+ return output.trim() ? JSON.parse(output) : [];
+}
+
+function keyValueEntries(rows: Record[]): ParsedEntry[] {
+ return rows.map(row => {
+ const value = row.value === null || row.value === undefined ? null : String(row.value);
+ if (value === null) return { key: String(row.key), type: "null", value: "null", bytes: 0 };
+ return { key: String(row.key), type: jsonType(value) ?? "string", value, bytes: Buffer.byteLength(value) };
+ });
+}
+
+// AsyncStorage tables are recognized by name: v1/v2 use catalystLocalStorage, v2's next storage a Room
+// table named Storage, and v3 an `entries` table under async-storage/databases/.
+async function readSqlite(container: AppContainer, candidate: StoreCandidate, run: CommandRunner): Promise {
+ return withTemporaryDirectory(async directory => {
+ // The copy includes the write-ahead log, so recent writes that SQLite has not checkpointed are read too.
+ await container.copy(candidate.files, directory);
+ const file = join(directory, candidate.name);
+ let tables: string[];
+ try { tables = (await sqlite(run, file, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")).map(row => String(row.name)); }
+ catch { return { status: "unreadable", note: "Not a readable SQLite database.", entries: [] }; }
+ const keyValueTable = tables.includes("catalystLocalStorage") ? "catalystLocalStorage"
+ : tables.includes("entries") && candidate.path.includes("async-storage/") ? "entries"
+ : tables.includes("Storage") && /^AsyncStorage/.test(candidate.name) ? "Storage" : undefined;
+ if (keyValueTable) {
+ const rows = await sqlite(run, file, `SELECT key, value FROM ${quoteIdentifier(keyValueTable)}`);
+ return { status: "ok", entries: keyValueEntries(rows), asyncStorage: true };
+ }
+ const entries = await Promise.all(tables.map(async (table): Promise => {
+ const [count] = await sqlite(run, file, `SELECT count(*) AS rows FROM ${quoteIdentifier(table)}`).catch(() => [{ rows: "?" }]);
+ const columns = (await sqlite(run, file, `PRAGMA table_info(${quoteIdentifier(table)})`).catch(() => [])).map(column => String(column.name));
+ const value = `${count?.rows ?? "?"} rows · ${columns.join(", ")}`;
+ return { key: table, type: "table", value, bytes: 0 };
+ }));
+ return { status: "ok", entries };
+ });
+}
+
+async function readAsyncManifest(container: AppContainer, candidate: StoreCandidate): Promise {
+ const manifest: unknown = JSON.parse((await container.read(candidate.path)).toString("utf8"));
+ if (typeof manifest !== "object" || manifest === null) return { status: "unreadable", note: "The AsyncStorage manifest is not an object.", entries: [] };
+ const directory = candidate.path.slice(0, candidate.path.lastIndexOf("/"));
+ // Values over 1 KB live in a file named after the MD5 of their key; the manifest stores null for them.
+ const entries = await Promise.all(Object.entries(manifest).map(async ([key, stored]): Promise => {
+ const value = typeof stored === "string" ? stored
+ : await container.read(`${directory}/${createHash("md5").update(key).digest("hex")}`).then(buffer => buffer.toString("utf8"), () => null);
+ if (value === null) return { key, type: "null", value: "null", bytes: 0 };
+ return { key, type: jsonType(value) ?? "string", value, bytes: Buffer.byteLength(value) };
+ }));
+ return { status: "ok", entries };
+}
+
+async function readStore(container: AppContainer, candidate: StoreCandidate, run: CommandRunner): Promise {
+ switch (candidate.kind) {
+ case "mmkv": {
+ const [data, meta] = await Promise.all(candidate.files.map(file => container.read(file)));
+ let decoded = decodeMmkv(data, meta);
+ // The app may be writing while the files are read; a CRC mismatch is retried once.
+ if (decoded.status === "ok" && decoded.crcValid === false) {
+ const [again, againMeta] = await Promise.all(candidate.files.map(file => container.read(file)));
+ decoded = decodeMmkv(again, againMeta);
+ }
+ if (decoded.status === "encrypted") return { status: "encrypted", note: "Encrypted with a key only the app knows.", entries: [] };
+ if (decoded.status === "unreadable") return { status: "unreadable", note: decoded.reason, entries: [] };
+ const note = decoded.crcValid === false ? "The checksum does not match; the app may be writing. Refresh to read again." : undefined;
+ return { status: "ok", note, entries: decoded.records.map(record => ({ key: record.key, ...guessMmkvValue(record.value), bytes: record.value.length, guessed: true })) };
+ }
+ case "user-defaults": {
+ const xml = container.preferences ? await container.preferences(candidate.path) : (await container.read(candidate.path)).toString("utf8");
+ return { status: "ok", entries: plistEntries(parsePlistXml(xml)) };
+ }
+ case "shared-preferences":
+ return { status: "ok", entries: parseSharedPreferences((await container.read(candidate.path)).toString("utf8")) };
+ case "datastore":
+ return { status: "ok", entries: parseDataStorePreferences(await container.read(candidate.path)) };
+ case "async-storage":
+ return readAsyncManifest(container, candidate);
+ case "sqlite":
+ return readSqlite(container, candidate, run);
+ case "realm":
+ return { status: "ok", note: "Realm files are listed but not decoded.", entries: [] };
+ }
+}
+
+function limitEntry(entry: ParsedEntry, valueLength: number): StorageEntry {
+ if (entry.value.length <= valueLength) return entry;
+ return { ...entry, value: entry.value.slice(0, valueLength), truncated: true };
+}
+
+async function mapLimited(items: readonly T[], limit: number, work: (item: T) => Promise): Promise {
+ const results: R[] = new Array(items.length);
+ let next = 0;
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
+ while (next < items.length) { const index = next++; results[index] = await work(items[index]); }
+ }));
+ return results;
+}
+
+export async function readStorage(container: AppContainer, { entries: entryLimit, valueLength, bundleId, run }: ReadOptions): Promise {
+ const files = await container.list();
+ const sizes = new Map(files.map(file => [file.path, file.bytes]));
+ const candidates = classifyFiles(files.map(file => file.path), container.platform);
+ const owner = new Map();
+ const stores = await mapLimited(candidates, 4, async (candidate): Promise => {
+ const id = storeId(candidate);
+ for (const file of candidate.files) owner.set(file, id);
+ let content: StoreContent & { asyncStorage?: boolean };
+ try { content = await readStore(container, candidate, run); }
+ catch (error) {
+ if (error instanceof ExpectedOperationError) throw error;
+ content = { status: "unreadable", note: "This file could not be read or parsed.", entries: [] };
+ }
+ const kind = content.asyncStorage ? "async-storage" : candidate.kind;
+ return {
+ id, kind, name: candidate.name, path: candidate.path, status: content.status, ...content.note ? { note: content.note } : {},
+ bytes: candidate.files.reduce((total, file) => total + (sizes.get(file) ?? 0), 0),
+ entryCount: content.entries.length,
+ entries: content.entries.slice(0, entryLimit).map(entry => limitEntry(entry, valueLength)),
+ };
+ });
+ const sorted = [...files].sort((a, b) => b.bytes - a.bytes).slice(0, storageLimits.files);
+ return {
+ platform: container.platform, bundleId, readAt: Date.now(), stores,
+ files: sorted.map((file: ContainerFile): StorageFile => ({ ...file, ...owner.has(file.path) ? { store: owner.get(file.path) } : {} })),
+ fileCount: files.length,
+ totalBytes: files.reduce((total, file) => total + file.bytes, 0),
+ };
+}
+
+// Store IDs are matched against a fresh listing, so a caller can only remove files that belong to a found store.
+export async function clearStore(container: AppContainer, id: string) {
+ const files = await container.list();
+ const candidate = classifyFiles(files.map(file => file.path), container.platform).find(store => storeId(store) === id);
+ if (candidate === undefined) throw new ExpectedOperationError("invalid_input", "That storage no longer exists. Refresh and try again.");
+ if (candidate.kind === "user-defaults" && container.forgetPreferences) await container.forgetPreferences(candidate.path);
+ await container.remove(candidate.files);
+ return candidate;
+}
diff --git a/src/server/app-storage/tools.ts b/src/server/app-storage/tools.ts
new file mode 100644
index 00000000..3c44acba
--- /dev/null
+++ b/src/server/app-storage/tools.ts
@@ -0,0 +1,85 @@
+import { z } from "zod";
+import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { captureServerError, recordAppStorageOperation } from "../telemetry.ts";
+import { errorMessage } from "../../shared/protocol.ts";
+import { expectedOutcome } from "../../shared/error-reporting.ts";
+import { appTargetShape, bundleIdSchema } from "../../shared/app-control.ts";
+import { storageLimits } from "../../shared/app-storage.ts";
+import { systemCommands } from "../app-control/commands.ts";
+import type { Commands } from "../app-control/commands.ts";
+import { createDrivers } from "../app-control/tools.ts";
+import type { DriverFactory } from "../app-control/tools.ts";
+import { AndroidContainer, SimulatorContainer } from "./container.ts";
+import type { AppContainer } from "./container.ts";
+import { clearStore, readStorage } from "./reader.ts";
+
+type Target = { platform: "ios" | "android"; deviceId: string };
+export type ContainerFactory = (target: Target, bundleId: string) => Promise;
+
+export function createContainers(commands: Commands = systemCommands): ContainerFactory {
+ return async ({ platform, deviceId }, bundleId) => platform === "android"
+ ? new AndroidContainer(deviceId, bundleId, commands)
+ : SimulatorContainer.open(deviceId, bundleId, commands);
+}
+
+const bundleId = bundleIdSchema.describe("App bundle ID or Android package name.");
+
+export function registerAppStorageTools(server: McpServer, commands: Commands = systemCommands, driverFor: DriverFactory = createDrivers(commands), containerFor: ContainerFactory = createContainers(commands)) {
+ async function run(operation: "read" | "clear" | "reset", target: Target, work: () => Promise>) {
+ const startedAt = performance.now();
+ try {
+ const result = await work();
+ recordAppStorageOperation(operation, target.platform, "success", performance.now() - startedAt);
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }], structuredContent: result };
+ } catch (error) {
+ recordAppStorageOperation(operation, target.platform, "failed", performance.now() - startedAt);
+ // Paths, keys, and values can be personal, so Sentry receives only the operation.
+ captureServerError(expectedOutcome(error) === undefined ? new Error(`App storage ${operation} failed.`) : error, `app_storage.${operation}`);
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(error) }] };
+ }
+ }
+
+ registerAppTool(server, "mobile_app_storage", {
+ title: "Read app storage",
+ description: "Read an app's saved data without changes to the app: MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite databases, and the largest files. iOS simulators, and debuggable Android builds. MMKV value types are guessed from bytes.",
+ inputSchema: {
+ ...appTargetShape, bundleId,
+ store: z.string().max(4096).optional().describe("Only return this store ID from an earlier result."),
+ entries: z.number().int().min(1).max(storageLimits.maxEntries).optional().describe(`Entries per store, default ${storageLimits.entries}.`),
+ valueLength: z.number().int().min(40).max(storageLimits.maxValueLength).optional().describe(`Characters per value, default ${storageLimits.valueLength}.`),
+ },
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
+ _meta: { ui: { visibility: ["app", "model"] } },
+ }, async input => {
+ const target = { platform: input.platform, deviceId: input.deviceId };
+ return run("read", target, async () => {
+ driverFor(target);
+ const container = await containerFor(target, input.bundleId);
+ const snapshot = await readStorage(container, { bundleId: input.bundleId, entries: input.entries ?? storageLimits.entries, valueLength: input.valueLength ?? storageLimits.valueLength, run: commands.run });
+ return input.store ? { ...snapshot, stores: snapshot.stores.filter(store => store.id === input.store) } : snapshot;
+ });
+ });
+
+ registerAppTool(server, "mobile_clear_app_storage", {
+ title: "Clear app storage",
+ description: "Stop the app, then delete one store from mobile_app_storage, or all of the app's saved data when store is omitted. The app stays installed and is not relaunched.",
+ inputSchema: { ...appTargetShape, bundleId, store: z.string().max(4096).optional().describe("Store ID to clear. Omit to reset all app data.") },
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
+ _meta: { ui: { visibility: ["app", "model"] } },
+ }, async input => {
+ const target = { platform: input.platform, deviceId: input.deviceId };
+ return run(input.store ? "clear" : "reset", target, async () => {
+ const driver = driverFor(target);
+ const container = await containerFor(target, input.bundleId);
+ // A running app keeps values in memory and would write them back.
+ await driver.stop(input.bundleId);
+ if (input.store === undefined) {
+ await container.reset();
+ return { cleared: "all", stopped: true };
+ }
+ const store = await clearStore(container, input.store);
+ return { cleared: store.name, stopped: true };
+ });
+ });
+}
diff --git a/src/server/plugin.ts b/src/server/plugin.ts
index ff671ffb..d592bdc8 100644
--- a/src/server/plugin.ts
+++ b/src/server/plugin.ts
@@ -16,6 +16,7 @@ import { OpenAIExtensions } from "@openai/mcp-extensions/server";
import { z } from "zod";
import { registerDeviceSettingsTools } from "./device-settings.ts";
import { registerAppControlTools } from "./app-control/tools.ts";
+import { registerAppStorageTools } from "./app-storage/tools.ts";
import { readBezel } from "./bezel.ts";
import { ServeEmu } from "./serve-emu.ts";
import { registerAndroidTools } from "./android-tools.ts";
@@ -123,6 +124,7 @@ export async function createPlugin(html: string | (() => Promise), b
});
const closeDeviceSettings = registerDeviceSettingsTools(server, baguette, android);
registerAppControlTools(server);
+ registerAppStorageTools(server);
const automaticRepairs = new Map }>();
async function panelInputStatus(udid: string) {
diff --git a/src/server/telemetry.ts b/src/server/telemetry.ts
index 9c5fa2ce..0c98d2bc 100644
--- a/src/server/telemetry.ts
+++ b/src/server/telemetry.ts
@@ -27,6 +27,13 @@ export function recordPluginUpdate(operation: "check" | "install", outcome: "dis
Sentry.metrics.gauge("plugin.update.duration", duration, { unit: "millisecond", attributes });
}
+export function recordAppStorageOperation(operation: "read" | "clear" | "reset", platform: "ios" | "android", outcome: "success" | "failed", duration: number) {
+ if (process.env.MOBILE_DEV_TELEMETRY === "off") return;
+ const attributes = { component: "server", surface: "storage", operation, device_platform: platform, outcome };
+ Sentry.metrics.count("app_storage.operation.samples", 1, { attributes });
+ Sentry.metrics.gauge("app_storage.operation.duration", duration, { unit: "millisecond", attributes });
+}
+
export function recordTraceSiftOperation(operation: "ready" | "analyze" | "remember" | "restore" | "handoff" | "task", outcome: "success" | "failed", duration: number) {
if (process.env.MOBILE_DEV_TELEMETRY === "off") return;
const attributes = { component: "server", surface: "tracesift", operation, outcome };
@@ -233,7 +240,7 @@ const resourceKinds: Record = {
"android-stream:": "android_video", "ios-video:": "ios_video", "ui:": "ui",
};
const allowedContext: Record = {
- surface: ["logs", "performance", "simulator", "recording", "comparison"], view: ["panel", "workspace", "recording", "comparison"], layout: ["ios", "android", "both", "none"],
+ surface: ["logs", "performance", "storage", "simulator", "recording", "comparison"], view: ["panel", "workspace", "recording", "comparison"], layout: ["ios", "android", "both", "none"],
device_platform: ["ios", "android", "mixed"], device_kind: ["physical", "simulator", "emulator", "none"],
};
diff --git a/src/shared/app-storage.ts b/src/shared/app-storage.ts
new file mode 100644
index 00000000..3f79ec78
--- /dev/null
+++ b/src/shared/app-storage.ts
@@ -0,0 +1,59 @@
+import { z } from "zod";
+
+// Storage the panel recognizes from files in the app's data directory, without code in the app.
+export const storeKinds = ["mmkv", "async-storage", "user-defaults", "shared-preferences", "datastore", "sqlite", "realm"] as const;
+export type StoreKind = typeof storeKinds[number];
+export const storeKindLabels: Record = {
+ "mmkv": "MMKV", "async-storage": "AsyncStorage", "user-defaults": "UserDefaults", "shared-preferences": "SharedPreferences",
+ "datastore": "DataStore", "sqlite": "Databases", "realm": "Realm",
+};
+
+export const entryTypes = ["string", "json", "number", "integer", "boolean", "date", "data", "array", "object", "null", "table"] as const;
+export type EntryType = typeof entryTypes[number];
+
+// Values arrive as display text: JSON for json, arrays and objects, hex for data. `guessed` marks a type
+// inferred from bytes, because MMKV files do not record value types.
+const entry = z.object({
+ key: z.string(),
+ type: z.enum(entryTypes),
+ value: z.string(),
+ bytes: z.number().int().nonnegative(),
+ guessed: z.boolean().optional(),
+ truncated: z.boolean().optional(),
+});
+export type StorageEntry = z.infer;
+
+// `encrypted` stores are found but cannot be read without the app's key; `unreadable` ones failed to parse.
+export const storeStatuses = ["ok", "encrypted", "unreadable"] as const;
+const store = z.object({
+ id: z.string(),
+ kind: z.enum(storeKinds),
+ name: z.string(),
+ path: z.string(),
+ status: z.enum(storeStatuses),
+ note: z.string().optional(),
+ bytes: z.number().int().nonnegative(),
+ entryCount: z.number().int().nonnegative(),
+ entries: z.array(entry),
+});
+export type StorageStore = z.infer;
+
+const file = z.object({ path: z.string(), bytes: z.number().int().nonnegative(), store: z.string().optional() });
+export type StorageFile = z.infer;
+
+export const storageSnapshotSchema = z.object({
+ platform: z.enum(["ios", "android"]),
+ bundleId: z.string(),
+ readAt: z.number(),
+ stores: z.array(store),
+ // Largest files first, capped; fileCount and totalBytes cover the whole data directory.
+ files: z.array(file),
+ fileCount: z.number().int().nonnegative(),
+ totalBytes: z.number().int().nonnegative(),
+});
+export type StorageSnapshot = z.infer;
+
+export const storageClearSchema = z.object({ cleared: z.string(), stopped: z.boolean() });
+
+// Defaults keep model-visible results small; the panel asks for more.
+export const storageLimits = { entries: 200, maxEntries: 2000, valueLength: 300, maxValueLength: 16000, files: 400 };
diff --git a/src/shared/telemetry.ts b/src/shared/telemetry.ts
index 342c0b27..1ded4b90 100644
--- a/src/shared/telemetry.ts
+++ b/src/shared/telemetry.ts
@@ -17,7 +17,7 @@ export function validateTelemetryEnvironment(value: unknown): TelemetryEnvironme
throw new Error("Sentry environment must be development or release.");
}
-export type Surface = "logs" | "performance" | "simulator" | "recording" | "comparison" | "tracesift";
+export type Surface = "logs" | "performance" | "storage" | "simulator" | "recording" | "comparison" | "tracesift";
export type TelemetryAttributes = Record;
type Options = NonNullable[0]>;
type ErrorEvent = Parameters>[0];
diff --git a/src/ui/components/storage-view.tsx b/src/ui/components/storage-view.tsx
new file mode 100644
index 00000000..6a8fc3df
--- /dev/null
+++ b/src/ui/components/storage-view.tsx
@@ -0,0 +1,237 @@
+import { CopyIcon, DatabaseIcon, FolderIcon, LockIcon, PlayIcon, RefreshCwIcon, RotateCcwIcon, SearchIcon, TriangleAlertIcon } from "lucide-react";
+import { memo, useEffect, useMemo, useState, useSyncExternalStore } from "react";
+import { storeKindLabels, storeKinds } from "../../shared/app-storage.ts";
+import type { StorageEntry, StorageStore } from "../../shared/app-storage.ts";
+import type { StoragePanel } from "../storage-panel.ts";
+import { Alert, AlertDescription } from "./ui/alert";
+import { Badge } from "./ui/badge";
+import { Button } from "./ui/button";
+import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "./ui/empty";
+import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group";
+import { NativeSelect, NativeSelectOption } from "./ui/native-select";
+import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "./ui/popover";
+import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
+import { Toggle } from "./ui/toggle";
+import { useMediaQuery } from "./use-media-query";
+
+const FILES = "files";
+
+export function formatBytes(bytes: number) {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+function formatAge(milliseconds: number) {
+ const seconds = Math.max(0, Math.round(milliseconds / 1000));
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.round(seconds / 60);
+ return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`;
+}
+
+export function displayValue(entry: StorageEntry) {
+ if (entry.type === "data") return entry.value.replace(/(..)(?!$)/g, "$1 ");
+ if ((entry.type === "json" || entry.type === "array" || entry.type === "object") && !entry.truncated) {
+ try { return JSON.stringify(JSON.parse(entry.value), null, 2); } catch { return entry.value; }
+ }
+ return entry.value;
+}
+
+const typeLabel = (entry: StorageEntry) => `${entry.type === "data" ? "bytes" : entry.type}${entry.guessed ? "?" : ""}`;
+
+function useNow(active: boolean) {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ if (!active) return;
+ setNow(Date.now());
+ const timer = setInterval(() => { if (document.visibilityState !== "hidden") setNow(Date.now()); }, 5000);
+ return () => clearInterval(timer);
+ }, [active]);
+ return now;
+}
+
+export const StorageView = memo(function StorageView({ panel }: { panel: StoragePanel }) {
+ const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot);
+ const wide = useMediaQuery("(min-width: 600px)");
+ const [query, setQuery] = useState("");
+ const [changedOnly, setChangedOnly] = useState(false);
+ const [selectedStore, setSelectedStore] = useState("");
+ const [selectedKey, setSelectedKey] = useState();
+ const [copied, setCopied] = useState("");
+ const now = useNow(state.open && state.snapshot !== undefined);
+ const snapshot = state.snapshot;
+ const busy = state.busy !== null && state.busy !== "apps";
+ const search = query.trim().toLowerCase();
+
+ const matches = useMemo(() => {
+ const result = new Map();
+ for (const store of snapshot?.stores ?? []) {
+ const changes = state.changes.get(store.id);
+ result.set(store.id, store.entries.filter(entry => (!changedOnly || changes?.has(entry.key))
+ && (!search || entry.key.toLowerCase().includes(search) || entry.value.toLowerCase().includes(search))));
+ }
+ return result;
+ }, [snapshot, state.changes, search, changedOnly]);
+
+ const filtering = search !== "" || changedOnly;
+ const visibleStores = (snapshot?.stores ?? []).filter(store => !filtering || (matches.get(store.id)?.length ?? 0) > 0);
+ const store = snapshot?.stores.find(item => item.id === selectedStore);
+ const activeStore = selectedStore === FILES ? undefined : store ?? visibleStores[0];
+ const showFiles = selectedStore === FILES || (activeStore === undefined && snapshot !== undefined);
+ const entries = activeStore ? matches.get(activeStore.id) ?? [] : [];
+ const entry = entries.find(item => item.key === selectedKey);
+ const changes = activeStore ? state.changes.get(activeStore.id) : undefined;
+ const changedCount = [...state.changes.values()].reduce((total, marks) => total + marks.size, 0);
+
+ const copy = async (text: string) => {
+ try { await navigator.clipboard.writeText(text); setCopied("Copied"); }
+ catch { setCopied("Could not copy"); }
+ };
+
+ const tree =
+ {storeKinds.map(kind => {
+ const stores = visibleStores.filter(item => item.kind === kind);
+ if (stores.length === 0) return null;
+ return
+
{storeKindLabels[kind]}
+ {stores.map(item =>
0} selected={activeStore?.id === item.id && !showFiles}
+ onSelect={() => { setSelectedStore(item.id); setSelectedKey(undefined); }} />)}
+ ;
+ })}
+ {snapshot && { setSelectedStore(FILES); setSelectedKey(undefined); }}>
+ Files {formatBytes(snapshot.totalBytes)}
+ }
+ ;
+
+ const detail = entry && activeStore &&
+
+ {entry.key}
+ {typeLabel(entry)} · {formatBytes(entry.bytes)}
+ void copy(entry.value)}>
+
+
{displayValue(entry)}
+
+ {entry.guessed && "Type guessed from the stored bytes; MMKV does not record types. "}
+ {entry.truncated && "Value cut short; copy the full value with the mobile_app_storage tool. "}
+ {activeStore.path}
+
+
;
+
+ const storeView = activeStore &&
+
+ {storeKindLabels[activeStore.kind]} › {activeStore.name}
+ {activeStore.entryCount} {activeStore.kind === "sqlite" ? "table" : "key"}{activeStore.entryCount === 1 ? "" : "s"} · {formatBytes(activeStore.bytes)}
+ void panel.clear(activeStore.id)} />
+
+ {activeStore.note &&
{activeStore.status !== "ok" && (activeStore.status === "encrypted" ? : )}{activeStore.note}
}
+
+ {entries.map(item => {
+ const change = changes?.get(item.key);
+ return
setSelectedKey(item.key === selectedKey ? undefined : item.key)}
+ className="grid w-full grid-cols-[minmax(6rem,2fr)_5rem_minmax(0,3fr)] items-center gap-2 border-b px-2 py-1 text-left hover:bg-muted data-[selected=true]:bg-muted">
+ {change && }{item.key}
+ {typeLabel(item)}
+ {item.type === "data" ? `${item.value.slice(0, 48).replace(/(..)(?!$)/g, "$1 ")}${item.value.length > 48 ? " …" : ""}` : item.value}
+ ;
+ })}
+ {entries.length === 0 && activeStore.status === "ok" &&
{filtering ? "No matching keys." : "Empty."}
}
+ {activeStore.entries.length < activeStore.entryCount && !filtering &&
Showing the first {activeStore.entries.length} of {activeStore.entryCount}.
}
+
+ {detail}
+
;
+
+ const filesView = snapshot &&
+
+ Files
+ {snapshot.fileCount} files · {formatBytes(snapshot.totalBytes)}
+
+
+ {snapshot.files.filter(file => !search || file.path.toLowerCase().includes(search)).map(file => {
+ const owner = file.store ? snapshot.stores.find(item => item.id === file.store) : undefined;
+ return
+ {file.path}
+ {owner && {storeKindLabels[owner.kind]} }
+ {formatBytes(file.bytes)}
+
;
+ })}
+ {snapshot.files.length < snapshot.fileCount &&
Showing the {snapshot.files.length} largest files.
}
+
+
;
+
+ let body;
+ if (state.unsupported || !state.available) {
+ body = ;
+ } else if (!state.bundleId) {
+ body = ;
+ } else if (!snapshot) {
+ body = state.error ? null : ;
+ } else if (snapshot.stores.length === 0 && !showFiles) {
+ body = ;
+ } else {
+ body =
+ {tree}
+
+ {showFiles ? filesView : storeView}
+ ;
+ }
+
+ return
+
+
+ panel.selectApp(event.target.value)}>
+ {state.busy === "apps" ? "Loading apps…" : "Choose an app"}
+ {state.apps.map(app => {app.name === app.bundleId ? app.bundleId : `${app.name} · ${app.bundleId}`} )}
+
+ setQuery(event.target.value)} />
+ Changed{changedCount > 0 && {changedCount} }
+ {snapshot && {state.busy === "read" ? "Reading…" : formatAge(now - snapshot.readAt)} }
+ void panel.refresh()}>
+ void panel.clear()} />
+
+ {copied}
+ {state.error && {state.error} }
+ {state.status &&
+
{state.status}
+
void panel.launch()}> Launch app
+
}
+ {body}
+ ;
+});
+
+function StoreButton({ store, count, changed, selected, onSelect }: { store: StorageStore; count: number; changed: boolean; selected: boolean; onSelect: () => void }) {
+ return
+ {store.status === "encrypted" ?
+ : store.status === "unreadable" ? : null}
+ {store.name}
+ {changed && }
+ {store.status === "ok" ? count : "—"}
+ ;
+}
+
+function ConfirmButton({ label, title, description, disabled, onConfirm }: { label: string; title: string; description: string; disabled?: boolean; onConfirm: () => void }) {
+ return
+ {label === "Reset" && }{label}
+
+ {title}?
+ {description}
+
+
Cancel
+
{label === "Reset" ? "Reset app data" : label}
+
+
+ ;
+}
+
+function StorageEmpty({ title, description }: { title: string; description: string }) {
+ return
+
+
+ {title}
+ {description}
+
+ ;
+}
diff --git a/src/ui/components/workspace.tsx b/src/ui/components/workspace.tsx
index 7b947980..a77e3e88 100644
--- a/src/ui/components/workspace.tsx
+++ b/src/ui/components/workspace.tsx
@@ -1,10 +1,12 @@
-import { ActivityIcon, TerminalIcon, PanelsTopLeftIcon } from "lucide-react";
+import { ActivityIcon, DatabaseIcon, TerminalIcon, PanelsTopLeftIcon } from "lucide-react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { LogsPanel } from "../logs-panel.ts";
import type { PerformancePanel } from "../performance-panel.ts";
+import type { StoragePanel } from "../storage-panel.ts";
import { PerformanceView } from "./performance-view";
import { SimulatorView } from "./simulator-view";
import { LogsView } from "./logs-view";
+import { StorageView } from "./storage-view";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "./ui/base-select";
import { Button } from "./ui/button";
@@ -20,8 +22,12 @@ import type { PluginUpdateController } from "../plugin-updates.ts";
import type { DeviceLayout } from "../device-layout.ts";
export type { DeviceLayout } from "../device-layout.ts";
-export function Workspace({ logs, performance, recordingController, updates, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; recordingController: RecordingController; updates?: PluginUpdateController; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) {
- const [tool, setTool] = useState<"logs" | "performance">("logs");
+type Tool = "logs" | "performance" | "storage";
+const toolLabels: Record = { logs: "Logs", performance: "Performance", storage: "Storage" };
+const toolIcons = { logs: TerminalIcon, performance: ActivityIcon, storage: DatabaseIcon };
+
+export function Workspace({ logs, performance, storage, recordingController, updates, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; recordingController: RecordingController; updates?: PluginUpdateController; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) {
+ const [tool, setTool] = useState("logs");
const [savedVisible, setSavedVisible] = useState(false);
const [layout, setLayout] = useState(automaticLayout ?? "none");
const manualLayout = useRef(false);
@@ -40,6 +46,7 @@ export function Workspace({ logs, performance, recordingController, updates, aut
onLayout("none");
logs.hide();
performance.hide();
+ storage.hide();
}, [recordingState.recording?.id]);
const performanceState = useSyncExternalStore(performance.subscribe, performance.getSnapshot);
const wide = useMediaQuery("(min-width: 900px)");
@@ -47,26 +54,30 @@ export function Workspace({ logs, performance, recordingController, updates, aut
const split = wide && fullscreen;
const collapsedToolSize = fullscreen ? (split ? 48 : 44) : 0;
const logState = useSyncExternalStore(logs.subscribe, logs.getSnapshot);
- const open = tool === "logs" ? logState.open : savedVisible || performanceState.open;
+ const storageState = useSyncExternalStore(storage.subscribe, storage.getSnapshot);
+ const open = tool === "logs" ? logState.open : tool === "storage" ? storageState.open : savedVisible || performanceState.open;
useEffect(() => { setUiSurface(open ? savedVisible ? "recording" : tool : "simulator"); }, [tool, open, savedVisible]);
useEffect(() => {
setUiTelemetryContext({ logs_open: logState.open, logs_paused: logState.paused, performance_running: performanceState.monitoring || performanceState.fpsMonitoring });
}, [logState.open, logState.paused, performanceState.monitoring, performanceState.fpsMonitoring]);
- const isOpen = () => tool === "logs" ? logs.getSnapshot().open : savedVisible || performance.getSnapshot().open;
+ const isOpen = () => tool === "logs" ? logs.getSnapshot().open : tool === "storage" ? storage.getSnapshot().open : savedVisible || performance.getSnapshot().open;
const closeTools = () => {
setSavedVisible(false);
if (logs.getSnapshot().open) logs.toggle();
performance.hide();
+ storage.hide();
void performance.disconnect();
};
- const showTool = (next: "logs" | "performance") => {
+ const showTool = (next: Tool) => {
const startedAt = globalThis.performance.now();
if (next === tool && isOpen()) { closeTools(); return; }
setUiSurface(next);
setTool(next);
setSavedVisible(false);
+ if (next !== "storage") storage.hide();
if (next === "logs") { performance.hide(); logs.show(); }
- else { logs.hide(); performance.show(); }
+ else if (next === "performance") { logs.hide(); performance.show(); }
+ else { logs.hide(); performance.hide(); storage.show(); }
markUiSurfaceReady(startedAt);
};
const logsRef = usePanelRef();
@@ -82,21 +93,23 @@ export function Workspace({ logs, performance, recordingController, updates, aut
if (!previous) return;
if (size.inPixels <= collapsedToolSize + 1 && isOpen()) closeTools();
else if (size.inPixels > 80 && isOpen() === false) showTool(tool);
- }}>{tool === "logs" && }{tool === "performance" && open && (savedVisible ? { setSavedVisible(false); performance.show(); }}>Live performance
: )};
+ }}>{tool === "logs" && }{tool === "storage" && }{tool === "performance" && open && (savedVisible ? { setSavedVisible(false); performance.show(); }}>Live performance
: )};
// The sidebar keeps these controls under its simulators. Fullscreen, or no visible
// platform, moves them above the tools.
const bottomToolbar = !fullscreen && layout !== "none";
const menuSide = bottomToolbar ? "top" : "bottom";
+ const ToolIcon = toolIcons[tool];
const toolbar =
- {
+ {
if (value === "none") closeTools();
- else if (value === "logs" || value === "performance") showTool(value);
+ else if (value === "logs" || value === "performance" || value === "storage") showTool(value);
}}>
- {open ? tool === "logs" ? : : }{open ? tool === "logs" ? "Logs" : "Performance" : "Tools"}
+ {open ? : }{open ? toolLabels[tool] : "Tools"}
Hide tools
Logs
Performance
+ Storage
{
diff --git a/src/ui/storage-panel.ts b/src/ui/storage-panel.ts
new file mode 100644
index 00000000..45a36296
--- /dev/null
+++ b/src/ui/storage-panel.ts
@@ -0,0 +1,204 @@
+import type { App } from "@modelcontextprotocol/ext-apps";
+import { installedAppsSchema } from "../shared/app-control.ts";
+import type { InstalledApp } from "../shared/app-control.ts";
+import { storageClearSchema, storageLimits, storageSnapshotSchema } from "../shared/app-storage.ts";
+import type { StorageSnapshot } from "../shared/app-storage.ts";
+import type { SimulatorDevice } from "../shared/protocol.ts";
+import { errorMessage } from "../shared/protocol.ts";
+import type { DeviceAppsStore } from "./device-apps.ts";
+import { measureUiOperation, recordUiTiming, setUiGauge } from "./telemetry.ts";
+
+export type StorageChange = "new" | "changed";
+export type StorageAction = "apps" | "read" | "clear" | "reset" | "launch";
+export type StorageState = {
+ open: boolean;
+ available: boolean;
+ platform: "ios" | "android";
+ deviceId: string;
+ deviceLabel: string;
+ // Why the selected device cannot be read, such as a physical iPhone or a stopped simulator.
+ unsupported: string;
+ apps: InstalledApp[];
+ bundleId: string;
+ snapshot?: StorageSnapshot;
+ // Keyed by store ID, then entry key, against the first read of this app since it was picked.
+ changes: ReadonlyMap>;
+ busy: StorageAction | null;
+ error: string;
+ status: string;
+};
+
+// The panel shows long values in full up to this length; the tool truncates beyond it.
+const panelValueLength = 4000;
+
+export function diffSnapshots(baseline: StorageSnapshot | undefined, next: StorageSnapshot) {
+ const changes = new Map>();
+ if (baseline === undefined) return changes;
+ const before = new Map(baseline.stores.map(store => [store.id, new Map(store.entries.map(entry => [entry.key, `${entry.type}\u0000${entry.value}`]))]));
+ for (const store of next.stores) {
+ const previous = before.get(store.id);
+ const marks = new Map();
+ for (const entry of store.entries) {
+ const value = previous?.get(entry.key);
+ if (value === undefined) marks.set(entry.key, "new");
+ else if (value !== `${entry.type}\u0000${entry.value}`) marks.set(entry.key, "changed");
+ }
+ if (marks.size > 0) changes.set(store.id, marks);
+ }
+ return changes;
+}
+
+export class StoragePanel {
+ private readonly app: App;
+ private readonly deviceApps: DeviceAppsStore;
+ private readonly unsubscribe: () => void;
+ private listeners = new Set<() => void>();
+ private revision = 0;
+ private baseline?: StorageSnapshot;
+ private appsFor = "";
+ private selections = new Map();
+ private state: StorageState = { open: false, available: false, platform: "ios", deviceId: "", deviceLabel: "", unsupported: "", apps: [], bundleId: "", changes: new Map(), busy: null, error: "", status: "" };
+
+ constructor(app: App, deviceApps: DeviceAppsStore) {
+ this.app = app;
+ this.deviceApps = deviceApps;
+ this.unsubscribe = deviceApps.subscribe(() => this.selectDevice(deviceApps.getSnapshot().device));
+ this.selectDevice(deviceApps.getSnapshot().device);
+ }
+
+ subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
+ getSnapshot = () => this.state;
+ private update(patch: Partial) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); }
+
+ // Results apply only while the device and app that started the work are still selected.
+ private current() {
+ const revision = this.revision;
+ return () => revision === this.revision;
+ }
+
+ setAvailable(available: boolean) {
+ this.update({ available });
+ if (available && this.state.open) void this.loadApps();
+ }
+
+ private selectDevice(device?: SimulatorDevice) {
+ const platform = device?.platform ?? "ios";
+ const deviceId = device?.udid ?? "";
+ const running = device !== undefined && (device.state === "Booted" || device.state === "connected");
+ const unsupported = device === undefined ? "Choose a simulator or Android device."
+ : platform === "ios" && device.kind === "physical" ? "Reading app data on a physical iPhone isn't supported yet. Use an iOS simulator."
+ : running ? "" : `${device.name} isn't running.`;
+ const changed = deviceId !== this.state.deviceId || platform !== this.state.platform;
+ if (!changed) {
+ if (unsupported !== this.state.unsupported || device?.name !== this.state.deviceLabel) this.update({ unsupported, deviceLabel: device?.name ?? "" });
+ return;
+ }
+ this.revision++;
+ this.baseline = undefined;
+ this.appsFor = "";
+ this.update({ platform, deviceId, deviceLabel: device?.name ?? "", unsupported, apps: [], bundleId: this.selections.get(`${platform}:${deviceId}`) ?? "", snapshot: undefined, changes: new Map(), busy: null, error: "", status: "" });
+ if (this.state.open) void this.loadApps();
+ }
+
+ show() {
+ this.update({ open: true });
+ void this.loadApps();
+ }
+
+ hide() { this.update({ open: false }); }
+
+ private async call(name: string, args: Record) {
+ const result = await this.app.callServerTool({ name, arguments: { platform: this.state.platform, deviceId: this.state.deviceId, ...args } }, { timeout: 60000 });
+ if (result.isError) throw new Error(result.content.filter(item => item.type === "text").map(item => item.text).join("\n") || "The plugin could not read app storage.");
+ return result.structuredContent;
+ }
+
+ private ready() { return this.state.available && this.state.unsupported === "" && this.state.deviceId !== ""; }
+
+ async loadApps(force = false) {
+ if (!this.ready() || this.state.busy === "apps") return;
+ const key = `${this.state.platform}:${this.state.deviceId}`;
+ if (!force && this.appsFor === key) return;
+ const current = this.current();
+ this.update({ busy: "apps", error: "" });
+ try {
+ const data = installedAppsSchema.parse(await measureUiOperation("ui.storage.list_apps", "ui.storage.failure", () => this.call("mobile_list_apps", {})));
+ if (!current()) return;
+ this.appsFor = key;
+ const installed = new Set(data.apps.map(app => app.bundleId));
+ const bundleId = [this.state.bundleId, data.runningBundleIds[0]].find(id => id && installed.has(id)) ?? "";
+ this.update({ apps: data.apps, busy: null });
+ if (bundleId !== this.state.bundleId || this.state.snapshot === undefined) this.selectApp(bundleId);
+ } catch (error) {
+ if (current()) this.update({ busy: null, error: errorMessage(error) });
+ }
+ }
+
+ selectApp(bundleId: string) {
+ if (bundleId !== this.state.bundleId) {
+ this.revision++;
+ this.baseline = undefined;
+ this.selections.set(`${this.state.platform}:${this.state.deviceId}`, bundleId);
+ this.update({ bundleId, snapshot: undefined, changes: new Map(), busy: null, error: "", status: "" });
+ }
+ if (bundleId) void this.refresh();
+ }
+
+ async refresh() {
+ const bundleId = this.state.bundleId;
+ if (!this.ready() || !bundleId || (this.state.busy && this.state.busy !== "apps")) return;
+ const current = this.current();
+ this.update({ busy: "read", error: "" });
+ try {
+ const data = await measureUiOperation("ui.storage.read", "ui.storage.failure", () => this.call("mobile_app_storage", { bundleId, entries: storageLimits.maxEntries, valueLength: panelValueLength }));
+ if (!current()) return;
+ const startedAt = performance.now();
+ const snapshot = storageSnapshotSchema.parse(data);
+ const changes = diffSnapshots(this.baseline, snapshot);
+ this.baseline ??= snapshot;
+ recordUiTiming("ui.storage.process", performance.now() - startedAt);
+ setUiGauge("ui.storage.stores", snapshot.stores.length);
+ setUiGauge("ui.storage.entries", snapshot.stores.reduce((total, store) => total + store.entries.length, 0));
+ this.update({ snapshot, changes, busy: null });
+ } catch (error) {
+ if (current()) this.update({ busy: null, error: errorMessage(error) });
+ }
+ }
+
+ // Clearing stops the app; the panel then reads again and starts change tracking over.
+ async clear(storeId?: string) {
+ const bundleId = this.state.bundleId;
+ if (!this.ready() || !bundleId || this.state.busy) return;
+ const action = storeId ? "clear" : "reset";
+ const current = this.current();
+ this.update({ busy: action, error: "", status: "" });
+ try {
+ const result = storageClearSchema.parse(await measureUiOperation(`ui.storage.${action}`, "ui.storage.failure", () => this.call("mobile_clear_app_storage", storeId ? { bundleId, store: storeId } : { bundleId })));
+ if (!current()) return;
+ this.baseline = undefined;
+ this.update({ busy: null, status: result.cleared === "all" ? "Cleared all app data. The app was stopped." : `Cleared ${result.cleared}. The app was stopped.` });
+ await this.refresh();
+ } catch (error) {
+ if (current()) this.update({ busy: null, error: errorMessage(error) });
+ }
+ }
+
+ async launch() {
+ const bundleId = this.state.bundleId;
+ if (!this.ready() || !bundleId || this.state.busy) return;
+ const current = this.current();
+ this.update({ busy: "launch", error: "", status: "" });
+ try {
+ await measureUiOperation("ui.storage.launch", "ui.storage.failure", () => this.call("mobile_launch_app", { bundleId }));
+ if (current()) this.update({ busy: null, status: "Launched the app." });
+ } catch (error) {
+ if (current()) this.update({ busy: null, error: errorMessage(error) });
+ }
+ }
+
+ dispose() {
+ this.revision++;
+ this.unsubscribe();
+ this.listeners.clear();
+ }
+}
diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx
index e06dede0..92905593 100644
--- a/src/ui/workspace-app.tsx
+++ b/src/ui/workspace-app.tsx
@@ -14,6 +14,7 @@ import { PanelContext } from "./model-context.ts";
import { LogsPanel } from "./logs-panel.ts";
import { DeviceAppsStore } from "./device-apps.ts";
import { PerformancePanel } from "./performance-panel.ts";
+import { StoragePanel } from "./storage-panel.ts";
import { createSimulatorPanel } from "./simulator-panel.ts";
import { startLiveReload } from "./live-reload.ts";
import { ErrorBoundary, captureUiError, startUiTelemetry, stopUiTelemetry, setUiTelemetryContext } from "./telemetry.ts";
@@ -28,12 +29,13 @@ const panelContext = new PanelContext(app, extensions);
const deviceApps = new DeviceAppsStore(app, document);
const performancePanel = new PerformancePanel(app, deviceApps);
const logsPanel = new LogsPanel(app, panelContext, deviceApps);
+const storagePanel = new StoragePanel(app, deviceApps);
const recordingController = new RecordingController(app, extensions);
const updates = new PluginUpdateController(app);
const reactRoot = createRoot(document.getElementById("root")!);
function renderWorkspace(automaticLayout?: DeviceLayout) {
const workspace = Mobile Dev could not render. Reopen the panel to try again.
}>
-
+
;
reactRoot.render(workspace);
}
@@ -94,6 +96,7 @@ function disposeUI() {
document.removeEventListener("visibilitychange", resumeContext);
workspaceDevices.dispose();
deviceApps.dispose();
+ storagePanel.dispose();
recordingController.dispose();
updates.dispose();
await Promise.allSettled([...panels.map(panel => panel.dispose()), logsPanel.dispose(), performancePanel.dispose()]);
@@ -164,6 +167,7 @@ void (async () => {
const available = !!capabilities?.serverTools && !!capabilities?.serverResources;
logsPanel.setAvailable(available);
performancePanel.setAvailable(available);
+ storagePanel.setAvailable(available);
deviceApps.setAvailable(available);
updates.setAvailable(available);
for (const panel of panels) if (!available) panel.notice("This host cannot call the plugin's simulator tools.");
diff --git a/tests/app-storage.test.ts b/tests/app-storage.test.ts
new file mode 100644
index 00000000..bc83d0e6
--- /dev/null
+++ b/tests/app-storage.test.ts
@@ -0,0 +1,241 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { decodeMmkv, guessMmkvValue } from "../src/server/app-storage/mmkv.ts";
+import { classifyFiles, parseDataStorePreferences, parsePlistXml, parseSharedPreferences, plistEntries } from "../src/server/app-storage/formats.ts";
+import { clearStore, readStorage } from "../src/server/app-storage/reader.ts";
+import type { AppContainer } from "../src/server/app-storage/container.ts";
+import { runAsError } from "../src/server/app-storage/container.ts";
+import { registerAppStorageTools } from "../src/server/app-storage/tools.ts";
+import type { AppDriver } from "../src/server/app-control/driver.ts";
+import type { Commands } from "../src/server/app-control/commands.ts";
+import { storageSnapshotSchema } from "../src/shared/app-storage.ts";
+import { expectedOutcome } from "../src/shared/error-reporting.ts";
+
+const fixtures = join(import.meta.dirname, "fixtures/app-storage/mmkv");
+const mmkv = async (id: string) => [await readFile(join(fixtures, id)), await readFile(join(fixtures, `${id}.crc`))] as const;
+
+test("MMKV files written by MMKV 2.4 decode with the last write winning and removals applied", async () => {
+ const decoded = decodeMmkv(...await mmkv("fixture"));
+ assert.equal(decoded.status, "ok");
+ if (decoded.status !== "ok") return;
+ assert.equal(decoded.crcValid, true);
+ const values = Object.fromEntries(decoded.records.map(record => [record.key, guessMmkvValue(record.value)]));
+ assert.deepEqual(values.greeting, { type: "string", value: "hello" });
+ assert.deepEqual(values.flag, { type: "boolean", value: "true" });
+ assert.deepEqual(values.off, { type: "boolean", value: "false" });
+ assert.deepEqual(values.ratio, { type: "number", value: "3.5" });
+ assert.deepEqual(values.count, { type: "integer", value: "42" });
+ assert.deepEqual(values.negative, { type: "integer", value: "-7" });
+ assert.deepEqual(values.json, { type: "json", value: "{\"a\":[1,2]}" });
+ assert.deepEqual(values.bytes, { type: "data", value: "ff001080" });
+ assert.deepEqual(values.replaced, { type: "string", value: "new" });
+ // An empty string and false share one byte on disk.
+ assert.deepEqual(values.empty, { type: "boolean", value: "false" });
+ assert.equal("removed" in values, false);
+ assert.deepEqual(decoded.records.map(record => record.key), ["greeting", "flag", "off", "ratio", "count", "negative", "json", "empty", "bytes", "replaced"]);
+});
+
+test("MMKV encryption, key expiry, and corruption are reported instead of misread", async () => {
+ assert.deepEqual(decodeMmkv(...await mmkv("locked")), { status: "encrypted" });
+ const expiring = decodeMmkv(...await mmkv("expiring"));
+ assert.equal(expiring.status, "ok");
+ if (expiring.status === "ok") assert.deepEqual(expiring.records.map(record => [record.key, guessMmkvValue(record.value).value]), [["session", "soon"], ["seen", "true"]]);
+ const [data, meta] = await mmkv("fixture");
+ const changed = Buffer.from(data);
+ changed[20] ^= 0xff;
+ const tampered = decodeMmkv(changed, meta);
+ assert.equal(tampered.status === "ok" ? tampered.crcValid : tampered.status, false);
+ assert.equal(decodeMmkv(data.subarray(0, 40), meta).status, "unreadable");
+ // Without a meta file, older files keep the size in the data header.
+ const legacy = Buffer.concat([Buffer.from([7, 0, 0, 0]), Buffer.from([0x00, 0x01, 0x61, 0x02, 0x01, 0x78, 0x00])]);
+ const decoded = decodeMmkv(legacy);
+ assert.deepEqual(decoded.status === "ok" ? decoded.records.map(record => record.key) : decoded, ["a"]);
+});
+
+test("UserDefaults plists keep their types, including dates, data, and nested values", () => {
+ const xml = `
+
+
+
+ onboarding.done
+ launches 12
+ scale 1.5
+ name Tom & Jerry
+ user {"id":1}
+ seen 2026-10-19T12:00:00Z
+ blob AAEC
+ empty
+ list a 2
+ nested inner
+
+ `;
+ const entries = Object.fromEntries(plistEntries(parsePlistXml(xml)).map(entry => [entry.key, [entry.type, entry.value]]));
+ assert.deepEqual(entries, {
+ "onboarding.done": ["boolean", "true"], launches: ["integer", "12"], scale: ["number", "1.5"], name: ["string", "Tom & Jerry"],
+ user: ["json", "{\"id\":1}"], seen: ["date", "2026-10-19T12:00:00Z"], blob: ["data", "000102"], empty: ["string", ""],
+ list: ["array", "[\"a\",2,{}]"], nested: ["object", "{\"inner\":false}"],
+ });
+ assert.deepEqual(parsePlistXml("\n \n "), {});
+});
+
+test("SharedPreferences XML and DataStore protobuf decode every value type", () => {
+ const xml = `
+
+ abc<1>
+
+
+
+
+ a b
+
+ `;
+ assert.deepEqual(parseSharedPreferences(xml).map(entry => [entry.key, entry.type, entry.value]), [
+ ["token", "string", "abc<1>"], ["onboarded", "boolean", "true"], ["count", "integer", "3"], ["since", "integer", "1760870400000"],
+ ["ratio", "number", "0.5"], ["tags", "array", "[\"a\",\"b\"]"], ["empty", "string", ""],
+ ]);
+ const field = (number: number, payload: Buffer) => Buffer.concat([Buffer.from([(number << 3) | 2, payload.length]), payload]);
+ const pair = (key: string, value: Buffer) => field(1, Buffer.concat([field(1, Buffer.from(key)), field(2, value)]));
+ const pb = Buffer.concat([
+ pair("dark", Buffer.from([0x08, 0x01])),
+ pair("name", field(5, Buffer.from("Ada"))),
+ pair("visits", Buffer.from([0x18, 0x05])),
+ pair("tags", field(6, field(1, Buffer.from("x")))),
+ ]);
+ assert.deepEqual(parseDataStorePreferences(pb).map(entry => [entry.key, entry.type, entry.value]), [
+ ["dark", "boolean", "true"], ["name", "string", "Ada"], ["visits", "integer", "5"], ["tags", "array", "[\"x\"]"],
+ ]);
+});
+
+test("files are recognized by location and companions, wherever the app keeps them", () => {
+ const ios = classifyFiles([
+ "Documents/mmkv/mmkv.default", "Documents/mmkv/mmkv.default.crc", "Library/custom/user", "Library/custom/user.crc",
+ "Library/Preferences/com.example.plist", "Library/Application Support/com.example/RCTAsyncLocalStorage_V1/manifest.json",
+ "Library/Application Support/com.example/RCTAsyncLocalStorage_V1/0cc175b9c0f1b6a831c399e269772661",
+ "Library/Application Support/async-storage/databases/cache/cache.sqlite", "Library/Application Support/async-storage/databases/cache/cache.sqlite-wal",
+ "Library/HTTPStorages/com.example/httpstorages.sqlite", "Documents/default.realm", "Documents/default.realm.lock", "Documents/photo.jpg",
+ ], "ios");
+ assert.deepEqual(ios.map(store => [store.kind, store.name, store.files.length]), [
+ ["mmkv", "mmkv.default", 2], ["mmkv", "user", 2], ["user-defaults", "com.example", 1], ["async-storage", "default", 2],
+ ["sqlite", "cache.sqlite", 2], ["realm", "default", 2],
+ ]);
+ const android = classifyFiles(["shared_prefs/settings.xml", "shared_prefs/settings.xml.bak", "databases/RKStorage", "databases/RKStorage-journal", "files/datastore/settings.preferences_pb", "files/mmkv/mmkv.default", "files/mmkv/mmkv.default.crc", "app_webview/Default/Cookies"], "android");
+ assert.deepEqual(android.map(store => [store.kind, store.name, store.files]), [
+ ["shared-preferences", "settings", ["shared_prefs/settings.xml", "shared_prefs/settings.xml.bak"]],
+ ["sqlite", "RKStorage", ["databases/RKStorage", "databases/RKStorage-journal"]],
+ ["datastore", "settings", ["files/datastore/settings.preferences_pb"]],
+ ["mmkv", "mmkv.default", ["files/mmkv/mmkv.default", "files/mmkv/mmkv.default.crc"]],
+ ]);
+});
+
+class MemoryContainer implements AppContainer {
+ readonly platform = "ios";
+ readonly files: Map;
+ removed: string[] = [];
+ reset = async () => { this.files.clear(); };
+ constructor(files: Record) { this.files = new Map(Object.entries(files).map(([path, value]) => [path, Buffer.from(value)])); }
+ async list() { return [...this.files].map(([path, value]) => ({ path, bytes: value.length })); }
+ async read(path: string) {
+ const value = this.files.get(path);
+ if (!value) throw new Error("missing");
+ return value;
+ }
+ async copy() { throw new Error("not used"); }
+ async remove(paths: string[]) { this.removed.push(...paths); for (const path of paths) this.files.delete(path); }
+}
+
+test("snapshots read every store, cap values, and only clear files of a store that exists", async () => {
+ const [data, meta] = await mmkv("fixture");
+ const [locked, lockedMeta] = await mmkv("locked");
+ const long = "x".repeat(2000);
+ const manifest = JSON.stringify({ short: "1", long: null, missing: null });
+ const container = new MemoryContainer({
+ "Documents/mmkv/fixture": data, "Documents/mmkv/fixture.crc": meta, "Documents/mmkv/locked": locked, "Documents/mmkv/locked.crc": lockedMeta,
+ "Library/Application Support/com.example/RCTAsyncLocalStorage_V1/manifest.json": manifest,
+ [`Library/Application Support/com.example/RCTAsyncLocalStorage_V1/${createHash("md5").update("long").digest("hex")}`]: long,
+ "Library/Preferences/com.example.plist": "a ",
+ "Documents/big.bin": Buffer.alloc(5000),
+ });
+ const snapshot = storageSnapshotSchema.parse(await readStorage(container, { bundleId: "com.example", entries: 3, valueLength: 100, run: async () => "" }));
+ const byName = Object.fromEntries(snapshot.stores.map(store => [store.name, store]));
+ assert.equal(byName.fixture.entryCount, 10);
+ assert.equal(byName.fixture.entries.length, 3);
+ assert.equal(byName.fixture.entries[0].guessed, true);
+ assert.equal(byName.locked.status, "encrypted");
+ const asyncStore = byName.default;
+ assert.equal(asyncStore.kind, "async-storage");
+ assert.deepEqual(asyncStore.entries.map(entry => [entry.key, entry.type, entry.value.length, entry.truncated ?? false]), [["short", "string", 1, false], ["long", "string", 100, true], ["missing", "null", 4, false]]);
+ assert.equal(byName["com.example"].entries[0].value, "true");
+ assert.equal(snapshot.files[0].path, "Documents/big.bin");
+ assert.equal(snapshot.files.find(file => file.path === "Documents/mmkv/fixture")?.store, "mmkv:Documents/mmkv/fixture");
+ assert.equal(snapshot.fileCount, 8);
+
+ const cleared = await clearStore(container, "mmkv:Documents/mmkv/fixture");
+ assert.equal(cleared.name, "fixture");
+ assert.deepEqual(container.removed, ["Documents/mmkv/fixture", "Documents/mmkv/fixture.crc"]);
+ await assert.rejects(clearStore(container, "mmkv:../../etc/passwd"), error => expectedOutcome(error) === "invalid_input");
+});
+
+test("Android run-as failures explain release builds and missing apps", () => {
+ assert.equal(expectedOutcome(runAsError({ stderr: "run-as: package not debuggable: com.example" }, "Listing app data", "com.example")), "unsupported_platform");
+ assert.match(runAsError({ stderr: "run-as: package not debuggable: com.example" }, "Listing app data", "com.example").message, /release build/);
+ assert.equal(expectedOutcome(runAsError({ stderr: "run-as: unknown package: com.example" }, "Listing app data", "com.example")), "invalid_input");
+});
+
+test("storage tools stop the app before clearing and reset everything when no store is named", async () => {
+ const container = new MemoryContainer({ "Library/Preferences/com.example.plist": " ", "Documents/a.txt": "a" });
+ const calls: string[] = [];
+ const driver = { stop: async (bundleId: string) => { calls.push(`stop ${bundleId}`); } } as unknown as AppDriver;
+ const commands = { run: async () => "" } as unknown as Commands;
+ const server = new McpServer({ name: "test", version: "1" });
+ registerAppStorageTools(server, commands, () => driver, async () => container);
+ const client = new Client({ name: "client", version: "1" });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
+ const target = { platform: "ios", deviceId: "B5C969F6-58A4-4C31-AB12-FB9E56D681DE", bundleId: "com.example" };
+
+ const read = await client.callTool({ name: "mobile_app_storage", arguments: { ...target, store: "user-defaults:Library/Preferences/com.example.plist" } });
+ assert.deepEqual(storageSnapshotSchema.parse(read.structuredContent).stores.map(store => store.name), ["com.example"]);
+ const missing = await client.callTool({ name: "mobile_clear_app_storage", arguments: { ...target, store: "mmkv:nothing" } });
+ assert.equal(missing.isError, true);
+ const reset = await client.callTool({ name: "mobile_clear_app_storage", arguments: target });
+ assert.deepEqual(reset.structuredContent, { cleared: "all", stopped: true });
+ assert.deepEqual(calls, ["stop com.example", "stop com.example"]);
+ assert.equal(container.files.size, 0);
+ await client.close();
+});
+
+test("change markers compare each read with the first read of the app", async () => {
+ const { diffSnapshots } = await import("../src/ui/storage-panel.ts");
+ const snapshot = (entries: [string, string][]) => storageSnapshotSchema.parse({ platform: "ios", bundleId: "com.example", readAt: 0, files: [], fileCount: 0, totalBytes: 0,
+ stores: [{ id: "mmkv:a", kind: "mmkv", name: "a", path: "a", status: "ok", bytes: 0, entryCount: entries.length, entries: entries.map(([key, value]) => ({ key, type: "string", value, bytes: value.length })) }] });
+ const first = snapshot([["kept", "1"], ["edited", "1"]]);
+ assert.equal(diffSnapshots(undefined, first).size, 0);
+ const marks = diffSnapshots(first, snapshot([["kept", "1"], ["edited", "2"], ["added", "x"]]));
+ assert.deepEqual([...marks.get("mmkv:a")!], [["edited", "changed"], ["added", "new"]]);
+});
+
+test("simulator resets empty the container but keep its layout, metadata, and cfprefsd in sync", async () => {
+ const { mkdtemp, mkdir, writeFile, readdir, rm } = await import("node:fs/promises");
+ const { tmpdir } = await import("node:os");
+ const { SimulatorContainer } = await import("../src/server/app-storage/container.ts");
+ const root = await mkdtemp(join(tmpdir(), "mobile-dev-storage-"));
+ try {
+ for (const directory of ["Documents/mmkv", "Library/Preferences", "Library/Caches/images", "tmp", "SystemData"]) await mkdir(join(root, directory), { recursive: true });
+ for (const file of ["Documents/mmkv/mmkv.default", "Documents/mmkv/mmkv.default.crc", "Library/Preferences/com.example.plist", "Library/Caches/images/a.png", "tmp/x", ".com.apple.mobile_container_manager.metadata.plist", "SystemData/keep"]) await writeFile(join(root, file), "x");
+ const calls: string[][] = [];
+ const commands = { run: async (_file: string, args: string[]) => { calls.push(args); return ""; } } as unknown as Commands;
+ const container = new SimulatorContainer(root, "B5C969F6-58A4-4C31-AB12-FB9E56D681DE", commands);
+ assert.deepEqual((await container.list()).map(file => file.path).sort(), [".com.apple.mobile_container_manager.metadata.plist", "Documents/mmkv/mmkv.default", "Documents/mmkv/mmkv.default.crc", "Library/Caches/images/a.png", "Library/Preferences/com.example.plist", "SystemData/keep", "tmp/x"]);
+ await assert.rejects(container.read("../outside"), error => expectedOutcome(error) === "invalid_input");
+ await container.reset();
+ assert.deepEqual(calls, [["simctl", "spawn", "B5C969F6-58A4-4C31-AB12-FB9E56D681DE", "defaults", "delete", join(root, "Library/Preferences/com.example")]]);
+ assert.deepEqual((await container.list()).map(file => file.path).sort(), [".com.apple.mobile_container_manager.metadata.plist", "SystemData/keep"]);
+ assert.deepEqual((await readdir(join(root, "Library"))).sort(), ["Caches", "Preferences"]);
+ } finally { await rm(root, { recursive: true, force: true }); }
+});
diff --git a/tests/fixtures/app-storage/mmkv/expiring b/tests/fixtures/app-storage/mmkv/expiring
new file mode 100644
index 0000000000000000000000000000000000000000..cb43293f19bc6c08a57de085856a0789d1426190
GIT binary patch
literal 37
pcmZQzU|`tP|2dewIJLMqGe3`$r8qx74=BjMQkfBUA$b6ej~;
literal 0
HcmV?d00001
diff --git a/tests/fixtures/app-storage/mmkv/fixture b/tests/fixtures/app-storage/mmkv/fixture
new file mode 100644
index 0000000000000000000000000000000000000000..4a52dd78fd254e1706bb5e548765ca0ed9f1bc2e
GIT binary patch
literal 169
zcmZQzU|`tP|2dc=y(l%cBr`9ajWr`RCnujJEhjOZk&!t+Esc?ZwJ5P9GoJ&f6bN`6
zSd;Th^GX=CIPy}{ff8k@TtELq0V7LRaef{TcePTYl2x>!j!|qaYie#mNhQ#bq{@=i
vVpf*_3<3=tMX3cjiOH!cEX?^iDM-A$)N=Ns)ZF~C)D%{h^!&V37>@w}A*eUP
literal 0
HcmV?d00001
diff --git a/tests/fixtures/app-storage/mmkv/fixture.crc b/tests/fixtures/app-storage/mmkv/fixture.crc
new file mode 100644
index 0000000000000000000000000000000000000000..3cedc0388fa77aad7e595f7636c8038fb7c5e499
GIT binary patch
literal 112
gcmb2GVX9+apZr8DmnkZ#a$&xW?*6@BX9568;|PfW
literal 0
HcmV?d00001
diff --git a/tests/fixtures/app-storage/mmkv/locked.crc b/tests/fixtures/app-storage/mmkv/locked.crc
new file mode 100644
index 0000000000000000000000000000000000000000..1413c5659895341a2694713355e1748d95f505a7
GIT binary patch
literal 112
vcmeAvlgejdU|?VbV!;scQ=b-?AAcybK7sd{^hqJ07)Vay&8eF}iYfpAK#B|p
literal 0
HcmV?d00001
diff --git a/tests/fixtures/app-storage/mmkv/mmkv.default b/tests/fixtures/app-storage/mmkv/mmkv.default
new file mode 100644
index 0000000000000000000000000000000000000000..df72f8d265ba7fd3b71488cb9011165938472c28
GIT binary patch
literal 26
ecmZQzU|`tP|2dc=C$TgyIU}{011JClAM62S6bBjr
literal 0
HcmV?d00001
diff --git a/tests/fixtures/app-storage/mmkv/mmkv.default.crc b/tests/fixtures/app-storage/mmkv/mmkv.default.crc
new file mode 100644
index 0000000000000000000000000000000000000000..bcebeff713c6cfad8e28a121a46f0ce5517ef02c
GIT binary patch
literal 112
gcmY#nj5))?z`(!=#OOc_C;*ac*uHclx*WND09G0U_W%F@
literal 0
HcmV?d00001
diff --git a/tests/react-ui.test.ts b/tests/react-ui.test.ts
index a20c4d7c..9cd4bb99 100644
--- a/tests/react-ui.test.ts
+++ b/tests/react-ui.test.ts
@@ -8,6 +8,7 @@ import { pathToFileURL } from "node:url";
import { LogsPanel } from "../src/ui/logs-panel.ts";
import { createDeviceApps } from "./device-apps-fixtures.ts";
import { PerformancePanel } from "../src/ui/performance-panel.ts";
+import { StoragePanel } from "../src/ui/storage-panel.ts";
import { RecordingController } from "../src/ui/recording-controller.ts";
import { recordingFixture } from "./recording-fixtures.ts";
import type { App } from "@modelcontextprotocol/ext-apps";
@@ -20,7 +21,7 @@ import type { DeviceLayout } from "../src/ui/device-layout.ts";
test("React log controls filter virtual rows, attach full logs, and preserve simulator DOM", async t => {
const dom = new JSDOM('
', { pretendToBeVisual: true, url: "http://localhost" });
- async function selectTool(tool: "logs" | "performance" | "none") {
+ async function selectTool(tool: "logs" | "performance" | "storage" | "none") {
await act(async () => { (dom.window.document.getElementById("tool-select") as HTMLButtonElement).click(); });
await act(async () => { (dom.window.document.getElementById(`tool-${tool}`) as HTMLElement).click(); });
}
@@ -95,17 +96,33 @@ test("React log controls filter virtual rows, attach full logs, and preserve sim
};
const performance = new PerformancePanel(performanceApp as unknown as App, deviceApps);
+ const storageCalls: string[] = [];
+ const storageApp = {
+ async callServerTool({ name }: { name: string }) {
+ storageCalls.push(name);
+ if (name === "mobile_list_apps") return { content: [], structuredContent: { apps: [{ bundleId: "com.example.app", name: "Example" }], foregroundBundleId: "com.example.app", runningBundleIds: ["com.example.app"] } };
+ return { content: [], structuredContent: { platform: "ios", bundleId: "com.example.app", readAt: Date.now(), fileCount: 3, totalBytes: 300,
+ files: [{ path: "Documents/mmkv/mmkv.default", bytes: 200, store: "mmkv:Documents/mmkv/mmkv.default" }],
+ stores: [
+ { id: "mmkv:Documents/mmkv/mmkv.default", kind: "mmkv", name: "mmkv.default", path: "Documents/mmkv/mmkv.default", status: "ok", bytes: 200, entryCount: 2,
+ entries: [{ key: "onboarding.done", type: "boolean", value: "true", bytes: 1, guessed: true }, { key: "user", type: "json", value: "{\"id\":1}", bytes: 8, guessed: true }] },
+ { id: "mmkv:Documents/mmkv/secure", kind: "mmkv", name: "secure", path: "Documents/mmkv/secure", status: "encrypted", note: "Encrypted with a key only the app knows.", bytes: 100, entryCount: 0, entries: [] },
+ ] } };
+ },
+ };
+ const storage = new StoragePanel(storageApp as unknown as App, deviceApps);
+ storage.setAvailable(true);
const recordingController = new RecordingController(performanceApp as unknown as App);
deviceApps.selectDevice({ udid: UDID, name: "iPhone", state: "Booted", runtime: "iOS" });
performance.setAvailable(true);
deviceApps.setAvailable(true);
const root = createRoot(dom.window.document.getElementById("root")!);
- cleanupView = async () => { recordingController.dispose(); await act(async () => { root.unmount(); await panel.dispose(); deviceApps.dispose(); await performance.dispose(); }); };
+ cleanupView = async () => { recordingController.dispose(); await act(async () => { root.unmount(); await panel.dispose(); deviceApps.dispose(); storage.dispose(); await performance.dispose(); }); };
const layouts: string[] = [];
let commits = 0;
const onLayout = (layout: string) => { layouts.push(layout); };
const renderWorkspace = (automaticLayout?: DeviceLayout) => {
- const workspace = createElement(Workspace, { logs: panel, performance, recordingController, automaticLayout, onLayout });
+ const workspace = createElement(Workspace, { logs: panel, performance, storage, recordingController, automaticLayout, onLayout });
const profiled = createElement(Profiler, { id: "workspace", onRender() { commits++; } }, workspace);
root.render(profiled);
};
@@ -496,6 +513,18 @@ test("React log controls filter virtual rows, attach full logs, and preserve sim
await togglePlatform("android");
assert.deepEqual(layouts, ["both", "android", "none", "ios", "both"]);
assert.equal(dom.window.document.querySelector("canvas"), canvas);
+ await selectTool("storage");
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
+ assert.deepEqual(storageCalls, ["mobile_list_apps", "mobile_app_storage"]);
+ const storageDrawer = dom.window.document.getElementById("storage-drawer")!;
+ assert.deepEqual([...storageDrawer.querySelectorAll('nav[aria-label="Stores"] button')].map(button => button.textContent), ["mmkv.default2", "secure—", "Files300 B"]);
+ const valueRow = [...storageDrawer.querySelectorAll('[role="row"]')].find(row => row.textContent?.startsWith("user")) as HTMLElement;
+ await act(async () => { valueRow.click(); });
+ assert.equal(storageDrawer.querySelector('[aria-label="Value"] pre')?.textContent, '{\n "id": 1\n}');
+ assert.match(storageDrawer.textContent ?? "", /Type guessed from the stored bytes/);
+ assert.equal(dom.window.document.querySelector("canvas"), canvas, "The storage tool preserves the device DOM.");
+ await selectTool("logs");
+ assert.equal(dom.window.document.getElementById("storage-drawer"), null);
await selectTool("none");
assert.equal(dom.window.document.getElementById("logs-body")?.hidden, true);
assert.equal(dom.window.document.querySelectorAll("[data-log-row]").length, 0);
From d8f5d48291baa00d5573ade4ef3571925140db62 Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 13:40:00 +0200
Subject: [PATCH 02/63] chore(release): bump version to 0.1.172
---
.codex-plugin/plugin.json | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
src/shared/version.ts | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index 1dac5374..4bfb3598 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "mobile-dev",
- "version": "0.1.160",
+ "version": "0.1.172",
"description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, analyze CPU and React profiles with TraceSift, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.",
"author": {
"name": "Callstack"
diff --git a/package-lock.json b/package-lock.json
index bd6e876f..173b3fa6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.160",
+ "version": "0.1.172",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-mobile-dev-plugin",
- "version": "0.1.160",
+ "version": "0.1.172",
"dependencies": {
"@base-ui/react": "^1.8.0",
"@fontsource-variable/inter": "^5.3.0",
diff --git a/package.json b/package.json
index c906332e..3d36c334 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.160",
+ "version": "0.1.172",
"private": true,
"type": "module",
"engines": {
diff --git a/src/shared/version.ts b/src/shared/version.ts
index f6a34261..3bbbe5cc 100644
--- a/src/shared/version.ts
+++ b/src/shared/version.ts
@@ -1 +1 @@
-export const PLUGIN_VERSION = "0.1.160";
+export const PLUGIN_VERSION = "0.1.172";
From 0a809a24dbe6eec6b56b5b2baaaf27ff99ca3e52 Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 13:41:18 +0200
Subject: [PATCH 03/63] fix(storage): keep clearing app data in the panel
The model tool budget is full, so the destructive clear tool is panel-only
and the read tool's schema is shorter.
---
skills/mobile-dev/SKILL.md | 3 +--
src/server/app-storage/tools.ts | 11 ++++++-----
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/skills/mobile-dev/SKILL.md b/skills/mobile-dev/SKILL.md
index 286a61f1..829723e4 100644
--- a/skills/mobile-dev/SKILL.md
+++ b/skills/mobile-dev/SKILL.md
@@ -39,8 +39,7 @@ These tools work on iOS simulators and Android devices with `{ platform, deviceI
- `mobile_set_app_permission` allows, denies, or resets (`ask`) a permission so system prompts do not interrupt a flow; `permission: "all"` with `state: "ask"` restores first-launch prompts. iOS simulators cannot change camera or notification access, and changes can restart the app.
- `mobile_send_push` sends an APNs payload to an app on an iOS simulator. Android push needs Firebase Cloud Messaging and is not supported.
- `mobile_set_app_language` runs one app in another language and region, such as `de`, `pt-BR`, or `ar` for right-to-left layout, then relaunches it. `language: null` returns the app to the device language. The device language does not change. Android needs version 13 or later.
-- `mobile_app_storage` reads what an app saved, without changes to the app: MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite tables, and the largest files. Use it for bugs that may come from stale state, such as a cached login or an old onboarding flag. MMKV types are guessed from bytes; encrypted MMKV stays locked. Android needs a debuggable build.
-- `mobile_clear_app_storage` stops the app, then clears one store ID from `mobile_app_storage`, or all app data when `store` is omitted. It does not relaunch; call `mobile_launch_app` afterwards.
+- `mobile_app_storage` reads what an app saved, without changes to the app: MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite tables, and the largest files. Use it for bugs that may come from stale state, such as a cached login or an old onboarding flag. MMKV types are guessed from bytes; encrypted MMKV stays locked. Android needs a debuggable build. Clearing data is a confirmed action in the Storage panel.
The panel's settings button opens the same controls under its App tab. Its Device tab also switches to a clean status bar (9:41, full battery and signal) for screenshots and simulates LTE, 3G, Edge, or offline networks. Those device settings are panel-only, so ask the user to set them. Android throttling applies right away to standard app networking such as fetch and OkHttp, leaves local dev servers like Metro at full speed, and is unavailable while the device uses its own proxy. iOS throttling applies to URLSession and fetch requests in apps launched after it was turned on, so relaunch the app after the user enables it.
diff --git a/src/server/app-storage/tools.ts b/src/server/app-storage/tools.ts
index 3c44acba..de740830 100644
--- a/src/server/app-storage/tools.ts
+++ b/src/server/app-storage/tools.ts
@@ -42,12 +42,12 @@ export function registerAppStorageTools(server: McpServer, commands: Commands =
registerAppTool(server, "mobile_app_storage", {
title: "Read app storage",
- description: "Read an app's saved data without changes to the app: MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite databases, and the largest files. iOS simulators, and debuggable Android builds. MMKV value types are guessed from bytes.",
+ description: "Read an app's saved data (MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite, largest files) on iOS simulators and debuggable Android builds. MMKV types are guessed.",
inputSchema: {
...appTargetShape, bundleId,
- store: z.string().max(4096).optional().describe("Only return this store ID from an earlier result."),
- entries: z.number().int().min(1).max(storageLimits.maxEntries).optional().describe(`Entries per store, default ${storageLimits.entries}.`),
- valueLength: z.number().int().min(40).max(storageLimits.maxValueLength).optional().describe(`Characters per value, default ${storageLimits.valueLength}.`),
+ store: z.string().max(4096).optional().describe("A store ID to return alone."),
+ entries: z.number().int().min(1).max(storageLimits.maxEntries).optional(),
+ valueLength: z.number().int().min(40).max(storageLimits.maxValueLength).optional(),
},
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
_meta: { ui: { visibility: ["app", "model"] } },
@@ -66,7 +66,8 @@ export function registerAppStorageTools(server: McpServer, commands: Commands =
description: "Stop the app, then delete one store from mobile_app_storage, or all of the app's saved data when store is omitted. The app stays installed and is not relaunched.",
inputSchema: { ...appTargetShape, bundleId, store: z.string().max(4096).optional().describe("Store ID to clear. Omit to reset all app data.") },
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
- _meta: { ui: { visibility: ["app", "model"] } },
+ // Panel-only: the shared model tool budget is full, and clearing is a confirmed panel action.
+ _meta: { ui: { visibility: ["app"] } },
}, async input => {
const target = { platform: input.platform, deviceId: input.deviceId };
return run(input.store ? "clear" : "reset", target, async () => {
From df1f92bdf23d59baea636ade8597384ca0b4f0b5 Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 14:53:35 +0200
Subject: [PATCH 04/63] feat(storage): redesign panel with search and a changes
feed
Browse groups the app's own stores apart from SDK data, renders values by
type with inline JSON trees, and searches every store at once. The Changes
tab reads storage every 5 seconds while open and lists keys added, changed,
and removed, highlighting only the changed JSON fields.
---
README.md | 2 +-
docs/telemetry.md | 6 +-
src/shared/app-storage.ts | 2 +-
src/ui/components/storage-view.tsx | 491 ++++++++++++++++++-----------
src/ui/storage-labels.ts | 56 ++++
src/ui/storage-panel.ts | 109 +++++--
tests/app-storage.test.ts | 25 +-
tests/react-ui.test.ts | 19 +-
8 files changed, 477 insertions(+), 233 deletions(-)
create mode 100644 src/ui/storage-labels.ts
diff --git a/README.md b/README.md
index 85330914..206ca026 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ Track app CPU and memory, expand individual thread charts, and view device-wide

### App storage
-See what an app has saved without adding code to it: MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite databases, and the largest files in its data directory. Keys added or changed since the first read are marked, so you can watch stale flags and cached logins change. Clear one store or reset all app data. Works on iOS simulators and debuggable Android builds; MMKV value types are guessed from the stored bytes, and encrypted MMKV instances show as locked.
+See what an app has saved without adding code to it: MMKV, AsyncStorage, UserDefaults, SharedPreferences, DataStore, SQLite databases, and the largest files in its data directory. Search across every store at once, and open the Changes tab to watch keys being added, changed, and removed while you use the app, with only the changed part of JSON values highlighted. SDK data from Firebase, Google Analytics and similar libraries is grouped separately. Clear one store or reset all app data. Works on iOS simulators and debuggable Android builds; MMKV value types are guessed from the stored bytes, and encrypted MMKV instances show as locked.
## Requirements
diff --git a/docs/telemetry.md b/docs/telemetry.md
index 5ee0127b..20d0cf4f 100644
--- a/docs/telemetry.md
+++ b/docs/telemetry.md
@@ -220,9 +220,11 @@ app first. Unexpected failures report under `app_storage.` with a fix
message; a release Android build, a missing app, or a stopped device counts only
through `server.error.outcome`. The panel records `ui.storage.list_apps`, `.read`,
`.clear`, `.reset`, and `.launch` round trips, `ui.storage.process` for validating
-a snapshot and marking changes, gauges `ui.storage.stores` and `ui.storage.entries`,
+a snapshot and computing the Changes feed, gauges `ui.storage.stores` and `ui.storage.entries`,
and counts `ui.storage.failure`. Bundle IDs, store names, paths, keys, values, and
-file names are never sent.
+file names are never sent. Since 0.1.173 the Changes tab reads storage again every
+5 seconds while it is open and the page is visible; each read is one `ui.storage.read`
+sample, and the interval stops when the tab, panel, or page is hidden.
Since 0.1.152, `mobile_app_language` and `mobile_set_app_language` use the same
app control coverage under `app_control.language` and `app_control.set_language`.
diff --git a/src/shared/app-storage.ts b/src/shared/app-storage.ts
index 3f79ec78..74b14f0f 100644
--- a/src/shared/app-storage.ts
+++ b/src/shared/app-storage.ts
@@ -5,7 +5,7 @@ export const storeKinds = ["mmkv", "async-storage", "user-defaults", "shared-pre
export type StoreKind = typeof storeKinds[number];
export const storeKindLabels: Record = {
"mmkv": "MMKV", "async-storage": "AsyncStorage", "user-defaults": "UserDefaults", "shared-preferences": "SharedPreferences",
- "datastore": "DataStore", "sqlite": "Databases", "realm": "Realm",
+ "datastore": "DataStore", "sqlite": "SQLite", "realm": "Realm",
};
export const entryTypes = ["string", "json", "number", "integer", "boolean", "date", "data", "array", "object", "null", "table"] as const;
diff --git a/src/ui/components/storage-view.tsx b/src/ui/components/storage-view.tsx
index 6a8fc3df..c21f7776 100644
--- a/src/ui/components/storage-view.tsx
+++ b/src/ui/components/storage-view.tsx
@@ -1,226 +1,139 @@
-import { CopyIcon, DatabaseIcon, FolderIcon, LockIcon, PlayIcon, RefreshCwIcon, RotateCcwIcon, SearchIcon, TriangleAlertIcon } from "lucide-react";
-import { memo, useEffect, useMemo, useState, useSyncExternalStore } from "react";
+import { BinaryIcon, BracesIcon, CalendarIcon, ChevronDownIcon, ChevronRightIcon, CopyIcon, DatabaseIcon, FolderIcon, HashIcon, LayersIcon, LockIcon, MinusIcon, PlayIcon, PuzzleIcon, RefreshCwIcon, RotateCcwIcon, SearchIcon, ToggleLeftIcon, TriangleAlertIcon, TypeIcon } from "lucide-react";
+import { memo, useMemo, useState, useSyncExternalStore } from "react";
+import type { ReactNode } from "react";
import { storeKindLabels, storeKinds } from "../../shared/app-storage.ts";
-import type { StorageEntry, StorageStore } from "../../shared/app-storage.ts";
-import type { StoragePanel } from "../storage-panel.ts";
+import type { StorageEntry, StorageSnapshot, StorageStore, StoreKind } from "../../shared/app-storage.ts";
+import { changesPollMs } from "../storage-panel.ts";
+import type { StorageChangeEvent, StoragePanel, StorageTab } from "../storage-panel.ts";
+import { bytesAsText, changedFields, formatBytes, isSdkStore, parseJson, relativeTime, storeTitle } from "../storage-labels.ts";
import { Alert, AlertDescription } from "./ui/alert";
-import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "./ui/empty";
import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group";
import { NativeSelect, NativeSelectOption } from "./ui/native-select";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "./ui/popover";
-import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
-import { Toggle } from "./ui/toggle";
-import { useMediaQuery } from "./use-media-query";
+
+export { formatBytes } from "../storage-labels.ts";
const FILES = "files";
+const kindLook: Record = {
+ "user-defaults": { tone: "text-sky-600 bg-sky-500/10", icon: LayersIcon },
+ "shared-preferences": { tone: "text-sky-600 bg-sky-500/10", icon: LayersIcon },
+ "datastore": { tone: "text-sky-600 bg-sky-500/10", icon: LayersIcon },
+ "mmkv": { tone: "text-amber-600 bg-amber-500/10", icon: LayersIcon },
+ "async-storage": { tone: "text-violet-600 bg-violet-500/10", icon: BracesIcon },
+ "sqlite": { tone: "text-emerald-600 bg-emerald-500/10", icon: DatabaseIcon },
+ "realm": { tone: "text-rose-600 bg-rose-500/10", icon: DatabaseIcon },
+};
+const typeIcons: Record = {
+ string: TypeIcon, json: BracesIcon, object: BracesIcon, array: BracesIcon, number: HashIcon, integer: HashIcon,
+ boolean: ToggleLeftIcon, date: CalendarIcon, data: BinaryIcon, table: DatabaseIcon, null: MinusIcon,
+};
+const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
-export function formatBytes(bytes: number) {
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+function KindIcon({ kind, large }: { kind: StoreKind; large?: boolean }) {
+ const { tone, icon: Icon } = kindLook[kind];
+ return ;
}
-function formatAge(milliseconds: number) {
- const seconds = Math.max(0, Math.round(milliseconds / 1000));
- if (seconds < 60) return `${seconds}s ago`;
- const minutes = Math.round(seconds / 60);
- return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`;
+function Highlight({ text, query }: { text: string; query: string }) {
+ const index = query ? text.toLowerCase().indexOf(query) : -1;
+ if (index < 0) return <>{text}>;
+ return <>{text.slice(0, index)}{text.slice(index, index + query.length)} {text.slice(index + query.length)}>;
}
-export function displayValue(entry: StorageEntry) {
- if (entry.type === "data") return entry.value.replace(/(..)(?!$)/g, "$1 ");
- if ((entry.type === "json" || entry.type === "array" || entry.type === "object") && !entry.truncated) {
- try { return JSON.stringify(JSON.parse(entry.value), null, 2); } catch { return entry.value; }
- }
- return entry.value;
+function objectPreview(value: unknown) {
+ if (Array.isArray(value)) return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
+ const entries = Object.entries(value as Record);
+ const parts = entries.slice(0, 3).map(([key, child]) => `${key}: ${typeof child === "object" && child !== null ? Array.isArray(child) ? "[…]" : "{…}" : JSON.stringify(child)}`);
+ return `{ ${parts.join(", ")}${entries.length > 3 ? ", …" : ""} }`;
}
-const typeLabel = (entry: StorageEntry) => `${entry.type === "data" ? "bytes" : entry.type}${entry.guessed ? "?" : ""}`;
-
-function useNow(active: boolean) {
- const [now, setNow] = useState(() => Date.now());
- useEffect(() => {
- if (!active) return;
- setNow(Date.now());
- const timer = setInterval(() => { if (document.visibilityState !== "hidden") setNow(Date.now()); }, 5000);
- return () => clearInterval(timer);
- }, [active]);
- return now;
+// Inline, single-line rendering of a value by its type.
+function ValuePreview({ entry, query }: { entry: StorageEntry; query: string }) {
+ if (entry.type === "boolean") {
+ const on = entry.value === "true";
+ return {entry.value} ;
+ }
+ if (entry.type === "number" || entry.type === "integer") return ;
+ if (entry.type === "date") return {relativeTime(entry.value) && {relativeTime(entry.value)} }{entry.value} ;
+ if (entry.type === "null") return null ;
+ if (entry.type === "table") return ;
+ const parsed = parseJson(entry);
+ if (parsed !== undefined && !query) return {objectPreview(parsed)} ;
+ const text = entry.type === "data" ? bytesAsText(entry) : entry.value;
+ if (text === undefined) return {entry.value.slice(0, 64).replace(/(..)(?!$)/g, "$1 ")}{entry.value.length > 64 ? " …" : ""} ;
+ return ;
}
-export const StorageView = memo(function StorageView({ panel }: { panel: StoragePanel }) {
- const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot);
- const wide = useMediaQuery("(min-width: 600px)");
- const [query, setQuery] = useState("");
- const [changedOnly, setChangedOnly] = useState(false);
- const [selectedStore, setSelectedStore] = useState("");
- const [selectedKey, setSelectedKey] = useState();
- const [copied, setCopied] = useState("");
- const now = useNow(state.open && state.snapshot !== undefined);
- const snapshot = state.snapshot;
- const busy = state.busy !== null && state.busy !== "apps";
- const search = query.trim().toLowerCase();
-
- const matches = useMemo(() => {
- const result = new Map();
- for (const store of snapshot?.stores ?? []) {
- const changes = state.changes.get(store.id);
- result.set(store.id, store.entries.filter(entry => (!changedOnly || changes?.has(entry.key))
- && (!search || entry.key.toLowerCase().includes(search) || entry.value.toLowerCase().includes(search))));
- }
- return result;
- }, [snapshot, state.changes, search, changedOnly]);
-
- const filtering = search !== "" || changedOnly;
- const visibleStores = (snapshot?.stores ?? []).filter(store => !filtering || (matches.get(store.id)?.length ?? 0) > 0);
- const store = snapshot?.stores.find(item => item.id === selectedStore);
- const activeStore = selectedStore === FILES ? undefined : store ?? visibleStores[0];
- const showFiles = selectedStore === FILES || (activeStore === undefined && snapshot !== undefined);
- const entries = activeStore ? matches.get(activeStore.id) ?? [] : [];
- const entry = entries.find(item => item.key === selectedKey);
- const changes = activeStore ? state.changes.get(activeStore.id) : undefined;
- const changedCount = [...state.changes.values()].reduce((total, marks) => total + marks.size, 0);
-
- const copy = async (text: string) => {
- try { await navigator.clipboard.writeText(text); setCopied("Copied"); }
- catch { setCopied("Could not copy"); }
- };
-
- const tree =
- {storeKinds.map(kind => {
- const stores = visibleStores.filter(item => item.kind === kind);
- if (stores.length === 0) return null;
- return
-
{storeKindLabels[kind]}
- {stores.map(item =>
0} selected={activeStore?.id === item.id && !showFiles}
- onSelect={() => { setSelectedStore(item.id); setSelectedKey(undefined); }} />)}
- ;
- })}
- {snapshot && { setSelectedStore(FILES); setSelectedKey(undefined); }}>
- Files {formatBytes(snapshot.totalBytes)}
- }
- ;
-
- const detail = entry && activeStore &&
-
- {entry.key}
- {typeLabel(entry)} · {formatBytes(entry.bytes)}
- void copy(entry.value)}>
-
-
{displayValue(entry)}
-
- {entry.guessed && "Type guessed from the stored bytes; MMKV does not record types. "}
- {entry.truncated && "Value cut short; copy the full value with the mobile_app_storage tool. "}
- {activeStore.path}
-
-
;
-
- const storeView = activeStore &&
-
- {storeKindLabels[activeStore.kind]} › {activeStore.name}
- {activeStore.entryCount} {activeStore.kind === "sqlite" ? "table" : "key"}{activeStore.entryCount === 1 ? "" : "s"} · {formatBytes(activeStore.bytes)}
- void panel.clear(activeStore.id)} />
-
- {activeStore.note &&
{activeStore.status !== "ok" && (activeStore.status === "encrypted" ? : )}{activeStore.note}
}
-
- {entries.map(item => {
- const change = changes?.get(item.key);
- return
setSelectedKey(item.key === selectedKey ? undefined : item.key)}
- className="grid w-full grid-cols-[minmax(6rem,2fr)_5rem_minmax(0,3fr)] items-center gap-2 border-b px-2 py-1 text-left hover:bg-muted data-[selected=true]:bg-muted">
- {change && }{item.key}
- {typeLabel(item)}
- {item.type === "data" ? `${item.value.slice(0, 48).replace(/(..)(?!$)/g, "$1 ")}${item.value.length > 48 ? " …" : ""}` : item.value}
- ;
- })}
- {entries.length === 0 && activeStore.status === "ok" &&
{filtering ? "No matching keys." : "Empty."}
}
- {activeStore.entries.length < activeStore.entryCount && !filtering &&
Showing the first {activeStore.entries.length} of {activeStore.entryCount}.
}
-
- {detail}
-
;
-
- const filesView = snapshot &&
-
- Files
- {snapshot.fileCount} files · {formatBytes(snapshot.totalBytes)}
-
-
- {snapshot.files.filter(file => !search || file.path.toLowerCase().includes(search)).map(file => {
- const owner = file.store ? snapshot.stores.find(item => item.id === file.store) : undefined;
- return
- {file.path}
- {owner && {storeKindLabels[owner.kind]} }
- {formatBytes(file.bytes)}
-
;
- })}
- {snapshot.files.length < snapshot.fileCount &&
Showing the {snapshot.files.length} largest files.
}
-
-
;
-
- let body;
- if (state.unsupported || !state.available) {
- body = ;
- } else if (!state.bundleId) {
- body = ;
- } else if (!snapshot) {
- body = state.error ? null : ;
- } else if (snapshot.stores.length === 0 && !showFiles) {
- body = ;
- } else {
- body =
- {tree}
-
- {showFiles ? filesView : storeView}
- ;
+function JsonTree({ value }: { value: unknown }): ReactNode {
+ if (value === null || typeof value !== "object") {
+ const tone = typeof value === "boolean" ? value ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400" : typeof value === "number" ? "text-blue-600 dark:text-blue-400" : "text-amber-700 dark:text-amber-400";
+ return {JSON.stringify(value)} ;
}
+ const entries = Object.entries(value);
+ if (entries.length === 0) return {Array.isArray(value) ? "[]" : "{}"} ;
+ return {entries.map(([key, child]) =>
+
{key}:
+ {typeof child === "object" && child !== null ?
:
}
+
)}
;
+}
- return
-
-
- panel.selectApp(event.target.value)}>
- {state.busy === "apps" ? "Loading apps…" : "Choose an app"}
- {state.apps.map(app => {app.name === app.bundleId ? app.bundleId : `${app.name} · ${app.bundleId}`} )}
-
- setQuery(event.target.value)} />
- Changed{changedCount > 0 && {changedCount} }
- {snapshot && {state.busy === "read" ? "Reading…" : formatAge(now - snapshot.readAt)} }
- void panel.refresh()}>
- void panel.clear()} />
-
- {copied}
- {state.error && {state.error} }
- {state.status &&
-
{state.status}
-
void panel.launch()}> Launch app
+function EntryRow({ entry, store, query, open, onToggle, copy }: { entry: StorageEntry; store: StorageStore; query: string; open: boolean; onToggle: () => void; copy: (text: string) => void }) {
+ const Icon = typeIcons[entry.type];
+ const parsed = open ? parseJson(entry) : undefined;
+ const text = entry.type === "data" ? bytesAsText(entry) ?? entry.value.replace(/(..)(?!$)/g, "$1 ") : entry.value;
+ return
+
+ {open ? : }
+
+
+
+ {open &&
+ {parsed !== undefined ?
:
{text} }
+
+ {entry.type === "data" && bytesAsText(entry) ? "bytes, shown as text" : entry.type}{entry.guessed ? " (guessed; MMKV does not record types)" : ""} · {formatBytes(entry.bytes)}{entry.truncated ? " · cut short" : ""}
+ {store.path}
+ copy(entry.type === "data" ? bytesAsText(entry) ?? entry.value : entry.value)}> Copy
+
}
- {body}
- ;
-});
+
;
+}
-function StoreButton({ store, count, changed, selected, onSelect }: { store: StorageStore; count: number; changed: boolean; selected: boolean; onSelect: () => void }) {
+function StoreButton({ store, title, count, selected, onSelect }: { store: StorageStore; title: string; count: number; selected: boolean; onSelect: () => void }) {
return
- {store.status === "encrypted" ?
- : store.status === "unreadable" ? : null}
- {store.name}
- {changed && }
- {store.status === "ok" ? count : "—"}
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-muted data-[selected=true]:bg-muted">
+
+ {title} {storeKindLabels[store.kind]}
+ {store.status === "encrypted" ?
+ : store.status === "unreadable" ?
+ : {count} }
;
}
-function ConfirmButton({ label, title, description, disabled, onConfirm }: { label: string; title: string; description: string; disabled?: boolean; onConfirm: () => void }) {
+// Approximate split of the data directory by what owns each listed file.
+function containerBreakdown(snapshot: StorageSnapshot) {
+ const owners = new Map(snapshot.stores.map(store => [store.id, store.kind]));
+ const parts = { Databases: 0, Preferences: 0, Caches: 0 };
+ for (const file of snapshot.files) {
+ const kind = file.store ? owners.get(file.store) : undefined;
+ if (kind === "sqlite" || kind === "realm" || kind === "async-storage") parts.Databases += file.bytes;
+ else if (kind) parts.Preferences += file.bytes;
+ else if (/^(?:Library\/Caches|tmp|cache|code_cache)\//.test(file.path)) parts.Caches += file.bytes;
+ }
+ const other = Math.max(0, snapshot.totalBytes - parts.Databases - parts.Preferences - parts.Caches);
+ return [["Caches", parts.Caches, "bg-neutral-400"], ["Databases", parts.Databases, "bg-emerald-500"], ["Preferences", parts.Preferences, "bg-sky-500"], ["Other", other, "bg-muted-foreground/30"]] as const;
+}
+
+function ConfirmButton({ children, title, description, action, disabled, onConfirm, className }: { children: ReactNode; title: string; description: string; action: string; disabled?: boolean; onConfirm: () => void; className?: string }) {
return
- {label === "Reset" && }{label}
+ {children}
{title}?
{description}
Cancel
-
{label === "Reset" ? "Reset app data" : label}
+
{action}
;
@@ -235,3 +148,199 @@ function StorageEmpty({ title, description }: { title: string; description: stri
;
}
+
+const entryMatches = (entry: StorageEntry, query: string) => entry.key.toLowerCase().includes(query)
+ || (entry.type === "data" ? bytesAsText(entry) ?? "" : entry.value).toLowerCase().includes(query);
+
+function BrowseView({ panel, snapshot, query, copy }: { panel: StoragePanel; snapshot: StorageSnapshot; query: string; copy: (text: string) => void }) {
+ const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot);
+ const [selected, setSelected] = useState("");
+ const [open, setOpen] = useState
();
+ const [showSdks, setShowSdks] = useState(false);
+ const busy = state.busy !== null && state.busy !== "apps";
+ const title = (store: StorageStore) => storeTitle(store, snapshot.bundleId);
+ const own = snapshot.stores.filter(store => !isSdkStore(store)).sort((a, b) => storeKinds.indexOf(a.kind) - storeKinds.indexOf(b.kind));
+ const sdks = snapshot.stores.filter(isSdkStore);
+ const matches = useMemo(() => new Map(snapshot.stores.map(store => [store.id,
+ !query ? store.entries : store.name.toLowerCase().includes(query) ? store.entries : store.entries.filter(entry => entryMatches(entry, query))])), [snapshot, query]);
+ const store = snapshot.stores.find(item => item.id === selected) ?? (selected === FILES ? undefined : own[0] ?? sdks[0]);
+ const toggle = (store: StorageStore, key: string) => { const id = `${store.id}\u0000${key}`; setOpen(open === id ? undefined : id); };
+ const visible = (store: StorageStore) => !query || (matches.get(store.id)?.length ?? 0) > 0;
+ const select = (id: string) => { setSelected(id); setOpen(undefined); };
+
+ const sidebar =
+ Your app
+ {own.filter(visible).map(item => select(item.id)} />)}
+ {own.length === 0 && No storage written by the app yet.
}
+ {sdks.length > 0 && <>
+ setShowSdks(!showSdks)} className="mt-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-muted-foreground hover:bg-muted">
+ {showSdks || query ? : }SDKs & system {sdks.length}
+
+ {(showSdks || query) && sdks.filter(visible).map(item => select(item.id)} />)}
+ >}
+ select(FILES)} className="mt-3 w-full rounded-lg border p-2.5 text-left hover:bg-muted/50 data-[selected=true]:bg-muted">
+ Files {formatBytes(snapshot.totalBytes)}
+ {containerBreakdown(snapshot).map(([label, bytes, tone]) => )}
+
+ {containerBreakdown(snapshot).filter(([, bytes]) => bytes > 0).map(([label, bytes, tone]) => {label} {formatBytes(bytes)} )}
+
+
+ ;
+
+ let main: ReactNode;
+ if (query) {
+ const groups = [...own, ...sdks].map(item => ({ store: item, entries: matches.get(item.id) ?? [] })).filter(group => group.entries.length > 0);
+ const total = groups.reduce((sum, group) => sum + group.entries.length, 0);
+ main =
+
{total} match{total === 1 ? "" : "es"} in {groups.length} store{groups.length === 1 ? "" : "s"}
+ {groups.map(group =>
+
+ {title(group.store)} {storeKindLabels[group.store.kind]}
+ {group.entries.length} of {group.store.entryCount}
+
+ {group.entries.map(entry => toggle(group.store, entry.key)} copy={copy} />)}
+ )}
+ {groups.length === 0 &&
No keys or values match.
}
+
;
+ } else if (store === undefined) {
+ main =
+
+
+ Files {snapshot.fileCount} files · {formatBytes(snapshot.totalBytes)} in the app's data directory
+
+ {snapshot.files.map(file => {
+ const owner = file.store ? snapshot.stores.find(item => item.id === file.store) : undefined;
+ return
+ {file.path}
+ {owner && {storeKindLabels[owner.kind]} }
+ {formatBytes(file.bytes)}
+
;
+ })}
+ {snapshot.files.length < snapshot.fileCount &&
Showing the {snapshot.files.length} largest files.
}
+
;
+ } else {
+ const json = () => JSON.stringify(Object.fromEntries(store.entries.map(entry => [entry.key, parseJson(entry) ?? (entry.type === "boolean" ? entry.value === "true" : entry.type === "number" || entry.type === "integer" ? Number(entry.value) : entry.type === "data" ? bytesAsText(entry) ?? entry.value : entry.value)])), null, 2);
+ main =
+
+
+
+
{title(store)}
+
{storeKindLabels[store.kind]} · {store.entryCount} {store.kind === "sqlite" ? "table" : "key"}{store.entryCount === 1 ? "" : "s"} · {formatBytes(store.bytes)} · {store.path}
+
+ {store.status === "ok" && store.kind !== "sqlite" &&
copy(json())}> Copy as JSON }
+
void panel.clear(store.id)}
+ className="inline-flex h-6 items-center rounded-md px-2 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50">Clear
+
+ {store.note &&
{store.status === "encrypted" ? : store.status === "unreadable" ? : null}{store.note}
}
+
+ {store.entries.map(entry =>
toggle(store, entry.key)} copy={copy} />)}
+ {store.entries.length === 0 && store.status === "ok" && Empty.
}
+ {store.entries.length < store.entryCount && Showing the first {store.entries.length} of {store.entryCount}.
}
+
+
;
+ }
+ // Narrow panels swap the sidebar for a picker.
+ const picker = !query &&
+ select(event.target.value)}>
+ {[...own, ...sdks].map(item => {title(item)} · {storeKindLabels[item.kind]}{isSdkStore(item) ? " (SDK)" : ""} )}
+ Files · {formatBytes(snapshot.totalBytes)}
+
+
;
+ return ;
+}
+
+const changeLook = {
+ added: { label: "Added", text: "text-emerald-700 dark:text-emerald-400", dot: "bg-emerald-500" },
+ changed: { label: "Changed", text: "text-amber-700 dark:text-amber-400", dot: "bg-amber-500" },
+ removed: { label: "Removed", text: "text-rose-700 dark:text-rose-400", dot: "bg-rose-500" },
+};
+function Old({ children }: { children: ReactNode }) { return {children} ; }
+function New({ children }: { children: ReactNode }) { return {children} ; }
+const shown = (entry: StorageEntry) => entry.type === "data" ? bytesAsText(entry) ?? `${entry.bytes} bytes` : entry.value;
+
+function ChangeDiff({ event }: { event: StorageChangeEvent }) {
+ const { before, after } = event;
+ if (event.change === "added" && after) return {shown(after)} ;
+ if (event.change === "removed" && before) return {shown(before)} ;
+ if (!before || !after) return null;
+ const fields = changedFields(parseJson(before), parseJson(after));
+ const arrow = → ;
+ if (fields) return {fields.map(field =>
+ {field.path} {field.before !== undefined && {field.before} }{arrow}{field.after !== undefined ? {field.after} : removed }
+ )} ;
+ return {shown(before)} {arrow}{shown(after)} ;
+}
+
+function ChangesView({ panel, query }: { panel: StoragePanel; query: string }) {
+ const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot);
+ const own = state.changes.filter(event => !event.sdk);
+ const hidden = state.changes.length - own.length;
+ const visible = query ? own.filter(event => event.key.toLowerCase().includes(query) || event.store.name.toLowerCase().includes(query)
+ || [event.before, event.after].some(entry => entry && shown(entry).toLowerCase().includes(query))) : own;
+ return
+ {own.length === 0 ?
+ :
+ {visible.map(event => {
+ const look = changeLook[event.change];
+ return
+ {timeFormat.format(event.at)}
+ {look.label}
+ {event.key}
+
+
;
+ })}
+ {visible.length === 0 &&
No changes match.
}
+
}
+
+ {own.length} change{own.length === 1 ? "" : "s"}{state.watchingSince ? ` since ${timeFormat.format(state.watchingSince)}` : ""}{hidden > 0 ? ` · ${hidden} SDK change${hidden === 1 ? "" : "s"} hidden` : " · SDK data hidden"}
+ panel.clearChanges()}>Clear
+
+
;
+}
+
+export const StorageView = memo(function StorageView({ panel }: { panel: StoragePanel }) {
+ const state = useSyncExternalStore(panel.subscribe, panel.getSnapshot);
+ const [query, setQuery] = useState("");
+ const [copied, setCopied] = useState("");
+ const snapshot = state.snapshot;
+ const busy = state.busy !== null && state.busy !== "apps";
+ const search = query.trim().toLowerCase();
+ const ownChanges = state.changes.filter(event => !event.sdk).length;
+ const copy = (text: string) => { void navigator.clipboard.writeText(text).then(() => setCopied("Copied"), () => setCopied("Could not copy")); };
+ const tab = (value: StorageTab, label: string, count?: number) => panel.setTab(value)}
+ className="flex h-full items-center gap-1.5 rounded-md px-2.5 text-muted-foreground aria-selected:bg-muted aria-selected:font-medium aria-selected:text-foreground">{label}{count !== undefined && count > 0 && {count} } ;
+
+ let body: ReactNode;
+ if (state.unsupported || !state.available) body = ;
+ else if (!state.bundleId) body = ;
+ else if (!snapshot) body = state.error ? null : ;
+ else if (state.tab === "changes") body = ;
+ else body = ;
+
+ return
+
+ panel.selectApp(event.target.value)}>
+ {state.busy === "apps" ? "Loading apps…" : "Choose an app"}
+ {state.apps.map(app => {app.name === app.bundleId ? app.bundleId : `${app.name} · ${app.bundleId}`} )}
+
+
+ {tab("browse", "Browse")}{tab("changes", "Changes", ownChanges)}
+
+ setQuery(event.target.value)} />
+
+ void panel.refresh()}
+ className="flex h-full w-8 items-center justify-center hover:bg-muted disabled:opacity-50">
+ void panel.clear()} className="flex h-full items-center gap-1.5 px-2.5 hover:bg-muted disabled:opacity-50"> Reset
+
+
+ {copied}
+ {state.error && {state.error} }
+ {state.status &&
+
{state.status}
+
void panel.launch()}> Launch app
+
}
+ {body}
+ ;
+});
diff --git a/src/ui/storage-labels.ts b/src/ui/storage-labels.ts
new file mode 100644
index 00000000..ccbc82a0
--- /dev/null
+++ b/src/ui/storage-labels.ts
@@ -0,0 +1,56 @@
+import type { StorageEntry, StorageStore } from "../shared/app-storage.ts";
+
+// Stores written by analytics, crash, and attribution SDKs rather than the app's own code.
+const sdkStore = /^(?:APM|FIR|GUL|GDT|com\.(?:google|firebase|crashlytics|facebook|appsflyer|braze|segment|amplitude|mixpanel|onesignal|sentry|datadog|branch)|google[-_]|io\.branch|WebViewChromium|AppsFlyer|Branch|io\.sentry|com\.apple\.)/i;
+export const isSdkStore = (store: Pick) => sdkStore.test(store.name) || /\/(?:Google|com\.crashlytics|io\.sentry|com\.firebase)\//.test(store.path);
+
+// The app's own preferences domain and MMKV's default instance get plain names.
+export function storeTitle(store: Pick, bundleId: string) {
+ if (store.kind === "user-defaults" && store.name === bundleId) return "Standard defaults";
+ if (store.kind === "shared-preferences" && store.name === `${bundleId}_preferences`) return "Default preferences";
+ if (store.kind === "mmkv" && store.name === "mmkv.default") return "Default instance";
+ return store.name;
+}
+
+// Plist and MMKV byte values often hold UTF-8 text such as JSON; show that text when every byte is printable.
+export function bytesAsText(entry: StorageEntry): string | undefined {
+ if (entry.type !== "data" || entry.value.length === 0 || entry.value.length % 2 !== 0) return undefined;
+ const bytes = new Uint8Array(entry.value.length / 2);
+ for (let index = 0; index < bytes.length; index++) bytes[index] = Number.parseInt(entry.value.slice(index * 2, index * 2 + 2), 16);
+ try {
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ return /^[^\u0000-\u0008\u000b\u000c\u000e-\u001f]+$/.test(text) ? text : undefined;
+ } catch { return undefined; }
+}
+
+export function parseJson(entry: StorageEntry): unknown {
+ const text = entry.type === "data" ? bytesAsText(entry) : entry.value;
+ if (text === undefined || entry.truncated || !/^\s*[[{]/.test(text)) return undefined;
+ try { return JSON.parse(text); } catch { return undefined; }
+}
+
+const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
+export function relativeTime(iso: string, now = Date.now()) {
+ const time = Date.parse(iso);
+ if (!Number.isFinite(time)) return undefined;
+ const seconds = (time - now) / 1000;
+ const steps: [Intl.RelativeTimeFormatUnit, number][] = [["year", 31536000], ["month", 2592000], ["day", 86400], ["hour", 3600], ["minute", 60]];
+ for (const [unit, size] of steps) if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit);
+ return relative.format(Math.round(seconds), "second");
+}
+
+export function formatBytes(bytes: number) {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+// For JSON values, the fields that differ, so a change shows `count 30 → 31` instead of two whole objects.
+export function changedFields(before: unknown, after: unknown): { path: string; before?: string; after?: string }[] | undefined {
+ const object = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value);
+ if (!object(before) || !object(after)) return undefined;
+ const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])]
+ .filter(key => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
+ .map(key => ({ path: key, before: before[key] === undefined ? undefined : JSON.stringify(before[key]), after: after[key] === undefined ? undefined : JSON.stringify(after[key]) }));
+ return fields.length > 0 && fields.length <= 3 ? fields : undefined;
+}
diff --git a/src/ui/storage-panel.ts b/src/ui/storage-panel.ts
index 45a36296..c7f6c41f 100644
--- a/src/ui/storage-panel.ts
+++ b/src/ui/storage-panel.ts
@@ -2,14 +2,27 @@ import type { App } from "@modelcontextprotocol/ext-apps";
import { installedAppsSchema } from "../shared/app-control.ts";
import type { InstalledApp } from "../shared/app-control.ts";
import { storageClearSchema, storageLimits, storageSnapshotSchema } from "../shared/app-storage.ts";
-import type { StorageSnapshot } from "../shared/app-storage.ts";
+import type { StorageEntry, StorageSnapshot, StorageStore } from "../shared/app-storage.ts";
import type { SimulatorDevice } from "../shared/protocol.ts";
import { errorMessage } from "../shared/protocol.ts";
import type { DeviceAppsStore } from "./device-apps.ts";
+import { isSdkStore } from "./storage-labels.ts";
import { measureUiOperation, recordUiTiming, setUiGauge } from "./telemetry.ts";
-export type StorageChange = "new" | "changed";
export type StorageAction = "apps" | "read" | "clear" | "reset" | "launch";
+export type StorageTab = "browse" | "changes";
+export type StorageChangeKind = "added" | "changed" | "removed";
+// One key that differed between two reads, kept in the Changes feed.
+export type StorageChangeEvent = {
+ id: number;
+ at: number;
+ store: Pick;
+ sdk: boolean;
+ key: string;
+ change: StorageChangeKind;
+ before?: StorageEntry;
+ after?: StorageEntry;
+};
export type StorageState = {
open: boolean;
available: boolean;
@@ -21,8 +34,10 @@ export type StorageState = {
apps: InstalledApp[];
bundleId: string;
snapshot?: StorageSnapshot;
- // Keyed by store ID, then entry key, against the first read of this app since it was picked.
- changes: ReadonlyMap>;
+ tab: StorageTab;
+ // Newest first, from the first read of this app until the feed is cleared.
+ changes: readonly StorageChangeEvent[];
+ watchingSince?: number;
busy: StorageAction | null;
error: string;
status: string;
@@ -30,22 +45,38 @@ export type StorageState = {
// The panel shows long values in full up to this length; the tool truncates beyond it.
const panelValueLength = 4000;
+// While the Changes tab is visible, storage is read again on this interval.
+export const changesPollMs = 5000;
+const maxChanges = 500;
+
+const same = (a: StorageEntry, b: StorageEntry) => a.type === b.type && a.value === b.value;
-export function diffSnapshots(baseline: StorageSnapshot | undefined, next: StorageSnapshot) {
- const changes = new Map>();
- if (baseline === undefined) return changes;
- const before = new Map(baseline.stores.map(store => [store.id, new Map(store.entries.map(entry => [entry.key, `${entry.type}\u0000${entry.value}`]))]));
+// Keys added, changed, or removed between two reads. Stores that were capped skip removals,
+// since a key past the cap is not missing.
+export function diffSnapshots(previous: StorageSnapshot, next: StorageSnapshot): Omit[] {
+ const events: Omit[] = [];
+ const before = new Map(previous.stores.map(store => [store.id, store]));
+ const after = new Map(next.stores.map(store => [store.id, store]));
+ const event = (store: StorageStore, key: string, change: StorageChangeKind, old?: StorageEntry, value?: StorageEntry) =>
+ events.push({ at: next.readAt, store: { id: store.id, kind: store.kind, name: store.name, path: store.path }, sdk: isSdkStore(store), key, change, before: old, after: value });
for (const store of next.stores) {
- const previous = before.get(store.id);
- const marks = new Map();
+ const old = before.get(store.id);
+ if (store.status !== "ok" || (old && old.status !== "ok")) continue;
+ const oldEntries = new Map(old?.entries.map(entry => [entry.key, entry]));
for (const entry of store.entries) {
- const value = previous?.get(entry.key);
- if (value === undefined) marks.set(entry.key, "new");
- else if (value !== `${entry.type}\u0000${entry.value}`) marks.set(entry.key, "changed");
+ const previousEntry = oldEntries.get(entry.key);
+ if (previousEntry === undefined) event(store, entry.key, "added", undefined, entry);
+ else if (!same(previousEntry, entry)) event(store, entry.key, "changed", previousEntry, entry);
}
- if (marks.size > 0) changes.set(store.id, marks);
+ if (old === undefined || old.entries.length < old.entryCount || store.entries.length < store.entryCount) continue;
+ const keys = new Set(store.entries.map(entry => entry.key));
+ for (const entry of old.entries) if (!keys.has(entry.key)) event(store, entry.key, "removed", entry);
+ }
+ for (const store of previous.stores) {
+ if (after.has(store.id) || store.status !== "ok") continue;
+ for (const entry of store.entries) event(store, entry.key, "removed", entry);
}
- return changes;
+ return events;
}
export class StoragePanel {
@@ -54,10 +85,11 @@ export class StoragePanel {
private readonly unsubscribe: () => void;
private listeners = new Set<() => void>();
private revision = 0;
- private baseline?: StorageSnapshot;
+ private nextChange = 1;
+ private poll?: ReturnType;
private appsFor = "";
private selections = new Map();
- private state: StorageState = { open: false, available: false, platform: "ios", deviceId: "", deviceLabel: "", unsupported: "", apps: [], bundleId: "", changes: new Map(), busy: null, error: "", status: "" };
+ private state: StorageState = { open: false, available: false, platform: "ios", deviceId: "", deviceLabel: "", unsupported: "", apps: [], bundleId: "", tab: "browse", changes: [], busy: null, error: "", status: "" };
constructor(app: App, deviceApps: DeviceAppsStore) {
this.app = app;
@@ -94,18 +126,40 @@ export class StoragePanel {
return;
}
this.revision++;
- this.baseline = undefined;
this.appsFor = "";
- this.update({ platform, deviceId, deviceLabel: device?.name ?? "", unsupported, apps: [], bundleId: this.selections.get(`${platform}:${deviceId}`) ?? "", snapshot: undefined, changes: new Map(), busy: null, error: "", status: "" });
+ this.update({ platform, deviceId, deviceLabel: device?.name ?? "", unsupported, apps: [], bundleId: this.selections.get(`${platform}:${deviceId}`) ?? "", snapshot: undefined, changes: [], watchingSince: undefined, busy: null, error: "", status: "" });
if (this.state.open) void this.loadApps();
}
show() {
this.update({ open: true });
void this.loadApps();
+ this.schedule();
}
- hide() { this.update({ open: false }); }
+ hide() { this.update({ open: false }); this.schedule(); }
+
+ setTab(tab: StorageTab) {
+ if (tab === this.state.tab) return;
+ this.update({ tab });
+ this.schedule();
+ if (tab === "changes") void this.refresh();
+ }
+
+ clearChanges() { this.update({ changes: [], watchingSince: this.state.snapshot?.readAt }); }
+
+ // Only the visible Changes tab re-reads storage; Browse refreshes on request.
+ private schedule() {
+ const watching = this.state.open && this.state.tab === "changes";
+ if (watching && this.poll === undefined) {
+ this.poll = setInterval(() => {
+ if (document.visibilityState !== "hidden" && this.state.busy === null) void this.refresh();
+ }, changesPollMs);
+ } else if (!watching && this.poll !== undefined) {
+ clearInterval(this.poll);
+ this.poll = undefined;
+ }
+ }
private async call(name: string, args: Record) {
const result = await this.app.callServerTool({ name, arguments: { platform: this.state.platform, deviceId: this.state.deviceId, ...args } }, { timeout: 60000 });
@@ -137,9 +191,8 @@ export class StoragePanel {
selectApp(bundleId: string) {
if (bundleId !== this.state.bundleId) {
this.revision++;
- this.baseline = undefined;
this.selections.set(`${this.state.platform}:${this.state.deviceId}`, bundleId);
- this.update({ bundleId, snapshot: undefined, changes: new Map(), busy: null, error: "", status: "" });
+ this.update({ bundleId, snapshot: undefined, changes: [], watchingSince: undefined, busy: null, error: "", status: "" });
}
if (bundleId) void this.refresh();
}
@@ -154,18 +207,19 @@ export class StoragePanel {
if (!current()) return;
const startedAt = performance.now();
const snapshot = storageSnapshotSchema.parse(data);
- const changes = diffSnapshots(this.baseline, snapshot);
- this.baseline ??= snapshot;
+ const previous = this.state.snapshot;
+ const found = previous ? diffSnapshots(previous, snapshot).map(event => ({ ...event, id: this.nextChange++ })) : [];
+ const changes = found.length > 0 ? [...found.reverse(), ...this.state.changes].slice(0, maxChanges) : this.state.changes;
recordUiTiming("ui.storage.process", performance.now() - startedAt);
setUiGauge("ui.storage.stores", snapshot.stores.length);
setUiGauge("ui.storage.entries", snapshot.stores.reduce((total, store) => total + store.entries.length, 0));
- this.update({ snapshot, changes, busy: null });
+ this.update({ snapshot, changes, watchingSince: this.state.watchingSince ?? snapshot.readAt, busy: null });
} catch (error) {
if (current()) this.update({ busy: null, error: errorMessage(error) });
}
}
- // Clearing stops the app; the panel then reads again and starts change tracking over.
+ // Clearing stops the app; the next read records the cleared keys as removed.
async clear(storeId?: string) {
const bundleId = this.state.bundleId;
if (!this.ready() || !bundleId || this.state.busy) return;
@@ -175,7 +229,6 @@ export class StoragePanel {
try {
const result = storageClearSchema.parse(await measureUiOperation(`ui.storage.${action}`, "ui.storage.failure", () => this.call("mobile_clear_app_storage", storeId ? { bundleId, store: storeId } : { bundleId })));
if (!current()) return;
- this.baseline = undefined;
this.update({ busy: null, status: result.cleared === "all" ? "Cleared all app data. The app was stopped." : `Cleared ${result.cleared}. The app was stopped.` });
await this.refresh();
} catch (error) {
@@ -198,6 +251,8 @@ export class StoragePanel {
dispose() {
this.revision++;
+ if (this.poll !== undefined) clearInterval(this.poll);
+ this.poll = undefined;
this.unsubscribe();
this.listeners.clear();
}
diff --git a/tests/app-storage.test.ts b/tests/app-storage.test.ts
index bc83d0e6..a880f1ee 100644
--- a/tests/app-storage.test.ts
+++ b/tests/app-storage.test.ts
@@ -210,14 +210,25 @@ test("storage tools stop the app before clearing and reset everything when no st
await client.close();
});
-test("change markers compare each read with the first read of the app", async () => {
+test("the change feed records keys added, changed, and removed between reads, and labels SDK stores", async () => {
const { diffSnapshots } = await import("../src/ui/storage-panel.ts");
- const snapshot = (entries: [string, string][]) => storageSnapshotSchema.parse({ platform: "ios", bundleId: "com.example", readAt: 0, files: [], fileCount: 0, totalBytes: 0,
- stores: [{ id: "mmkv:a", kind: "mmkv", name: "a", path: "a", status: "ok", bytes: 0, entryCount: entries.length, entries: entries.map(([key, value]) => ({ key, type: "string", value, bytes: value.length })) }] });
- const first = snapshot([["kept", "1"], ["edited", "1"]]);
- assert.equal(diffSnapshots(undefined, first).size, 0);
- const marks = diffSnapshots(first, snapshot([["kept", "1"], ["edited", "2"], ["added", "x"]]));
- assert.deepEqual([...marks.get("mmkv:a")!], [["edited", "changed"], ["added", "new"]]);
+ const { changedFields, bytesAsText, isSdkStore, storeTitle } = await import("../src/ui/storage-labels.ts");
+ const snapshot = (readAt: number, entries: [string, string][], entryCount = entries.length) => storageSnapshotSchema.parse({ platform: "ios", bundleId: "com.example", readAt, files: [], fileCount: 0, totalBytes: 0,
+ stores: [{ id: "mmkv:a", kind: "mmkv", name: "a", path: "a", status: "ok", bytes: 0, entryCount, entries: entries.map(([key, value]) => ({ key, type: "string", value, bytes: value.length })) }] });
+ const first = snapshot(1, [["kept", "1"], ["edited", "1"], ["dropped", "x"]]);
+ const events = diffSnapshots(first, snapshot(2, [["kept", "1"], ["edited", "2"], ["added", "x"]]));
+ assert.deepEqual(events.map(event => [event.key, event.change, event.before?.value, event.after?.value, event.at]), [
+ ["edited", "changed", "1", "2", 2], ["added", "added", undefined, "x", 2], ["dropped", "removed", "x", undefined, 2],
+ ]);
+ // A capped read is not evidence that keys past the cap were removed.
+ assert.deepEqual(diffSnapshots(first, snapshot(3, [["kept", "1"]], 3)).map(event => event.change), []);
+ assert.deepEqual(changedFields({ user: "ada", count: 1 }, { user: "ada", count: 2 }), [{ path: "count", before: "1", after: "2" }]);
+ assert.equal(changedFields("a", "b"), undefined);
+ assert.equal(bytesAsText({ key: "k", type: "data", value: Buffer.from("[1,2]").toString("hex"), bytes: 5 }), "[1,2]");
+ assert.equal(bytesAsText({ key: "k", type: "data", value: "ff00", bytes: 2 }), undefined);
+ assert.equal(isSdkStore({ name: "com.google.gmp.measurement.monitor", path: "Library/Preferences/com.google.gmp.measurement.monitor.plist" }), true);
+ assert.equal(isSdkStore({ name: "com.example", path: "Library/Preferences/com.example.plist" }), false);
+ assert.equal(storeTitle({ kind: "user-defaults", name: "com.example" }, "com.example"), "Standard defaults");
});
test("simulator resets empty the container but keep its layout, metadata, and cfprefsd in sync", async () => {
diff --git a/tests/react-ui.test.ts b/tests/react-ui.test.ts
index 9cd4bb99..24be45a4 100644
--- a/tests/react-ui.test.ts
+++ b/tests/react-ui.test.ts
@@ -517,11 +517,22 @@ test("React log controls filter virtual rows, attach full logs, and preserve sim
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
assert.deepEqual(storageCalls, ["mobile_list_apps", "mobile_app_storage"]);
const storageDrawer = dom.window.document.getElementById("storage-drawer")!;
- assert.deepEqual([...storageDrawer.querySelectorAll('nav[aria-label="Stores"] button')].map(button => button.textContent), ["mmkv.default2", "secure—", "Files300 B"]);
- const valueRow = [...storageDrawer.querySelectorAll('[role="row"]')].find(row => row.textContent?.startsWith("user")) as HTMLElement;
+ const storeNames = [...storageDrawer.querySelectorAll('nav[aria-label="Stores"] button')].map(button => button.textContent);
+ assert.deepEqual(storeNames.slice(0, 2), ["Default instanceMMKV2", "secureMMKV"]);
+ assert.match(storeNames[2] ?? "", /^Files300 B/);
+ const valueRow = [...storageDrawer.querySelectorAll('[role="row"] > button')].find(row => row.textContent?.startsWith("user")) as HTMLElement;
await act(async () => { valueRow.click(); });
- assert.equal(storageDrawer.querySelector('[aria-label="Value"] pre')?.textContent, '{\n "id": 1\n}');
- assert.match(storageDrawer.textContent ?? "", /Type guessed from the stored bytes/);
+ assert.equal(storageDrawer.querySelector('[aria-label="Value"]')?.textContent?.startsWith("id: 1"), true);
+ assert.match(storageDrawer.textContent ?? "", /guessed; MMKV does not record types/);
+ const search = storageDrawer.querySelector('input[type="search"]') as HTMLInputElement;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(search, "onboarding");
+ search.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
+ });
+ assert.equal(storageDrawer.querySelector('[aria-label="Search results"] [role="status"]')?.textContent, "1 match in 1 store");
+ await act(async () => { (storageDrawer.querySelector('[role="tab"]:nth-child(2)') as HTMLElement).click(); });
+ assert.match(storageDrawer.textContent ?? "", /No changes yet/);
+ await act(async () => { (storageDrawer.querySelector('[role="tab"]:nth-child(1)') as HTMLElement).click(); });
assert.equal(dom.window.document.querySelector("canvas"), canvas, "The storage tool preserves the device DOM.");
await selectTool("logs");
assert.equal(dom.window.document.getElementById("storage-drawer"), null);
From 043513804138fcfee6ceefc134430628bea1d388 Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 14:53:35 +0200
Subject: [PATCH 05/63] chore(release): bump version to 0.1.173
---
.codex-plugin/plugin.json | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
src/shared/version.ts | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index 4bfb3598..b8451b1d 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "mobile-dev",
- "version": "0.1.172",
+ "version": "0.1.173",
"description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, analyze CPU and React profiles with TraceSift, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.",
"author": {
"name": "Callstack"
diff --git a/package-lock.json b/package-lock.json
index 173b3fa6..dfcc76a8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.172",
+ "version": "0.1.173",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-mobile-dev-plugin",
- "version": "0.1.172",
+ "version": "0.1.173",
"dependencies": {
"@base-ui/react": "^1.8.0",
"@fontsource-variable/inter": "^5.3.0",
diff --git a/package.json b/package.json
index 3d36c334..3734f3fa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.172",
+ "version": "0.1.173",
"private": true,
"type": "module",
"engines": {
diff --git a/src/shared/version.ts b/src/shared/version.ts
index 3bbbe5cc..9f0fa2ee 100644
--- a/src/shared/version.ts
+++ b/src/shared/version.ts
@@ -1 +1 @@
-export const PLUGIN_VERSION = "0.1.172";
+export const PLUGIN_VERSION = "0.1.173";
From d2c4a13bdd518e41d032d29eca7810f03db1ed59 Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 14:54:49 +0200
Subject: [PATCH 06/63] feat(storage): mark changes with plus, tilde, and minus
icons
---
src/ui/components/storage-view.tsx | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/src/ui/components/storage-view.tsx b/src/ui/components/storage-view.tsx
index c21f7776..5c0abfa6 100644
--- a/src/ui/components/storage-view.tsx
+++ b/src/ui/components/storage-view.tsx
@@ -1,4 +1,4 @@
-import { BinaryIcon, BracesIcon, CalendarIcon, ChevronDownIcon, ChevronRightIcon, CopyIcon, DatabaseIcon, FolderIcon, HashIcon, LayersIcon, LockIcon, MinusIcon, PlayIcon, PuzzleIcon, RefreshCwIcon, RotateCcwIcon, SearchIcon, ToggleLeftIcon, TriangleAlertIcon, TypeIcon } from "lucide-react";
+import { BinaryIcon, BracesIcon, CalendarIcon, ChevronDownIcon, ChevronRightIcon, CopyIcon, DatabaseIcon, FolderIcon, HashIcon, LayersIcon, LockIcon, MinusIcon, PlayIcon, PlusIcon, PuzzleIcon, RefreshCwIcon, RotateCcwIcon, SearchIcon, ToggleLeftIcon, TriangleAlertIcon, TypeIcon } from "lucide-react";
import { memo, useMemo, useState, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import { storeKindLabels, storeKinds } from "../../shared/app-storage.ts";
@@ -249,10 +249,14 @@ function BrowseView({ panel, snapshot, query, copy }: { panel: StoragePanel; sna
return ;
}
+// lucide has no tilde; this matches its 24px grid and 2px round stroke.
+function TildeIcon({ className }: { className?: string }) {
+ return ;
+}
const changeLook = {
- added: { label: "Added", text: "text-emerald-700 dark:text-emerald-400", dot: "bg-emerald-500" },
- changed: { label: "Changed", text: "text-amber-700 dark:text-amber-400", dot: "bg-amber-500" },
- removed: { label: "Removed", text: "text-rose-700 dark:text-rose-400", dot: "bg-rose-500" },
+ added: { label: "Added", icon: PlusIcon, tone: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400" },
+ changed: { label: "Changed", icon: TildeIcon, tone: "text-amber-600 bg-amber-500/10 dark:text-amber-400" },
+ removed: { label: "Removed", icon: MinusIcon, tone: "text-rose-600 bg-rose-500/10 dark:text-rose-400" },
};
function Old({ children }: { children: ReactNode }) { return {children} ; }
function New({ children }: { children: ReactNode }) { return {children} ; }
@@ -282,9 +286,9 @@ function ChangesView({ panel, query }: { panel: StoragePanel; query: string }) {
:
{visible.map(event => {
const look = changeLook[event.change];
- return
+ return
{timeFormat.format(event.at)}
- {look.label}
+
{event.key}
;
From 9bf71e06aef6c496c645ef72fe9d181b26acf5ab Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 14:54:49 +0200
Subject: [PATCH 07/63] chore(release): bump version to 0.1.174
---
.codex-plugin/plugin.json | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
src/shared/version.ts | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index b8451b1d..e01ba3ea 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "mobile-dev",
- "version": "0.1.173",
+ "version": "0.1.174",
"description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, analyze CPU and React profiles with TraceSift, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.",
"author": {
"name": "Callstack"
diff --git a/package-lock.json b/package-lock.json
index dfcc76a8..3bcb867a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.173",
+ "version": "0.1.174",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-mobile-dev-plugin",
- "version": "0.1.173",
+ "version": "0.1.174",
"dependencies": {
"@base-ui/react": "^1.8.0",
"@fontsource-variable/inter": "^5.3.0",
diff --git a/package.json b/package.json
index 3734f3fa..e241507b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.173",
+ "version": "0.1.174",
"private": true,
"type": "module",
"engines": {
diff --git a/src/shared/version.ts b/src/shared/version.ts
index 9f0fa2ee..d70db2b9 100644
--- a/src/shared/version.ts
+++ b/src/shared/version.ts
@@ -1 +1 @@
-export const PLUGIN_VERSION = "0.1.173";
+export const PLUGIN_VERSION = "0.1.174";
From 25a0f67071c8da053415ee539dd1c0966e793e12 Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 16:10:11 +0200
Subject: [PATCH 08/63] feat(storage): lead change rows with a plain change
icon
---
src/ui/components/storage-view.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/ui/components/storage-view.tsx b/src/ui/components/storage-view.tsx
index 5c0abfa6..44ee4f70 100644
--- a/src/ui/components/storage-view.tsx
+++ b/src/ui/components/storage-view.tsx
@@ -254,9 +254,9 @@ function TildeIcon({ className }: { className?: string }) {
return ;
}
const changeLook = {
- added: { label: "Added", icon: PlusIcon, tone: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400" },
- changed: { label: "Changed", icon: TildeIcon, tone: "text-amber-600 bg-amber-500/10 dark:text-amber-400" },
- removed: { label: "Removed", icon: MinusIcon, tone: "text-rose-600 bg-rose-500/10 dark:text-rose-400" },
+ added: { label: "Added", icon: PlusIcon, tone: "text-emerald-600 dark:text-emerald-400" },
+ changed: { label: "Changed", icon: TildeIcon, tone: "text-amber-600 dark:text-amber-400" },
+ removed: { label: "Removed", icon: MinusIcon, tone: "text-rose-600 dark:text-rose-400" },
};
function Old({ children }: { children: ReactNode }) { return {children} ; }
function New({ children }: { children: ReactNode }) { return {children} ; }
@@ -286,9 +286,9 @@ function ChangesView({ panel, query }: { panel: StoragePanel; query: string }) {
:
{visible.map(event => {
const look = changeLook[event.change];
- return
+ return
+
{timeFormat.format(event.at)}
-
{event.key}
;
From deef96aee5b0ce7a593d5d805f92a1c2d6eed3ca Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 16:10:12 +0200
Subject: [PATCH 09/63] chore(release): bump version to 0.1.175
---
.codex-plugin/plugin.json | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
src/shared/version.ts | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index e01ba3ea..c69a3329 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "mobile-dev",
- "version": "0.1.174",
+ "version": "0.1.175",
"description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, analyze CPU and React profiles with TraceSift, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.",
"author": {
"name": "Callstack"
diff --git a/package-lock.json b/package-lock.json
index 3bcb867a..f956ac48 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.174",
+ "version": "0.1.175",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-mobile-dev-plugin",
- "version": "0.1.174",
+ "version": "0.1.175",
"dependencies": {
"@base-ui/react": "^1.8.0",
"@fontsource-variable/inter": "^5.3.0",
diff --git a/package.json b/package.json
index e241507b..7f0aadc3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.174",
+ "version": "0.1.175",
"private": true,
"type": "module",
"engines": {
diff --git a/src/shared/version.ts b/src/shared/version.ts
index d70db2b9..877151a3 100644
--- a/src/shared/version.ts
+++ b/src/shared/version.ts
@@ -1 +1 @@
-export const PLUGIN_VERSION = "0.1.174";
+export const PLUGIN_VERSION = "0.1.175";
From ed0ddd379a1040dab4520b7820dada05160cebfb Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 16:13:26 +0200
Subject: [PATCH 10/63] feat(storage): mark changed keys with swap arrows
---
src/ui/components/storage-view.tsx | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/src/ui/components/storage-view.tsx b/src/ui/components/storage-view.tsx
index 44ee4f70..886c8370 100644
--- a/src/ui/components/storage-view.tsx
+++ b/src/ui/components/storage-view.tsx
@@ -1,4 +1,4 @@
-import { BinaryIcon, BracesIcon, CalendarIcon, ChevronDownIcon, ChevronRightIcon, CopyIcon, DatabaseIcon, FolderIcon, HashIcon, LayersIcon, LockIcon, MinusIcon, PlayIcon, PlusIcon, PuzzleIcon, RefreshCwIcon, RotateCcwIcon, SearchIcon, ToggleLeftIcon, TriangleAlertIcon, TypeIcon } from "lucide-react";
+import { ArrowRightLeftIcon, BinaryIcon, BracesIcon, CalendarIcon, ChevronDownIcon, ChevronRightIcon, CopyIcon, DatabaseIcon, FolderIcon, HashIcon, LayersIcon, LockIcon, MinusIcon, PlayIcon, PlusIcon, PuzzleIcon, RefreshCwIcon, RotateCcwIcon, SearchIcon, ToggleLeftIcon, TriangleAlertIcon, TypeIcon } from "lucide-react";
import { memo, useMemo, useState, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import { storeKindLabels, storeKinds } from "../../shared/app-storage.ts";
@@ -249,13 +249,9 @@ function BrowseView({ panel, snapshot, query, copy }: { panel: StoragePanel; sna
return ;
}
-// lucide has no tilde; this matches its 24px grid and 2px round stroke.
-function TildeIcon({ className }: { className?: string }) {
- return ;
-}
const changeLook = {
added: { label: "Added", icon: PlusIcon, tone: "text-emerald-600 dark:text-emerald-400" },
- changed: { label: "Changed", icon: TildeIcon, tone: "text-amber-600 dark:text-amber-400" },
+ changed: { label: "Changed", icon: ArrowRightLeftIcon, tone: "text-amber-600 dark:text-amber-400" },
removed: { label: "Removed", icon: MinusIcon, tone: "text-rose-600 dark:text-rose-400" },
};
function Old({ children }: { children: ReactNode }) { return {children} ; }
From f6606033b09672f3c92bfcd300ddd5a6c871aebf Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 16:13:26 +0200
Subject: [PATCH 11/63] chore(release): bump version to 0.1.176
---
.codex-plugin/plugin.json | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
src/shared/version.ts | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index c69a3329..bad1a54a 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "mobile-dev",
- "version": "0.1.175",
+ "version": "0.1.176",
"description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, analyze CPU and React profiles with TraceSift, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.",
"author": {
"name": "Callstack"
diff --git a/package-lock.json b/package-lock.json
index f956ac48..e2de4242 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.175",
+ "version": "0.1.176",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codex-mobile-dev-plugin",
- "version": "0.1.175",
+ "version": "0.1.176",
"dependencies": {
"@base-ui/react": "^1.8.0",
"@fontsource-variable/inter": "^5.3.0",
diff --git a/package.json b/package.json
index 7f0aadc3..f7041441 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codex-mobile-dev-plugin",
- "version": "0.1.175",
+ "version": "0.1.176",
"private": true,
"type": "module",
"engines": {
diff --git a/src/shared/version.ts b/src/shared/version.ts
index 877151a3..89e0920b 100644
--- a/src/shared/version.ts
+++ b/src/shared/version.ts
@@ -1 +1 @@
-export const PLUGIN_VERSION = "0.1.175";
+export const PLUGIN_VERSION = "0.1.176";
From 6a787f223b7a06ada55630e51286522a7942a8b8 Mon Sep 17 00:00:00 2001
From: Laurids Kern
Date: Tue, 20 Oct 2026 16:30:11 +0200
Subject: [PATCH 12/63] test(storage): add an Expo app that writes to every
store
Buttons write realistic data to MMKV (default, custom ID, encrypted),
AsyncStorage, UserDefaults or SharedPreferences, SQLite, and files, and the
app shows what it reads back. run.sh builds a standalone release app; the
Android release build is debuggable so the storage tool can read it.
---
examples/storage-test-app/.gitignore | 46 +
examples/storage-test-app/AGENTS.md | 41 +
examples/storage-test-app/App.tsx | 87 +
examples/storage-test-app/README.md | 50 +
examples/storage-test-app/app.json | 28 +
.../assets/android-icon-background.png | Bin 0 -> 17549 bytes
.../assets/android-icon-foreground.png | Bin 0 -> 78796 bytes
.../assets/android-icon-monochrome.png | Bin 0 -> 4140 bytes
examples/storage-test-app/assets/icon.png | Bin 0 -> 393493 bytes
.../storage-test-app/assets/splash-icon.png | Bin 0 -> 17547 bytes
examples/storage-test-app/index.ts | 8 +
examples/storage-test-app/package-lock.json | 6248 +++++++++++++++++
examples/storage-test-app/package.json | 29 +
.../plugins/debuggable-release.js | 12 +
examples/storage-test-app/run.sh | 29 +
examples/storage-test-app/src/storage.ts | 116 +
examples/storage-test-app/tsconfig.json | 6 +
17 files changed, 6700 insertions(+)
create mode 100644 examples/storage-test-app/.gitignore
create mode 100644 examples/storage-test-app/AGENTS.md
create mode 100644 examples/storage-test-app/App.tsx
create mode 100644 examples/storage-test-app/README.md
create mode 100644 examples/storage-test-app/app.json
create mode 100644 examples/storage-test-app/assets/android-icon-background.png
create mode 100644 examples/storage-test-app/assets/android-icon-foreground.png
create mode 100644 examples/storage-test-app/assets/android-icon-monochrome.png
create mode 100644 examples/storage-test-app/assets/icon.png
create mode 100644 examples/storage-test-app/assets/splash-icon.png
create mode 100644 examples/storage-test-app/index.ts
create mode 100644 examples/storage-test-app/package-lock.json
create mode 100644 examples/storage-test-app/package.json
create mode 100644 examples/storage-test-app/plugins/debuggable-release.js
create mode 100755 examples/storage-test-app/run.sh
create mode 100644 examples/storage-test-app/src/storage.ts
create mode 100644 examples/storage-test-app/tsconfig.json
diff --git a/examples/storage-test-app/.gitignore b/examples/storage-test-app/.gitignore
new file mode 100644
index 00000000..19837156
--- /dev/null
+++ b/examples/storage-test-app/.gitignore
@@ -0,0 +1,46 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+# Native
+.kotlin/
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+# generated native folders
+/ios
+/android
+
+# Generated native projects (npx expo prebuild)
+/ios
+/android
+/.build
diff --git a/examples/storage-test-app/AGENTS.md b/examples/storage-test-app/AGENTS.md
new file mode 100644
index 00000000..be05e778
--- /dev/null
+++ b/examples/storage-test-app/AGENTS.md
@@ -0,0 +1,41 @@
+This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
+
+## Expo has changed — do not trust your training data
+
+Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API:
+
+1. Read the major version of the `expo` package in `package.json`.
+2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v.0.0/`
+3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory.
+
+## Commands
+
+Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present).
+
+```bash
+npx expo install # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions
+npx expo start # start the dev server
+npx expo lint # lint
+npx tsc --noEmit # typecheck
+npx expo-doctor # diagnose dependency and config issues
+npx expo install --fix # fix incompatible package versions
+```
+
+Run lint and typecheck before declaring any task done.
+
+## Navigation & Routing
+
+- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`.
+- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`.
+- Docs: https://docs.expo.dev/router/introduction.md
+
+## Building with EAS
+
+Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli ` in Bun projects, or `npx eas-cli@latest ` otherwise; substitute that for bare `eas` in docs examples.
+Docs: https://docs.expo.dev/eas/index.md
+
+## Rules
+
+- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins.
+- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`.
+- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md
diff --git a/examples/storage-test-app/App.tsx b/examples/storage-test-app/App.tsx
new file mode 100644
index 00000000..d0c3bb31
--- /dev/null
+++ b/examples/storage-test-app/App.tsx
@@ -0,0 +1,87 @@
+import { StatusBar } from "expo-status-bar";
+import { useCallback, useEffect, useState } from "react";
+import { AppState, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
+import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
+import * as storage from "./src/storage";
+
+type Action = { title: string; detail: string; run: () => unknown; destructive?: boolean };
+
+const actions: Action[] = [
+ { title: "Sign in", detail: "MMKV user + session, encrypted token, AsyncStorage user, lastLogin preference", run: storage.signIn },
+ { title: "Sign out", detail: "Removes the sign-in keys", run: storage.signOut },
+ { title: "Finish onboarding", detail: "Preference flag, MMKV step, typed iOS UserDefaults", run: storage.finishOnboarding },
+ { title: "Reset onboarding", detail: "Removes or flips the onboarding keys", run: storage.resetOnboarding },
+ { title: "Toggle theme", detail: "MMKV instance \"settings\" and a theme preference", run: storage.toggleTheme },
+ { title: "Add todo", detail: "SQLite row in todos.db and an AsyncStorage draft", run: storage.addTodo },
+ { title: "Save bytes", detail: "Binary MMKV value (shown as bytes)", run: storage.saveBytes },
+ { title: "Write files", detail: "Documents/notes.txt and a 512 KB cache file", run: storage.writeFiles },
+ { title: "Clear everything in the app", detail: "Wipes all stores from inside the app", run: storage.clearAll, destructive: true },
+];
+
+export default function App() {
+ const [readout, setReadout] = useState([]);
+ const [error, setError] = useState("");
+ const refresh = useCallback(() => { storage.readAll().then(setReadout, (reason: unknown) => setError(String(reason))); }, []);
+
+ useEffect(() => {
+ storage.recordLaunch().then(refresh, (reason: unknown) => setError(String(reason)));
+ // Re-read when the app returns to the foreground, for example after the tool cleared a store.
+ const subscription = AppState.addEventListener("change", state => { if (state === "active") refresh(); });
+ return () => subscription.remove();
+ }, [refresh]);
+
+ const run = async (action: Action) => {
+ setError("");
+ try { await action.run(); }
+ catch (reason) { setError(`${action.title} failed: ${String(reason)}`); }
+ refresh();
+ };
+
+ return
+
+
+ Storage Test
+ Each button writes to real stores. Open Mobile Dev → Tools → Storage to watch them, ideally on the Changes tab.
+
+ Actions
+ {actions.map(action => void run(action)}
+ style={({ pressed }) => [styles.action, pressed && styles.pressed]}>
+ {action.title}
+ {action.detail}
+ )}
+
+ {error ? {error} : null}
+
+
+ What the app reads
+ Refresh
+
+
+ {readout.map(row =>
+ {row.label}
+ {row.value}
+ )}
+
+
+ ;
+}
+
+const styles = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: "#f6f6f7" },
+ content: { padding: 20, paddingBottom: 48 },
+ title: { fontSize: 28, fontWeight: "700", marginTop: 8 },
+ subtitle: { fontSize: 14, color: "#6b6b70", marginTop: 6, lineHeight: 20 },
+ section: { fontSize: 13, fontWeight: "600", color: "#6b6b70", textTransform: "uppercase", marginTop: 24, marginBottom: 8 },
+ sectionRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end" },
+ link: { color: "#2563eb", fontSize: 14, marginBottom: 8 },
+ action: { backgroundColor: "white", borderRadius: 12, paddingVertical: 12, paddingHorizontal: 14, marginBottom: 8, borderWidth: StyleSheet.hairlineWidth, borderColor: "#d4d4d8" },
+ pressed: { opacity: 0.6 },
+ actionTitle: { fontSize: 16, fontWeight: "600" },
+ destructive: { color: "#dc2626" },
+ actionDetail: { fontSize: 13, color: "#6b6b70", marginTop: 2 },
+ error: { color: "#dc2626", marginTop: 8 },
+ card: { backgroundColor: "white", borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, borderColor: "#d4d4d8" },
+ row: { flexDirection: "row", justifyContent: "space-between", gap: 12, paddingVertical: 10, paddingHorizontal: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: "#e4e4e7" },
+ rowLabel: { fontSize: 14, color: "#3f3f46", flexShrink: 0 },
+ rowValue: { fontSize: 14, fontWeight: "500", flex: 1, textAlign: "right" },
+});
diff --git a/examples/storage-test-app/README.md b/examples/storage-test-app/README.md
new file mode 100644
index 00000000..4c625875
--- /dev/null
+++ b/examples/storage-test-app/README.md
@@ -0,0 +1,50 @@
+# Storage test app
+
+A small Expo app for testing the plugin's **Storage** tool. Each button writes
+realistic data to every store the tool reads, and the app shows what it reads
+back, so you can confirm that the tool's Clear and Reset reached the app.
+
+| Store | Written by |
+| --- | --- |
+| MMKV `mmkv.default` | Sign in, Finish onboarding, Save bytes, launch counter |
+| MMKV `settings` (custom ID) | Toggle theme |
+| MMKV `secure` (encrypted) | Sign in; the tool should show it as locked |
+| AsyncStorage | Sign in, Add todo, launch stats |
+| UserDefaults (iOS) / SharedPreferences (Android) | Sign in, Finish onboarding, Toggle theme |
+| Typed UserDefaults (iOS only) | Finish onboarding |
+| SQLite `todos.db` | Add todo |
+| Files | Write files: `notes.txt` and a 512 KB cache file |
+
+- Bundle ID / package: `dev.mobile.storagetest`
+- Display name: **Storage Test**
+
+## Run
+
+Boot a simulator or emulator in the Mobile Dev panel, then from the repository root:
+
+```sh
+./examples/storage-test-app/run.sh ios
+./examples/storage-test-app/run.sh android # e.g. emulator-5554
+```
+
+The script installs npm dependencies on first run, builds a Release app with the
+JavaScript bundle embedded, installs it and launches it. Metro is not needed. The
+first build takes several minutes. Native projects are generated into the ignored
+`ios/` and `android/` folders; `npx expo prebuild --clean` regenerates them.
+
+Android release builds are normally not debuggable, and Android only lets the tool
+read app data of debuggable builds. `plugins/debuggable-release.js` marks this app's
+release build debuggable so it can be read without Metro.
+
+## Things to try
+
+1. Open **Tools → Storage**, pick **Storage Test**, and switch to **Changes**.
+2. Tap **Sign in**, **Finish onboarding**, and **Add todo** in the app. Within about
+ five seconds each change appears, with only the changed part of JSON values highlighted.
+3. On **Browse**, check that `secure` shows as locked, `avatar.header` shows as bytes,
+ and `todos.db` lists the `todos` table.
+4. Search for `ada` to find the user across MMKV and AsyncStorage at once.
+5. **Clear** one store or **Reset** all app data, relaunch the app, and check that
+ **What the app reads** shows the cleared values.
+
+This app is a local test fixture and is not part of the plugin package.
diff --git a/examples/storage-test-app/app.json b/examples/storage-test-app/app.json
new file mode 100644
index 00000000..6b841f3f
--- /dev/null
+++ b/examples/storage-test-app/app.json
@@ -0,0 +1,28 @@
+{
+ "expo": {
+ "name": "Storage Test",
+ "slug": "storage-test-app",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "light",
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "dev.mobile.storagetest"
+ },
+ "android": {
+ "adaptiveIcon": {
+ "backgroundColor": "#E6F4FE",
+ "foregroundImage": "./assets/android-icon-foreground.png",
+ "backgroundImage": "./assets/android-icon-background.png",
+ "monochromeImage": "./assets/android-icon-monochrome.png"
+ },
+ "predictiveBackGestureEnabled": false,
+ "package": "dev.mobile.storagetest"
+ },
+ "plugins": [
+ "expo-sqlite",
+ "./plugins/debuggable-release"
+ ]
+ }
+}
diff --git a/examples/storage-test-app/assets/android-icon-background.png b/examples/storage-test-app/assets/android-icon-background.png
new file mode 100644
index 0000000000000000000000000000000000000000..5ffefc5bb57a3d7b39ec6ff4e96979226522cc49
GIT binary patch
literal 17549
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